Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 947837fe40 | |||
| 5ef833fde5 | |||
| 67e192b0e1 | |||
| 8ef011bfc8 | |||
| 9403e3698d | |||
| 5b15ec12fa | |||
| 28c6dafa02 | |||
| f98524390b | |||
| da37fe9fc9 | |||
| d09cd7e508 | |||
| 907ea75182 | |||
| 4285883e28 | |||
| b16aa046e7 | |||
| 958c75d4fb | |||
| d5d4974751 | |||
| 46fcb0e2cf | |||
| f506e31549 | |||
| 30ec2e55b0 | |||
| dddb57333c | |||
| b758823537 | |||
| 66408aaa1b | |||
| 426e7de177 | |||
| 953837137a | |||
| f1756a4ff1 |
@@ -14,6 +14,7 @@ const topicsRoutes = require('./src/routes/topics')
|
||||
const tierListsRoutes = require('./src/routes/tierlists')
|
||||
const usersRoutes = require('./src/routes/users')
|
||||
const adminRoutes = require('./src/routes/admin')
|
||||
const shareRoutes = require('./src/routes/share')
|
||||
|
||||
const app = express()
|
||||
const PORT = process.env.PORT ? Number(process.env.PORT) : 5179
|
||||
@@ -88,6 +89,7 @@ app.use('/api/topics', topicsRoutes)
|
||||
app.use('/api/tierlists', tierListsRoutes)
|
||||
app.use('/api/users', usersRoutes)
|
||||
app.use('/api/admin', adminRoutes)
|
||||
app.use('/share', shareRoutes)
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[backend] listening on http://localhost:${PORT}`)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"start": "APP_ORIGIN=http://localhost:5173 DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js",
|
||||
"images:backfill": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/backfill-legacy-image-assets.js",
|
||||
"images:migrate-legacy": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/migrate-legacy-uploads-to-assets.js",
|
||||
"images:shard-assets": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/migrate-flat-assets-to-sharded.js",
|
||||
"uploads:cleanup-legacy": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/cleanup-unreferenced-legacy-uploads.js"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
102
backend/scripts/migrate-flat-assets-to-sharded.js
Normal file
102
backend/scripts/migrate-flat-assets-to-sharded.js
Normal file
@@ -0,0 +1,102 @@
|
||||
const fs = require('fs/promises')
|
||||
const path = require('path')
|
||||
const {
|
||||
ensureData,
|
||||
closePool,
|
||||
updateImageAssetSrc,
|
||||
replaceUploadSourceReferences,
|
||||
} = require('../src/db')
|
||||
|
||||
const BACKEND_ROOT = path.join(__dirname, '..')
|
||||
const ASSETS_ROOT = path.join(BACKEND_ROOT, 'uploads', 'assets')
|
||||
const FLAT_ASSET_PATTERN = /^\/uploads\/assets\/[^/]+$/
|
||||
|
||||
function getShardedAssetSrc(src) {
|
||||
const filename = path.basename(src || '')
|
||||
const shardDirectory = filename.slice(0, 2)
|
||||
if (!filename || shardDirectory.length < 2) return ''
|
||||
return `/uploads/assets/${shardDirectory}/${filename}`
|
||||
}
|
||||
|
||||
async function moveAssetFile(fromSrc, toSrc) {
|
||||
const fromPath = path.join(BACKEND_ROOT, fromSrc.replace(/^\//, ''))
|
||||
const toPath = path.join(BACKEND_ROOT, toSrc.replace(/^\//, ''))
|
||||
await fs.mkdir(path.dirname(toPath), { recursive: true })
|
||||
|
||||
try {
|
||||
await fs.rename(fromPath, toPath)
|
||||
return 'moved'
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(toPath)
|
||||
return 'already_moved'
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return 'missing'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureData()
|
||||
|
||||
let dirEntries = []
|
||||
try {
|
||||
dirEntries = await fs.readdir(ASSETS_ROOT, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error
|
||||
}
|
||||
|
||||
const flatAssets = dirEntries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => ({ src: `/uploads/assets/${entry.name}` }))
|
||||
.filter((asset) => FLAT_ASSET_PATTERN.test(asset.src || ''))
|
||||
const summary = {
|
||||
scanned: flatAssets.length,
|
||||
migrated: 0,
|
||||
alreadyMoved: 0,
|
||||
skipped: 0,
|
||||
missingFiles: 0,
|
||||
failed: 0,
|
||||
updatedRows: 0,
|
||||
}
|
||||
|
||||
for (const asset of flatAssets) {
|
||||
const nextSrc = getShardedAssetSrc(asset.src)
|
||||
if (!nextSrc) {
|
||||
summary.skipped += 1
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const moveStatus = await moveAssetFile(asset.src, nextSrc)
|
||||
if (moveStatus === 'missing') {
|
||||
summary.missingFiles += 1
|
||||
continue
|
||||
}
|
||||
|
||||
await updateImageAssetSrc({ fromSrc: asset.src, toSrc: nextSrc })
|
||||
const replaced = await replaceUploadSourceReferences({ fromSrc: asset.src, toSrc: nextSrc })
|
||||
summary.updatedRows += Number(replaced.updatedRows || 0)
|
||||
|
||||
if (moveStatus === 'already_moved') summary.alreadyMoved += 1
|
||||
else summary.migrated += 1
|
||||
} catch (error) {
|
||||
summary.failed += 1
|
||||
console.error('[migrate-flat-assets-to-sharded] failed:', asset.src, error?.message || error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await closePool()
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
const fs = require('fs/promises')
|
||||
const path = require('path')
|
||||
const mysql = require('mysql2/promise')
|
||||
const { nanoid } = require('nanoid')
|
||||
|
||||
const DB_HOST = process.env.DB_HOST || '127.0.0.1'
|
||||
const DB_PORT = process.env.DB_PORT ? Number(process.env.DB_PORT) : 3306
|
||||
@@ -9,6 +10,7 @@ const DB_PASSWORD = process.env.DB_PASSWORD || ''
|
||||
const DB_NAME = process.env.DB_NAME || 'tier_cursor'
|
||||
const DB_CONNECTION_LIMIT = process.env.DB_CONNECTION_LIMIT ? Number(process.env.DB_CONNECTION_LIMIT) : 10
|
||||
const FREEFORM_TOPIC_ID = 'freeform'
|
||||
const TOPIC_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
|
||||
let poolPromise = null
|
||||
let initPromise = null
|
||||
@@ -30,6 +32,20 @@ function serializeJson(value) {
|
||||
return JSON.stringify(value || [])
|
||||
}
|
||||
|
||||
function normalizeTopicSlug(value) {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function assertTopicSlug(slug) {
|
||||
const normalized = normalizeTopicSlug(slug)
|
||||
if (!normalized || normalized.length > 120 || !TOPIC_SLUG_PATTERN.test(normalized)) {
|
||||
const err = new Error('topic_slug_invalid')
|
||||
err.code = 'TOPIC_SLUG_INVALID'
|
||||
throw err
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function collectUploadSrcsFromItems(items, bucket) {
|
||||
for (const item of items || []) {
|
||||
if (typeof item?.src === 'string' && item.src.startsWith('/uploads/')) {
|
||||
@@ -64,6 +80,9 @@ function mapUserRow(row) {
|
||||
avatarSrc: row.avatar_src || '',
|
||||
createdAt: Number(row.created_at),
|
||||
tierListCount: Number(row.tierlist_count || 0),
|
||||
followerCount: Number(row.follower_count || 0),
|
||||
receivedFavoriteCount: Number(row.received_favorite_count || 0),
|
||||
lastLoginAt: Number(row.last_login_at || 0),
|
||||
recentActivityAt: Number(row.recent_activity_at || row.created_at || 0),
|
||||
}
|
||||
}
|
||||
@@ -72,8 +91,10 @@ function mapTopicRow(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug || row.id,
|
||||
name: row.name,
|
||||
topicId: row.id,
|
||||
topicSlug: row.slug || row.id,
|
||||
topicName: row.name,
|
||||
thumbnailSrc: row.thumbnail_src || '',
|
||||
isPublic: row.is_public == null ? true : !!row.is_public,
|
||||
@@ -135,6 +156,7 @@ function mapTierListRow(row) {
|
||||
authorAccountName: getUserAccountName(row),
|
||||
authorAvatarSrc: row.avatar_src || '',
|
||||
topicId: row.topic_id,
|
||||
topicSlug: row.topic_slug || row.topic_id,
|
||||
topicName: row.topic_name || '',
|
||||
title: row.title,
|
||||
thumbnailSrc: row.thumbnail_src || '',
|
||||
@@ -166,11 +188,13 @@ function mapTemplateRequestRow(row) {
|
||||
requesterAvatarSrc: row.requester_avatar_src || '',
|
||||
sourceTierListId: row.source_tierlist_id || '',
|
||||
sourceTopicId: row.source_topic_id,
|
||||
sourceTopicSlug: row.source_topic_slug || row.source_topic_id,
|
||||
sourceTopicName: row.source_topic_name || '',
|
||||
sourceTierListTitle: row.title_snapshot || '',
|
||||
sourceDescription: row.description_snapshot || '',
|
||||
thumbnailSrc: row.thumbnail_src_snapshot || '',
|
||||
targetTopicId: row.target_topic_id || '',
|
||||
targetTopicSlug: row.target_topic_slug || row.target_topic_id || '',
|
||||
targetTopicName: row.target_topic_name || '',
|
||||
status: row.status,
|
||||
items: parseJson(row.items_json, []),
|
||||
@@ -198,6 +222,21 @@ function getUserAccountName(row) {
|
||||
return email.split('@')[0] || email
|
||||
}
|
||||
|
||||
function getAssetLibrarySourceLabel(src) {
|
||||
const normalizedSrc = String(src || '').trim()
|
||||
if (normalizedSrc.includes('/uploads/assets/avatars/')) return '프로필 아바타'
|
||||
if (normalizedSrc.includes('/uploads/assets/tierlists/')) return '티어표 썸네일'
|
||||
if (normalizedSrc.includes('/uploads/assets/topics/')) return '썸네일 이미지'
|
||||
return '보관 자산'
|
||||
}
|
||||
|
||||
function getAssetLibraryKind(src) {
|
||||
const normalizedSrc = String(src || '').trim()
|
||||
if (normalizedSrc.includes('/uploads/assets/avatars/')) return 'avatar'
|
||||
if (normalizedSrc.includes('/uploads/assets/tierlists/') || normalizedSrc.includes('/uploads/assets/topics/')) return 'thumbnail'
|
||||
return 'asset'
|
||||
}
|
||||
|
||||
async function createPool() {
|
||||
const rootConnection = await mysql.createConnection({
|
||||
host: DB_HOST,
|
||||
@@ -236,32 +275,6 @@ async function query(sql, params = []) {
|
||||
return rows
|
||||
}
|
||||
|
||||
async function tableExists(name) {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT TABLE_NAME
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
[DB_NAME, name]
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
async function columnExists(tableName, columnName) {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT COLUMN_NAME
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
[DB_NAME, tableName, columnName]
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
async function closePool() {
|
||||
if (!poolPromise) return
|
||||
const pool = await poolPromise
|
||||
@@ -282,16 +295,11 @@ async function ensureSchema() {
|
||||
email_verified TINYINT(1) NOT NULL DEFAULT 1,
|
||||
is_admin TINYINT(1) NOT NULL DEFAULT 0,
|
||||
avatar_src VARCHAR(255) NOT NULL DEFAULT '',
|
||||
last_login_at BIGINT NOT NULL DEFAULT 0,
|
||||
created_at BIGINT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
const userEmailVerifiedColumns = await query("SHOW COLUMNS FROM users LIKE 'email_verified'")
|
||||
if (!userEmailVerifiedColumns.length) {
|
||||
await query('ALTER TABLE users ADD COLUMN email_verified TINYINT(1) NOT NULL DEFAULT 1 AFTER password_hash')
|
||||
await query('UPDATE users SET email_verified = 1 WHERE email_verified IS NULL')
|
||||
}
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
@@ -323,25 +331,16 @@ async function ensureSchema() {
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS topics (
|
||||
id VARCHAR(120) PRIMARY KEY,
|
||||
slug VARCHAR(120) NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
|
||||
is_public TINYINT(1) NOT NULL DEFAULT 1,
|
||||
display_rank INT NULL DEFAULT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
created_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uq_topics_slug (slug)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
const topicIsPublicColumns = await query("SHOW COLUMNS FROM topics LIKE 'is_public'")
|
||||
if (!topicIsPublicColumns.length) {
|
||||
await query('ALTER TABLE topics ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER thumbnail_src')
|
||||
await query('UPDATE topics SET is_public = 1 WHERE is_public IS NULL')
|
||||
}
|
||||
|
||||
const displayRankColumns = await query("SHOW COLUMNS FROM topics LIKE 'display_rank'")
|
||||
if (!displayRankColumns.length) {
|
||||
await query('ALTER TABLE topics ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src')
|
||||
}
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS topic_items (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
@@ -355,11 +354,6 @@ async function ensureSchema() {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
const topicItemDisplayOrderColumns = await query("SHOW COLUMNS FROM topic_items LIKE 'display_order'")
|
||||
if (!topicItemDisplayOrderColumns.length) {
|
||||
await query('ALTER TABLE topic_items ADD COLUMN display_order INT NULL DEFAULT NULL AFTER label')
|
||||
}
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS custom_items (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
@@ -454,11 +448,6 @@ async function ensureSchema() {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
const imageAssetLabelColumns = await query("SHOW COLUMNS FROM image_assets LIKE 'label_override'")
|
||||
if (!imageAssetLabelColumns.length) {
|
||||
await query("ALTER TABLE image_assets ADD COLUMN label_override VARCHAR(120) NOT NULL DEFAULT '' AFTER src")
|
||||
}
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS image_optimization_jobs (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
@@ -481,7 +470,7 @@ async function ensureSchema() {
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
request_type VARCHAR(20) NOT NULL,
|
||||
requester_id VARCHAR(64) NOT NULL,
|
||||
source_tierlist_id VARCHAR(64) NOT NULL,
|
||||
source_tierlist_id VARCHAR(64) NULL DEFAULT NULL,
|
||||
source_topic_id VARCHAR(120) NOT NULL,
|
||||
target_topic_id VARCHAR(120) NOT NULL DEFAULT '',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
@@ -489,6 +478,9 @@ async function ensureSchema() {
|
||||
description_snapshot TEXT NOT NULL,
|
||||
thumbnail_src_snapshot VARCHAR(255) NOT NULL DEFAULT '',
|
||||
items_json LONGTEXT NOT NULL,
|
||||
groups_json LONGTEXT NOT NULL,
|
||||
board_items_json LONGTEXT NOT NULL,
|
||||
show_character_names_snapshot TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
INDEX idx_template_requests_status_created (status, created_at),
|
||||
@@ -499,125 +491,14 @@ async function ensureSchema() {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
const templateRequestSourceTierListColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_tierlist_id'")
|
||||
if (templateRequestSourceTierListColumns[0]?.Null !== 'YES') {
|
||||
await query('ALTER TABLE template_requests MODIFY source_tierlist_id VARCHAR(64) NULL')
|
||||
}
|
||||
const templateRequestTypeColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'request_type'")
|
||||
if (!templateRequestTypeColumns.length) {
|
||||
await query("ALTER TABLE template_requests ADD COLUMN request_type VARCHAR(20) NOT NULL DEFAULT 'create' AFTER id")
|
||||
}
|
||||
const hasSourceTopicId = await columnExists('template_requests', 'source_topic_id')
|
||||
if (!hasSourceTopicId) {
|
||||
await query("ALTER TABLE template_requests ADD COLUMN source_topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id")
|
||||
}
|
||||
const hasTargetTopicId = await columnExists('template_requests', 'target_topic_id')
|
||||
if (!hasTargetTopicId) {
|
||||
await query("ALTER TABLE template_requests ADD COLUMN target_topic_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_topic_id")
|
||||
}
|
||||
const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'")
|
||||
if (!templateRequestStatusColumns.length) {
|
||||
await query("ALTER TABLE template_requests ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending' AFTER target_topic_id")
|
||||
}
|
||||
const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'")
|
||||
if (!templateRequestGroupsColumns.length) {
|
||||
await query("ALTER TABLE template_requests ADD COLUMN groups_json LONGTEXT NOT NULL AFTER items_json")
|
||||
}
|
||||
const templateRequestBoardItemsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'board_items_json'")
|
||||
if (!templateRequestBoardItemsColumns.length) {
|
||||
await query("ALTER TABLE template_requests ADD COLUMN board_items_json LONGTEXT NOT NULL AFTER groups_json")
|
||||
}
|
||||
const templateRequestShowNamesColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'show_character_names_snapshot'")
|
||||
if (!templateRequestShowNamesColumns.length) {
|
||||
await query("ALTER TABLE template_requests ADD COLUMN show_character_names_snapshot TINYINT(1) NOT NULL DEFAULT 0 AFTER board_items_json")
|
||||
}
|
||||
|
||||
const tierListThumbnailColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'thumbnail_src'")
|
||||
if (!tierListThumbnailColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
|
||||
}
|
||||
const tierListTopicIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'topic_id'")
|
||||
if (!tierListTopicIdColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER author_id")
|
||||
}
|
||||
const tierListShowNamesColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'show_character_names'")
|
||||
if (!tierListShowNamesColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
|
||||
}
|
||||
const tierListFeaturedColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'is_featured'")
|
||||
if (!tierListFeaturedColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN is_featured TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
|
||||
}
|
||||
const tierListFeaturedAtColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'featured_at'")
|
||||
if (!tierListFeaturedAtColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN featured_at BIGINT NOT NULL DEFAULT 0 AFTER is_featured")
|
||||
}
|
||||
const tierListFeaturedByColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'featured_by'")
|
||||
if (!tierListFeaturedByColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN featured_by VARCHAR(64) NOT NULL DEFAULT '' AFTER featured_at")
|
||||
}
|
||||
const tierListIconSizeColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'icon_size'")
|
||||
if (!tierListIconSizeColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN icon_size INT NOT NULL DEFAULT 80 AFTER show_character_names")
|
||||
}
|
||||
const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'")
|
||||
if (!tierListSourceIdColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER icon_size")
|
||||
} else if (tierListSourceIdColumns[0]?.Null !== 'YES') {
|
||||
await query('ALTER TABLE tierlists MODIFY source_tierlist_id VARCHAR(64) NULL DEFAULT NULL')
|
||||
}
|
||||
const tierListSourceTitleColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_title'")
|
||||
if (!tierListSourceTitleColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN source_snapshot_title VARCHAR(120) NOT NULL DEFAULT '' AFTER source_tierlist_id")
|
||||
}
|
||||
const tierListSourceAuthorColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_author'")
|
||||
if (!tierListSourceAuthorColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN source_snapshot_author VARCHAR(120) NOT NULL DEFAULT '' AFTER source_snapshot_title")
|
||||
}
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO topics (id, name, thumbnail_src, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name)
|
||||
INSERT INTO topics (id, slug, name, thumbnail_src, is_public, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE slug = VALUES(slug), name = VALUES(name), is_public = VALUES(is_public)
|
||||
`,
|
||||
[FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', now()]
|
||||
[FREEFORM_TOPIC_ID, FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', 1, now()]
|
||||
)
|
||||
|
||||
const countRows = await query('SELECT COUNT(*) AS count FROM topics')
|
||||
if (Number(countRows[0]?.count || 0) <= 1) {
|
||||
const createdAt = now()
|
||||
await query(
|
||||
`
|
||||
INSERT INTO topics (id, name, thumbnail_src, created_at)
|
||||
VALUES
|
||||
(?, ?, ?, ?),
|
||||
(?, ?, ?, ?)
|
||||
`,
|
||||
['example-topic', '예시 주제', '', createdAt, 'another-topic', '다른 예시 주제', '', createdAt]
|
||||
)
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO topic_items (id, topic_id, src, label, created_at)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
'img-1',
|
||||
'example-topic',
|
||||
'/uploads/seeds/example1.png',
|
||||
'샘플 1',
|
||||
createdAt,
|
||||
'img-2',
|
||||
'example-topic',
|
||||
'/uploads/seeds/example2.png',
|
||||
'샘플 2',
|
||||
createdAt,
|
||||
]
|
||||
)
|
||||
}
|
||||
})()
|
||||
|
||||
return initPromise
|
||||
@@ -634,7 +515,7 @@ async function countUsers() {
|
||||
|
||||
async function findUserByEmail(email) {
|
||||
const rows = await query(
|
||||
'SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at FROM users WHERE email = ? LIMIT 1',
|
||||
'SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE email = ? LIMIT 1',
|
||||
[email]
|
||||
)
|
||||
const row = rows[0]
|
||||
@@ -648,7 +529,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
|
||||
const rows = excludeUserId
|
||||
? await query(
|
||||
`
|
||||
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at
|
||||
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
|
||||
FROM users
|
||||
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) AND id <> ?
|
||||
LIMIT 1
|
||||
@@ -657,7 +538,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
|
||||
)
|
||||
: await query(
|
||||
`
|
||||
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at
|
||||
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
|
||||
FROM users
|
||||
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?))
|
||||
LIMIT 1
|
||||
@@ -671,7 +552,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
|
||||
|
||||
async function findUserById(id) {
|
||||
const rows = await query(
|
||||
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1',
|
||||
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE id = ? LIMIT 1',
|
||||
[id]
|
||||
)
|
||||
return mapUserRow(rows[0])
|
||||
@@ -681,14 +562,24 @@ async function createUser({ id, email, nickname, passwordHash, emailVerified = t
|
||||
const createdAt = now()
|
||||
await query(
|
||||
`
|
||||
INSERT INTO users (id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO users (id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[id, email, nickname || '', passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', createdAt]
|
||||
[id, email, nickname || '', passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt]
|
||||
)
|
||||
return findUserById(id)
|
||||
}
|
||||
|
||||
async function touchUserLastLoginAt(userId, timestamp = now()) {
|
||||
const lastLoginAt = Number(timestamp) || now()
|
||||
const staleBefore = lastLoginAt - 10 * 60 * 1000
|
||||
await query(
|
||||
'UPDATE users SET last_login_at = ? WHERE id = ? AND last_login_at < ?',
|
||||
[lastLoginAt, userId, staleBefore]
|
||||
)
|
||||
return findUserById(userId)
|
||||
}
|
||||
|
||||
async function updateUserPassword({ id, passwordHash }) {
|
||||
await query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, id])
|
||||
return findUserById(id)
|
||||
@@ -814,7 +705,7 @@ async function updateUserProfile({ id, nickname, avatarSrc }) {
|
||||
|
||||
async function findPrimaryAdminUser() {
|
||||
const rows = await query(
|
||||
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1'
|
||||
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1'
|
||||
)
|
||||
return mapUserRow(rows[0])
|
||||
}
|
||||
@@ -839,6 +730,18 @@ async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' }
|
||||
? isAsc
|
||||
? 'tierlist_count ASC, recent_activity_at ASC, u.email ASC'
|
||||
: 'tierlist_count DESC, recent_activity_at DESC, u.email ASC'
|
||||
: sort === 'followers'
|
||||
? isAsc
|
||||
? 'follower_count ASC, recent_activity_at ASC, u.email ASC'
|
||||
: 'follower_count DESC, recent_activity_at DESC, u.email ASC'
|
||||
: sort === 'favorites'
|
||||
? isAsc
|
||||
? 'received_favorite_count ASC, recent_activity_at ASC, u.email ASC'
|
||||
: 'received_favorite_count DESC, recent_activity_at DESC, u.email ASC'
|
||||
: sort === 'lastLogin'
|
||||
? isAsc
|
||||
? 'u.last_login_at ASC, recent_activity_at ASC, u.email ASC'
|
||||
: 'u.last_login_at DESC, recent_activity_at DESC, u.email ASC'
|
||||
: isAsc
|
||||
? 'recent_activity_at ASC, u.created_at ASC, u.email ASC'
|
||||
: 'recent_activity_at DESC, u.created_at ASC, u.email ASC'
|
||||
@@ -852,16 +755,21 @@ async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' }
|
||||
u.email_verified,
|
||||
u.is_admin,
|
||||
u.avatar_src,
|
||||
u.last_login_at,
|
||||
u.created_at,
|
||||
COUNT(t.id) AS tierlist_count,
|
||||
COUNT(DISTINCT t.id) AS tierlist_count,
|
||||
COUNT(DISTINCT uf.follower_id) AS follower_count,
|
||||
COUNT(DISTINCT ft.user_id, ft.tierlist_id) AS received_favorite_count,
|
||||
GREATEST(
|
||||
u.created_at,
|
||||
COALESCE(MAX(t.updated_at), 0)
|
||||
) AS recent_activity_at
|
||||
FROM users u
|
||||
LEFT JOIN tierlists t ON t.author_id = u.id
|
||||
LEFT JOIN user_follows uf ON uf.following_id = u.id
|
||||
LEFT JOIN favorite_tierlists ft ON ft.tierlist_id = t.id
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
GROUP BY u.id, u.email, u.nickname, u.email_verified, u.is_admin, u.avatar_src, u.created_at
|
||||
GROUP BY u.id, u.email, u.nickname, u.email_verified, u.is_admin, u.avatar_src, u.last_login_at, u.created_at
|
||||
ORDER BY ${orderBy}
|
||||
`,
|
||||
params
|
||||
@@ -902,7 +810,7 @@ async function listTopics(currentUserId = '', options = {}) {
|
||||
const includePrivate = !!options.includePrivate
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT id, name, thumbnail_src, is_public, display_rank, created_at
|
||||
SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at
|
||||
FROM topics
|
||||
WHERE id <> ?
|
||||
${includePrivate ? '' : 'AND is_public = 1'}
|
||||
@@ -926,7 +834,39 @@ async function listTopics(currentUserId = '', options = {}) {
|
||||
}
|
||||
|
||||
async function findTopicById(id) {
|
||||
const rows = await query('SELECT id, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1', [id])
|
||||
const rows = await query(
|
||||
'SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1',
|
||||
[id]
|
||||
)
|
||||
return mapTopicRow(rows[0])
|
||||
}
|
||||
|
||||
async function findTopicBySlug(slug) {
|
||||
const normalizedSlug = normalizeTopicSlug(slug)
|
||||
if (!normalizedSlug) return null
|
||||
const rows = await query(
|
||||
'SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE slug = ? LIMIT 1',
|
||||
[normalizedSlug]
|
||||
)
|
||||
return mapTopicRow(rows[0])
|
||||
}
|
||||
|
||||
async function findTopicByIdentifier(topicRef) {
|
||||
const rawRef = String(topicRef || '').trim()
|
||||
if (!rawRef) return null
|
||||
if (rawRef === FREEFORM_TOPIC_ID) return findTopicById(FREEFORM_TOPIC_ID)
|
||||
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at
|
||||
FROM topics
|
||||
WHERE id = ? OR slug = ?
|
||||
ORDER BY
|
||||
CASE WHEN id = ? THEN 0 ELSE 1 END ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[rawRef, normalizeTopicSlug(rawRef), rawRef]
|
||||
)
|
||||
return mapTopicRow(rows[0])
|
||||
}
|
||||
|
||||
@@ -952,23 +892,37 @@ async function findTopicItemById(itemId) {
|
||||
return mapTopicItemRow(rows[0])
|
||||
}
|
||||
|
||||
async function getTopicDetail(topicId) {
|
||||
const topic = await findTopicById(topicId)
|
||||
async function getTopicDetail(topicRef) {
|
||||
const topic = await findTopicByIdentifier(topicRef)
|
||||
if (!topic) return null
|
||||
const items = await listTopicItems(topicId)
|
||||
const items = await listTopicItems(topic.id)
|
||||
return { topic, template: topic, items }
|
||||
}
|
||||
|
||||
async function createTopic({ id, name, isPublic = true }) {
|
||||
await query('INSERT INTO topics (id, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
|
||||
id,
|
||||
async function createTopic({ slug, name, isPublic = true }) {
|
||||
const topicId = nanoid()
|
||||
const topicSlug = assertTopicSlug(slug)
|
||||
await query('INSERT INTO topics (id, slug, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [
|
||||
topicId,
|
||||
topicSlug,
|
||||
name,
|
||||
'',
|
||||
isPublic ? 1 : 0,
|
||||
null,
|
||||
now(),
|
||||
])
|
||||
return findTopicById(id)
|
||||
return findTopicById(topicId)
|
||||
}
|
||||
|
||||
async function updateTopicMeta(topicId, { slug, name, isPublic }) {
|
||||
const topicSlug = assertTopicSlug(slug)
|
||||
await query('UPDATE topics SET slug = ?, name = ?, is_public = ? WHERE id = ?', [
|
||||
topicSlug,
|
||||
name,
|
||||
isPublic ? 1 : 0,
|
||||
topicId,
|
||||
])
|
||||
return findTopicById(topicId)
|
||||
}
|
||||
|
||||
async function updateTopicThumbnail(topicId, thumbnailSrc) {
|
||||
@@ -1248,6 +1202,12 @@ async function findImageAssetById(id) {
|
||||
return mapImageAssetRow(rows[0])
|
||||
}
|
||||
|
||||
async function updateImageAssetSrc({ fromSrc, toSrc }) {
|
||||
if (!fromSrc || !toSrc || fromSrc === toSrc) return null
|
||||
await query('UPDATE image_assets SET src = ? WHERE src = ?', [toSrc, fromSrc])
|
||||
return findImageAssetBySrc(toSrc)
|
||||
}
|
||||
|
||||
async function getReferencedUploadFootprint() {
|
||||
const [referencedSrcs, assets] = await Promise.all([listReferencedUploadSources(), listImageAssets()])
|
||||
const assetMap = new Map(assets.map((asset) => [asset.src, asset]))
|
||||
@@ -1777,6 +1737,32 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
getCustomItemUsageMeta(),
|
||||
])
|
||||
|
||||
const [userAvatarRows, topicThumbnailRows, tierListThumbnailRows, templateRequestThumbnailRows] = await Promise.all([
|
||||
query("SELECT avatar_src AS src FROM users WHERE avatar_src <> ''"),
|
||||
query("SELECT thumbnail_src AS src FROM topics WHERE thumbnail_src <> ''"),
|
||||
query("SELECT thumbnail_src AS src FROM tierlists WHERE thumbnail_src <> ''"),
|
||||
query("SELECT thumbnail_src_snapshot AS src FROM template_requests WHERE thumbnail_src_snapshot <> ''"),
|
||||
])
|
||||
|
||||
const avatarSrcSet = new Set(userAvatarRows.map((row) => row.src).filter(Boolean))
|
||||
const thumbnailSrcSet = new Set([
|
||||
...topicThumbnailRows.map((row) => row.src).filter(Boolean),
|
||||
...tierListThumbnailRows.map((row) => row.src).filter(Boolean),
|
||||
...templateRequestThumbnailRows.map((row) => row.src).filter(Boolean),
|
||||
])
|
||||
|
||||
const resolveLibraryAssetKind = (src) => {
|
||||
if (avatarSrcSet.has(src)) return 'avatar'
|
||||
if (thumbnailSrcSet.has(src)) return 'thumbnail'
|
||||
return getAssetLibraryKind(src)
|
||||
}
|
||||
|
||||
const resolveLibraryAssetLabel = (src) => {
|
||||
if (avatarSrcSet.has(src)) return '프로필 아바타'
|
||||
if (thumbnailSrcSet.has(src)) return '썸네일 이미지'
|
||||
return getAssetLibrarySourceLabel(src)
|
||||
}
|
||||
|
||||
const templateLinkedBySrc = new Map()
|
||||
topicItemRows.forEach((row) => {
|
||||
if (!row?.src) return
|
||||
@@ -1799,8 +1785,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
ownerEmail: row.email,
|
||||
usageCount: usageMeta.usageMap.get(row.id) || 0,
|
||||
linkedTemplates,
|
||||
assetKind: resolveLibraryAssetKind(row.src),
|
||||
sourceType: 'user',
|
||||
sourceLabel: '사용자 업로드',
|
||||
sourceLabel: '사용자 아이템',
|
||||
canDelete: true,
|
||||
}
|
||||
})
|
||||
@@ -1808,7 +1795,11 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
const templateSrcSet = new Set(topicItemRows.map((row) => row.src).filter(Boolean))
|
||||
const customSrcSet = new Set(customRows.map((row) => row.src).filter(Boolean))
|
||||
const assetLibraryItems = assetRows
|
||||
.filter((row) => row?.src && !templateSrcSet.has(row.src) && !customSrcSet.has(row.src))
|
||||
.filter((row) => {
|
||||
if (!row?.src) return false
|
||||
if (avatarSrcSet.has(row.src) || thumbnailSrcSet.has(row.src)) return true
|
||||
return !templateSrcSet.has(row.src) && !customSrcSet.has(row.src)
|
||||
})
|
||||
.map((row) => ({
|
||||
id: `asset:${row.id}`,
|
||||
assetId: row.id,
|
||||
@@ -1820,8 +1811,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
ownerEmail: '',
|
||||
usageCount: 0,
|
||||
linkedTemplates: [],
|
||||
sourceType: 'template',
|
||||
sourceLabel: '관리자 템플릿',
|
||||
sourceType: 'asset',
|
||||
sourceLabel: resolveLibraryAssetLabel(row.src),
|
||||
assetKind: resolveLibraryAssetKind(row.src),
|
||||
canDelete: true,
|
||||
sourceTopicId: '',
|
||||
sourceTopicName: '',
|
||||
@@ -1838,8 +1830,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
ownerEmail: '',
|
||||
usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size,
|
||||
linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
|
||||
assetKind: resolveLibraryAssetKind(row.src),
|
||||
sourceType: 'template',
|
||||
sourceLabel: '관리자 템플릿',
|
||||
sourceLabel: '템플릿 아이템',
|
||||
canDelete: true,
|
||||
sourceTopicId: row.topic_id,
|
||||
sourceTopicName: row.topic_name || row.topic_id,
|
||||
@@ -1863,7 +1856,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
|
||||
siblings.forEach((entry) => {
|
||||
if (entry.sourceType === 'user') userReferenceCount += 1
|
||||
else if (entry.isAssetLibraryItem) assetReferenceCount += 1
|
||||
else if (entry.sourceType === 'asset' || entry.isAssetLibraryItem) assetReferenceCount += 1
|
||||
else templateReferenceCount += 1
|
||||
;(entry.linkedTemplates || []).forEach((template) => {
|
||||
if (template?.id) linkedTemplates.set(template.id, template)
|
||||
@@ -1885,6 +1878,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
label: entry.label,
|
||||
sourceLabel: entry.sourceLabel,
|
||||
sourceType: entry.sourceType,
|
||||
assetKind: entry.assetKind || '',
|
||||
ownerName: entry.ownerName,
|
||||
createdAt: entry.createdAt,
|
||||
sourceTopicId: entry.sourceTopicId || '',
|
||||
@@ -1902,11 +1896,17 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
case 'template':
|
||||
return item.sourceType === 'template' && !item.isAssetLibraryItem
|
||||
case 'asset':
|
||||
return !!item.isAssetLibraryItem
|
||||
return item.sourceType === 'asset' || !!item.isAssetLibraryItem
|
||||
case 'thumbnail':
|
||||
return item.assetKind === 'thumbnail'
|
||||
case 'avatar':
|
||||
return item.assetKind === 'avatar'
|
||||
case 'library':
|
||||
return item.sourceType === 'user' || (item.sourceType === 'template' && !item.isAssetLibraryItem)
|
||||
case 'unused-user':
|
||||
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedTemplates.length === 0
|
||||
case 'unused-admin':
|
||||
return !!item.isAssetLibraryItem
|
||||
return item.sourceType === 'asset' || !!item.isAssetLibraryItem
|
||||
default:
|
||||
return true
|
||||
}
|
||||
@@ -2012,8 +2012,15 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
|
||||
const params = []
|
||||
let whereClause = 'WHERE t.is_public = 1'
|
||||
if (topicId) {
|
||||
const topic = await findTopicByIdentifier(topicId)
|
||||
if (!topic) {
|
||||
return {
|
||||
featuredTierLists: [],
|
||||
tierLists: [],
|
||||
}
|
||||
}
|
||||
whereClause += ' AND t.topic_id = ?'
|
||||
params.push(topicId)
|
||||
params.push(topic.id)
|
||||
}
|
||||
if ((queryText || '').trim()) {
|
||||
const search = `%${queryText.trim()}%`
|
||||
@@ -2026,6 +2033,8 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
|
||||
SELECT
|
||||
t.id,
|
||||
t.topic_id,
|
||||
tp.slug AS topic_slug,
|
||||
tp.name AS topic_name,
|
||||
t.title,
|
||||
t.thumbnail_src,
|
||||
t.is_featured,
|
||||
@@ -2039,6 +2048,7 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
|
||||
u.avatar_src
|
||||
FROM tierlists t
|
||||
INNER JOIN users u ON u.id = t.author_id
|
||||
INNER JOIN topics tp ON tp.id = t.topic_id
|
||||
${whereClause}
|
||||
ORDER BY t.is_featured DESC, t.featured_at DESC, t.updated_at DESC
|
||||
LIMIT 200
|
||||
@@ -2049,6 +2059,8 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
|
||||
const tierLists = rows.map((row) => ({
|
||||
id: row.id,
|
||||
topicId: row.topic_id,
|
||||
topicSlug: row.topic_slug || row.topic_id,
|
||||
topicName: row.topic_name || '',
|
||||
title: row.title,
|
||||
thumbnailSrc: row.thumbnail_src || '',
|
||||
isFeatured: !!row.is_featured,
|
||||
@@ -2108,6 +2120,7 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
|
||||
t.id,
|
||||
t.author_id,
|
||||
t.topic_id,
|
||||
tp.slug AS topic_slug,
|
||||
tp.name AS topic_name,
|
||||
t.title,
|
||||
t.thumbnail_src,
|
||||
@@ -2158,6 +2171,8 @@ async function listUserTierLists(userId) {
|
||||
SELECT
|
||||
t.id,
|
||||
t.topic_id,
|
||||
tp.slug AS topic_slug,
|
||||
tp.name AS topic_name,
|
||||
t.title,
|
||||
t.thumbnail_src,
|
||||
t.created_at,
|
||||
@@ -2168,6 +2183,7 @@ async function listUserTierLists(userId) {
|
||||
u.avatar_src
|
||||
FROM tierlists t
|
||||
INNER JOIN users u ON u.id = t.author_id
|
||||
INNER JOIN topics tp ON tp.id = t.topic_id
|
||||
WHERE t.author_id = ?
|
||||
ORDER BY updated_at DESC
|
||||
`,
|
||||
@@ -2177,6 +2193,8 @@ async function listUserTierLists(userId) {
|
||||
const tierLists = rows.map((row) => ({
|
||||
id: row.id,
|
||||
topicId: row.topic_id,
|
||||
topicSlug: row.topic_slug || row.topic_id,
|
||||
topicName: row.topic_name || '',
|
||||
title: row.title,
|
||||
thumbnailSrc: row.thumbnail_src || '',
|
||||
createdAt: Number(row.created_at),
|
||||
@@ -2275,6 +2293,7 @@ async function listPublicTierListsByAuthor(authorId, currentUserId = '', queryTe
|
||||
SELECT
|
||||
t.id,
|
||||
t.topic_id,
|
||||
tp.slug AS topic_slug,
|
||||
tp.name AS topic_name,
|
||||
t.title,
|
||||
t.thumbnail_src,
|
||||
@@ -2301,6 +2320,7 @@ async function listPublicTierListsByAuthor(authorId, currentUserId = '', queryTe
|
||||
const tierLists = rows.map((row) => ({
|
||||
id: row.id,
|
||||
topicId: row.topic_id,
|
||||
topicSlug: row.topic_slug || row.topic_id,
|
||||
topicName: row.topic_name || '',
|
||||
title: row.title,
|
||||
thumbnailSrc: row.thumbnail_src || '',
|
||||
@@ -2336,6 +2356,7 @@ async function listFollowingTierLists(userId, queryText = '') {
|
||||
SELECT
|
||||
t.id,
|
||||
t.topic_id,
|
||||
tp.slug AS topic_slug,
|
||||
tp.name AS topic_name,
|
||||
t.title,
|
||||
t.thumbnail_src,
|
||||
@@ -2364,6 +2385,7 @@ async function listFollowingTierLists(userId, queryText = '') {
|
||||
const tierLists = rows.map((row) => ({
|
||||
id: row.id,
|
||||
topicId: row.topic_id,
|
||||
topicSlug: row.topic_slug || row.topic_id,
|
||||
topicName: row.topic_name || '',
|
||||
title: row.title,
|
||||
thumbnailSrc: row.thumbnail_src || '',
|
||||
@@ -2414,9 +2436,18 @@ function getAutoThumbnailSrc(groups = [], pool = []) {
|
||||
return fallbackItem?.src || ''
|
||||
}
|
||||
|
||||
async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limit = 50, currentUserId = '' } = {}) {
|
||||
async function listAdminTierLists({
|
||||
queryText = '',
|
||||
topicId = '',
|
||||
page = 1,
|
||||
limit = 50,
|
||||
sort = 'recent',
|
||||
minFavorites = 0,
|
||||
currentUserId = '',
|
||||
} = {}) {
|
||||
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||
const normalizedPage = Math.max(Number(page) || 1, 1)
|
||||
const normalizedMinFavorites = Math.max(Number(minFavorites) || 0, 0)
|
||||
const hasQuery = !!(queryText || '').trim()
|
||||
const resolvedTopicId = (topicId || '').trim()
|
||||
const hasTopicId = !!resolvedTopicId
|
||||
@@ -2433,11 +2464,12 @@ async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limi
|
||||
whereParts.push(`(
|
||||
t.title LIKE ?
|
||||
OR tp.name LIKE ?
|
||||
OR tp.slug LIKE ?
|
||||
OR tp.id LIKE ?
|
||||
OR u.email LIKE ?
|
||||
OR u.nickname LIKE ?
|
||||
)`)
|
||||
params.push(search, search, search, search, search)
|
||||
params.push(search, search, search, search, search, search)
|
||||
}
|
||||
|
||||
const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''
|
||||
@@ -2448,6 +2480,7 @@ async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limi
|
||||
t.id,
|
||||
t.author_id,
|
||||
t.topic_id,
|
||||
tp.slug AS topic_slug,
|
||||
tp.name AS topic_name,
|
||||
t.title,
|
||||
t.thumbnail_src,
|
||||
@@ -2477,7 +2510,7 @@ async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limi
|
||||
params
|
||||
)
|
||||
|
||||
const allItems = rows.map((row) => {
|
||||
const baseItems = rows.map((row) => {
|
||||
const tierList = mapTierListRow(row)
|
||||
const poolItems = uniqueTierListItems(tierList.pool)
|
||||
const extraItems = poolItems.filter((item) => item.origin === 'custom')
|
||||
@@ -2488,23 +2521,47 @@ async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limi
|
||||
extraItems,
|
||||
}
|
||||
})
|
||||
|
||||
const total = allItems.length
|
||||
const offset = (normalizedPage - 1) * normalizedLimit
|
||||
const pagedTierLists = allItems.slice(offset, offset + normalizedLimit)
|
||||
const favoriteStats = await getFavoriteStatsForTierListIds(
|
||||
pagedTierLists.map((tierList) => tierList.id),
|
||||
baseItems.map((tierList) => tierList.id),
|
||||
currentUserId
|
||||
)
|
||||
const filteredItems = applyFavoriteMetaToTierLists(baseItems, favoriteStats)
|
||||
.filter((tierList) => Number(tierList.favoriteCount || 0) >= normalizedMinFavorites)
|
||||
.sort((a, b) => {
|
||||
if (sort === 'favorites') {
|
||||
return (
|
||||
Number(b.favoriteCount || 0) - Number(a.favoriteCount || 0) ||
|
||||
Number(b.updatedAt || 0) - Number(a.updatedAt || 0) ||
|
||||
Number(b.createdAt || 0) - Number(a.createdAt || 0)
|
||||
)
|
||||
}
|
||||
if (sort === 'created') {
|
||||
return (
|
||||
Number(b.createdAt || 0) - Number(a.createdAt || 0) ||
|
||||
Number(b.updatedAt || 0) - Number(a.updatedAt || 0) ||
|
||||
String(a.title || '').localeCompare(String(b.title || ''))
|
||||
)
|
||||
}
|
||||
return (
|
||||
Number(b.updatedAt || 0) - Number(a.updatedAt || 0) ||
|
||||
Number(b.createdAt || 0) - Number(a.createdAt || 0) ||
|
||||
String(a.title || '').localeCompare(String(b.title || ''))
|
||||
)
|
||||
})
|
||||
|
||||
const total = filteredItems.length
|
||||
const offset = (normalizedPage - 1) * normalizedLimit
|
||||
const pagedTierLists = filteredItems.slice(offset, offset + normalizedLimit)
|
||||
return {
|
||||
tierLists: applyFavoriteMetaToTierLists(pagedTierLists, favoriteStats),
|
||||
tierLists: pagedTierLists,
|
||||
total,
|
||||
page: normalizedPage,
|
||||
limit: normalizedLimit,
|
||||
}
|
||||
}
|
||||
|
||||
async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) {
|
||||
async function summarizeAdminTierLists({ queryText = '', topicId = '', minFavorites = 0 } = {}) {
|
||||
const normalizedMinFavorites = Math.max(Number(minFavorites) || 0, 0)
|
||||
const hasQuery = !!(queryText || '').trim()
|
||||
const resolvedTopicId = (topicId || '').trim()
|
||||
const hasTopicId = !!resolvedTopicId
|
||||
@@ -2521,17 +2578,19 @@ async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) {
|
||||
whereParts.push(`(
|
||||
t.title LIKE ?
|
||||
OR tp.name LIKE ?
|
||||
OR tp.slug LIKE ?
|
||||
OR tp.id LIKE ?
|
||||
OR u.email LIKE ?
|
||||
OR u.nickname LIKE ?
|
||||
)`)
|
||||
params.push(search, search, search, search, search)
|
||||
params.push(search, search, search, search, search, search)
|
||||
}
|
||||
|
||||
const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT t.is_public, t.is_featured
|
||||
, t.id
|
||||
FROM tierlists t
|
||||
INNER JOIN users u ON u.id = t.author_id
|
||||
INNER JOIN topics tp ON tp.id = t.topic_id
|
||||
@@ -2540,9 +2599,16 @@ async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) {
|
||||
params
|
||||
)
|
||||
|
||||
const total = rows.length
|
||||
const publicCount = rows.filter((row) => Number(row.is_public) === 1).length
|
||||
const featuredCount = rows.filter((row) => Number(row.is_featured) === 1).length
|
||||
const favoriteStats = normalizedMinFavorites > 0
|
||||
? await getFavoriteStatsForTierListIds(rows.map((row) => row.id), '')
|
||||
: { countMap: new Map(), favoritedSet: new Set() }
|
||||
const scopedRows = normalizedMinFavorites > 0
|
||||
? rows.filter((row) => Number(favoriteStats.countMap.get(row.id) || 0) >= normalizedMinFavorites)
|
||||
: rows
|
||||
|
||||
const total = scopedRows.length
|
||||
const publicCount = scopedRows.filter((row) => Number(row.is_public) === 1).length
|
||||
const featuredCount = scopedRows.filter((row) => Number(row.is_featured) === 1).length
|
||||
return {
|
||||
total,
|
||||
publicCount,
|
||||
@@ -2558,6 +2624,7 @@ async function findTierListById(id, currentUserId = '') {
|
||||
t.id,
|
||||
t.author_id,
|
||||
t.topic_id,
|
||||
tp.slug AS topic_slug,
|
||||
tp.name AS topic_name,
|
||||
t.title,
|
||||
t.thumbnail_src,
|
||||
@@ -2696,7 +2763,9 @@ async function findTemplateRequestById(id) {
|
||||
u.nickname,
|
||||
u.email,
|
||||
u.avatar_src AS requester_avatar_src,
|
||||
sg.slug AS source_topic_slug,
|
||||
sg.name AS source_topic_name,
|
||||
tg.slug AS target_topic_slug,
|
||||
tg.name AS target_topic_name
|
||||
FROM template_requests tr
|
||||
INNER JOIN users u ON u.id = tr.requester_id
|
||||
@@ -2738,7 +2807,9 @@ async function listAdminTemplateRequests({ status = 'pending', statuses = [] } =
|
||||
u.nickname,
|
||||
u.email,
|
||||
u.avatar_src AS requester_avatar_src,
|
||||
sg.slug AS source_topic_slug,
|
||||
sg.name AS source_topic_name,
|
||||
tg.slug AS target_topic_slug,
|
||||
tg.name AS target_topic_name
|
||||
FROM template_requests tr
|
||||
INNER JOIN users u ON u.id = tr.requester_id
|
||||
@@ -2932,6 +3003,7 @@ module.exports = {
|
||||
findUserById,
|
||||
findUserProfileById,
|
||||
createUser,
|
||||
touchUserLastLoginAt,
|
||||
updateUserPassword,
|
||||
verifyUserEmail,
|
||||
createEmailVerificationToken,
|
||||
@@ -2948,15 +3020,19 @@ module.exports = {
|
||||
adminDeleteUser,
|
||||
listTopics,
|
||||
findTopicById,
|
||||
findTopicBySlug,
|
||||
findTopicByIdentifier,
|
||||
listTopicItems,
|
||||
findTopicItemById,
|
||||
getTopicDetail,
|
||||
createTopic,
|
||||
updateTopicMeta,
|
||||
updateTopicThumbnail,
|
||||
updateTopicVisibility,
|
||||
findImageAssetByHash,
|
||||
findImageAssetBySrc,
|
||||
findImageAssetById,
|
||||
updateImageAssetSrc,
|
||||
createImageAsset,
|
||||
createImageOptimizationJob,
|
||||
findImageOptimizationJobById,
|
||||
@@ -2964,6 +3040,7 @@ module.exports = {
|
||||
listRecentImageOptimizationJobs,
|
||||
listUnusedImageAssets,
|
||||
deleteImageAssets,
|
||||
listImageAssets,
|
||||
listReferencedUploadSources,
|
||||
listReferencedUploadUsage,
|
||||
replaceUploadSourceReferences,
|
||||
|
||||
@@ -75,10 +75,13 @@ async function optimizeAndPersist({ file, width, height, fit, quality }) {
|
||||
}
|
||||
}
|
||||
|
||||
const filename = nanoid() + '.webp'
|
||||
const absoluteDir = path.join(UPLOAD_ROOT, OPTIMIZED_DIR)
|
||||
const basename = nanoid()
|
||||
const shardDirectory = basename.slice(0, 2)
|
||||
const filename = basename + '.webp'
|
||||
const relativeDir = path.join(OPTIMIZED_DIR, shardDirectory)
|
||||
const absoluteDir = path.join(UPLOAD_ROOT, relativeDir)
|
||||
const absolutePath = path.join(absoluteDir, filename)
|
||||
const src = '/uploads/' + OPTIMIZED_DIR + '/' + filename
|
||||
const src = '/uploads/' + relativeDir.split(path.sep).join('/') + '/' + filename
|
||||
|
||||
await fs.mkdir(absoluteDir, { recursive: true })
|
||||
await fs.writeFile(absolutePath, data)
|
||||
|
||||
@@ -10,13 +10,14 @@ const {
|
||||
findUserByEmail,
|
||||
findUserByNickname,
|
||||
findTopicById,
|
||||
findTopicBySlug,
|
||||
findTopicItemById,
|
||||
listTopicItems,
|
||||
findImageAssetById,
|
||||
createTopic,
|
||||
listTopics,
|
||||
updateTopicMeta,
|
||||
updateTopicThumbnail,
|
||||
updateTopicVisibility,
|
||||
createTopicItem,
|
||||
updateTopicItemLabel,
|
||||
updateTopicItemDisplayOrder,
|
||||
@@ -119,16 +120,23 @@ function canManageAdminRole(actingUser, primaryAdmin) {
|
||||
|
||||
router.post('/templates', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
id: z.string().min(1),
|
||||
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
name: z.string().min(1).max(60),
|
||||
isPublic: z.boolean().optional().default(false),
|
||||
thumbnailSrc: z.string().max(255).optional().default(''),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
const exists = await findTopicById(parsed.data.id)
|
||||
if (exists) return res.status(409).json({ error: 'topic_id_taken' })
|
||||
const template = await createTopic({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic })
|
||||
const exists = await findTopicBySlug(parsed.data.slug)
|
||||
if (exists) return res.status(409).json({ error: 'topic_slug_taken' })
|
||||
let template
|
||||
try {
|
||||
template = await createTopic({ slug: parsed.data.slug, name: parsed.data.name, isPublic: parsed.data.isPublic })
|
||||
} catch (error) {
|
||||
if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' })
|
||||
if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' })
|
||||
throw error
|
||||
}
|
||||
if (parsed.data.thumbnailSrc) {
|
||||
const copiedThumb = await copyUploadIntoTopicAsset(parsed.data.thumbnailSrc)
|
||||
await updateTopicThumbnail(template.id, copiedThumb)
|
||||
@@ -139,7 +147,9 @@ router.post('/templates', requireAdmin, async (req, res) => {
|
||||
|
||||
router.patch('/templates/:templateId', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
isPublic: z.boolean(),
|
||||
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
|
||||
name: z.string().trim().min(1).max(60).optional(),
|
||||
isPublic: z.boolean().optional(),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
@@ -148,8 +158,21 @@ router.patch('/templates/:templateId', requireAdmin, async (req, res) => {
|
||||
const template = await findTopicById(templateId)
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const updated = await updateTopicVisibility(template.id, parsed.data.isPublic)
|
||||
res.json({ template: updated })
|
||||
try {
|
||||
const updated =
|
||||
typeof parsed.data.name === 'string' || typeof parsed.data.slug === 'string' || typeof parsed.data.isPublic === 'boolean'
|
||||
? await updateTopicMeta(template.id, {
|
||||
slug: parsed.data.slug || template.slug,
|
||||
name: parsed.data.name || template.name,
|
||||
isPublic: typeof parsed.data.isPublic === 'boolean' ? parsed.data.isPublic : template.isPublic,
|
||||
})
|
||||
: await findTopicById(template.id)
|
||||
return res.json({ template: updated })
|
||||
} catch (error) {
|
||||
if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' })
|
||||
if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' })
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
router.patch('/templates/display-order', requireAdmin, async (req, res) => {
|
||||
@@ -304,7 +327,7 @@ router.delete('/templates/:templateId', requireAdmin, async (req, res) => {
|
||||
router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
label: z.string().trim().min(1).max(60),
|
||||
sourceType: z.enum(['template', 'user']).optional().default('user'),
|
||||
sourceType: z.enum(['template', 'user', 'asset']).optional().default('user'),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
@@ -332,7 +355,10 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
page: z.coerce.number().int().min(1).optional().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
||||
filter: z.enum(['all', 'user', 'template', 'asset', 'unused-user', 'unused-admin']).optional().default('all'),
|
||||
filter: z
|
||||
.enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused-user', 'unused-admin'])
|
||||
.optional()
|
||||
.default('library'),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
@@ -350,6 +376,8 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
topicId: z.string().trim().max(120).optional().default(''),
|
||||
sort: z.enum(['recent', 'created', 'favorites']).optional().default('recent'),
|
||||
minFavorites: z.coerce.number().int().min(0).max(1000000).optional().default(0),
|
||||
page: z.coerce.number().int().min(1).optional().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
||||
})
|
||||
@@ -359,6 +387,8 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||
const result = await listAdminTierLists({
|
||||
queryText: parsed.data.q,
|
||||
topicId: parsed.data.topicId,
|
||||
sort: parsed.data.sort,
|
||||
minFavorites: parsed.data.minFavorites,
|
||||
page: parsed.data.page,
|
||||
limit: parsed.data.limit,
|
||||
currentUserId: req.session?.userId || '',
|
||||
@@ -370,6 +400,7 @@ router.get('/tierlists/stats', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
topicId: z.string().trim().max(120).optional().default(''),
|
||||
minFavorites: z.coerce.number().int().min(0).max(1000000).optional().default(0),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
@@ -377,6 +408,7 @@ router.get('/tierlists/stats', requireAdmin, async (req, res) => {
|
||||
const result = await summarizeAdminTierLists({
|
||||
queryText: parsed.data.q,
|
||||
topicId: parsed.data.topicId,
|
||||
minFavorites: parsed.data.minFavorites,
|
||||
})
|
||||
res.json(result)
|
||||
})
|
||||
@@ -623,11 +655,11 @@ function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}
|
||||
})
|
||||
}
|
||||
|
||||
async function createTemplateFromTierList({ tierList, templateId, templateName }) {
|
||||
await createTopic({ id: templateId, name: templateName, isPublic: false })
|
||||
async function createTemplateFromTierList({ tierList, templateSlug, templateName }) {
|
||||
const template = await createTopic({ slug: templateSlug, name: templateName, isPublic: false })
|
||||
if (tierList.thumbnailSrc) {
|
||||
const copiedThumb = await copyUploadIntoTopicAsset(tierList.thumbnailSrc)
|
||||
await updateTopicThumbnail(templateId, copiedThumb)
|
||||
await updateTopicThumbnail(template.id, copiedThumb)
|
||||
}
|
||||
|
||||
const createdItems = []
|
||||
@@ -636,36 +668,45 @@ async function createTemplateFromTierList({ tierList, templateId, templateName }
|
||||
createdItems.push(
|
||||
await createTopicItem({
|
||||
id: nanoid(),
|
||||
topicId: templateId,
|
||||
topicId: template.id,
|
||||
src: copiedSrc,
|
||||
label: item.label,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return { template: await findTopicById(templateId), items: createdItems }
|
||||
return { template: await findTopicById(template.id), items: createdItems }
|
||||
}
|
||||
|
||||
async function createTemplateFromRequest({ templateRequest, templateId, templateName }) {
|
||||
await createTopic({ id: templateId, name: templateName, isPublic: false })
|
||||
async function createTemplateFromRequest({ templateRequest, templateSlug, templateName }) {
|
||||
const template = await createTopic({ slug: templateSlug, name: templateName, isPublic: false })
|
||||
|
||||
if (templateRequest.thumbnailSrc) {
|
||||
const copiedThumb = await copyUploadIntoTopicAsset(templateRequest.thumbnailSrc)
|
||||
await updateTopicThumbnail(templateId, copiedThumb)
|
||||
await updateTopicThumbnail(template.id, copiedThumb)
|
||||
}
|
||||
|
||||
const items = await promoteSnapshotItemsToTemplate({
|
||||
items: templateRequest.items || [],
|
||||
templateId,
|
||||
templateId: template.id,
|
||||
})
|
||||
|
||||
return { template: await findTopicById(templateId), items }
|
||||
return { template: await findTopicById(template.id), items }
|
||||
}
|
||||
|
||||
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||
const result = await listCustomItems({ page: 1, limit: 10000, filterMode: 'all' })
|
||||
const target = result.items.find((item) => item.id === req.params.itemId)
|
||||
if (!target) return res.status(404).json({ error: 'not_found' })
|
||||
if (target.sourceType === 'asset' || String(target.id || '').startsWith('asset:')) {
|
||||
const assetId = String(target.id).slice('asset:'.length)
|
||||
const asset = await findImageAssetById(assetId)
|
||||
if (!asset) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteImageAssets([assetId])
|
||||
await removeUploadFiles([asset.src])
|
||||
return res.json({ ok: true, sourceType: 'asset' })
|
||||
}
|
||||
|
||||
if (target.sourceType === 'template') {
|
||||
if (String(target.id || '').startsWith('asset:')) {
|
||||
const assetId = String(target.id).slice('asset:'.length)
|
||||
@@ -743,28 +784,34 @@ router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, re
|
||||
|
||||
router.post('/tierlists/:tierListId/create-template', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
topicId: z.string().trim().min(1).max(120),
|
||||
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
itemIds: z.array(z.string().min(1)).optional().default([]),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const exists = await findTopicById(parsed.data.topicId)
|
||||
if (exists) return res.status(409).json({ error: 'topic_id_taken' })
|
||||
const exists = await findTopicBySlug(parsed.data.slug)
|
||||
if (exists) return res.status(409).json({ error: 'topic_slug_taken' })
|
||||
|
||||
const tierList = await findTierListById(req.params.tierListId)
|
||||
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const result = await createTemplateFromTierList({
|
||||
tierList: {
|
||||
...tierList,
|
||||
pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool,
|
||||
},
|
||||
templateId: parsed.data.topicId,
|
||||
templateName: parsed.data.name,
|
||||
})
|
||||
res.json(result)
|
||||
try {
|
||||
const result = await createTemplateFromTierList({
|
||||
tierList: {
|
||||
...tierList,
|
||||
pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool,
|
||||
},
|
||||
templateSlug: parsed.data.slug,
|
||||
templateName: parsed.data.name,
|
||||
})
|
||||
return res.json(result)
|
||||
} catch (error) {
|
||||
if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' })
|
||||
if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' })
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
router.patch('/tierlists/:tierListId', requireAdmin, async (req, res) => {
|
||||
@@ -814,20 +861,27 @@ router.post('/template-requests/:requestId/approve', requireAdmin, async (req, r
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
topicId: z.string().trim().min(1).max(120),
|
||||
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const exists = await findTopicById(parsed.data.topicId)
|
||||
if (exists) return res.status(409).json({ error: 'topic_id_taken' })
|
||||
const exists = await findTopicBySlug(parsed.data.slug)
|
||||
if (exists) return res.status(409).json({ error: 'topic_slug_taken' })
|
||||
|
||||
const result = await createTemplateFromRequest({
|
||||
templateRequest,
|
||||
templateId: parsed.data.topicId,
|
||||
templateName: parsed.data.name,
|
||||
})
|
||||
let result
|
||||
try {
|
||||
result = await createTemplateFromRequest({
|
||||
templateRequest,
|
||||
templateSlug: parsed.data.slug,
|
||||
templateName: parsed.data.name,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' })
|
||||
if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' })
|
||||
throw error
|
||||
}
|
||||
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
|
||||
res.json({ request, ...result })
|
||||
})
|
||||
@@ -970,7 +1024,7 @@ router.delete('/custom-items', requireAdmin, async (req, res) => {
|
||||
router.get('/users', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
sort: z.enum(['recent', 'created', 'tierlists']).optional().default('recent'),
|
||||
sort: z.enum(['recent', 'lastLogin', 'created', 'tierlists', 'followers', 'favorites']).optional().default('recent'),
|
||||
direction: z.enum(['asc', 'desc']).optional().default('desc'),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
findUserByNickname,
|
||||
findUserById,
|
||||
createUser,
|
||||
touchUserLastLoginAt,
|
||||
updateUserPassword,
|
||||
verifyUserEmail,
|
||||
createEmailVerificationToken,
|
||||
@@ -204,7 +205,8 @@ router.post('/signup', async (req, res) => {
|
||||
|
||||
try {
|
||||
await establishSession(req, user)
|
||||
res.json({ user: await serializeUser(user), verificationRequired: false })
|
||||
const touchedUser = await touchUserLastLoginAt(user.id)
|
||||
res.json({ user: await serializeUser(touchedUser || user), verificationRequired: false })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: 'session_save_failed' })
|
||||
}
|
||||
@@ -227,7 +229,8 @@ router.post('/login', async (req, res) => {
|
||||
|
||||
try {
|
||||
await establishSession(req, user)
|
||||
res.json({ user: await serializeUser(user) })
|
||||
const touchedUser = await touchUserLastLoginAt(user.id)
|
||||
res.json({ user: await serializeUser(touchedUser || user) })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: 'session_save_failed' })
|
||||
}
|
||||
@@ -240,7 +243,7 @@ router.post('/logout', async (req, res) => {
|
||||
|
||||
router.get('/me', async (req, res) => {
|
||||
if (!req.session || !req.session.userId) return res.json({ user: null })
|
||||
const user = await findUserById(req.session.userId)
|
||||
const user = await touchUserLastLoginAt(req.session.userId)
|
||||
if (!user) return res.json({ user: null })
|
||||
res.json({ user: await serializeUser(user) })
|
||||
})
|
||||
@@ -265,7 +268,8 @@ router.post('/email/verify', async (req, res) => {
|
||||
|
||||
try {
|
||||
await establishSession(req, user)
|
||||
res.json({ user: await serializeUser(user) })
|
||||
const touchedUser = await touchUserLastLoginAt(user.id)
|
||||
res.json({ user: await serializeUser(touchedUser || user) })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: 'session_save_failed' })
|
||||
}
|
||||
@@ -321,7 +325,8 @@ router.post('/password-reset/confirm', async (req, res) => {
|
||||
|
||||
try {
|
||||
await establishSession(req, verifiedUser)
|
||||
res.json({ user: await serializeUser(verifiedUser) })
|
||||
const touchedUser = await touchUserLastLoginAt(verifiedUser.id)
|
||||
res.json({ user: await serializeUser(touchedUser || verifiedUser) })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: 'session_save_failed' })
|
||||
}
|
||||
|
||||
75
backend/src/routes/share.js
Normal file
75
backend/src/routes/share.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const express = require('express')
|
||||
const { findTierListById } = require('../db')
|
||||
|
||||
const router = express.Router()
|
||||
const APP_ORIGIN = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
const DEFAULT_TITLE = 'Tier Maker | 템플릿으로 쉽게 만드는 티어표'
|
||||
const DEFAULT_DESCRIPTION = '템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요.'
|
||||
const DEFAULT_IMAGE_URL = `${APP_ORIGIN}/og-card.png`
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function toAbsoluteUrl(pathname) {
|
||||
const src = String(pathname || '').trim()
|
||||
if (!src) return DEFAULT_IMAGE_URL
|
||||
if (/^https?:\/\//i.test(src)) return src
|
||||
return `${APP_ORIGIN}${src.startsWith('/') ? src : `/${src}`}`
|
||||
}
|
||||
|
||||
function buildShareHtml({ title, description, imageUrl, shareUrl, appUrl }) {
|
||||
const safeTitle = escapeHtml(title || DEFAULT_TITLE)
|
||||
const safeDescription = escapeHtml(description || DEFAULT_DESCRIPTION)
|
||||
const safeImageUrl = escapeHtml(imageUrl || DEFAULT_IMAGE_URL)
|
||||
const safeShareUrl = escapeHtml(shareUrl || APP_ORIGIN)
|
||||
const safeAppUrl = escapeHtml(appUrl || APP_ORIGIN)
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${safeTitle}</title>
|
||||
<meta name="description" content="${safeDescription}" />
|
||||
<link rel="canonical" href="${safeAppUrl}" />
|
||||
<meta property="og:site_name" content="Tier Maker" />
|
||||
<meta property="og:locale" content="ko_KR" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="${safeShareUrl}" />
|
||||
<meta property="og:title" content="${safeTitle}" />
|
||||
<meta property="og:description" content="${safeDescription}" />
|
||||
<meta property="og:image" content="${safeImageUrl}" />
|
||||
<meta property="og:image:alt" content="${safeTitle}" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="${safeTitle}" />
|
||||
<meta name="twitter:description" content="${safeDescription}" />
|
||||
<meta name="twitter:image" content="${safeImageUrl}" />
|
||||
<meta http-equiv="refresh" content="0; url=${safeAppUrl}" />
|
||||
</head>
|
||||
<body>
|
||||
<script>window.location.replace(${JSON.stringify(appUrl || APP_ORIGIN)})</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
router.get('/editor/:topicId/:tierListId', async (req, res) => {
|
||||
const { topicId, tierListId } = req.params
|
||||
const appUrl = `${APP_ORIGIN}/editor/${encodeURIComponent(topicId)}/${encodeURIComponent(tierListId)}?preview=1`
|
||||
const shareUrl = `${APP_ORIGIN}${req.originalUrl || `/share/editor/${encodeURIComponent(topicId)}/${encodeURIComponent(tierListId)}`}`
|
||||
|
||||
const tierList = await findTierListById(tierListId)
|
||||
const isPublicMatch = tierList?.isPublic && (tierList.topicSlug === topicId || tierList.topicId === topicId)
|
||||
const title = isPublicMatch ? tierList.title : DEFAULT_TITLE
|
||||
const description = isPublicMatch && tierList.description ? tierList.description : DEFAULT_DESCRIPTION
|
||||
const imageUrl = isPublicMatch ? toAbsoluteUrl(tierList.thumbnailSrc) : DEFAULT_IMAGE_URL
|
||||
|
||||
res.type('html').send(buildShareHtml({ title, description, imageUrl, shareUrl, appUrl }))
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
createCustomItem,
|
||||
createTemplateRequest,
|
||||
findUserById,
|
||||
findTopicByIdentifier,
|
||||
favoriteTierList,
|
||||
unfavoriteTierList,
|
||||
duplicateTierListForUser,
|
||||
@@ -234,14 +235,15 @@ router.post('/template-request', requireAuth, async (req, res) => {
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const payload = parsed.data
|
||||
const topicId = payload.topicId
|
||||
const topic = await findTopicByIdentifier(payload.topicId)
|
||||
if (!topic) return res.status(404).json({ error: 'not_found' })
|
||||
const normalizedBoardItems = payload.boardItems.map(normalizePoolItem)
|
||||
const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom')
|
||||
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
|
||||
|
||||
if (payload.type === 'create') {
|
||||
if (topicId !== FREEFORM_TOPIC_ID) return res.status(400).json({ error: 'freeform_required' })
|
||||
} else if (topicId === FREEFORM_TOPIC_ID) {
|
||||
if (topic.id !== FREEFORM_TOPIC_ID) return res.status(400).json({ error: 'freeform_required' })
|
||||
} else if (topic.id === FREEFORM_TOPIC_ID) {
|
||||
return res.status(400).json({ error: 'topic_template_required' })
|
||||
}
|
||||
|
||||
@@ -260,8 +262,8 @@ router.post('/template-request', requireAuth, async (req, res) => {
|
||||
type: payload.type,
|
||||
requesterId: req.session.userId,
|
||||
sourceTierListId: sourceTierList?.id || '',
|
||||
sourceTopicId: topicId,
|
||||
targetTopicId: payload.type === 'update' ? topicId : '',
|
||||
sourceTopicId: topic.id,
|
||||
targetTopicId: payload.type === 'update' ? topic.id : '',
|
||||
title: payload.requestTitle,
|
||||
description: payload.requestDescription,
|
||||
thumbnailSrc: payload.thumbnailSrc || '',
|
||||
@@ -283,7 +285,8 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
const parsed = tierListUpsertSchema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
const payload = parsed.data
|
||||
const topicId = payload.topicId
|
||||
const topic = await findTopicByIdentifier(payload.topicId)
|
||||
if (!topic) return res.status(404).json({ error: 'not_found' })
|
||||
const normalizedPool = payload.pool.map(normalizePoolItem)
|
||||
|
||||
let existing = null
|
||||
@@ -313,7 +316,7 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
const created = await saveTierList({
|
||||
id: nanoid(),
|
||||
authorId: req.session.userId,
|
||||
topicId,
|
||||
topicId: topic.id,
|
||||
title: payload.title,
|
||||
thumbnailSrc: payload.thumbnailSrc || '',
|
||||
description: payload.description || '',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express')
|
||||
const { listTopics, getTopicDetail, findTopicById, favoriteTopic, unfavoriteTopic } = require('../db')
|
||||
const { listTopics, getTopicDetail, findTopicByIdentifier, favoriteTopic, unfavoriteTopic } = require('../db')
|
||||
const { requireAuth } = require('../middleware/auth')
|
||||
|
||||
const router = express.Router()
|
||||
@@ -10,7 +10,7 @@ router.get('/', async (req, res) => {
|
||||
})
|
||||
|
||||
router.post('/:topicId/favorite', requireAuth, async (req, res) => {
|
||||
const topic = await findTopicById(req.params.topicId)
|
||||
const topic = await findTopicByIdentifier(req.params.topicId)
|
||||
if (!topic || topic.id === 'freeform') return res.status(404).json({ error: 'not_found' })
|
||||
await favoriteTopic({ userId: req.session.userId, topicId: topic.id })
|
||||
const topics = await listTopics(req.session.userId)
|
||||
@@ -19,7 +19,7 @@ router.post('/:topicId/favorite', requireAuth, async (req, res) => {
|
||||
})
|
||||
|
||||
router.delete('/:topicId/favorite', requireAuth, async (req, res) => {
|
||||
const topic = await findTopicById(req.params.topicId)
|
||||
const topic = await findTopicByIdentifier(req.params.topicId)
|
||||
if (!topic || topic.id === 'freeform') return res.status(404).json({ error: 'not_found' })
|
||||
await unfavoriteTopic({ userId: req.session.userId, topicId: topic.id })
|
||||
const topics = await listTopics(req.session.userId)
|
||||
|
||||
@@ -1,5 +1,90 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-03 v1.4.76
|
||||
- 프리뷰용 `viewerSidebar__section`은 데스크톱 오른쪽 레일에서 하단 액션 카드처럼 보이게 하려고 `margin-top: auto`를 갖고 있었지만, 모바일 전체 화면 overlay에서는 이 규칙이 카드를 바닥으로 밀어 과도하게 붙은 인상을 만들 수 있다고 판단했다.
|
||||
- 게다가 `localRightRailRoot`가 최소 높이 100%를 유지한 채 상위 콘텐츠 컨테이너도 flex 남은 높이를 채우면, 하단 footer 영역과 Teleport 콘텐츠의 시각적 쌓임이 어색해질 수 있으므로 모바일 overlay에서는 콘텐츠 컨테이너를 내용 높이 기준으로 풀어 footer가 자연스럽게 아래로 따라오게 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.75
|
||||
- 데스크톱/태블릿에서는 오른쪽 레일을 폭이 정해진 서랍 패널처럼 여는 게 맞지만, 모바일에서는 같은 폭 규칙을 유지하면 오히려 “오른쪽에서 덜 열린 반쪽 패널”처럼 보여 하단 공간까지 어색해질 수 있다고 판단했다.
|
||||
- 그래서 모바일 한정으로 오른쪽 레일 overlay를 전체 화면 패널로 바꾸고, 공유/복사 같은 하단 액션이 기기 하단 UI나 safe-area에 붙어 잘리지 않도록 내부 바디 패딩을 더 넉넉하게 두는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.74
|
||||
- 모바일에서는 `workspaceBody` 자체가 카드처럼 배경색을 가지는 것보다, 바깥 앱 셸 배경 위에 각 화면의 실제 카드/섹션만 떠 있는 편이 시각 구조가 더 명확하다고 판단했다.
|
||||
- 특히 `workspaceBody`가 좌우 마진을 가진 상태로 별도 배경색을 칠하면 “내용과 무관한 중간 레이어 박스”처럼 보일 수 있으므로, 모바일 한정으로 공통 워크스페이스 배경을 투명 처리해 불필요한 레이어감을 줄이는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.73
|
||||
- 모바일 `appShell`은 PC처럼 좌우 3열이 아니라 위쪽 레일과 아래쪽 본문이 세로로 쌓이는 2행 구조이므로, 열 정의만 1fr로 바꾸고 행 정의를 비워두면 암묵 그리드 행이 남는 높이를 늘려 본문이 아래로 밀려 보일 수 있다고 판단했다.
|
||||
- 이 문제는 각 화면 본문을 개별 조정하기보다 모바일 셸 컨테이너에서 첫 행은 `auto`, 본문 행은 `minmax(0, 1fr)`로 고정하고 전체 콘텐츠 정렬을 위쪽으로 붙이는 편이 공통 회귀를 가장 작게 되돌리는 방법이라고 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.72
|
||||
- 모바일 상단 레일은 왼쪽 유저 카드 영역과 오른쪽 패널 토글 버튼이 같은 시각적 간격 체계를 가져야 전체 셸이 덜 비뚤어져 보이므로, 모바일에서만 `railHeader` 좌우 패딩을 본문 카드 여백보다 조금 넓은 `20px`로 맞추는 편이 낫다고 판단했다.
|
||||
- 오른쪽 레일 토글 아이콘이 모바일에서 테두리 없는 아이콘만 보이면 왼쪽 네비 토글 버튼과 컴포넌트 문법이 달라 보이므로, 모바일 한정으로 같은 버튼형 배경/테두리/라운드를 적용해 조작 가능한 컨트롤처럼 통일하는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.71
|
||||
- 모바일에서 공통 본문 하단이 딱 붙어 보이는 문제는 로그인 화면 하나만 고치는 것보다 `workspaceBody` 공통 하단 여백을 safe-area까지 포함해 보강하는 편이 이후 모든 본문 화면에 일괄 적용되어 유지보수상 낫다고 판단했다.
|
||||
- 모바일 왼쪽 네비게이션은 데스크톱의 폭 축소형 접기와 목적이 다르므로, 기존 `leftRailCollapsed`를 억지로 재사용하기보다 `mobileLeftNavOpen` 상태를 분리하고 유저 카드 우측 버튼으로 검색/메뉴 묶음만 접는 방식이 더 자연스럽다고 정리했다.
|
||||
- 오른쪽 레일은 모바일에서 기본 자동 열림이 실제 조작 공간을 빼앗는 경우가 많으므로, 모바일 진입과 라우트 이동 시 기본 닫힘으로 두되 PC 레이아웃으로 돌아오면 다시 기본 열림을 복원하는 쪽으로 맞췄다.
|
||||
- 모바일 터치에서는 짧은 탭 선택과 드래그 시작이 같은 포인터 입력에서 충돌하기 쉬우므로, Sortable에 터치 전용 지연과 threshold를 둬 탭은 선택, 길게 누르고 움직이면 드래그가 되도록 의도를 분리했다.
|
||||
|
||||
## 2026-04-03 v1.4.70
|
||||
- 카카오톡/디스코드/X 공유 미리보기는 대개 프런트 SPA 자바스크립트를 실행하기 전에 HTML 메타를 먼저 읽으므로, 기존 `index.html` 고정 메타를 프런트 런타임에서 바꾸는 방식만으로는 티어표별 썸네일/제목/설명을 안정적으로 보여주기 어렵다고 판단했다.
|
||||
- 현재 운영 구조가 프런트 Nginx 정적 서빙 + 백엔드 API 분리 형태이므로, 모든 SPA 경로를 SSR로 바꾸기보다 공유 버튼만 `/share/editor/...` 서버 렌더링 경로를 사용하게 하고, 이 경로에서 OG 메타를 만든 뒤 기존 `preview=1` 화면으로 넘기는 방식이 가장 작은 변경이라고 정리했다.
|
||||
- 다만 비공개 티어표의 제목/설명/썸네일이 외부 크롤러에게 노출되면 안 되므로, 공유 메타 생성은 공개 티어표이면서 URL의 주제 식별자와 실제 티어표 소속이 일치하는 경우에만 개별 메타를 사용하고, 그 외에는 서비스 기본 메타로 떨어지게 제한했다.
|
||||
|
||||
## 2026-04-03 v1.4.69
|
||||
- 아이템 검색 실패가 라벨 누락이나 이벤트 문제처럼 보일 수도 있지만, 코드상 필터링 조건 자체는 단순했으므로 한글 입력/저장 문자열의 유니코드 정규형 차이까지 먼저 흡수하는 편이 더 안전하다고 판단했다.
|
||||
- 검색 시점에만 임시 보정하는 것보다, 검색어와 저장 라벨 비교를 같은 정규화 함수로 통일하고 커스텀 파일명 기반 기본 라벨 생성도 `NFC`로 맞춰 이후 신규 업로드 항목까지 같은 규칙을 타게 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.68
|
||||
- 우클릭 복제 UX는 카드 영역과 썸네일 이미지 중 어디를 눌러도 같은 동작이어야 하므로, 개별 카드의 버블링 이벤트만 믿기보다 전역 캡처 단계에서 아이템 우클릭을 먼저 가로채는 방식이 더 안전하다고 판단했다.
|
||||
- 편집기에서는 아이템 이미지를 브라우저 기본 이미지처럼 드래그하거나 저장 메뉴로 여는 것보다 보드 조작이 우선이므로, 썸네일 이미지의 기본 드래그도 명시적으로 꺼두는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.67
|
||||
- 이미지 최적화는 해시 기반 중복 재사용을 하기 때문에, 프로필 아바타로 올린 이미지가 우연히 템플릿/사용자 아이템과 같은 `src`를 공유할 수 있다. 이때 자산 카드 쪽을 무조건 숨기면 “실제로는 프로필 이미지로 쓰이는데 관리자 필터에서 안 보이는 상태”가 생기므로, 아바타/썸네일 참조가 있는 `src`는 자산 카드도 유지하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-03 v1.4.66
|
||||
- 특정 템플릿은 같은 아이템을 조건별로 여러 칸에 동시에 배치해야 하므로, 기존 아이템을 직접 공유해서 재사용하기보다 우클릭으로 새 복제본을 만들어 미사용 풀에 넣는 방식이 더 자연스럽다고 판단했다.
|
||||
- 현재 티어표 저장 구조는 아이템 ID 기준으로 위치를 추적하므로, 복제본이 원본과 같은 ID를 쓰면 중복 배치가 불가능해진다. 따라서 복제 시 `dup-...` 형태의 새 ID를 발급해 원본과 복제본을 별도 인스턴스로 관리하기로 했다.
|
||||
|
||||
## 2026-04-03 v1.4.62
|
||||
- 운영 서버를 새 DB로 다시 시작하는 절차는 “일반 업데이트 재빌드”와 “볼륨까지 삭제하는 완전 초기화”가 같은 문서 안에 섞이면 실수로 데이터를 날릴 위험이 크므로, 배포 문서에서 두 흐름을 별도 섹션으로 나누는 편이 맞다고 판단했다.
|
||||
- DB만 비우고 업로드 볼륨을 남기는 방식도 가능하지만, 현재 서비스는 DB 레코드와 업로드 파일 참조가 강하게 연결되어 있으므로 이 방법 역시 “운영 데이터를 전부 버리는 전제”라는 경고를 같이 적어두는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.61
|
||||
- 운영자가 쓰는 템플릿 주소를 `topics.id` 자체로 두면 나중에 이름/URL을 다듬고 싶을 때 참조 FK와 기존 링크까지 같이 흔들릴 수 있으므로, 내부 참조용 랜덤 `id`와 공개/관리용 `slug`를 분리하는 구조가 더 안전하다고 판단했다.
|
||||
- 운영 DB와 로컬 DB를 모두 새로 시작할 수 있는 상황이라면 예전 `id -> slug` 백필이나 레거시 호환 코드를 남기는 편이 오히려 유지보수 비용만 늘리므로, 이번 변경은 새 스키마 기준으로 깔끔하게 정리하고 기존 데이터 호환 마이그레이션은 두지 않기로 했다.
|
||||
- 빈 DB 초기화 시 예시 템플릿 2개가 자동 생성되면 운영자가 “진짜 운영 데이터인지 샘플인지”를 매번 구분해야 하므로, 시스템 필수 `freeform`만 남기고 빈 예시 템플릿 시드는 제거하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.60
|
||||
- 신규 업로드만 샤딩 저장하고 기존 평면 `assets` 파일을 그대로 두면 운영자가 파일 구조를 볼 때 두 방식이 오래 섞여 보여 정리성이 떨어지므로, 기존 평면 자산도 같은 규칙으로 옮기는 일회성 마이그레이션 스크립트를 제공하는 편이 맞다고 판단했다.
|
||||
- 기존 파일을 재인코딩해서 새 자산으로 다시 만드는 방식은 해시 중복 처리와 품질/메타 차이가 다시 얽힐 수 있으므로, 이번 샤딩 정리는 실제 파일 rename과 경로 참조 치환만 수행해 이미지 내용 자체는 건드리지 않는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.59
|
||||
- 최근 최적화 이미지가 `assets` 바로 아래 평면 파일로 저장되면서 경로만으로 프로필/썸네일 역할을 구분할 수 없게 되었으므로, 관리자 아이템 분류는 폴더명 규칙 하나에만 기대지 말고 실제 DB 참조 컬럼을 역추적해 판별하는 편이 더 안전하다고 판단했다.
|
||||
- 이미지가 장기적으로 많이 쌓일 수 있는 서비스라면 한 폴더에 모든 파일을 계속 몰아넣기보다 적당한 수준의 하위 폴더 분산이 낫다고 보고, 신규 파일만 ID 앞 2글자로 1단계 샤딩 저장하되 기존 평면 경로는 그대로 유지하는 점진 방식으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.58
|
||||
- 작성자 프로필 화면 상단에서 닉네임과 `@accountName`을 다시 보여주면 바로 아래 프로필 카드의 동일 정보와 역할이 겹치므로, 상단은 페이지 성격을 설명하는 공통 제목으로 두고 실제 사용자 식별 정보는 프로필 카드 한 곳에만 모으는 편이 낫다고 판단했다.
|
||||
- `@accountName`은 사용자가 직접 만든 핸들이 아니라 이메일 앞부분 기반 표시라서 계정명이 따로 존재하는 것처럼 오해를 만들 수 있으므로, 별도 사용자명 정책을 도입하기 전까지는 공개 프로필 UI에서 숨기는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.57
|
||||
- 아이템 관리 필터는 전체 데이터 탐색과 실제 반복 아이템 검수를 같은 셀렉트에서 오가야 하므로, `전체 이미지`를 맨 위에 두되 기본값은 여전히 `아이템(템플릿 + 사용자)`로 유지해 운영자가 처음부터 프로필/썸네일 자산에 묻히지 않게 하는 편이 맞다고 판단했다.
|
||||
- `미사용 사용자 업로드`라는 표현은 계정 탈퇴 잔여물처럼 오해될 수 있으므로, 실제 의미가 “사용자 아이템 레코드는 남아 있지만 현재 저장 티어표/템플릿 참조가 없는 항목”이라는 점에 맞춰 `미사용 아이템`으로 줄이고, 계정 삭제 시 외래키로 같이 삭제되는 항목은 이 범주로 남지 않는다고 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.56
|
||||
- 아이템 관리의 원래 목적은 “반복 사용 가능한 티어표 아이템”과 “사용자가 올린 커스텀 아이템”을 운영자가 구분해 검수하는 것이었는데, 프로필 아바타나 티어표 썸네일까지 `관리자 템플릿`으로 보이면 의미가 흐려지므로 보관 이미지 자산은 별도 출처와 배지로 분리하는 편이 맞다고 판단했다.
|
||||
- 평소 운영자가 가장 먼저 봐야 하는 대상도 1회성 썸네일이 아니라 실제 아이템이므로, 아이템 관리 기본 필터는 `전체 이미지`가 아니라 `아이템만 (템플릿+사용자)`로 두고 썸네일/프로필 이미지는 필요할 때만 따로 보게 하는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.55
|
||||
- 기존 `최근 활동`은 실제 의미가 “작성한 티어표의 마지막 수정일”에 가까웠는데, 이를 마지막 접속일처럼 읽을 수 있으면 장기 미접속 계정 정리 판단이 틀어질 수 있으므로 `최근 콘텐츠 활동`과 `마지막 접속일`을 아예 분리하는 편이 맞다고 판단했다.
|
||||
- 마지막 접속일은 로그인 성공 순간만 찍으면 장기 세션 사용자를 놓칠 수 있으므로, 세션이 살아 있는 `/api/auth/me` 확인에서도 일정 간격으로 갱신해 실제 접속 흔적에 더 가깝게 유지하는 쪽으로 정리했다.
|
||||
- 관리자 회원 카드에서 팔로워/즐겨찾기 수치를 보더라도 실제 공개 프로필과 작성 글 구성을 바로 확인할 진입점이 없으면 운영 판단이 끊기므로, `회원 정보 수정` 옆에 `프로필 보기` 버튼을 같이 두는 편이 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-04-03 v1.4.54
|
||||
- 추천 티어표를 수동으로만 지정할 수 있어도, 운영자가 후보 자체를 찾지 못하면 실무상 큐레이션이 막히므로, 관리자 전체 티어표 목록에 받은 즐겨찾기 수를 직접 보여주고 즐겨찾기 많은 순/최소 즐겨찾기 필터를 먼저 붙이는 편이 맞다고 판단했다.
|
||||
- 누가 핵심 작성자인지 보는 기준도 작성 티어표 수 하나만으로는 부족하므로, 팔로워 수와 받은 즐겨찾기 수를 회원 관리 카드에 같이 노출하고 이 지표로 정렬할 수 있게 두는 쪽으로 정리했다.
|
||||
- 이메일 인증/재설정 메일이 들어간 뒤에는 운영자가 평소 화면에서 회원 비밀번호를 직접 덮어쓰는 버튼을 계속 보는 것이 과한 권한처럼 느껴질 수 있으므로, 서버 API는 최후 보루로 남기되 관리자 회원 카드의 비밀번호 초기화 UI는 숨기는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-03 v1.4.53
|
||||
- 본인 티어표 복사 기능이 타인 티어표 전용 조건으로만 남아 있었지만, 실제 사용에서는 자기 작업본을 변형용 복사본으로 다시 만들고 싶은 경우도 많으므로 저장된 본인 티어표에도 복사 버튼을 여는 편이 맞다고 판단했다.
|
||||
- 편집 중 저장하지 않은 변경이 있는 상태에서 복사본을 만들 때는 마지막 저장본이 아니라 현재 화면 상태가 복사되기를 기대하기 쉬우므로, 본인 편집본 복사는 복사 직전에 현재 원본을 한 번 저장한 뒤 새 복사본을 만드는 쪽으로 정리했다.
|
||||
@@ -606,6 +691,18 @@
|
||||
- 작성자가 자기 티어표를 직접 삭제할 수 있어야 관리 흐름이 완결되므로, `내 티어표` 화면에 삭제 기능을 추가하기로 결정했다.
|
||||
- 사용자 커스텀 이미지는 서버 용량을 계속 차지하므로, 참조가 하나도 남지 않은 이미지(미사용 항목)를 식별하고 관리자 화면에서 개별/일괄 정리할 수 있도록 하기로 결정했다.
|
||||
|
||||
## 2026-04-03 v1.4.65
|
||||
- 운영 환경에서 루트 정적 favicon 요청이 계속 `403`으로 떨어지는 상황에서는 원인을 프록시/권한 계층에서 끝까지 추적하기보다, 브라우저 탭용 파비콘을 인라인 데이터 URL로 제공해 해당 요청 자체를 없애는 편이 더 단순하고 안정적이라고 판단했다.
|
||||
- 다만 iOS 홈 화면용 `apple-touch-icon.png`와 외부 공유용 `og-card.png`는 실제 파일이 필요하므로, 일반 브라우저 탭 favicon만 인라인 처리하는 선으로 범위를 제한했다.
|
||||
|
||||
## 2026-04-03 v1.4.64
|
||||
- 운영/로컬 DB를 새로 미는 흐름을 공식화한 만큼, 더 이상 “기존 DB에서만 우연히 남아 있던 컬럼”에 기대지 않고 `ensureSchema()`의 신규 생성 정의만으로 관리자 화면 전체가 떠야 한다고 다시 정리했다.
|
||||
- `template_requests`는 요청 목록 카드뿐 아니라 요청 미리보기와 이미지 참조 추적에도 쓰이므로, 저장 스냅샷 컬럼(`groups_json`, `board_items_json`, `show_character_names_snapshot`)을 초기 스키마에 반드시 포함하기로 결정했다.
|
||||
|
||||
## 2026-04-03 v1.4.63
|
||||
- 관리자/에디터 화면의 우측 패널은 Teleport로 공통 셸의 레일 DOM에 끼워 넣는 구조이므로, 라우트 변경 시 Teleport 대상 노드 자체를 조건부로 없애면 Vue 언마운트/패치 순서에 따라 DOM 기준점이 깨질 수 있다고 판단했다.
|
||||
- 따라서 `#local-right-rail-root`는 항상 렌더링해두고, 일반 화면에서는 숨김 클래스만 적용하는 방식으로 유지해 라우트 전환 안정성을 우선 확보하기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.13
|
||||
- 관리자 페이지는 기능 수가 늘어난 만큼 게임, 아이템, 회원 관리 탭으로 나누는 편이 더 안전하다고 판단했다.
|
||||
- 사용자 커스텀 아이템은 수량이 빠르게 커질 수 있으므로, 검색과 페이지네이션을 기본 제공하는 관리 화면으로 보는 방향을 채택했다.
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
|
||||
## `/topics/:topicId`
|
||||
- 화면 파일: `frontend/src/views/TopicHubView.vue`
|
||||
- 역할: 선택한 주제 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
|
||||
- 역할: 선택한 주제 slug 기준 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
|
||||
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
|
||||
|
||||
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
|
||||
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
||||
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰 렌더링
|
||||
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰 렌더링
|
||||
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request`
|
||||
|
||||
## `/login`
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
## `/admin`
|
||||
- 화면 파일: `frontend/src/views/AdminView.vue`
|
||||
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제
|
||||
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 템플릿 이름/slug 수정, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/받은 즐겨찾기 수 표시/인기순 정렬/최소 즐겨찾기 필터/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원의 작성 티어표·팔로워·받은 즐겨찾기·최근 콘텐츠 활동·마지막 접속일 확인과 회원 정보·권한 수정 및 공개 프로필 보기, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제
|
||||
- 연동 API: `POST /api/admin/templates`, `POST /api/admin/templates/:templateId/thumbnail`, `POST /api/admin/templates/:templateId/images`, `PATCH /api/admin/templates/:templateId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/tierlists/stats`, `PATCH /api/admin/tierlists/:tierListId/featured`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/template-requests/:requestId/link-template`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/templates/:templateId/items/:itemId`, `DELETE /api/admin/templates/:templateId`
|
||||
|
||||
## `/profile`
|
||||
@@ -57,7 +57,7 @@
|
||||
|
||||
## 공통 레이아웃
|
||||
- 앱 셸 파일: `frontend/src/App.vue`
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링, 관리자/에디터 화면이 Teleport로 사용하는 `#local-right-rail-root` 대상 DOM을 상시 유지해 라우트 전환 중 우측 레일 언마운트 순서를 안정화
|
||||
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker`와 서비스 설명이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
|
||||
|
||||
## 백엔드 진입점
|
||||
|
||||
78
docs/spec.md
78
docs/spec.md
@@ -8,7 +8,7 @@
|
||||
- 업로드 저장소: 로컬 파일 시스템(`backend/uploads/`)
|
||||
- 운영 배포: `frontend(Nginx 정적 서빙 + /api,/uploads 프록시) + backend + mariadb` Docker Compose 구조
|
||||
- NAS HTTPS 리버스 프록시 운영 시 프런트 Nginx는 백엔드로 `X-Forwarded-Proto: https`를 전달하고, Express 세션은 프록시 환경에서 `secure` 쿠키를 허용하도록 설정한다.
|
||||
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
|
||||
- 프런트 브라우저 탭 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 SVG 데이터 URL로 제공하고, iOS 홈 화면용 `apple-touch-icon.png`와 공유 미리보기용 `og-card.png`만 정적 파일로 유지한다.
|
||||
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, `preview=1` 모드에서도 같은 셸을 유지한 채 중앙 본문만 완성본 프리뷰로 렌더링한다.
|
||||
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
|
||||
- 좌측 패널은 필요 시 축소형 레일로 접을 수 있으며, 접힌 상태에서는 아이콘 중심 내비게이션과 축약된 바로가기만 유지한다.
|
||||
@@ -24,6 +24,8 @@
|
||||
- 아바타: `backend/uploads/avatars/`
|
||||
- 커스텀 아이템: `backend/uploads/custom/`
|
||||
- 시드 이미지: `backend/uploads/seeds/`
|
||||
- 최적화 이미지 자산: 신규 업로드는 `backend/uploads/assets/<앞2글자>/<파일명>.webp` 형태로 1단계 샤딩 저장하고, 기존 `backend/uploads/assets/<파일명>.webp` 평면 경로도 계속 읽는다.
|
||||
- 기존 평면 자산을 샤딩 구조로 정리할 때는 `npm --prefix backend run images:shard-assets`를 실행하며, 스크립트가 파일 이동과 DB/JSON 참조 치환을 함께 처리한다.
|
||||
|
||||
## 화면 구조
|
||||
- 좌측 패널
|
||||
@@ -67,7 +69,9 @@
|
||||
- `emailVerified`: boolean
|
||||
- `isAdmin`: boolean
|
||||
- `avatarSrc`: string
|
||||
- `lastLoginAt`: number
|
||||
- `createdAt`: number
|
||||
- 관리자 목록 집계 응답에서는 `tierListCount`, `followerCount`, `receivedFavoriteCount`, `lastLoginAt`, `recentActivityAt`도 함께 내려준다.
|
||||
- `emailVerificationTokens`
|
||||
- `id`: string
|
||||
- `userId`: string
|
||||
@@ -82,17 +86,23 @@
|
||||
- `expiresAt`: number
|
||||
- `consumedAt`: number
|
||||
- `createdAt`: number
|
||||
- `games`
|
||||
- `topics`
|
||||
- `id`: string
|
||||
- 서버가 자동 생성하는 내부 참조용 랜덤 ID이며, 공개 URL 노출값으로 직접 사용하지 않는다.
|
||||
- `slug`: string
|
||||
- 운영자가 지정/수정하는 공개 주소용 식별자이며, 영문 소문자/숫자/하이픈 조합만 허용한다.
|
||||
- `name`: string
|
||||
- `thumbnailSrc`: string
|
||||
- `isPublic`: boolean
|
||||
- `displayRank`: number | null
|
||||
- `createdAt`: number
|
||||
- 시스템 전용 `freeform` 레코드는 홈 화면의 `직접 티어표 만들기` 저장 대상이며 일반 게임 목록에서는 숨긴다.
|
||||
- `gameItems`
|
||||
- 시스템 전용 `freeform` 레코드는 홈 화면의 `직접 티어표 만들기` 저장 대상이며 일반 주제 목록에서는 숨긴다. 신규 빈 DB 초기화 시 자동 생성되는 템플릿은 이 `freeform` 한 건만 유지한다.
|
||||
- `topicItems`
|
||||
- `id`: string
|
||||
- `gameId`: string
|
||||
- `topicId`: string
|
||||
- `src`: string
|
||||
- `label`: string
|
||||
- `displayOrder`: number | null
|
||||
- `createdAt`: number
|
||||
- `customItems`
|
||||
- `id`: string
|
||||
@@ -103,7 +113,8 @@
|
||||
- `tierLists`
|
||||
- `id`: string
|
||||
- `authorId`: string
|
||||
- `gameId`: string
|
||||
- `topicId`: string
|
||||
- DB에는 내부 `topics.id`를 저장하고, API 응답에는 공개 경로용 `topicSlug`도 함께 내려준다.
|
||||
- `title`: string
|
||||
- `thumbnailSrc`: string
|
||||
- 사용자가 직접 지정하지 않으면 저장 시 티어표 대표 아이템 이미지로 자동 채운다.
|
||||
@@ -124,10 +135,27 @@
|
||||
- `followerId`: string
|
||||
- `followingId`: string
|
||||
- `createdAt`: number
|
||||
- `gameSuggestions`
|
||||
- `id`: string
|
||||
- `name`: string
|
||||
- `favoriteTopics`
|
||||
- `userId`: string
|
||||
- `topicId`: string
|
||||
- `createdAt`: number
|
||||
- `templateRequests`
|
||||
- `id`: string
|
||||
- `type`: string
|
||||
- `requesterId`: string
|
||||
- `sourceTierListId`: string | null
|
||||
- `sourceTopicId`: string
|
||||
- `targetTopicId`: string
|
||||
- `status`: string
|
||||
- `sourceTierListTitle`: string
|
||||
- `sourceDescription`: string
|
||||
- `thumbnailSrc`: string
|
||||
- `items`: `{ id, src, label, origin }[]`
|
||||
- `snapshotGroups`: `{ id, name, itemIds[] }[]`
|
||||
- `snapshotItems`: `{ id, src, label, origin }[]`
|
||||
- `snapshotShowCharacterNames`: boolean
|
||||
- `createdAt`: number
|
||||
- `updatedAt`: number
|
||||
|
||||
## 주요 API
|
||||
- 인증
|
||||
@@ -137,25 +165,29 @@
|
||||
- 이메일 인증이 끝나지 않은 계정은 `email_unverified`로 차단한다.
|
||||
- `POST /api/auth/logout`
|
||||
- `GET /api/auth/me`
|
||||
- 로그인 세션이 살아 있는 사용자의 `last_login_at`을 주기적으로 갱신해, 회원 관리에서 `마지막 접속일`을 따로 볼 수 있게 한다.
|
||||
- `GET /api/auth/meta`
|
||||
- `POST /api/auth/profile`
|
||||
- `POST /api/auth/password`
|
||||
- 로그인한 사용자가 현재 비밀번호를 확인한 뒤 새 비밀번호로 직접 변경한다.
|
||||
- `POST /api/auth/email/verify`
|
||||
- `login?verifyToken=...` 링크에서 받은 토큰으로 이메일 인증을 완료하고 바로 로그인 세션을 만든다.
|
||||
- 인증 완료 직후 로그인 세션이 열리면서 `last_login_at`도 함께 갱신한다.
|
||||
- `POST /api/auth/email/resend`
|
||||
- 미인증 계정의 인증 메일을 다시 발송한다.
|
||||
- `POST /api/auth/password-reset/request`
|
||||
- 입력한 이메일로 비밀번호 재설정 링크를 발송한다.
|
||||
- `POST /api/auth/password-reset/confirm`
|
||||
- `login?resetToken=...` 링크의 토큰과 새 비밀번호로 비밀번호를 재설정하고 바로 로그인 세션을 만든다.
|
||||
- 재설정 완료 직후 로그인 세션이 열리면서 `last_login_at`도 함께 갱신한다.
|
||||
- 주제
|
||||
- `GET /api/topics`
|
||||
- `GET /api/topics/:topicId`
|
||||
- `:topicId`는 공개 URL에서는 보통 `slug`를 받지만, 내부 ID를 넘겨도 같은 템플릿을 찾을 수 있게 서버가 레코드를 해석한다.
|
||||
- 티어표
|
||||
- `GET /api/tierlists/public`
|
||||
- `featuredTierLists`와 일반 공개 `tierLists`를 분리해서 반환한다.
|
||||
- `topicId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
|
||||
- `topicId`에는 주제 `slug`를 우선 전달하며, `topicId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
|
||||
- `GET /api/tierlists/me`
|
||||
- `GET /api/tierlists/favorites/me`
|
||||
- `GET /api/tierlists/:id`
|
||||
@@ -177,12 +209,17 @@
|
||||
- `DELETE /api/users/:userId/follow`
|
||||
- 관리자
|
||||
- `POST /api/admin/templates`
|
||||
- 요청 본문은 `slug`, `name`, `isPublic`, `thumbnailSrc`를 사용하고, 내부 `topics.id`는 서버가 자동 생성한다.
|
||||
- `PATCH /api/admin/templates/:templateId`
|
||||
- 내부 ID로 템플릿을 찾아 `name`, `slug`, `isPublic`을 수정한다.
|
||||
- `POST /api/admin/templates/:templateId/thumbnail`
|
||||
- `POST /api/admin/templates/:templateId/images`
|
||||
- 여러 이미지를 한 번에 최대 `100개`까지 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
|
||||
- `PATCH /api/admin/templates/:templateId/items/:itemId`
|
||||
- `GET /api/admin/tierlists`
|
||||
- `sort=recent|created|favorites`, `minFavorites`, `topicId`, `q`, `page`, `limit`으로 인기 티어표 후보를 정렬/필터링할 수 있다.
|
||||
- `GET /api/admin/tierlists/stats`
|
||||
- 현재 검색어/주제/최소 즐겨찾기 필터가 적용된 범위의 전체/공개/비공개/추천 수를 반환한다.
|
||||
- `PATCH /api/admin/tierlists/:tierListId/featured`
|
||||
- `GET /api/admin/template-requests`
|
||||
- `POST /api/admin/template-requests/:requestId/approve`
|
||||
@@ -191,10 +228,13 @@
|
||||
- `POST /api/admin/tierlists/:tierListId/promote-items`
|
||||
- `POST /api/admin/tierlists/:tierListId/create-template`
|
||||
- `GET /api/admin/custom-items`
|
||||
- `filter=library`를 기본값으로 사용해 반복 사용 가능한 `템플릿 아이템 + 사용자 아이템`만 먼저 보여주고, `filter=thumbnail` / `filter=avatar`로는 현재 참조 역할이 썸네일/프로필인 이미지를 따로 조회한다.
|
||||
- `filter=all|library|template|user|thumbnail|avatar|unused-user`를 사용하며, `filter=asset|unused-admin`은 과거 UI 호환용으로만 유지한다.
|
||||
- `POST /api/admin/custom-items/:itemId/promote`
|
||||
- `DELETE /api/admin/custom-items/:itemId`
|
||||
- `DELETE /api/admin/custom-items`
|
||||
- `GET /api/admin/users`
|
||||
- `sort=recent|lastLogin|created|tierlists|followers|favorites`, `direction=asc|desc`로 회원을 콘텐츠 활동/마지막 접속/작성량/팔로워/받은 즐겨찾기 기준으로 정렬한다.
|
||||
- `PATCH /api/admin/users/:userId`
|
||||
- `PATCH /api/admin/users/:userId/password`
|
||||
- `DELETE /api/admin/users/:userId`
|
||||
@@ -209,20 +249,28 @@
|
||||
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
|
||||
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
|
||||
- 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 드래그 또는 위/아래 버튼으로 순서를 저장할 수 있다.
|
||||
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
||||
- 사용자 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
||||
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
|
||||
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
|
||||
- 사용자 아이템은 사용 횟수(`usageCount`)를 표시하며, `미사용 아이템` 필터에서 저장 티어표/템플릿 참조가 더 이상 없는 항목만 개별/일괄 삭제할 수 있다. 사용자가 계정 탈퇴 등으로 삭제된 경우는 `custom_items.owner_id` 외래키로 레코드가 같이 사라지므로 보통 `미사용 아이템`으로 남지 않는다.
|
||||
- 아이템 관리 기본 필터는 `아이템(템플릿 + 사용자)`이며, 우측 필터 순서는 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템`을 사용한다.
|
||||
- `/uploads/assets/avatars/`는 `프로필 아바타`, `/uploads/assets/tierlists/`와 `/uploads/assets/topics/`는 `썸네일 이미지`, 그 외 보관 이미지는 `보관 자산` 배지로 표시한다. 최근처럼 `/uploads/assets/<파일명>.webp` 또는 `/uploads/assets/<앞2글자>/<파일명>.webp` 경로만 보고 종류를 알 수 없는 자산은 DB 참조(`avatar_src`, `thumbnail_src`, `thumbnail_src_snapshot`)를 역추적해 프로필/썸네일 여부를 분류한다. 실제 템플릿 기본 항목은 `템플릿 아이템`, 사용자 커스텀 항목은 `사용자 아이템` 배지를 사용한다.
|
||||
- 같은 이미지 `src`가 해시 중복 재사용으로 템플릿 아이템/사용자 아이템과 프로필 아바타 또는 썸네일 자산에서 동시에 공유되더라도, 아바타/썸네일로 참조 중인 `src`는 자산 카드도 함께 유지해 `프로필 이미지`, `썸네일 이미지`, `전체 이미지` 필터에서 누락되지 않게 한다.
|
||||
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
|
||||
- `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다.
|
||||
- `전체 티어표 관리` 카드에는 받은 즐겨찾기 수를 함께 보여주며, 우측 운영 패널에서 즐겨찾기 많은 순 정렬과 최소 즐겨찾기 수 필터로 추천 후보를 좁힐 수 있다.
|
||||
- `티어표 관리` 탭의 추가 아이템은 작은 그리드 카드로 표시하고, 클릭 시 `기존 템플릿에 추가 / 새 템플릿 만들기` 모달을 통해 목적지를 선택한다.
|
||||
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
|
||||
- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다.
|
||||
- `freeform` 티어표는 관리자 화면에서 새 템플릿 slug/이름을 입력해 새로운 템플릿으로 복제 생성할 수 있다. 내부 ID는 서버가 자동 생성하므로 운영자가 직접 입력하지 않는다.
|
||||
- 관리자 티어표 관리 상단에는 사용자가 보낸 템플릿 등록/업데이트 요청 목록이 별도로 표시되며, 여기서 승인/반려를 바로 처리할 수 있다.
|
||||
- 관리자 템플릿 요청 목록에서 `반려 후 숨김`을 누르면 해당 요청은 pending 목록에서 즉시 제외된다.
|
||||
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
|
||||
- 단, 일반 운영자는 최고 관리자 계정의 프로필 이미지/회원 정보/비밀번호/삭제 버튼을 사용할 수 없고, 최고 관리자만 다른 관리자 권한을 변경할 수 있다.
|
||||
- 관리자 회원 정보 수정은 운영상 필요한 경우 예약어 닉네임도 저장할 수 있지만, 일반 회원가입과 개인 프로필 수정에서는 운영자 사칭성 예약어 닉네임을 계속 차단한다.
|
||||
- 회원 관리 카드에는 아바타, 작성 티어표 수, 최근 활동 시각을 함께 표시한다.
|
||||
- 회원 관리 카드에는 아바타, 작성 티어표 수, 팔로워 수, 받은 즐겨찾기 수, 최근 콘텐츠 활동, 마지막 접속일을 함께 표시한다.
|
||||
- 운영자는 회원 목록을 작성 티어표 수뿐 아니라 팔로워 수와 받은 즐겨찾기 수 기준으로도 정렬할 수 있어, 핵심 작성자를 더 빠르게 찾을 수 있다.
|
||||
- 마지막 접속일은 로그인/세션 확인 기준, 최근 콘텐츠 활동은 작성한 티어표의 마지막 수정일 기준으로 분리해서 보여준다. 따라서 장기 미접속 계정 정리 판단은 마지막 접속일을 우선 사용하고, 콘텐츠 기여가 최근인지 볼 때는 최근 콘텐츠 활동을 사용한다.
|
||||
- 회원 카드의 `프로필 보기` 버튼은 해당 회원의 `/users/:userId` 공개 프로필 화면으로 이동해, 팔로워/공개 티어표 현황을 관리자 화면 밖에서도 바로 확인할 수 있게 한다.
|
||||
- 회원 비밀번호를 운영자가 임의로 덮어쓰는 기능은 비상 상황용 API로만 유지하고, 일반 회원 관리 카드에서는 비밀번호 초기화 버튼과 모달을 숨긴다. 평소 사용자 비밀번호 변경은 이메일 재설정 메일과 설정 화면 직접 변경을 우선 사용한다.
|
||||
|
||||
## 티어표 접근 메모
|
||||
- `new` 작성 경로는 로그인한 사용자만 진입할 수 있다.
|
||||
@@ -232,6 +280,7 @@
|
||||
- 로그인한 사용자는 뷰어 모드 우측 레일에서 저장된 티어표를 복사할 수 있고, 타인 티어표면 `내 티어표로 복사`, 본인 티어표면 `복사본 만들기` 문구를 사용한다. 작성자 본인은 `수정 모드로 전환`도 사용할 수 있다.
|
||||
- 작성자 본인이 일반 편집 화면에서 저장된 본인 티어표를 보고 있을 때는 우측 패널의 `뷰어 모드로 보기`로 공유 화면 형태를 바로 확인할 수 있다.
|
||||
- 편집/뷰어 우측 패널의 `작성자 프로필 보기`로 해당 작성자의 공개 프로필과 공개 티어표 목록을 열 수 있고, 로그인 상태에서는 작성자 프로필에서 팔로우/언팔로우를 전환할 수 있다.
|
||||
- `/users/:userId` 공개 프로필 화면 상단 헤더는 고정 제목 `사용자 프로필`과 안내 문구를 보여주고, 실제 닉네임/아바타는 본문 프로필 카드에서만 표시한다. 이메일 앞부분에서 파생된 `@accountName`은 사용자가 직접 설정한 핸들이 아니므로 프로필 UI에 노출하지 않는다.
|
||||
- 같은 `TierEditorView` 안에서 `topicId / tierListId / preview` 라우트 값만 바뀌어도 화면이 이전 티어표 데이터에 남지 않도록, 라우트 변경마다 에디터 상태를 다시 로드한다.
|
||||
- 복사한 티어표 상단의 `원본` 링크를 누르면 원본 티어표로 이동하며, 편집 모드에서 저장하지 않은 변경이 있으면 이동 전에 `저장 없이 이동` 확인 모달을 먼저 띄운다.
|
||||
- 본인 티어표도 저장된 상태라면 편집/뷰어 우측 패널에서 복사본을 만들 수 있고, 편집 중 저장하지 않은 수정이 남아 있으면 복사 직전에 현재 수정본을 먼저 저장해 최신 상태 기준 복사본을 만든다.
|
||||
@@ -247,6 +296,7 @@
|
||||
- 티어 행에 넣은 아이템은 작은 제거 버튼으로 다시 우측 아이템 풀로 되돌릴 수 있다.
|
||||
- 편집 가능한 티어표에서는 아이템을 드래그로 옮길 수 있고, 아이템을 클릭해 선택한 뒤 원하는 셀이나 아이템 풀 빈 영역을 클릭하는 방식으로도 위치를 바꿀 수 있다.
|
||||
- 클릭 배치 모드에서 선택된 아이템은 파란 포커스 테두리로 표시하며, 드래그를 시작하면 선택 상태를 해제하고 드래그 직후 짧은 클릭 입력은 무시해 드래그/클릭 조작이 섞이지 않게 한다.
|
||||
- 보드 칸이나 미사용 아이템 풀의 아이템을 우클릭하면 `아이템 복제` 메뉴가 열리고, 실행 시 같은 이미지/이름/출처를 가진 새 아이템 인스턴스를 미사용 풀 맨 앞에 추가한다. 복제본은 `dup-...` 형태의 새 ID를 쓰므로 원본과 복제본을 서로 다른 칸에 동시에 배치할 수 있다.
|
||||
- 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때는 저장본의 그룹/풀을 먼저 복원하고, 이후 현재 템플릿에 새로 추가된 기본 아이템만 미사용 풀 끝에 병합한다.
|
||||
- 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 포함되어 있던 항목은 자동 제거하지 않고 그대로 보존한다.
|
||||
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
||||
|
||||
34
docs/todo.md
34
docs/todo.md
@@ -1,6 +1,33 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `v1.4.68`에서 아이템 우클릭 처리를 `window` 캡처 단계로 보강했으므로, 보드에 배치된 아이템/미사용 풀 아이템/아이템 썸네일 이미지 위에서 각각 우클릭했을 때 브라우저 기본 메뉴 대신 `아이템 복제` 메뉴가 바로 뜨는지 QA한다.
|
||||
- `v1.4.67`에서 같은 `src`가 프로필 아바타와 템플릿/사용자 아이템으로 동시에 쓰여도 자산 카드를 유지하도록 바꿨으므로, 운영 관리자 화면의 `전체 이미지`와 `프로필 이미지` 필터에서 실제 아바타가 보이고 상세 모달의 공유 참조 목록도 자연스럽게 읽히는지 QA한다.
|
||||
- 아이템 우클릭 복제 기능을 추가했으므로, 템플릿 아이템 복제/커스텀 아이템 복제/이미 보드에 배치된 아이템 복제 각각에서 복제본이 미사용 풀 맨 앞에 생기고 원본과 복제본을 서로 다른 칸에 동시에 둘 수 있는지 QA한다.
|
||||
- 복제본은 `dup-...` 새 ID로 저장되므로, 저장 후 재진입/티어표 복사본 생성/뷰어 모드 열람에서도 복제본이 그대로 유지되는지와, 템플릿 업데이트 요청에 복제된 커스텀 아이템이 포함될 때 운영상 이상이 없는지 확인한다.
|
||||
- `v1.4.62`에서 NAS 배포 문서에 운영 DB 완전 초기화 절차를 추가했으므로, 실제 NAS에서 `git pull → docker compose ... down -v → up -d --build` 순서로 재배포했을 때 빈 DB가 현재 스키마로 다시 올라오고 `freeform`만 생성되는지 확인한다.
|
||||
- `docker volume rm tier-maker_tmaker_mariadb_data` 방식은 프로젝트 디렉터리명에 따라 실제 볼륨 이름이 달라질 수 있으므로, 운영 NAS에서는 먼저 `docker volume ls | grep tmaker`로 이름을 확인한 뒤 문서 명령이 그대로 맞는지 점검한다.
|
||||
- `v1.4.61`에서 템플릿 공개 주소를 `slug`로 분리했으므로, 홈 카드/주제 상세/나의 티어표/즐겨찾기/검색 결과/팔로우 피드/사용자 프로필에서 열리는 URL이 `/topics/:slug`, `/editor/:slug/...` 형태로 바뀌고, 실제 화면 내용도 같은 주제 템플릿으로 정확히 열리는지 QA한다.
|
||||
- 관리자 템플릿 생성/설정은 이제 내부 ID가 아니라 `slug + 이름`만 입력하므로, 새 템플릿 생성, 기존 템플릿 이름/slug 저장, 중복 slug 입력, 대문자/특수문자 slug 입력, 공개/비공개 토글, 썸네일/기본 아이템 관리가 모두 같은 템플릿에 정상 반영되는지 확인한다.
|
||||
- 신규 빈 DB 초기화 시 `topics`에 `freeform` 한 건만 생성되고 `example-topic`, `another-topic` 같은 예시 템플릿이 더 이상 자동으로 생기지 않는지 운영/로컬 재배포 후 확인한다.
|
||||
- `v1.4.60`에서 추가한 `npm --prefix backend run images:shard-assets`를 로컬/운영에 적용할 때는 먼저 백업을 확보한 뒤 실행하고, 평면 `/uploads/assets/<파일명>.webp` 파일이 샤딩 폴더로 이동하면서 `image_assets.src`와 각 참조 컬럼/JSON이 모두 새 경로로 바뀌었는지 확인한다.
|
||||
- `v1.4.59`에서 `thumbnail/avatar` 필터를 실제 DB 참조 역할 기준으로 다시 판별하도록 바꿨으므로, 최근 업로드처럼 `/uploads/assets/<파일명>.webp` 또는 `/uploads/assets/<앞2글자>/<파일명>.webp` 경로여도 썸네일 이미지/프로필 이미지 필터에서 빠지지 않는지 확인한다.
|
||||
- 신규 업로드 이미지는 `/uploads/assets/<앞2글자>/<파일명>.webp`로 저장되므로, 템플릿 썸네일/티어표 썸네일/프로필 아바타/아이템 업로드를 각각 새로 올린 뒤 실제 파일이 샤딩 폴더에 생성되고, 브라우저 표시·삭제·중복 재사용이 모두 기존처럼 동작하는지 QA한다.
|
||||
- 기존 `/uploads/assets/<파일명>.webp` 평면 경로는 그대로 유지되므로, 예전에 만든 티어표 썸네일과 아이템 이미지가 새 저장 구조 변경 후에도 깨지지 않는지 확인한다.
|
||||
- `v1.4.58`에서 작성자 프로필 상단 헤더를 `사용자 프로필` 공통 제목으로 바꾸고 `@accountName` 노출을 뺐으므로, `/users/:userId`에서 상단 문구와 본문 프로필 카드가 중복되지 않고 닉네임/아바타/팔로우 버튼만 자연스럽게 읽히는지 확인한다.
|
||||
- `v1.4.57`에서 관리자 아이템 필터 순서를 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템`으로 바꿨으므로, 우측 셀렉트 순서와 실제 필터링 결과가 같은 의미로 동작하는지 QA한다.
|
||||
- `썸네일 이미지` 필터에서는 `/uploads/assets/tierlists`, `/uploads/assets/topics`만 모이고, `프로필 이미지` 필터에서는 `/uploads/assets/avatars`만 모이며, 각 카드 배지가 `썸네일 이미지 / 프로필 아바타`로 구분되는지 확인한다.
|
||||
- `미사용 아이템` 필터는 사용자 아이템 중 저장 티어표 사용 횟수와 템플릿 연결이 모두 0인 항목만 보여주고, 계정 탈퇴로 이미 `custom_items` 레코드가 삭제된 항목이 따로 남지 않는지 확인한다.
|
||||
- `v1.4.56`에서 아이템 관리 기본 필터를 `아이템만 (템플릿+사용자)`로 바꿨으므로, 관리자 화면 첫 진입 시 프로필 아바타/티어표 썸네일 같은 1회성 자산이 기본 목록에서 빠지고 실제 템플릿/사용자 아이템만 보이는지 확인한다.
|
||||
- 보관 이미지 자산도 이름 변경, 템플릿에 추가, 개별 삭제가 기존처럼 동작하는지 확인한다.
|
||||
- 아이템 관리 상단 통계의 `미사용 아이템` 수치는 프로필/썸네일 자산을 포함하지 않고, 실제 사용자 아이템 중 사용 횟수와 템플릿 연결이 모두 0인 항목만 세는지 확인한다.
|
||||
- `v1.4.55`에서 회원 카드의 `최근 활동`을 `최근 콘텐츠 활동`으로 바꾸고 `마지막 접속일`을 따로 추가했으므로, 티어표를 수정하지 않고 로그인만 한 계정은 마지막 접속일만 갱신되고 최근 콘텐츠 활동은 유지되는지, 반대로 로그인 없이 과거 티어표만 있던 계정은 두 값이 다르게 보이는지 QA한다.
|
||||
- `/api/auth/me`에서도 `last_login_at`을 10분 단위 이상 간격으로만 갱신하도록 넣었으므로, 새로고침을 반복해도 과도한 DB 쓰기가 생기지 않으면서 실제 재접속 후에는 마지막 접속일이 자연스럽게 갱신되는지 확인한다.
|
||||
- 관리자 회원 목록의 `마지막 접속순` 정렬과 회원 카드의 `프로필 보기` 버튼이 정상 동작하고, 버튼 클릭 시 해당 회원의 `/users/:userId` 공개 프로필 화면으로 이동하는지 확인한다.
|
||||
- `v1.4.54`에서 관리자 전체 티어표 카드에 즐겨찾기 수와 인기순 정렬/최소 즐겨찾기 필터를 붙였으므로, 즐겨찾기 많은 순으로 바꿨을 때 실제 받은 즐겨찾기 수가 큰 글부터 보이고 최소값을 올리면 추천 후보만 남는지 확인한다.
|
||||
- 관리자 전체 티어표 통계 카드도 최소 즐겨찾기 필터가 적용된 범위 기준으로 `전체/추천/공개/비공개` 숫자가 바뀌는지 QA한다.
|
||||
- `v1.4.54`에서 회원 관리 카드에 팔로워 수와 받은 즐겨찾기 수를 추가했으므로, 팔로워 많은 순/받은 즐겨찾기 많은 순 정렬이 실제 운영 데이터 순서와 맞고 최고 관리자 보호 로직도 그대로 유지되는지 확인한다.
|
||||
- 관리자 회원 카드에서 `비밀번호 초기화` 버튼과 모달을 숨겼으므로, 일반 운영 동선에서는 비밀번호 직접 조작 UI가 보이지 않고 기존 회원 정보 수정/삭제/썸네일 변경은 그대로 동작하는지 확인한다.
|
||||
- `v1.4.53`에서 본인 티어표 복사 버튼을 다시 열었으므로, 작성자 본인 편집 모드와 뷰어 모드 모두에서 `복사본 만들기`가 보이고, 복사 후 새 복사본 화면으로 실제 이동하는지 확인한다.
|
||||
- 본인 티어표를 수정한 뒤 저장하지 않은 상태로 `복사본 만들기`를 누르면 복사 직전에 원본이 먼저 저장되고, 새 복사본이 방금 수정한 최신 내용 기준으로 생성되는지 QA한다.
|
||||
- `/users/:userId` 작성자 프로필에서 비로그인 사용자는 팔로우 버튼이 안 보이고, 로그인 사용자는 타인 프로필에서 `팔로우 / 팔로잉` 전환과 팔로워 수 갱신이 정상이며, 자기 프로필에서는 팔로우 버튼이 숨겨지는지 확인한다.
|
||||
@@ -93,6 +120,9 @@
|
||||
- 검색 결과 화면도 `pageHead` 구조로 맞췄으므로, 주요 목록 화면들 간 상단 여백과 타이포 리듬이 자연스러운지 한 번 더 비교 QA한다.
|
||||
- 주제 상세 컬렉션 화면은 `pageHead` 공통 레이아웃과 `/topics` 기본 경로로 옮겼으므로, 직접 진입·뒤로가기·검색 후 재진입 시 주소와 헤더 흐름이 자연스러운지 한 번 더 QA한다.
|
||||
- `/topics/:gameId`를 기본 경로로 세우고 `/games/:gameId`는 alias로 남겼으므로, 다음 단계에서는 에디터/검색/공유 흐름에서 어떤 링크를 새 경로로 더 전환할지 범위를 정한다.
|
||||
- 신규 DB 기준 `template_requests` 스키마 누락은 보정했으므로, 운영 NAS에서 `down -v` 후 재배포했을 때 관리자 `/admin/featured`, `/admin/tierlists`, `/admin/users` 진입과 템플릿 요청/이미지 통계 API가 모두 500 없이 동작하는지 한 번 더 QA한다.
|
||||
- 브라우저 탭 파비콘은 다시 인라인 SVG로 돌려 정적 `/favicon.svg`, `/favicon-32x32.png` 요청 자체를 끊었으므로, 최신 배포 후 강력 새로고침 기준으로 favicon 403 로그가 실제로 사라졌는지 한 번 더 QA한다.
|
||||
- 우측 레일 Teleport 대상 DOM을 상시 유지하는 방식으로 바꿨으므로, 관리자 `/admin/...` → 설정 `/profile` → 홈 `/`처럼 전용 우측 레일과 일반 우측 레일을 오가는 라우트 전환에서 콘솔 오류가 더 이상 재현되지 않는지 운영 브라우저로 한 번 더 QA한다.
|
||||
- 내부 리네이밍 2단계로 관리자 `selectedTemplate / templates / loadTemplate / refreshTemplates` 묶음까지 정리했으므로, 다음 단계에서는 `/games/:gameId` 라우트와 프런트 API 호출부를 어디까지 `topic/template` 의미로 감쌀지 범위를 먼저 정리한다.
|
||||
- 화면/문서 층의 용어 정리는 거의 마무리됐으므로, 다음 단계에서는 내부 `gameId / games` 모델을 실제로 옮길지, 아니면 라우트 alias/리다이렉트부터 둘지 점진 전환 순서를 정한다.
|
||||
- 관리자 화면까지 포함한 사용자 노출 `게임` 문구를 3차까지 정리했으므로, 실제 운영 흐름에서 뜨는 확인창/토스트/선택 모달까지 표현이 자연스러운지 한 번 더 QA한다.
|
||||
@@ -141,7 +171,7 @@
|
||||
|
||||
## 중기 개선
|
||||
- 목록 카드의 작성자 메타를 카드 전체 열기 버튼과 충돌 없이 직접 프로필 링크로 분리하는 후속 UX를 검토한다.
|
||||
- 추천 티어표는 이번에 관리자 수동 지정부터 붙였으므로, 다음 단계에서는 최근 N일 좋아요 수 기준 추천 후보 필터와 추천 섹션 노출 개수 설정을 관리자 화면에 추가할지 검토한다.
|
||||
- 추천 티어표는 전체 누적 즐겨찾기 기준 정렬/필터부터 붙였으므로, 다음 단계에서는 최근 N일 기준 급상승 추천 후보 필터와 추천 섹션 노출 개수 설정을 관리자 화면에 추가할지 검토한다.
|
||||
- 이메일 인증/비밀번호 재설정 1차 구현이 들어갔으므로, 다음 단계에서는 Gmail 발신 기반이 실제 운영에서 스팸함으로 얼마나 가는지 보고 필요하면 Cloudflare DNS의 SPF/DKIM/DMARC와 도메인 발신 주소 전환을 정리한다.
|
||||
- 구글 계정 로그인은 아직 붙이지 않았으므로, 이메일 인증 안정화 후 Google OAuth 클라이언트를 만들고 일반 이메일 계정과 같은 이메일의 구글 계정을 자동 연결할지 정책을 먼저 결정한다.
|
||||
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
|
||||
@@ -157,7 +187,7 @@
|
||||
- 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다.
|
||||
- 라이트모드 공통 토큰 재정비와 카드/아바타/즐겨찾기 버튼 보정까지 반영했으므로, 다음 QA에서는 로그인/홈/주제 허브/에디터/관리자 순으로 실제 플로우를 돌리며 남은 하드코딩 색과 과한 대비가 없는지 확인한다.
|
||||
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
|
||||
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
|
||||
- 회원 일괄 작업(다중 선택, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다. 비밀번호는 평소 운영자가 직접 덮어쓰기보다 이메일 재설정 흐름을 우선하므로, 관리자 일괄 비밀번호 초기화는 별도 긴급 대응 정책이 생긴 뒤에만 다시 검토한다.
|
||||
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
||||
- 로그인/회원가입/관리자 비밀번호 초기화에 요청 횟수 제한을 추가한다.
|
||||
- 업로드 파일은 MIME 타입뿐 아니라 파일 시그니처 기반 검증까지 확장한다.
|
||||
|
||||
@@ -93,7 +93,41 @@ docker compose --env-file .env.production -f docker-compose.prod.yml up -d --bui
|
||||
- Dockerfile, Nginx 설정, 프런트 소스, 백엔드 소스가 바뀐 경우에는 `--build`를 유지한다.
|
||||
- 단순 재시작만 필요할 때도 있지만, 운영에서는 실수 방지를 위해 `up -d --build`를 기본값으로 두는 편이 안전하다.
|
||||
|
||||
## 3-2. 이번 v0.1.34까지 적용하는 예시
|
||||
## 3-2. 운영 DB/업로드/세션까지 완전 초기화하고 새로 빌드하기
|
||||
- 운영 데이터를 전부 버리고 새 DB로 다시 시작할 때만 사용한다.
|
||||
- 아래 명령은 MariaDB 데이터, 업로드 이미지, 세션 파일 볼륨까지 같이 삭제하므로 실행 전에 정말 초기화해도 되는지 반드시 확인한다.
|
||||
- `.env.production`은 프로젝트 폴더에 그대로 남고, Docker volume 데이터만 제거된다.
|
||||
|
||||
```bash
|
||||
cd /volume1/docker/projects/apps/tier-maker
|
||||
git pull origin main
|
||||
docker compose --env-file .env.production -f docker-compose.prod.yml down -v
|
||||
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
- 이렇게 올리면 백엔드가 빈 MariaDB에 현재 스키마를 새로 만들고, 초기 템플릿은 시스템용 `freeform` 한 건만 생성한다.
|
||||
- `down -v` 후 첫 기동은 MariaDB 초기화 때문에 조금 더 오래 걸릴 수 있으니, 아래 명령으로 상태를 확인한다.
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.production -f docker-compose.prod.yml ps
|
||||
docker compose --env-file .env.production -f docker-compose.prod.yml logs -f mariadb
|
||||
docker compose --env-file .env.production -f docker-compose.prod.yml logs -f backend
|
||||
```
|
||||
|
||||
## 3-3. DB만 비우고 업로드/세션 볼륨은 유지하기
|
||||
- 이미지 파일과 세션 볼륨은 유지하고 MariaDB 데이터만 새로 시작하고 싶다면 `tmaker_mariadb_data` 볼륨만 지운다.
|
||||
- 이 경우에도 기존 티어표/유저 DB 레코드와 업로드 파일 참조가 끊길 수 있으므로, 현재 운영 데이터를 전부 버리는 전제에서만 사용한다.
|
||||
|
||||
```bash
|
||||
cd /volume1/docker/projects/apps/tier-maker
|
||||
docker compose --env-file .env.production -f docker-compose.prod.yml down
|
||||
docker volume rm tier-maker_tmaker_mariadb_data
|
||||
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
- 만약 볼륨 이름이 다르게 잡혀 있는지 확인하고 싶다면 먼저 `docker volume ls | grep tmaker`로 실제 이름을 확인한 뒤 지운다.
|
||||
|
||||
## 3-4. 이번 최신 main까지 적용하는 예시
|
||||
- 이미 NAS 폴더가 Git clone 상태라면:
|
||||
|
||||
```bash
|
||||
@@ -153,6 +187,8 @@ docker compose --env-file .env.production -f docker-compose.prod.yml down -v
|
||||
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
- 이 명령도 `down -v` 때문에 DB/업로드/세션 볼륨을 모두 삭제한다. 데이터를 유지해야 하는 상황이면 `-v`를 빼고 다시 올린다.
|
||||
|
||||
## 9. 참고
|
||||
- 현재 업로드 이미지는 서버 저장 전에 리사이즈/압축하지 않는다.
|
||||
- 운영 중 원본 이미지가 많이 쌓이면 이후 `sharp` 기반 최적화 단계를 추가하는 것이 좋다.
|
||||
|
||||
122
docs/update.md
122
docs/update.md
@@ -1,5 +1,114 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-03 v1.4.76
|
||||
- 모바일 티어표 프리뷰에서 오른쪽 레일의 `VIEWER MODE` 카드가 패널 바닥에 딱 붙고, 카피라이트 문구가 카드 뒤쪽 중간 높이에 겹쳐 보일 수 있던 배치를 보정했다.
|
||||
- 모바일 오른쪽 overlay 레일에서는 `rightRail__content`가 남는 높이를 억지로 채우지 않도록 `flex: 0 0 auto`로 풀고, `localRightRailRoot`의 최소 높이도 `auto`로 낮춰 footer와 콘텐츠가 자연스럽게 순서대로 쌓이게 했다.
|
||||
- 프리뷰 전용 `viewerSidebar__section`의 `margin-top: auto`는 모바일에서만 끄고, 광고 아래에 바로 카드가 이어지도록 조정했다.
|
||||
- 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다.
|
||||
|
||||
## 2026-04-03 v1.4.75
|
||||
- 모바일에서 오른쪽 레일을 열었을 때 패널이 `calc(100vw - 20px)` 폭의 좁은 서랍처럼 떠서 화면 전체를 채우지 못하고, 아래쪽도 어색하게 비어 보이던 부분을 조정했다.
|
||||
- 모바일 오른쪽 레일 overlay는 `inset: 0`, `width: 100vw`, `height: 100dvh`로 화면 전체를 덮는 패널처럼 열리게 바꾸고, 하단 액션/공유 버튼이 바닥에 붙거나 잘려 보이지 않도록 내부 패딩을 `32px + safe-area`까지 늘렸다.
|
||||
- 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다.
|
||||
|
||||
## 2026-04-03 v1.4.74
|
||||
- 모바일 본문 영역에서 `workspaceBody` 배경색이 좌우 마진 안쪽에만 칠해져 중앙에 어설픈 배경 박스가 떠 있는 것처럼 보이던 부분을 정리했다.
|
||||
- 모바일에서는 공통 워크스페이스 배경을 투명하게 두고, 실제 화면별 카드/섹션 배경만 남겨 덜 미완성처럼 보이도록 조정했다.
|
||||
- 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다.
|
||||
|
||||
## 2026-04-03 v1.4.73
|
||||
- 모바일에서 왼쪽 레일 아래 메인 컨텐츠가 화면 중간부터 시작하는 것처럼 보이던 회귀를 수정했다.
|
||||
- 원인은 모바일 `.appShell`이 1열 그리드로 바뀐 상태에서 세로 행 정의가 없어 `leftRail` 행과 `appMain` 행이 남는 높이를 나눠 가지며 위쪽이 불필요하게 늘어날 수 있던 점이었다. 모바일 그리드를 `auto + minmax(0, 1fr)` 행으로 고정하고 `align-content: start`를 적용해 상단 레일 바로 아래에 본문이 이어지도록 보정했다.
|
||||
- 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다.
|
||||
|
||||
## 2026-04-03 v1.4.72
|
||||
- 모바일 공통 상단 헤더(`railHeader`) 좌우 패딩을 `20px`로 넓혀, 오른쪽 레일 토글 버튼과 화면 가장자리 간격이 왼쪽 유저 카드 쪽과 더 자연스럽게 맞도록 조정했다.
|
||||
- 모바일에서 오른쪽 레일 열기/닫기 아이콘도 왼쪽 네비게이션 토글과 같은 버튼형 카드 스타일로 보이도록 `42px` 크기, 테두리, 배경, 둥근 모서리를 맞췄다.
|
||||
- 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다.
|
||||
|
||||
## 2026-04-03 v1.4.71
|
||||
- 모바일에서 본문 페이지나 로그인 화면 하단이 카드/버튼 바로 아래에서 끊겨 보여 답답했던 부분을 줄이기 위해, 공통 워크스페이스 본문 하단에 모바일 safe-area 기반 여백을 추가했다.
|
||||
- 모바일 왼쪽 네비게이션은 유저 프로필 카드 오른쪽 토글 버튼으로 접고 펼칠 수 있게 바꾸고, 닫힘/열림 전환 시 검색창과 메뉴가 위아래로 부드럽게 스르륵 접히는 애니메이션을 추가했다.
|
||||
- 모바일 진입 시 오른쪽 레일은 기본 닫힘으로 시작하고, 모바일에서 직접 오른쪽 레일을 열었을 때도 레일 하단 컨텐츠가 화면 바닥에 붙지 않도록 safe-area 여백을 더했다.
|
||||
- 티어표 편집기 모바일 터치 조작에서 아이템을 짧게 탭하면 선택만 하고, 길게 누른 뒤 움직일 때 드래그가 시작되도록 Sortable 터치 시작 지연과 이동 임계값을 추가했다.
|
||||
- 서버 점검 안내 문구는 `서비스 내부 점검이 필요합니다.` 대신 `서비스 내부 점검중입니다.`로 다듬었고, 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다.
|
||||
|
||||
## 2026-04-03 v1.4.70
|
||||
- 저장된 티어표의 `공유하기` 버튼이 기존 `preview=1` 편집기 주소 대신 `/share/editor/:topicId/:tierListId` 공유 전용 주소를 복사하도록 바꿨다.
|
||||
- 이 공유 전용 주소는 공개 티어표인 경우 해당 티어표의 제목, 설명, 썸네일을 기반으로 Open Graph/Twitter 메타 태그를 서버에서 동적으로 생성한 뒤, 실제 뷰어 화면 `/editor/:topicId/:tierListId?preview=1`로 즉시 이동시킨다.
|
||||
- 비공개 티어표이거나 주제 경로와 티어표 소속이 맞지 않는 경우에는 개별 제목/설명/썸네일을 노출하지 않고 서비스 기본 공유 메타를 사용하도록 제한했다.
|
||||
- 운영 프런트 Nginx에서 `/share/` 경로를 백엔드로 프록시하도록 추가해, 카카오톡/디스코드/X 같은 크롤러가 JS 실행 전에 공유 메타를 먼저 읽을 수 있게 했다.
|
||||
- `backend/index.js`, `backend/src/routes/share.js` 문법 검사와 프런트 프로덕션 빌드(`npm run build`)까지 통과하는 것을 확인했다.
|
||||
|
||||
## 2026-04-03 v1.4.69
|
||||
- 티어표 편집 화면의 아이템 검색에서 한글 아이템명이 검색어와 눈으로는 같아 보여도 내부 유니코드 정규형 차이 때문에 일부 항목이 매칭되지 않을 수 있던 문제를 보강했다.
|
||||
- 검색어와 아이템 라벨을 비교하기 전에 `NFC`로 정규화하도록 바꾸고, 커스텀 이미지 파일명에서 기본 라벨을 만들 때도 같은 정규화를 거쳐 한글 조합형 차이로 검색이 빗나가는 상황을 줄였다.
|
||||
- 프런트 프로덕션 빌드(`npm run build`)까지 통과하는 것을 확인했다.
|
||||
|
||||
## 2026-04-03 v1.4.68
|
||||
- 티어표 편집 화면에서 아이템을 우클릭해도 브라우저 기본 컨텍스트 메뉴가 먼저 떠서 `아이템 복제` 메뉴를 누르기 어려울 수 있던 부분을 보강했다.
|
||||
- 기존에는 각 아이템 카드의 `@contextmenu.prevent`에 주로 의존했지만, 이제는 `window` 캡처 단계에서 `[data-item-id]` 대상 우클릭을 먼저 잡아 기본 메뉴를 막고 커스텀 복제 메뉴를 열도록 바꿨다.
|
||||
- 아이템 썸네일 이미지에도 `draggable="false"`를 명시해, 이미지 자체의 기본 드래그/컨텍스트 동작이 편집 조작보다 앞서는 상황을 줄였다.
|
||||
|
||||
## 2026-04-03 v1.4.67
|
||||
- 관리자 아이템 관리에서 프로필 아바타가 `전체 이미지`와 `프로필 이미지` 필터에 보이지 않을 수 있던 문제를 수정했다.
|
||||
- 원인은 `image_assets`의 같은 `src`가 템플릿 아이템이나 사용자 아이템에서도 쓰이는 경우, 자산 카드 생성 단계에서 해당 `src`를 무조건 제외하던 필터였다. 이제는 `users.avatar_src`나 각종 썸네일 참조로 실제 사용 중인 자산이면 같은 이미지가 다른 아이템에 재사용되더라도 자산 카드도 함께 유지한다.
|
||||
- 로컬 MariaDB를 새로 만든 뒤 `프로필 아바타`와 사용자 아이템이 같은 `src`를 공유하는 테스트 데이터를 직접 넣고, `listCustomItems({ filterMode: 'avatar' })`와 `filterMode: 'all'` 결과에 프로필 자산 카드가 포함되는 것까지 확인했다.
|
||||
|
||||
## 2026-04-03 v1.4.66
|
||||
- 티어표 편집 화면에서 보드 위 아이템이나 미사용 아이템 풀의 아이템을 우클릭하면 `아이템 복제` 메뉴가 뜨고, 선택한 아이템의 이미지/이름/출처를 유지한 새 복제본을 미사용 풀 맨 앞에 추가하도록 구현했다.
|
||||
- 기존 아이템 ID를 그대로 다시 쓰면 같은 항목을 서로 다른 칸에 동시에 둘 수 없으므로, 복제 시 `dup-...` 새 ID를 발급해 원본과 복제본을 별도 아이템 인스턴스로 저장하도록 정리했다.
|
||||
- 우클릭 메뉴는 메뉴 밖 클릭, 다른 곳 우클릭, 스크롤, 창 포커스 이탈 시 닫히도록 했고, 화면 가장자리에서는 메뉴가 뷰포트 밖으로 나가지 않게 좌표를 보정했다.
|
||||
|
||||
## 2026-04-03 v1.4.62
|
||||
- UGREEN NAS 운영 배포 문서에 `git pull origin main` 후 일반 재빌드하는 절차와, 운영 데이터를 전부 버리고 `docker compose ... down -v`로 MariaDB/업로드/세션 볼륨까지 초기화한 뒤 새로 `up -d --build` 하는 절차를 분리해서 추가했다.
|
||||
- DB만 비우고 싶을 때 `tmaker_mariadb_data` 볼륨만 삭제하는 방법과, 실제 볼륨 이름이 다를 수 있으니 `docker volume ls | grep tmaker`로 먼저 확인하는 안내도 함께 적었다.
|
||||
- 새로 초기화한 운영 DB로 올리면 현재 스키마가 다시 생성되고 시스템 템플릿은 `freeform` 한 건만 들어간다는 점을 배포 문서에 명시했다.
|
||||
|
||||
## 2026-04-03 v1.4.61
|
||||
- 템플릿 공개 URL과 내부 참조를 분리해, `topics.id`는 서버가 자동 생성하는 랜덤 내부 ID로 두고 운영자가 직접 관리하는 공개 주소는 `topics.slug`로 저장하도록 바꿨다.
|
||||
- 공개 주제/에디터 경로는 `slug`를 우선 사용하고, 백엔드는 `/api/topics/:topicId`, `/api/tierlists/public?topicId=...`, 티어표 저장/템플릿 요청의 `topicId` 입력을 `slug` 또는 내부 ID에서 실제 템플릿 레코드로 해석한 뒤 내부 `topic_id`를 저장하도록 정리했다.
|
||||
- 관리자 템플릿 생성 모달과 템플릿 설정 카드에서 내부 ID 대신 `템플릿 이름 + slug`를 입력/수정할 수 있게 바꾸고, `slug` 중복/형식 오류는 `이미 사용 중인 템플릿 slug입니다.`, `slug는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.`처럼 원인 문구를 분리했다.
|
||||
- 새 DB를 처음 만들 때는 시스템 전용 `freeform` 템플릿만 생성하고, 예전에 기본 시드로 넣던 빈 예시 템플릿 `example-topic`, `another-topic`과 샘플 아이템은 더 이상 자동 생성하지 않도록 제거했다.
|
||||
- 로컬 MariaDB를 한 번 비운 뒤 새 스키마로 `ensureData()`를 실행해, 초기 `topics`가 `[{ id: "freeform", slug: "freeform", name: "직접 티어표 만들기" }]` 한 건만 생성되는 상태까지 확인했다.
|
||||
|
||||
## 2026-04-03 v1.4.60
|
||||
- 샤딩 구조가 생기기 전에 이미 `/uploads/assets/<파일명>.webp`로 평면 저장된 기존 최적화 이미지도 `/uploads/assets/<앞2글자>/<파일명>.webp`로 옮길 수 있도록 일회성 마이그레이션 스크립트 `backend/scripts/migrate-flat-assets-to-sharded.js`를 추가했다.
|
||||
- 이 스크립트는 `backend/uploads/assets` 루트에 남아 있는 실제 평면 파일을 기준으로 샤딩 폴더로 이동하고, `image_assets.src`와 사용자 아바타/주제 썸네일/템플릿 아이템/사용자 아이템/티어표 JSON/템플릿 요청 JSON 참조도 같은 새 경로로 일괄 치환한다.
|
||||
- 로컬 실행용 `npm --prefix backend run images:shard-assets` 스크립트를 추가해, 기존 100여 개 수준의 평면 자산도 별도 수작업 없이 한 번에 정리할 수 있게 했다.
|
||||
|
||||
## 2026-04-03 v1.4.59
|
||||
- 최근 업로드된 최적화 이미지가 `/uploads/assets/<파일명>.webp`처럼 하위 폴더 없이 저장되면서, `썸네일 이미지 / 프로필 이미지` 필터가 경로 문자열만으로 자산 종류를 판별하지 못해 비어 보일 수 있던 문제를 고쳤다.
|
||||
- 관리자 아이템 목록 생성 시 `users.avatar_src`, `topics.thumbnail_src`, `tierlists.thumbnail_src`, `template_requests.thumbnail_src_snapshot`을 역으로 모아 해당 `src`가 프로필 이미지인지 썸네일 이미지인지 먼저 판별하고, `thumbnail/avatar` 필터는 `sourceType`이 아니라 이 실제 참조 역할(`assetKind`) 기준으로 걸리도록 보정했다.
|
||||
- 신규 최적화 이미지 저장은 한 폴더에 무한정 쌓이지 않도록 파일 ID 앞 2글자 기준으로 `/uploads/assets/ab/<파일명>.webp`처럼 1단계 샤딩 디렉터리를 사용하게 바꿨다. 기존에 이미 저장된 `/uploads/assets/<파일명>.webp` 평면 경로는 그대로 유지해 과거 이미지 링크가 깨지지 않게 했다.
|
||||
|
||||
## 2026-04-03 v1.4.58
|
||||
- 작성자 프로필 화면 상단 헤더가 `Author + 닉네임 + @accountName`을 다시 보여주면서, 바로 아래 프로필 카드의 아바타/닉네임 정보와 거의 같은 내용이 반복되던 구성을 정리했다.
|
||||
- 상단 헤더는 공통 제목 `사용자 프로필`과 안내 문구로 바꾸고, 실제 닉네임은 아래 프로필 카드에서만 보여주도록 나눠 화면의 정보 역할이 겹치지 않게 했다.
|
||||
- 이메일 앞부분에서 파생된 `@accountName`은 사용자가 직접 설정한 핸들이 아니라서 오히려 “내가 입력한 적 없는 계정명”처럼 느껴질 수 있으므로, 프로필 화면의 시각 노출에서는 제거했다.
|
||||
|
||||
## 2026-04-03 v1.4.57
|
||||
- 관리자 아이템 관리 필터를 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템` 순서로 다시 정리해, 전체 조회와 실제 아이템 검수 흐름이 더 직관적으로 이어지게 맞췄다.
|
||||
- 기본 필터는 계속 `아이템(템플릿 + 사용자)`를 유지하되, 썸네일과 프로필 이미지는 각각 `filter=thumbnail`, `filter=avatar`로 분리 조회할 수 있게 백엔드 필터 enum과 자산 분류 값을 확장했다.
|
||||
- 보관 자산 배지 문구도 `/uploads/assets/topics/` 경로는 `썸네일 이미지`, 사용자 업로드 항목은 `사용자 아이템`, 템플릿 기본 항목은 `템플릿 아이템`으로 맞춰 `관리자 템플릿`처럼 실제 의미와 어긋나는 표현이 남지 않도록 정리했다.
|
||||
- `미사용 아이템`은 계정 탈퇴로 같이 삭제된 항목이 아니라, 사용자 아이템 레코드는 남아 있지만 저장 티어표/템플릿에서 더 이상 참조하지 않는 항목이라는 의미가 드러나도록 통계 라벨과 일괄 삭제 버튼 문구를 다시 정돈했다.
|
||||
|
||||
## 2026-04-03 v1.4.56
|
||||
- 관리자 아이템 관리에서 `/uploads/assets/...` 아래의 보관 이미지가 템플릿 기본 아이템이 아닌데도 모두 `관리자 템플릿` 배지로 표시되던 분류를 정리했다.
|
||||
- 보관 이미지 자산은 이제 `asset` 출처로 분리하고, 경로에 따라 `프로필 아바타`, `티어표 썸네일`, `템플릿 썸네일`, `보관 자산` 배지가 붙도록 바꿔 반복 사용 아이템과 1회성/관리용 이미지를 구분해서 볼 수 있게 했다.
|
||||
- 아이템 관리 필터 기본값을 `아이템만 (템플릿+사용자)`로 바꾸고, `썸네일·프로필 이미지`, `미사용 썸네일·프로필 이미지` 필터를 따로 제공해 기본 화면에서는 실제 아이템만 먼저 검수할 수 있게 했다.
|
||||
- 아이템 관리 상단의 `미사용` 통계가 프로필/썸네일 같은 자산까지 `usageCount=0`으로 같이 세면 잘못된 숫자처럼 보일 수 있으므로, `미사용 사용자 아이템`이라는 라벨로 바꾸고 실제 사용자 업로드 아이템 중 템플릿 연결과 사용 횟수가 모두 없는 항목만 세도록 보정했다.
|
||||
|
||||
## 2026-04-03 v1.4.55
|
||||
- 관리자 회원 카드의 `최근 활동`이 실제로는 마지막 접속이 아니라 작성 티어표의 마지막 수정 시각 기준이었으므로, 라벨을 `최근 콘텐츠 활동`으로 분명하게 바꾸고 `마지막 접속일`을 별도 줄로 추가해 두 의미를 분리했다.
|
||||
- 백엔드 `users`에 `last_login_at`을 추가하고, 로그인/이메일 인증 완료/비밀번호 재설정 완료/세션 기반 `/api/auth/me` 확인 시 해당 시각을 갱신하도록 보강했다. 기존 계정은 마이그레이션 시 `created_at`으로 1차 채워 오래된 계정도 빈 값 없이 정렬할 수 있게 했다.
|
||||
- 관리자 회원 목록 정렬에 `마지막 접속순`을 추가하고, 회원 카드의 `회원 정보 수정` 옆에 `프로필 보기` 버튼을 붙여 해당 유저의 `/users/:userId` 공개 프로필 화면으로 바로 이동할 수 있게 했다.
|
||||
|
||||
## 2026-04-03 v1.4.54
|
||||
- 관리자 `전체 티어표 관리` 카드에 받은 즐겨찾기 수를 표시하고, 우측 운영 패널에 `최근 수정순 / 최근 생성순 / 즐겨찾기 많은 순` 정렬과 `최소 즐겨찾기 수` 필터를 추가해, 운영자가 추천 후보가 될 만한 인기 티어표를 더 빨리 찾을 수 있게 했다.
|
||||
- 관리자 회원 관리 카드에 `팔로워 수`와 `받은 즐겨찾기 수`를 추가하고, 정렬 기준에도 `팔로워 많은 순`, `받은 즐겨찾기 많은 순`을 붙여 어떤 작성자가 핵심 기여자인지 운영자가 더 쉽게 파악할 수 있게 했다.
|
||||
- 이메일 인증과 비밀번호 재설정 메일이 들어간 뒤에는 운영자가 회원 비밀번호를 직접 바꾸는 버튼이 평소 화면에 드러나 있을 필요가 작다고 보고, 회원 카드의 `비밀번호 초기화` 버튼과 해당 모달 UI를 숨겼다. 서버의 관리자 비밀번호 변경 API는 비상 상황용 최후 수단으로 남겨두되, 일반 운영 동선에서는 직접 조작처럼 보이지 않도록 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.53
|
||||
- 티어표 복사 버튼이 타인 티어표에서만 보이도록 묶여 있어 본인 티어표에서는 숨겨지던 문제를 고쳐, 저장된 본인 티어표도 `복사본 만들기`로 새 복사본을 만들 수 있게 복구했다.
|
||||
- 본인 티어표를 편집 중 저장하지 않은 변경이 있는 상태로 복사본을 만들면 화면에 보이는 최신 수정 내용이 빠질 수 있었으므로, 복사 실행 직전에 현재 수정본을 먼저 저장한 뒤 복사본을 생성하도록 보정했다.
|
||||
@@ -1150,6 +1259,19 @@
|
||||
- **아이템 카드 레이아웃 개선**: 아이템 목록과 추가 미리보기를 1:1 비율 기준으로 재구성하고 더 촘촘한 카드 그리드로 조정
|
||||
- **레거시 파일 역할 정리**: `db.json`과 lowdb 관련 코드는 현재 MariaDB 기본 런타임에는 필수가 아니며, 마이그레이션/예외 fallback 용도임을 문서에 명시
|
||||
|
||||
## 2026-04-03 v1.4.65
|
||||
- **파비콘 403 재발 차단**: 운영 환경에서 `/favicon.svg`, `/favicon-32x32.png` 정적 요청이 계속 `403 Forbidden`으로 떨어지던 문제를 피하기 위해, 브라우저 탭 파비콘을 다시 `index.html` 인라인 SVG 데이터 URL로 전환하고 해당 정적 favicon 링크를 제거
|
||||
- **광고 스크립트 외부 DNS 오류 분리**: `e.dlx.addthis.com ... net::ERR_NAME_NOT_RESOLVED`는 애드센스/광고 네트워크에서 발생한 외부 도메인 해석 실패 로그로, 서비스 파비콘/관리자 API 오류와는 별개 현상으로 분리
|
||||
|
||||
## 2026-04-03 v1.4.64
|
||||
- **신규 DB 관리자 페이지 500 수정**: 빈 DB를 새로 만든 직후 `/admin/...` 진입 시 `GET /api/admin/template-requests`와 `GET /api/admin/image-assets/stats`가 500으로 터지던 문제를 수정
|
||||
- **`template_requests` 초기 스키마 보정**: 새 테이블 생성 정의에 누락돼 있던 `groups_json`, `board_items_json`, `show_character_names_snapshot` 컬럼을 추가하고, `source_tierlist_id`는 요청 종류에 따라 비어 있을 수 있도록 `NULL` 허용으로 정리
|
||||
- **빈 DB 재현 검증**: 로컬 MariaDB를 `DROP DATABASE → CREATE DATABASE → ensureData()`로 다시 초기화한 뒤 `listAdminTemplateRequests()`가 `[]`, `getImageAssetStats()`가 0값 통계를 반환하는 것까지 직접 확인
|
||||
|
||||
## 2026-04-03 v1.4.63
|
||||
- **우측 레일 Teleport 전환 안정화**: 관리자/에디터 전용 우측 패널이 사용하는 `#local-right-rail-root` DOM을 라우트에 따라 생성/삭제하지 않고 항상 유지하도록 바꿔, `/admin/...`에서 설정/다른 페이지로 이동하거나 새로고침 후 화면을 바꿀 때 Vue가 `nextSibling`/`emitsOptions` 기준점을 잃고 크래시하는 문제를 방지
|
||||
- **정적 favicon 403 분리 확인**: 프런트 빌드 기준 `favicon.svg`, `favicon-32x32.png`, `apple-touch-icon.png` 파일은 레포와 Vite `public/` 출력에 존재함을 확인했고, 운영 환경의 favicon `403 Forbidden`은 코드 누락보다 컨테이너/정적 서빙/프록시 권한 쪽 후속 점검 항목으로 분리
|
||||
|
||||
## 2026-03-19 v0.1.7
|
||||
- **AI 작업 규칙 보강**: `ai-rules.md`에 Git 작성자 정보, 한국어 커밋 메시지, 버전/태그 동기화, 민감 정보 확인 규칙 추가
|
||||
- **관리자 화면 재구성**: `/admin`을 좌우 병렬 구조에서 `모드 선택 → 게임 선택/생성 → 선택된 게임 상세 관리` 흐름으로 재구성
|
||||
|
||||
@@ -12,8 +12,11 @@
|
||||
<meta name="application-name" content="Tier Maker" />
|
||||
|
||||
<link rel="canonical" href="https://tmaker.sori.studio/" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='18' fill='%23090d16'/%3E%3Cpath d='M17 15h30v10H36v24H26V25H17V15Z' fill='%237fe7d6'/%3E%3Cpath d='M39 31h8v18h-8V31Z' fill='%235fcaff' opacity='.9'/%3E%3C/svg%3E"
|
||||
/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
|
||||
<meta property="og:site_name" content="Tier Maker" />
|
||||
|
||||
@@ -19,6 +19,15 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
location /share/ {
|
||||
proxy_pass http://backend:5179/share/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
location /uploads/ {
|
||||
proxy_pass http://backend:5179/uploads/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -27,6 +27,7 @@ const RIGHT_RAIL_COPYRIGHT_URL = 'https://x.com/zennbox'
|
||||
const currentTopicId = computed(() => route.params.topicId || '')
|
||||
|
||||
const leftRailCollapsed = ref(false)
|
||||
const mobileLeftNavOpen = ref(false)
|
||||
const rightRailOpen = ref(true)
|
||||
const searchQuery = ref('')
|
||||
const leftRailSearchPlaceholder = '주제 템플릿 검색'
|
||||
@@ -145,6 +146,7 @@ const showSettingsThemePanel = computed(() => route.name === 'profile')
|
||||
const showTopicViewToggle = computed(() => route.name === 'topicHub')
|
||||
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||
const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value))
|
||||
const shouldLockRightRailBodyScroll = computed(() => isRightRailOverlay.value && rightRailOpen.value && !showBackendFallback.value)
|
||||
const leftBottomPrimaryAction = computed(() => {
|
||||
if (!authReady.value) return null
|
||||
if (route.name === 'home' && auth.user) {
|
||||
@@ -295,6 +297,11 @@ function toggleTheme() {
|
||||
applyTheme(isLightTheme.value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
function syncRightRailBodyScrollLock(shouldLock) {
|
||||
if (typeof document === 'undefined') return
|
||||
document.body.style.overflow = shouldLock ? 'hidden' : ''
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedTheme = window.localStorage.getItem('tier-maker:theme')
|
||||
@@ -312,6 +319,12 @@ onMounted(async () => {
|
||||
const saved = window.localStorage.getItem('tier-maker:right-rail-open')
|
||||
if (saved === '0') rightRailOpen.value = false
|
||||
}
|
||||
if (isMobileLayout.value) {
|
||||
mobileLeftNavOpen.value = false
|
||||
rightRailOpen.value = false
|
||||
} else {
|
||||
rightRailOpen.value = true
|
||||
}
|
||||
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
||||
})
|
||||
|
||||
@@ -331,6 +344,7 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', syncViewportWidth)
|
||||
window.removeEventListener('keydown', handleGlobalKeydown)
|
||||
}
|
||||
syncRightRailBodyScrollLock(false)
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -339,13 +353,27 @@ watch(
|
||||
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
||||
isCollapsedSearchOpen.value = false
|
||||
isGuideModalOpen.value = false
|
||||
if (isMobileLayout.value) {
|
||||
mobileLeftNavOpen.value = false
|
||||
rightRailOpen.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
isMobileLayout,
|
||||
(mobile) => {
|
||||
if (mobile) leftRailCollapsed.value = false
|
||||
if (mobile) {
|
||||
leftRailCollapsed.value = false
|
||||
mobileLeftNavOpen.value = false
|
||||
rightRailOpen.value = false
|
||||
return
|
||||
}
|
||||
mobileLeftNavOpen.value = false
|
||||
rightRailOpen.value = true
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem('tier-maker:right-rail-open', '1')
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@@ -353,7 +381,7 @@ watch(
|
||||
watch(
|
||||
usesLocalRightRail,
|
||||
(needed) => {
|
||||
if (!needed || rightRailOpen.value) return
|
||||
if (!needed || rightRailOpen.value || isMobileLayout.value) return
|
||||
rightRailOpen.value = true
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem('tier-maker:right-rail-open', '1')
|
||||
@@ -362,13 +390,24 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
shouldLockRightRailBodyScroll,
|
||||
(shouldLock) => {
|
||||
syncRightRailBodyScrollLock(shouldLock)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function isRouteActive(path) {
|
||||
if (path === '/') return route.path === '/'
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
function toggleLeftRail() {
|
||||
if (isMobileLayout.value) return
|
||||
if (isMobileLayout.value) {
|
||||
mobileLeftNavOpen.value = !mobileLeftNavOpen.value
|
||||
return
|
||||
}
|
||||
leftRailCollapsed.value = !leftRailCollapsed.value
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem('tier-maker:left-rail-collapsed', leftRailCollapsed.value ? '1' : '0')
|
||||
@@ -449,6 +488,7 @@ function reloadApp() {
|
||||
class="appShell"
|
||||
:class="{
|
||||
'appShell--leftCollapsed': leftRailCollapsed,
|
||||
'appShell--mobileNavClosed': isMobileLayout && !mobileLeftNavOpen,
|
||||
'appShell--rightClosed': !rightRailOpen,
|
||||
'appShell--rightOverlay': isRightRailOverlay,
|
||||
}"
|
||||
@@ -483,7 +523,7 @@ function reloadApp() {
|
||||
|
||||
<div class="leftRail__body">
|
||||
<div class="leftRail__content">
|
||||
<div v-if="authReady && auth.user" class="appUserCard">
|
||||
<div v-if="authReady" class="appUserCard">
|
||||
<div class="appUserCard__button">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" draggable="false" />
|
||||
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
|
||||
@@ -491,40 +531,52 @@ function reloadApp() {
|
||||
<div class="appUserCard__name">{{ accountName }}</div>
|
||||
<div class="appUserCard__email">{{ accountEmail }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="isMobileLayout"
|
||||
class="appUserCard__navToggle"
|
||||
type="button"
|
||||
:aria-label="mobileLeftNavOpen ? '네비게이션 메뉴 닫기' : '네비게이션 메뉴 열기'"
|
||||
:aria-expanded="mobileLeftNavOpen"
|
||||
@click="toggleLeftRail"
|
||||
>
|
||||
<SvgIcon :src="mobileLeftNavOpen ? iconDockToLeft : iconDockToRight" :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="searchStub" @submit.prevent="submitGlobalSearch">
|
||||
<button class="searchStub__iconButton" type="button" :aria-label="leftRailCollapsed ? '검색 열기' : '검색'" @click="handleLeftRailSearch">
|
||||
<span class="searchStub__icon">
|
||||
<SvgIcon :src="iconSearch" :size="24" />
|
||||
</span>
|
||||
</button>
|
||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
|
||||
</form>
|
||||
<div class="leftRail__mobileMenu">
|
||||
<form class="searchStub" @submit.prevent="submitGlobalSearch">
|
||||
<button class="searchStub__iconButton" type="button" :aria-label="leftRailCollapsed ? '검색 열기' : '검색'" @click="handleLeftRailSearch">
|
||||
<span class="searchStub__icon">
|
||||
<SvgIcon :src="iconSearch" :size="24" />
|
||||
</span>
|
||||
</button>
|
||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
|
||||
</form>
|
||||
|
||||
<nav
|
||||
class="leftNav"
|
||||
:class="{ 'leftNav--hasActive': activeLeftNavIndex >= 0 }"
|
||||
:style="{ '--left-nav-active-index': String(Math.max(activeLeftNavIndex, 0)) }"
|
||||
>
|
||||
<span class="leftNav__indicator" aria-hidden="true"></span>
|
||||
<RouterLink
|
||||
v-for="item in leftNavItems"
|
||||
:key="item.key"
|
||||
:to="item.path"
|
||||
class="leftNav__item"
|
||||
:class="{ 'leftNav__item--active': isRouteActive(item.path) }"
|
||||
:title="leftRailCollapsed ? item.label : ''"
|
||||
:aria-label="leftRailCollapsed ? item.label : undefined"
|
||||
<nav
|
||||
class="leftNav"
|
||||
:class="{ 'leftNav--hasActive': activeLeftNavIndex >= 0 }"
|
||||
:style="{ '--left-nav-active-index': String(Math.max(activeLeftNavIndex, 0)) }"
|
||||
>
|
||||
<span class="leftNav__glyph">
|
||||
<SvgIcon v-if="item.iconSrc" :src="item.iconSrc" :size="24" />
|
||||
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg>
|
||||
</span>
|
||||
<span class="leftNav__label">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
<span class="leftNav__indicator" aria-hidden="true"></span>
|
||||
<RouterLink
|
||||
v-for="item in leftNavItems"
|
||||
:key="item.key"
|
||||
:to="item.path"
|
||||
class="leftNav__item"
|
||||
:class="{ 'leftNav__item--active': isRouteActive(item.path) }"
|
||||
:title="leftRailCollapsed ? item.label : ''"
|
||||
:aria-label="leftRailCollapsed ? item.label : undefined"
|
||||
>
|
||||
<span class="leftNav__glyph">
|
||||
<SvgIcon v-if="item.iconSrc" :src="item.iconSrc" :size="24" />
|
||||
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg>
|
||||
</span>
|
||||
<span class="leftNav__label">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="leftRail__bottom">
|
||||
@@ -659,8 +711,12 @@ function reloadApp() {
|
||||
</div>
|
||||
<div class="rightRail__body">
|
||||
<div class="rightRail__content">
|
||||
<div v-if="usesLocalRightRail" id="local-right-rail-root" class="localRightRailRoot"></div>
|
||||
<template v-else>
|
||||
<div
|
||||
id="local-right-rail-root"
|
||||
class="localRightRailRoot"
|
||||
:class="{ 'localRightRailRoot--hidden': !usesLocalRightRail }"
|
||||
></div>
|
||||
<template v-if="!usesLocalRightRail">
|
||||
<section v-if="showSettingsThemePanel" class="settingsThemePanel">
|
||||
<div class="settingsThemePanel__eyebrow">Appearance</div>
|
||||
<div class="settingsThemePanel__title">테마 설정</div>
|
||||
@@ -964,6 +1020,25 @@ function reloadApp() {
|
||||
transition: opacity 180ms ease, max-width 220ms ease, transform 220ms ease;
|
||||
}
|
||||
|
||||
.leftRail__mobileMenu {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.appUserCard__navToggle {
|
||||
display: none;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
margin-left: auto;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
background: var(--theme-surface-soft);
|
||||
color: var(--theme-text-soft);
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.appUserCard__name {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
@@ -1780,6 +1855,10 @@ function reloadApp() {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.localRightRailRoot--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toastStack {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
@@ -1967,9 +2046,15 @@ function reloadApp() {
|
||||
@media (max-width: 860px) {
|
||||
.appShell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
align-content: start;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.railHeader {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.leftRail {
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
@@ -1986,6 +2071,43 @@ function reloadApp() {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.appUserCard {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.appUserCard__button {
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.appUserCard__meta {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.appUserCard__navToggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.workspaceHead .ghostIcon--iconOnly,
|
||||
.rightRail__top .ghostIcon--iconOnly {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
min-width: 42px;
|
||||
border: 1px solid var(--theme-border);
|
||||
border-radius: 14px;
|
||||
background: var(--theme-surface-soft);
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
|
||||
.rightRail--overlay {
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
min-width: 0;
|
||||
height: 100dvh;
|
||||
min-height: 100dvh;
|
||||
border-left: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.appMain {
|
||||
min-height: auto;
|
||||
border-left: 0;
|
||||
@@ -2003,6 +2125,18 @@ function reloadApp() {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.leftRail__mobileMenu {
|
||||
max-height: 540px;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height 260ms ease,
|
||||
opacity 220ms ease,
|
||||
transform 220ms ease,
|
||||
margin-top 220ms ease;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftRail__top {
|
||||
display: none;
|
||||
}
|
||||
@@ -2038,17 +2172,44 @@ function reloadApp() {
|
||||
}
|
||||
|
||||
.workspaceBody {
|
||||
padding: 0;
|
||||
padding: 0 0 calc(28px + env(safe-area-inset-bottom));
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
margin: 14px 14px 0;
|
||||
}
|
||||
|
||||
.workspaceBody--localRail {
|
||||
padding: 0;
|
||||
padding: 0 0 calc(28px + env(safe-area-inset-bottom));
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
margin: 14px 14px 0;
|
||||
}
|
||||
|
||||
.appShell--mobileNavClosed .leftRail__mobileMenu {
|
||||
max-height: 0;
|
||||
margin-top: -8px;
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.appShell--mobileNavClosed .leftRail__bottom {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rightRail--overlay .rightRail__body {
|
||||
padding: 14px 20px calc(32px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.rightRail--overlay .rightRail__content {
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.rightRail--overlay .localRightRailRoot {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.collapsedSearchModal {
|
||||
padding: 72px 16px 16px;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ const props = defineProps({
|
||||
<span class="featuredCard__rank">{{ index + 1 }}</span>
|
||||
<div>
|
||||
<div class="featuredCard__title">{{ template.name }}</div>
|
||||
<div class="featuredCard__id">{{ template.id }}</div>
|
||||
<div class="featuredCard__id">{{ template.slug || template.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="featuredCard__actions">
|
||||
@@ -55,7 +55,7 @@ const props = defineProps({
|
||||
@click="props.addFeaturedTemplate(template.id)"
|
||||
>
|
||||
<span>{{ template.name }}</span>
|
||||
<span class="featuredPickerItem__id">{{ template.id }}</span>
|
||||
<span class="featuredPickerItem__id">{{ template.slug || template.id }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,15 @@ const props = defineProps({
|
||||
<div v-if="!props.customItems.length" class="hint">조건에 맞는 관리 대상 아이템이 없어요.</div>
|
||||
<div v-else class="customItemGrid">
|
||||
<button v-for="item in props.customItems" :key="item.id" type="button" class="customItemCard" @click="props.openCustomItemModal(item)">
|
||||
<span class="customItemCard__badge" :class="{ 'customItemCard__badge--template': item.sourceType === 'template' }">{{ item.sourceLabel }}</span>
|
||||
<span
|
||||
class="customItemCard__badge"
|
||||
:class="{
|
||||
'customItemCard__badge--template': item.sourceType === 'template',
|
||||
'customItemCard__badge--asset': item.sourceType === 'asset',
|
||||
}"
|
||||
>
|
||||
{{ item.sourceLabel }}
|
||||
</span>
|
||||
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
|
||||
</button>
|
||||
|
||||
@@ -13,6 +13,11 @@ const props = defineProps({
|
||||
hasSelectedTemplate: { type: Boolean, required: true },
|
||||
selectedTemplate: { type: Object, default: null },
|
||||
displayThumbnailUrl: { type: String, default: '' },
|
||||
templateMetaDraftName: { type: String, default: '' },
|
||||
templateMetaDraftSlug: { type: String, default: '' },
|
||||
templateMetaSaving: { type: Boolean, required: true },
|
||||
canSaveTemplateMeta: { type: Boolean, required: true },
|
||||
saveTemplateMeta: { type: Function, required: true },
|
||||
canApplyThumbnail: { type: Boolean, required: true },
|
||||
templateVisibilitySaving: { type: Boolean, required: true },
|
||||
thumbFileInputRef: { type: Function, required: true },
|
||||
@@ -47,6 +52,8 @@ const props = defineProps({
|
||||
selectedTemplateId: { type: String, default: '' },
|
||||
})
|
||||
|
||||
defineEmits(['update:templateMetaDraftName', 'update:templateMetaDraftSlug'])
|
||||
|
||||
function setTemplateItemListElement(el) {
|
||||
props.templateItemListRef(el)
|
||||
}
|
||||
@@ -131,13 +138,40 @@ function setThumbFileElement(el) {
|
||||
</div>
|
||||
<div class="templateSettingsCard__body">
|
||||
<div class="panel__title">템플릿 설정</div>
|
||||
<div class="templateSettingsCard__meta">{{ props.selectedTemplate.template.name }} · {{ props.selectedTemplate.template.id }}</div>
|
||||
<div class="templateMetaForm">
|
||||
<label class="templateMetaField">
|
||||
<span class="templateMetaField__label">템플릿 이름</span>
|
||||
<input
|
||||
class="input input--dense"
|
||||
type="text"
|
||||
maxlength="60"
|
||||
:value="props.templateMetaDraftName"
|
||||
placeholder="템플릿 이름"
|
||||
@input="$emit('update:templateMetaDraftName', $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
<label class="templateMetaField">
|
||||
<span class="templateMetaField__label">템플릿 slug</span>
|
||||
<input
|
||||
class="input input--dense"
|
||||
type="text"
|
||||
maxlength="120"
|
||||
:value="props.templateMetaDraftSlug"
|
||||
placeholder="예: idol-rhythm"
|
||||
@input="$emit('update:templateMetaDraftSlug', $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="templateSettingsCard__meta">공개 URL: /topics/{{ props.selectedTemplate.template.slug || props.selectedTemplate.template.id }}</div>
|
||||
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.templateVisibilitySaving }">
|
||||
<input :checked="!!props.selectedTemplate.template.isPublic" type="checkbox" @change="props.toggleSelectedTemplateVisibility($event.target.checked)" />
|
||||
<span class="toggleSwitch__label">{{ props.selectedTemplate.template.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
|
||||
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
||||
</label>
|
||||
<div class="templateSettingsCard__actions">
|
||||
<button class="btn btn--ghost" :disabled="!props.canSaveTemplateMeta || props.templateMetaSaving" @click="props.saveTemplateMeta">
|
||||
{{ props.templateMetaSaving ? '저장중...' : '이름/slug 저장' }}
|
||||
</button>
|
||||
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
|
||||
<button class="btn btn--danger" @click="props.removeTemplate">템플릿 삭제</button>
|
||||
</div>
|
||||
@@ -239,3 +273,25 @@ function setThumbFileElement(el) {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.templateMetaForm {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.templateMetaField {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.templateMetaField__label {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
|
||||
.input--dense {
|
||||
padding: 11px 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -159,6 +159,7 @@ const props = defineProps({
|
||||
<div class="tierAdminCard__stats">
|
||||
<span class="pill" :class="tierList.isPublic ? 'pill--public' : 'pill--private'">{{ props.tierListVisibilityLabel(tierList) }}</span>
|
||||
<span v-if="tierList.isFeatured" class="pill pill--accent">추천 노출중</span>
|
||||
<span class="pill pill--soft">즐겨찾기 {{ tierList.favoriteCount || 0 }}개</span>
|
||||
<span class="pill">전체 아이템 {{ tierList.itemCount }}개</span>
|
||||
<span class="pill" :class="{ 'pill--accent': tierList.extraItemCount > 0 }">추가 아이템 {{ tierList.extraItemCount }}개</span>
|
||||
</div>
|
||||
|
||||
@@ -17,14 +17,12 @@ const props = defineProps({
|
||||
removeUserAvatar: { type: Function, required: true },
|
||||
canEditUserAvatar: { type: Function, required: true },
|
||||
canEditUserInfo: { type: Function, required: true },
|
||||
canResetUserPassword: { type: Function, required: true },
|
||||
canDeleteUser: { type: Function, required: true },
|
||||
roleLabelOf: { type: Function, required: true },
|
||||
fmt: { type: Function, required: true },
|
||||
openUserPasswordModal: { type: Function, required: true },
|
||||
openUserProfile: { type: Function, required: true },
|
||||
openUserDeleteModal: { type: Function, required: true },
|
||||
openUserEditModal: { type: Function, required: true },
|
||||
lockResetIcon: { type: String, required: true },
|
||||
deleteIcon: { type: String, required: true },
|
||||
})
|
||||
|
||||
@@ -49,16 +47,19 @@ const userSortDirectionModel = computed({
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="panel__title">회원 관리</div>
|
||||
<div class="hint hint--tight">회원 프로필을 정리하고, 필요한 경우에만 권한 변경과 비밀번호 초기화를 진행할 수 있어요.</div>
|
||||
<div class="hint hint--tight">팔로워·즐겨찾기 지표로 핵심 작성자를 확인하고, 회원 정보와 권한만 최소한으로 관리해요.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar toolbar--secondary">
|
||||
<input v-model="userQueryModel" class="input toolbar__search" placeholder="이메일, 닉네임 검색" @keydown.enter.prevent="props.submitUserFilters" />
|
||||
<select v-model="userSortModel" class="select toolbar__select" @change="props.submitUserFilters">
|
||||
<option value="recent">최근 활동순</option>
|
||||
<option value="recent">최근 콘텐츠 활동순</option>
|
||||
<option value="lastLogin">마지막 접속순</option>
|
||||
<option value="created">가입순</option>
|
||||
<option value="tierlists">작성 티어표 많은 순</option>
|
||||
<option value="followers">팔로워 많은 순</option>
|
||||
<option value="favorites">받은 즐겨찾기 많은 순</option>
|
||||
</select>
|
||||
<select v-model="userSortDirectionModel" class="select toolbar__select" @change="props.submitUserFilters">
|
||||
<option value="desc">내림차순</option>
|
||||
@@ -113,22 +114,16 @@ const userSortDirectionModel = computed({
|
||||
<div class="userInfoList">
|
||||
<div class="userInfoLine"><span>가입일</span><strong>{{ props.fmt(user.createdAt) }}</strong></div>
|
||||
<div class="userInfoLine"><span>작성 티어표</span><strong>{{ user.tierListCount }}개</strong></div>
|
||||
<div class="userInfoLine"><span>최근 활동</span><strong>{{ props.fmt(user.recentActivityAt || user.createdAt) }}</strong></div>
|
||||
<div class="userInfoLine"><span>팔로워</span><strong>{{ user.followerCount || 0 }}명</strong></div>
|
||||
<div class="userInfoLine"><span>받은 즐겨찾기</span><strong>{{ user.receivedFavoriteCount || 0 }}개</strong></div>
|
||||
<div class="userInfoLine"><span>최근 콘텐츠 활동</span><strong>{{ props.fmt(user.recentActivityAt || user.createdAt) }}</strong></div>
|
||||
<div class="userInfoLine"><span>마지막 접속일</span><strong>{{ user.lastLoginAt ? props.fmt(user.lastLoginAt) : '기록 없음' }}</strong></div>
|
||||
<div class="userInfoLine"><span>계정명</span><strong>{{ user.email }}</strong></div>
|
||||
<div class="userInfoLine"><span>닉네임</span><strong>{{ user.nickname || '미설정' }}</strong></div>
|
||||
<div class="userInfoLine"><span>권한</span><strong>{{ props.roleLabelOf(user) }}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="userCard__actions userCard__actions--compact">
|
||||
<button
|
||||
class="iconActionButton"
|
||||
type="button"
|
||||
title="비밀번호 초기화"
|
||||
:disabled="!props.canResetUserPassword(user)"
|
||||
@click="props.openUserPasswordModal(user)"
|
||||
>
|
||||
<SvgIcon class="iconActionButton__icon" :src="props.lockResetIcon" :size="18" />
|
||||
</button>
|
||||
<button
|
||||
class="iconActionButton iconActionButton--danger"
|
||||
type="button"
|
||||
@@ -146,6 +141,13 @@ const userSortDirectionModel = computed({
|
||||
>
|
||||
회원 정보 수정
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--ghost userSaveButton"
|
||||
type="button"
|
||||
@click="props.openUserProfile(user)"
|
||||
>
|
||||
프로필 보기
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -119,9 +119,19 @@ export function useAdminCustomItems({
|
||||
closeCustomItemDeleteModal()
|
||||
closeCustomItemModal()
|
||||
await refreshCustomItems()
|
||||
success.value = item.sourceType === 'template' ? '선택한 템플릿 아이템을 제거했어요.' : '사용자 업로드 이미지를 삭제했어요.'
|
||||
success.value =
|
||||
item.sourceType === 'template'
|
||||
? '선택한 템플릿 아이템을 제거했어요.'
|
||||
: item.sourceType === 'asset'
|
||||
? '선택한 이미지 자산을 삭제했어요.'
|
||||
: '사용자 업로드 이미지를 삭제했어요.'
|
||||
} catch (e) {
|
||||
error.value = item.sourceType === 'template' ? '템플릿 아이템 제거에 실패했어요.' : '사용자 업로드 이미지 삭제에 실패했어요.'
|
||||
error.value =
|
||||
item.sourceType === 'template'
|
||||
? '템플릿 아이템 제거에 실패했어요.'
|
||||
: item.sourceType === 'asset'
|
||||
? '이미지 자산 삭제에 실패했어요.'
|
||||
: '사용자 업로드 이미지 삭제에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +152,10 @@ export function useAdminTemplateManager({
|
||||
}
|
||||
|
||||
async function createTemplate(options = {}) {
|
||||
const nextTopicId = typeof options.topicId === 'string' ? options.topicId.trim() : newTemplateId.value.trim()
|
||||
const nextTopicSlug =
|
||||
typeof options.topicId === 'string'
|
||||
? options.topicId.trim().toLowerCase()
|
||||
: newTemplateId.value.trim().toLowerCase()
|
||||
const nextTopicName = typeof options.topicName === 'string' ? options.topicName.trim() : newTemplateName.value.trim()
|
||||
const preserveUploadState = !!options.preserveUploadState
|
||||
resetMessages()
|
||||
@@ -162,15 +165,18 @@ export function useAdminTemplateManager({
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: nextTopicId,
|
||||
slug: nextTopicSlug,
|
||||
name: nextTopicName,
|
||||
isPublic: !!newTemplateIsPublic.value,
|
||||
thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '',
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error('failed')
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
const requestError = new Error('failed')
|
||||
requestError.data = data
|
||||
throw requestError
|
||||
}
|
||||
const createdTemplate = data.template || {}
|
||||
if (activeTemplateRequest.value?.type === 'create' && activeTemplateRequest.value?.id) {
|
||||
const linkData = await api.linkAdminTemplateRequestTemplate(activeTemplateRequest.value.id, {
|
||||
@@ -201,7 +207,16 @@ export function useAdminTemplateManager({
|
||||
}
|
||||
success.value = '템플릿이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
|
||||
} catch (e) {
|
||||
error.value = '템플릿 생성 실패(관리자 권한/중복 ID 확인)'
|
||||
const errorCode = e?.data?.error || ''
|
||||
if (errorCode === 'topic_slug_taken') {
|
||||
error.value = '이미 사용 중인 템플릿 주소(slug)입니다.'
|
||||
return
|
||||
}
|
||||
if (errorCode === 'topic_slug_invalid') {
|
||||
error.value = '템플릿 주소(slug)는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.'
|
||||
return
|
||||
}
|
||||
error.value = '템플릿 생성 실패(관리자 권한/템플릿 주소 중복 확인)'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,10 @@ export function useAdminTemplateRequests({
|
||||
draftTopicIsPublic: !!request.draftTopicIsPublic,
|
||||
sourceTierListId: request.sourceTierListId || '',
|
||||
sourceTopicId: request.sourceTopicId || '',
|
||||
sourceTopicSlug: request.sourceTopicSlug || '',
|
||||
sourceTierListTitle: request.sourceTierListTitle || '',
|
||||
targetTopicId: request.targetTopicId || '',
|
||||
targetTopicSlug: request.targetTopicSlug || '',
|
||||
targetTopicName: request.targetTopicName || '',
|
||||
requesterName: request.requesterName || '',
|
||||
}
|
||||
@@ -38,8 +40,9 @@ export function useAdminTemplateRequests({
|
||||
}
|
||||
|
||||
function templateRequestSourceUrl(request) {
|
||||
if (!request?.sourceTopicId || !request?.sourceTierListId) return ''
|
||||
return editorPath(request.sourceTopicId, request.sourceTierListId, { preview: true })
|
||||
const topicRef = request?.sourceTopicSlug || request?.sourceTopicId || ''
|
||||
if (!topicRef || !request?.sourceTierListId) return ''
|
||||
return editorPath(topicRef, request.sourceTierListId, { preview: true })
|
||||
}
|
||||
|
||||
function templateRequestReviewHint(request) {
|
||||
|
||||
@@ -39,7 +39,7 @@ async function request(path, { method = 'GET', body, headers } = {}) {
|
||||
} else if (res.status >= 500) {
|
||||
emitBackendStatus({
|
||||
state: 'maintenance',
|
||||
message: '서비스 내부 점검이 필요합니다. 잠시 후 다시 이용해주세요.',
|
||||
message: '서비스 내부 점검중입니다. 잠시 후 다시 이용해주세요.',
|
||||
path,
|
||||
})
|
||||
}
|
||||
@@ -81,10 +81,10 @@ export const api = {
|
||||
request(
|
||||
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
|
||||
),
|
||||
listAdminTierLists: ({ q = '', topicId = '', page = 1, limit = 50 } = {}) =>
|
||||
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
|
||||
getAdminTierListStats: ({ q = '', topicId = '' } = {}) =>
|
||||
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}`),
|
||||
listAdminTierLists: ({ q = '', topicId = '', sort = 'recent', minFavorites = 0, page = 1, limit = 50 } = {}) =>
|
||||
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}&sort=${encodeURIComponent(sort)}&minFavorites=${encodeURIComponent(minFavorites)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
|
||||
getAdminTierListStats: ({ q = '', topicId = '', minFavorites = 0 } = {}) =>
|
||||
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}&minFavorites=${encodeURIComponent(minFavorites)}`),
|
||||
updateAdminTierList: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }),
|
||||
updateAdminTierListFeatured: (tierListId, payload) =>
|
||||
|
||||
@@ -25,6 +25,10 @@ export function editorPath(topicId, tierListId, { preview = false } = {}) {
|
||||
return preview ? `${base}?preview=1` : base
|
||||
}
|
||||
|
||||
export function shareEditorPath(topicId, tierListId) {
|
||||
return `/share/editor/${encodeSegment(topicId)}/${encodeSegment(tierListId)}`
|
||||
}
|
||||
|
||||
export function mePath() {
|
||||
return '/me'
|
||||
}
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { editorPath } from '../lib/paths'
|
||||
import { editorPath, userProfilePath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import lockResetIcon from '../assets/icons/lock_reset.svg'
|
||||
import deleteIcon from '../assets/icons/delete.svg'
|
||||
import SvgIcon from '../components/SvgIcon.vue'
|
||||
import AdminFeaturedSection from '../components/admin/AdminFeaturedSection.vue'
|
||||
@@ -45,12 +44,14 @@ const customItemQuery = ref('')
|
||||
const customItemPage = ref(1)
|
||||
const customItemLimit = ref(50)
|
||||
const customItemTotal = ref(0)
|
||||
const customItemFilter = ref('all')
|
||||
const customItemFilter = ref('library')
|
||||
const customItemModalTargetTemplateId = ref('')
|
||||
|
||||
const adminTierLists = ref([])
|
||||
const adminTierListQuery = ref('')
|
||||
const adminTierListTopicId = ref('')
|
||||
const adminTierListSort = ref('recent')
|
||||
const adminTierListMinFavorites = ref(0)
|
||||
const adminTierListPage = ref(1)
|
||||
const adminTierListLimit = ref(50)
|
||||
const adminTierListTotal = ref(0)
|
||||
@@ -109,6 +110,9 @@ const success = ref('')
|
||||
const newTemplateId = ref('')
|
||||
const newTemplateName = ref('')
|
||||
const newTemplateIsPublic = ref(false)
|
||||
const templateMetaDraftName = ref('')
|
||||
const templateMetaDraftSlug = ref('')
|
||||
const templateMetaSaving = ref(false)
|
||||
const templateVisibilitySaving = ref(false)
|
||||
|
||||
const uploadFiles = ref([])
|
||||
@@ -176,6 +180,17 @@ function normalizeAdminSrc(src) {
|
||||
}
|
||||
|
||||
const hasSelectedTemplate = computed(() => !!selectedTemplate.value?.template?.id)
|
||||
const canSaveTemplateMeta = computed(() => {
|
||||
const template = selectedTemplate.value?.template
|
||||
if (!template?.id) return false
|
||||
const nextName = templateMetaDraftName.value.trim()
|
||||
const nextSlug = templateMetaDraftSlug.value.trim()
|
||||
return (
|
||||
!!nextName &&
|
||||
!!nextSlug &&
|
||||
(nextName !== (template.name || '') || nextSlug !== (template.slug || template.id || ''))
|
||||
)
|
||||
})
|
||||
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value)
|
||||
const canAddItem = computed(() => uploadItemDrafts.value.length > 0 && uploadItemDrafts.value.every((item) => !!item.label.trim()) && !!selectedTemplateId.value)
|
||||
const stagedRequestDraftCount = computed(() => uploadItemDrafts.value.filter((item) => item.kind === 'request').length)
|
||||
@@ -202,7 +217,7 @@ const filteredTemplatePickerTemplates = computed(() => {
|
||||
const query = templatePickerQuery.value.trim().toLowerCase()
|
||||
const list = templates.value.filter((template) => {
|
||||
if (!query) return true
|
||||
const haystack = `${template.name || ''} ${template.id || ''}`.toLowerCase()
|
||||
const haystack = `${template.name || ''} ${template.slug || ''} ${template.id || ''}`.toLowerCase()
|
||||
return haystack.includes(query)
|
||||
})
|
||||
|
||||
@@ -230,7 +245,7 @@ const activeTabDescription = computed(() => {
|
||||
return '템플릿 생성, 선택, 썸네일, 기본 아이템 관리를 전용 작업 화면으로 분리했습니다.'
|
||||
}
|
||||
if (activeTab.value === 'items') {
|
||||
return '사용자 업로드와 관리자 템플릿 이미지를 함께 검수하고, 필요한 템플릿에 직접 연결할 수 있어요.'
|
||||
return '사용자 아이템과 템플릿 아이템을 함께 검수하고, 필요한 템플릿에 직접 연결할 수 있어요.'
|
||||
}
|
||||
if (activeTab.value === 'tierlists') {
|
||||
return tierlistsMode.value === 'requests'
|
||||
@@ -241,7 +256,12 @@ const activeTabDescription = computed(() => {
|
||||
})
|
||||
const adminOverviewStats = computed(() => {
|
||||
const pendingRequests = templateRequests.value.length
|
||||
const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length
|
||||
const orphanItems = customItems.value.filter(
|
||||
(item) =>
|
||||
item.sourceType === 'user' &&
|
||||
Number(item.usageCount || 0) === 0 &&
|
||||
!(Array.isArray(item.linkedTemplates) && item.linkedTemplates.length > 0)
|
||||
).length
|
||||
const adminCount = users.value.filter((user) => user.isAdmin).length
|
||||
|
||||
if (activeTab.value === 'featured') {
|
||||
@@ -263,8 +283,9 @@ const adminOverviewStats = computed(() => {
|
||||
if (activeTab.value === 'items') {
|
||||
return [
|
||||
{ label: '검색 결과', value: `${customItemTotal.value}` },
|
||||
{ label: '미사용', value: `${orphanItems}` },
|
||||
{ label: '미사용 아이템', value: `${orphanItems}` },
|
||||
{ label: '템플릿 아이템', value: `${customItems.value.filter((item) => item.sourceType === 'template').length}` },
|
||||
{ label: '이미지 자산', value: `${customItems.value.filter((item) => item.sourceType === 'asset').length}` },
|
||||
]
|
||||
}
|
||||
if (activeTab.value === 'tierlists') {
|
||||
@@ -455,6 +476,8 @@ watch(
|
||||
watch(
|
||||
() => selectedTemplate.value?.template?.id || '',
|
||||
async (templateId) => {
|
||||
templateMetaDraftName.value = selectedTemplate.value?.template?.name || ''
|
||||
templateMetaDraftSlug.value = selectedTemplate.value?.template?.slug || selectedTemplate.value?.template?.id || ''
|
||||
await refreshSelectedTemplateTierListStats(templateId)
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -489,7 +512,7 @@ watch(
|
||||
|
||||
if (tab === 'items') {
|
||||
customItemQuery.value = ''
|
||||
customItemFilter.value = 'all'
|
||||
customItemFilter.value = 'library'
|
||||
customItemPage.value = 1
|
||||
await refreshCustomItems()
|
||||
return
|
||||
@@ -598,13 +621,14 @@ function formatImageJobStatus(status) {
|
||||
|
||||
function customItemDeleteImpactText(item) {
|
||||
if (!item) return ''
|
||||
if (item.sourceType === 'asset' || item.isAssetLibraryItem) {
|
||||
return `"${item.label}" ${item.sourceLabel || '이미지 자산'} 항목을 정리할까요? 라이브러리 항목만 제거되고, 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.`
|
||||
}
|
||||
if (item.sourceType === 'template') {
|
||||
return item.isAssetLibraryItem
|
||||
? `"${item.label}" 보관 자산 항목을 정리할까요? 라이브러리 항목만 제거되고, 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.`
|
||||
: `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 주제의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.`
|
||||
return `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 주제의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.`
|
||||
}
|
||||
|
||||
return `"${item.label}" 사용자 업로드 이미지를 삭제할까요? 현재 항목만 정리됩니다.`
|
||||
return `"${item.label}" 사용자 아이템을 삭제할까요? 현재 항목만 정리됩니다.`
|
||||
}
|
||||
|
||||
const imageDiagnosticsCards = computed(() => {
|
||||
@@ -746,7 +770,7 @@ function setTab(tab) {
|
||||
}
|
||||
if (tab === 'items') {
|
||||
customItemQuery.value = ''
|
||||
customItemFilter.value = 'all'
|
||||
customItemFilter.value = 'library'
|
||||
customItemPage.value = 1
|
||||
refreshCustomItems()
|
||||
}
|
||||
@@ -824,6 +848,8 @@ async function refreshAdminTierLists() {
|
||||
const data = await api.listAdminTierLists({
|
||||
q: adminTierListQuery.value,
|
||||
topicId: adminTierListTopicId.value,
|
||||
sort: adminTierListSort.value,
|
||||
minFavorites: adminTierListMinFavorites.value,
|
||||
page: adminTierListPage.value,
|
||||
limit: adminTierListLimit.value,
|
||||
})
|
||||
@@ -840,7 +866,11 @@ async function refreshAdminTierLists() {
|
||||
async function refreshAdminTierListStats() {
|
||||
if (!auth.user?.isAdmin) return
|
||||
try {
|
||||
const data = await api.getAdminTierListStats({ q: adminTierListQuery.value, topicId: adminTierListTopicId.value })
|
||||
const data = await api.getAdminTierListStats({
|
||||
q: adminTierListQuery.value,
|
||||
topicId: adminTierListTopicId.value,
|
||||
minFavorites: adminTierListMinFavorites.value,
|
||||
})
|
||||
adminTierListStats.value = {
|
||||
total: data.total || 0,
|
||||
publicCount: data.publicCount || 0,
|
||||
@@ -879,7 +909,7 @@ async function refreshTemplateRequests() {
|
||||
draftTopicId:
|
||||
request.type === 'create'
|
||||
? ('tmpl-' + String(request.id || 'request').replace(/[^a-zA-Z0-9]+/g, '').slice(0, 12).toLowerCase())
|
||||
: request.targetTopicId || request.sourceTopicId || '',
|
||||
: request.targetTopicSlug || request.sourceTopicSlug || request.targetTopicId || request.sourceTopicId || '',
|
||||
draftTopicName:
|
||||
request.type === 'create'
|
||||
? `${request.sourceTierListTitle || request.sourceTopicName || '새 템플릿'}`
|
||||
@@ -1198,6 +1228,44 @@ async function saveTemplateVisibility() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTemplateMeta() {
|
||||
if (!selectedTemplate.value?.template?.id || templateMetaSaving.value || !canSaveTemplateMeta.value) return
|
||||
|
||||
try {
|
||||
templateMetaSaving.value = true
|
||||
const data = await api.updateAdminTemplate(selectedTemplate.value.template.id, {
|
||||
name: templateMetaDraftName.value.trim(),
|
||||
slug: templateMetaDraftSlug.value.trim().toLowerCase(),
|
||||
isPublic: !!selectedTemplate.value.template.isPublic,
|
||||
})
|
||||
const nextTemplate = data.template || {}
|
||||
selectedTemplate.value = {
|
||||
...selectedTemplate.value,
|
||||
template: {
|
||||
...selectedTemplate.value.template,
|
||||
...nextTemplate,
|
||||
},
|
||||
}
|
||||
templateMetaDraftName.value = nextTemplate.name || selectedTemplate.value.template.name || ''
|
||||
templateMetaDraftSlug.value = nextTemplate.slug || selectedTemplate.value.template.slug || selectedTemplate.value.template.id || ''
|
||||
await refreshTemplates()
|
||||
success.value = '템플릿 이름과 slug를 저장했어요.'
|
||||
} catch (e) {
|
||||
const errorCode = e?.data?.error || ''
|
||||
if (errorCode === 'topic_slug_taken') {
|
||||
error.value = '이미 사용 중인 템플릿 slug입니다.'
|
||||
return
|
||||
}
|
||||
if (errorCode === 'topic_slug_invalid') {
|
||||
error.value = 'slug는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.'
|
||||
return
|
||||
}
|
||||
error.value = '템플릿 이름/slug를 저장하지 못했어요.'
|
||||
} finally {
|
||||
templateMetaSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSelectedTemplateVisibility(nextValue) {
|
||||
if (!selectedTemplate.value?.template?.id || templateVisibilitySaving.value) return
|
||||
const previous = !!selectedTemplate.value.template.isPublic
|
||||
@@ -1312,6 +1380,17 @@ function submitAdminTierListSearch() {
|
||||
refreshAdminTierLists()
|
||||
}
|
||||
|
||||
function changeAdminTierListSort() {
|
||||
adminTierListPage.value = 1
|
||||
refreshAdminTierLists()
|
||||
}
|
||||
|
||||
function changeAdminTierListMinFavorites() {
|
||||
adminTierListMinFavorites.value = Math.max(Number(adminTierListMinFavorites.value) || 0, 0)
|
||||
adminTierListPage.value = 1
|
||||
refreshAdminTierLists()
|
||||
}
|
||||
|
||||
function setAdminTierListTopicId(topicId) {
|
||||
adminTierListTopicId.value = topicId || ''
|
||||
adminTierListPage.value = 1
|
||||
@@ -1371,7 +1450,7 @@ function buildModalItemFromTierListItem(item, tierList) {
|
||||
id,
|
||||
label: item?.label || matchedItem?.label || '이름 없음',
|
||||
src: item?.src || matchedItem?.src || '',
|
||||
sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'template' : 'user'),
|
||||
sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'asset' : 'user'),
|
||||
sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템',
|
||||
ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList),
|
||||
linkedTemplates: Array.isArray(matchedItem?.linkedTemplates) ? matchedItem.linkedTemplates : [],
|
||||
@@ -1578,8 +1657,9 @@ function closePreviewModal() {
|
||||
}
|
||||
|
||||
function previewTierListUrl(tierList) {
|
||||
if (!tierList?.topicId || !tierList?.id) return ''
|
||||
return editorPath(tierList.topicId, tierList.id, { preview: true })
|
||||
const topicRef = tierList?.topicSlug || tierList?.topicId || ''
|
||||
if (!topicRef || !tierList?.id) return ''
|
||||
return editorPath(topicRef, tierList.id, { preview: true })
|
||||
}
|
||||
|
||||
function openTierListImportModal(tierList, items) {
|
||||
@@ -1594,9 +1674,10 @@ function openTierListImportModal(tierList, items) {
|
||||
importModalItems.value = nextItems
|
||||
importModalMode.value = 'existing'
|
||||
importModalTargetTemplateId.value = ''
|
||||
importModalNewTemplateId.value = tierList.topicId === 'freeform' ? '' : `${tierList.topicId}-copy`
|
||||
const baseSlug = tierList.topicSlug || tierList.topicId || ''
|
||||
importModalNewTemplateId.value = baseSlug === 'freeform' ? '' : `${baseSlug}-copy`
|
||||
importModalNewTemplateName.value =
|
||||
tierList.topicId === 'freeform' ? `${tierList.title} 템플릿` : `${tierList.topicName || tierList.topicId} 파생 템플릿`
|
||||
baseSlug === 'freeform' ? `${tierList.title} 템플릿` : `${tierList.topicName || baseSlug} 파생 템플릿`
|
||||
importModalOpen.value = true
|
||||
}
|
||||
|
||||
@@ -1630,15 +1711,15 @@ async function confirmTierListImport() {
|
||||
if (selectedTemplateId.value === importModalTargetTemplateId.value) await loadTemplate()
|
||||
success.value = `${data.items?.length || 0}개의 아이템을 기존 템플릿에 추가했어요.`
|
||||
} else {
|
||||
const nextTopicId = (importModalNewTemplateId.value || '').trim()
|
||||
const nextTopicId = (importModalNewTemplateId.value || '').trim().toLowerCase()
|
||||
const nextTopicName = (importModalNewTemplateName.value || '').trim()
|
||||
if (!nextTopicId || !nextTopicName) {
|
||||
error.value = '새 템플릿 ID와 이름을 모두 입력해주세요.'
|
||||
error.value = '새 템플릿 slug와 이름을 모두 입력해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
const data = await api.createAdminTemplateFromTierList(tierList.id, {
|
||||
topicId: nextTopicId,
|
||||
slug: nextTopicId,
|
||||
name: nextTopicName,
|
||||
itemIds,
|
||||
})
|
||||
@@ -1659,11 +1740,11 @@ function templateRequestTypeLabel(request) {
|
||||
function templateRequestTargetLabel(request) {
|
||||
if (request.type === 'create') {
|
||||
if (request.targetTopicName || request.targetTopicId) {
|
||||
return `연결된 템플릿 · ${request.targetTopicName || request.targetTopicId}`
|
||||
return `연결된 템플릿 · ${request.targetTopicName || request.targetTopicSlug || request.targetTopicId}`
|
||||
}
|
||||
return '연결된 템플릿 없음'
|
||||
}
|
||||
return request.targetTopicName || request.targetTopicId || request.sourceTopicName
|
||||
return request.targetTopicName || request.targetTopicSlug || request.targetTopicId || request.sourceTopicName
|
||||
}
|
||||
|
||||
const displayThumbnailUrl = computed(() => {
|
||||
@@ -1690,6 +1771,11 @@ function userAvatarFallback(user) {
|
||||
return (user?.email?.trim()?.[0] || '?').toUpperCase()
|
||||
}
|
||||
|
||||
function openUserProfile(user) {
|
||||
if (!user?.id) return
|
||||
router.push(userProfilePath(user.id))
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1738,6 +1824,11 @@ function userAvatarFallback(user) {
|
||||
:has-selected-template="hasSelectedTemplate"
|
||||
:selected-template="selectedTemplate"
|
||||
:display-thumbnail-url="displayThumbnailUrl"
|
||||
v-model:template-meta-draft-name="templateMetaDraftName"
|
||||
v-model:template-meta-draft-slug="templateMetaDraftSlug"
|
||||
:template-meta-saving="templateMetaSaving"
|
||||
:can-save-template-meta="canSaveTemplateMeta"
|
||||
:save-template-meta="saveTemplateMeta"
|
||||
:can-apply-thumbnail="canApplyThumbnail"
|
||||
:template-visibility-saving="templateVisibilitySaving"
|
||||
:thumb-file-input-ref="setThumbFileInputRef"
|
||||
@@ -1825,14 +1916,12 @@ function userAvatarFallback(user) {
|
||||
:remove-user-avatar="removeUserAvatar"
|
||||
:can-edit-user-avatar="canEditUserAvatar"
|
||||
:can-edit-user-info="canEditUserInfo"
|
||||
:can-reset-user-password="canResetUserPassword"
|
||||
:can-delete-user="canDeleteUser"
|
||||
:role-label-of="roleLabelOf"
|
||||
:fmt="fmt"
|
||||
:open-user-password-modal="openUserPasswordModal"
|
||||
:open-user-profile="openUserProfile"
|
||||
:open-user-delete-modal="openUserDeleteModal"
|
||||
:open-user-edit-modal="openUserEditModal"
|
||||
:lock-reset-icon="lockResetIcon"
|
||||
:delete-icon="deleteIcon"
|
||||
@update:user-query="userQuery = $event"
|
||||
@update:user-sort="userSort = $event"
|
||||
@@ -1840,9 +1929,9 @@ function userAvatarFallback(user) {
|
||||
/>
|
||||
|
||||
<div v-if="templateCreateModalOpen" class="modalOverlay" @click.self="closeTemplateCreateModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">새 템플릿 만들기</div>
|
||||
<div class="modalCard__desc">템플릿 이름과 고유 ID를 입력한 뒤 생성하면 바로 아래 상세 관리 화면으로 이어집니다.</div>
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">새 템플릿 만들기</div>
|
||||
<div class="modalCard__desc">템플릿 이름과 공개 주소용 slug를 입력하면, 내부 ID는 서버가 자동으로 생성합니다.</div>
|
||||
<div class="modalCard__form">
|
||||
<label class="field">
|
||||
<span class="field__label">템플릿 이름</span>
|
||||
@@ -1850,15 +1939,15 @@ function userAvatarFallback(user) {
|
||||
<span class="field__hint">{{ newTemplateName.length }}/60자</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">템플릿 ID</span>
|
||||
<span class="field__label">템플릿 slug</span>
|
||||
<input
|
||||
v-model="newTemplateId"
|
||||
class="field__input"
|
||||
maxlength="120"
|
||||
placeholder="topic id (영문/숫자)"
|
||||
placeholder="예: idol-rhythm"
|
||||
@keydown.enter.prevent="createTemplate"
|
||||
/>
|
||||
<span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newTemplateId.length }}/120자</span>
|
||||
<span class="field__hint">영문 소문자, 숫자, 하이픈만 사용 · {{ newTemplateId.length }}/120자</span>
|
||||
</label>
|
||||
<label class="toggleSwitch">
|
||||
<input v-model="newTemplateIsPublic" type="checkbox" />
|
||||
@@ -1906,31 +1995,6 @@ function userAvatarFallback(user) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userPasswordModalOpen" class="modalOverlay" @click.self="closeUserPasswordModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">비밀번호 초기화</div>
|
||||
<div class="modalCard__desc">{{ modalTargetUser ? `${userDisplayName(modalTargetUser)} 계정에 설정할 새 비밀번호를 입력해주세요.` : '' }}</div>
|
||||
<div class="modalCard__form">
|
||||
<label class="field">
|
||||
<span class="field__label">새 비밀번호</span>
|
||||
<input
|
||||
v-model="modalPasswordDraft"
|
||||
class="field__input"
|
||||
type="password"
|
||||
maxlength="120"
|
||||
placeholder="초기화할 비밀번호 입력"
|
||||
@keydown.enter.prevent="confirmUserPasswordReset"
|
||||
/>
|
||||
<span class="field__hint">6~120자 권장 · {{ modalPasswordDraft.length }}/120자</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeUserPasswordModal">취소</button>
|
||||
<button class="btn btn--primary" :disabled="!modalPasswordDraft.trim()" @click="confirmUserPasswordReset">초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userDeleteModalOpen" class="modalOverlay" @click.self="closeUserDeleteModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">회원 삭제</div>
|
||||
@@ -1985,7 +2049,7 @@ function userAvatarFallback(user) {
|
||||
</div>
|
||||
|
||||
<div v-else class="modalCard__form">
|
||||
<input v-model="importModalNewTemplateId" class="input" placeholder="새 템플릿 ID" />
|
||||
<input v-model="importModalNewTemplateId" class="input" placeholder="새 템플릿 slug" />
|
||||
<input v-model="importModalNewTemplateName" class="input" placeholder="새 템플릿 이름" />
|
||||
</div>
|
||||
|
||||
@@ -2009,7 +2073,7 @@ function userAvatarFallback(user) {
|
||||
<div class="adminSelectionCard">
|
||||
<div class="adminSelectionCard__label">선택한 템플릿</div>
|
||||
<div class="adminSelectionCard__title">{{ customItemTargetTemplate?.name || '아직 선택하지 않음' }}</div>
|
||||
<div class="adminSelectionCard__meta">{{ customItemTargetTemplate?.id || '템플릿을 골라 주세요.' }}</div>
|
||||
<div class="adminSelectionCard__meta">{{ customItemTargetTemplate?.slug || customItemTargetTemplate?.id || '템플릿을 골라 주세요.' }}</div>
|
||||
</div>
|
||||
<div class="customItemModal__pickerActions">
|
||||
<button class="btn btn--ghost" type="button" @click="openTemplatePickerModal('custom-item-target')">템플릿 선택</button>
|
||||
@@ -2075,7 +2139,7 @@ function userAvatarFallback(user) {
|
||||
<button class="btn btn--ghost btn--small" @click="closeTemplatePickerModal">닫기</button>
|
||||
</div>
|
||||
<div class="modalCard__form">
|
||||
<input v-model="templatePickerQuery" class="input" placeholder="템플릿 이름 또는 ID 검색" />
|
||||
<input v-model="templatePickerQuery" class="input" placeholder="템플릿 이름 또는 slug 검색" />
|
||||
<select v-model="templatePickerSort" class="select">
|
||||
<option value="recent">최신순</option>
|
||||
<option value="oldest">오래된순</option>
|
||||
@@ -2107,7 +2171,7 @@ function userAvatarFallback(user) {
|
||||
@click="chooseTemplateFromPicker(template.id)"
|
||||
>
|
||||
<span class="adminTemplatePicker__name">{{ template.name }}</span>
|
||||
<span class="adminTemplatePicker__meta">{{ template.id }}</span>
|
||||
<span class="adminTemplatePicker__meta">{{ template.slug || template.id }}</span>
|
||||
<span v-if="templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id)" class="adminTemplatePicker__state">이미 추가됨</span>
|
||||
</button>
|
||||
<div v-if="!filteredTemplatePickerTemplates.length" class="hint hint--tight">검색 결과가 없어요.</div>
|
||||
@@ -2273,7 +2337,7 @@ function userAvatarFallback(user) {
|
||||
<div v-if="selectedTemplate?.template" class="adminSelectionCard">
|
||||
<div class="adminSelectionCard__label">선택한 템플릿</div>
|
||||
<div class="adminSelectionCard__title">{{ selectedTemplate.template.name }}</div>
|
||||
<div class="adminSelectionCard__meta">{{ selectedTemplate.template.id }}</div>
|
||||
<div class="adminSelectionCard__meta">{{ selectedTemplate.template.slug || selectedTemplate.template.id }}</div>
|
||||
</div>
|
||||
<div v-if="selectedTemplateId && !hasSelectedTemplate && !isTemplateLoading" class="hint hint--tight">선택된 템플릿 ID: {{ selectedTemplateId }}</div>
|
||||
</div>
|
||||
@@ -2294,16 +2358,17 @@ function userAvatarFallback(user) {
|
||||
</select>
|
||||
<select :value="customItemFilter" class="select" @change="changeCustomItemFilter($event.target.value)">
|
||||
<option value="all">전체 이미지</option>
|
||||
<option value="user">사용자 업로드</option>
|
||||
<option value="template">템플릿 사용 이미지</option>
|
||||
<option value="asset">관리자 보관 자산</option>
|
||||
<option value="unused-user">미사용 사용자 업로드</option>
|
||||
<option value="unused-admin">미사용 관리자 자산</option>
|
||||
<option value="library">아이템(템플릿 + 사용자)</option>
|
||||
<option value="template">템플릿 아이템</option>
|
||||
<option value="user">사용자 아이템</option>
|
||||
<option value="thumbnail">썸네일 이미지</option>
|
||||
<option value="avatar">프로필 이미지</option>
|
||||
<option value="unused-user">미사용 아이템</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button>
|
||||
<button class="btn btn--danger" :disabled="customItemFilter !== 'unused-user' || !customItems.length" @click="removeUnusedCustomItems">미사용 사용자 이미지 일괄 삭제</button>
|
||||
<button class="btn btn--danger" :disabled="customItemFilter !== 'unused-user' || !customItems.length" @click="removeUnusedCustomItems">미사용 아이템 일괄 삭제</button>
|
||||
</div>
|
||||
<div class="adminSidebar__stats">
|
||||
<div class="sidebarStat">
|
||||
@@ -2350,6 +2415,21 @@ function userAvatarFallback(user) {
|
||||
<option :value="50">50개씩 보기</option>
|
||||
<option :value="200">200개씩 보기</option>
|
||||
</select>
|
||||
<select v-model="adminTierListSort" class="select" @change="changeAdminTierListSort">
|
||||
<option value="recent">최근 수정순</option>
|
||||
<option value="created">최근 생성순</option>
|
||||
<option value="favorites">즐겨찾기 많은 순</option>
|
||||
</select>
|
||||
<input
|
||||
v-model.number="adminTierListMinFavorites"
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1000000"
|
||||
placeholder="최소 즐겨찾기 수"
|
||||
@change="changeAdminTierListMinFavorites"
|
||||
@keydown.enter.prevent="changeAdminTierListMinFavorites"
|
||||
/>
|
||||
</div>
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--ghost" @click="refreshAdminTierLists">새로고침</button>
|
||||
@@ -3469,6 +3549,9 @@ function userAvatarFallback(user) {
|
||||
.adminUiScope .customItemCard__badge--template {
|
||||
background: rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
.adminUiScope .customItemCard__badge--asset {
|
||||
background: rgba(251, 191, 36, 0.18);
|
||||
}
|
||||
.adminUiScope .customItemCard:hover {
|
||||
border-color: rgba(126, 162, 255, 0.42);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
|
||||
@@ -48,7 +48,7 @@ async function loadFavorites() {
|
||||
}
|
||||
|
||||
function openTierList(tierList) {
|
||||
router.push(editorPath(tierList.topicId, tierList.id))
|
||||
router.push(editorPath(tierList.topicSlug || tierList.topicId, tierList.id))
|
||||
}
|
||||
|
||||
onMounted(loadFavorites)
|
||||
|
||||
@@ -66,7 +66,7 @@ async function loadFollowingFeed() {
|
||||
}
|
||||
|
||||
function openTierList(tierList) {
|
||||
router.push(editorPath(tierList.topicId, tierList.id))
|
||||
router.push(editorPath(tierList.topicSlug || tierList.topicId, tierList.id))
|
||||
}
|
||||
|
||||
function openAuthorProfile(tierList) {
|
||||
|
||||
@@ -21,7 +21,7 @@ const templates = computed(() => {
|
||||
.filter((item) => item.id !== 'freeform')
|
||||
.filter((item) => {
|
||||
if (!query.value) return true
|
||||
const haystack = `${item.name || ''} ${item.id || ''}`.toLowerCase()
|
||||
const haystack = `${item.name || ''} ${item.slug || ''}`.toLowerCase()
|
||||
return haystack.includes(query.value)
|
||||
})
|
||||
|
||||
@@ -49,8 +49,8 @@ async function loadTemplates() {
|
||||
onMounted(loadTemplates)
|
||||
watch(() => auth.user?.id, loadTemplates)
|
||||
|
||||
function openTopic(templateId) {
|
||||
router.push(topicPath(templateId))
|
||||
function openTopic(template) {
|
||||
router.push(topicPath(template?.slug || template?.id || ''))
|
||||
}
|
||||
|
||||
async function toggleFavorite(template, event) {
|
||||
@@ -99,14 +99,14 @@ function templateThumbUrl(template) {
|
||||
>
|
||||
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
|
||||
</button>
|
||||
<button class="libraryCard__main" type="button" @click="openTopic(template.id)">
|
||||
<button class="libraryCard__main" type="button" @click="openTopic(template)">
|
||||
<div class="libraryCard__thumbWrap">
|
||||
<img v-if="templateThumbUrl(template)" class="libraryCard__thumb" :src="templateThumbUrl(template)" :alt="template.name" draggable="false" />
|
||||
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="libraryCard__body">
|
||||
<div class="libraryCard__title">{{ template.name }}</div>
|
||||
<div class="libraryCard__meta">{{ template.id }}</div>
|
||||
<div class="libraryCard__meta">{{ template.slug || template.id }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
|
||||
@@ -60,7 +60,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
function openList(t) {
|
||||
router.push(editorPath(t.topicId, t.id))
|
||||
router.push(editorPath(t.topicSlug || t.topicId, t.id))
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ function tierListThumbnailUrl(tierList) {
|
||||
}
|
||||
|
||||
function openTierList(tierList) {
|
||||
router.push(editorPath(tierList.topicId, tierList.id))
|
||||
router.push(editorPath(tierList.topicSlug || tierList.topicId, tierList.id))
|
||||
}
|
||||
|
||||
async function loadResults() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { Teleport, computed, inject, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Sortable from 'sortablejs'
|
||||
import * as htmlToImage from 'html-to-image'
|
||||
@@ -10,7 +10,7 @@ import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
|
||||
import shareIcon from '../assets/icons/share.svg'
|
||||
import RightRailAd from '../components/RightRailAd.vue'
|
||||
import { api } from '../lib/api'
|
||||
import { editorNewPath, editorPath, loginPath, mePath, topicPath, userProfilePath } from '../lib/paths'
|
||||
import { editorNewPath, editorPath, loginPath, mePath, shareEditorPath, topicPath, userProfilePath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToast } from '../composables/useToast'
|
||||
@@ -79,6 +79,12 @@ const poolSearchQuery = ref('')
|
||||
const selectedItemId = ref('')
|
||||
const recentDragFinishedAt = ref(0)
|
||||
const savedEditorSnapshot = ref('')
|
||||
const itemContextMenu = ref({
|
||||
open: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
itemId: '',
|
||||
})
|
||||
let editorLoadToken = 0
|
||||
|
||||
const boardEl = ref(null)
|
||||
@@ -96,6 +102,12 @@ const isNewTierList = computed(() => tierListId.value === 'new')
|
||||
const isOwnTierList = computed(() => !!auth.user && !!ownerId.value && ownerId.value === auth.user.id)
|
||||
const canEdit = computed(() => !!auth.user && !previewMode.value && (!ownerId.value || ownerId.value === auth.user.id))
|
||||
const iconSizeOptions = [48, 64, 80, 96, 112]
|
||||
const touchSortableOptions = {
|
||||
delayOnTouchOnly: true,
|
||||
delay: 180,
|
||||
touchStartThreshold: 8,
|
||||
fallbackTolerance: 8,
|
||||
}
|
||||
const hasCustomTitle = computed(() => !!(title.value || '').trim())
|
||||
const fallbackTimestamp = computed(() => (updatedAt.value ? updatedAt.value : Date.now()))
|
||||
const effectiveAuthorName = computed(() => {
|
||||
@@ -135,7 +147,7 @@ const copiedFromLabel = computed(() => {
|
||||
return parts.join(' · ') || '복사해 온 티어표'
|
||||
})
|
||||
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
|
||||
const normalizedPoolSearchQuery = computed(() => poolSearchQuery.value.trim().toLowerCase())
|
||||
const normalizedPoolSearchQuery = computed(() => normalizeSearchText(poolSearchQuery.value))
|
||||
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
|
||||
const canRequestTemplateCreate = computed(
|
||||
() => canEdit.value && hasSavedTierList.value && templateId.value === 'freeform' && customItems.value.length > 0
|
||||
@@ -149,8 +161,9 @@ const templateRequestTargetLabel = computed(() => (templateId.value === 'freefor
|
||||
const shareTierListUrl = computed(() => {
|
||||
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
||||
if (!savedTierListId) return ''
|
||||
if (typeof window === 'undefined') return editorPath(templateId.value, savedTierListId, { preview: true })
|
||||
return new URL(editorPath(templateId.value, savedTierListId, { preview: true }), window.location.origin).toString()
|
||||
const sharePath = shareEditorPath(templateId.value, savedTierListId)
|
||||
if (typeof window === 'undefined') return sharePath
|
||||
return new URL(sharePath, window.location.origin).toString()
|
||||
})
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
@@ -158,6 +171,13 @@ watch(error, (message) => {
|
||||
error.value = ''
|
||||
})
|
||||
|
||||
function normalizeSearchText(text) {
|
||||
return String(text || '')
|
||||
.normalize('NFC')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function createAutoTierListTitle() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
const pick = (size) => Array.from({ length: size }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
|
||||
@@ -223,7 +243,7 @@ function isPoolItemVisible(itemId) {
|
||||
const query = normalizedPoolSearchQuery.value
|
||||
if (!query) return true
|
||||
const item = itemsById.value[itemId]
|
||||
const label = String(item?.label || itemId || '').toLowerCase()
|
||||
const label = normalizeSearchText(item?.label || itemId || '')
|
||||
return label.includes(query)
|
||||
}
|
||||
|
||||
@@ -329,6 +349,31 @@ function shouldIgnoreItemClick() {
|
||||
return Date.now() - recentDragFinishedAt.value < 180
|
||||
}
|
||||
|
||||
function closeItemContextMenu() {
|
||||
if (!itemContextMenu.value.open) return
|
||||
itemContextMenu.value = {
|
||||
open: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
itemId: '',
|
||||
}
|
||||
}
|
||||
|
||||
function openItemContextMenu(itemId, event) {
|
||||
if (!canEdit.value || !itemId || !itemsById.value[itemId] || shouldIgnoreItemClick()) return
|
||||
selectedItemId.value = itemId
|
||||
const viewportWidth = typeof window === 'undefined' ? 0 : window.innerWidth
|
||||
const viewportHeight = typeof window === 'undefined' ? 0 : window.innerHeight
|
||||
const menuX = Number(event?.clientX || 0)
|
||||
const menuY = Number(event?.clientY || 0)
|
||||
itemContextMenu.value = {
|
||||
open: true,
|
||||
x: viewportWidth ? Math.max(12, Math.min(menuX, viewportWidth - 180)) : menuX,
|
||||
y: viewportHeight ? Math.max(12, Math.min(menuY, viewportHeight - 72)) : menuY,
|
||||
itemId,
|
||||
}
|
||||
}
|
||||
|
||||
function getItemLocation(itemId) {
|
||||
if (!itemId) return { type: null, groupId: '', columnIndex: -1, index: -1 }
|
||||
|
||||
@@ -405,6 +450,56 @@ function moveSelectedItemToPool() {
|
||||
selectedItemId.value = ''
|
||||
}
|
||||
|
||||
function duplicateItemToPool() {
|
||||
if (!canEdit.value || !itemContextMenu.value.itemId) return
|
||||
|
||||
const sourceItem = itemsById.value[itemContextMenu.value.itemId]
|
||||
if (!sourceItem) {
|
||||
closeItemContextMenu()
|
||||
return
|
||||
}
|
||||
|
||||
const clonedId = createClonedItemId()
|
||||
itemsById.value = {
|
||||
...itemsById.value,
|
||||
[clonedId]: {
|
||||
...sourceItem,
|
||||
id: clonedId,
|
||||
},
|
||||
}
|
||||
pool.value = [clonedId, ...pool.value]
|
||||
selectedItemId.value = clonedId
|
||||
closeItemContextMenu()
|
||||
toast.success('아이템 추가 완료')
|
||||
}
|
||||
|
||||
function handleGlobalContextMenu(event) {
|
||||
const target = event?.target
|
||||
if (target?.closest?.('[data-item-context-menu]')) {
|
||||
event?.preventDefault?.()
|
||||
event?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
|
||||
const itemEl = target?.closest?.('[data-item-id]')
|
||||
if (canEdit.value && itemEl?.dataset?.itemId) {
|
||||
event?.preventDefault?.()
|
||||
event?.stopPropagation?.()
|
||||
openItemContextMenu(itemEl.dataset.itemId, event)
|
||||
return
|
||||
}
|
||||
|
||||
if (!itemContextMenu.value.open) return
|
||||
closeItemContextMenu()
|
||||
}
|
||||
|
||||
function handleGlobalPointerDown(event) {
|
||||
if (!itemContextMenu.value.open) return
|
||||
const target = event?.target
|
||||
if (target?.closest?.('[data-item-context-menu]')) return
|
||||
closeItemContextMenu()
|
||||
}
|
||||
|
||||
function setGroupDropEl(groupId, columnIndex, el) {
|
||||
const key = `${groupId}::${columnIndex}`
|
||||
if (!el) {
|
||||
@@ -458,6 +553,7 @@ async function initSortables() {
|
||||
destroySortables()
|
||||
|
||||
groupSortable.value = Sortable.create(groupListEl.value, {
|
||||
...touchSortableOptions,
|
||||
animation: 160,
|
||||
handle: '[data-group-handle]',
|
||||
ghostClass: 'ghost',
|
||||
@@ -471,6 +567,7 @@ async function initSortables() {
|
||||
})
|
||||
|
||||
poolSortable.value = Sortable.create(poolEl.value, {
|
||||
...touchSortableOptions,
|
||||
group: 'tier-items',
|
||||
animation: 160,
|
||||
draggable: '[data-item-id]',
|
||||
@@ -488,6 +585,7 @@ async function initSortables() {
|
||||
|
||||
dropSortables.value = Object.entries(groupDropEls.value).map(([, el]) =>
|
||||
Sortable.create(el, {
|
||||
...touchSortableOptions,
|
||||
group: 'tier-items',
|
||||
animation: 160,
|
||||
draggable: '[data-item-id]',
|
||||
@@ -537,6 +635,7 @@ function createColumnName(index = columns.value.length) {
|
||||
|
||||
function createCustomItemLabel(fileName = '') {
|
||||
const normalized = String(fileName || '')
|
||||
.normalize('NFC')
|
||||
.replace(/\.[^.]+$/, '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
@@ -544,6 +643,10 @@ function createCustomItemLabel(fileName = '') {
|
||||
return (normalized || 'custom').slice(0, 60)
|
||||
}
|
||||
|
||||
function createClonedItemId() {
|
||||
return `dup-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
|
||||
}
|
||||
|
||||
async function addGroup() {
|
||||
groups.value = [
|
||||
...groups.value,
|
||||
@@ -1135,6 +1238,7 @@ function resetEditorStateForRoute() {
|
||||
selectedItemId.value = ''
|
||||
recentDragFinishedAt.value = 0
|
||||
savedEditorSnapshot.value = ''
|
||||
closeItemContextMenu()
|
||||
resetTemplateRequestDrafts()
|
||||
}
|
||||
|
||||
@@ -1240,7 +1344,21 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
window.addEventListener('pointerdown', handleGlobalPointerDown)
|
||||
window.addEventListener('contextmenu', handleGlobalContextMenu, true)
|
||||
window.addEventListener('blur', closeItemContextMenu)
|
||||
window.addEventListener('scroll', closeItemContextMenu, true)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('pointerdown', handleGlobalPointerDown)
|
||||
window.removeEventListener('contextmenu', handleGlobalContextMenu, true)
|
||||
window.removeEventListener('blur', closeItemContextMenu)
|
||||
window.removeEventListener('scroll', closeItemContextMenu, true)
|
||||
}
|
||||
if (thumbnailPreviewUrl.value) URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
||||
destroySortables()
|
||||
})
|
||||
@@ -1557,7 +1675,12 @@ onUnmounted(() => {
|
||||
:data-item-id="id"
|
||||
@click.stop="selectItemByClick(id)"
|
||||
>
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<img
|
||||
:src="resolveItemSrc(itemsById[id])"
|
||||
class="thumb"
|
||||
:alt="itemsById[id]?.label || id"
|
||||
draggable="false"
|
||||
/>
|
||||
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
||||
<button
|
||||
v-if="canEdit && !isExporting"
|
||||
@@ -1638,7 +1761,12 @@ onUnmounted(() => {
|
||||
:data-item-id="id"
|
||||
@click.stop="selectItemByClick(id)"
|
||||
>
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<img
|
||||
:src="resolveItemSrc(itemsById[id])"
|
||||
class="thumb"
|
||||
:alt="itemsById[id]?.label || id"
|
||||
draggable="false"
|
||||
/>
|
||||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||
<div v-if="!canEdit" class="poolItem__state">미배치</div>
|
||||
</div>
|
||||
@@ -1646,6 +1774,19 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="itemContextMenu.open && canEdit"
|
||||
class="itemContextMenu"
|
||||
:style="{ left: `${itemContextMenu.x}px`, top: `${itemContextMenu.y}px` }"
|
||||
data-item-context-menu
|
||||
@click.stop
|
||||
@contextmenu.prevent.stop
|
||||
>
|
||||
<button class="itemContextMenu__action" type="button" @click="duplicateItemToPool">
|
||||
아이템 복제
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
@@ -2940,6 +3081,35 @@ onUnmounted(() => {
|
||||
.poolItem--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.itemContextMenu {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
min-width: 150px;
|
||||
padding: 8px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: color-mix(in srgb, var(--theme-main-bg) 96%, transparent);
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.itemContextMenu__action {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
padding: 11px 12px;
|
||||
background: transparent;
|
||||
color: var(--theme-text);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.itemContextMenu__action:hover {
|
||||
background: var(--theme-pill-bg);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
@@ -3028,6 +3198,9 @@ onUnmounted(() => {
|
||||
.previewOnly {
|
||||
padding: 14px;
|
||||
}
|
||||
.viewerSidebar__section {
|
||||
margin-top: 0;
|
||||
}
|
||||
.pool {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ async function toggleFollow() {
|
||||
}
|
||||
|
||||
function openTierList(tierList) {
|
||||
router.push(editorPath(tierList.topicId, tierList.id))
|
||||
router.push(editorPath(tierList.topicSlug || tierList.topicId, tierList.id))
|
||||
}
|
||||
|
||||
watch(userId, loadProfile, { immediate: true })
|
||||
@@ -113,10 +113,10 @@ watch(userId, loadProfile, { immediate: true })
|
||||
<section class="pageWrap">
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Author</div>
|
||||
<h2 class="pageHead__title">{{ profileDisplayName }}</h2>
|
||||
<div class="pageHead__eyebrow">User Profile</div>
|
||||
<h2 class="pageHead__title">사용자 프로필</h2>
|
||||
<div class="pageHead__desc">
|
||||
{{ profile?.accountName ? `@${profile.accountName}` : '작성자 프로필' }}
|
||||
이 사용자가 공개한 티어표를 모아볼 수 있어요.
|
||||
</div>
|
||||
</div>
|
||||
<div class="pageHead__aside profileActions">
|
||||
@@ -133,7 +133,6 @@ watch(userId, loadProfile, { immediate: true })
|
||||
<div v-else class="profileAvatar profileAvatar--fallback">{{ profileFallback }}</div>
|
||||
<div class="profileMeta">
|
||||
<div class="profileMeta__name">{{ profileDisplayName }}</div>
|
||||
<div class="profileMeta__handle">{{ profile?.accountName ? `@${profile.accountName}` : '작성자 프로필' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profileStats">
|
||||
@@ -265,10 +264,6 @@ watch(userId, loadProfile, { immediate: true })
|
||||
color: var(--theme-text);
|
||||
word-break: break-word;
|
||||
}
|
||||
.profileMeta__handle {
|
||||
font-size: 14px;
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
.profileStats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
Reference in New Issue
Block a user