Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 136db137ec | |||
| 1fabf66f04 | |||
| 9b97a7c23b | |||
| 9b0a6d8f15 | |||
| 5af5202455 | |||
| 6b6676ceec | |||
| de640de4a1 | |||
| 20955e277c | |||
| 1ed08d1e34 | |||
| a733c97991 | |||
| 31613e4613 |
@@ -80,6 +80,7 @@ app.use(async (req, res, next) => {
|
|||||||
|
|
||||||
app.use('/api/auth', authRoutes)
|
app.use('/api/auth', authRoutes)
|
||||||
app.use('/api/games', gamesRoutes)
|
app.use('/api/games', gamesRoutes)
|
||||||
|
app.use('/api/topics', gamesRoutes)
|
||||||
app.use('/api/tierlists', tierListsRoutes)
|
app.use('/api/tierlists', tierListsRoutes)
|
||||||
app.use('/api/admin', adminRoutes)
|
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_PASSWORD = process.env.DB_PASSWORD || ''
|
||||||
const DB_NAME = process.env.DB_NAME || 'tier_cursor'
|
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 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 poolPromise = null
|
||||||
let initPromise = null
|
let initPromise = null
|
||||||
@@ -72,6 +73,8 @@ function mapGameRow(row) {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
topicId: row.id,
|
||||||
|
topicName: row.name,
|
||||||
thumbnailSrc: row.thumbnail_src || '',
|
thumbnailSrc: row.thumbnail_src || '',
|
||||||
isPublic: row.is_public == null ? true : !!row.is_public,
|
isPublic: row.is_public == null ? true : !!row.is_public,
|
||||||
displayRank: row.display_rank == null ? null : Number(row.display_rank),
|
displayRank: row.display_rank == null ? null : Number(row.display_rank),
|
||||||
@@ -83,7 +86,8 @@ function mapGameItemRow(row) {
|
|||||||
if (!row) return null
|
if (!row) return null
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
gameId: row.game_id,
|
topicId: row.topic_id,
|
||||||
|
gameId: row.topic_id,
|
||||||
src: row.src,
|
src: row.src,
|
||||||
label: row.label,
|
label: row.label,
|
||||||
displayOrder: row.display_order == null ? null : Number(row.display_order),
|
displayOrder: row.display_order == null ? null : Number(row.display_order),
|
||||||
@@ -131,8 +135,10 @@ function mapTierListRow(row) {
|
|||||||
authorName: getUserDisplayName(row),
|
authorName: getUserDisplayName(row),
|
||||||
authorAccountName: getUserAccountName(row),
|
authorAccountName: getUserAccountName(row),
|
||||||
authorAvatarSrc: row.avatar_src || '',
|
authorAvatarSrc: row.avatar_src || '',
|
||||||
gameId: row.game_id,
|
topicId: row.topic_id,
|
||||||
gameName: row.game_name || '',
|
topicName: row.topic_name || '',
|
||||||
|
gameId: row.topic_id,
|
||||||
|
gameName: row.topic_name || '',
|
||||||
title: row.title,
|
title: row.title,
|
||||||
thumbnailSrc: row.thumbnail_src || '',
|
thumbnailSrc: row.thumbnail_src || '',
|
||||||
description: row.description || '',
|
description: row.description || '',
|
||||||
@@ -159,13 +165,17 @@ function mapTemplateRequestRow(row) {
|
|||||||
requesterAccountName: getUserAccountName(row),
|
requesterAccountName: getUserAccountName(row),
|
||||||
requesterAvatarSrc: row.requester_avatar_src || '',
|
requesterAvatarSrc: row.requester_avatar_src || '',
|
||||||
sourceTierListId: row.source_tierlist_id || '',
|
sourceTierListId: row.source_tierlist_id || '',
|
||||||
sourceGameId: row.source_game_id,
|
sourceTopicId: row.source_topic_id,
|
||||||
sourceGameName: row.source_game_name || '',
|
sourceTopicName: row.source_topic_name || '',
|
||||||
|
sourceGameId: row.source_topic_id,
|
||||||
|
sourceGameName: row.source_topic_name || '',
|
||||||
sourceTierListTitle: row.title_snapshot || '',
|
sourceTierListTitle: row.title_snapshot || '',
|
||||||
sourceDescription: row.description_snapshot || '',
|
sourceDescription: row.description_snapshot || '',
|
||||||
thumbnailSrc: row.thumbnail_src_snapshot || '',
|
thumbnailSrc: row.thumbnail_src_snapshot || '',
|
||||||
targetGameId: row.target_game_id || '',
|
targetTopicId: row.target_topic_id || '',
|
||||||
targetGameName: row.target_game_name || '',
|
targetTopicName: row.target_topic_name || '',
|
||||||
|
targetGameId: row.target_topic_id || '',
|
||||||
|
targetGameName: row.target_topic_name || '',
|
||||||
status: row.status,
|
status: row.status,
|
||||||
items: parseJson(row.items_json, []),
|
items: parseJson(row.items_json, []),
|
||||||
snapshotGroups: parseJson(row.groups_json, []),
|
snapshotGroups: parseJson(row.groups_json, []),
|
||||||
@@ -230,6 +240,16 @@ async function query(sql, params = []) {
|
|||||||
return rows
|
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() {
|
async function closePool() {
|
||||||
if (!poolPromise) return
|
if (!poolPromise) return
|
||||||
const pool = await poolPromise
|
const pool = await poolPromise
|
||||||
@@ -241,6 +261,32 @@ async function closePool() {
|
|||||||
async function ensureSchema() {
|
async function ensureSchema() {
|
||||||
if (initPromise) return initPromise
|
if (initPromise) return initPromise
|
||||||
initPromise = (async () => {
|
initPromise = (async () => {
|
||||||
|
if ((await tableExists('games')) && !(await tableExists('topics'))) {
|
||||||
|
await query('RENAME TABLE games TO topics')
|
||||||
|
}
|
||||||
|
if ((await tableExists('game_items')) && !(await tableExists('topic_items'))) {
|
||||||
|
await query('RENAME TABLE game_items TO topic_items')
|
||||||
|
}
|
||||||
|
if ((await tableExists('favorite_games')) && !(await tableExists('favorite_topics'))) {
|
||||||
|
await query('RENAME TABLE favorite_games TO favorite_topics')
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((await tableExists('tierlists')) && (await columnExists('tierlists', 'game_id')) && !(await columnExists('tierlists', 'topic_id'))) {
|
||||||
|
await query('ALTER TABLE tierlists CHANGE COLUMN game_id topic_id VARCHAR(120) NOT NULL')
|
||||||
|
}
|
||||||
|
if ((await tableExists('topic_items')) && (await columnExists('topic_items', 'game_id')) && !(await columnExists('topic_items', 'topic_id'))) {
|
||||||
|
await query('ALTER TABLE topic_items CHANGE COLUMN game_id topic_id VARCHAR(120) NOT NULL')
|
||||||
|
}
|
||||||
|
if ((await tableExists('favorite_topics')) && (await columnExists('favorite_topics', 'game_id')) && !(await columnExists('favorite_topics', 'topic_id'))) {
|
||||||
|
await query('ALTER TABLE favorite_topics CHANGE COLUMN game_id topic_id VARCHAR(120) NOT NULL')
|
||||||
|
}
|
||||||
|
if ((await tableExists('template_requests')) && (await columnExists('template_requests', 'source_game_id')) && !(await columnExists('template_requests', 'source_topic_id'))) {
|
||||||
|
await query('ALTER TABLE template_requests CHANGE COLUMN source_game_id source_topic_id VARCHAR(120) NOT NULL')
|
||||||
|
}
|
||||||
|
if ((await tableExists('template_requests')) && (await columnExists('template_requests', 'target_game_id')) && !(await columnExists('template_requests', 'target_topic_id'))) {
|
||||||
|
await query("ALTER TABLE template_requests CHANGE COLUMN target_game_id target_topic_id VARCHAR(120) NOT NULL DEFAULT ''")
|
||||||
|
}
|
||||||
|
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id VARCHAR(64) PRIMARY KEY,
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
@@ -254,7 +300,7 @@ async function ensureSchema() {
|
|||||||
`)
|
`)
|
||||||
|
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS games (
|
CREATE TABLE IF NOT EXISTS topics (
|
||||||
id VARCHAR(120) PRIMARY KEY,
|
id VARCHAR(120) PRIMARY KEY,
|
||||||
name VARCHAR(120) NOT NULL,
|
name VARCHAR(120) NOT NULL,
|
||||||
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
|
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
@@ -264,33 +310,33 @@ async function ensureSchema() {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) 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) {
|
if (!gameIsPublicColumns.length) {
|
||||||
await query('ALTER TABLE games ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER thumbnail_src')
|
await query('ALTER TABLE topics 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('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) {
|
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')
|
||||||
}
|
}
|
||||||
|
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS game_items (
|
CREATE TABLE IF NOT EXISTS topic_items (
|
||||||
id VARCHAR(64) PRIMARY KEY,
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
game_id VARCHAR(120) NOT NULL,
|
topic_id VARCHAR(120) NOT NULL,
|
||||||
src VARCHAR(255) NOT NULL,
|
src VARCHAR(255) NOT NULL,
|
||||||
label VARCHAR(120) NOT NULL,
|
label VARCHAR(120) NOT NULL,
|
||||||
display_order INT NULL DEFAULT NULL,
|
display_order INT NULL DEFAULT NULL,
|
||||||
created_at BIGINT NOT NULL,
|
created_at BIGINT NOT NULL,
|
||||||
INDEX idx_game_items_game_id (game_id),
|
INDEX idx_topic_items_topic_id (topic_id),
|
||||||
CONSTRAINT fk_game_items_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
|
CONSTRAINT fk_topic_items_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) 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) {
|
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')
|
||||||
}
|
}
|
||||||
|
|
||||||
await query(`
|
await query(`
|
||||||
@@ -309,7 +355,7 @@ async function ensureSchema() {
|
|||||||
CREATE TABLE IF NOT EXISTS tierlists (
|
CREATE TABLE IF NOT EXISTS tierlists (
|
||||||
id VARCHAR(64) PRIMARY KEY,
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
author_id VARCHAR(64) NOT NULL,
|
author_id VARCHAR(64) NOT NULL,
|
||||||
game_id VARCHAR(120) NOT NULL,
|
topic_id VARCHAR(120) NOT NULL,
|
||||||
title VARCHAR(120) NOT NULL,
|
title VARCHAR(120) NOT NULL,
|
||||||
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
|
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
@@ -324,10 +370,10 @@ async function ensureSchema() {
|
|||||||
created_at BIGINT NOT NULL,
|
created_at BIGINT NOT NULL,
|
||||||
updated_at BIGINT NOT NULL,
|
updated_at BIGINT NOT NULL,
|
||||||
INDEX idx_tierlists_author_id (author_id),
|
INDEX idx_tierlists_author_id (author_id),
|
||||||
INDEX idx_tierlists_game_id (game_id),
|
INDEX idx_tierlists_topic_id (topic_id),
|
||||||
INDEX idx_tierlists_public_game_updated (is_public, game_id, updated_at),
|
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_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
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -344,14 +390,14 @@ async function ensureSchema() {
|
|||||||
`)
|
`)
|
||||||
|
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS favorite_games (
|
CREATE TABLE IF NOT EXISTS favorite_topics (
|
||||||
user_id VARCHAR(64) NOT NULL,
|
user_id VARCHAR(64) NOT NULL,
|
||||||
game_id VARCHAR(120) NOT NULL,
|
topic_id VARCHAR(120) NOT NULL,
|
||||||
created_at BIGINT NOT NULL,
|
created_at BIGINT NOT NULL,
|
||||||
PRIMARY KEY (user_id, game_id),
|
PRIMARY KEY (user_id, topic_id),
|
||||||
INDEX idx_favorite_games_game_id (game_id),
|
INDEX idx_favorite_topics_topic_id (topic_id),
|
||||||
CONSTRAINT fk_favorite_games_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
CONSTRAINT fk_favorite_topics_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
|
CONSTRAINT fk_favorite_topics_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -399,8 +445,8 @@ async function ensureSchema() {
|
|||||||
request_type VARCHAR(20) NOT NULL,
|
request_type VARCHAR(20) NOT NULL,
|
||||||
requester_id VARCHAR(64) NOT NULL,
|
requester_id VARCHAR(64) NOT NULL,
|
||||||
source_tierlist_id VARCHAR(64) NOT NULL,
|
source_tierlist_id VARCHAR(64) NOT NULL,
|
||||||
source_game_id VARCHAR(120) NOT NULL,
|
source_topic_id VARCHAR(120) NOT NULL,
|
||||||
target_game_id VARCHAR(120) NOT NULL DEFAULT '',
|
target_topic_id VARCHAR(120) NOT NULL DEFAULT '',
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
title_snapshot VARCHAR(120) NOT NULL,
|
title_snapshot VARCHAR(120) NOT NULL,
|
||||||
description_snapshot TEXT NOT NULL,
|
description_snapshot TEXT NOT NULL,
|
||||||
@@ -424,17 +470,17 @@ async function ensureSchema() {
|
|||||||
if (!templateRequestTypeColumns.length) {
|
if (!templateRequestTypeColumns.length) {
|
||||||
await query("ALTER TABLE template_requests ADD COLUMN request_type VARCHAR(20) NOT NULL DEFAULT 'create' AFTER id")
|
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) {
|
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")
|
||||||
}
|
}
|
||||||
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) {
|
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")
|
||||||
}
|
}
|
||||||
const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'")
|
const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'")
|
||||||
if (!templateRequestStatusColumns.length) {
|
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'")
|
const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'")
|
||||||
if (!templateRequestGroupsColumns.length) {
|
if (!templateRequestGroupsColumns.length) {
|
||||||
@@ -478,19 +524,19 @@ async function ensureSchema() {
|
|||||||
|
|
||||||
await query(
|
await query(
|
||||||
`
|
`
|
||||||
INSERT INTO games (id, name, thumbnail_src, created_at)
|
INSERT INTO topics (id, name, thumbnail_src, created_at)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE name = VALUES(name)
|
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) {
|
if (Number(countRows[0]?.count || 0) <= 1) {
|
||||||
const createdAt = now()
|
const createdAt = now()
|
||||||
await query(
|
await query(
|
||||||
`
|
`
|
||||||
INSERT INTO games (id, name, thumbnail_src, created_at)
|
INSERT INTO topics (id, name, thumbnail_src, created_at)
|
||||||
VALUES
|
VALUES
|
||||||
(?, ?, ?, ?),
|
(?, ?, ?, ?),
|
||||||
(?, ?, ?, ?)
|
(?, ?, ?, ?)
|
||||||
@@ -500,7 +546,7 @@ async function ensureSchema() {
|
|||||||
|
|
||||||
await query(
|
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
|
VALUES
|
||||||
(?, ?, ?, ?, ?),
|
(?, ?, ?, ?, ?),
|
||||||
(?, ?, ?, ?, ?)
|
(?, ?, ?, ?, ?)
|
||||||
@@ -662,7 +708,7 @@ async function listGames(currentUserId = '', options = {}) {
|
|||||||
const rows = await query(
|
const rows = await query(
|
||||||
`
|
`
|
||||||
SELECT id, name, thumbnail_src, is_public, display_rank, created_at
|
SELECT id, name, thumbnail_src, is_public, display_rank, created_at
|
||||||
FROM games
|
FROM topics
|
||||||
WHERE id <> ?
|
WHERE id <> ?
|
||||||
${includePrivate ? '' : 'AND is_public = 1'}
|
${includePrivate ? '' : 'AND is_public = 1'}
|
||||||
ORDER BY
|
ORDER BY
|
||||||
@@ -671,13 +717,13 @@ async function listGames(currentUserId = '', options = {}) {
|
|||||||
created_at DESC,
|
created_at DESC,
|
||||||
name ASC
|
name ASC
|
||||||
`,
|
`,
|
||||||
[FREEFORM_GAME_ID]
|
[FREEFORM_TOPIC_ID]
|
||||||
)
|
)
|
||||||
const games = rows.map(mapGameRow)
|
const games = rows.map(mapGameRow)
|
||||||
if (!currentUserId) return games.map((game) => ({ ...game, isFavorited: false }))
|
if (!currentUserId) return games.map((game) => ({ ...game, isFavorited: false }))
|
||||||
|
|
||||||
const favoriteRows = await query('SELECT game_id FROM favorite_games WHERE user_id = ?', [currentUserId])
|
const favoriteRows = await query('SELECT topic_id FROM favorite_topics WHERE user_id = ?', [currentUserId])
|
||||||
const favoriteSet = new Set(favoriteRows.map((row) => row.game_id))
|
const favoriteSet = new Set(favoriteRows.map((row) => row.topic_id))
|
||||||
return games.map((game) => ({
|
return games.map((game) => ({
|
||||||
...game,
|
...game,
|
||||||
isFavorited: favoriteSet.has(game.id),
|
isFavorited: favoriteSet.has(game.id),
|
||||||
@@ -685,16 +731,16 @@ async function listGames(currentUserId = '', options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function findGameById(id) {
|
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])
|
return mapGameRow(rows[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listGameItems(gameId) {
|
async function listGameItems(gameId) {
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`
|
`
|
||||||
SELECT id, game_id, src, label, display_order, created_at
|
SELECT id, topic_id, src, label, display_order, created_at
|
||||||
FROM game_items
|
FROM topic_items
|
||||||
WHERE game_id = ?
|
WHERE topic_id = ?
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE WHEN display_order IS NULL THEN 1 ELSE 0 END ASC,
|
CASE WHEN display_order IS NULL THEN 1 ELSE 0 END ASC,
|
||||||
display_order ASC,
|
display_order ASC,
|
||||||
@@ -707,7 +753,7 @@ async function listGameItems(gameId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function findGameItemById(itemId) {
|
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])
|
return mapGameItemRow(rows[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,7 +765,7 @@ async function getGameDetail(gameId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createGame({ id, name, isPublic = true }) {
|
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,
|
id,
|
||||||
name,
|
name,
|
||||||
'',
|
'',
|
||||||
@@ -731,12 +777,12 @@ async function createGame({ id, name, isPublic = true }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateGameThumbnail(gameId, thumbnailSrc) {
|
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)
|
return findGameById(gameId)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateGameVisibility(gameId, isPublic) {
|
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)
|
return findGameById(gameId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,8 +884,8 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
|
|||||||
|
|
||||||
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
|
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
|
||||||
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
|
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
|
||||||
query("SELECT thumbnail_src FROM games WHERE thumbnail_src <> ''"),
|
query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
|
||||||
query("SELECT src FROM game_items WHERE src <> ''"),
|
query("SELECT src FROM topic_items WHERE src <> ''"),
|
||||||
query("SELECT src FROM custom_items WHERE src <> ''"),
|
query("SELECT src FROM custom_items WHERE src <> ''"),
|
||||||
query("SELECT thumbnail_src, pool_json FROM tierlists"),
|
query("SELECT thumbnail_src, pool_json FROM tierlists"),
|
||||||
query("SELECT thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"),
|
query("SELECT thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"),
|
||||||
@@ -891,8 +937,8 @@ async function listReferencedUploadUsage() {
|
|||||||
|
|
||||||
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
|
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
|
||||||
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
|
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
|
||||||
query("SELECT thumbnail_src FROM games WHERE thumbnail_src <> ''"),
|
query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
|
||||||
query("SELECT src FROM game_items WHERE src <> ''"),
|
query("SELECT src FROM topic_items WHERE src <> ''"),
|
||||||
query("SELECT src FROM custom_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, pool_json FROM tierlists"),
|
||||||
query("SELECT id, thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"),
|
query("SELECT id, thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"),
|
||||||
@@ -934,8 +980,8 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
|
|||||||
|
|
||||||
const [userResult, gameResult, gameItemResult, customItemResult] = await Promise.all([
|
const [userResult, gameResult, gameItemResult, customItemResult] = await Promise.all([
|
||||||
query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]),
|
query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]),
|
||||||
query('UPDATE games SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]),
|
query('UPDATE topics SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]),
|
||||||
query('UPDATE game_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
|
query('UPDATE topic_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
|
||||||
query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
|
query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -1099,8 +1145,8 @@ async function cleanupMissingUploadReferences() {
|
|||||||
|
|
||||||
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
|
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
|
||||||
query("SELECT id, avatar_src FROM users WHERE avatar_src <> ''"),
|
query("SELECT id, avatar_src FROM users WHERE avatar_src <> ''"),
|
||||||
query("SELECT id, thumbnail_src FROM games WHERE thumbnail_src <> ''"),
|
query("SELECT id, thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
|
||||||
query("SELECT id, src FROM game_items WHERE src <> ''"),
|
query("SELECT id, src FROM topic_items WHERE src <> ''"),
|
||||||
query("SELECT id, src FROM custom_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, groups_json, pool_json FROM tierlists"),
|
||||||
query("SELECT id, thumbnail_src_snapshot, groups_json, items_json, board_items_json FROM template_requests"),
|
query("SELECT id, thumbnail_src_snapshot, groups_json, items_json, board_items_json FROM template_requests"),
|
||||||
@@ -1114,7 +1160,7 @@ async function cleanupMissingUploadReferences() {
|
|||||||
|
|
||||||
for (const row of gameRows) {
|
for (const row of gameRows) {
|
||||||
if (await fileExistsForUploadSrc(row.thumbnail_src)) continue
|
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
|
stats.clearedGameThumbnails += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1269,10 +1315,10 @@ async function clearImageOptimizationJobs({ month } = {}) {
|
|||||||
}
|
}
|
||||||
async function createGameItem({ id, gameId, src, label }) {
|
async function createGameItem({ id, gameId, src, label }) {
|
||||||
const createdAt = now()
|
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 =
|
const nextDisplayOrder =
|
||||||
minOrderRows[0]?.min_display_order == null ? 0 : Number(minOrderRows[0].min_display_order) - 1
|
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,
|
id,
|
||||||
gameId,
|
gameId,
|
||||||
src,
|
src,
|
||||||
@@ -1280,13 +1326,13 @@ async function createGameItem({ id, gameId, src, label }) {
|
|||||||
nextDisplayOrder,
|
nextDisplayOrder,
|
||||||
createdAt,
|
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])
|
return mapGameItemRow(rows[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateGameItemLabel(itemId, label) {
|
async function updateGameItemLabel(itemId, label) {
|
||||||
await query('UPDATE game_items SET label = ? WHERE id = ?', [label, itemId])
|
await query('UPDATE topic_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])
|
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
|
||||||
return mapGameItemRow(rows[0])
|
return mapGameItemRow(rows[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1299,7 +1345,7 @@ async function updateGameItemDisplayOrder(gameId, itemIds) {
|
|||||||
const finalIds = [...orderedIds, ...remainingIds]
|
const finalIds = [...orderedIds, ...remainingIds]
|
||||||
|
|
||||||
await Promise.all(
|
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)
|
return listGameItems(gameId)
|
||||||
@@ -1334,15 +1380,15 @@ async function updateImageAssetLabel(assetId, label) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteGameItem(itemId) {
|
async function deleteGameItem(itemId) {
|
||||||
const gameItemRows = await query('SELECT game_id FROM game_items WHERE id = ? LIMIT 1', [itemId])
|
const gameItemRows = await query('SELECT topic_id FROM topic_items WHERE id = ? LIMIT 1', [itemId])
|
||||||
const gameId = gameItemRows[0]?.game_id
|
const gameId = gameItemRows[0]?.topic_id
|
||||||
|
|
||||||
if (gameId) {
|
if (gameId) {
|
||||||
const tierListRows = await query(
|
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
|
FROM tierlists
|
||||||
WHERE game_id = ?
|
WHERE topic_id = ?
|
||||||
`,
|
`,
|
||||||
[gameId]
|
[gameId]
|
||||||
)
|
)
|
||||||
@@ -1362,21 +1408,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) {
|
async function deleteGame(gameId) {
|
||||||
await query('DELETE FROM games WHERE id = ?', [gameId])
|
await query('DELETE FROM topics WHERE id = ?', [gameId])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateGameDisplayOrder(gameIds) {
|
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(
|
await Promise.all(
|
||||||
normalizedIds.map((gameId, index) =>
|
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 +1484,9 @@ async function findCustomItemById(id) {
|
|||||||
async function getCustomItemUsageMeta() {
|
async function getCustomItemUsageMeta() {
|
||||||
const rows = await query(
|
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
|
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()
|
const usageMap = new Map()
|
||||||
@@ -1465,13 +1511,13 @@ async function getCustomItemUsageMeta() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!row.game_id) return
|
if (!row.topic_id) return
|
||||||
|
|
||||||
seenItemIds.forEach((itemId) => {
|
seenItemIds.forEach((itemId) => {
|
||||||
if (!linkedGamesMap.has(itemId)) linkedGamesMap.set(itemId, new Map())
|
if (!linkedGamesMap.has(itemId)) linkedGamesMap.set(itemId, new Map())
|
||||||
linkedGamesMap.get(itemId).set(row.game_id, {
|
linkedGamesMap.get(itemId).set(row.topic_id, {
|
||||||
id: row.game_id,
|
id: row.topic_id,
|
||||||
name: row.game_name || row.game_id,
|
name: row.topic_name || row.topic_id,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1511,14 +1557,14 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
|||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
gi.id,
|
gi.id,
|
||||||
gi.game_id,
|
gi.topic_id,
|
||||||
gi.src,
|
gi.src,
|
||||||
gi.label,
|
gi.label,
|
||||||
gi.created_at,
|
gi.created_at,
|
||||||
g.name AS game_name
|
tp.name AS topic_name
|
||||||
FROM game_items gi
|
FROM topic_items gi
|
||||||
INNER JOIN games g ON g.id = gi.game_id
|
INNER JOIN topics tp ON tp.id = gi.topic_id
|
||||||
${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.game_id LIKE ? OR g.name LIKE ?' : ''}
|
${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.topic_id LIKE ? OR tp.name LIKE ?' : ''}
|
||||||
ORDER BY gi.created_at DESC
|
ORDER BY gi.created_at DESC
|
||||||
`,
|
`,
|
||||||
hasQuery ? [search, search, search, search] : []
|
hasQuery ? [search, search, search, search] : []
|
||||||
@@ -1540,9 +1586,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
|||||||
gameItemRows.forEach((row) => {
|
gameItemRows.forEach((row) => {
|
||||||
if (!row?.src) return
|
if (!row?.src) return
|
||||||
if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map())
|
if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map())
|
||||||
templateLinkedBySrc.get(row.src).set(row.game_id, {
|
templateLinkedBySrc.get(row.src).set(row.topic_id, {
|
||||||
id: row.game_id,
|
id: row.topic_id,
|
||||||
name: row.game_name || row.game_id,
|
name: row.topic_name || row.topic_id,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1593,15 +1639,15 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
|||||||
src: row.src,
|
src: row.src,
|
||||||
label: row.label,
|
label: row.label,
|
||||||
createdAt: Number(row.created_at),
|
createdAt: Number(row.created_at),
|
||||||
ownerName: row.game_name || row.game_id,
|
ownerName: row.topic_name || row.topic_id,
|
||||||
ownerEmail: '',
|
ownerEmail: '',
|
||||||
usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size,
|
usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size,
|
||||||
linkedGames: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
|
linkedGames: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
|
||||||
sourceType: 'template',
|
sourceType: 'template',
|
||||||
sourceLabel: '관리자 템플릿',
|
sourceLabel: '관리자 템플릿',
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
sourceGameId: row.game_id,
|
sourceGameId: row.topic_id,
|
||||||
sourceGameName: row.game_name || row.game_id,
|
sourceGameName: row.topic_name || row.topic_id,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const baseItems = [...customItems, ...templateItems, ...assetLibraryItems]
|
const baseItems = [...customItems, ...templateItems, ...assetLibraryItems]
|
||||||
@@ -1767,12 +1813,12 @@ function applyFavoriteMetaToTierLists(tierLists, favoriteStats) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listPublicTierLists(gameId, currentUserId = '', queryText = '') {
|
async function listPublicTierLists(topicId, currentUserId = '', queryText = '') {
|
||||||
const params = []
|
const params = []
|
||||||
let whereClause = 'WHERE t.is_public = 1'
|
let whereClause = 'WHERE t.is_public = 1'
|
||||||
if (gameId) {
|
if (topicId) {
|
||||||
whereClause += ' AND t.game_id = ?'
|
whereClause += ' AND t.topic_id = ?'
|
||||||
params.push(gameId)
|
params.push(topicId)
|
||||||
}
|
}
|
||||||
if ((queryText || '').trim()) {
|
if ((queryText || '').trim()) {
|
||||||
const search = `%${queryText.trim()}%`
|
const search = `%${queryText.trim()}%`
|
||||||
@@ -1784,7 +1830,7 @@ async function listPublicTierLists(gameId, currentUserId = '', queryText = '') {
|
|||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
t.id,
|
t.id,
|
||||||
t.game_id,
|
t.topic_id,
|
||||||
t.title,
|
t.title,
|
||||||
t.thumbnail_src,
|
t.thumbnail_src,
|
||||||
t.created_at,
|
t.created_at,
|
||||||
@@ -1804,7 +1850,8 @@ async function listPublicTierLists(gameId, currentUserId = '', queryText = '') {
|
|||||||
|
|
||||||
const tierLists = rows.map((row) => ({
|
const tierLists = rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
gameId: row.game_id,
|
topicId: row.topic_id,
|
||||||
|
gameId: row.topic_id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
thumbnailSrc: row.thumbnail_src || '',
|
thumbnailSrc: row.thumbnail_src || '',
|
||||||
createdAt: Number(row.created_at),
|
createdAt: Number(row.created_at),
|
||||||
@@ -1830,7 +1877,7 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
|
|||||||
|
|
||||||
if ((queryText || '').trim()) {
|
if ((queryText || '').trim()) {
|
||||||
const search = `%${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)
|
params.push(search, search, search, search)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1846,8 +1893,8 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
|
|||||||
SELECT
|
SELECT
|
||||||
t.id,
|
t.id,
|
||||||
t.author_id,
|
t.author_id,
|
||||||
t.game_id,
|
t.topic_id,
|
||||||
g.name AS game_name,
|
tp.name AS topic_name,
|
||||||
t.title,
|
t.title,
|
||||||
t.thumbnail_src,
|
t.thumbnail_src,
|
||||||
t.description,
|
t.description,
|
||||||
@@ -1873,7 +1920,7 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
|
|||||||
FROM favorite_tierlists f
|
FROM favorite_tierlists f
|
||||||
INNER JOIN tierlists t ON t.id = f.tierlist_id
|
INNER JOIN tierlists t ON t.id = f.tierlist_id
|
||||||
INNER JOIN users u ON u.id = t.author_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}
|
${whereClause}
|
||||||
${orderClause}
|
${orderClause}
|
||||||
`,
|
`,
|
||||||
@@ -1893,7 +1940,7 @@ async function listUserTierLists(userId) {
|
|||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
t.id,
|
t.id,
|
||||||
t.game_id,
|
t.topic_id,
|
||||||
t.title,
|
t.title,
|
||||||
t.thumbnail_src,
|
t.thumbnail_src,
|
||||||
t.created_at,
|
t.created_at,
|
||||||
@@ -1912,7 +1959,8 @@ async function listUserTierLists(userId) {
|
|||||||
|
|
||||||
const tierLists = rows.map((row) => ({
|
const tierLists = rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
gameId: row.game_id,
|
topicId: row.topic_id,
|
||||||
|
gameId: row.topic_id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
thumbnailSrc: row.thumbnail_src || '',
|
thumbnailSrc: row.thumbnail_src || '',
|
||||||
createdAt: Number(row.created_at),
|
createdAt: Number(row.created_at),
|
||||||
@@ -1958,25 +2006,26 @@ function getAutoThumbnailSrc(groups = [], pool = []) {
|
|||||||
return fallbackItem?.src || ''
|
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 normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||||
const normalizedPage = Math.max(Number(page) || 1, 1)
|
const normalizedPage = Math.max(Number(page) || 1, 1)
|
||||||
const hasQuery = !!(queryText || '').trim()
|
const hasQuery = !!(queryText || '').trim()
|
||||||
const hasGameId = !!(gameId || '').trim()
|
const resolvedTopicId = (topicId || gameId || '').trim()
|
||||||
|
const hasGameId = !!resolvedTopicId
|
||||||
const search = `%${(queryText || '').trim()}%`
|
const search = `%${(queryText || '').trim()}%`
|
||||||
const whereParts = []
|
const whereParts = []
|
||||||
const params = []
|
const params = []
|
||||||
|
|
||||||
if (hasGameId) {
|
if (hasGameId) {
|
||||||
whereParts.push('t.game_id = ?')
|
whereParts.push('t.topic_id = ?')
|
||||||
params.push((gameId || '').trim())
|
params.push(resolvedTopicId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasQuery) {
|
if (hasQuery) {
|
||||||
whereParts.push(`(
|
whereParts.push(`(
|
||||||
t.title LIKE ?
|
t.title LIKE ?
|
||||||
OR g.name LIKE ?
|
OR tp.name LIKE ?
|
||||||
OR g.id LIKE ?
|
OR tp.id LIKE ?
|
||||||
OR u.email LIKE ?
|
OR u.email LIKE ?
|
||||||
OR u.nickname LIKE ?
|
OR u.nickname LIKE ?
|
||||||
)`)
|
)`)
|
||||||
@@ -1990,8 +2039,8 @@ async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit
|
|||||||
SELECT
|
SELECT
|
||||||
t.id,
|
t.id,
|
||||||
t.author_id,
|
t.author_id,
|
||||||
t.game_id,
|
t.topic_id,
|
||||||
g.name AS game_name,
|
tp.name AS topic_name,
|
||||||
t.title,
|
t.title,
|
||||||
t.thumbnail_src,
|
t.thumbnail_src,
|
||||||
t.description,
|
t.description,
|
||||||
@@ -2010,7 +2059,7 @@ async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit
|
|||||||
u.avatar_src
|
u.avatar_src
|
||||||
FROM tierlists t
|
FROM tierlists t
|
||||||
INNER JOIN users u ON u.id = t.author_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}
|
${whereClause}
|
||||||
ORDER BY t.updated_at DESC, t.created_at DESC
|
ORDER BY t.updated_at DESC, t.created_at DESC
|
||||||
`,
|
`,
|
||||||
@@ -2044,23 +2093,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 hasQuery = !!(queryText || '').trim()
|
||||||
const hasGameId = !!(gameId || '').trim()
|
const resolvedTopicId = (topicId || gameId || '').trim()
|
||||||
|
const hasGameId = !!resolvedTopicId
|
||||||
const search = `%${(queryText || '').trim()}%`
|
const search = `%${(queryText || '').trim()}%`
|
||||||
const whereParts = []
|
const whereParts = []
|
||||||
const params = []
|
const params = []
|
||||||
|
|
||||||
if (hasGameId) {
|
if (hasGameId) {
|
||||||
whereParts.push('t.game_id = ?')
|
whereParts.push('t.topic_id = ?')
|
||||||
params.push((gameId || '').trim())
|
params.push(resolvedTopicId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasQuery) {
|
if (hasQuery) {
|
||||||
whereParts.push(`(
|
whereParts.push(`(
|
||||||
t.title LIKE ?
|
t.title LIKE ?
|
||||||
OR g.name LIKE ?
|
OR tp.name LIKE ?
|
||||||
OR g.id LIKE ?
|
OR tp.id LIKE ?
|
||||||
OR u.email LIKE ?
|
OR u.email LIKE ?
|
||||||
OR u.nickname LIKE ?
|
OR u.nickname LIKE ?
|
||||||
)`)
|
)`)
|
||||||
@@ -2073,7 +2123,7 @@ async function summarizeAdminTierLists({ queryText = '', gameId = '' } = {}) {
|
|||||||
SELECT t.is_public
|
SELECT t.is_public
|
||||||
FROM tierlists t
|
FROM tierlists t
|
||||||
INNER JOIN users u ON u.id = t.author_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}
|
${whereClause}
|
||||||
`,
|
`,
|
||||||
params
|
params
|
||||||
@@ -2094,8 +2144,8 @@ async function findTierListById(id, currentUserId = '') {
|
|||||||
SELECT
|
SELECT
|
||||||
t.id,
|
t.id,
|
||||||
t.author_id,
|
t.author_id,
|
||||||
t.game_id,
|
t.topic_id,
|
||||||
g.name AS game_name,
|
tp.name AS topic_name,
|
||||||
t.title,
|
t.title,
|
||||||
t.thumbnail_src,
|
t.thumbnail_src,
|
||||||
t.description,
|
t.description,
|
||||||
@@ -2114,7 +2164,7 @@ async function findTierListById(id, currentUserId = '') {
|
|||||||
u.avatar_src
|
u.avatar_src
|
||||||
FROM tierlists t
|
FROM tierlists t
|
||||||
INNER JOIN users u ON u.id = t.author_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
|
||||||
WHERE t.id = ?
|
WHERE t.id = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`,
|
`,
|
||||||
@@ -2146,6 +2196,8 @@ async function createTemplateRequest({
|
|||||||
sourceTierListId = '',
|
sourceTierListId = '',
|
||||||
sourceGameId,
|
sourceGameId,
|
||||||
targetGameId = '',
|
targetGameId = '',
|
||||||
|
sourceTopicId = sourceGameId,
|
||||||
|
targetTopicId = targetGameId,
|
||||||
title,
|
title,
|
||||||
description = '',
|
description = '',
|
||||||
thumbnailSrc = '',
|
thumbnailSrc = '',
|
||||||
@@ -2171,8 +2223,8 @@ async function createTemplateRequest({
|
|||||||
request_type,
|
request_type,
|
||||||
requester_id,
|
requester_id,
|
||||||
source_tierlist_id,
|
source_tierlist_id,
|
||||||
source_game_id,
|
source_topic_id,
|
||||||
target_game_id,
|
target_topic_id,
|
||||||
status,
|
status,
|
||||||
title_snapshot,
|
title_snapshot,
|
||||||
description_snapshot,
|
description_snapshot,
|
||||||
@@ -2191,8 +2243,8 @@ async function createTemplateRequest({
|
|||||||
type,
|
type,
|
||||||
requesterId,
|
requesterId,
|
||||||
sourceTierListId || null,
|
sourceTierListId || null,
|
||||||
sourceGameId,
|
sourceTopicId,
|
||||||
targetGameId,
|
targetTopicId,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
thumbnailSrc,
|
thumbnailSrc,
|
||||||
@@ -2215,8 +2267,8 @@ async function findTemplateRequestById(id) {
|
|||||||
tr.request_type,
|
tr.request_type,
|
||||||
tr.requester_id,
|
tr.requester_id,
|
||||||
tr.source_tierlist_id,
|
tr.source_tierlist_id,
|
||||||
tr.source_game_id,
|
tr.source_topic_id,
|
||||||
tr.target_game_id,
|
tr.target_topic_id,
|
||||||
tr.status,
|
tr.status,
|
||||||
tr.title_snapshot,
|
tr.title_snapshot,
|
||||||
tr.description_snapshot,
|
tr.description_snapshot,
|
||||||
@@ -2230,12 +2282,12 @@ async function findTemplateRequestById(id) {
|
|||||||
u.nickname,
|
u.nickname,
|
||||||
u.email,
|
u.email,
|
||||||
u.avatar_src AS requester_avatar_src,
|
u.avatar_src AS requester_avatar_src,
|
||||||
sg.name AS source_game_name,
|
sg.name AS source_topic_name,
|
||||||
tg.name AS target_game_name
|
tg.name AS target_topic_name
|
||||||
FROM template_requests tr
|
FROM template_requests tr
|
||||||
INNER JOIN users u ON u.id = tr.requester_id
|
INNER JOIN users u ON u.id = tr.requester_id
|
||||||
LEFT JOIN games sg ON sg.id = tr.source_game_id
|
LEFT JOIN topics sg ON sg.id = tr.source_topic_id
|
||||||
LEFT JOIN games tg ON tg.id = tr.target_game_id
|
LEFT JOIN topics tg ON tg.id = tr.target_topic_id
|
||||||
WHERE tr.id = ?
|
WHERE tr.id = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`,
|
`,
|
||||||
@@ -2257,8 +2309,8 @@ async function listAdminTemplateRequests({ status = 'pending', statuses = [] } =
|
|||||||
tr.request_type,
|
tr.request_type,
|
||||||
tr.requester_id,
|
tr.requester_id,
|
||||||
tr.source_tierlist_id,
|
tr.source_tierlist_id,
|
||||||
tr.source_game_id,
|
tr.source_topic_id,
|
||||||
tr.target_game_id,
|
tr.target_topic_id,
|
||||||
tr.status,
|
tr.status,
|
||||||
tr.title_snapshot,
|
tr.title_snapshot,
|
||||||
tr.description_snapshot,
|
tr.description_snapshot,
|
||||||
@@ -2272,12 +2324,12 @@ async function listAdminTemplateRequests({ status = 'pending', statuses = [] } =
|
|||||||
u.nickname,
|
u.nickname,
|
||||||
u.email,
|
u.email,
|
||||||
u.avatar_src AS requester_avatar_src,
|
u.avatar_src AS requester_avatar_src,
|
||||||
sg.name AS source_game_name,
|
sg.name AS source_topic_name,
|
||||||
tg.name AS target_game_name
|
tg.name AS target_topic_name
|
||||||
FROM template_requests tr
|
FROM template_requests tr
|
||||||
INNER JOIN users u ON u.id = tr.requester_id
|
INNER JOIN users u ON u.id = tr.requester_id
|
||||||
LEFT JOIN games sg ON sg.id = tr.source_game_id
|
LEFT JOIN topics sg ON sg.id = tr.source_topic_id
|
||||||
LEFT JOIN games tg ON tg.id = tr.target_game_id
|
LEFT JOIN topics tg ON tg.id = tr.target_topic_id
|
||||||
WHERE tr.status IN (${placeholders})
|
WHERE tr.status IN (${placeholders})
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE tr.status
|
CASE tr.status
|
||||||
@@ -2299,7 +2351,7 @@ async function updateTemplateRequestStatus({ id, status }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateTemplateRequestTargetGame({ id, targetGameId }) {
|
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)
|
return findTemplateRequestById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2350,6 +2402,7 @@ async function saveTierList({
|
|||||||
id,
|
id,
|
||||||
authorId,
|
authorId,
|
||||||
gameId,
|
gameId,
|
||||||
|
topicId = gameId,
|
||||||
title,
|
title,
|
||||||
thumbnailSrc = '',
|
thumbnailSrc = '',
|
||||||
description,
|
description,
|
||||||
@@ -2383,11 +2436,11 @@ async function saveTierList({
|
|||||||
await query(
|
await query(
|
||||||
`
|
`
|
||||||
INSERT INTO tierlists (
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
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)
|
return findTierListById(nextId, authorId)
|
||||||
}
|
}
|
||||||
@@ -2400,7 +2453,8 @@ async function duplicateTierListForUser({ tierList, targetUserId }) {
|
|||||||
return saveTierList({
|
return saveTierList({
|
||||||
id: duplicateId,
|
id: duplicateId,
|
||||||
authorId: targetUserId,
|
authorId: targetUserId,
|
||||||
gameId: tierList.gameId,
|
gameId: tierList.topicId || tierList.gameId,
|
||||||
|
topicId: tierList.topicId || tierList.gameId,
|
||||||
title: copyTitle,
|
title: copyTitle,
|
||||||
thumbnailSrc: tierList.thumbnailSrc || '',
|
thumbnailSrc: tierList.thumbnailSrc || '',
|
||||||
description: tierList.description || '',
|
description: tierList.description || '',
|
||||||
@@ -2424,11 +2478,11 @@ async function unfavoriteTierList({ userId, tierListId }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function favoriteGame({ userId, gameId }) {
|
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 }) {
|
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 = {
|
module.exports = {
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState
|
|||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
|
function getTemplateIdParam(req) {
|
||||||
|
return req.params.templateId || req.params.gameId || ''
|
||||||
|
}
|
||||||
|
|
||||||
function buildUploadFilename(file) {
|
function buildUploadFilename(file) {
|
||||||
const ext = path.extname(file.originalname || '').toLowerCase()
|
const ext = path.extname(file.originalname || '').toLowerCase()
|
||||||
const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : ''
|
const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : ''
|
||||||
@@ -110,7 +114,7 @@ function canManageAdminRole(actingUser, primaryAdmin) {
|
|||||||
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
|
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({
|
const schema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
name: z.string().min(1).max(60),
|
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)
|
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc)
|
||||||
await updateGameThumbnail(game.id, copiedThumb)
|
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({
|
const schema = z.object({
|
||||||
isPublic: z.boolean(),
|
isPublic: z.boolean(),
|
||||||
})
|
})
|
||||||
const parsed = schema.safeParse(req.body)
|
const parsed = schema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
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' })
|
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|
||||||
const updated = await updateGameVisibility(game.id, parsed.data.isPublic)
|
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({
|
const schema = z.object({
|
||||||
gameIds: z.array(z.string().min(1)).max(50),
|
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 validGameIds = new Set(games.map((game) => game.id))
|
||||||
const filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId))
|
const filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId))
|
||||||
const updatedGames = await updateGameDisplayOrder(filteredIds)
|
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({
|
const schema = z.object({
|
||||||
itemIds: z.array(z.string().min(1)).min(1),
|
itemIds: z.array(z.string().min(1)).min(1),
|
||||||
})
|
})
|
||||||
const parsed = schema.safeParse(req.body)
|
const parsed = schema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
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' })
|
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|
||||||
const items = await updateGameItemDisplayOrder(game.id, parsed.data.itemIds)
|
const items = await updateGameItemDisplayOrder(game.id, parsed.data.itemIds)
|
||||||
res.json({ items })
|
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' })
|
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' })
|
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|
||||||
const optimized = await writeOptimizedImage({
|
const optimized = await writeOptimizedImage({
|
||||||
@@ -185,14 +193,15 @@ router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail')
|
|||||||
quality: 84,
|
quality: 84,
|
||||||
})
|
})
|
||||||
|
|
||||||
const updated = await updateGameThumbnail(req.params.gameId, optimized.src)
|
const updated = await updateGameThumbnail(templateId, optimized.src)
|
||||||
res.json({ game: updated })
|
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 : []
|
const files = Array.isArray(req.files) ? req.files : []
|
||||||
if (!files.length) return res.status(400).json({ error: 'file_required' })
|
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' })
|
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|
||||||
const labelsRaw = req.body?.labels
|
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 })
|
res.json({ item: items[0], items })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.delete('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => {
|
router.delete(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => {
|
||||||
const game = await findGameById(req.params.gameId)
|
const game = await findGameById(getTemplateIdParam(req))
|
||||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||||
await deleteGameItem(req.params.itemId)
|
await deleteGameItem(req.params.itemId)
|
||||||
res.json({ ok: true })
|
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 schema = z.object({ label: z.string().trim().min(1).max(60) })
|
||||||
const parsed = schema.safeParse(req.body)
|
const parsed = schema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
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' })
|
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|
||||||
const updated = await updateGameItemLabel(req.params.itemId, parsed.data.label)
|
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 })
|
res.json({ item: updated })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.delete('/games/:gameId', requireAdmin, async (req, res) => {
|
router.delete(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => {
|
||||||
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' })
|
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||||
await deleteGame(req.params.gameId)
|
await deleteGame(templateId)
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -298,6 +308,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
|||||||
router.get('/tierlists', requireAdmin, async (req, res) => {
|
router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
q: z.string().trim().max(120).optional().default(''),
|
q: z.string().trim().max(120).optional().default(''),
|
||||||
|
topicId: z.string().trim().max(120).optional().default(''),
|
||||||
gameId: 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),
|
page: z.coerce.number().int().min(1).optional().default(1),
|
||||||
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
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({
|
const result = await listAdminTierLists({
|
||||||
queryText: parsed.data.q,
|
queryText: parsed.data.q,
|
||||||
|
topicId: parsed.data.topicId || parsed.data.gameId,
|
||||||
gameId: parsed.data.gameId,
|
gameId: parsed.data.gameId,
|
||||||
page: parsed.data.page,
|
page: parsed.data.page,
|
||||||
limit: parsed.data.limit,
|
limit: parsed.data.limit,
|
||||||
@@ -318,6 +330,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
|
|||||||
router.get('/tierlists/stats', requireAdmin, async (req, res) => {
|
router.get('/tierlists/stats', requireAdmin, async (req, res) => {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
q: z.string().trim().max(120).optional().default(''),
|
q: z.string().trim().max(120).optional().default(''),
|
||||||
|
topicId: z.string().trim().max(120).optional().default(''),
|
||||||
gameId: z.string().trim().max(120).optional().default(''),
|
gameId: z.string().trim().max(120).optional().default(''),
|
||||||
})
|
})
|
||||||
const parsed = schema.safeParse(req.query)
|
const parsed = schema.safeParse(req.query)
|
||||||
@@ -325,6 +338,7 @@ router.get('/tierlists/stats', requireAdmin, async (req, res) => {
|
|||||||
|
|
||||||
const result = await summarizeAdminTierLists({
|
const result = await summarizeAdminTierLists({
|
||||||
queryText: parsed.data.q,
|
queryText: parsed.data.q,
|
||||||
|
topicId: parsed.data.topicId || parsed.data.gameId,
|
||||||
gameId: parsed.data.gameId,
|
gameId: parsed.data.gameId,
|
||||||
})
|
})
|
||||||
res.json(result)
|
res.json(result)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const router = express.Router()
|
|||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const games = await listGames(req.session?.userId || '', { includePrivate: !!req.session?.isAdmin })
|
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) => {
|
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 })
|
await favoriteGame({ userId: req.session.userId, gameId: game.id })
|
||||||
const games = await listGames(req.session.userId)
|
const games = await listGames(req.session.userId)
|
||||||
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: true }
|
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) => {
|
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 })
|
await unfavoriteGame({ userId: req.session.userId, gameId: game.id })
|
||||||
const games = await listGames(req.session.userId)
|
const games = await listGames(req.session.userId)
|
||||||
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: false }
|
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) => {
|
router.get('/:gameId', async (req, res) => {
|
||||||
const detail = await getGameDetail(req.params.gameId)
|
const detail = await getGameDetail(req.params.gameId)
|
||||||
if (!detail) return res.status(404).json({ error: 'not_found' })
|
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' })
|
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
|
module.exports = router
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const { requireAuth } = require('../middleware/auth')
|
|||||||
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
|
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
const FREEFORM_GAME_ID = 'freeform'
|
const FREEFORM_TOPIC_ID = 'freeform'
|
||||||
const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기'
|
const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기'
|
||||||
|
|
||||||
function normalizePoolItem(item) {
|
function normalizePoolItem(item) {
|
||||||
@@ -61,7 +61,8 @@ const thumbnailUpload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024 }
|
|||||||
const templateRequestSchema = z.object({
|
const templateRequestSchema = z.object({
|
||||||
type: z.enum(['create', 'update']),
|
type: z.enum(['create', 'update']),
|
||||||
sourceTierListId: z.string().max(64).optional().default(''),
|
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),
|
requestTitle: z.string().trim().min(1).max(120),
|
||||||
requestDescription: z.string().trim().min(1).max(1000),
|
requestDescription: z.string().trim().min(1).max(1000),
|
||||||
thumbnailSrc: z.string().max(255).optional().default(''),
|
thumbnailSrc: z.string().max(255).optional().default(''),
|
||||||
@@ -72,7 +73,11 @@ const templateRequestSchema = z.object({
|
|||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
name: z.string().min(1).max(16),
|
name: z.string().min(1).max(16),
|
||||||
itemIds: z.array(z.string()).optional().default([]),
|
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(
|
boardItems: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -86,7 +91,8 @@ const templateRequestSchema = z.object({
|
|||||||
|
|
||||||
const tierListUpsertSchema = z.object({
|
const tierListUpsertSchema = z.object({
|
||||||
id: z.string().optional(),
|
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),
|
title: z.string().min(1).max(120),
|
||||||
thumbnailSrc: z.string().max(255).optional().default(''),
|
thumbnailSrc: z.string().max(255).optional().default(''),
|
||||||
description: z.string().max(1000).optional().default(''),
|
description: z.string().max(1000).optional().default(''),
|
||||||
@@ -111,12 +117,16 @@ const tierListUpsertSchema = z.object({
|
|||||||
origin: z.enum(['game', 'custom']).default('game'),
|
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) => {
|
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 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 })
|
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' })
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
const payload = parsed.data
|
const payload = parsed.data
|
||||||
|
const topicId = payload.topicId || payload.gameId
|
||||||
const normalizedBoardItems = payload.boardItems.map(normalizePoolItem)
|
const normalizedBoardItems = payload.boardItems.map(normalizePoolItem)
|
||||||
const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom')
|
const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom')
|
||||||
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
|
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
|
||||||
|
|
||||||
if (payload.type === 'create') {
|
if (payload.type === 'create') {
|
||||||
if (payload.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
|
if (topicId !== FREEFORM_TOPIC_ID) return res.status(400).json({ error: 'freeform_required' })
|
||||||
} else if (payload.gameId === FREEFORM_GAME_ID) {
|
} else if (topicId === FREEFORM_TOPIC_ID) {
|
||||||
return res.status(400).json({ error: 'game_template_required' })
|
return res.status(400).json({ error: 'topic_template_required' })
|
||||||
}
|
}
|
||||||
|
|
||||||
let sourceTierList = null
|
let sourceTierList = null
|
||||||
@@ -251,8 +262,10 @@ router.post('/template-request', requireAuth, async (req, res) => {
|
|||||||
type: payload.type,
|
type: payload.type,
|
||||||
requesterId: req.session.userId,
|
requesterId: req.session.userId,
|
||||||
sourceTierListId: sourceTierList?.id || '',
|
sourceTierListId: sourceTierList?.id || '',
|
||||||
sourceGameId: payload.gameId,
|
sourceGameId: topicId,
|
||||||
targetGameId: payload.type === 'update' ? payload.gameId : '',
|
sourceTopicId: topicId,
|
||||||
|
targetGameId: payload.type === 'update' ? topicId : '',
|
||||||
|
targetTopicId: payload.type === 'update' ? topicId : '',
|
||||||
title: payload.requestTitle,
|
title: payload.requestTitle,
|
||||||
description: payload.requestDescription,
|
description: payload.requestDescription,
|
||||||
thumbnailSrc: payload.thumbnailSrc || '',
|
thumbnailSrc: payload.thumbnailSrc || '',
|
||||||
@@ -274,6 +287,7 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
const parsed = tierListUpsertSchema.safeParse(req.body)
|
const parsed = tierListUpsertSchema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
const payload = parsed.data
|
const payload = parsed.data
|
||||||
|
const topicId = payload.topicId || payload.gameId
|
||||||
const normalizedPool = payload.pool.map(normalizePoolItem)
|
const normalizedPool = payload.pool.map(normalizePoolItem)
|
||||||
|
|
||||||
let existing = null
|
let existing = null
|
||||||
@@ -284,7 +298,8 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
const updated = await saveTierList({
|
const updated = await saveTierList({
|
||||||
id: existing.id,
|
id: existing.id,
|
||||||
authorId: existing.authorId,
|
authorId: existing.authorId,
|
||||||
gameId: existing.gameId,
|
gameId: existing.topicId || existing.gameId,
|
||||||
|
topicId: existing.topicId || existing.gameId,
|
||||||
title: payload.title,
|
title: payload.title,
|
||||||
thumbnailSrc: payload.thumbnailSrc || '',
|
thumbnailSrc: payload.thumbnailSrc || '',
|
||||||
description: payload.description || '',
|
description: payload.description || '',
|
||||||
@@ -303,7 +318,8 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
const created = await saveTierList({
|
const created = await saveTierList({
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
authorId: req.session.userId,
|
authorId: req.session.userId,
|
||||||
gameId: payload.gameId,
|
gameId: topicId,
|
||||||
|
topicId,
|
||||||
title: payload.title,
|
title: payload.title,
|
||||||
thumbnailSrc: payload.thumbnailSrc || '',
|
thumbnailSrc: payload.thumbnailSrc || '',
|
||||||
description: payload.description || '',
|
description: payload.description || '',
|
||||||
|
|||||||
@@ -1,5 +1,41 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 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.2
|
||||||
- 용어 정리를 시작한 뒤에는 일부 화면만 바꾸는 것보다, 관리자 모달과 확인 메시지처럼 실제 운영 중 많이 보는 문구도 함께 맞춰 주는 편이 체감 일관성이 더 높다고 판단했다.
|
- 용어 정리를 시작한 뒤에는 일부 화면만 바꾸는 것보다, 관리자 모달과 확인 메시지처럼 실제 운영 중 많이 보는 문구도 함께 맞춰 주는 편이 체감 일관성이 더 높다고 판단했다.
|
||||||
|
|
||||||
|
|||||||
44
docs/todo.md
44
docs/todo.md
@@ -1,11 +1,27 @@
|
|||||||
# 할 일 및 이슈
|
# 할 일 및 이슈
|
||||||
|
|
||||||
## 단기 확인
|
## 단기 확인
|
||||||
- 관리자 화면까지 포함한 사용자 노출 `게임` 문구를 2차로 정리했으므로, 실제 운영 흐름에서 뜨는 확인창/토스트/선택 모달까지 표현이 자연스러운지 한 번 더 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한다.
|
||||||
- 용어 정리 1차는 사용자 노출 문구만 `주제 / 템플릿`으로 바꿨으므로, 홈/주제 화면/관리자 템플릿 관리에서 어색하게 남은 `게임` 문구가 없는지 한 번 더 QA한다.
|
- 사용자 노출 용어는 `주제 / 템플릿` 기준으로 계속 걷어내고 있으므로, 홈/주제 화면/관리자 템플릿 관리에서 어색하게 남은 `게임` 문구가 없는지 한 번 더 QA한다.
|
||||||
- 내부 모델명은 아직 `game`을 유지하므로, 다음 단계에서는 문서와 보조 화면 문구를 더 정리할지, 아니면 내부 리네이밍 계획을 따로 잡을지 결정한다.
|
- 내부 모델명은 아직 `game`을 유지하므로, 다음 단계에서는 문서와 보조 화면 문구를 더 정리할지, 아니면 내부 리네이밍 계획을 따로 잡을지 결정한다.
|
||||||
- 게임 목록과 티어표 카드 썸네일은 기본 이미지 드래그를 막았으므로, 데스크톱 브라우저에서 클릭/드래그 시 원본 이미지 프리뷰가 더 이상 뜨지 않는지 한 번 더 QA한다.
|
- 주제 목록과 티어표 카드 썸네일은 기본 이미지 드래그를 막았으므로, 데스크톱 브라우저에서 클릭/드래그 시 원본 이미지 프리뷰가 더 이상 뜨지 않는지 한 번 더 QA한다.
|
||||||
- 왼쪽 레일 활성 배경은 공용 인디케이터가 이동하는 방식으로 바뀌었으므로, 홈/내 티어표/즐겨찾기/설정 전환과 레일 접힘 상태 양쪽에서 위치 보정이 자연스러운지 한 번 더 QA한다.
|
- 왼쪽 레일 활성 배경은 공용 인디케이터가 이동하는 방식으로 바뀌었으므로, 홈/내 티어표/즐겨찾기/설정 전환과 레일 접힘 상태 양쪽에서 위치 보정이 자연스러운지 한 번 더 QA한다.
|
||||||
- 로그인 화면 상단 토글은 이동형 인디케이터로 바뀌었으므로, 데스크톱과 모바일에서 `로그인 / 회원가입` 전환 애니메이션이 어색하지 않고 포커스/클릭 상태도 자연스러운지 한 번 더 QA한다.
|
- 로그인 화면 상단 토글은 이동형 인디케이터로 바뀌었으므로, 데스크톱과 모바일에서 `로그인 / 회원가입` 전환 애니메이션이 어색하지 않고 포커스/클릭 상태도 자연스러운지 한 번 더 QA한다.
|
||||||
- 관리자 카드 설명 줄임은 `line-clamp` 표준 속성까지 함께 선언했으므로, 실제 브라우저별 표시 차이가 없는지 한 번 더 QA한다.
|
- 관리자 카드 설명 줄임은 `line-clamp` 표준 속성까지 함께 선언했으므로, 실제 브라우저별 표시 차이가 없는지 한 번 더 QA한다.
|
||||||
@@ -21,21 +37,21 @@
|
|||||||
- 관리자 템플릿 요청 카드는 썸네일 클릭이 새창 열기 역할로 바뀌었으므로, 썸네일 클릭과 `확인하기` 액션이 서로 헷갈리지 않는지 한 번 더 QA한다.
|
- 관리자 템플릿 요청 카드는 썸네일 클릭이 새창 열기 역할로 바뀌었으므로, 썸네일 클릭과 `확인하기` 액션이 서로 헷갈리지 않는지 한 번 더 QA한다.
|
||||||
- 티어표 만들기 화면의 `공유하기`는 저장된 티어표에서만 노출되므로, 저장 직후/수정 중/복사본/읽기 전용 상태 각각에서 노출 조건과 클립보드 복사가 자연스러운지 한 번 더 QA한다.
|
- 티어표 만들기 화면의 `공유하기`는 저장된 티어표에서만 노출되므로, 저장 직후/수정 중/복사본/읽기 전용 상태 각각에서 노출 조건과 클립보드 복사가 자연스러운지 한 번 더 QA한다.
|
||||||
- 우측 카피라이트는 이제 공통 오른쪽 레일 footer이므로, 관리자 화면뿐 아니라 홈/프로필 등 오른쪽 사이드가 보이는 화면에서도 같은 최하단 위치에 유지되는지 한 번 더 QA한다.
|
- 우측 카피라이트는 이제 공통 오른쪽 레일 footer이므로, 관리자 화면뿐 아니라 홈/프로필 등 오른쪽 사이드가 보이는 화면에서도 같은 최하단 위치에 유지되는지 한 번 더 QA한다.
|
||||||
- 왼쪽 레일 축소 상태의 하단 액션 아이콘은 홈과 게임 허브에서 서로 다른 아이콘을 쓰도록 나눴으므로, 실제로 두 문맥이 한눈에 구분되는지 한 번 더 QA한다.
|
- 왼쪽 레일 축소 상태의 하단 액션 아이콘은 홈과 주제 화면에서 서로 다른 아이콘을 쓰도록 나눴으므로, 실제로 두 문맥이 한눈에 구분되는지 한 번 더 QA한다.
|
||||||
- 왼쪽 레일 축소 상태 최하단의 `티어표 만들기` 아이콘 버튼은 새로 추가했으므로, 홈/게임 허브에서 실제로 같은 위치 감각으로 동작하는지 한 번 더 QA한다.
|
- 왼쪽 레일 축소 상태 최하단의 `티어표 만들기` 아이콘 버튼은 새로 추가했으므로, 홈/주제 화면에서 실제로 같은 위치 감각으로 동작하는지 한 번 더 QA한다.
|
||||||
- 관리자 우측 카피라이트 문구는 사이드바 내부 최하단으로 다시 옮겼으므로, 실제 관리자 화면에서 스크롤/창 크기 변화에도 계속 보이는지 한 번 더 QA한다.
|
- 관리자 우측 카피라이트 문구는 사이드바 내부 최하단으로 다시 옮겼으므로, 실제 관리자 화면에서 스크롤/창 크기 변화에도 계속 보이는지 한 번 더 QA한다.
|
||||||
- 왼쪽 레일 축소 상태는 아이콘 줄 높이를 50px 기준으로 통일했으므로, 실제 데스크톱에서 아바타/검색/메뉴 아이콘이 시각적으로 같은 리듬으로 보이는지 한 번 더 QA한다.
|
- 왼쪽 레일 축소 상태는 아이콘 줄 높이를 50px 기준으로 통일했으므로, 실제 데스크톱에서 아바타/검색/메뉴 아이콘이 시각적으로 같은 리듬으로 보이는지 한 번 더 QA한다.
|
||||||
- 왼쪽 레일 검색은 이제 항상 게임 검색으로 홈으로 이동하므로, 홈이 아닌 화면에서 사이드 검색 후 게임 목록 결과로 자연스럽게 이동하는지 한 번 더 QA한다.
|
- 왼쪽 레일 검색은 이제 항상 주제 템플릿 검색으로 홈으로 이동하므로, 홈이 아닌 화면에서 사이드 검색 후 주제 목록 결과로 자연스럽게 이동하는지 한 번 더 QA한다.
|
||||||
- 앱 왼쪽 사이드 레일은 접힘 상태 레이아웃을 다시 손봤으므로, 데스크톱에서 접기/펼치기 반복 시 아바타 영역 높이, 아이콘 중앙 정렬, 검색 버튼 간격, 네비게이션 히트 영역이 모두 자연스러운지 한 번 더 QA한다.
|
- 앱 왼쪽 사이드 레일은 접힘 상태 레이아웃을 다시 손봤으므로, 데스크톱에서 접기/펼치기 반복 시 아바타 영역 높이, 아이콘 중앙 정렬, 검색 버튼 간격, 네비게이션 히트 영역이 모두 자연스러운지 한 번 더 QA한다.
|
||||||
- 관리자 우측 사이드바 하단 카피라이트 링크는 새 창 외부 링크로 바꿨으므로, 실제 클릭 시 `zenn.town` 연결과 hover 대비가 자연스러운지 한 번 더 QA한다.
|
- 관리자 우측 사이드바 하단 카피라이트 링크는 새 창 외부 링크로 바꿨으므로, 실제 클릭 시 `zenn.town` 연결과 hover 대비가 자연스러운지 한 번 더 QA한다.
|
||||||
- 관리자 아이템 상세 모달은 공통 패딩 예외 처리를 다시 넣었으므로, 대형 상세 모달과 일반 게임 선택 모달이 각각 기대한 크기로 보이는지 한 번 더 QA한다.
|
- 관리자 아이템 상세 모달은 공통 패딩 예외 처리를 다시 넣었으므로, 대형 상세 모달과 일반 템플릿 선택 모달이 각각 기대한 크기로 보이는지 한 번 더 QA한다.
|
||||||
- 아이템 관리 모달의 공용 게임 선택기에서는 이미 연결된 게임이 비활성화되므로, 실제 운영 데이터에서 중복 연결 방지와 `이미 추가됨` 표시가 기대대로 읽히는지 한 번 더 QA한다.
|
- 아이템 관리 모달의 공용 템플릿 선택기에서는 이미 연결된 템플릿이 비활성화되므로, 실제 운영 데이터에서 중복 연결 방지와 `이미 추가됨` 표시가 기대대로 읽히는지 한 번 더 QA한다.
|
||||||
- 공용 게임 선택 모달을 아이템 관리 모달에도 붙였으므로, 관리자 `게임 관리 / 전체 티어표 관리 / 아이템 관리` 세 흐름에서 같은 검색/선택 UX가 자연스럽게 느껴지는지 한 번 더 QA한다.
|
- 공용 템플릿 선택 모달을 아이템 관리 모달에도 붙였으므로, 관리자 `템플릿 관리 / 전체 티어표 관리 / 아이템 관리` 세 흐름에서 같은 검색/선택 UX가 자연스럽게 느껴지는지 한 번 더 QA한다.
|
||||||
- 관리자 `/admin/games?gameId=...` 직접 진입과 새로고침에서 선택 게임이 정상 복원되고 콘솔 오류가 없는지 한 번 더 QA한다.
|
- 관리자 `/admin/games?gameId=...` 직접 진입과 새로고침에서 선택 템플릿이 정상 복원되고 콘솔 오류가 없는지 한 번 더 QA한다.
|
||||||
- 공용 `게임 선택` 검색 모달은 새로 붙였으므로, 게임 관리 선택/전체 티어표 관리 필터 양쪽에서 검색어 입력, 선택, 해제, 뒤로가기 흐름을 한 번 더 QA한다.
|
- 공용 `템플릿 선택` 검색 모달은 새로 붙였으므로, 템플릿 관리 선택/전체 티어표 관리 필터 양쪽에서 검색어 입력, 선택, 해제, 뒤로가기 흐름을 한 번 더 QA한다.
|
||||||
- 관리자 `전체 티어표 관리`의 게임 필터와 관리 모달은 새로 붙였으므로, 실제 운영 데이터에서 필터 전환 후 공개/비공개 집계, 제목 수정, 비공개 전환, 삭제 흐름이 자연스럽게 이어지는지 한 번 더 QA한다.
|
- 관리자 `전체 티어표 관리`의 템플릿 필터와 관리 모달은 새로 붙였으므로, 실제 운영 데이터에서 필터 전환 후 공개/비공개 집계, 제목 수정, 비공개 전환, 삭제 흐름이 자연스럽게 이어지는지 한 번 더 QA한다.
|
||||||
- 관리자 게임 관리 상단의 `전체 / 공개 / 비공개` 티어표 수치와 전체 티어표 관리 상단 집계가 실제 운영 데이터와 맞는지, 공개 전환 직후 숫자가 자연스럽게 갱신되는지 한 번 더 QA한다.
|
- 관리자 템플릿 관리 상단의 `전체 / 공개 / 비공개` 티어표 수치와 전체 티어표 관리 상단 집계가 실제 운영 데이터와 맞는지, 공개 전환 직후 숫자가 자연스럽게 갱신되는지 한 번 더 QA한다.
|
||||||
- 관리자 아이템 상세 모달은 왼쪽 선택 카드와 오른쪽 상세 본문으로 다시 정리했으므로, 데스크톱/모바일에서 긴 게임 목록과 긴 참조 목록이 각각 자연스럽게 스크롤되는지 한 번 더 QA한다.
|
- 관리자 아이템 상세 모달은 왼쪽 선택 카드와 오른쪽 상세 본문으로 다시 정리했으므로, 데스크톱/모바일에서 긴 템플릿 목록과 긴 참조 목록이 각각 자연스럽게 스크롤되는지 한 번 더 QA한다.
|
||||||
- 관리자 계정으로 `/admin/...`을 직접 새로고침했을 때 홈으로 튕기지 않고 그대로 유지되는지, 실제 세션이 살아 있는 브라우저와 만료된 브라우저 각각에서 한 번 더 QA한다.
|
- 관리자 계정으로 `/admin/...`을 직접 새로고침했을 때 홈으로 튕기지 않고 그대로 유지되는지, 실제 세션이 살아 있는 브라우저와 만료된 브라우저 각각에서 한 번 더 QA한다.
|
||||||
- 티어표 만들기 화면의 보드 드롭존은 점선/높이/중앙 정렬로 존재감이 커졌으므로, 데스크톱과 모바일에서 파일 선택 버튼과 안내 문구 밀도를 한 번 더 QA한다.
|
- 티어표 만들기 화면의 보드 드롭존은 점선/높이/중앙 정렬로 존재감이 커졌으므로, 데스크톱과 모바일에서 파일 선택 버튼과 안내 문구 밀도를 한 번 더 QA한다.
|
||||||
- 관리자 썸네일 드롭존과 기본 아이템 추가 드롭존은 에디터 드롭존과 시각 문법을 맞췄으므로, 라이트모드와 좁은 화면에서 아이콘 대비와 배경 밝기가 과하지 않은지 한 번 더 QA한다.
|
- 관리자 썸네일 드롭존과 기본 아이템 추가 드롭존은 에디터 드롭존과 시각 문법을 맞췄으므로, 라이트모드와 좁은 화면에서 아이콘 대비와 배경 밝기가 과하지 않은지 한 번 더 QA한다.
|
||||||
|
|||||||
@@ -1,5 +1,48 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 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.2
|
||||||
- 관리자 화면과 보조 모달에 남아 있던 사용자 노출 `게임` 문구를 추가로 걷어내고, `템플릿 / 주제` 기준 표현으로 더 통일했다.
|
- 관리자 화면과 보조 모달에 남아 있던 사용자 노출 `게임` 문구를 추가로 걷어내고, `템플릿 / 주제` 기준 표현으로 더 통일했다.
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
|
import { editorNewPath, favoritesPath, homePath, loginPath, mePath } from './lib/paths'
|
||||||
import { toApiUrl } from './lib/runtime'
|
import { toApiUrl } from './lib/runtime'
|
||||||
import { useToast } from './composables/useToast'
|
import { useToast } from './composables/useToast'
|
||||||
import iconDockToLeft from './assets/icons/dock_to_left.svg'
|
import iconDockToLeft from './assets/icons/dock_to_left.svg'
|
||||||
@@ -22,6 +23,7 @@ const router = useRouter()
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const { toasts, dismissToast } = useToast()
|
const { toasts, dismissToast } = useToast()
|
||||||
const RIGHT_RAIL_COPYRIGHT_URL = 'https://zenn.town/@murabito'
|
const RIGHT_RAIL_COPYRIGHT_URL = 'https://zenn.town/@murabito'
|
||||||
|
const currentTopicId = computed(() => route.params.topicId || route.params.gameId || '')
|
||||||
|
|
||||||
const leftRailCollapsed = ref(false)
|
const leftRailCollapsed = ref(false)
|
||||||
const rightRailOpen = ref(true)
|
const rightRailOpen = ref(true)
|
||||||
@@ -134,16 +136,16 @@ const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.le
|
|||||||
const isLightTheme = computed(() => themeMode.value === 'light')
|
const isLightTheme = computed(() => themeMode.value === 'light')
|
||||||
const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드'))
|
const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드'))
|
||||||
const showSettingsThemePanel = computed(() => false && route.name === 'profile')
|
const showSettingsThemePanel = computed(() => false && route.name === 'profile')
|
||||||
const showGameHubViewToggle = computed(() => route.name === 'gameHub')
|
const showTopicViewToggle = computed(() => route.name === 'topicHub')
|
||||||
const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||||
const leftBottomPrimaryAction = computed(() => {
|
const leftBottomPrimaryAction = computed(() => {
|
||||||
if (!authReady.value) return null
|
if (!authReady.value) return null
|
||||||
if (route.name === 'home' && auth.user) {
|
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') {
|
if (route.name === 'topicHub') {
|
||||||
const target = `/editor/${route.params.gameId}/new`
|
const target = editorNewPath(currentTopicId.value)
|
||||||
return { label: '새 티어표 만들기', to: auth.user ? target : `/login?redirect=${target}`, iconSrc: iconAddNotes }
|
return { label: '새 티어표 만들기', to: auth.user ? target : loginPath(target), iconSrc: iconAddNotes }
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
@@ -157,11 +159,11 @@ const routeMeta = computed(() => {
|
|||||||
contextText: auth.user ? '커스텀 티어표를 만들거나 원하는 주제를 바로 선택할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
|
contextText: auth.user ? '커스텀 티어표를 만들거나 원하는 주제를 바로 선택할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
|
||||||
actionLabel: auth.user ? '커스텀 티어표 만들기' : '로그인하러 가기',
|
actionLabel: auth.user ? '커스텀 티어표 만들기' : '로그인하러 가기',
|
||||||
action: () => {
|
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 {
|
return {
|
||||||
title: '주제 티어표',
|
title: '주제 티어표',
|
||||||
subtitle: '주제별 공개 티어표 탐색',
|
subtitle: '주제별 공개 티어표 탐색',
|
||||||
@@ -169,29 +171,29 @@ const routeMeta = computed(() => {
|
|||||||
contextText: auth.user ? '이 주제의 새 티어표를 만들거나 기존 공개 티어표를 확인할 수 있어요.' : '로그인 후 새 티어표를 만들 수 있어요.',
|
contextText: auth.user ? '이 주제의 새 티어표를 만들거나 기존 공개 티어표를 확인할 수 있어요.' : '로그인 후 새 티어표를 만들 수 있어요.',
|
||||||
actionLabel: auth.user ? '새 티어표 만들기' : '로그인하러 가기',
|
actionLabel: auth.user ? '새 티어표 만들기' : '로그인하러 가기',
|
||||||
action: () => {
|
action: () => {
|
||||||
const target = `/editor/${route.params.gameId}/new`
|
const target = editorNewPath(currentTopicId.value)
|
||||||
router.push(auth.user ? target : `/login?redirect=${target}`)
|
router.push(auth.user ? target : loginPath(target))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (route.name === 'editEditor' || route.name === 'newEditor') {
|
if (route.name === 'editEditor' || route.name === 'newEditor') {
|
||||||
return {
|
return {
|
||||||
title: 'Deck Builder',
|
title: '티어표 만들기',
|
||||||
subtitle: '티어표 편집 및 공유',
|
subtitle: '티어표 편집 및 공유',
|
||||||
contextTitle: '편집 패널',
|
contextTitle: '편집 패널',
|
||||||
contextText: '현재 편집 옵션은 중앙 화면 안에 유지되어 있습니다. 다음 단계에서 우측 패널로 정리해갈게요.',
|
contextText: '현재 편집 옵션은 중앙 화면 안에 유지되어 있습니다. 다음 단계에서 우측 패널로 정리해갈게요.',
|
||||||
actionLabel: '주제 목록으로',
|
actionLabel: '주제 목록으로',
|
||||||
action: () => router.push('/'),
|
action: () => router.push(homePath()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isAdminRoute.value) {
|
if (isAdminRoute.value) {
|
||||||
return {
|
return {
|
||||||
title: 'Admin Workspace',
|
title: '관리자 작업실',
|
||||||
subtitle: '템플릿·아이템·회원 관리',
|
subtitle: '템플릿·아이템·회원 관리',
|
||||||
contextTitle: '운영 노트',
|
contextTitle: '운영 노트',
|
||||||
contextText: '관리자 화면은 기능이 많아 우선 공통 셸 톤을 맞췄고, 세부 패널은 다음 단계에서 시안 방식으로 더 세밀하게 나눌 예정입니다.',
|
contextText: '관리자 화면은 기능이 많아 우선 공통 셸 톤을 맞췄고, 세부 패널은 다음 단계에서 시안 방식으로 더 세밀하게 나눌 예정입니다.',
|
||||||
actionLabel: '주제 목록으로',
|
actionLabel: '주제 목록으로',
|
||||||
action: () => router.push('/'),
|
action: () => router.push(homePath()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (route.name === 'me') {
|
if (route.name === 'me') {
|
||||||
@@ -201,7 +203,7 @@ const routeMeta = computed(() => {
|
|||||||
contextTitle: '작성 이력',
|
contextTitle: '작성 이력',
|
||||||
contextText: '최근 저장한 티어표를 열람하고 정리할 수 있어요.',
|
contextText: '최근 저장한 티어표를 열람하고 정리할 수 있어요.',
|
||||||
actionLabel: '즐겨찾기 보기',
|
actionLabel: '즐겨찾기 보기',
|
||||||
action: () => router.push('/favorites'),
|
action: () => router.push(favoritesPath()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (route.name === 'favorites') {
|
if (route.name === 'favorites') {
|
||||||
@@ -211,7 +213,7 @@ const routeMeta = computed(() => {
|
|||||||
contextTitle: '정리 도구',
|
contextTitle: '정리 도구',
|
||||||
contextText: '정렬과 검색으로 원하는 티어표를 빠르게 다시 찾을 수 있어요.',
|
contextText: '정렬과 검색으로 원하는 티어표를 빠르게 다시 찾을 수 있어요.',
|
||||||
actionLabel: '나의 티어표 보기',
|
actionLabel: '나의 티어표 보기',
|
||||||
action: () => router.push('/me'),
|
action: () => router.push(mePath()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (route.name === 'profile') {
|
if (route.name === 'profile') {
|
||||||
@@ -221,7 +223,7 @@ const routeMeta = computed(() => {
|
|||||||
contextTitle: '계정 관리',
|
contextTitle: '계정 관리',
|
||||||
contextText: '아바타와 닉네임을 관리하고 표시 정보를 확인할 수 있어요.',
|
contextText: '아바타와 닉네임을 관리하고 표시 정보를 확인할 수 있어요.',
|
||||||
actionLabel: '나의 티어표 보기',
|
actionLabel: '나의 티어표 보기',
|
||||||
action: () => router.push('/me'),
|
action: () => router.push(mePath()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (route.name === 'search') {
|
if (route.name === 'search') {
|
||||||
@@ -231,16 +233,16 @@ const routeMeta = computed(() => {
|
|||||||
contextTitle: '검색',
|
contextTitle: '검색',
|
||||||
contextText: '제목, 작성자 기준으로 공개 티어표를 전체 검색할 수 있어요.',
|
contextText: '제목, 작성자 기준으로 공개 티어표를 전체 검색할 수 있어요.',
|
||||||
actionLabel: '홈으로',
|
actionLabel: '홈으로',
|
||||||
action: () => router.push('/'),
|
action: () => router.push(homePath()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: 'Tier Maker',
|
title: 'Tier Maker',
|
||||||
subtitle: '주제 템플릿으로 만드는 티어표',
|
subtitle: '주제 템플릿으로 만드는 티어표',
|
||||||
contextTitle: 'Workspace',
|
contextTitle: '작업 공간',
|
||||||
contextText: '현재 화면에 맞는 도구와 안내를 여기에 배치할 수 있습니다.',
|
contextText: '현재 화면에 맞는 도구와 안내를 여기에 배치할 수 있습니다.',
|
||||||
actionLabel: '홈으로',
|
actionLabel: '홈으로',
|
||||||
action: () => router.push('/'),
|
action: () => router.push(homePath()),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -344,8 +346,8 @@ function toggleRightRail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setGameHubViewMode(mode) {
|
function setTopicViewMode(mode) {
|
||||||
if (route.name !== 'gameHub') return
|
if (route.name !== 'topicHub') return
|
||||||
const nextQuery = { ...route.query }
|
const nextQuery = { ...route.query }
|
||||||
if (mode === 'list') nextQuery.view = 'list'
|
if (mode === 'list') nextQuery.view = 'list'
|
||||||
else delete nextQuery.view
|
else delete nextQuery.view
|
||||||
@@ -395,7 +397,7 @@ function handleLeftRailSearch() {
|
|||||||
function submitGlobalSearch() {
|
function submitGlobalSearch() {
|
||||||
const query = (searchQuery.value || '').trim()
|
const query = (searchQuery.value || '').trim()
|
||||||
isCollapsedSearchOpen.value = false
|
isCollapsedSearchOpen.value = false
|
||||||
router.push(query ? `/?q=${encodeURIComponent(query)}` : '/')
|
router.push(homePath(query))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -499,11 +501,11 @@ function submitGlobalSearch() {
|
|||||||
<span class="workspaceHead__brandTitle">Tier Maker</span>
|
<span class="workspaceHead__brandTitle">Tier Maker</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="workspaceHead__actions">
|
<div class="workspaceHead__actions">
|
||||||
<div v-if="showGameHubViewToggle" class="viewToggle" role="group" aria-label="티어표 보기 방식">
|
<div v-if="showTopicViewToggle" class="viewToggle" role="group" aria-label="티어표 보기 방식">
|
||||||
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': gameHubViewMode === 'grid' }" type="button" aria-label="그리드 보기" @click="setGameHubViewMode('grid')">
|
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': topicViewMode === 'grid' }" type="button" aria-label="그리드 보기" @click="setTopicViewMode('grid')">
|
||||||
<SvgIcon :src="iconGridView" :size="24" />
|
<SvgIcon :src="iconGridView" :size="24" />
|
||||||
</button>
|
</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" />
|
<SvgIcon :src="iconLists" :size="24" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
featuredGames: { type: Array, required: true },
|
featuredTemplates: { type: Array, required: true },
|
||||||
availableGamesForFeatured: { type: Array, required: true },
|
availableTemplatesForFeatured: { type: Array, required: true },
|
||||||
featuredGameIds: { type: Array, required: true },
|
featuredTemplateIds: { type: Array, required: true },
|
||||||
featuredListRef: { type: Function, required: true },
|
featuredListRef: { type: Function, required: true },
|
||||||
saveFeaturedOrder: { type: Function, required: true },
|
saveFeaturedOrder: { type: Function, required: true },
|
||||||
moveFeaturedGame: { type: Function, required: true },
|
moveFeaturedTemplate: { type: Function, required: true },
|
||||||
removeFeaturedGame: { type: Function, required: true },
|
removeFeaturedTemplate: { type: Function, required: true },
|
||||||
addFeaturedGame: { type: Function, required: true },
|
addFeaturedTemplate: { type: Function, required: true },
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -24,21 +24,21 @@ const props = defineProps({
|
|||||||
<div class="featuredOrderPanel">
|
<div class="featuredOrderPanel">
|
||||||
<div class="featuredOrderPanel__list">
|
<div class="featuredOrderPanel__list">
|
||||||
<div class="section__title">상단 고정 목록</div>
|
<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">
|
<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">
|
<div class="featuredCard__meta">
|
||||||
<span class="featuredCard__rank">{{ index + 1 }}</span>
|
<span class="featuredCard__rank">{{ index + 1 }}</span>
|
||||||
<div>
|
<div>
|
||||||
<div class="featuredCard__title">{{ game.name }}</div>
|
<div class="featuredCard__title">{{ template.name }}</div>
|
||||||
<div class="featuredCard__id">{{ game.id }}</div>
|
<div class="featuredCard__id">{{ template.id }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="featuredCard__actions">
|
<div class="featuredCard__actions">
|
||||||
<button class="btn btn--ghost btn--small" data-featured-handle>드래그</button>
|
<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 === 0" @click="props.moveFeaturedTemplate(template.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--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.removeFeaturedGame(game.id)">제외</button>
|
<button class="btn btn--danger btn--small" @click="props.removeFeaturedTemplate(template.id)">제외</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,14 +48,14 @@ const props = defineProps({
|
|||||||
<div class="section__title">템플릿 추가</div>
|
<div class="section__title">템플릿 추가</div>
|
||||||
<div class="featuredPickerList">
|
<div class="featuredPickerList">
|
||||||
<button
|
<button
|
||||||
v-for="game in props.availableGamesForFeatured"
|
v-for="template in props.availableTemplatesForFeatured"
|
||||||
:key="game.id"
|
:key="template.id"
|
||||||
class="featuredPickerItem"
|
class="featuredPickerItem"
|
||||||
:disabled="props.featuredGameIds.length >= 50"
|
:disabled="props.featuredTemplateIds.length >= 50"
|
||||||
@click="props.addFeaturedGame(game.id)"
|
@click="props.addFeaturedTemplate(template.id)"
|
||||||
>
|
>
|
||||||
<span>{{ game.name }}</span>
|
<span>{{ template.name }}</span>
|
||||||
<span class="featuredPickerItem__id">{{ game.id }}</span>
|
<span class="featuredPickerItem__id">{{ template.id }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ const props = defineProps({
|
|||||||
templateRequestSourceUrl: { type: Function, required: true },
|
templateRequestSourceUrl: { type: Function, required: true },
|
||||||
stagedRequestDraftCount: { type: Number, required: true },
|
stagedRequestDraftCount: { type: Number, required: true },
|
||||||
appliedRequestItemCount: { type: Number, required: true },
|
appliedRequestItemCount: { type: Number, required: true },
|
||||||
openGameCreateModal: { type: Function, required: true },
|
openTemplateCreateModal: { type: Function, required: true },
|
||||||
isGameLoading: { type: Boolean, required: true },
|
isGameLoading: { type: Boolean, required: true },
|
||||||
hasSelectedGame: { type: Boolean, required: true },
|
hasSelectedTemplate: { type: Boolean, required: true },
|
||||||
selectedGame: { type: Object, default: null },
|
selectedTemplate: { type: Object, default: null },
|
||||||
displayThumbnailUrl: { type: String, default: '' },
|
displayThumbnailUrl: { type: String, default: '' },
|
||||||
canApplyThumbnail: { type: Boolean, required: true },
|
canApplyThumbnail: { type: Boolean, required: true },
|
||||||
gameVisibilitySaving: { type: Boolean, required: true },
|
gameVisibilitySaving: { type: Boolean, required: true },
|
||||||
@@ -24,8 +24,8 @@ const props = defineProps({
|
|||||||
onThumbDrop: { type: Function, required: true },
|
onThumbDrop: { type: Function, required: true },
|
||||||
isThumbDragOver: { type: Boolean, required: true },
|
isThumbDragOver: { type: Boolean, required: true },
|
||||||
uploadThumbnail: { type: Function, required: true },
|
uploadThumbnail: { type: Function, required: true },
|
||||||
removeGame: { type: Function, required: true },
|
removeTemplate: { type: Function, required: true },
|
||||||
toggleSelectedGameVisibility: { type: Function, required: true },
|
toggleSelectedTemplateVisibility: { type: Function, required: true },
|
||||||
itemFileInputRef: { type: Function, required: true },
|
itemFileInputRef: { type: Function, required: true },
|
||||||
onFile: { type: Function, required: true },
|
onFile: { type: Function, required: true },
|
||||||
isItemDragOver: { type: Boolean, required: true },
|
isItemDragOver: { type: Boolean, required: true },
|
||||||
@@ -39,12 +39,12 @@ const props = defineProps({
|
|||||||
canAddItem: { type: Boolean, required: true },
|
canAddItem: { type: Boolean, required: true },
|
||||||
uploadItem: { type: Function, required: true },
|
uploadItem: { type: Function, required: true },
|
||||||
removeUploadDraft: { type: Function, required: true },
|
removeUploadDraft: { type: Function, required: true },
|
||||||
hasGameItemOrderChanges: { type: Boolean, required: true },
|
hasTemplateItemOrderChanges: { type: Boolean, required: true },
|
||||||
saveGameItemOrder: { type: Function, required: true },
|
saveTemplateItemOrder: { type: Function, required: true },
|
||||||
gameItemListRef: { type: Function, required: true },
|
gameItemListRef: { type: Function, required: true },
|
||||||
saveGameItemLabel: { type: Function, required: true },
|
saveTemplateItemLabel: { type: Function, required: true },
|
||||||
removeGameItem: { type: Function, required: true },
|
removeTemplateItem: { type: Function, required: true },
|
||||||
selectedGameId: { type: String, default: '' },
|
selectedTemplateId: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
function setGameItemListElement(el) {
|
function setGameItemListElement(el) {
|
||||||
@@ -95,7 +95,7 @@ function setThumbFileElement(el) {
|
|||||||
v-if="props.activeTemplateRequest.type === 'create' && !props.activeTemplateRequest.targetGameId"
|
v-if="props.activeTemplateRequest.type === 'create' && !props.activeTemplateRequest.targetGameId"
|
||||||
class="btn btn--ghost btn--small"
|
class="btn btn--ghost btn--small"
|
||||||
type="button"
|
type="button"
|
||||||
@click="props.openGameCreateModal"
|
@click="props.openTemplateCreateModal"
|
||||||
>
|
>
|
||||||
새 템플릿 만들기
|
새 템플릿 만들기
|
||||||
</button>
|
</button>
|
||||||
@@ -108,7 +108,7 @@ function setThumbFileElement(el) {
|
|||||||
<div class="emptyState__desc">선택한 템플릿의 썸네일과 기본 아이템을 곧 표시합니다.</div>
|
<div class="emptyState__desc">선택한 템플릿의 썸네일과 기본 아이템을 곧 표시합니다.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="props.hasSelectedGame" class="panel">
|
<div v-else-if="props.hasSelectedTemplate" class="panel">
|
||||||
<section class="adminCard gameSettingsCard">
|
<section class="adminCard gameSettingsCard">
|
||||||
<div class="gameSettingsCard__media">
|
<div class="gameSettingsCard__media">
|
||||||
<input :ref="setThumbFileElement" type="file" accept="image/*" class="srOnlyInput" @change="props.onThumb" />
|
<input :ref="setThumbFileElement" type="file" accept="image/*" class="srOnlyInput" @change="props.onThumb" />
|
||||||
@@ -122,7 +122,7 @@ function setThumbFileElement(el) {
|
|||||||
@dragleave="props.onThumbDragLeave"
|
@dragleave="props.onThumbDragLeave"
|
||||||
@drop="props.onThumbDrop"
|
@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 v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
|
||||||
<div class="thumbDropZone__copy">
|
<div class="thumbDropZone__copy">
|
||||||
<div class="thumbDropZone__iconWrap">
|
<div class="thumbDropZone__iconWrap">
|
||||||
@@ -134,15 +134,15 @@ function setThumbFileElement(el) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="gameSettingsCard__body">
|
<div class="gameSettingsCard__body">
|
||||||
<div class="panel__title">템플릿 설정</div>
|
<div class="panel__title">템플릿 설정</div>
|
||||||
<div class="gameSettingsCard__meta">{{ props.selectedGame.game.name }} · {{ props.selectedGame.game.id }}</div>
|
<div class="gameSettingsCard__meta">{{ props.selectedTemplate.game.name }} · {{ props.selectedTemplate.game.id }}</div>
|
||||||
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.gameVisibilitySaving }">
|
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.gameVisibilitySaving }">
|
||||||
<input :checked="!!props.selectedGame.game.isPublic" type="checkbox" @change="props.toggleSelectedGameVisibility($event.target.checked)" />
|
<input :checked="!!props.selectedTemplate.game.isPublic" type="checkbox" @change="props.toggleSelectedTemplateVisibility($event.target.checked)" />
|
||||||
<span class="toggleSwitch__label">{{ props.selectedGame.game.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
|
<span class="toggleSwitch__label">{{ props.selectedTemplate.game.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
|
||||||
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
||||||
</label>
|
</label>
|
||||||
<div class="gameSettingsCard__actions">
|
<div class="gameSettingsCard__actions">
|
||||||
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -212,11 +212,11 @@ function setThumbFileElement(el) {
|
|||||||
<div class="section__title">현재 기본 아이템 목록</div>
|
<div class="section__title">현재 기본 아이템 목록</div>
|
||||||
<div class="hint hint--tight">드래그해서 기본 노출 순서를 바꿀 수 있어요. 저장 전까지는 화면에서만 순서가 바뀝니다.</div>
|
<div class="hint hint--tight">드래그해서 기본 노출 순서를 바꿀 수 있어요. 저장 전까지는 화면에서만 순서가 바뀝니다.</div>
|
||||||
</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>
|
||||||
<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-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" />
|
<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 />
|
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag />
|
||||||
<div class="thumbCard__actions">
|
<div class="thumbCard__actions">
|
||||||
@@ -224,11 +224,11 @@ function setThumbFileElement(el) {
|
|||||||
class="btn btn--ghost btn--small"
|
class="btn btn--ghost btn--small"
|
||||||
data-no-drag
|
data-no-drag
|
||||||
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || item.draftLabel.trim() === item.label"
|
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || item.draftLabel.trim() === item.label"
|
||||||
@click="props.saveGameItemLabel(item)"
|
@click="props.saveTemplateItemLabel(item)"
|
||||||
>
|
>
|
||||||
{{ item.isSavingLabel ? '저장중...' : '이름 저장' }}
|
{{ item.isSavingLabel ? '저장중...' : '이름 저장' }}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,7 +238,7 @@ function setThumbFileElement(el) {
|
|||||||
<div class="emptyState">
|
<div class="emptyState">
|
||||||
<div class="emptyState__title">템플릿을 선택해 주세요.</div>
|
<div class="emptyState__title">템플릿을 선택해 주세요.</div>
|
||||||
<div v-if="props.activeTemplateRequest?.type === 'create'" class="hint hint--tight">진행 중인 신규 템플릿 요청이 있어요. 위의 `새 템플릿 만들기`로 템플릿을 만든 뒤 아이템을 추가할 수 있습니다.</div>
|
<div v-if="props.activeTemplateRequest?.type === 'create'" class="hint hint--tight">진행 중인 신규 템플릿 요청이 있어요. 위의 `새 템플릿 만들기`로 템플릿을 만든 뒤 아이템을 추가할 수 있습니다.</div>
|
||||||
<div v-if="props.selectedGameId" class="hint hint--tight">선택한 템플릿을 찾지 못했거나 로딩 중 오류가 발생했어요. 다시 선택해보세요.</div>
|
<div v-if="props.selectedTemplateId" class="hint hint--tight">선택한 템플릿을 찾지 못했거나 로딩 중 오류가 발생했어요. 다시 선택해보세요.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ export function useAdminCustomItems({
|
|||||||
modalTargetCustomItem,
|
modalTargetCustomItem,
|
||||||
customItemModalDraftLabel,
|
customItemModalDraftLabel,
|
||||||
customItemModalLabelSaving,
|
customItemModalLabelSaving,
|
||||||
customItemModalTargetGameId,
|
customItemModalTargetTemplateId,
|
||||||
games,
|
templates,
|
||||||
selectedGameId,
|
selectedTemplateId,
|
||||||
refreshCustomItems,
|
refreshCustomItems,
|
||||||
loadGame,
|
loadTemplate,
|
||||||
setTab,
|
setTab,
|
||||||
selectAdminGame,
|
selectAdminTemplate,
|
||||||
resetMessages,
|
resetMessages,
|
||||||
success,
|
success,
|
||||||
error,
|
error,
|
||||||
@@ -59,7 +59,7 @@ export function useAdminCustomItems({
|
|||||||
function openCustomItemModal(item) {
|
function openCustomItemModal(item) {
|
||||||
modalTargetCustomItem.value = item || null
|
modalTargetCustomItem.value = item || null
|
||||||
customItemModalDraftLabel.value = item?.label || ''
|
customItemModalDraftLabel.value = item?.label || ''
|
||||||
customItemModalTargetGameId.value = ''
|
customItemModalTargetTemplateId.value = ''
|
||||||
customItemModalOpen.value = true
|
customItemModalOpen.value = true
|
||||||
pushCustomItemModalHistoryState()
|
pushCustomItemModalHistoryState()
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ export function useAdminCustomItems({
|
|||||||
modalTargetCustomItem.value = null
|
modalTargetCustomItem.value = null
|
||||||
customItemModalDraftLabel.value = ''
|
customItemModalDraftLabel.value = ''
|
||||||
customItemModalLabelSaving.value = false
|
customItemModalLabelSaving.value = false
|
||||||
customItemModalTargetGameId.value = ''
|
customItemModalTargetTemplateId.value = ''
|
||||||
|
|
||||||
if (fromPopState) {
|
if (fromPopState) {
|
||||||
customItemModalHistoryActive.value = false
|
customItemModalHistoryActive.value = false
|
||||||
@@ -97,12 +97,12 @@ export function useAdminCustomItems({
|
|||||||
customItemDeleteModalOpen.value = false
|
customItemDeleteModalOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function jumpToGameAdmin(gameId) {
|
function jumpToTemplateAdmin(templateId) {
|
||||||
if (!gameId) return
|
if (!templateId) return
|
||||||
closeCustomItemModal()
|
closeCustomItemModal()
|
||||||
setTab('game-admin')
|
setTab('game-admin')
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
selectAdminGame(gameId)
|
selectAdminTemplate(templateId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,18 +160,19 @@ export function useAdminCustomItems({
|
|||||||
|
|
||||||
async function promoteCustomItem(item) {
|
async function promoteCustomItem(item) {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
if (!customItemModalTargetGameId.value) {
|
if (!customItemModalTargetTemplateId.value) {
|
||||||
error.value = '추가할 게임을 먼저 선택해주세요.'
|
error.value = '추가할 템플릿을 먼저 선택해주세요.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
item.isPromoting = true
|
item.isPromoting = true
|
||||||
await api.promoteAdminCustomItem(item.id, { gameId: customItemModalTargetGameId.value })
|
await api.promoteAdminTemplateItem(item.id, { gameId: customItemModalTargetTemplateId.value })
|
||||||
const targetGameName = games.value.find((game) => game.id === customItemModalTargetGameId.value)?.name || customItemModalTargetGameId.value
|
const targetTemplateName =
|
||||||
if (selectedGameId.value === customItemModalTargetGameId.value) await loadGame()
|
templates.value.find((template) => template.id === customItemModalTargetTemplateId.value)?.name || customItemModalTargetTemplateId.value
|
||||||
|
if (selectedTemplateId.value === customItemModalTargetTemplateId.value) await loadTemplate()
|
||||||
closeCustomItemModal()
|
closeCustomItemModal()
|
||||||
success.value = `"${item.label}" 이미지를 ${targetGameName} 템플릿으로 추가했어요.`
|
success.value = `"${item.label}" 이미지를 ${targetTemplateName} 템플릿으로 추가했어요.`
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '선택한 이미지를 템플릿으로 추가하지 못했어요.'
|
error.value = '선택한 이미지를 템플릿으로 추가하지 못했어요.'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -189,7 +190,7 @@ export function useAdminCustomItems({
|
|||||||
closeCustomItemModal,
|
closeCustomItemModal,
|
||||||
openCustomItemDeleteModal,
|
openCustomItemDeleteModal,
|
||||||
closeCustomItemDeleteModal,
|
closeCustomItemDeleteModal,
|
||||||
jumpToGameAdmin,
|
jumpToTemplateAdmin,
|
||||||
removeCustomItem,
|
removeCustomItem,
|
||||||
removeUnusedCustomItems,
|
removeUnusedCustomItems,
|
||||||
saveCustomItemModalLabel,
|
saveCustomItemModalLabel,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ export function useAdminFeaturedGames({
|
|||||||
api,
|
api,
|
||||||
featuredListEl,
|
featuredListEl,
|
||||||
featuredSortable,
|
featuredSortable,
|
||||||
featuredGameIds,
|
featuredTemplateIds,
|
||||||
games,
|
templates,
|
||||||
resetMessages,
|
resetMessages,
|
||||||
success,
|
success,
|
||||||
error,
|
error,
|
||||||
@@ -31,63 +31,63 @@ export function useAdminFeaturedGames({
|
|||||||
chosenClass: 'chosen',
|
chosenClass: 'chosen',
|
||||||
onEnd: (evt) => {
|
onEnd: (evt) => {
|
||||||
if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
|
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)
|
const [moved] = nextIds.splice(evt.oldIndex, 1)
|
||||||
nextIds.splice(evt.newIndex, 0, moved)
|
nextIds.splice(evt.newIndex, 0, moved)
|
||||||
featuredGameIds.value = nextIds
|
featuredTemplateIds.value = nextIds
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function addFeaturedGame(gameId) {
|
function addFeaturedTemplate(templateId) {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
if (!gameId || featuredGameIds.value.includes(gameId)) return
|
if (!templateId || featuredTemplateIds.value.includes(templateId)) return
|
||||||
if (featuredGameIds.value.length >= 50) {
|
if (featuredTemplateIds.value.length >= 50) {
|
||||||
error.value = '상단 고정 게임은 최대 50개까지만 설정할 수 있어요.'
|
error.value = '상단 고정 템플릿은 최대 50개까지만 설정할 수 있어요.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
featuredGameIds.value = [...featuredGameIds.value, gameId]
|
featuredTemplateIds.value = [...featuredTemplateIds.value, templateId]
|
||||||
syncFeaturedSortable()
|
syncFeaturedSortable()
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFeaturedGame(gameId) {
|
function removeFeaturedTemplate(templateId) {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
featuredGameIds.value = featuredGameIds.value.filter((id) => id !== gameId)
|
featuredTemplateIds.value = featuredTemplateIds.value.filter((id) => id !== templateId)
|
||||||
syncFeaturedSortable()
|
syncFeaturedSortable()
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveFeaturedGame(gameId, direction) {
|
function moveFeaturedTemplate(templateId, direction) {
|
||||||
const currentIndex = featuredGameIds.value.indexOf(gameId)
|
const currentIndex = featuredTemplateIds.value.indexOf(templateId)
|
||||||
const nextIndex = currentIndex + direction
|
const nextIndex = currentIndex + direction
|
||||||
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= featuredGameIds.value.length) return
|
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= featuredTemplateIds.value.length) return
|
||||||
const nextIds = [...featuredGameIds.value]
|
const nextIds = [...featuredTemplateIds.value]
|
||||||
const [moved] = nextIds.splice(currentIndex, 1)
|
const [moved] = nextIds.splice(currentIndex, 1)
|
||||||
nextIds.splice(nextIndex, 0, moved)
|
nextIds.splice(nextIndex, 0, moved)
|
||||||
featuredGameIds.value = nextIds
|
featuredTemplateIds.value = nextIds
|
||||||
syncFeaturedSortable()
|
syncFeaturedSortable()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveFeaturedOrder() {
|
async function saveFeaturedOrder() {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
try {
|
try {
|
||||||
const data = await api.updateAdminGameDisplayOrder({ gameIds: featuredGameIds.value })
|
const data = await api.updateAdminTemplateDisplayOrder({ gameIds: featuredTemplateIds.value })
|
||||||
games.value = data.games || []
|
templates.value = data.games || []
|
||||||
featuredGameIds.value = games.value
|
featuredTemplateIds.value = templates.value
|
||||||
.filter((game) => game.displayRank != null)
|
.filter((template) => template.displayRank != null)
|
||||||
.sort((a, b) => a.displayRank - b.displayRank)
|
.sort((a, b) => a.displayRank - b.displayRank)
|
||||||
.map((game) => game.id)
|
.map((template) => template.id)
|
||||||
success.value = '홈 화면 게임 순서를 저장했어요.'
|
success.value = '홈 화면 템플릿 순서를 저장했어요.'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '게임 순서 저장에 실패했어요.'
|
error.value = '템플릿 순서 저장에 실패했어요.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
destroyFeaturedSortable,
|
destroyFeaturedSortable,
|
||||||
syncFeaturedSortable,
|
syncFeaturedSortable,
|
||||||
addFeaturedGame,
|
addFeaturedTemplate,
|
||||||
removeFeaturedGame,
|
removeFeaturedTemplate,
|
||||||
moveFeaturedGame,
|
moveFeaturedTemplate,
|
||||||
saveFeaturedOrder,
|
saveFeaturedOrder,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import Sortable from 'sortablejs'
|
|||||||
export function useAdminGameManager({
|
export function useAdminGameManager({
|
||||||
api,
|
api,
|
||||||
toApiUrl,
|
toApiUrl,
|
||||||
selectedGameId,
|
selectedTemplateId,
|
||||||
selectedGame,
|
selectedTemplate,
|
||||||
uploadFiles,
|
uploadFiles,
|
||||||
uploadItemDrafts,
|
uploadItemDrafts,
|
||||||
thumbFile,
|
thumbFile,
|
||||||
@@ -18,15 +18,15 @@ export function useAdminGameManager({
|
|||||||
activeTemplateRequest,
|
activeTemplateRequest,
|
||||||
templateRequests,
|
templateRequests,
|
||||||
customItemModalOpen,
|
customItemModalOpen,
|
||||||
customItemModalTargetGameId,
|
customItemModalTargetTemplateId,
|
||||||
newGameId,
|
newTemplateId,
|
||||||
newGameName,
|
newTemplateName,
|
||||||
newGameIsPublic,
|
newTemplateIsPublic,
|
||||||
clearPreviewUrl,
|
clearPreviewUrl,
|
||||||
resetFileInput,
|
resetFileInput,
|
||||||
resetUploadState,
|
resetUploadState,
|
||||||
refreshGames,
|
refreshTemplates,
|
||||||
closeGameCreateModal,
|
closeTemplateCreateModal,
|
||||||
resetMessages,
|
resetMessages,
|
||||||
success,
|
success,
|
||||||
error,
|
error,
|
||||||
@@ -59,7 +59,7 @@ export function useAdminGameManager({
|
|||||||
async function syncGameItemSortable() {
|
async function syncGameItemSortable() {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
destroyGameItemSortable()
|
destroyGameItemSortable()
|
||||||
if (!gameItemListEl.value || !selectedGame.value?.items?.length) return
|
if (!gameItemListEl.value || !selectedTemplate.value?.items?.length) return
|
||||||
|
|
||||||
gameItemSortable.value = Sortable.create(gameItemListEl.value, {
|
gameItemSortable.value = Sortable.create(gameItemListEl.value, {
|
||||||
animation: 160,
|
animation: 160,
|
||||||
@@ -73,11 +73,11 @@ export function useAdminGameManager({
|
|||||||
chosenClass: 'chosen',
|
chosenClass: 'chosen',
|
||||||
onEnd: (evt) => {
|
onEnd: (evt) => {
|
||||||
if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
|
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)
|
const [moved] = nextItems.splice(evt.oldIndex, 1)
|
||||||
nextItems.splice(evt.newIndex, 0, moved)
|
nextItems.splice(evt.newIndex, 0, moved)
|
||||||
selectedGame.value = {
|
selectedTemplate.value = {
|
||||||
...selectedGame.value,
|
...selectedTemplate.value,
|
||||||
items: nextItems,
|
items: nextItems,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -87,7 +87,7 @@ export function useAdminGameManager({
|
|||||||
function mergeRequestItemsIntoDrafts(request) {
|
function mergeRequestItemsIntoDrafts(request) {
|
||||||
const requestId = request?.id
|
const requestId = request?.id
|
||||||
if (!requestId) return
|
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 existingKeys = new Set(uploadItemDrafts.value.map((draft) => `${draft.kind}:${draft.requestId || ''}:${draft.itemId || draft.file?.name || ''}`))
|
||||||
const nextRequestDrafts = (request.items || [])
|
const nextRequestDrafts = (request.items || [])
|
||||||
.filter((item) => item?.src)
|
.filter((item) => item?.src)
|
||||||
@@ -100,7 +100,7 @@ export function useAdminGameManager({
|
|||||||
sourceName: requestItemFilename(item),
|
sourceName: requestItemFilename(item),
|
||||||
src: item.src,
|
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}`))
|
.filter((draft) => !existingKeys.has(`${draft.kind}:${draft.requestId}:${draft.itemId}`))
|
||||||
|
|
||||||
if (nextRequestDrafts.length) {
|
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)
|
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
|
const preserveUploadState = !!options.preserveUploadState
|
||||||
resetMessages()
|
resetMessages()
|
||||||
if (!preserveUploadState) resetUploadState()
|
if (!preserveUploadState) resetUploadState()
|
||||||
|
|
||||||
if (!selectedGameId.value) {
|
if (!selectedTemplateId.value) {
|
||||||
selectedGame.value = null
|
selectedTemplate.value = null
|
||||||
savedGameItemOrderIds.value = []
|
savedGameItemOrderIds.value = []
|
||||||
destroyGameItemSortable()
|
destroyGameItemSortable()
|
||||||
return
|
return
|
||||||
@@ -131,8 +131,8 @@ export function useAdminGameManager({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
isGameLoading.value = true
|
isGameLoading.value = true
|
||||||
const data = await api.getGame(selectedGameId.value)
|
const data = await api.getTopic(selectedTemplateId.value)
|
||||||
selectedGame.value = {
|
selectedTemplate.value = {
|
||||||
...data,
|
...data,
|
||||||
items: (data.items || []).map((item) => ({
|
items: (data.items || []).map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -142,27 +142,27 @@ export function useAdminGameManager({
|
|||||||
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
|
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
|
||||||
await syncGameItemSortable()
|
await syncGameItemSortable()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
selectedGame.value = null
|
selectedTemplate.value = null
|
||||||
error.value = '게임 정보를 불러오지 못했어요.'
|
error.value = '템플릿 정보를 불러오지 못했어요.'
|
||||||
} finally {
|
} finally {
|
||||||
isGameLoading.value = false
|
isGameLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createGame(options = {}) {
|
async function createTemplate(options = {}) {
|
||||||
const nextGameId = typeof options.gameId === 'string' ? options.gameId.trim() : newGameId.value.trim()
|
const nextGameId = typeof options.gameId === 'string' ? options.gameId.trim() : newTemplateId.value.trim()
|
||||||
const nextGameName = typeof options.gameName === 'string' ? options.gameName.trim() : newGameName.value.trim()
|
const nextGameName = typeof options.gameName === 'string' ? options.gameName.trim() : newTemplateName.value.trim()
|
||||||
const preserveUploadState = !!options.preserveUploadState
|
const preserveUploadState = !!options.preserveUploadState
|
||||||
resetMessages()
|
resetMessages()
|
||||||
try {
|
try {
|
||||||
const res = await fetch(toApiUrl('/api/admin/games'), {
|
const res = await fetch(toApiUrl('/api/admin/templates'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: nextGameId,
|
id: nextGameId,
|
||||||
name: nextGameName,
|
name: nextGameName,
|
||||||
isPublic: !!newGameIsPublic.value,
|
isPublic: !!newTemplateIsPublic.value,
|
||||||
thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '',
|
thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -187,18 +187,18 @@ export function useAdminGameManager({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await refreshGames()
|
await refreshTemplates()
|
||||||
selectedGameId.value = data.game.id
|
selectedTemplateId.value = data.game.id
|
||||||
if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id
|
if (customItemModalOpen.value) customItemModalTargetTemplateId.value = data.game.id
|
||||||
closeGameCreateModal()
|
closeTemplateCreateModal()
|
||||||
await loadGame({ preserveUploadState })
|
await loadTemplate({ preserveUploadState })
|
||||||
if (!preserveUploadState && activeTemplateRequest.value?.id) {
|
if (!preserveUploadState && activeTemplateRequest.value?.id) {
|
||||||
const sourceRequest = templateRequests.value.find((entry) => entry.id === activeTemplateRequest.value.id) || activeTemplateRequest.value
|
const sourceRequest = templateRequests.value.find((entry) => entry.id === activeTemplateRequest.value.id) || activeTemplateRequest.value
|
||||||
mergeRequestItemsIntoDrafts(sourceRequest)
|
mergeRequestItemsIntoDrafts(sourceRequest)
|
||||||
}
|
}
|
||||||
success.value = '게임이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
|
success.value = '템플릿이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '게임 생성 실패(관리자 권한/중복 ID 확인)'
|
error.value = '템플릿 생성 실패(관리자 권한/중복 ID 확인)'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,22 +254,22 @@ export function useAdminGameManager({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 draftGameId = (activeTemplateRequest.value?.draftGameId || '').trim()
|
||||||
const draftGameName = (activeTemplateRequest.value?.draftGameName || '').trim()
|
const draftGameName = (activeTemplateRequest.value?.draftGameName || '').trim()
|
||||||
if (!draftGameId || !draftGameName) {
|
if (!draftGameId || !draftGameName) {
|
||||||
error.value = '먼저 신규 템플릿의 게임 이름과 게임 ID를 저장해주세요.'
|
error.value = '먼저 신규 템플릿의 이름과 템플릿 ID를 저장해주세요.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await createGame({
|
await createTemplate({
|
||||||
gameId: draftGameId,
|
gameId: draftGameId,
|
||||||
gameName: draftGameName,
|
gameName: draftGameName,
|
||||||
preserveUploadState: true,
|
preserveUploadState: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedGameId.value) {
|
if (!selectedTemplateId.value) {
|
||||||
error.value = '게임을 먼저 선택해주세요.'
|
error.value = '템플릿을 먼저 선택해주세요.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +283,7 @@ export function useAdminGameManager({
|
|||||||
fd.append('images', entry.file)
|
fd.append('images', entry.file)
|
||||||
fd.append('labels', entry.label.trim())
|
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',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: fd,
|
body: fd,
|
||||||
@@ -297,7 +297,7 @@ export function useAdminGameManager({
|
|||||||
for (const requestId of requestIds) {
|
for (const requestId of requestIds) {
|
||||||
const draftsForRequest = requestDrafts.filter((entry) => entry.requestId === requestId)
|
const draftsForRequest = requestDrafts.filter((entry) => entry.requestId === requestId)
|
||||||
const result = await api.promoteAdminTemplateRequestItems(requestId, {
|
const result = await api.promoteAdminTemplateRequestItems(requestId, {
|
||||||
gameId: selectedGameId.value,
|
gameId: selectedTemplateId.value,
|
||||||
itemIds: draftsForRequest.map((entry) => entry.itemId).filter(Boolean),
|
itemIds: draftsForRequest.map((entry) => entry.itemId).filter(Boolean),
|
||||||
itemSrcs: draftsForRequest.map((entry) => entry.src).filter(Boolean),
|
itemSrcs: draftsForRequest.map((entry) => entry.src).filter(Boolean),
|
||||||
itemLabels: draftsForRequest.reduce((acc, entry) => {
|
itemLabels: draftsForRequest.reduce((acc, entry) => {
|
||||||
@@ -310,8 +310,8 @@ export function useAdminGameManager({
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetUploadState()
|
resetUploadState()
|
||||||
await loadGame()
|
await loadTemplate()
|
||||||
success.value = `게임 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
|
success.value = `템플릿 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const apiError = e?.data?.error || ''
|
const apiError = e?.data?.error || ''
|
||||||
if (apiError === 'no_items_selected') {
|
if (apiError === 'no_items_selected') {
|
||||||
@@ -320,27 +320,27 @@ export function useAdminGameManager({
|
|||||||
}
|
}
|
||||||
if (apiError === 'promote_items_failed') {
|
if (apiError === 'promote_items_failed') {
|
||||||
const detail = e?.data?.detail ? ` (${e.data.detail})` : ''
|
const detail = e?.data?.detail ? ` (${e.data.detail})` : ''
|
||||||
error.value = `요청 아이템을 게임 기본 아이템으로 옮기지 못했어요.${detail}`
|
error.value = `요청 아이템을 템플릿 기본 아이템으로 옮기지 못했어요.${detail}`
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (apiError === 'game_not_found') {
|
if (apiError === 'game_not_found') {
|
||||||
error.value = '선택한 게임을 찾지 못했어요.'
|
error.value = '선택한 템플릿을 찾지 못했어요.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
error.value = '아이템 추가에 실패했어요.'
|
error.value = '아이템 추가에 실패했어요.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveGameItemOrder() {
|
async function saveTemplateItemOrder() {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
if (!selectedGameId.value || !selectedGame.value?.items?.length) return
|
if (!selectedTemplateId.value || !selectedTemplate.value?.items?.length) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.updateAdminGameItemDisplayOrder(selectedGameId.value, {
|
const data = await api.updateAdminTemplateItemDisplayOrder(selectedTemplateId.value, {
|
||||||
itemIds: selectedGame.value.items.map((item) => item.id),
|
itemIds: selectedTemplate.value.items.map((item) => item.id),
|
||||||
})
|
})
|
||||||
selectedGame.value = {
|
selectedTemplate.value = {
|
||||||
...selectedGame.value,
|
...selectedTemplate.value,
|
||||||
items: (data.items || []).map((item) => ({
|
items: (data.items || []).map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
draftLabel: item.label,
|
draftLabel: item.label,
|
||||||
@@ -360,13 +360,13 @@ export function useAdminGameManager({
|
|||||||
syncGameItemSortable,
|
syncGameItemSortable,
|
||||||
mergeRequestItemsIntoDrafts,
|
mergeRequestItemsIntoDrafts,
|
||||||
removeUploadDraft,
|
removeUploadDraft,
|
||||||
loadGame,
|
loadTemplate,
|
||||||
createGame,
|
createTemplate,
|
||||||
handleItemFiles,
|
handleItemFiles,
|
||||||
onFile,
|
onFile,
|
||||||
openItemFilePicker,
|
openItemFilePicker,
|
||||||
clearItemFiles,
|
clearItemFiles,
|
||||||
uploadItem,
|
uploadItem,
|
||||||
saveGameItemOrder,
|
saveTemplateItemOrder,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
import { editorPath } from '../lib/paths'
|
||||||
|
|
||||||
export function useAdminTemplateRequests({
|
export function useAdminTemplateRequests({
|
||||||
api,
|
api,
|
||||||
activeTemplateRequest,
|
activeTemplateRequest,
|
||||||
refreshTemplateRequests,
|
refreshTemplateRequests,
|
||||||
setTab,
|
setTab,
|
||||||
openGameCreateModal,
|
openTemplateCreateModal,
|
||||||
newGameId,
|
newTemplateId,
|
||||||
newGameName,
|
newTemplateName,
|
||||||
selectAdminGame,
|
selectAdminTemplate,
|
||||||
mergeRequestItemsIntoDrafts,
|
mergeRequestItemsIntoDrafts,
|
||||||
resetMessages,
|
resetMessages,
|
||||||
success,
|
success,
|
||||||
@@ -37,12 +39,12 @@ export function useAdminTemplateRequests({
|
|||||||
|
|
||||||
function templateRequestSourceUrl(request) {
|
function templateRequestSourceUrl(request) {
|
||||||
if (!request?.sourceGameId || !request?.sourceTierListId) return ''
|
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) {
|
function templateRequestReviewHint(request) {
|
||||||
if (request.type === 'create') return '게임 생성 후 필요한 아이템만 골라 추가하고, 끝나면 여기서 처리 완료하세요.'
|
if (request.type === 'create') return '템플릿 생성 후 필요한 아이템만 골라 추가하고, 끝나면 여기서 처리 완료하세요.'
|
||||||
return '확인하기를 누르면 게임 관리 화면에 요청 아이템이 임시 추가되어 필요한 것만 선별 저장할 수 있어요.'
|
return '확인하기를 누르면 템플릿 관리 화면에 요청 아이템이 임시 추가되어 필요한 것만 선별 저장할 수 있어요.'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startTemplateRequestReview(request) {
|
async function startTemplateRequestReview(request) {
|
||||||
@@ -65,19 +67,19 @@ export function useAdminTemplateRequests({
|
|||||||
if (request.type === 'create') {
|
if (request.type === 'create') {
|
||||||
const linkedGameId = syncedRequest.targetGameId || ''
|
const linkedGameId = syncedRequest.targetGameId || ''
|
||||||
if (linkedGameId) {
|
if (linkedGameId) {
|
||||||
await selectAdminGame(linkedGameId)
|
await selectAdminTemplate(linkedGameId)
|
||||||
} else {
|
} else {
|
||||||
openGameCreateModal()
|
openTemplateCreateModal()
|
||||||
newGameId.value = (syncedRequest.draftGameId || '').trim()
|
newTemplateId.value = (syncedRequest.draftGameId || '').trim()
|
||||||
newGameName.value = (syncedRequest.draftGameName || '').trim()
|
newTemplateName.value = (syncedRequest.draftGameName || '').trim()
|
||||||
}
|
}
|
||||||
mergeRequestItemsIntoDrafts(syncedRequest)
|
mergeRequestItemsIntoDrafts(syncedRequest)
|
||||||
} else {
|
} else {
|
||||||
const nextGameId = syncedRequest.targetGameId || syncedRequest.sourceGameId || ''
|
const nextGameId = syncedRequest.targetGameId || syncedRequest.sourceGameId || ''
|
||||||
if (nextGameId) await selectAdminGame(nextGameId)
|
if (nextGameId) await selectAdminTemplate(nextGameId)
|
||||||
mergeRequestItemsIntoDrafts(syncedRequest)
|
mergeRequestItemsIntoDrafts(syncedRequest)
|
||||||
}
|
}
|
||||||
success.value = '요청 아이템을 게임 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.'
|
success.value = '요청 아이템을 템플릿 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '요청 확인 단계로 이동하지 못했어요.'
|
error.value = '요청 확인 단계로 이동하지 못했어요.'
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -30,27 +30,27 @@ export const api = {
|
|||||||
login: ({ email, password }) => request('/api/auth/login', { method: 'POST', body: { email, password } }),
|
login: ({ email, password }) => request('/api/auth/login', { method: 'POST', body: { email, password } }),
|
||||||
logout: () => request('/api/auth/logout', { method: 'POST' }),
|
logout: () => request('/api/auth/logout', { method: 'POST' }),
|
||||||
|
|
||||||
listGames: () => request('/api/games'),
|
listTopics: () => request('/api/topics'),
|
||||||
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
|
getTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}`),
|
||||||
favoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'POST' }),
|
favoriteTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}/favorite`, { method: 'POST' }),
|
||||||
unfavoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'DELETE' }),
|
unfavoriteTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}/favorite`, { method: 'DELETE' }),
|
||||||
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
|
updateAdminTemplateDisplayOrder: (payload) => request('/api/admin/templates/display-order', { method: 'PATCH', body: payload }),
|
||||||
updateAdminGameItemDisplayOrder: (gameId, payload) =>
|
updateAdminTemplateItemDisplayOrder: (templateId, payload) =>
|
||||||
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/display-order`, { method: 'PATCH', body: payload }),
|
request(`/api/admin/templates/${encodeURIComponent(templateId)}/items/display-order`, { method: 'PATCH', body: payload }),
|
||||||
updateAdminGame: (gameId, payload) =>
|
updateAdminTemplate: (templateId, payload) =>
|
||||||
request(`/api/admin/games/${encodeURIComponent(gameId)}`, { method: 'PATCH', body: payload }),
|
request(`/api/admin/templates/${encodeURIComponent(templateId)}`, { method: 'PATCH', body: payload }),
|
||||||
updateAdminGameItem: (gameId, itemId, payload) =>
|
updateAdminTemplateItem: (templateId, itemId, payload) =>
|
||||||
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
request(`/api/admin/templates/${encodeURIComponent(templateId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||||
listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all' } = {}) =>
|
listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all' } = {}) =>
|
||||||
request(
|
request(
|
||||||
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
|
`/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(
|
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 = '' } = {}) =>
|
getAdminTierListStats: ({ q = '', topicId = '', gameId = '' } = {}) =>
|
||||||
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}`),
|
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId || gameId)}`),
|
||||||
updateAdminTierList: (tierListId, payload) =>
|
updateAdminTierList: (tierListId, payload) =>
|
||||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }),
|
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }),
|
||||||
deleteAdminTierList: (tierListId) =>
|
deleteAdminTierList: (tierListId) =>
|
||||||
@@ -66,13 +66,13 @@ export const api = {
|
|||||||
cleanupAdminMissingImageReferences: () => request('/api/admin/image-assets/missing/cleanup', { method: 'POST', body: {} }),
|
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)}`),
|
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 || {} }),
|
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 }),
|
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||||
updateAdminCustomItemLabel: (itemId, payload) =>
|
updateAdminCustomItemLabel: (itemId, payload) =>
|
||||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }),
|
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }),
|
||||||
promoteAdminTierListItems: (tierListId, payload) =>
|
promoteAdminTierListItems: (tierListId, payload) =>
|
||||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: 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 }),
|
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
|
||||||
startAdminTemplateRequestReview: (requestId) =>
|
startAdminTemplateRequestReview: (requestId) =>
|
||||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/review`, { method: 'POST', body: {} }),
|
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' }),
|
deleteAdminUser: (userId) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
listPublicTierLists: (gameId) =>
|
listPublicTierListsByTopic: (topicId) =>
|
||||||
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`),
|
request(`/api/tierlists/public?topicId=${encodeURIComponent(topicId || '')}`),
|
||||||
searchPublicTierLists: (gameId, q = '') =>
|
searchPublicTierListsByTopic: (topicId, q = '') =>
|
||||||
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`),
|
request(`/api/tierlists/public?topicId=${encodeURIComponent(topicId || '')}&q=${encodeURIComponent(q || '')}`),
|
||||||
searchAllPublicTierLists: (q = '') => request(`/api/tierlists/public?q=${encodeURIComponent(q || '')}`),
|
searchAllPublicTierLists: (q = '') => request(`/api/tierlists/public?q=${encodeURIComponent(q || '')}`),
|
||||||
listMyTierLists: () => request('/api/tierlists/me'),
|
listMyTierLists: () => request('/api/tierlists/me'),
|
||||||
listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) =>
|
listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) =>
|
||||||
@@ -146,4 +146,24 @@ export const api = {
|
|||||||
deleteAdminCustomItem: (itemId) => request(`/api/admin/custom-items/${encodeURIComponent(itemId)}`, { method: 'DELETE' }),
|
deleteAdminCustomItem: (itemId) => request(`/api/admin/custom-items/${encodeURIComponent(itemId)}`, { method: 'DELETE' }),
|
||||||
deleteAdminUnusedCustomItems: ({ q = '' } = {}) =>
|
deleteAdminUnusedCustomItems: ({ q = '' } = {}) =>
|
||||||
request(`/api/admin/custom-items?q=${encodeURIComponent(q)}`, { method: 'DELETE' }),
|
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
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,9 @@ export function createRouter() {
|
|||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', name: 'home', component: HomeView },
|
{ path: '/', name: 'home', component: HomeView },
|
||||||
{ path: '/games/:gameId', name: 'gameHub', component: GameHubView },
|
{ path: '/topics/:topicId', alias: ['/games/:gameId'], name: 'topicHub', component: GameHubView },
|
||||||
{ path: '/editor/:gameId/new', name: 'newEditor', component: TierEditorView },
|
{ path: '/editor/:topicId/new', alias: ['/editor/:gameId/new'], name: 'newEditor', component: TierEditorView },
|
||||||
{ path: '/editor/:gameId/:tierListId', name: 'editEditor', component: TierEditorView },
|
{ path: '/editor/:topicId/:tierListId', alias: ['/editor/:gameId/:tierListId'], name: 'editEditor', component: TierEditorView },
|
||||||
{ path: '/login', name: 'login', component: LoginView },
|
{ path: '/login', name: 'login', component: LoginView },
|
||||||
{ path: '/me', name: 'me', component: MyTierListsView },
|
{ path: '/me', name: 'me', component: MyTierListsView },
|
||||||
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView },
|
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView },
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { toApiUrl } from '../lib/runtime'
|
import { toApiUrl } from '../lib/runtime'
|
||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
|
import { editorPath, loginPath } from '../lib/paths'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -42,12 +43,12 @@ async function loadFavorites() {
|
|||||||
favorites.value = data.tierLists || []
|
favorites.value = data.tierLists || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error('로그인이 필요해요.')
|
toast.error('로그인이 필요해요.')
|
||||||
router.push('/login?redirect=/favorites')
|
router.push(loginPath('/favorites'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTierList(tierList) {
|
function openTierList(tierList) {
|
||||||
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
|
router.push(editorPath(tierList.gameId, tierList.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadFavorites)
|
onMounted(loadFavorites)
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { toApiUrl } from '../lib/runtime'
|
import { toApiUrl } from '../lib/runtime'
|
||||||
|
import { editorNewPath, editorPath, loginPath } from '../lib/paths'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const gameId = computed(() => route.params.gameId)
|
const topicId = computed(() => route.params.topicId || route.params.gameId)
|
||||||
|
const topicName = ref('')
|
||||||
const gameName = ref('')
|
|
||||||
const tierLists = ref([])
|
const tierLists = ref([])
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const brokenThumbnailIds = ref({})
|
const brokenThumbnailIds = ref({})
|
||||||
|
const isTopicLoading = ref(false)
|
||||||
const isListView = computed(() => route.query.view === 'list')
|
const isListView = computed(() => route.query.view === 'list')
|
||||||
|
const topicTitle = computed(() => topicName.value || (isTopicLoading.value ? '주제 불러오는 중...' : ''))
|
||||||
|
|
||||||
function fmt(ts) {
|
function fmt(ts) {
|
||||||
return new Date(ts).toLocaleDateString(undefined, {
|
return new Date(ts).toLocaleDateString(undefined, {
|
||||||
@@ -48,62 +50,66 @@ function handleThumbnailError(tierListId) {
|
|||||||
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
|
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await loadTierLists()
|
|
||||||
})
|
|
||||||
|
|
||||||
async function loadTierLists() {
|
async function loadTierLists() {
|
||||||
|
isTopicLoading.value = true
|
||||||
try {
|
try {
|
||||||
const [gameRes, listRes] = await Promise.all([
|
const [gameRes, listRes] = await Promise.all([
|
||||||
api.getGame(gameId.value),
|
api.getTopic(topicId.value),
|
||||||
api.searchPublicTierLists(gameId.value, query.value),
|
api.searchPublicTierListsByTopic(topicId.value, query.value),
|
||||||
])
|
])
|
||||||
gameName.value = gameRes.game?.name || gameId.value
|
topicName.value = gameRes.game?.name || ''
|
||||||
brokenThumbnailIds.value = {}
|
brokenThumbnailIds.value = {}
|
||||||
tierLists.value = listRes.tierLists || []
|
tierLists.value = listRes.tierLists || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '주제 정보를 불러오지 못했어요.'
|
error.value = '주제 정보를 불러오지 못했어요.'
|
||||||
|
} finally {
|
||||||
|
isTopicLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createNew() {
|
function createNew() {
|
||||||
|
const target = editorNewPath(topicId.value)
|
||||||
if (!auth.user) {
|
if (!auth.user) {
|
||||||
router.push(`/login?redirect=/editor/${gameId.value}/new`)
|
router.push(loginPath(target))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
router.push(`/editor/${gameId.value}/new`)
|
router.push(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTierList(id) {
|
function openTierList(id) {
|
||||||
router.push(`/editor/${gameId.value}/${id}`)
|
router.push(editorPath(topicId.value, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitSearch() {
|
function submitSearch() {
|
||||||
loadTierLists()
|
loadTierLists()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
topicId,
|
||||||
|
() => {
|
||||||
|
topicName.value = ''
|
||||||
|
error.value = ''
|
||||||
|
loadTierLists()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="dashboardHero">
|
<section class="pageHead">
|
||||||
<div class="dashboardHero__left">
|
<div class="pageHead__main">
|
||||||
<div class="dashboardHero__eyebrow">Collection</div>
|
<div class="pageHead__eyebrow">Collection</div>
|
||||||
<h2 class="dashboardHero__title">{{ gameName || gameId }}</h2>
|
<h2 class="pageHead__title">{{ topicTitle }}</h2>
|
||||||
<p class="dashboardHero__desc">이 주제의 공개 티어표를 탐색하고, 바로 새 보드를 만들어 같은 흐름으로 이어갈 수 있어요.</p>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
<section class="panel">
|
<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-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
|
||||||
<div v-else class="list" :class="{ 'list--table': isListView }">
|
<div v-else class="list" :class="{ 'list--table': isListView }">
|
||||||
<article v-for="t in tierLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
<article v-for="t in tierLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
||||||
@@ -135,72 +141,17 @@ function submitSearch() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.panel {
|
||||||
/* border: 1px solid var(--theme-border); */
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.error {
|
.toolbar {
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.searchBar__input {
|
.input {
|
||||||
min-width: 240px;
|
min-width: 240px;
|
||||||
padding: 11px 13px;
|
padding: 11px 13px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -208,8 +159,8 @@ function submitSearch() {
|
|||||||
background: var(--theme-surface-soft);
|
background: var(--theme-surface-soft);
|
||||||
color: var(--theme-text);
|
color: var(--theme-text);
|
||||||
}
|
}
|
||||||
.searchBar__button {
|
.btn {
|
||||||
padding: 11px 14px;
|
padding: 11px 13px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid var(--theme-border);
|
border: 1px solid var(--theme-border);
|
||||||
background: var(--theme-surface-soft-2);
|
background: var(--theme-surface-soft-2);
|
||||||
@@ -217,6 +168,13 @@ function submitSearch() {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
cursor: pointer;
|
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 {
|
.empty {
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
@@ -405,7 +363,11 @@ function submitSearch() {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchBar__input {
|
.toolbar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,19 @@ import { api } from '../lib/api'
|
|||||||
import SvgIcon from '../components/SvgIcon.vue'
|
import SvgIcon from '../components/SvgIcon.vue'
|
||||||
import kidStarIcon from '../assets/icons/kid_star.svg'
|
import kidStarIcon from '../assets/icons/kid_star.svg'
|
||||||
import { toApiUrl } from '../lib/runtime'
|
import { toApiUrl } from '../lib/runtime'
|
||||||
|
import { loginPath, topicPath } from '../lib/paths'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const items = ref([])
|
const templateRecords = ref([])
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const loadingFavoriteId = ref('')
|
const loadingFavoriteId = ref('')
|
||||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
|
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
|
||||||
const games = computed(() => {
|
const templates = computed(() => {
|
||||||
const filtered = items.value
|
const filtered = templateRecords.value
|
||||||
.filter((item) => item.id !== 'freeform')
|
.filter((item) => item.id !== 'freeform')
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
if (!query.value) return true
|
if (!query.value) return true
|
||||||
@@ -33,34 +34,34 @@ const games = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadGames() {
|
async function loadTemplates() {
|
||||||
try {
|
try {
|
||||||
const data = await api.listGames()
|
const data = await api.listTopics()
|
||||||
items.value = data.games || []
|
templateRecords.value = data.games || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
|
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadGames)
|
onMounted(loadTemplates)
|
||||||
watch(() => auth.user?.id, loadGames)
|
watch(() => auth.user?.id, loadTemplates)
|
||||||
|
|
||||||
function goGame(gameId) {
|
function openTopic(templateId) {
|
||||||
router.push(`/games/${gameId}`)
|
router.push(topicPath(templateId))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleFavorite(game, event) {
|
async function toggleFavorite(template, event) {
|
||||||
event?.stopPropagation()
|
event?.stopPropagation()
|
||||||
if (!auth.user) {
|
if (!auth.user) {
|
||||||
router.push(`/login?redirect=${encodeURIComponent(route.fullPath || '/')}`)
|
router.push(loginPath(route.fullPath || '/'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!game?.id || loadingFavoriteId.value === game.id) return
|
if (!template?.id || loadingFavoriteId.value === template.id) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loadingFavoriteId.value = game.id
|
loadingFavoriteId.value = template.id
|
||||||
const res = game.isFavorited ? await api.unfavoriteGame(game.id) : await api.favoriteGame(game.id)
|
const res = template.isFavorited ? await api.unfavoriteTopic(template.id) : await api.favoriteTopic(template.id)
|
||||||
items.value = items.value.map((entry) => (entry.id === game.id ? { ...entry, ...res.game } : entry))
|
templateRecords.value = templateRecords.value.map((entry) => (entry.id === template.id ? { ...entry, ...res.game } : entry))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '즐겨찾기 변경에 실패했어요.'
|
error.value = '즐겨찾기 변경에 실패했어요.'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -68,41 +69,41 @@ async function toggleFavorite(game, event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function thumbUrl(g) {
|
function templateThumbUrl(template) {
|
||||||
return g.thumbnailSrc ? toApiUrl(g.thumbnailSrc) : ''
|
return template.thumbnailSrc ? toApiUrl(template.thumbnailSrc) : ''
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="pageHead">
|
<section class="pageHead">
|
||||||
<div class="pageHead__main">
|
<div class="pageHead__main">
|
||||||
<div class="pageHead__eyebrow">Workspace</div>
|
<div class="pageHead__eyebrow">Topic</div>
|
||||||
<h1 class="pageHead__title">Topic Library</h1>
|
<h1 class="pageHead__title">주제 선택</h1>
|
||||||
<p class="pageHead__desc">자주 쓰는 주제 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
<p class="pageHead__desc">자주 쓰는 주제 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
||||||
<p v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 주제 템플릿만 보고 있어요.</p>
|
<p v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 주제 템플릿만 보고 있어요.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
<TransitionGroup v-if="games.length" name="libraryCard" tag="section" class="libraryGrid">
|
<TransitionGroup v-if="templates.length" name="libraryCard" tag="section" class="libraryGrid">
|
||||||
<article v-for="g in games" :key="g.id" class="libraryCard">
|
<article v-for="template in templates" :key="template.id" class="libraryCard">
|
||||||
<button
|
<button
|
||||||
class="libraryCard__favorite"
|
class="libraryCard__favorite"
|
||||||
type="button"
|
type="button"
|
||||||
:class="{ 'libraryCard__favorite--active': g.isFavorited }"
|
:class="{ 'libraryCard__favorite--active': template.isFavorited }"
|
||||||
:disabled="loadingFavoriteId === g.id"
|
:disabled="loadingFavoriteId === template.id"
|
||||||
@click.stop="toggleFavorite(g, $event)"
|
@click.stop="toggleFavorite(template, $event)"
|
||||||
>
|
>
|
||||||
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
|
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
|
||||||
</button>
|
</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">
|
<div class="libraryCard__thumbWrap">
|
||||||
<img v-if="thumbUrl(g)" class="libraryCard__thumb" :src="thumbUrl(g)" :alt="g.name" draggable="false" />
|
<img v-if="templateThumbUrl(template)" class="libraryCard__thumb" :src="templateThumbUrl(template)" :alt="template.name" draggable="false" />
|
||||||
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="libraryCard__body">
|
<div class="libraryCard__body">
|
||||||
<div class="libraryCard__title">{{ g.name }}</div>
|
<div class="libraryCard__title">{{ template.name }}</div>
|
||||||
<div class="libraryCard__meta">{{ g.id }}</div>
|
<div class="libraryCard__meta">{{ template.id }}</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
|
import { homePath, mePath } from '../lib/paths'
|
||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -36,7 +37,7 @@ const checkingSession = computed(() => !authReady.value || auth.status === 'load
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!auth.hydrated) await auth.refresh()
|
if (!auth.hydrated) await auth.refresh()
|
||||||
if (auth.user) {
|
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
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -51,7 +52,7 @@ watch(
|
|||||||
() => [auth.hydrated, auth.user],
|
() => [auth.hydrated, auth.user],
|
||||||
([hydrated, user]) => {
|
([hydrated, user]) => {
|
||||||
if (!hydrated || !user) return
|
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 }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
@@ -65,7 +66,7 @@ async function submit() {
|
|||||||
try {
|
try {
|
||||||
if (mode.value === 'signup') await auth.signup(email.value, password.value)
|
if (mode.value === 'signup') await auth.signup(email.value, password.value)
|
||||||
else await auth.login(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) {
|
} catch (e) {
|
||||||
error.value = '로그인/회원가입에 실패했어요.'
|
error.value = '로그인/회원가입에 실패했어요.'
|
||||||
}
|
}
|
||||||
@@ -133,7 +134,7 @@ async function submit() {
|
|||||||
<div v-if="!hasUsers" class="roleBadge">첫 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
|
<div v-if="!hasUsers" class="roleBadge">첫 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
|
||||||
|
|
||||||
<div class="authActions">
|
<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>
|
<button class="primaryAction" type="submit">{{ submitLabel }}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { toApiUrl } from '../lib/runtime'
|
import { toApiUrl } from '../lib/runtime'
|
||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
|
import { editorPath, loginPath } from '../lib/paths'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -54,21 +55,19 @@ onMounted(async () => {
|
|||||||
myLists.value = data.tierLists || []
|
myLists.value = data.tierLists || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error('로그인이 필요해요.')
|
toast.error('로그인이 필요해요.')
|
||||||
router.push('/login?redirect=/me')
|
router.push(loginPath('/me'))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function openList(t) {
|
function openList(t) {
|
||||||
router.push(
|
router.push(editorPath(t.gameId, t.id))
|
||||||
"/editor/" + t.gameId + "/" + t.id,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="pageHead">
|
<section class="pageHead">
|
||||||
<div class="pageHead__main">
|
<div class="pageHead__main">
|
||||||
<div class="pageHead__eyebrow">Library</div>
|
<div class="pageHead__eyebrow">Tier Lists</div>
|
||||||
<h2 class="pageHead__title">나의 티어표</h2>
|
<h2 class="pageHead__title">나의 티어표</h2>
|
||||||
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</div>
|
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { homePath, loginPath } from '../lib/paths'
|
||||||
import { toApiUrl } from '../lib/runtime'
|
import { toApiUrl } from '../lib/runtime'
|
||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ const displayInitial = computed(() => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!auth.hydrated) await auth.refresh()
|
if (!auth.hydrated) await auth.refresh()
|
||||||
if (!auth.user) {
|
if (!auth.user) {
|
||||||
router.replace('/login')
|
router.replace(loginPath())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
nickname.value = auth.user?.nickname || ''
|
nickname.value = auth.user?.nickname || ''
|
||||||
@@ -112,7 +113,7 @@ async function saveProfile() {
|
|||||||
async function logout() {
|
async function logout() {
|
||||||
await auth.logout()
|
await auth.logout()
|
||||||
toast.success('로그아웃했어요.')
|
toast.success('로그아웃했어요.')
|
||||||
router.push('/')
|
router.push(homePath())
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { toApiUrl } from '../lib/runtime'
|
import { toApiUrl } from '../lib/runtime'
|
||||||
|
import { editorPath } from '../lib/paths'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -37,7 +38,7 @@ function tierListThumbnailUrl(tierList) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openTierList(tierList) {
|
function openTierList(tierList) {
|
||||||
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
|
router.push(editorPath(tierList.gameId, tierList.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadResults() {
|
async function loadResults() {
|
||||||
@@ -65,13 +66,13 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="wrap">
|
<section class="wrap">
|
||||||
<div class="head">
|
<section class="pageHead">
|
||||||
<div>
|
<div class="pageHead__main">
|
||||||
<div class="head__eyebrow">검색</div>
|
<div class="pageHead__eyebrow">Search</div>
|
||||||
<h2 class="title">전체 티어표 검색</h2>
|
<h2 class="pageHead__title">전체 티어표 검색</h2>
|
||||||
<div class="desc">공개된 모든 티어표를 제목과 작성자 기준으로 찾아볼 수 있어요.</div>
|
<div class="pageHead__desc">공개된 티어표를 제목과 작성자 기준으로 다시 찾아볼 수 있어요.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
<div v-else-if="loading" class="empty">검색 중이에요.</div>
|
<div v-else-if="loading" class="empty">검색 중이에요.</div>
|
||||||
@@ -110,30 +111,6 @@ watch(
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
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 {
|
.error {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
padding: 10px 12px;
|
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 addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
|
||||||
import shareIcon from '../assets/icons/share.svg'
|
import shareIcon from '../assets/icons/share.svg'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
|
import { editorNewPath, editorPath, loginPath, mePath, topicPath } from '../lib/paths'
|
||||||
import { toApiUrl } from '../lib/runtime'
|
import { toApiUrl } from '../lib/runtime'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
@@ -19,10 +20,10 @@ const auth = useAuthStore()
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const globalRightRailOpen = inject('rightRailOpen', ref(true))
|
const globalRightRailOpen = inject('rightRailOpen', ref(true))
|
||||||
const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root')
|
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 tierListId = computed(() => route.params.tierListId)
|
||||||
const previewMode = computed(() => route.query.preview === '1')
|
const previewMode = computed(() => route.query.preview === '1')
|
||||||
const gameName = ref('')
|
const templateName = ref('')
|
||||||
|
|
||||||
const columns = ref([{ id: 'col-1', name: '' }])
|
const columns = ref([{ id: 'col-1', name: '' }])
|
||||||
const groups = ref([
|
const groups = ref([
|
||||||
@@ -123,19 +124,19 @@ const copiedFromLabel = computed(() => {
|
|||||||
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
|
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
|
||||||
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
|
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
|
||||||
const canRequestTemplateCreate = computed(
|
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(
|
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 canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||||
const canSubmitTemplateUpdateRequest = 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 shareTierListUrl = computed(() => {
|
||||||
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
||||||
if (!savedTierListId) return ''
|
if (!savedTierListId) return ''
|
||||||
if (typeof window === 'undefined') return `/editor/${gameId.value}/${savedTierListId}?preview=1`
|
if (typeof window === 'undefined') return editorPath(templateId.value, savedTierListId, { preview: true })
|
||||||
return new URL(`/editor/${gameId.value}/${savedTierListId}?preview=1`, window.location.origin).toString()
|
return new URL(editorPath(templateId.value, savedTierListId, { preview: true }), window.location.origin).toString()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(error, (message) => {
|
watch(error, (message) => {
|
||||||
@@ -672,7 +673,7 @@ function buildPayload(existingId) {
|
|||||||
const finalTitle = effectiveTitle.value
|
const finalTitle = effectiveTitle.value
|
||||||
return {
|
return {
|
||||||
id: existingId || undefined,
|
id: existingId || undefined,
|
||||||
gameId: gameId.value,
|
gameId: templateId.value,
|
||||||
title: finalTitle,
|
title: finalTitle,
|
||||||
thumbnailSrc: thumbnailSrc.value || '',
|
thumbnailSrc: thumbnailSrc.value || '',
|
||||||
description: (description.value || '').trim(),
|
description: (description.value || '').trim(),
|
||||||
@@ -697,7 +698,7 @@ async function persistTierList({ showModal = false } = {}) {
|
|||||||
persistedTierListId.value = savedTierListId || ''
|
persistedTierListId.value = savedTierListId || ''
|
||||||
title.value = res.tierList?.title || payload.title
|
title.value = res.tierList?.title || payload.title
|
||||||
if (tierListId.value === 'new' && res.tierList?.id) {
|
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())
|
updatedAt.value = Number(res.tierList?.updatedAt || Date.now())
|
||||||
authorName.value = res.tierList?.authorName || effectiveAuthorName.value
|
authorName.value = res.tierList?.authorName || effectiveAuthorName.value
|
||||||
@@ -793,7 +794,7 @@ async function confirmDeleteTierList() {
|
|||||||
await api.deleteTierList(currentTierListId)
|
await api.deleteTierList(currentTierListId)
|
||||||
closeDeleteModal()
|
closeDeleteModal()
|
||||||
toast.success('티어표를 삭제했어요.')
|
toast.success('티어표를 삭제했어요.')
|
||||||
router.push(gameId.value === 'freeform' ? '/me' : `/games/${gameId.value}`)
|
router.push(templateId.value === 'freeform' ? mePath() : topicPath(templateId.value))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '티어표 삭제에 실패했어요.'
|
error.value = '티어표 삭제에 실패했어요.'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -808,7 +809,7 @@ async function duplicateCurrentTierList() {
|
|||||||
const duplicatedId = data.tierList?.id
|
const duplicatedId = data.tierList?.id
|
||||||
if (!duplicatedId) throw new Error('duplicate_failed')
|
if (!duplicatedId) throw new Error('duplicate_failed')
|
||||||
toast.success('티어표를 복사해 내 작업으로 가져왔어요.')
|
toast.success('티어표를 복사해 내 작업으로 가져왔어요.')
|
||||||
router.push(`/editor/${gameId.value}/${duplicatedId}`)
|
router.push(editorPath(templateId.value, duplicatedId))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '티어표 복사에 실패했어요.'
|
error.value = '티어표 복사에 실패했어요.'
|
||||||
}
|
}
|
||||||
@@ -841,7 +842,7 @@ async function requestTemplate(type) {
|
|||||||
await api.requestTierListTemplate({
|
await api.requestTierListTemplate({
|
||||||
type,
|
type,
|
||||||
sourceTierListId: sourceId,
|
sourceTierListId: sourceId,
|
||||||
gameId: gameId.value,
|
gameId: templateId.value,
|
||||||
requestTitle: templateRequestDraftTitle.value.trim(),
|
requestTitle: templateRequestDraftTitle.value.trim(),
|
||||||
requestDescription: templateRequestDraftDescription.value.trim(),
|
requestDescription: templateRequestDraftDescription.value.trim(),
|
||||||
thumbnailSrc: saved.tierList?.thumbnailSrc || thumbnailSrc.value || '',
|
thumbnailSrc: saved.tierList?.thumbnailSrc || thumbnailSrc.value || '',
|
||||||
@@ -892,13 +893,13 @@ onMounted(() => {
|
|||||||
authorAccountName.value = ((auth.user?.email || '').trim().split('@')[0] || '').trim()
|
authorAccountName.value = ((auth.user?.email || '').trim().split('@')[0] || '').trim()
|
||||||
|
|
||||||
if (isNewTierList.value && !auth.user) {
|
if (isNewTierList.value && !auth.user) {
|
||||||
router.replace(`/login?redirect=/editor/${gameId.value}/new`)
|
router.replace(loginPath(editorNewPath(templateId.value)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const gameRes = await api.getGame(gameId.value)
|
const gameRes = await api.getTopic(templateId.value)
|
||||||
gameName.value = gameRes.game?.name || gameId.value
|
templateName.value = gameRes.game?.name || templateId.value
|
||||||
const base = (gameRes.items || []).map((img) => ({
|
const base = (gameRes.items || []).map((img) => ({
|
||||||
id: img.id,
|
id: img.id,
|
||||||
src: img.src,
|
src: img.src,
|
||||||
@@ -1079,7 +1080,7 @@ onUnmounted(() => {
|
|||||||
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteTierListTitle">
|
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteTierListTitle">
|
||||||
<div id="deleteTierListTitle" class="modalCard__title">티어표 삭제</div>
|
<div id="deleteTierListTitle" class="modalCard__title">티어표 삭제</div>
|
||||||
<div class="modalCard__desc">
|
<div class="modalCard__desc">
|
||||||
"{{ title || gameName || '이 티어표' }}"를 삭제할까요? 삭제 후에는 복구할 수 없어요.
|
"{{ title || templateName || '이 티어표' }}"를 삭제할까요? 삭제 후에는 복구할 수 없어요.
|
||||||
</div>
|
</div>
|
||||||
<div class="modalCard__actions">
|
<div class="modalCard__actions">
|
||||||
<button class="btn btn--ghost" @click="closeDeleteModal">취소</button>
|
<button class="btn btn--ghost" @click="closeDeleteModal">취소</button>
|
||||||
@@ -1120,7 +1121,7 @@ onUnmounted(() => {
|
|||||||
<div class="editorMain">
|
<div class="editorMain">
|
||||||
<section class="head">
|
<section class="head">
|
||||||
<div class="editorMain__headCopy">
|
<div class="editorMain__headCopy">
|
||||||
<div class="editorMain__title">{{ gameName || gameId }}</div>
|
<div class="editorMain__title">{{ templateName || templateId }}</div>
|
||||||
<div class="editorMain__subtitle">
|
<div class="editorMain__subtitle">
|
||||||
<template v-if="canEdit">
|
<template v-if="canEdit">
|
||||||
행/열 이름과 순서를 바꾸고 아이템을 드래그해서 배치할 수 있어요.
|
행/열 이름과 순서를 바꾸고 아이템을 드래그해서 배치할 수 있어요.
|
||||||
@@ -1131,7 +1132,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="sourceTierListId" class="editorMain__sourceNote">
|
<div v-if="sourceTierListId" class="editorMain__sourceNote">
|
||||||
<span>복사본</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user