Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6dc64dfc8 | |||
| 40a8dac7b6 | |||
| faa2a01f6c | |||
| 2cdd627658 | |||
| 34ddd1083d | |||
| b5ec579e5d | |||
| 25b893407c | |||
| ba6ad0593a | |||
| df46e43da5 | |||
| 26d7e4c4a8 | |||
| ed68b609bc |
@@ -1 +1 @@
|
|||||||
모든 작업 시 프로젝트 루트의 .ai-rules.md 지침을 엄격히 준수하고, 작업 종료 시마다 docs/ 폴더 내 문서를 업데이트할 것.
|
모든 작업 시 프로젝트 루트의 /ai-rules.md 지침을 엄격히 준수하고, 작업 종료 시마다 docs/ 폴더 내 문서를 업데이트할 것.
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,3 +10,5 @@ backend/uploads/games/
|
|||||||
backend/uploads/custom/
|
backend/uploads/custom/
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.env.production
|
||||||
|
.vscode/
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ function mapTierListRow(row) {
|
|||||||
thumbnailSrc: row.thumbnail_src || '',
|
thumbnailSrc: row.thumbnail_src || '',
|
||||||
description: row.description || '',
|
description: row.description || '',
|
||||||
isPublic: !!row.is_public,
|
isPublic: !!row.is_public,
|
||||||
|
showCharacterNames: !!row.show_character_names,
|
||||||
groups: parseJson(row.groups_json, []),
|
groups: parseJson(row.groups_json, []),
|
||||||
pool: parseJson(row.pool_json, []),
|
pool: parseJson(row.pool_json, []),
|
||||||
createdAt: Number(row.created_at),
|
createdAt: Number(row.created_at),
|
||||||
@@ -226,6 +227,7 @@ async function ensureSchema() {
|
|||||||
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
|
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
is_public TINYINT(1) NOT NULL DEFAULT 0,
|
is_public TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
show_character_names TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
groups_json LONGTEXT NOT NULL,
|
groups_json LONGTEXT NOT NULL,
|
||||||
pool_json LONGTEXT NOT NULL,
|
pool_json LONGTEXT NOT NULL,
|
||||||
created_at BIGINT NOT NULL,
|
created_at BIGINT NOT NULL,
|
||||||
@@ -250,6 +252,18 @@ async function ensureSchema() {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
await query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS favorite_games (
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
game_id VARCHAR(120) NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, game_id),
|
||||||
|
INDEX idx_favorite_games_game_id (game_id),
|
||||||
|
CONSTRAINT fk_favorite_games_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_favorite_games_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`)
|
||||||
|
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS template_requests (
|
CREATE TABLE IF NOT EXISTS template_requests (
|
||||||
id VARCHAR(64) PRIMARY KEY,
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
@@ -277,6 +291,10 @@ async function ensureSchema() {
|
|||||||
if (!tierListThumbnailColumns.length) {
|
if (!tierListThumbnailColumns.length) {
|
||||||
await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
|
await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
|
||||||
}
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`
|
`
|
||||||
@@ -396,13 +414,23 @@ async function listUsers() {
|
|||||||
return rows.map(mapUserRow)
|
return rows.map(mapUserRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function adminUpdateUser({ id, email, nickname, isAdmin }) {
|
async function adminUpdateUser({ id, email, nickname, isAdmin, avatarSrc }) {
|
||||||
await query('UPDATE users SET email = ?, nickname = ?, is_admin = ? WHERE id = ?', [
|
if (typeof avatarSrc === 'string') {
|
||||||
email,
|
await query('UPDATE users SET email = ?, nickname = ?, is_admin = ?, avatar_src = ? WHERE id = ?', [
|
||||||
nickname || '',
|
email,
|
||||||
isAdmin ? 1 : 0,
|
nickname || '',
|
||||||
id,
|
isAdmin ? 1 : 0,
|
||||||
])
|
avatarSrc,
|
||||||
|
id,
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
await query('UPDATE users SET email = ?, nickname = ?, is_admin = ? WHERE id = ?', [
|
||||||
|
email,
|
||||||
|
nickname || '',
|
||||||
|
isAdmin ? 1 : 0,
|
||||||
|
id,
|
||||||
|
])
|
||||||
|
}
|
||||||
return findUserById(id)
|
return findUserById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,7 +443,7 @@ async function adminDeleteUser(id) {
|
|||||||
await query('DELETE FROM users WHERE id = ?', [id])
|
await query('DELETE FROM users WHERE id = ?', [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listGames() {
|
async function listGames(currentUserId = '') {
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`
|
`
|
||||||
SELECT id, name, thumbnail_src, display_rank, created_at
|
SELECT id, name, thumbnail_src, display_rank, created_at
|
||||||
@@ -429,7 +457,15 @@ async function listGames() {
|
|||||||
`,
|
`,
|
||||||
[FREEFORM_GAME_ID]
|
[FREEFORM_GAME_ID]
|
||||||
)
|
)
|
||||||
return rows.map(mapGameRow)
|
const games = rows.map(mapGameRow)
|
||||||
|
if (!currentUserId) return games.map((game) => ({ ...game, isFavorited: false }))
|
||||||
|
|
||||||
|
const favoriteRows = await query('SELECT game_id FROM favorite_games WHERE user_id = ?', [currentUserId])
|
||||||
|
const favoriteSet = new Set(favoriteRows.map((row) => row.game_id))
|
||||||
|
return games.map((game) => ({
|
||||||
|
...game,
|
||||||
|
isFavorited: favoriteSet.has(game.id),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findGameById(id) {
|
async function findGameById(id) {
|
||||||
@@ -589,28 +625,51 @@ async function findCustomItemById(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCustomItemUsageMap() {
|
async function getCustomItemUsageMeta() {
|
||||||
const rows = await query('SELECT groups_json, pool_json FROM tierlists')
|
const rows = await query(
|
||||||
|
`
|
||||||
|
SELECT t.game_id, g.name AS game_name, t.groups_json, t.pool_json
|
||||||
|
FROM tierlists t
|
||||||
|
LEFT JOIN games g ON g.id = t.game_id
|
||||||
|
`
|
||||||
|
)
|
||||||
const usageMap = new Map()
|
const usageMap = new Map()
|
||||||
|
const linkedGamesMap = new Map()
|
||||||
|
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
const groups = parseJson(row.groups_json, [])
|
const groups = parseJson(row.groups_json, [])
|
||||||
const pool = parseJson(row.pool_json, [])
|
const pool = parseJson(row.pool_json, [])
|
||||||
|
const seenItemIds = new Set()
|
||||||
|
|
||||||
groups.forEach((group) => {
|
groups.forEach((group) => {
|
||||||
;(group?.itemIds || []).forEach((itemId) => {
|
;(group?.itemIds || []).forEach((itemId) => {
|
||||||
usageMap.set(itemId, (usageMap.get(itemId) || 0) + 1)
|
usageMap.set(itemId, (usageMap.get(itemId) || 0) + 1)
|
||||||
|
if (itemId) seenItemIds.add(itemId)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
pool.forEach((item) => {
|
pool.forEach((item) => {
|
||||||
if (item?.id) {
|
if (item?.id) {
|
||||||
usageMap.set(item.id, (usageMap.get(item.id) || 0) + 1)
|
usageMap.set(item.id, (usageMap.get(item.id) || 0) + 1)
|
||||||
|
seenItemIds.add(item.id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!row.game_id) return
|
||||||
|
|
||||||
|
seenItemIds.forEach((itemId) => {
|
||||||
|
if (!linkedGamesMap.has(itemId)) linkedGamesMap.set(itemId, new Map())
|
||||||
|
linkedGamesMap.get(itemId).set(row.game_id, {
|
||||||
|
id: row.game_id,
|
||||||
|
name: row.game_name || row.game_id,
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return usageMap
|
return {
|
||||||
|
usageMap,
|
||||||
|
linkedGamesMap: new Map(Array.from(linkedGamesMap.entries()).map(([itemId, gameMap]) => [itemId, Array.from(gameMap.values())])),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnly = false } = {}) {
|
async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnly = false } = {}) {
|
||||||
@@ -639,7 +698,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
|
|||||||
params
|
params
|
||||||
)
|
)
|
||||||
|
|
||||||
const usageMap = await getCustomItemUsageMap()
|
const { usageMap, linkedGamesMap } = await getCustomItemUsageMeta()
|
||||||
const allItems = rows
|
const allItems = rows
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -650,6 +709,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
|
|||||||
ownerName: row.nickname || row.email,
|
ownerName: row.nickname || row.email,
|
||||||
ownerEmail: row.email,
|
ownerEmail: row.email,
|
||||||
usageCount: usageMap.get(row.id) || 0,
|
usageCount: usageMap.get(row.id) || 0,
|
||||||
|
linkedGames: linkedGamesMap.get(row.id) || [],
|
||||||
}))
|
}))
|
||||||
.filter((item) => (orphanOnly ? item.usageCount === 0 : true))
|
.filter((item) => (orphanOnly ? item.usageCount === 0 : true))
|
||||||
|
|
||||||
@@ -689,7 +749,7 @@ async function findUnusedCustomItems({ queryText = '' } = {}) {
|
|||||||
params
|
params
|
||||||
)
|
)
|
||||||
|
|
||||||
const usageMap = await getCustomItemUsageMap()
|
const { usageMap } = await getCustomItemUsageMeta()
|
||||||
return rows
|
return rows
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -833,6 +893,7 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
|
|||||||
t.thumbnail_src,
|
t.thumbnail_src,
|
||||||
t.description,
|
t.description,
|
||||||
t.is_public,
|
t.is_public,
|
||||||
|
t.show_character_names,
|
||||||
t.groups_json,
|
t.groups_json,
|
||||||
t.pool_json,
|
t.pool_json,
|
||||||
t.created_at,
|
t.created_at,
|
||||||
@@ -962,6 +1023,7 @@ async function listAdminTierLists({ queryText = '', page = 1, limit = 50, curren
|
|||||||
t.thumbnail_src,
|
t.thumbnail_src,
|
||||||
t.description,
|
t.description,
|
||||||
t.is_public,
|
t.is_public,
|
||||||
|
t.show_character_names,
|
||||||
t.groups_json,
|
t.groups_json,
|
||||||
t.pool_json,
|
t.pool_json,
|
||||||
t.created_at,
|
t.created_at,
|
||||||
@@ -1017,6 +1079,7 @@ async function findTierListById(id, currentUserId = '') {
|
|||||||
t.thumbnail_src,
|
t.thumbnail_src,
|
||||||
t.description,
|
t.description,
|
||||||
t.is_public,
|
t.is_public,
|
||||||
|
t.show_character_names,
|
||||||
t.groups_json,
|
t.groups_json,
|
||||||
t.pool_json,
|
t.pool_json,
|
||||||
t.created_at,
|
t.created_at,
|
||||||
@@ -1214,7 +1277,7 @@ async function deleteCustomItems(ids) {
|
|||||||
await query(`DELETE FROM custom_items WHERE id IN (${placeholders})`, ids)
|
await query(`DELETE FROM custom_items WHERE id IN (${placeholders})`, ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', description, isPublic, groups, pool }) {
|
async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', description, isPublic, showCharacterNames = false, groups, pool }) {
|
||||||
const existing = id ? await findTierListById(id, authorId) : null
|
const existing = id ? await findTierListById(id, authorId) : null
|
||||||
await syncOwnedCustomItemLabels({ ownerId: authorId, items: pool })
|
await syncOwnedCustomItemLabels({ ownerId: authorId, items: pool })
|
||||||
const nextThumbnailSrc = (thumbnailSrc || '').trim() || getAutoThumbnailSrc(groups, pool)
|
const nextThumbnailSrc = (thumbnailSrc || '').trim() || getAutoThumbnailSrc(groups, pool)
|
||||||
@@ -1223,10 +1286,10 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de
|
|||||||
await query(
|
await query(
|
||||||
`
|
`
|
||||||
UPDATE tierlists
|
UPDATE tierlists
|
||||||
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, groups_json = ?, pool_json = ?, updated_at = ?
|
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, show_character_names = ?, groups_json = ?, pool_json = ?, updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`,
|
`,
|
||||||
[title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id]
|
[title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id]
|
||||||
)
|
)
|
||||||
return findTierListById(existing.id, authorId)
|
return findTierListById(existing.id, authorId)
|
||||||
}
|
}
|
||||||
@@ -1235,11 +1298,11 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de
|
|||||||
await query(
|
await query(
|
||||||
`
|
`
|
||||||
INSERT INTO tierlists (
|
INSERT INTO tierlists (
|
||||||
id, author_id, game_id, title, thumbnail_src, description, is_public, groups_json, pool_json, created_at, updated_at
|
id, author_id, game_id, title, thumbnail_src, description, is_public, show_character_names, groups_json, pool_json, created_at, updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
[id, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
|
[id, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
|
||||||
)
|
)
|
||||||
return findTierListById(id, authorId)
|
return findTierListById(id, authorId)
|
||||||
}
|
}
|
||||||
@@ -1252,6 +1315,14 @@ async function unfavoriteTierList({ userId, tierListId }) {
|
|||||||
await query('DELETE FROM favorite_tierlists WHERE user_id = ? AND tierlist_id = ?', [userId, tierListId])
|
await query('DELETE FROM favorite_tierlists WHERE user_id = ? AND tierlist_id = ?', [userId, tierListId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function favoriteGame({ userId, gameId }) {
|
||||||
|
await query('INSERT IGNORE INTO favorite_games (user_id, game_id, created_at) VALUES (?, ?, ?)', [userId, gameId, now()])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unfavoriteGame({ userId, gameId }) {
|
||||||
|
await query('DELETE FROM favorite_games WHERE user_id = ? AND game_id = ?', [userId, gameId])
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
DB_NAME,
|
DB_NAME,
|
||||||
ensureData,
|
ensureData,
|
||||||
@@ -1286,6 +1357,8 @@ module.exports = {
|
|||||||
findTierListById,
|
findTierListById,
|
||||||
favoriteTierList,
|
favoriteTierList,
|
||||||
unfavoriteTierList,
|
unfavoriteTierList,
|
||||||
|
favoriteGame,
|
||||||
|
unfavoriteGame,
|
||||||
deleteTierList,
|
deleteTierList,
|
||||||
findCustomItemsByIds,
|
findCustomItemsByIds,
|
||||||
deleteCustomItems,
|
deleteCustomItems,
|
||||||
|
|||||||
@@ -61,6 +61,14 @@ const upload = multer({
|
|||||||
limits: { fileSize: 6 * 1024 * 1024 },
|
limits: { fileSize: 6 * 1024 * 1024 },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const avatarUpload = multer({
|
||||||
|
storage: multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'avatars')),
|
||||||
|
filename: (req, file, cb) => cb(null, buildUploadFilename(file)),
|
||||||
|
}),
|
||||||
|
limits: { fileSize: 3 * 1024 * 1024 },
|
||||||
|
})
|
||||||
|
|
||||||
router.post('/games', requireAdmin, async (req, res) => {
|
router.post('/games', requireAdmin, async (req, res) => {
|
||||||
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
|
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
|
||||||
const parsed = schema.safeParse(req.body)
|
const parsed = schema.safeParse(req.body)
|
||||||
@@ -494,6 +502,29 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar'), async (req, res) => {
|
||||||
|
const schema = z.object({
|
||||||
|
removeAvatar: z.union([z.literal('1'), z.undefined()]).optional(),
|
||||||
|
})
|
||||||
|
const parsed = schema.safeParse(req.body)
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
|
const user = await findUserById(req.params.userId)
|
||||||
|
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|
||||||
|
const shouldRemoveAvatar = parsed.data.removeAvatar === '1'
|
||||||
|
const nextAvatarSrc = shouldRemoveAvatar ? '' : req.file ? `/uploads/avatars/${req.file.filename}` : user.avatarSrc || ''
|
||||||
|
const updated = await adminUpdateUser({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
nickname: user.nickname || '',
|
||||||
|
isAdmin: !!user.isAdmin,
|
||||||
|
avatarSrc: nextAvatarSrc,
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({ user: updated })
|
||||||
|
})
|
||||||
|
|
||||||
router.delete('/users/:userId', requireAdmin, async (req, res) => {
|
router.delete('/users/:userId', requireAdmin, async (req, res) => {
|
||||||
if (req.params.userId === req.session.userId) {
|
if (req.params.userId === req.session.userId) {
|
||||||
return res.status(400).json({ error: 'cannot_delete_self' })
|
return res.status(400).json({ error: 'cannot_delete_self' })
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const signupSchema = z.object({
|
|||||||
|
|
||||||
const profileSchema = z.object({
|
const profileSchema = z.object({
|
||||||
nickname: z.string().trim().min(1).max(40),
|
nickname: z.string().trim().min(1).max(40),
|
||||||
|
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/signup', async (req, res) => {
|
router.post('/signup', async (req, res) => {
|
||||||
@@ -108,7 +109,12 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
|
|||||||
const user = await findUserById(req.session.userId)
|
const user = await findUserById(req.session.userId)
|
||||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|
||||||
const nextAvatarSrc = req.file ? `/uploads/avatars/${req.file.filename}` : user.avatarSrc || ''
|
const shouldRemoveAvatar = parsed.data.removeAvatar === '1'
|
||||||
|
const nextAvatarSrc = shouldRemoveAvatar
|
||||||
|
? ''
|
||||||
|
: req.file
|
||||||
|
? `/uploads/avatars/${req.file.filename}`
|
||||||
|
: user.avatarSrc || ''
|
||||||
const updated = await updateUserProfile({
|
const updated = await updateUserProfile({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
nickname: parsed.data.nickname,
|
nickname: parsed.data.nickname,
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
const express = require('express')
|
const express = require('express')
|
||||||
const { listGames, getGameDetail } = require('../db')
|
const { listGames, getGameDetail, findGameById, favoriteGame, unfavoriteGame } = require('../db')
|
||||||
|
const { requireAuth } = require('../middleware/auth')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const games = await listGames()
|
const games = await listGames(req.session?.userId || '')
|
||||||
res.json({ games })
|
res.json({ games })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.post('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||||
|
const game = await findGameById(req.params.gameId)
|
||||||
|
if (!game || game.id === 'freeform') return res.status(404).json({ error: 'not_found' })
|
||||||
|
await favoriteGame({ userId: req.session.userId, gameId: game.id })
|
||||||
|
const games = await listGames(req.session.userId)
|
||||||
|
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: true }
|
||||||
|
res.json({ game: updated })
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||||
|
const game = await findGameById(req.params.gameId)
|
||||||
|
if (!game || game.id === 'freeform') return res.status(404).json({ error: 'not_found' })
|
||||||
|
await unfavoriteGame({ userId: req.session.userId, gameId: game.id })
|
||||||
|
const games = await listGames(req.session.userId)
|
||||||
|
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: false }
|
||||||
|
res.json({ game: updated })
|
||||||
|
})
|
||||||
|
|
||||||
router.get('/:gameId', async (req, res) => {
|
router.get('/:gameId', async (req, res) => {
|
||||||
const detail = await getGameDetail(req.params.gameId)
|
const detail = await getGameDetail(req.params.gameId)
|
||||||
if (!detail) return res.status(404).json({ error: 'not_found' })
|
if (!detail) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ const tierListUpsertSchema = z.object({
|
|||||||
thumbnailSrc: z.string().max(255).optional().default(''),
|
thumbnailSrc: z.string().max(255).optional().default(''),
|
||||||
description: z.string().max(1000).optional().default(''),
|
description: z.string().max(1000).optional().default(''),
|
||||||
isPublic: z.boolean().default(false),
|
isPublic: z.boolean().default(false),
|
||||||
|
showCharacterNames: z.boolean().optional().default(false),
|
||||||
groups: z.array(
|
groups: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
@@ -183,6 +184,8 @@ router.post('/thumbnail', requireAuth, thumbnailUpload.single('thumbnail'), asyn
|
|||||||
router.post('/:id/template-request', requireAuth, async (req, res) => {
|
router.post('/:id/template-request', requireAuth, async (req, res) => {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
type: z.enum(['create', 'update']),
|
type: z.enum(['create', 'update']),
|
||||||
|
requestTitle: z.string().trim().min(1).max(80),
|
||||||
|
requestDescription: z.string().trim().min(1).max(240),
|
||||||
})
|
})
|
||||||
const parsed = schema.safeParse(req.body)
|
const parsed = schema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
@@ -196,9 +199,6 @@ router.post('/:id/template-request', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
if (parsed.data.type === 'create') {
|
if (parsed.data.type === 'create') {
|
||||||
if (tierList.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
|
if (tierList.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
|
||||||
if (!(tierList.title || '').trim() || (tierList.title || '').trim() === FREEFORM_DEFAULT_TITLE) {
|
|
||||||
return res.status(400).json({ error: 'title_required' })
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (tierList.gameId === FREEFORM_GAME_ID) return res.status(400).json({ error: 'game_template_required' })
|
if (tierList.gameId === FREEFORM_GAME_ID) return res.status(400).json({ error: 'game_template_required' })
|
||||||
}
|
}
|
||||||
@@ -211,8 +211,8 @@ router.post('/:id/template-request', requireAuth, async (req, res) => {
|
|||||||
sourceTierListId: tierList.id,
|
sourceTierListId: tierList.id,
|
||||||
sourceGameId: tierList.gameId,
|
sourceGameId: tierList.gameId,
|
||||||
targetGameId: parsed.data.type === 'update' ? tierList.gameId : '',
|
targetGameId: parsed.data.type === 'update' ? tierList.gameId : '',
|
||||||
title: tierList.title,
|
title: parsed.data.requestTitle,
|
||||||
description: tierList.description || '',
|
description: parsed.data.requestDescription,
|
||||||
thumbnailSrc: tierList.thumbnailSrc || '',
|
thumbnailSrc: tierList.thumbnailSrc || '',
|
||||||
items: customItems,
|
items: customItems,
|
||||||
})
|
})
|
||||||
@@ -244,6 +244,7 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
thumbnailSrc: payload.thumbnailSrc || '',
|
thumbnailSrc: payload.thumbnailSrc || '',
|
||||||
description: payload.description || '',
|
description: payload.description || '',
|
||||||
isPublic: !!payload.isPublic,
|
isPublic: !!payload.isPublic,
|
||||||
|
showCharacterNames: !!payload.showCharacterNames,
|
||||||
groups: payload.groups,
|
groups: payload.groups,
|
||||||
pool: normalizedPool,
|
pool: normalizedPool,
|
||||||
})
|
})
|
||||||
@@ -258,6 +259,7 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
thumbnailSrc: payload.thumbnailSrc || '',
|
thumbnailSrc: payload.thumbnailSrc || '',
|
||||||
description: payload.description || '',
|
description: payload.description || '',
|
||||||
isPublic: !!payload.isPublic,
|
isPublic: !!payload.isPublic,
|
||||||
|
showCharacterNames: !!payload.showCharacterNames,
|
||||||
groups: payload.groups,
|
groups: payload.groups,
|
||||||
pool: normalizedPool,
|
pool: normalizedPool,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-03-30 v1.2.25
|
||||||
|
- 홈 게임 카드는 메인 썸네일까지 없애는 것보다, 큰 썸네일은 유지하고 ID 옆의 작은 보조 표시만 제거하는 편이 원래 의도와 맞다고 정리했다.
|
||||||
|
- 좌우 하단 액션 여백은 `margin`으로 푸터 전체를 밀기보다, 푸터 내부 `padding-bottom`으로 확보해야 버튼 자체는 항상 보이고 여백만 남는다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-03-30 v1.2.24
|
||||||
|
- `내 티어표` 헤더의 저장 개수 stat은 정보 가치보다 시각 잡음이 더 크다고 보고, 제목/설명 중심 헤더로 단순화하는 편이 낫다고 정리했다.
|
||||||
|
- 게임 선택 카드는 티어표 카드와 달리 템플릿 선택 진입점이므로, 썸네일까지 반복하기보다 제목과 ID만 간결하게 보여주는 편이 더 적합하다고 판단했다.
|
||||||
|
- 좌우 하단 액션 버튼은 푸터 블록 안에 있더라도 화면 바닥에 너무 붙으면 무거워 보이므로, 추가 하단 여백을 두어 숨을 쉬게 하는 편이 낫다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-03-30 v1.2.23
|
||||||
|
- 홈 화면의 게임 카드도 다른 목록 카드와 같은 밀도를 따라가야 하므로, 메인 라이브러리 역시 데스크톱 기본 4열을 기준으로 두는 편이 더 일관되다고 정리했다.
|
||||||
|
- 게임 허브에서 새 티어표 만들기 버튼이 본문과 우측 패널에 동시에 있으면 역할이 겹치므로, 생성 CTA는 우측 사이드 하나만 남기는 편이 맞다고 판단했다.
|
||||||
|
- 좌우 레일 액션 버튼은 스크롤되는 본문 안보다 독립된 하단 `56px` 푸터 영역에 놓는 편이 위치 인지가 더 안정적이라고 정리했다.
|
||||||
|
|
||||||
## 2026-03-30 v1.2.22
|
## 2026-03-30 v1.2.22
|
||||||
- 왼쪽 레일은 홈/목록/에디터 어디서든 “사라지는 패널”보다 “축소된 내비 레일”로 읽히는 편이 구조적으로 더 일관되므로, 완전 숨김 대신 아이콘 중심 축소 상태를 유지하기로 했다.
|
- 왼쪽 레일은 홈/목록/에디터 어디서든 “사라지는 패널”보다 “축소된 내비 레일”로 읽히는 편이 구조적으로 더 일관되므로, 완전 숨김 대신 아이콘 중심 축소 상태를 유지하기로 했다.
|
||||||
- 좌우 패널 토글은 상태마다 다른 아이콘이 바뀌기보다 방향만 고정하는 편이 덜 혼란스러우므로, 우측은 `dock_to_left`, 좌측은 `dock_to_right` 하나로 통일하기로 정리했다.
|
- 좌우 패널 토글은 상태마다 다른 아이콘이 바뀌기보다 방향만 고정하는 편이 덜 혼란스러우므로, 우측은 `dock_to_left`, 좌측은 `dock_to_right` 하나로 통일하기로 정리했다.
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
## `/`
|
## `/`
|
||||||
- 화면 파일: `frontend/src/views/HomeView.vue`
|
- 화면 파일: `frontend/src/views/HomeView.vue`
|
||||||
- 역할: 상단 상태/CTA가 있는 라이브러리 대시보드, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
|
- 역할: 데스크톱 기본 4열 게임 카드 라이브러리 대시보드, 상단 메인 썸네일과 `게임명 + 작은 ID` 메타를 가진 템플릿 선택 카드, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
|
||||||
- 연동 API: `GET /api/games`
|
- 연동 API: `GET /api/games`
|
||||||
|
|
||||||
## `/games/:gameId`
|
## `/games/:gameId`
|
||||||
- 화면 파일: `frontend/src/views/GameHubView.vue`
|
- 화면 파일: `frontend/src/views/GameHubView.vue`
|
||||||
- 역할: 선택한 게임 정보 표시, 상단 생성 CTA, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성 진입
|
- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
|
||||||
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
|
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
|
||||||
|
|
||||||
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
|
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
## 공통 레이아웃
|
## 공통 레이아웃
|
||||||
- 앱 셸 파일: `frontend/src/App.vue`
|
- 앱 셸 파일: `frontend/src/App.vue`
|
||||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
|
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
|
||||||
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker by zenn`이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다.
|
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker by zenn`이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
|
||||||
|
|
||||||
## 백엔드 진입점
|
## 백엔드 진입점
|
||||||
- 서버 엔트리: `backend/index.js`
|
- 서버 엔트리: `backend/index.js`
|
||||||
|
|||||||
@@ -35,12 +35,15 @@
|
|||||||
- 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다.
|
- 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다.
|
||||||
- 공통 `workspaceBody`는 별도 외곽 카드 테두리 없이 셸 여백만 제공하고, 실제 카드/패널 레이어는 각 화면 내부에서만 구성한다.
|
- 공통 `workspaceBody`는 별도 외곽 카드 테두리 없이 셸 여백만 제공하고, 실제 카드/패널 레이어는 각 화면 내부에서만 구성한다.
|
||||||
- 홈, 게임 허브, 내 티어표, 즐겨찾기, 검색 결과 화면은 같은 카드 문법(상단 16:9 썸네일, `제목+좋아요` 1행, `작성자+최종 수정일` 1행)을 공유하며, 데스크톱 기준 기본 4열 카드 그리드를 사용한다.
|
- 홈, 게임 허브, 내 티어표, 즐겨찾기, 검색 결과 화면은 같은 카드 문법(상단 16:9 썸네일, `제목+좋아요` 1행, `작성자+최종 수정일` 1행)을 공유하며, 데스크톱 기준 기본 4열 카드 그리드를 사용한다.
|
||||||
|
- 단, 홈 게임 선택 카드는 템플릿 선택용이므로 상단 메인 썸네일은 유지하되, 하단 메타는 `게임명 + 작은 ID`만 간결하게 표시한다.
|
||||||
- 목록 계열 화면의 상단 도구 영역은 통계 카드와 액션 버튼을 공통 높이/반경으로 맞춰, 같은 라이브러리 대시보드로 읽히도록 정리한다.
|
- 목록 계열 화면의 상단 도구 영역은 통계 카드와 액션 버튼을 공통 높이/반경으로 맞춰, 같은 라이브러리 대시보드로 읽히도록 정리한다.
|
||||||
- 우측 패널
|
- 우측 패널
|
||||||
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
|
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
|
||||||
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
|
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
|
||||||
- 공통 토글 버튼은 패널이 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 각각 아이콘만 표시하는 방식으로 동작한다.
|
- 공통 토글 버튼은 패널이 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 각각 아이콘만 표시하는 방식으로 동작한다.
|
||||||
- 오른쪽 패널 토글은 열기/닫기 모두 `dock_to_left`, 왼쪽 패널 토글은 `dock_to_right` 아이콘으로 통일한다.
|
- 오른쪽 패널 토글은 열기/닫기 모두 `dock_to_left`, 왼쪽 패널 토글은 `dock_to_right` 아이콘으로 통일한다.
|
||||||
|
- 좌우 레일의 주요 CTA는 스크롤되는 본문과 분리된 하단 `56px` 액션 영역에 배치한다.
|
||||||
|
- 하단 액션은 화면 바닥에 바로 붙지 않도록 푸터 내부에 추가 하단 여백을 둔다.
|
||||||
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
|
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
|
||||||
- 티어표 편집 화면
|
- 티어표 편집 화면
|
||||||
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
|
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
- 공통 56px 셸 헤더는 반영했으므로, 다음 단계는 좌/중앙/우 헤더 안에 실제 아이콘/상태 요소를 시안 순서에 맞게 하나씩 채워 넣는 작업이다.
|
- 공통 56px 셸 헤더는 반영했으므로, 다음 단계는 좌/중앙/우 헤더 안에 실제 아이콘/상태 요소를 시안 순서에 맞게 하나씩 채워 넣는 작업이다.
|
||||||
- 좌측 레일은 최근 즐겨찾기와 전역 검색까지 붙었으므로, 다음 단계는 검색 자동완성이나 즐겨찾기 썸네일 품질 같은 디테일을 더 다듬는 작업이다.
|
- 좌측 레일은 최근 즐겨찾기와 전역 검색까지 붙었으므로, 다음 단계는 검색 자동완성이나 즐겨찾기 썸네일 품질 같은 디테일을 더 다듬는 작업이다.
|
||||||
- 좌측 레일 축소형은 반영했으므로, 다음 단계는 축소 상태에서 관리자/로그인 진입점과 hover 툴팁 같은 보조 UX를 더 다듬는 작업이다.
|
- 좌측 레일 축소형은 반영했으므로, 다음 단계는 축소 상태에서 관리자/로그인 진입점과 hover 툴팁 같은 보조 UX를 더 다듬는 작업이다.
|
||||||
|
- 좌우 하단 액션 영역은 분리했으므로, 다음 단계는 축소된 왼쪽 레일에서도 관리자/로그인 버튼을 아이콘형으로 어떻게 유지할지 검토할 수 있다.
|
||||||
|
- 홈 게임 카드 메타는 간소화했으므로, 이후 필요하면 게임 썸네일은 상세 허브나 우측 패널처럼 더 맥락이 분명한 위치에만 쓰는 방향을 검토할 수 있다.
|
||||||
|
- 좌우 하단 액션은 항상 보이도록 보정했으므로, 다음 단계는 축소된 레일 상태에서 액션 버튼의 아이콘화 여부를 추가 검토할 수 있다.
|
||||||
- 카드 목록은 4열 기준과 메타 줄 구성까지 통일했으므로, 다음 단계는 필터 상태 배지나 hover·selection 강조 같은 상호작용 디테일을 더 다듬는 작업이다.
|
- 카드 목록은 4열 기준과 메타 줄 구성까지 통일했으므로, 다음 단계는 필터 상태 배지나 hover·selection 강조 같은 상호작용 디테일을 더 다듬는 작업이다.
|
||||||
- 검색 결과 화면은 좌측 전역 검색 입력만 쓰도록 정리됐으므로, 다음 단계는 결과 필터/정렬 여부를 검토하는 식으로 확장하면 된다.
|
- 검색 결과 화면은 좌측 전역 검색 입력만 쓰도록 정리됐으므로, 다음 단계는 결과 필터/정렬 여부를 검토하는 식으로 확장하면 된다.
|
||||||
- 공통 3단 셸 구조는 고정했지만, 관리자/에디터 우측 패널 내부에 아직 바디에 남아 있는 제어 요소를 더 옮겨야 한다.
|
- 공통 3단 셸 구조는 고정했지만, 관리자/에디터 우측 패널 내부에 아직 바디에 남아 있는 제어 요소를 더 옮겨야 한다.
|
||||||
|
|||||||
366
docs/update.md
366
docs/update.md
@@ -1,5 +1,181 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.63
|
||||||
|
- 앱 셸과 워크스페이스에 걸려 있던 고정 `100dvh` 높이를 풀어, 본문이 길어질 때 중앙 `main` 영역이 잘리거나 접히는 현상을 보정함.
|
||||||
|
- 좌우 레일은 그대로 화면 기준 높이를 유지하되, 중앙 작업 영역은 내용만큼 자연스럽게 늘어나도록 높이 계산을 다시 정리함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.62
|
||||||
|
- 템플릿 요청 모달의 제목/설명 입력을 Settings 화면과 같은 어두운 입력 문법으로 맞춰 흰 배경/흰 글자처럼 보이던 문제를 정리함.
|
||||||
|
- 앱 셸은 사이드 기본 바탕색을 중심으로 재정리하고, 중앙 바디에 배경과 좌우 보더를 줘 긴 스크롤에서도 사이드가 잘리는 듯한 인상을 줄이도록 조정함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.61
|
||||||
|
- Game Library 왼쪽 검색을 전체 티어표 검색이 아니라 게임 템플릿 검색으로 바꾸고, 홈 화면에서 검색어에 맞는 게임만 필터링하도록 조정함.
|
||||||
|
- 게임 템플릿에 사용자별 즐겨찾기 별 아이콘을 추가하고, 즐겨찾기한 게임이 관리자 고정 순서보다 우선 노출되도록 백엔드와 홈 화면을 함께 확장함.
|
||||||
|
- 앱 셸의 100vh 높이 계산을 100dvh와 고정 행 구조로 정리해, 콘텐츠가 없어도 생기던 불필요한 세로 스크롤을 줄임.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.60
|
||||||
|
- 관리자 티어표 관리 카드에서 사용자가 입력한 설명을 제목 아래에 함께 노출해 요청 의도를 더 빨리 파악할 수 있게 함.
|
||||||
|
- 템플릿 등록/업데이트 요청은 이제 에디터 모달에서 제목과 설명을 별도로 입력받고, 예시 문구와 함께 전송하도록 정리함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.59
|
||||||
|
- 관리자 아이템 상세 모달의 게임 선택을 전용 상태로 분리해 기본 선택값이 비어 있도록 바꾸고, 썸네일 아래에 배치해 정보/액션과 시각적으로 분리함.
|
||||||
|
- 커스텀 아이템이 실제로 사용 중인 게임 목록을 백엔드에서 함께 내려주고, 템플릿 요청 생성 폼에는 게임 ID와 게임 이름 라벨을 추가해 구분을 명확히 함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.58
|
||||||
|
- 관리자 아이템 관리 카드를 썸네일과 제목만 보이는 compact 카드로 줄여, 대량 업로드된 이미지도 훨씬 높은 밀도로 탐색할 수 있게 정리함.
|
||||||
|
- 카드 클릭 시 상세 정보를 모달로 열고 이미지 다운로드, 기본 템플릿 추가, 삭제를 모달 안에서 결정하는 흐름으로 바꿈.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.57
|
||||||
|
- 관리자 오른쪽 사이드에서 Featured, Game Summary, Users 패널을 완전히 제거하고, 티어표 요청 모드에는 모드 전환 탭만 남기도록 정리함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.56
|
||||||
|
- 관리자 아이템 관리 카드 그리드에 최대 폭을 줘서 결과가 1~2개일 때 카드가 과하게 늘어나지 않도록 조정함.
|
||||||
|
- 관리자 오른쪽 사이드에서 Featured, Game Summary, Users 요약 패널과 티어표 요청 새로고침/대기 개수 영역을 제거해 중복 정보를 정리함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.55
|
||||||
|
- 관리자 게임 관리 썸네일 입력을 파일 버튼 대신 클릭/드래그형 드롭존으로 바꿔 에디터 쪽 업로드 경험과 맞춤.
|
||||||
|
- 관리자 아이템 관리 카드를 세로 카드 구조로 재정리해 긴 파일명과 버튼 문구에도 레이아웃이 무너지지 않도록 보정함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.54
|
||||||
|
- 관리자 게임 상세 로딩 전에 호출되던 preview reset helper를 복구해, 게임 선택 시 런타임 오류로 상세 패널이 비어 있던 문제를 보정함.
|
||||||
|
- 선택 실패 시 원인을 더 쉽게 확인할 수 있도록 로딩 실패 안내와 콘솔 에러 로그를 추가함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.53
|
||||||
|
- 관리자 게임 관리에서 새 게임 만들기 카드를 제거하고, 헤더 버튼으로 여는 모달 기반 생성 흐름으로 정리함.
|
||||||
|
- 게임 선택은 명시적인 변경 핸들러로 다시 묶어 선택 즉시 상세 정보를 불러오도록 보강함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.52
|
||||||
|
- 관리자 게임 관리에서 선택 이벤트를 놓치지 않도록 `selectedGameId`와 탭 진입 시점을 감시해 상세 정보를 자동으로 다시 불러오도록 보정함.
|
||||||
|
- 선택 후 잠시 비어 보이던 구간을 줄이기 위해 로딩 상태와 선택된 게임 ID 안내를 추가함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.51
|
||||||
|
- 운영 비밀값이 들어 있는 `.env.production`과 로컬 에디터 설정 `.vscode/`를 `.gitignore`에 추가해 푸시 대상에서 제외함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.50
|
||||||
|
- 관리자 회원 아바타 삭제 버튼 조건을 명확히 하고 hover 표시를 visibility까지 포함해 보정해 다른 사용자 카드에서도 안정적으로 노출되도록 조정함.
|
||||||
|
- 삭제 배지 아이콘을 흰색으로 보정하고 어두운 배경 위에서 더 잘 보이도록 스타일을 다듬음.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.49
|
||||||
|
- 관리자 회원 저장 후 통계 정보가 흔들리던 문제를 줄이기 위해 저장/아바타 변경 뒤 회원 목록을 다시 동기화하도록 보정함.
|
||||||
|
- 회원 아바타 액션을 hover 기반으로 재배치해 평소에는 숨기고, 마우스 오버 시에만 수정 오버레이와 삭제 버튼이 나타나도록 조정함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.48
|
||||||
|
- 관리자 회원 관리 배지를 Settings 화면의 Administrator 스타일로 통일하고, 카드 우측 상단에 걸치는 형태로 재배치함.
|
||||||
|
- 관리자 권한 체크박스를 제거하고 작은 텍스트 액션과 확인 모달을 거쳐 draft 상태만 바꾸는 흐름으로 정리함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.47
|
||||||
|
- 관리자 회원 관리에서 비밀번호 초기화와 삭제를 실제 모달 플로우로 연결하고, 저장 버튼은 회원 정보 변경 시에만 활성화되도록 정리함.
|
||||||
|
- 상단 휴지통 아이콘과 불필요 문구를 제거하고, 관리자도 회원 썸네일을 카드 안에서 바로 수정/삭제할 수 있게 보완함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.46
|
||||||
|
- **회원 액션 플로우 수정**: 회원 카드의 불필요한 안내 문구와 상단 삭제 아이콘을 제거하고, 비밀번호 초기화/회원 삭제를 각각 전용 확인 모달로 재구성
|
||||||
|
- **저장 버튼 활성 조건 정리**: 회원정보 저장은 필드가 실제로 바뀐 경우에만 활성화되고, 비밀번호 초기화와 삭제 아이콘은 즉시 사용할 수 있도록 조정
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.45
|
||||||
|
- **회원 카드 액션 재구성**: 비밀번호 초기화와 회원 삭제를 아이콘 액션으로 축소하고, `회원정보 저장` 버튼은 실제 변경이 있을 때만 활성화되도록 조정
|
||||||
|
- **관리자 아바타 편집 지원**: 관리자도 회원 아바타를 클릭해 변경하거나 삭제할 수 있도록 전용 업로드 API와 카드 UI를 추가
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.44
|
||||||
|
- **관리자 탭 구조 재정리**: `목록 관리`와 `게임 관리`를 분리하고, 게임 생성/선택 흐름을 우측 사이드가 아닌 본문 전용 작업 화면으로 이동
|
||||||
|
- **회원/액션 레이아웃 정리**: 회원 카드의 작성 수/최근 활동을 텍스트형 정보로 단순화하고, 관리 버튼의 줄바꿈이 어색하지 않도록 액션 그리드를 보정
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.43
|
||||||
|
- **이름 표시 옵션 추가**: 티어 에디터 우측 옵션에 `캐릭터 이름 표시` 토글을 추가하고, 보드 안에서는 이미지 하단 오버레이 라벨로 표시되도록 개선
|
||||||
|
- **저장/불러오기 연동**: 이름 표시 옵션이 저장된 티어표와 다운로드 이미지에도 그대로 반영되도록 프런트/백엔드 저장 구조를 확장
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.42
|
||||||
|
- **에디터 보드 폭 기준 정리**: 티어표 보드 영역을 저장 이미지 기준에 맞춰 최대 약 `960px` 폭으로 묶고, 넓은 화면에서는 아이템 풀이 남는 공간을 더 가져가도록 조정
|
||||||
|
- **아이템 풀 카드형 통일**: 넓은 화면에서도 우측 아이템 목록을 카드형 그리드로 바꿔 한 번에 더 많은 아이템을 보고 드래그할 수 있도록 개선
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.41
|
||||||
|
- **에디터 하단 아이템 풀 카드형 전환**: 브라우저 폭이 `980px` 이하로 줄어 아이템 풀이 티어표 아래로 내려오면, 세로 리스트 대신 `이미지 위 / 이름 아래` 카드형 그리드로 전환되도록 조정
|
||||||
|
- **소형 폭 열 수 최적화**: 약 `800px` 전후에서는 6열 그리드가 유지되고, 더 작은 폭에서는 4열/3열로 자연스럽게 줄어들며 긴 이름은 가운데 정렬된 말줄임 형태로 보이도록 정리
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.40
|
||||||
|
- **목록 카드 메타 정리**: `내 티어표`, `즐겨찾기`, `검색 결과`, `게임 목록` 카드의 작성자 썸네일을 원형으로 통일하고, 메타 행 간격과 날짜 크기(`10px`)를 조정했으며 날짜 정렬을 위해 `boardCard__metaRow`를 `align-items: flex-end`로 보정
|
||||||
|
- **게임 허브 CTA 좌측 하단 이동**: 게임 목록 화면의 `새 티어표 만들기` 버튼을 오른쪽 사이드에서 제거하고, 왼쪽 하단 액션 영역으로 옮겨 관리자 메뉴와 같은 버튼 문법으로 정리
|
||||||
|
- **필수 우측 패널 자동 열기**: 티어 메이커/관리자처럼 오른쪽 사이드 사용이 필요한 페이지는 패널이 닫혀 있더라도 진입 시 자동으로 열리게 해, 도구 접근성과 이후 광고 노출 흐름을 함께 보정
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.39
|
||||||
|
- **홈 하단 액션 재배치**: 홈 오른쪽 사이드의 `커스텀 티어표 만들기` CTA를 제거하고, 로그인/관리자 메뉴가 있던 왼쪽 하단 액션 영역으로 옮겨 같은 버튼 문법으로 정리
|
||||||
|
- **우측 중복 액션 축소**: 일반 화면에서 중복되던 `로그인 하러가기` 계열 우측 CTA는 제거하고, 오른쪽 레일은 광고/도구 용도로만 유지하도록 단순화
|
||||||
|
- **회원가입 확인 입력 추가**: 로그인 화면 회원가입 모드에 비밀번호 확인 필드를 추가하고, 버튼 문구를 `로그인 / 가입하기 / 취소` 같은 한글 흐름으로 정리
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.38
|
||||||
|
- **로그인 화면 문법 통일**: 로그인/회원가입 화면을 기존 카드형에서 Settings와 같은 단일 컬럼 계정 설정 스타일로 재구성해 두 화면의 톤을 통일
|
||||||
|
- **일반 우측 레일 광고 슬롯 전환**: 에디터/관리자처럼 실제 도구가 필요한 화면을 제외하면 오른쪽 레일은 중복 액션 버튼 대신 AdSense 수직형 반응형 슬롯을 기본으로 표시하도록 정리
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.37
|
||||||
|
- **대표 썸네일 드래그 업로드 추가**: 우측 대표 썸네일 영역도 드래그앤드롭으로 이미지를 받을 수 있게 하고, 여러 파일을 드롭하면 첫 번째만 사용된다는 안내 토스트를 표시하도록 수정
|
||||||
|
- **삭제/업데이트 요청 액션 경량화**: 우측 하단의 삭제와 템플릿 업데이트 요청을 무거운 정식 버튼 대신 작은 보조 링크형 액션으로 정리해 실제 주 행동과 시각적으로 분리
|
||||||
|
- **확인 모달 보강**: 템플릿 업데이트 요청과 티어표 삭제는 이제 브라우저 기본 얼럿 대신 전용 확인 모달을 통해 안내 후 진행되도록 변경
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.36
|
||||||
|
- **축소 검색 모달 재정의**: 좌측 레일 축소 상태에서는 검색 아이콘 클릭 시 카드형 다이얼로그 대신, 화면 중앙보다 약간 위에 뜨는 단일 검색 바와 은은한 암전 오버레이로 재구성하고 `ESC`/바깥 클릭으로 닫을 수 있게 보정
|
||||||
|
- **드롭 영역 위치 재조정**: 커스텀 이미지 추가 영역을 전체 `editorCanvas` 하단이 아니라 왼쪽 티어표 컬럼 내부의 보드 바로 아래로 옮겨, 오른쪽 아이템 목록 길이와 무관하게 가까운 위치에서 추가할 수 있도록 수정
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.35
|
||||||
|
- **축소 좌측 검색 동작 수정**: 접힌 상태의 검색 아이콘은 이제 즉시 모달을 열고, 일반 상태에서만 폼 제출이 되도록 분기해 실제 팝업이 보이도록 수정
|
||||||
|
- **우측 레일 높이 제한 해제**: 공통 `max-height: calc(100vh - 56px)` 규칙은 왼쪽 레일에만 남기고, 오버레이 상태를 포함한 오른쪽 레일은 별도 높이 제한 없이 내용 전체가 자연스럽게 흐르도록 조정
|
||||||
|
- **커스텀 업로드 영역 하단 이동**: 커스텀 이미지 드래그 영역과 파일 선택 버튼을 아이템 풀 아래가 아니라 티어표 섹션 하단으로 옮겨, 긴 아이템 목록과 충돌하지 않도록 정리
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.34
|
||||||
|
- **축소 좌측 검색 팝업 추가**: 왼쪽 레일이 접힌 상태에서 검색 아이콘을 누르면 즉시 검색 입력이 가능한 모달 팝업이 뜨도록 바꾸고, 셸 톤에 맞는 블러/글래스 스타일로 정리
|
||||||
|
- **에디터 빈 우측 섹션 제거**: 티어 메이커 우측 패널의 네 번째 빈 박스는 `즐겨찾기` 버튼 래퍼였고, 조건이 맞지 않을 때 박스만 남지 않도록 섹션 자체를 조건부 렌더링으로 수정
|
||||||
|
- **우측 레일 스크롤 구조 완화**: 오른쪽 패널은 이제 본문 전체가 자연스럽게 세로 스크롤되고, 로컬 패널 루트의 불필요한 최소 높이를 제거해 내용이 늘어나도 잘려 보이는 느낌을 줄임
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.33
|
||||||
|
- **우측 패널 토글 위치 보정**: 소형 해상도에서도 오른쪽 패널 열기 버튼이 본문 아래로 내려가지 않도록 워크스페이스 헤더 최상단 액션 영역으로 이동
|
||||||
|
- **모바일 좌측 레일 단순화**: 모바일에서는 좌측 레일 접기 버튼을 숨기고, 축소 상태가 남아 있더라도 텍스트와 사용자 메타를 다시 보여주도록 보정해 아이콘만 덩그러니 남는 상황을 제거
|
||||||
|
- **모바일 축소 상태 자동 해제**: 화면 폭이 모바일 범위로 들어오면 좌측 레일 축소 상태를 자동으로 풀어, 작은 화면에서는 항상 읽을 수 있는 메뉴 형태를 유지
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.32
|
||||||
|
- **왼쪽 레일 축소 상태 재정의**: 축소 시 사용자 정보는 아바타만 남기고, 메뉴는 아이콘만 보이도록 숨김 처리해 중앙 정렬이 자연스럽게 되도록 정리
|
||||||
|
- **축소 레일 검색/관리자 처리 보정**: 접힌 상태에서는 검색 입력을 숨기고 아이콘 중심으로 단순화했으며, 아이콘이 없는 하단 관리자 버튼은 축소 모드에서 숨김 유지
|
||||||
|
- **우측 패널 소형 해상도 오버레이 전환**: `1200px` 이하에서는 오른쪽 패널을 고정 컬럼 대신 오버레이 패널로 띄우고, 본문 상단 쪽에 다시 열기 버튼을 배치해 패널을 잃어버리지 않도록 수정
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.31
|
||||||
|
- **사이드 아이콘 에셋 정리**: 좌측 `Favorites` 메뉴도 제공된 `favorite.svg`를 사용하도록 바꿔, 다른 사이드 아이콘 및 패널 토글 SVG와 같은 자산 흐름으로 통일
|
||||||
|
- **프로필 아바타 삭제 UX 개선**: `Settings`에서 텍스트형 `이미지 제거` 버튼을 없애고, 아바타 썸네일 우측 상단의 고정 아이콘 버튼으로 삭제하도록 변경해 레이아웃 흔들림을 제거
|
||||||
|
- **셸 코드 정리**: `App.vue`의 비어 있던 감시 코드를 제거해 현재 사용자 수정 위에 불필요한 잔여 로직이 남지 않도록 정리
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.30
|
||||||
|
- **왼쪽 즐겨찾기 섹션 제거**: 좌측 레일의 `즐겨찾기 보기` 섹션을 삭제하고, 상단 내비의 즐겨찾기 메뉴만 진입점으로 유지
|
||||||
|
- **Settings 화면 리디자인**: 프로필 설정 화면을 카드형 대신 단일 컬럼의 미니멀한 계정 설정 레이아웃으로 재구성
|
||||||
|
- **아바타 클릭 업로드/삭제 UX**: 파일 input 노출을 없애고, 아바타를 클릭해 이미지 업로드와 제거를 처리하는 최근 앱 스타일 인터랙션으로 변경
|
||||||
|
- **백엔드 아바타 제거 지원**: 프로필 저장 API가 아바타 삭제 요청도 함께 처리하도록 확장
|
||||||
|
|
||||||
|
## 2026-03-30 v1.2.29
|
||||||
|
- **왼쪽 즐겨찾기 목록 제거**: 좌측 레일의 최근 즐겨찾기 목록과 관련 데이터 로딩 로직을 제거하고, `즐겨찾기 보기` 링크만 유지하도록 단순화
|
||||||
|
- **불필요한 즐겨찾기 API 호출 제거**: 사이드바 표시만을 위해 수행되던 즐겨찾기 목록 요청을 없애 초기 렌더 비용을 줄임
|
||||||
|
|
||||||
|
## 2026-03-30 v1.2.28
|
||||||
|
- **사이드 스크롤 영역 재분리**: 좌우 레일에서 스크롤되는 콘텐츠 영역과 하단 액션 영역을 분리해, 상단 헤더 높이와 무관하게 버튼이 항상 최초 화면 안에 보이도록 수정
|
||||||
|
- **레일 바디 overflow 구조 수정**: 레일 전체가 아니라 내부 콘텐츠만 스크롤되게 바꿔, 하단 버튼이 다시 스크롤 아래로 밀리는 문제를 해소
|
||||||
|
|
||||||
|
## 2026-03-30 v1.2.27
|
||||||
|
- **사이드 하단 버튼 즉시 노출**: 좌우 하단 액션 버튼을 별도 푸터가 아니라 각 레일의 스크롤 바디 안으로 옮기고, 남는 공간을 밀어내는 spacer 구조로 바꿔 스크롤 없이도 처음부터 하단에 보이도록 수정
|
||||||
|
- **56px 하단 여백 제거**: 기존 고정 푸터 높이와 추가 하단 패딩을 제거해, 하단 액션이 자연스럽게 레일 마지막 줄에 붙도록 정리
|
||||||
|
|
||||||
|
## 2026-03-30 v1.2.26
|
||||||
|
- **페이지 헤더 정렬 통일**: `Games`, `내 리스트`, `즐겨찾기`, `Settings` 화면이 모두 같은 전역 헤더 문법과 높이를 사용하도록 정리해, 페이지 이동 시 상단 블록 위치가 미묘하게 흔들리던 문제를 완화
|
||||||
|
- **헤더 내부 패딩 제거**: 워크스페이스 본문에 이미 좌우 여백이 있는 점을 반영해, 각 페이지 헤더 내부의 작은 추가 패딩을 제거하고 동일한 배치 규칙으로 맞춤
|
||||||
|
- **Settings 헤더 문법 통일**: 프로필 화면도 다른 목록 화면과 동일한 eyebrow/title/description 구조를 갖도록 보강해 전체 화면 톤을 통일
|
||||||
|
|
||||||
|
## 2026-03-30 v1.2.25
|
||||||
|
- **홈 게임 카드 썸네일 복구**: 메인 게임 선택 카드는 상단 메인 썸네일을 다시 표시하고, 하단 ID 라인 옆의 작은 보조 표시만 제거하도록 보정
|
||||||
|
- **사이드 하단 버튼 고정 가시성 보정**: 좌우 하단 액션 버튼이 스크롤을 해야 보이지 않던 문제를 수정하고, 버튼 자체는 항상 보이면서 아래쪽 여백만 확보되도록 조정
|
||||||
|
|
||||||
|
## 2026-03-30 v1.2.24
|
||||||
|
- **내 티어표 상단 stat 제거**: `내 티어표` 화면 헤더 오른쪽의 저장 개수 stat 카드를 제거해 제목/설명만 남도록 단순화
|
||||||
|
- **홈 게임 카드 메타 단순화**: 게임 선택 카드에서 썸네일과 점형 메타를 제거하고, 한글 게임 제목과 아래 작은 ID만 보이는 형태로 정리
|
||||||
|
- **좌우 하단 액션 여백 보정**: 왼쪽 로그인/관리자 버튼과 오른쪽 빠른 액션 버튼은 바닥에 바로 붙지 않도록 하단에 추가 여백을 확보
|
||||||
|
|
||||||
|
## 2026-03-30 v1.2.23
|
||||||
|
- **홈 게임 카드 4열 정리**: 메인 게임 목록 화면도 카드형 레이아웃에서 데스크톱 기준 기본 4열로 보이도록 그리드를 조정
|
||||||
|
- **게임 허브 중복 생성 CTA 제거**: 게임 선택 화면 본문 상단의 `새로운 티어표 만들기` 버튼을 제거하고, 우측 사이드 하단 CTA만 유지하도록 정리
|
||||||
|
- **좌우 하단 액션 영역 분리**: 왼쪽 `관리자 메뉴/로그인`과 오른쪽 빠른 액션 버튼을 각각 독립된 하단 `56px` 영역에 배치해, 본문/스크롤 영역과 분리된 고정 액션 위치로 통일
|
||||||
|
|
||||||
## 2026-03-30 v1.2.22
|
## 2026-03-30 v1.2.22
|
||||||
- **왼쪽 사이드 축소/확대 추가**: 좌측 레일을 완전히 숨기지 않고 축소형 내비로 접었다 펼 수 있게 바꾸고, 접힌 상태에서는 아이콘 중심으로만 보이도록 레이아웃을 정리
|
- **왼쪽 사이드 축소/확대 추가**: 좌측 레일을 완전히 숨기지 않고 축소형 내비로 접었다 펼 수 있게 바꾸고, 접힌 상태에서는 아이콘 중심으로만 보이도록 레이아웃을 정리
|
||||||
- **좌우 패널 토글 아이콘 통일**: 오른쪽 패널 열기/닫기는 모두 `dock_to_left`, 왼쪽 패널 토글은 `dock_to_right` 아이콘만 사용하도록 통일
|
- **좌우 패널 토글 아이콘 통일**: 오른쪽 패널 열기/닫기는 모두 `dock_to_left`, 왼쪽 패널 토글은 `dock_to_right` 아이콘만 사용하도록 통일
|
||||||
@@ -256,6 +432,101 @@
|
|||||||
- **미사용 아이콘 필터 수정**: 관리자 아이템 관리의 `미사용 아이콘 보기` 체크 상태가 실제 API 요청의 `orphanOnly` 파라미터로 전달되도록 수정
|
- **미사용 아이콘 필터 수정**: 관리자 아이템 관리의 `미사용 아이콘 보기` 체크 상태가 실제 API 요청의 `orphanOnly` 파라미터로 전달되도록 수정
|
||||||
- **삭제 활성화 흐름 정상화**: 미사용 아이콘만 조회했을 때 `usageCount = 0` 항목의 개별 삭제 버튼이 의도대로 활성화되도록 정리
|
- **삭제 활성화 흐름 정상화**: 미사용 아이콘만 조회했을 때 `usageCount = 0` 항목의 개별 삭제 버튼이 의도대로 활성화되도록 정리
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.17
|
||||||
|
- **내 티어표 삭제 추가**: `내 티어표` 목록에서 작성자가 자신의 티어표를 직접 삭제할 수 있도록 삭제 버튼과 API를 추가
|
||||||
|
- **미사용 커스텀 이미지 관리 추가**: 관리자 아이템 탭에서 커스텀 이미지의 사용 횟수를 표시하고, 미사용 항목만 따로 필터링해 개별/일괄 삭제할 수 있도록 보강
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.16
|
||||||
|
- **티어표 헤더 마감 정리**: 제목/설명 입력을 각각 한 줄 폭으로 정리하고, 액션 영역과 분리해 헤더 가독성을 개선
|
||||||
|
- **export 정보 보강**: 이미지 저장 시 제목 아래에 설명이 함께 표시되도록 보강
|
||||||
|
- **보드 여백/정렬 정리**: 보드 내부 패딩을 늘리고, 티어 그룹 제목을 중앙 정렬로 조정해 완성본 느낌을 개선
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.15
|
||||||
|
- **셀렉트 화살표 여백 정리**: 전역 `select` 스타일에 커스텀 화살표 위치와 오른쪽 여백을 추가해 텍스트와 화살표가 지나치게 붙지 않도록 조정
|
||||||
|
- **티어표 다운로드 결과 개선**: `TierEditorView`의 이미지 저장을 Blob 다운로드 방식으로 바꾸고, 캡처 대상을 보드 영역만 포함하는 전용 export 뷰로 분리해 우측 아이템 영역과 편집용 버튼/입력 UI가 저장 이미지에 섞이지 않도록 수정
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.14
|
||||||
|
- **커스텀 아이템 카드 반응형 수정**: 관리자 아이템 관리 탭의 커스텀 아이템 카드에서 이미지 폭을 유동값으로 조정하고, 텍스트 영역에 `min-width: 0`과 강제 줄바꿈 기준을 추가해 카드 바깥 overflow를 방지
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.13
|
||||||
|
- **관리자 탭 구조 정리**: 관리자 페이지를 `게임 관리 / 아이템 관리 / 회원 관리` 탭으로 분리하고 기능별 작업 영역을 명확히 분리
|
||||||
|
- **커스텀 아이템 조회 강화**: 사용자 커스텀 아이템 목록에 파일명 검색, `50/200` 단위 페이지네이션, 다운로드 흐름 추가
|
||||||
|
- **회원 비밀번호 초기화 추가**: 관리자 페이지와 API에서 회원 비밀번호를 직접 재설정할 수 있도록 기능 추가
|
||||||
|
- **가변 티어 행 지원**: 티어표 에디터에서 `S~D` 고정 5단이 아니라 티어 행을 직접 추가/삭제할 수 있도록 보강
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.12
|
||||||
|
- **전역 레이아웃 폭 정리**: 앱 메인 영역의 고정 최대 너비를 제거해 배경과 페이지 폭이 잘린 듯 보이지 않도록 조정
|
||||||
|
- **작성 권한 제한**: 비로그인 사용자는 새 티어표 작성 화면으로 직접 진입할 수 없도록 하고, 공개된 티어표는 읽기 전용으로만 보이게 조정
|
||||||
|
- **커스텀 이미지 업로드 개선**: 에디터의 커스텀 이미지 추가 영역에 다중 파일 선택과 드래그 앤 드롭 업로드를 추가
|
||||||
|
- **회원 관리 추가**: 관리자 페이지에서 가입 회원 목록 조회, 이메일/닉네임/권한 수정, 계정 삭제가 가능한 관리 영역과 API를 추가
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.11
|
||||||
|
- **관리자 레이아웃 재구성**: 인라인 스타일을 제거하고, 썸네일 적용과 아이템 추가를 상단 2열 카드로 재배치한 뒤 아이템 목록은 하단 리스트로 분리
|
||||||
|
- **직접 티어표 만들기 추가**: 홈 화면에 게임 카드와 동일한 형태의 `직접 티어표 만들기` 진입점을 추가하고, 내부 전용 `freeform` 게임 레코드로 1회성 빈 티어표 저장 흐름을 지원
|
||||||
|
- **게임 제안 흐름 제거**: 홈 화면의 `새로운 게임 제안` 버튼/모달과 관련 프런트 API를 제거해 현재 운영 흐름에 맞게 단순화
|
||||||
|
- **커스텀 아이템 검토 영역 추가**: 관리자 페이지에서 사용자 업로드 커스텀 아이템을 목록으로 보고 다운로드할 수 있는 검토 영역과 조회 API를 추가
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.10
|
||||||
|
- **관리자 썸네일 액션 정리**: 썸네일 버튼 문구를 `썸네일 적용`으로 바꾸고, 파일 선택 전에는 비활성화되도록 조정
|
||||||
|
- **아이템 추가 폼 정리**: 아이템 이름 입력 너비를 줄이고, 과한 미리보기 안내 문구를 제거해 작업 집중도를 높임
|
||||||
|
- **반응형 미리보기 보정**: 태블릿 이하 화면에서도 아이템 1:1 미리보기가 최대 `192px` 범위 안에서 보이도록 조정
|
||||||
|
- **파일 재선택 버그 수정**: 아이템 추가나 게임 전환 뒤 파일 입력 값을 초기화해 같은 이미지를 다시 선택해도 정상 인식되도록 수정
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.9
|
||||||
|
- **MariaDB 전용 전환 완료**: `backend/src/db.js`에서 lowdb 분기와 `DB_CLIENT` 기반 fallback을 제거하고 MariaDB 전용 저장 계층으로 정리
|
||||||
|
- **레거시 파일 제거**: `backend/data/db.json`, `backend/scripts/migrate-lowdb-to-mariadb.js`, `dev:lowdb/start:lowdb/migrate:lowdb` 스크립트 및 `lowdb` 의존성 제거
|
||||||
|
- **실행 문서 정리**: `README.md`, `docs/local-mariadb.md`, `docs/spec.md`, `docs/todo.md`, `docs/history.md`를 현재 MariaDB 전용 개발/배포 흐름 기준으로 갱신
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.8
|
||||||
|
- **관리자 업로드 UX 개선**: 썸네일과 아이템 추가 시 파일 선택 직후 미리보기 표시
|
||||||
|
- **썸네일 비율 정리**: 관리자 썸네일 미리보기와 대표 썸네일 표시를 16:9, 약 256px 폭 기준으로 조정
|
||||||
|
- **아이템 카드 레이아웃 개선**: 아이템 목록과 추가 미리보기를 1:1 비율 기준으로 재구성하고 더 촘촘한 카드 그리드로 조정
|
||||||
|
- **레거시 파일 역할 정리**: `db.json`과 lowdb 관련 코드는 현재 MariaDB 기본 런타임에는 필수가 아니며, 마이그레이션/예외 fallback 용도임을 문서에 명시
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.7
|
||||||
|
- **AI 작업 규칙 보강**: `ai-rules.md`에 Git 작성자 정보, 한국어 커밋 메시지, 버전/태그 동기화, 민감 정보 확인 규칙 추가
|
||||||
|
- **관리자 화면 재구성**: `/admin`을 좌우 병렬 구조에서 `모드 선택 → 게임 선택/생성 → 선택된 게임 상세 관리` 흐름으로 재구성
|
||||||
|
- **관리자 삭제 기능 추가**: 등록된 게임 자체 삭제 및 등록된 아이템 개별 삭제 기능 추가
|
||||||
|
- **데이터 정합성 보강**: 관리자 아이템 삭제 시 관련 티어표의 `groups/pool` 참조를 함께 정리하도록 백엔드 로직 보강
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.6
|
||||||
|
- **저장소 메타데이터 정리**: Git 작성자 정보를 프로젝트 계정 기준으로 통일하고, 초기 릴리스 커밋 메시지를 한국어 기준으로 재작성
|
||||||
|
- **버전 관리 규칙 보강**: 커밋 메시지 한국어 작성 및 문서 버전과 Git 태그를 함께 맞추는 규칙을 문서에 반영
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.5
|
||||||
|
- **로컬 개발 환경 정렬**: 기본 백엔드 실행 기준을 lowdb가 아닌 로컬 MariaDB로 전환
|
||||||
|
- **개발용 인프라 추가**: 루트 `docker-compose.yml`에 `MariaDB + phpMyAdmin` 추가
|
||||||
|
- **실행 문서 정리**: `README.md`, `docs/local-mariadb.md`, `docs/spec.md`에 로컬 MariaDB 실행 절차 반영
|
||||||
|
- **Fallback 분리**: `backend/package.json`에 `dev:lowdb`, `start:lowdb` 예외 스크립트 추가
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.4
|
||||||
|
- **DB 마이그레이션 준비**: 런타임 저장소를 `MariaDB(MySQL 호환)` 기준으로 재구성하고 `backend/scripts/migrate-lowdb-to-mariadb.js` 마이그레이션 스크립트 추가
|
||||||
|
- **데이터 구조 분리**: 관리자 지정 아이템은 `game_items`, 유저 커스텀 이미지는 `custom_items`로 분리
|
||||||
|
- **프로필 개선**: 작성자 닉네임 저장 지원, 아바타는 파일 선택 시 미리보기만 변경되고 저장 버튼 클릭 시 실제 반영되도록 수정
|
||||||
|
- **공개 티어표 목록 개선**: 공개 티어표 목록에 작성자 닉네임(없으면 이메일) 표시
|
||||||
|
- **관리자 UI 개편**: 게임 선택 전에는 우측 관리 패널을 숨기고, 선택 후에만 썸네일/아이템 관리가 보이도록 단계형 흐름으로 수정
|
||||||
|
- **관리자 레이아웃 수정**: 새 게임 입력 필드와 카드 셀 overflow 문제를 줄이도록 `box-sizing`, 썸네일/아이템 카드 레이아웃 정리
|
||||||
|
- **커스텀 아이템 저장 흐름 수정**: 에디터의 커스텀 이미지는 저장 시 서버 업로드 후 티어표에 반영되도록 변경
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.3
|
||||||
|
- **배포 설정 개선**: 프런트엔드의 API/정적 파일 주소 하드코딩(`http://localhost:5179`)을 `VITE_API_ORIGIN` 기반으로 통합
|
||||||
|
- **백엔드 운영 설정 추가**: `CORS_ORIGINS`, `TRUST_PROXY`, `SESSION_COOKIE_SECURE`, `SESSION_COOKIE_SAME_SITE`, `SESSION_SECRET` 환경변수 기반으로 NAS/리버스 프록시 배포 대응
|
||||||
|
- **업로드 파일명 안정화**: 한글 원본 파일명 기반 저장을 제거하고 ASCII 안전 파일명으로 저장하도록 변경
|
||||||
|
- **티어표 데이터 정규화**: 게임 이미지 경로가 절대 로컬 URL로 저장되지 않도록 저장/조회 시 `/uploads/...` 상대 경로로 정규화
|
||||||
|
- **프로젝트 점검 결과 문서화**: DB 구조, 화면-파일 매핑, 코딩 규칙, 기술 명세, 남은 위험 요소를 `docs/`에 신규 정리
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.2
|
||||||
|
- **로그인 UI 개선**: 로그인 카드 중앙 배치, 중복 타이틀 제거, 입력 overflow 수정, 엔터로 로그인/회원가입 제출
|
||||||
|
- **안내문 조건화**: “첫 회원가입 계정은 admin” 문구는 유저가 0명일 때만 표시(`/api/auth/meta`)
|
||||||
|
- **게임 목록 UI 개선**: 게임 카드에 썸네일 표시, 중복 텍스트 제거, “새로운 게임 제안” 모달 추가
|
||||||
|
- **관리자 기능 추가**: 게임 썸네일 업로드 API(`/api/admin/games/:gameId/thumbnail`) 및 UI 추가
|
||||||
|
- **에디터 레이아웃 개선**: 등급(그룹) 라벨 칼럼 확장으로 텍스트 잘림 방지, 설명 입력 1줄, 정렬을 좌측 기준으로 조정
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.1
|
||||||
|
- **티어표 메타데이터 개선**: 제목 미입력 시 저장 시점에 게임 이름 기반 자동 제목 적용, 설명(선택) 필드 추가
|
||||||
|
- **시간 정보 표시**: 내 티어표/공개 목록에서 저장 시간(createdAt)과 업데이트 시간(updatedAt)을 시:분:초까지 표시
|
||||||
|
- **에디터 UX 수정**: 빈 티어 칸 안내 문구가 첫 드래그 배치를 가리던 문제 수정(오버레이 처리), 제목 상단에 게임 이름 표시
|
||||||
|
|
||||||
## 2026-03-19 v0.1.0
|
## 2026-03-19 v0.1.0
|
||||||
- **초기 스캐폴딩**: `frontend/`에 Vue3(Vite, JavaScript) 프로젝트 생성
|
- **초기 스캐폴딩**: `frontend/`에 Vue3(Vite, JavaScript) 프로젝트 생성
|
||||||
- **라우팅/화면 골격**: 게임 선택(`/`), 게임 허브(`/games/:gameId`), 에디터(`/editor/:gameId/...`), 로그인(`/login`), 내 티어표(`/me`), 관리자(`/admin`) 라우트 추가
|
- **라우팅/화면 골격**: 게임 선택(`/`), 게임 허브(`/games/:gameId`), 에디터(`/editor/:gameId/...`), 로그인(`/login`), 내 티어표(`/me`), 관리자(`/admin`) 라우트 추가
|
||||||
@@ -268,98 +539,3 @@
|
|||||||
- **네비/권한 UX**: 관리자 메뉴는 admin 로그인 시에만 노출, 로그인 대신 아바타 버튼/메뉴 노출
|
- **네비/권한 UX**: 관리자 메뉴는 admin 로그인 시에만 노출, 로그인 대신 아바타 버튼/메뉴 노출
|
||||||
- **프로필**: `/profile` 페이지 추가, 아바타 업로드 API(`/api/auth/avatar`) 및 표시 지원
|
- **프로필**: `/profile` 페이지 추가, 아바타 업로드 API(`/api/auth/avatar`) 및 표시 지원
|
||||||
- **에디터 버그 수정**: 드래그 시 아이템들이 “묶음”으로 같이 움직이던 문제 해결(드롭 영역 DOM 구조/Sortable 옵션 수정), 드롭 영역 overflow/배치 레이아웃 개선
|
- **에디터 버그 수정**: 드래그 시 아이템들이 “묶음”으로 같이 움직이던 문제 해결(드롭 영역 DOM 구조/Sortable 옵션 수정), 드롭 영역 overflow/배치 레이아웃 개선
|
||||||
|
|
||||||
## 2026-03-19 v0.1.1
|
|
||||||
- **티어표 메타데이터 개선**: 제목 미입력 시 저장 시점에 게임 이름 기반 자동 제목 적용, 설명(선택) 필드 추가
|
|
||||||
- **시간 정보 표시**: 내 티어표/공개 목록에서 저장 시간(createdAt)과 업데이트 시간(updatedAt)을 시:분:초까지 표시
|
|
||||||
- **에디터 UX 수정**: 빈 티어 칸 안내 문구가 첫 드래그 배치를 가리던 문제 수정(오버레이 처리), 제목 상단에 게임 이름 표시
|
|
||||||
|
|
||||||
## 2026-03-19 v0.1.2
|
|
||||||
- **로그인 UI 개선**: 로그인 카드 중앙 배치, 중복 타이틀 제거, 입력 overflow 수정, 엔터로 로그인/회원가입 제출
|
|
||||||
- **안내문 조건화**: “첫 회원가입 계정은 admin” 문구는 유저가 0명일 때만 표시(`/api/auth/meta`)
|
|
||||||
- **게임 목록 UI 개선**: 게임 카드에 썸네일 표시, 중복 텍스트 제거, “새로운 게임 제안” 모달 추가
|
|
||||||
- **관리자 기능 추가**: 게임 썸네일 업로드 API(`/api/admin/games/:gameId/thumbnail`) 및 UI 추가
|
|
||||||
- **에디터 레이아웃 개선**: 등급(그룹) 라벨 칼럼 확장으로 텍스트 잘림 방지, 설명 입력 1줄, 정렬을 좌측 기준으로 조정
|
|
||||||
|
|
||||||
## 2026-03-19 v0.1.3
|
|
||||||
- **배포 설정 개선**: 프런트엔드의 API/정적 파일 주소 하드코딩(`http://localhost:5179`)을 `VITE_API_ORIGIN` 기반으로 통합
|
|
||||||
- **백엔드 운영 설정 추가**: `CORS_ORIGINS`, `TRUST_PROXY`, `SESSION_COOKIE_SECURE`, `SESSION_COOKIE_SAME_SITE`, `SESSION_SECRET` 환경변수 기반으로 NAS/리버스 프록시 배포 대응
|
|
||||||
- **업로드 파일명 안정화**: 한글 원본 파일명 기반 저장을 제거하고 ASCII 안전 파일명으로 저장하도록 변경
|
|
||||||
- **티어표 데이터 정규화**: 게임 이미지 경로가 절대 로컬 URL로 저장되지 않도록 저장/조회 시 `/uploads/...` 상대 경로로 정규화
|
|
||||||
- **프로젝트 점검 결과 문서화**: DB 구조, 화면-파일 매핑, 코딩 규칙, 기술 명세, 남은 위험 요소를 `docs/`에 신규 정리
|
|
||||||
|
|
||||||
## 2026-03-19 v0.1.4
|
|
||||||
- **DB 마이그레이션 준비**: 런타임 저장소를 `MariaDB(MySQL 호환)` 기준으로 재구성하고 `backend/scripts/migrate-lowdb-to-mariadb.js` 마이그레이션 스크립트 추가
|
|
||||||
- **데이터 구조 분리**: 관리자 지정 아이템은 `game_items`, 유저 커스텀 이미지는 `custom_items`로 분리
|
|
||||||
- **프로필 개선**: 작성자 닉네임 저장 지원, 아바타는 파일 선택 시 미리보기만 변경되고 저장 버튼 클릭 시 실제 반영되도록 수정
|
|
||||||
- **공개 티어표 목록 개선**: 공개 티어표 목록에 작성자 닉네임(없으면 이메일) 표시
|
|
||||||
- **관리자 UI 개편**: 게임 선택 전에는 우측 관리 패널을 숨기고, 선택 후에만 썸네일/아이템 관리가 보이도록 단계형 흐름으로 수정
|
|
||||||
- **관리자 레이아웃 수정**: 새 게임 입력 필드와 카드 셀 overflow 문제를 줄이도록 `box-sizing`, 썸네일/아이템 카드 레이아웃 정리
|
|
||||||
- **커스텀 아이템 저장 흐름 수정**: 에디터의 커스텀 이미지는 저장 시 서버 업로드 후 티어표에 반영되도록 변경
|
|
||||||
|
|
||||||
## 2026-03-19 v0.1.5
|
|
||||||
- **로컬 개발 환경 정렬**: 기본 백엔드 실행 기준을 lowdb가 아닌 로컬 MariaDB로 전환
|
|
||||||
- **개발용 인프라 추가**: 루트 `docker-compose.yml`에 `MariaDB + phpMyAdmin` 추가
|
|
||||||
- **실행 문서 정리**: `README.md`, `docs/local-mariadb.md`, `docs/spec.md`에 로컬 MariaDB 실행 절차 반영
|
|
||||||
- **Fallback 분리**: `backend/package.json`에 `dev:lowdb`, `start:lowdb` 예외 스크립트 추가
|
|
||||||
|
|
||||||
## 2026-03-19 v0.1.6
|
|
||||||
- **저장소 메타데이터 정리**: Git 작성자 정보를 프로젝트 계정 기준으로 통일하고, 초기 릴리스 커밋 메시지를 한국어 기준으로 재작성
|
|
||||||
- **버전 관리 규칙 보강**: 커밋 메시지 한국어 작성 및 문서 버전과 Git 태그를 함께 맞추는 규칙을 문서에 반영
|
|
||||||
|
|
||||||
## 2026-03-19 v0.1.7
|
|
||||||
- **AI 작업 규칙 보강**: `ai-rules.md`에 Git 작성자 정보, 한국어 커밋 메시지, 버전/태그 동기화, 민감 정보 확인 규칙 추가
|
|
||||||
- **관리자 화면 재구성**: `/admin`을 좌우 병렬 구조에서 `모드 선택 → 게임 선택/생성 → 선택된 게임 상세 관리` 흐름으로 재구성
|
|
||||||
- **관리자 삭제 기능 추가**: 등록된 게임 자체 삭제 및 등록된 아이템 개별 삭제 기능 추가
|
|
||||||
- **데이터 정합성 보강**: 관리자 아이템 삭제 시 관련 티어표의 `groups/pool` 참조를 함께 정리하도록 백엔드 로직 보강
|
|
||||||
|
|
||||||
## 2026-03-19 v0.1.8
|
|
||||||
- **관리자 업로드 UX 개선**: 썸네일과 아이템 추가 시 파일 선택 직후 미리보기 표시
|
|
||||||
- **썸네일 비율 정리**: 관리자 썸네일 미리보기와 대표 썸네일 표시를 16:9, 약 256px 폭 기준으로 조정
|
|
||||||
- **아이템 카드 레이아웃 개선**: 아이템 목록과 추가 미리보기를 1:1 비율 기준으로 재구성하고 더 촘촘한 카드 그리드로 조정
|
|
||||||
- **레거시 파일 역할 정리**: `db.json`과 lowdb 관련 코드는 현재 MariaDB 기본 런타임에는 필수가 아니며, 마이그레이션/예외 fallback 용도임을 문서에 명시
|
|
||||||
|
|
||||||
## 2026-03-19 v0.1.9
|
|
||||||
- **MariaDB 전용 전환 완료**: `backend/src/db.js`에서 lowdb 분기와 `DB_CLIENT` 기반 fallback을 제거하고 MariaDB 전용 저장 계층으로 정리
|
|
||||||
- **레거시 파일 제거**: `backend/data/db.json`, `backend/scripts/migrate-lowdb-to-mariadb.js`, `dev:lowdb/start:lowdb/migrate:lowdb` 스크립트 및 `lowdb` 의존성 제거
|
|
||||||
- **실행 문서 정리**: `README.md`, `docs/local-mariadb.md`, `docs/spec.md`, `docs/todo.md`, `docs/history.md`를 현재 MariaDB 전용 개발/배포 흐름 기준으로 갱신
|
|
||||||
|
|
||||||
## 2026-03-19 v0.1.10
|
|
||||||
- **관리자 썸네일 액션 정리**: 썸네일 버튼 문구를 `썸네일 적용`으로 바꾸고, 파일 선택 전에는 비활성화되도록 조정
|
|
||||||
- **아이템 추가 폼 정리**: 아이템 이름 입력 너비를 줄이고, 과한 미리보기 안내 문구를 제거해 작업 집중도를 높임
|
|
||||||
- **반응형 미리보기 보정**: 태블릿 이하 화면에서도 아이템 1:1 미리보기가 최대 `192px` 범위 안에서 보이도록 조정
|
|
||||||
- **파일 재선택 버그 수정**: 아이템 추가나 게임 전환 뒤 파일 입력 값을 초기화해 같은 이미지를 다시 선택해도 정상 인식되도록 수정
|
|
||||||
|
|
||||||
## 2026-03-19 v0.1.11
|
|
||||||
- **관리자 레이아웃 재구성**: 인라인 스타일을 제거하고, 썸네일 적용과 아이템 추가를 상단 2열 카드로 재배치한 뒤 아이템 목록은 하단 리스트로 분리
|
|
||||||
- **직접 티어표 만들기 추가**: 홈 화면에 게임 카드와 동일한 형태의 `직접 티어표 만들기` 진입점을 추가하고, 내부 전용 `freeform` 게임 레코드로 1회성 빈 티어표 저장 흐름을 지원
|
|
||||||
- **게임 제안 흐름 제거**: 홈 화면의 `새로운 게임 제안` 버튼/모달과 관련 프런트 API를 제거해 현재 운영 흐름에 맞게 단순화
|
|
||||||
- **커스텀 아이템 검토 영역 추가**: 관리자 페이지에서 사용자 업로드 커스텀 아이템을 목록으로 보고 다운로드할 수 있는 검토 영역과 조회 API를 추가
|
|
||||||
|
|
||||||
## 2026-03-19 v0.1.12
|
|
||||||
- **전역 레이아웃 폭 정리**: 앱 메인 영역의 고정 최대 너비를 제거해 배경과 페이지 폭이 잘린 듯 보이지 않도록 조정
|
|
||||||
- **작성 권한 제한**: 비로그인 사용자는 새 티어표 작성 화면으로 직접 진입할 수 없도록 하고, 공개된 티어표는 읽기 전용으로만 보이게 조정
|
|
||||||
- **커스텀 이미지 업로드 개선**: 에디터의 커스텀 이미지 추가 영역에 다중 파일 선택과 드래그 앤 드롭 업로드를 추가
|
|
||||||
- **회원 관리 추가**: 관리자 페이지에서 가입 회원 목록 조회, 이메일/닉네임/권한 수정, 계정 삭제가 가능한 관리 영역과 API를 추가
|
|
||||||
|
|
||||||
## 2026-03-19 v0.1.13
|
|
||||||
- **관리자 탭 구조 정리**: 관리자 페이지를 `게임 관리 / 아이템 관리 / 회원 관리` 탭으로 분리하고 기능별 작업 영역을 명확히 분리
|
|
||||||
- **커스텀 아이템 조회 강화**: 사용자 커스텀 아이템 목록에 파일명 검색, `50/200` 단위 페이지네이션, 다운로드 흐름 추가
|
|
||||||
- **회원 비밀번호 초기화 추가**: 관리자 페이지와 API에서 회원 비밀번호를 직접 재설정할 수 있도록 기능 추가
|
|
||||||
- **가변 티어 행 지원**: 티어표 에디터에서 `S~D` 고정 5단이 아니라 티어 행을 직접 추가/삭제할 수 있도록 보강
|
|
||||||
|
|
||||||
## 2026-03-19 v0.1.14
|
|
||||||
- **커스텀 아이템 카드 반응형 수정**: 관리자 아이템 관리 탭의 커스텀 아이템 카드에서 이미지 폭을 유동값으로 조정하고, 텍스트 영역에 `min-width: 0`과 강제 줄바꿈 기준을 추가해 카드 바깥 overflow를 방지
|
|
||||||
|
|
||||||
## 2026-03-19 v0.1.15
|
|
||||||
- **셀렉트 화살표 여백 정리**: 전역 `select` 스타일에 커스텀 화살표 위치와 오른쪽 여백을 추가해 텍스트와 화살표가 지나치게 붙지 않도록 조정
|
|
||||||
- **티어표 다운로드 결과 개선**: `TierEditorView`의 이미지 저장을 Blob 다운로드 방식으로 바꾸고, 캡처 대상을 보드 영역만 포함하는 전용 export 뷰로 분리해 우측 아이템 영역과 편집용 버튼/입력 UI가 저장 이미지에 섞이지 않도록 수정
|
|
||||||
|
|
||||||
## 2026-03-19 v0.1.16
|
|
||||||
- **티어표 헤더 마감 정리**: 제목/설명 입력을 각각 한 줄 폭으로 정리하고, 액션 영역과 분리해 헤더 가독성을 개선
|
|
||||||
- **export 정보 보강**: 이미지 저장 시 제목 아래에 설명이 함께 표시되도록 보강
|
|
||||||
- **보드 여백/정렬 정리**: 보드 내부 패딩을 늘리고, 티어 그룹 제목을 중앙 정렬로 조정해 완성본 느낌을 개선
|
|
||||||
|
|
||||||
## 2026-03-19 v0.1.17
|
|
||||||
- **내 티어표 삭제 추가**: `내 티어표` 목록에서 작성자가 자신의 티어표를 직접 삭제할 수 있도록 삭제 버튼과 API를 추가
|
|
||||||
- **미사용 커스텀 이미지 관리 추가**: 관리자 아이템 탭에서 커스텀 이미지의 사용 횟수를 표시하고, 미사용 항목만 따로 필터링해 개별/일괄 삭제할 수 있도록 보강
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, provide, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
import { toApiUrl } from './lib/runtime'
|
import { toApiUrl } from './lib/runtime'
|
||||||
import { api } from './lib/api'
|
|
||||||
import { useToast } from './composables/useToast'
|
import { useToast } from './composables/useToast'
|
||||||
import iconDockToLeft from './assets/icons/dock_to_left.svg'
|
import iconDockToLeft from './assets/icons/dock_to_left.svg'
|
||||||
import iconDockToRight from './assets/icons/dock_to_right.svg'
|
import iconDockToRight from './assets/icons/dock_to_right.svg'
|
||||||
import iconGridView from './assets/icons/grid_view.svg'
|
import iconGridView from './assets/icons/grid_view.svg'
|
||||||
|
import iconFavorite from './assets/icons/favorite.svg'
|
||||||
import iconLists from './assets/icons/lists.svg'
|
import iconLists from './assets/icons/lists.svg'
|
||||||
import iconMore from './assets/icons/more.svg'
|
|
||||||
import iconSearch from './assets/icons/search.svg'
|
import iconSearch from './assets/icons/search.svg'
|
||||||
import iconSettings from './assets/icons/settings.svg'
|
import iconSettings from './assets/icons/settings.svg'
|
||||||
|
import RightRailAd from './components/RightRailAd.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -21,13 +21,17 @@ const { toasts, dismissToast } = useToast()
|
|||||||
const leftRailCollapsed = ref(false)
|
const leftRailCollapsed = ref(false)
|
||||||
const rightRailOpen = ref(true)
|
const rightRailOpen = ref(true)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const favoriteShortcuts = ref([])
|
const searchPlaceholder = computed(() => (route.name === 'home' ? '게임 템플릿 검색' : '전체 티어표 검색'))
|
||||||
|
const isCollapsedSearchOpen = ref(false)
|
||||||
|
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
|
||||||
provide('rightRailOpen', rightRailOpen)
|
provide('rightRailOpen', rightRailOpen)
|
||||||
provide('localRightRailTarget', '#local-right-rail-root')
|
provide('localRightRailTarget', '#local-right-rail-root')
|
||||||
|
|
||||||
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
||||||
const isPreviewMode = computed(() => route.query.preview === '1')
|
const isPreviewMode = computed(() => route.query.preview === '1')
|
||||||
const usesLocalRightRail = computed(() => ['editEditor', 'newEditor', 'admin'].includes(String(route.name || '')))
|
const usesLocalRightRail = computed(() => ['editEditor', 'newEditor', 'admin'].includes(String(route.name || '')))
|
||||||
|
const isRightRailOverlay = computed(() => viewportWidth.value <= 1200)
|
||||||
|
const isMobileLayout = computed(() => viewportWidth.value <= 860)
|
||||||
const avatarUrl = computed(() => (auth.user?.avatarSrc ? toApiUrl(auth.user.avatarSrc) : ''))
|
const avatarUrl = computed(() => (auth.user?.avatarSrc ? toApiUrl(auth.user.avatarSrc) : ''))
|
||||||
const accountName = computed(() => {
|
const accountName = computed(() => {
|
||||||
const nickname = (auth.user?.nickname || '').trim()
|
const nickname = (auth.user?.nickname || '').trim()
|
||||||
@@ -39,17 +43,29 @@ const accountName = computed(() => {
|
|||||||
const accountEmail = computed(() => (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.')
|
const accountEmail = computed(() => (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.')
|
||||||
const shellStyle = computed(() => ({
|
const shellStyle = computed(() => ({
|
||||||
'--left-rail-width': leftRailCollapsed.value ? '76px' : '248px',
|
'--left-rail-width': leftRailCollapsed.value ? '76px' : '248px',
|
||||||
'--right-rail-width': rightRailOpen.value ? '320px' : '0px',
|
'--right-rail-width': !isRightRailOverlay.value && rightRailOpen.value ? '320px' : '0px',
|
||||||
}))
|
}))
|
||||||
const leftNavItems = computed(() => {
|
const leftNavItems = computed(() => {
|
||||||
const items = [
|
const items = [
|
||||||
{ key: 'home', label: 'Games', path: '/', iconSrc: iconGridView },
|
{ key: 'home', label: 'Games', path: '/', iconSrc: iconGridView },
|
||||||
{ key: 'me', label: '내 리스트', path: '/me', iconSrc: iconLists, requiresAuth: true },
|
{ key: 'me', label: 'My Lists', path: '/me', iconSrc: iconLists, requiresAuth: true },
|
||||||
{ key: 'favorites', label: '즐겨찾기', path: '/favorites', icon: 'M12 4.75l2.18 4.42 4.88.71-3.53 3.44.83 4.86L12 15.9 7.64 18.18l.83-4.86-3.53-3.44 4.88-.71z', requiresAuth: true },
|
{ key: 'favorites', label: 'Favorites', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
|
||||||
{ key: 'profile', label: 'Settings', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
|
{ key: 'profile', label: 'Settings', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
|
||||||
]
|
]
|
||||||
return items.filter((item) => !item.requiresAuth || auth.user)
|
return items.filter((item) => !item.requiresAuth || auth.user)
|
||||||
})
|
})
|
||||||
|
const showRightRailAction = computed(() => false)
|
||||||
|
const leftBottomPrimaryAction = computed(() => {
|
||||||
|
if (route.name === 'home' && auth.user) {
|
||||||
|
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new' }
|
||||||
|
}
|
||||||
|
if (route.name === 'gameHub') {
|
||||||
|
const target = `/editor/${route.params.gameId}/new`
|
||||||
|
return { label: '새 티어표 만들기', to: auth.user ? target : `/login?redirect=${target}` }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
const routeMeta = computed(() => {
|
const routeMeta = computed(() => {
|
||||||
if (route.name === 'home') {
|
if (route.name === 'home') {
|
||||||
return {
|
return {
|
||||||
@@ -146,31 +162,73 @@ const routeMeta = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function syncViewportWidth() {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
viewportWidth.value = window.innerWidth
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await auth.refresh()
|
await auth.refresh()
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
syncViewportWidth()
|
||||||
|
window.addEventListener('resize', syncViewportWidth)
|
||||||
|
window.addEventListener('keydown', handleGlobalKeydown)
|
||||||
const leftSaved = window.localStorage.getItem('tier-maker:left-rail-collapsed')
|
const leftSaved = window.localStorage.getItem('tier-maker:left-rail-collapsed')
|
||||||
if (leftSaved === '1') leftRailCollapsed.value = true
|
if (leftSaved === '1') leftRailCollapsed.value = true
|
||||||
const saved = window.localStorage.getItem('tier-maker:right-rail-open')
|
const saved = window.localStorage.getItem('tier-maker:right-rail-open')
|
||||||
if (saved === '0') rightRailOpen.value = false
|
if (saved === '0') rightRailOpen.value = false
|
||||||
}
|
}
|
||||||
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
||||||
await loadFavoriteShortcuts()
|
})
|
||||||
|
|
||||||
|
function handleGlobalKeydown(event) {
|
||||||
|
if (event.key === 'Escape' && isCollapsedSearchOpen.value) {
|
||||||
|
closeCollapsedSearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('resize', syncViewportWidth)
|
||||||
|
window.removeEventListener('keydown', handleGlobalKeydown)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.fullPath,
|
() => route.fullPath,
|
||||||
() => {
|
() => {
|
||||||
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
||||||
|
isCollapsedSearchOpen.value = false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
isMobileLayout,
|
||||||
|
(mobile) => {
|
||||||
|
if (mobile) leftRailCollapsed.value = false
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
usesLocalRightRail,
|
||||||
|
(needed) => {
|
||||||
|
if (!needed || rightRailOpen.value) return
|
||||||
|
rightRailOpen.value = true
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem('tier-maker:right-rail-open', '1')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
function isRouteActive(path) {
|
function isRouteActive(path) {
|
||||||
if (path === '/') return route.path === '/'
|
if (path === '/') return route.path === '/'
|
||||||
return route.path.startsWith(path)
|
return route.path.startsWith(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLeftRail() {
|
function toggleLeftRail() {
|
||||||
|
if (isMobileLayout.value) return
|
||||||
leftRailCollapsed.value = !leftRailCollapsed.value
|
leftRailCollapsed.value = !leftRailCollapsed.value
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.localStorage.setItem('tier-maker:left-rail-collapsed', leftRailCollapsed.value ? '1' : '0')
|
window.localStorage.setItem('tier-maker:left-rail-collapsed', leftRailCollapsed.value ? '1' : '0')
|
||||||
@@ -184,42 +242,34 @@ function toggleRightRail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function avatarFallbackOfFavorite(tierList) {
|
function openCollapsedSearch() {
|
||||||
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
if (!leftRailCollapsed.value || isMobileLayout.value) return
|
||||||
|
isCollapsedSearchOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function favoriteThumbnailUrl(tierList) {
|
function closeCollapsedSearch() {
|
||||||
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
|
isCollapsedSearchOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFavoriteShortcuts() {
|
function handleLeftRailSearch() {
|
||||||
if (!auth.user) {
|
if (leftRailCollapsed.value && !isMobileLayout.value) {
|
||||||
favoriteShortcuts.value = []
|
openCollapsedSearch()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
submitGlobalSearch()
|
||||||
const data = await api.listMyFavoriteTierLists({ sort: 'favorited' })
|
|
||||||
favoriteShortcuts.value = (data.tierLists || []).slice(0, 10)
|
|
||||||
} catch (e) {
|
|
||||||
favoriteShortcuts.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openFavoriteShortcut(item) {
|
|
||||||
router.push(`/editor/${item.gameId}/${item.id}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitGlobalSearch() {
|
function submitGlobalSearch() {
|
||||||
const query = (searchQuery.value || '').trim()
|
const query = (searchQuery.value || '').trim()
|
||||||
|
isCollapsedSearchOpen.value = false
|
||||||
|
if (route.name === 'home') {
|
||||||
|
router.push(query ? `/?q=${encodeURIComponent(query)}` : '/')
|
||||||
|
return
|
||||||
|
}
|
||||||
router.push(query ? `/search?q=${encodeURIComponent(query)}` : '/search')
|
router.push(query ? `/search?q=${encodeURIComponent(query)}` : '/search')
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
|
||||||
() => auth.user?.id,
|
|
||||||
async () => {
|
|
||||||
await loadFavoriteShortcuts()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -229,6 +279,7 @@ watch(
|
|||||||
'appShell--preview': isPreviewMode,
|
'appShell--preview': isPreviewMode,
|
||||||
'appShell--leftCollapsed': leftRailCollapsed,
|
'appShell--leftCollapsed': leftRailCollapsed,
|
||||||
'appShell--rightClosed': !rightRailOpen,
|
'appShell--rightClosed': !rightRailOpen,
|
||||||
|
'appShell--rightOverlay': isRightRailOverlay,
|
||||||
}"
|
}"
|
||||||
:style="shellStyle"
|
:style="shellStyle"
|
||||||
>
|
>
|
||||||
@@ -240,12 +291,13 @@ watch(
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<aside class="leftRail">
|
<aside class="leftRail">
|
||||||
<div class="leftRail__top railHeader">
|
<div class="leftRail__top railHeader">
|
||||||
<button class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="왼쪽 패널 토글" @click="toggleLeftRail">
|
<button v-if="!isMobileLayout" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="왼쪽 패널 토글" @click="toggleLeftRail">
|
||||||
<img :src="iconDockToRight" alt="" />
|
<img :src="iconDockToRight" alt="" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="leftRail__body">
|
<div class="leftRail__body">
|
||||||
|
<div class="leftRail__content">
|
||||||
<div v-if="auth.user" class="appUserCard">
|
<div v-if="auth.user" class="appUserCard">
|
||||||
<div class="appUserCard__button">
|
<div class="appUserCard__button">
|
||||||
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" />
|
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" />
|
||||||
@@ -258,10 +310,12 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="searchStub" @submit.prevent="submitGlobalSearch">
|
<form class="searchStub" @submit.prevent="submitGlobalSearch">
|
||||||
<span class="searchStub__icon">
|
<button class="searchStub__iconButton" type="button" :aria-label="leftRailCollapsed ? '검색 열기' : '검색'" @click="handleLeftRailSearch">
|
||||||
<img :src="iconSearch" alt="" />
|
<span class="searchStub__icon">
|
||||||
</span>
|
<img :src="iconSearch" alt="" />
|
||||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : '전체 티어표 검색'" />
|
</span>
|
||||||
|
</button>
|
||||||
|
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : searchPlaceholder" />
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<nav class="leftNav">
|
<nav class="leftNav">
|
||||||
@@ -271,6 +325,8 @@ watch(
|
|||||||
:to="item.path"
|
:to="item.path"
|
||||||
class="leftNav__item"
|
class="leftNav__item"
|
||||||
:class="{ 'leftNav__item--active': isRouteActive(item.path) }"
|
:class="{ 'leftNav__item--active': isRouteActive(item.path) }"
|
||||||
|
:title="leftRailCollapsed ? item.label : ''"
|
||||||
|
:aria-label="leftRailCollapsed ? item.label : undefined"
|
||||||
>
|
>
|
||||||
<span class="leftNav__glyph">
|
<span class="leftNav__glyph">
|
||||||
<img v-if="item.iconSrc" :src="item.iconSrc" alt="" />
|
<img v-if="item.iconSrc" :src="item.iconSrc" alt="" />
|
||||||
@@ -280,32 +336,9 @@ watch(
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="leftRail__section">
|
|
||||||
<div class="leftRail__sectionTitle">Favorites</div>
|
|
||||||
<template v-if="favoriteShortcuts.length">
|
|
||||||
<button
|
|
||||||
v-for="item in favoriteShortcuts"
|
|
||||||
:key="item.id"
|
|
||||||
type="button"
|
|
||||||
class="favoriteShortcut"
|
|
||||||
@click="openFavoriteShortcut(item)"
|
|
||||||
>
|
|
||||||
<img v-if="favoriteThumbnailUrl(item)" :src="favoriteThumbnailUrl(item)" alt="" class="favoriteShortcut__thumb" />
|
|
||||||
<div v-else class="favoriteShortcut__thumb favoriteShortcut__thumb--fallback">{{ avatarFallbackOfFavorite(item) }}</div>
|
|
||||||
<span class="favoriteShortcut__label">{{ item.title }}</span>
|
|
||||||
</button>
|
|
||||||
<RouterLink to="/favorites" class="favoriteMoreLink">
|
|
||||||
<span class="favoriteMoreLink__icon">
|
|
||||||
<img :src="iconMore" alt="" />
|
|
||||||
</span>
|
|
||||||
<span>즐겨찾기 더 보기</span>
|
|
||||||
<span class="favoriteMoreLink__arrow">↗</span>
|
|
||||||
</RouterLink>
|
|
||||||
</template>
|
|
||||||
<div v-else class="favoriteEmpty">아직 즐겨찾기한 티어표가 없어요.</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="leftRail__bottom">
|
<div class="leftRail__bottom">
|
||||||
|
<RouterLink v-if="leftBottomPrimaryAction" :to="leftBottomPrimaryAction.to" class="adminButton">{{ leftBottomPrimaryAction.label }}</RouterLink>
|
||||||
<RouterLink v-if="isAdmin" to="/admin" class="adminButton">관리자 메뉴</RouterLink>
|
<RouterLink v-if="isAdmin" to="/admin" class="adminButton">관리자 메뉴</RouterLink>
|
||||||
<RouterLink v-else-if="!auth.user" to="/login" class="adminButton">로그인</RouterLink>
|
<RouterLink v-else-if="!auth.user" to="/login" class="adminButton">로그인</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
@@ -331,21 +364,39 @@ watch(
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside class="rightRail" :class="{ 'rightRail--closed': !rightRailOpen }" :aria-hidden="!rightRailOpen">
|
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" :aria-label="searchPlaceholder" @click.self="closeCollapsedSearch">
|
||||||
|
<form class="collapsedSearchBar" @submit.prevent="submitGlobalSearch">
|
||||||
|
<span class="collapsedSearchBar__icon">
|
||||||
|
<img :src="iconSearch" alt="" />
|
||||||
|
</span>
|
||||||
|
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="searchPlaceholder" autofocus />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-if="rightRailOpen && isRightRailOverlay" class="rightRailBackdrop" type="button" aria-label="오른쪽 패널 닫기" @click="toggleRightRail"></button>
|
||||||
|
|
||||||
|
<aside class="rightRail" :class="{ 'rightRail--closed': !rightRailOpen, 'rightRail--overlay': isRightRailOverlay }" :aria-hidden="!rightRailOpen">
|
||||||
<div class="rightRail__top railHeader">
|
<div class="rightRail__top railHeader">
|
||||||
<button v-if="rightRailOpen" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="패널 닫기" @click="toggleRightRail">
|
<button v-if="rightRailOpen" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="패널 닫기" @click="toggleRightRail">
|
||||||
<img :src="iconDockToLeft" alt="" />
|
<img :src="iconDockToLeft" alt="" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="rightRail__body">
|
<div class="rightRail__body">
|
||||||
<template v-if="!usesLocalRightRail">
|
<div class="rightRail__content">
|
||||||
<section class="rightRailAction">
|
<div v-if="usesLocalRightRail" id="local-right-rail-root" class="localRightRailRoot"></div>
|
||||||
<button class="rightRailAction__button" type="button" @click="routeMeta.action">
|
<template v-else>
|
||||||
{{ routeMeta.actionLabel }}
|
<RightRailAd />
|
||||||
</button>
|
</template>
|
||||||
</section>
|
</div>
|
||||||
</template>
|
<div class="rightRail__bottom">
|
||||||
<div id="local-right-rail-root" class="localRightRailRoot"></div>
|
<template v-if="showRightRailAction">
|
||||||
|
<section class="rightRailAction">
|
||||||
|
<button class="rightRailAction__button" type="button" @click="routeMeta.action">
|
||||||
|
{{ routeMeta.actionLabel }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
@@ -364,12 +415,10 @@ watch(
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.appShell {
|
.appShell {
|
||||||
min-height: 100vh;
|
min-height: 100dvh;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr) var(--right-rail-width, 320px);
|
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr) var(--right-rail-width, 320px);
|
||||||
background:
|
background: rgba(14, 14, 14, 0.96);
|
||||||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.04), transparent 28%),
|
|
||||||
linear-gradient(180deg, #1a1a1a 0%, #121212 100%);
|
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
transition: grid-template-columns 220ms ease;
|
transition: grid-template-columns 220ms ease;
|
||||||
}
|
}
|
||||||
@@ -380,7 +429,7 @@ watch(
|
|||||||
|
|
||||||
.leftRail,
|
.leftRail,
|
||||||
.rightRail {
|
.rightRail {
|
||||||
min-height: 100vh;
|
min-height: 100dvh;
|
||||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
background: rgba(14, 14, 14, 0.92);
|
background: rgba(14, 14, 14, 0.92);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -420,6 +469,7 @@ watch(
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.leftRail__top,
|
.leftRail__top,
|
||||||
.rightRail__top {
|
.rightRail__top {
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -439,6 +489,34 @@ watch(
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 14px 12px;
|
padding: 14px 12px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leftRail__body {
|
||||||
|
max-height: calc(100dvh - 56px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightRail__body {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leftRail__content,
|
||||||
|
.rightRail__content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightRail__body {
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightRail__content {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghostIcon {
|
.ghostIcon {
|
||||||
@@ -460,8 +538,8 @@ watch(
|
|||||||
.searchStub__icon svg,
|
.searchStub__icon svg,
|
||||||
.leftNav__glyph svg,
|
.leftNav__glyph svg,
|
||||||
.contextLink svg {
|
.contextLink svg {
|
||||||
width: 16px;
|
width: 28px;
|
||||||
height: 16px;
|
height: 28px;
|
||||||
stroke: currentColor;
|
stroke: currentColor;
|
||||||
stroke-width: 1.8;
|
stroke-width: 1.8;
|
||||||
fill: none;
|
fill: none;
|
||||||
@@ -471,10 +549,9 @@ watch(
|
|||||||
|
|
||||||
.ghostIcon img,
|
.ghostIcon img,
|
||||||
.leftNav__glyph img,
|
.leftNav__glyph img,
|
||||||
.searchStub__icon img,
|
.searchStub__icon img {
|
||||||
.favoriteMoreLink__icon img {
|
width: 24px;
|
||||||
width: 16px;
|
height: 24px;
|
||||||
height: 16px;
|
|
||||||
display: block;
|
display: block;
|
||||||
filter: brightness(0) saturate(100%) invert(94%) sepia(6%) saturate(207%) hue-rotate(186deg) brightness(96%) contrast(92%);
|
filter: brightness(0) saturate(100%) invert(94%) sepia(6%) saturate(207%) hue-rotate(186deg) brightness(96%) contrast(92%);
|
||||||
}
|
}
|
||||||
@@ -500,8 +577,6 @@ watch(
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
@@ -576,6 +651,17 @@ watch(
|
|||||||
color: rgba(255, 255, 255, 0.42);
|
color: rgba(255, 255, 255, 0.42);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.searchStub__iconButton {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.searchStub__icon {
|
.searchStub__icon {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
@@ -612,104 +698,17 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.leftNav__glyph {
|
.leftNav__glyph {
|
||||||
width: 28px;
|
/* width: 28px; */
|
||||||
height: 28px;
|
/* height: 28px; */
|
||||||
border-radius: 10px;
|
/* border-radius: 10px; */
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background: rgba(255, 255, 255, 0.06);
|
/* background: rgba(255, 255, 255, 0.06); */
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leftRail__section {
|
.appShell--leftCollapsed .leftRail__top {
|
||||||
margin-top: 22px;
|
justify-content: center;
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
transition: margin 220ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leftRail__sectionTitle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: rgba(255, 255, 255, 0.38);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.favoriteEmpty {
|
|
||||||
font-size: 13px;
|
|
||||||
color: rgba(255, 255, 255, 0.46);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.favoriteShortcut {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 24px minmax(0, 1fr);
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 3px 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: rgba(255, 255, 255, 0.72);
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.favoriteShortcut__thumb {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: 6px;
|
|
||||||
object-fit: cover;
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.favoriteShortcut__thumb--fallback {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.favoriteShortcut__label {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.favoriteMoreLink {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 4px;
|
|
||||||
color: rgba(255, 255, 255, 0.62);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.favoriteMoreLink__icon {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.favoriteMoreLink__icon img {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
display: block;
|
|
||||||
filter: brightness(0) saturate(100%) invert(94%) sepia(6%) saturate(207%) hue-rotate(186deg) brightness(96%) contrast(92%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.favoriteMoreLink__arrow {
|
|
||||||
margin-left: auto;
|
|
||||||
opacity: 0.56;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .leftRail__body {
|
.appShell--leftCollapsed .leftRail__body {
|
||||||
@@ -724,24 +723,27 @@ watch(
|
|||||||
.appShell--leftCollapsed .appUserCard__button,
|
.appShell--leftCollapsed .appUserCard__button,
|
||||||
.appShell--leftCollapsed .appUserCard__guest {
|
.appShell--leftCollapsed .appUserCard__guest {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-left: 8px;
|
padding: 6px 0;
|
||||||
padding-right: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .appUserCard__meta,
|
.appShell--leftCollapsed .appUserCard__meta,
|
||||||
.appShell--leftCollapsed .searchStub__input,
|
|
||||||
.appShell--leftCollapsed .leftNav__label,
|
.appShell--leftCollapsed .leftNav__label,
|
||||||
.appShell--leftCollapsed .leftRail__sectionTitle,
|
.appShell--leftCollapsed .searchStub__input {
|
||||||
.appShell--leftCollapsed .favoriteShortcut__label,
|
|
||||||
.appShell--leftCollapsed .favoriteMoreLink span:not(.favoriteMoreLink__icon),
|
|
||||||
.appShell--leftCollapsed .favoriteEmpty {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.appShell--leftCollapsed .appUserCard__avatar {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .searchStub {
|
.appShell--leftCollapsed .searchStub {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-left: 8px;
|
padding: 10px 0;
|
||||||
padding-right: 8px;
|
}
|
||||||
|
|
||||||
|
.appShell--leftCollapsed .searchStub__iconButton {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .leftNav {
|
.appShell--leftCollapsed .leftNav {
|
||||||
@@ -750,31 +752,23 @@ watch(
|
|||||||
|
|
||||||
.appShell--leftCollapsed .leftNav__item {
|
.appShell--leftCollapsed .leftNav__item {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-left: 8px;
|
padding: 11px 0;
|
||||||
padding-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.appShell--leftCollapsed .leftRail__section {
|
|
||||||
margin-top: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.appShell--leftCollapsed .favoriteShortcut {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
justify-items: center;
|
|
||||||
padding: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.appShell--leftCollapsed .favoriteMoreLink {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .leftRail__bottom {
|
.appShell--leftCollapsed .leftRail__bottom {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.appShell--leftCollapsed .leftRail__content {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.leftRail__bottom {
|
.leftRail__bottom {
|
||||||
margin-top: auto;
|
display: grid;
|
||||||
padding-top: 20px;
|
gap: 10px;
|
||||||
|
justify-content: stretch;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.adminButton {
|
.adminButton {
|
||||||
@@ -794,7 +788,11 @@ watch(
|
|||||||
|
|
||||||
.appMain {
|
.appMain {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
background: rgba(18, 18, 18, 0.98);
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.appMain--preview {
|
.appMain--preview {
|
||||||
@@ -803,8 +801,9 @@ watch(
|
|||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-rows: 56px minmax(0, 1fr);
|
||||||
gap: 0;
|
gap: 0;
|
||||||
min-height: 100vh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace--localRail {
|
.workspace--localRail {
|
||||||
@@ -845,23 +844,23 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workspaceBody {
|
.workspaceBody {
|
||||||
min-height: calc(100vh - 56px);
|
min-height: 0;
|
||||||
padding: 0;
|
padding: 18px 18px 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: rgba(24, 24, 24, 0.92);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
margin: 18px 18px 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspaceBody--localRail {
|
.workspaceBody--localRail {
|
||||||
min-height: calc(100vh - 56px);
|
min-height: 0;
|
||||||
padding: 0;
|
padding: 18px 18px 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: rgba(24, 24, 24, 0.92);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
margin: 18px 18px 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rightRail {
|
.rightRail {
|
||||||
@@ -870,6 +869,13 @@ watch(
|
|||||||
|
|
||||||
.rightRailAction {
|
.rightRailAction {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightRail__bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rightRailAction__button {
|
.rightRailAction__button {
|
||||||
@@ -883,8 +889,13 @@ watch(
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rightRailBackdrop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.localRightRailRoot {
|
.localRightRailRoot {
|
||||||
min-height: calc(100vh - 84px);
|
min-height: auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
@@ -956,27 +967,113 @@ watch(
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1280px) {
|
@media (max-width: 1200px) {
|
||||||
.appShell {
|
.appShell {
|
||||||
grid-template-columns: 220px minmax(0, 1fr);
|
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rightRail {
|
.rightRailBackdrop {
|
||||||
display: none;
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: block;
|
||||||
|
border: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 29;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightRail--overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: min(360px, calc(100vw - 20px));
|
||||||
|
height: 100dvh;
|
||||||
|
z-index: 30;
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(14, 14, 14, 0.96);
|
||||||
|
box-shadow: -18px 0 36px rgba(0, 0, 0, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.appShell--rightClosed .rightRail--overlay {
|
||||||
|
transform: translateX(calc(100% + 24px));
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.appShell {
|
.appShell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leftRail {
|
.leftRail {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
|
height: auto;
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leftRail__top {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leftRail__body {
|
||||||
|
max-height: none;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appMain {
|
||||||
|
min-height: auto;
|
||||||
|
border-left: 0;
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace,
|
||||||
|
.workspaceBody,
|
||||||
|
.workspaceBody--localRail {
|
||||||
|
min-height: 0;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leftRail__content {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appShell--leftCollapsed .leftRail__top {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appShell--leftCollapsed .appUserCard__meta,
|
||||||
|
.appShell--leftCollapsed .leftNav__label,
|
||||||
|
.appShell--leftCollapsed .searchStub__input {
|
||||||
|
display: revert;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appShell--leftCollapsed .appUserCard__button,
|
||||||
|
.appShell--leftCollapsed .appUserCard__guest,
|
||||||
|
.appShell--leftCollapsed .searchStub,
|
||||||
|
.appShell--leftCollapsed .leftNav__item {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appShell--leftCollapsed .appUserCard__button,
|
||||||
|
.appShell--leftCollapsed .appUserCard__guest {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appShell--leftCollapsed .searchStub {
|
||||||
|
padding: 11px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appShell--leftCollapsed .leftNav__item {
|
||||||
|
padding: 11px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appShell--leftCollapsed .searchStub__iconButton {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.workspaceBody {
|
.workspaceBody {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@@ -988,5 +1085,18 @@ watch(
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
margin: 14px 14px 0;
|
margin: 14px 14px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.collapsedSearchModal {
|
||||||
|
padding-top: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsedSearchBar {
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsedSearchBar__input {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
1
frontend/src/assets/icons/delete.svg
Normal file
1
frontend/src/assets/icons/delete.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 318 B |
1
frontend/src/assets/icons/favorite.svg
Normal file
1
frontend/src/assets/icons/favorite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="m480-120-58-52q-101-91-167-157T150-447.5Q111-500 95.5-544T80-634q0-94 63-157t157-63q52 0 99 22t81 62q34-40 81-62t99-22q94 0 157 63t63 157q0 46-15.5 90T810-447.5Q771-395 705-329T538-172l-58 52Zm0-108q96-86 158-147.5t98-107q36-45.5 50-81t14-70.5q0-60-40-100t-100-40q-47 0-87 26.5T518-680h-76q-15-41-55-67.5T300-774q-60 0-100 40t-40 100q0 35 14 70.5t50 81q36 45.5 98 107T480-228Zm0-273Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 507 B |
1
frontend/src/assets/icons/lock_reset.svg
Normal file
1
frontend/src/assets/icons/lock_reset.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480h80q0 66 25 124.5t68.5 102q43.5 43.5 102 69T480-159q134 0 227-93t93-227q0-134-93-227t-227-93q-89 0-161.5 43.5T204-640h116v80H80v-240h80v80q55-73 138-116.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm-80-240q-17 0-28.5-11.5T360-360v-120q0-17 11.5-28.5T400-520v-40q0-33 23.5-56.5T480-640q33 0 56.5 23.5T560-560v40q17 0 28.5 11.5T600-480v120q0 17-11.5 28.5T560-320H400Zm40-200h80v-40q0-17-11.5-28.5T480-600q-17 0-28.5 11.5T440-560v40Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 663 B |
91
frontend/src/components/RightRailAd.vue
Normal file
91
frontend/src/components/RightRailAd.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
className: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const adEl = ref(null)
|
||||||
|
const client = 'ca-pub-4516420168710424'
|
||||||
|
const slot = '1236919061'
|
||||||
|
const panelClass = computed(() => ['rightRailAd', props.className].filter(Boolean).join(' '))
|
||||||
|
|
||||||
|
function ensureAdScript() {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') return Promise.resolve()
|
||||||
|
const existing = document.querySelector(`script[data-ad-client="${client}"]`)
|
||||||
|
if (existing) return Promise.resolve()
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.async = true
|
||||||
|
script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${client}`
|
||||||
|
script.crossOrigin = 'anonymous'
|
||||||
|
script.dataset.adClient = client
|
||||||
|
script.onload = () => resolve()
|
||||||
|
script.onerror = () => reject(new Error('adsense_load_failed'))
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
try {
|
||||||
|
await ensureAdScript()
|
||||||
|
await nextTick()
|
||||||
|
if (!adEl.value) return
|
||||||
|
window.adsbygoogle = window.adsbygoogle || []
|
||||||
|
if (!adEl.value.dataset.adsbygoogleStatus) {
|
||||||
|
window.adsbygoogle.push({})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Keep the slot quiet when ad blockers or network policies block the script.
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section :class="panelClass">
|
||||||
|
<div class="rightRailAd__eyebrow">Sponsored</div>
|
||||||
|
<div class="rightRailAd__frame">
|
||||||
|
<ins
|
||||||
|
ref="adEl"
|
||||||
|
class="adsbygoogle rightRailAd__slot"
|
||||||
|
style="display:block"
|
||||||
|
:data-ad-client="client"
|
||||||
|
:data-ad-slot="slot"
|
||||||
|
data-ad-format="auto"
|
||||||
|
data-full-width-responsive="true"
|
||||||
|
></ins>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rightRailAd {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightRailAd__eyebrow {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255, 255, 255, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightRailAd__frame {
|
||||||
|
min-height: 520px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightRailAd__slot {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 490px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -32,6 +32,8 @@ export const api = {
|
|||||||
|
|
||||||
listGames: () => request('/api/games'),
|
listGames: () => request('/api/games'),
|
||||||
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
|
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
|
||||||
|
favoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'POST' }),
|
||||||
|
unfavoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'DELETE' }),
|
||||||
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
|
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
|
||||||
updateAdminGameItem: (gameId, itemId, payload) =>
|
updateAdminGameItem: (gameId, itemId, payload) =>
|
||||||
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||||
@@ -56,6 +58,24 @@ export const api = {
|
|||||||
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
|
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
|
||||||
updateAdminUserPassword: (userId, payload) =>
|
updateAdminUserPassword: (userId, payload) =>
|
||||||
request(`/api/admin/users/${encodeURIComponent(userId)}/password`, { method: 'PATCH', body: payload }),
|
request(`/api/admin/users/${encodeURIComponent(userId)}/password`, { method: 'PATCH', body: payload }),
|
||||||
|
updateAdminUserAvatar: async (userId, { file, removeAvatar = false } = {}) => {
|
||||||
|
const fd = new FormData()
|
||||||
|
if (file) fd.append('avatar', file)
|
||||||
|
if (removeAvatar) fd.append('removeAvatar', '1')
|
||||||
|
const res = await fetch(toApiUrl(`/api/admin/users/${encodeURIComponent(userId)}/avatar`), {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: fd,
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = new Error('request_failed')
|
||||||
|
err.status = res.status
|
||||||
|
err.data = data
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
},
|
||||||
deleteAdminUser: (userId) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
|
deleteAdminUser: (userId) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
listPublicTierLists: (gameId) =>
|
listPublicTierLists: (gameId) =>
|
||||||
|
|||||||
@@ -72,3 +72,52 @@ p {
|
|||||||
#app {
|
#app {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pageWrap {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageHead {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-height: 96px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageHead__main {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageHead__eyebrow {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255, 255, 255, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageHead__title {
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
color: rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageHead__desc {
|
||||||
|
max-width: 720px;
|
||||||
|
color: rgba(255, 255, 255, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageHead__aside {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -54,14 +54,14 @@ onMounted(loadFavorites)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="wrap">
|
<section class="pageWrap">
|
||||||
<div class="head">
|
<div class="pageHead">
|
||||||
<div>
|
<div class="pageHead__main">
|
||||||
<div class="head__eyebrow">Collection</div>
|
<div class="pageHead__eyebrow">Collection</div>
|
||||||
<h2 class="title">내 즐겨찾기</h2>
|
<h2 class="pageHead__title">내 즐겨찾기</h2>
|
||||||
<div class="desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 수 있어요.</div>
|
<div class="pageHead__desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 수 있어요.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="pageHead__aside toolbar">
|
||||||
<input v-model="query" class="input" placeholder="제목, 게임, 작성자 검색" @keydown.enter.prevent="loadFavorites" />
|
<input v-model="query" class="input" placeholder="제목, 게임, 작성자 검색" @keydown.enter.prevent="loadFavorites" />
|
||||||
<select v-model="sort" class="select" @change="loadFavorites">
|
<select v-model="sort" class="select" @change="loadFavorites">
|
||||||
<option value="favorited">즐겨찾기한 순</option>
|
<option value="favorited">즐겨찾기한 순</option>
|
||||||
@@ -101,34 +101,6 @@ onMounted(loadFavorites)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.wrap {
|
|
||||||
display: grid;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
.head {
|
|
||||||
display: flex;
|
|
||||||
gap: 14px;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-end;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
padding: 6px 2px 8px;
|
|
||||||
}
|
|
||||||
.head__eyebrow {
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: rgba(255, 255, 255, 0.42);
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
font-size: 32px;
|
|
||||||
color: rgba(255, 255, 255, 0.96);
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
}
|
|
||||||
.desc {
|
|
||||||
margin-top: 6px;
|
|
||||||
color: rgba(255, 255, 255, 0.58);
|
|
||||||
}
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -210,15 +182,19 @@ onMounted(loadFavorites)
|
|||||||
.boardCard__head {
|
.boardCard__head {
|
||||||
padding: 16px 18px 18px;
|
padding: 16px 18px 18px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.boardCard__titleRow,
|
.boardCard__titleRow,
|
||||||
.boardCard__metaRow {
|
.boardCard__metaRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boardCard__metaRow {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
.boardCard__title {
|
.boardCard__title {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -244,7 +220,7 @@ onMounted(loadFavorites)
|
|||||||
.boardCard__avatar {
|
.boardCard__avatar {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
border-radius: 6px;
|
border-radius: 9999px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -262,6 +238,10 @@ onMounted(loadFavorites)
|
|||||||
color: rgba(255, 255, 255, 0.64);
|
color: rgba(255, 255, 255, 0.64);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boardCard__date {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
@media (max-width: 1400px) {
|
@media (max-width: 1400px) {
|
||||||
.list {
|
.list {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -81,9 +81,6 @@ function submitSearch() {
|
|||||||
<h2 class="dashboardHero__title">{{ gameName || gameId }}</h2>
|
<h2 class="dashboardHero__title">{{ gameName || gameId }}</h2>
|
||||||
<p class="dashboardHero__desc">이 게임의 공개 티어표를 탐색하고, 바로 새 보드를 만들어 같은 흐름으로 이어갈 수 있어요.</p>
|
<p class="dashboardHero__desc">이 게임의 공개 티어표를 탐색하고, 바로 새 보드를 만들어 같은 흐름으로 이어갈 수 있어요.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboardHero__right">
|
|
||||||
<button class="primary" @click="createNew">{{ auth.user ? '새로운 티어표 만들기' : '로그인 후 새 티어표 만들기' }}</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
@@ -141,12 +138,6 @@ function submitSearch() {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.dashboardHero__right {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.dashboardHero__eyebrow {
|
.dashboardHero__eyebrow {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.42);
|
color: rgba(255, 255, 255, 0.42);
|
||||||
@@ -164,22 +155,6 @@ function submitSearch() {
|
|||||||
color: rgba(255, 255, 255, 0.58);
|
color: rgba(255, 255, 255, 0.58);
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
}
|
}
|
||||||
.primary {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 14px;
|
|
||||||
border: 1px solid rgba(77, 127, 233, 0.96);
|
|
||||||
background: rgba(77, 127, 233, 0.88);
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 800;
|
|
||||||
transition:
|
|
||||||
transform 0.16s ease,
|
|
||||||
background 0.16s ease,
|
|
||||||
border-color 0.16s ease;
|
|
||||||
}
|
|
||||||
.primary:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
.panel {
|
.panel {
|
||||||
/* border: 1px solid rgba(255, 255, 255, 0.08); */
|
/* border: 1px solid rgba(255, 255, 255, 0.08); */
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -303,19 +278,23 @@ function submitSearch() {
|
|||||||
.boardCard__head {
|
.boardCard__head {
|
||||||
padding: 16px 18px 18px;
|
padding: 16px 18px 18px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.boardCard__titleRow,
|
.boardCard__titleRow,
|
||||||
.boardCard__metaRow {
|
.boardCard__metaRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boardCard__metaRow {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
.boardCard__author {
|
.boardCard__author {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 8px;
|
gap: 7px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
opacity: 0.86;
|
opacity: 0.86;
|
||||||
@@ -329,7 +308,7 @@ function submitSearch() {
|
|||||||
.boardCard__avatar {
|
.boardCard__avatar {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
border-radius: 6px;
|
border-radius: 9999px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
@@ -348,6 +327,10 @@ function submitSearch() {
|
|||||||
color: rgba(255, 255, 255, 0.64);
|
color: rgba(255, 255, 255, 0.64);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boardCard__date {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
@media (max-width: 1400px) {
|
@media (max-width: 1400px) {
|
||||||
.list {
|
.list {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -359,12 +342,6 @@ function submitSearch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.dashboardHero__right {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.primary {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.list {
|
.list {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,109 +1,117 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { toApiUrl } from '../lib/runtime'
|
import { toApiUrl } from '../lib/runtime'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const games = computed(() => items.value.filter((item) => item.id !== 'freeform'))
|
const loadingFavoriteId = ref('')
|
||||||
|
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
|
||||||
|
const games = computed(() => {
|
||||||
|
const filtered = items.value
|
||||||
|
.filter((item) => item.id !== 'freeform')
|
||||||
|
.filter((item) => {
|
||||||
|
if (!query.value) return true
|
||||||
|
const haystack = `${item.name || ''} ${item.id || ''}`.toLowerCase()
|
||||||
|
return haystack.includes(query.value)
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
return filtered.slice().sort((a, b) => {
|
||||||
|
if (!!a.isFavorited !== !!b.isFavorited) return a.isFavorited ? -1 : 1
|
||||||
|
const rankA = a.displayRank == null ? Number.MAX_SAFE_INTEGER : a.displayRank
|
||||||
|
const rankB = b.displayRank == null ? Number.MAX_SAFE_INTEGER : b.displayRank
|
||||||
|
if (rankA !== rankB) return rankA - rankB
|
||||||
|
return (a.name || '').localeCompare(b.name || '', 'ko')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadGames() {
|
||||||
try {
|
try {
|
||||||
const data = await api.listGames()
|
const data = await api.listGames()
|
||||||
items.value = data.games || []
|
items.value = data.games || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
|
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
onMounted(loadGames)
|
||||||
|
watch(() => auth.user?.id, loadGames)
|
||||||
|
|
||||||
function goGame(gameId) {
|
function goGame(gameId) {
|
||||||
router.push(`/games/${gameId}`)
|
router.push(`/games/${gameId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function goFreeform() {
|
async function toggleFavorite(game, event) {
|
||||||
|
event?.stopPropagation()
|
||||||
if (!auth.user) {
|
if (!auth.user) {
|
||||||
router.push('/login?redirect=/editor/freeform/new')
|
router.push(`/login?redirect=${encodeURIComponent(route.fullPath || '/')}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
router.push('/editor/freeform/new')
|
if (!game?.id || loadingFavoriteId.value === game.id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadingFavoriteId.value = game.id
|
||||||
|
const res = game.isFavorited ? await api.unfavoriteGame(game.id) : await api.favoriteGame(game.id)
|
||||||
|
items.value = items.value.map((entry) => (entry.id === game.id ? { ...entry, ...res.game } : entry))
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '즐겨찾기 변경에 실패했어요.'
|
||||||
|
} finally {
|
||||||
|
loadingFavoriteId.value = ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function thumbUrl(g) {
|
function thumbUrl(g) {
|
||||||
if (!g.thumbnailSrc) return ''
|
return g.thumbnailSrc ? toApiUrl(g.thumbnailSrc) : ''
|
||||||
return toApiUrl(g.thumbnailSrc)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="dashboardHero">
|
<section class="pageHead">
|
||||||
<div class="dashboardHero__copy">
|
<div class="pageHead__main">
|
||||||
<div class="dashboardHero__eyebrow">Workspace</div>
|
<div class="pageHead__eyebrow">Workspace</div>
|
||||||
<h1 class="dashboardHero__title">Game Library</h1>
|
<h1 class="pageHead__title">Game Library</h1>
|
||||||
<p class="dashboardHero__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
<p class="pageHead__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
||||||
|
<p v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 게임 템플릿만 보고 있어요.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
<section class="libraryGrid">
|
<section v-if="games.length" class="libraryGrid">
|
||||||
<button v-for="g in games" :key="g.id" class="libraryCard" @click="goGame(g.id)">
|
<article v-for="g in games" :key="g.id" class="libraryCard">
|
||||||
<div class="libraryCard__thumbWrap">
|
<button
|
||||||
|
class="libraryCard__favorite"
|
||||||
|
type="button"
|
||||||
|
:class="{ 'libraryCard__favorite--active': g.isFavorited }"
|
||||||
|
:disabled="loadingFavoriteId === g.id"
|
||||||
|
@click.stop="toggleFavorite(g, $event)"
|
||||||
|
>
|
||||||
|
{{ g.isFavorited ? '★' : '☆' }}
|
||||||
|
</button>
|
||||||
|
<button class="libraryCard__main" type="button" @click="goGame(g.id)">
|
||||||
|
<div class="libraryCard__thumbWrap">
|
||||||
<img v-if="thumbUrl(g)" class="libraryCard__thumb" :src="thumbUrl(g)" :alt="g.name" />
|
<img v-if="thumbUrl(g)" class="libraryCard__thumb" :src="thumbUrl(g)" :alt="g.name" />
|
||||||
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="libraryCard__body">
|
<div class="libraryCard__body">
|
||||||
<div class="libraryCard__title">{{ g.name }}</div>
|
<div class="libraryCard__title">{{ g.name }}</div>
|
||||||
<div class="libraryCard__meta">
|
<div class="libraryCard__meta">{{ g.id }}</div>
|
||||||
<span class="libraryCard__metaDot"></span>
|
|
||||||
<span>{{ g.id }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</button>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 게임 템플릿이 없어요.' : '표시할 게임 템플릿이 없어요.' }}</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dashboardHero {
|
|
||||||
display: flex;
|
|
||||||
gap: 18px;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 2px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
padding: 6px 2px 18px;
|
|
||||||
}
|
|
||||||
.dashboardHero__copy {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
max-width: 720px;
|
|
||||||
}
|
|
||||||
.dashboardHero__eyebrow {
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: rgba(255, 255, 255, 0.42);
|
|
||||||
}
|
|
||||||
.dashboardHero__title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 34px;
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
color: rgba(255, 255, 255, 0.96);
|
|
||||||
}
|
|
||||||
.dashboardHero__desc {
|
|
||||||
margin: 0;
|
|
||||||
color: rgba(255, 255, 255, 0.58);
|
|
||||||
line-height: 1.5;
|
|
||||||
max-width: 720px;
|
|
||||||
}
|
|
||||||
.libraryGrid {
|
.libraryGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
.error {
|
.error {
|
||||||
@@ -114,7 +122,12 @@ function thumbUrl(g) {
|
|||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
}
|
}
|
||||||
|
.pageHead__searchState {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.62);
|
||||||
|
}
|
||||||
.libraryCard {
|
.libraryCard {
|
||||||
|
position: relative;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
@@ -125,14 +138,40 @@ function thumbUrl(g) {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
transition:
|
transition: transform 0.16s ease, background 0.16s ease;
|
||||||
transform 0.16s ease,
|
|
||||||
background 0.16s ease;
|
|
||||||
}
|
}
|
||||||
.libraryCard:hover {
|
.libraryCard:hover {
|
||||||
background: rgba(70, 70, 70, 0.96);
|
background: rgba(70, 70, 70, 0.96);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
.libraryCard__main {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.libraryCard__favorite {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
background: rgba(15, 15, 15, 0.72);
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.libraryCard__favorite--active {
|
||||||
|
color: #ffd86b;
|
||||||
|
}
|
||||||
.libraryCard__thumbWrap {
|
.libraryCard__thumbWrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
@@ -149,32 +188,32 @@ function thumbUrl(g) {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
.libraryCard__thumbFallback {
|
.libraryCard__thumbFallback {
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: rgba(255, 255, 255, 0.4);
|
||||||
}
|
}
|
||||||
.libraryCard__body {
|
.libraryCard__body {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
.libraryCard__title {
|
.libraryCard__title {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
font-size: 16px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
.libraryCard__meta {
|
.libraryCard__meta {
|
||||||
display: inline-flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.libraryCard__metaDot {
|
.libraryEmpty {
|
||||||
width: 10px;
|
padding: 20px 0;
|
||||||
height: 10px;
|
color: rgba(255, 255, 255, 0.62);
|
||||||
border-radius: 3px;
|
}
|
||||||
background: rgba(255, 255, 255, 0.9);
|
@media (max-width: 1400px) {
|
||||||
|
.libraryGrid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.libraryGrid {
|
.libraryGrid {
|
||||||
@@ -187,17 +226,6 @@ function thumbUrl(g) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.dashboardHero {
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
.dashboardToolbar {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.dashboardToolbar__ghost,
|
|
||||||
.dashboardToolbar__stat,
|
|
||||||
.customTierBtn {
|
|
||||||
flex: 1 1 100%;
|
|
||||||
}
|
|
||||||
.libraryGrid {
|
.libraryGrid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
@@ -12,6 +12,7 @@ const toast = useToast()
|
|||||||
|
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
|
const passwordConfirm = ref('')
|
||||||
const mode = ref('login')
|
const mode = ref('login')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const hasUsers = ref(true)
|
const hasUsers = ref(true)
|
||||||
@@ -22,6 +23,14 @@ watch(error, (message) => {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const title = computed(() => (mode.value === 'signup' ? '회원가입' : '로그인'))
|
||||||
|
const description = computed(() =>
|
||||||
|
mode.value === 'signup'
|
||||||
|
? '티어표를 저장하고 즐겨찾기, 개인 설정을 관리할 수 있도록 계정을 만들어요.'
|
||||||
|
: '저장한 티어표와 즐겨찾기, 프로필 설정을 이어서 관리할 수 있어요.'
|
||||||
|
)
|
||||||
|
const submitLabel = computed(() => (mode.value === 'signup' ? '가입하기' : '로그인'))
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const meta = await api.authMeta()
|
const meta = await api.authMeta()
|
||||||
@@ -33,6 +42,10 @@ onMounted(async () => {
|
|||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
if (mode.value === 'signup' && password.value !== passwordConfirm.value) {
|
||||||
|
error.value = '비밀번호 확인이 일치하지 않아요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (mode.value === 'signup') await auth.signup(email.value, password.value)
|
if (mode.value === 'signup') await auth.signup(email.value, password.value)
|
||||||
else await auth.login(email.value, password.value)
|
else await auth.login(email.value, password.value)
|
||||||
@@ -44,104 +57,184 @@ async function submit() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="wrap">
|
<section class="pageWrap">
|
||||||
<form class="card" @submit.prevent="submit">
|
<header class="pageHead">
|
||||||
<div class="tabs">
|
<div class="pageHead__main">
|
||||||
<button type="button" class="tab" :class="{ 'tab--active': mode === 'login' }" @click="mode = 'login'">
|
<div class="pageHead__eyebrow">Account</div>
|
||||||
|
<h2 class="pageHead__title">{{ title }}</h2>
|
||||||
|
<div class="pageHead__desc">{{ description }}</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="authScreen">
|
||||||
|
<div class="authTabs" role="tablist" aria-label="로그인 또는 회원가입">
|
||||||
|
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'login' }" @click="mode = 'login'">
|
||||||
로그인
|
로그인
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="tab" :class="{ 'tab--active': mode === 'signup' }" @click="mode = 'signup'">
|
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'signup' }" @click="mode = 'signup'">
|
||||||
회원가입
|
회원가입
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<label class="label">이메일</label>
|
|
||||||
<input v-model="email" class="input" placeholder="you@example.com" autocomplete="email" />
|
|
||||||
|
|
||||||
<label class="label">비밀번호</label>
|
<form class="authFields" @submit.prevent="submit">
|
||||||
<input
|
<label class="field">
|
||||||
v-model="password"
|
<span class="field__label">이메일</span>
|
||||||
class="input"
|
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" />
|
||||||
type="password"
|
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다.</span>
|
||||||
placeholder="********"
|
</label>
|
||||||
autocomplete="current-password"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button class="btn" type="submit">{{ mode === 'signup' ? '회원가입' : '로그인' }}</button>
|
<label class="field">
|
||||||
|
<span class="field__label">비밀번호</span>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
class="field__input"
|
||||||
|
type="password"
|
||||||
|
placeholder="********"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
<span class="field__hint">8자 이상으로 설정하면 더 안전하게 사용할 수 있어요.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div v-if="!hasUsers" class="hint">첫 회원가입 계정은 자동으로 admin 권한이 부여됩니다(개발용).</div>
|
<label v-if="mode === 'signup'" class="field">
|
||||||
</form>
|
<span class="field__label">비밀번호 확인</span>
|
||||||
|
<input
|
||||||
|
v-model="passwordConfirm"
|
||||||
|
class="field__input"
|
||||||
|
type="password"
|
||||||
|
placeholder="********"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<span class="field__hint">같은 비밀번호를 한 번 더 입력해주세요.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div v-if="!hasUsers" class="roleBadge">첫 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
|
||||||
|
|
||||||
|
<div class="authActions">
|
||||||
|
<button class="secondaryAction" type="button" @click="router.push('/')">취소</button>
|
||||||
|
<button class="primaryAction" type="submit">{{ submitLabel }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.wrap {
|
.authScreen {
|
||||||
min-height: calc(100vh - 74px);
|
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
gap: 28px;
|
||||||
padding: 14px 2px;
|
max-width: 620px;
|
||||||
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
.card {
|
|
||||||
max-width: 420px;
|
.authTabs {
|
||||||
width: min(420px, 92vw);
|
display: inline-flex;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 14px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.tabs {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 10px;
|
width: fit-content;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
}
|
}
|
||||||
.tab {
|
|
||||||
padding: 10px 12px;
|
.authTabs__button {
|
||||||
border-radius: 12px;
|
min-width: 112px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
padding: 10px 16px;
|
||||||
background: rgba(0, 0, 0, 0.12);
|
border: 0;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
border-radius: 999px;
|
||||||
font-weight: 800;
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.62);
|
||||||
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.tab--active {
|
|
||||||
background: rgba(96, 165, 250, 0.18);
|
.authTabs__button--active {
|
||||||
border-color: rgba(255, 255, 255, 0.16);
|
background: rgba(76, 133, 245, 0.22);
|
||||||
|
color: rgba(255, 255, 255, 0.96);
|
||||||
}
|
}
|
||||||
.label {
|
|
||||||
display: block;
|
.authFields {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__label {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
opacity: 0.78;
|
color: rgba(255, 255, 255, 0.62);
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
}
|
||||||
.input {
|
|
||||||
|
.field__input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 12px;
|
padding: 14px 0;
|
||||||
border-radius: 12px;
|
border: 0;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(0, 0, 0, 0.18);
|
background: transparent;
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.94);
|
||||||
outline: none;
|
outline: none;
|
||||||
box-sizing: border-box;
|
font-size: 18px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
.btn {
|
|
||||||
margin-top: 12px;
|
.field__input:focus {
|
||||||
width: 100%;
|
border-bottom-color: rgba(96, 165, 250, 0.9);
|
||||||
padding: 10px 12px;
|
}
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
.field__hint {
|
||||||
background: rgba(96, 165, 250, 0.2);
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleBadge {
|
||||||
|
width: fit-content;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(96, 165, 250, 0.28);
|
||||||
|
background: rgba(96, 165, 250, 0.1);
|
||||||
|
color: rgba(191, 219, 254, 0.92);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryAction,
|
||||||
|
.secondaryAction {
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 800;
|
|
||||||
}
|
}
|
||||||
.btn:hover {
|
|
||||||
background: rgba(96, 165, 250, 0.26);
|
.primaryAction {
|
||||||
|
border: 1px solid rgba(76, 133, 245, 0.96);
|
||||||
|
background: rgba(76, 133, 245, 0.92);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
.hint {
|
|
||||||
margin-top: 10px;
|
.secondaryAction {
|
||||||
opacity: 0.72;
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
font-size: 13px;
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: rgba(255, 255, 255, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.authTabs {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.authTabs__button {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -69,16 +69,12 @@ async function removeList(t) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="wrap">
|
<section class="pageWrap">
|
||||||
<header class="head">
|
<header class="pageHead">
|
||||||
<div>
|
<div class="pageHead__main">
|
||||||
<div class="head__eyebrow">Library</div>
|
<div class="pageHead__eyebrow">Library</div>
|
||||||
<h2 class="title">내 티어표</h2>
|
<h2 class="pageHead__title">내 티어표</h2>
|
||||||
<div class="desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</div>
|
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</div>
|
||||||
</div>
|
|
||||||
<div class="head__stat">
|
|
||||||
<span class="head__statLabel">Saved Lists</span>
|
|
||||||
<strong class="head__statValue">{{ myLists.length }}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -113,52 +109,6 @@ async function removeList(t) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.wrap {
|
|
||||||
padding: 4px 2px;
|
|
||||||
}
|
|
||||||
.head {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
padding: 6px 2px 8px;
|
|
||||||
}
|
|
||||||
.head__eyebrow {
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: rgba(255, 255, 255, 0.42);
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
margin: 4px 0 6px;
|
|
||||||
font-size: 32px;
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
color: rgba(255, 255, 255, 0.96);
|
|
||||||
}
|
|
||||||
.desc {
|
|
||||||
color: rgba(255, 255, 255, 0.58);
|
|
||||||
}
|
|
||||||
.head__stat {
|
|
||||||
display: grid;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 112px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 14px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
background: rgba(255, 255, 255, 0.045);
|
|
||||||
}
|
|
||||||
.head__statLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
color: rgba(255, 255, 255, 0.48);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
.head__statValue {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
.card {
|
.card {
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -244,19 +194,23 @@ async function removeList(t) {
|
|||||||
.boardCard__head {
|
.boardCard__head {
|
||||||
padding: 16px 18px 18px;
|
padding: 16px 18px 18px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.boardCard__titleRow,
|
.boardCard__titleRow,
|
||||||
.boardCard__metaRow {
|
.boardCard__metaRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boardCard__metaRow {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
.boardCard__author {
|
.boardCard__author {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 8px;
|
gap: 7px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
opacity: 0.84;
|
opacity: 0.84;
|
||||||
@@ -270,7 +224,7 @@ async function removeList(t) {
|
|||||||
.boardCard__avatar {
|
.boardCard__avatar {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
border-radius: 6px;
|
border-radius: 9999px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
@@ -289,6 +243,9 @@ async function removeList(t) {
|
|||||||
color: rgba(255, 255, 255, 0.64);
|
color: rgba(255, 255, 255, 0.64);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.boardCard__date {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
.link--danger {
|
.link--danger {
|
||||||
background: rgba(239, 68, 68, 0.14);
|
background: rgba(239, 68, 68, 0.14);
|
||||||
border-color: rgba(239, 68, 68, 0.28);
|
border-color: rgba(239, 68, 68, 0.28);
|
||||||
@@ -305,9 +262,6 @@ async function removeList(t) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.head__stat {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.list {
|
.list {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { toApiUrl } from '../lib/runtime'
|
import { toApiUrl } from '../lib/runtime'
|
||||||
@@ -14,6 +14,8 @@ const saving = ref(false)
|
|||||||
const nickname = ref('')
|
const nickname = ref('')
|
||||||
const previewUrl = ref('')
|
const previewUrl = ref('')
|
||||||
const avatarFile = ref(null)
|
const avatarFile = ref(null)
|
||||||
|
const removeAvatar = ref(false)
|
||||||
|
const fileInput = ref(null)
|
||||||
|
|
||||||
watch(error, (message) => {
|
watch(error, (message) => {
|
||||||
if (!message) return
|
if (!message) return
|
||||||
@@ -23,26 +25,53 @@ watch(error, (message) => {
|
|||||||
|
|
||||||
const avatarUrl = computed(() => {
|
const avatarUrl = computed(() => {
|
||||||
if (previewUrl.value) return previewUrl.value
|
if (previewUrl.value) return previewUrl.value
|
||||||
|
if (removeAvatar.value) return ''
|
||||||
if (!auth.user?.avatarSrc) return ''
|
if (!auth.user?.avatarSrc) return ''
|
||||||
return toApiUrl(auth.user.avatarSrc)
|
return toApiUrl(auth.user.avatarSrc)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const displayInitial = computed(() => {
|
||||||
|
const email = auth.user?.email || 'U'
|
||||||
|
return email[0].toUpperCase()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await auth.refresh()
|
await auth.refresh()
|
||||||
if (!auth.user) router.push('/login')
|
if (!auth.user) router.push('/login')
|
||||||
nickname.value = auth.user?.nickname || ''
|
nickname.value = auth.user?.nickname || ''
|
||||||
|
removeAvatar.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function openAvatarPicker() {
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
function onAvatarChange(e) {
|
function onAvatarChange(e) {
|
||||||
const file = e.target.files && e.target.files[0]
|
const file = e.target.files && e.target.files[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
removeAvatar.value = false
|
||||||
avatarFile.value = file
|
avatarFile.value = file
|
||||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||||
previewUrl.value = URL.createObjectURL(file)
|
previewUrl.value = URL.createObjectURL(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearAvatar() {
|
||||||
|
error.value = ''
|
||||||
|
avatarFile.value = null
|
||||||
|
removeAvatar.value = true
|
||||||
|
if (previewUrl.value) {
|
||||||
|
URL.revokeObjectURL(previewUrl.value)
|
||||||
|
previewUrl.value = ''
|
||||||
|
}
|
||||||
|
if (fileInput.value) fileInput.value.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
async function saveProfile() {
|
async function saveProfile() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
saving.value = true
|
saving.value = true
|
||||||
@@ -50,6 +79,8 @@ async function saveProfile() {
|
|||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('nickname', nickname.value)
|
fd.append('nickname', nickname.value)
|
||||||
if (avatarFile.value) fd.append('avatar', avatarFile.value)
|
if (avatarFile.value) fd.append('avatar', avatarFile.value)
|
||||||
|
if (removeAvatar.value) fd.append('removeAvatar', '1')
|
||||||
|
|
||||||
const res = await fetch(toApiUrl('/api/auth/profile'), {
|
const res = await fetch(toApiUrl('/api/auth/profile'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@@ -59,10 +90,12 @@ async function saveProfile() {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
auth.user = data.user
|
auth.user = data.user
|
||||||
avatarFile.value = null
|
avatarFile.value = null
|
||||||
|
removeAvatar.value = false
|
||||||
if (previewUrl.value) {
|
if (previewUrl.value) {
|
||||||
URL.revokeObjectURL(previewUrl.value)
|
URL.revokeObjectURL(previewUrl.value)
|
||||||
previewUrl.value = ''
|
previewUrl.value = ''
|
||||||
}
|
}
|
||||||
|
if (fileInput.value) fileInput.value.value = ''
|
||||||
toast.success('프로필을 저장했어요.')
|
toast.success('프로필을 저장했어요.')
|
||||||
} catch (e2) {
|
} catch (e2) {
|
||||||
error.value = '프로필 저장에 실패했어요.'
|
error.value = '프로필 저장에 실패했어요.'
|
||||||
@@ -79,145 +112,280 @@ async function logout() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="wrap">
|
<section class="pageWrap">
|
||||||
<h2 class="title">프로필</h2>
|
<header class="pageHead">
|
||||||
|
<div class="pageHead__main">
|
||||||
<div class="card" v-if="auth.user">
|
<div class="pageHead__eyebrow">Account</div>
|
||||||
<div class="row">
|
<h2 class="pageHead__title">Settings</h2>
|
||||||
<div class="avatar">
|
<div class="pageHead__desc">계정 정보를 간결하게 정리하고, 프로필 이미지를 클릭해서 바로 변경할 수 있어요.</div>
|
||||||
<img v-if="avatarUrl" :src="avatarUrl" class="avatarImg" alt="avatar" />
|
|
||||||
<div v-else class="avatarFallback">{{ (auth.user.email || 'U')[0].toUpperCase() }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="meta">
|
|
||||||
<div class="email">{{ auth.user.email }}</div>
|
|
||||||
<input v-model="nickname" class="nicknameInput" placeholder="작성자 닉네임" />
|
|
||||||
<div class="badge" v-if="auth.user.isAdmin">admin</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="upload">
|
<section v-if="auth.user" class="settingsScreen">
|
||||||
<label class="label">아바타 업로드</label>
|
<div class="settingsIdentity">
|
||||||
<input class="file" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
|
<div class="avatarButtonWrap">
|
||||||
<div class="hint">파일 선택은 미리보기만 바뀌고, 실제 반영은 저장 버튼을 눌렀을 때 진행됩니다.</div>
|
<button class="avatarButton" type="button" @click="openAvatarPicker">
|
||||||
<div class="actions">
|
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" />
|
||||||
<button class="saveBtn" :disabled="saving" @click="saveProfile">
|
<div v-else class="avatarButton__fallback">{{ displayInitial }}</div>
|
||||||
{{ saving ? '저장중...' : '프로필 저장' }}
|
<div class="avatarButton__overlay">
|
||||||
|
<span>{{ avatarUrl ? '이미지 변경' : '이미지 추가' }}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="avatarUrl || previewUrl"
|
||||||
|
class="avatarButton__remove"
|
||||||
|
type="button"
|
||||||
|
aria-label="프로필 이미지 삭제"
|
||||||
|
@click="clearAvatar"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="logoutBtn" type="button" @click="logout">로그아웃</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="identityMeta">
|
||||||
|
<div class="identityMeta__eyebrow">Profile Photo</div>
|
||||||
|
<div class="identityMeta__title">프로필 이미지</div>
|
||||||
|
<div class="identityMeta__desc">아바타를 클릭해서 이미지를 추가하거나 교체할 수 있습니다.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input ref="fileInput" class="hiddenInput" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div class="settingsFields">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">닉네임</span>
|
||||||
|
<input v-model="nickname" class="field__input" placeholder="작성자 닉네임" />
|
||||||
|
<span class="field__hint">티어표 작성자 이름으로 표시됩니다.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">이메일</span>
|
||||||
|
<input :value="auth.user.email" class="field__input field__input--readonly" readonly />
|
||||||
|
<span class="field__hint">현재 로그인에 사용 중인 계정입니다.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div v-if="auth.user.isAdmin" class="roleBadge">Administrator</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settingsActions">
|
||||||
|
<button class="primaryAction" :disabled="saving" @click="saveProfile">{{ saving ? '저장 중...' : '변경사항 저장' }}</button>
|
||||||
|
<button class="secondaryAction" type="button" @click="logout">로그아웃</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.wrap {
|
.settingsScreen {
|
||||||
padding: 10px 2px;
|
display: grid;
|
||||||
|
gap: 32px;
|
||||||
|
max-width: 620px;
|
||||||
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
.title {
|
|
||||||
margin: 0 0 10px;
|
.settingsIdentity {
|
||||||
font-size: 26px;
|
display: grid;
|
||||||
letter-spacing: -0.02em;
|
grid-template-columns: 120px minmax(0, 1fr);
|
||||||
}
|
gap: 24px;
|
||||||
.card {
|
|
||||||
max-width: 520px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.avatar {
|
|
||||||
width: 68px;
|
.avatarButtonWrap {
|
||||||
height: 68px;
|
position: relative;
|
||||||
border-radius: 999px;
|
width: 120px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
height: 120px;
|
||||||
background: rgba(0, 0, 0, 0.16);
|
}
|
||||||
|
|
||||||
|
.avatarButton {
|
||||||
|
position: relative;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
.avatarImg {
|
|
||||||
|
.avatarButton__image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
.avatarFallback {
|
|
||||||
|
.avatarButton__fallback {
|
||||||
|
font-size: 34px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
font-size: 20px;
|
color: rgba(255, 255, 255, 0.86);
|
||||||
opacity: 0.9;
|
|
||||||
}
|
}
|
||||||
.meta {
|
|
||||||
|
.avatarButton__overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: auto 0 0 0;
|
||||||
|
padding: 12px 10px;
|
||||||
|
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.72));
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarButton__remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(10, 10, 10, 0.72);
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 2;
|
||||||
|
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.28);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarButton__remove svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2.1;
|
||||||
|
fill: none;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarButton__remove:hover {
|
||||||
|
background: rgba(190, 24, 24, 0.88);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identityMeta {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
.email {
|
|
||||||
font-weight: 900;
|
.identityMeta__eyebrow {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255, 255, 255, 0.36);
|
||||||
}
|
}
|
||||||
.nicknameInput {
|
|
||||||
|
.identityMeta__title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identityMeta__desc {
|
||||||
|
color: rgba(255, 255, 255, 0.58);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.hiddenInput {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsFields {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.62);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 12px;
|
padding: 14px 0;
|
||||||
border-radius: 12px;
|
border: 0;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(0, 0, 0, 0.18);
|
background: transparent;
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.94);
|
||||||
outline: none;
|
outline: none;
|
||||||
box-sizing: border-box;
|
font-size: 18px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
.badge {
|
|
||||||
|
.field__input:focus {
|
||||||
|
border-bottom-color: rgba(96, 165, 250, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__input--readonly {
|
||||||
|
color: rgba(255, 255, 255, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 2px 8px;
|
color: rgba(255, 255, 255, 0.42);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
}
|
||||||
border-radius: 999px;
|
|
||||||
|
.roleBadge {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
opacity: 0.9;
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(96, 165, 250, 0.28);
|
||||||
|
background: rgba(96, 165, 250, 0.1);
|
||||||
|
color: rgba(191, 219, 254, 0.92);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.upload {
|
|
||||||
margin-top: 14px;
|
.settingsActions {
|
||||||
padding-top: 12px;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
.label {
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
opacity: 0.78;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
.file {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.hint {
|
|
||||||
margin-top: 8px;
|
|
||||||
opacity: 0.72;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.saveBtn {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
||||||
background: rgba(96, 165, 250, 0.2);
|
|
||||||
color: rgba(255, 255, 255, 0.92);
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
.actions {
|
|
||||||
margin-top: 12px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
.logoutBtn {
|
|
||||||
padding: 10px 12px;
|
.primaryAction,
|
||||||
border-radius: 12px;
|
.secondaryAction {
|
||||||
border: 1px solid rgba(239, 68, 68, 0.24);
|
padding: 12px 18px;
|
||||||
background: rgba(239, 68, 68, 0.12);
|
border-radius: 999px;
|
||||||
color: rgba(255, 255, 255, 0.92);
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 800;
|
}
|
||||||
|
|
||||||
|
.primaryAction {
|
||||||
|
border: 1px solid rgba(76, 133, 245, 0.96);
|
||||||
|
background: rgba(76, 133, 245, 0.92);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondaryAction {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: rgba(255, 255, 255, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.settingsIdentity {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarButtonWrap {
|
||||||
|
width: 108px;
|
||||||
|
height: 108px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarButton {
|
||||||
|
width: 108px;
|
||||||
|
height: 108px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -198,15 +198,19 @@ watch(
|
|||||||
.boardCard__head {
|
.boardCard__head {
|
||||||
padding: 16px 18px 18px;
|
padding: 16px 18px 18px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.boardCard__titleRow,
|
.boardCard__titleRow,
|
||||||
.boardCard__metaRow {
|
.boardCard__metaRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boardCard__metaRow {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
.boardCard__title {
|
.boardCard__title {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -232,7 +236,7 @@ watch(
|
|||||||
.boardCard__avatar {
|
.boardCard__avatar {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
border-radius: 6px;
|
border-radius: 9999px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -250,6 +254,13 @@ watch(
|
|||||||
color: rgba(255, 255, 255, 0.64);
|
color: rgba(255, 255, 255, 0.64);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boardCard__date {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.boardCard__date {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
@media (max-width: 1400px) {
|
@media (max-width: 1400px) {
|
||||||
.list {
|
.list {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -36,21 +36,28 @@ const pendingThumbnailFile = ref(null)
|
|||||||
const thumbnailPreviewUrl = ref('')
|
const thumbnailPreviewUrl = ref('')
|
||||||
const description = ref('')
|
const description = ref('')
|
||||||
const isPublic = ref(true)
|
const isPublic = ref(true)
|
||||||
|
const showCharacterNames = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
const isExporting = ref(false)
|
const isExporting = ref(false)
|
||||||
const isSaveModalOpen = ref(false)
|
const isSaveModalOpen = ref(false)
|
||||||
const isTemplateRequestModalOpen = ref(false)
|
const isTemplateRequestModalOpen = ref(false)
|
||||||
|
const isTemplateUpdateModalOpen = ref(false)
|
||||||
|
const templateRequestDraftTitle = ref('')
|
||||||
|
const templateRequestDraftDescription = ref('')
|
||||||
|
const isDeleteModalOpen = ref(false)
|
||||||
const ownerId = ref('')
|
const ownerId = ref('')
|
||||||
const authorName = ref('')
|
const authorName = ref('')
|
||||||
const authorAccountName = ref('')
|
const authorAccountName = ref('')
|
||||||
const updatedAt = ref(0)
|
const updatedAt = ref(0)
|
||||||
const isDragActive = ref(false)
|
const isDragActive = ref(false)
|
||||||
|
const isThumbnailDragActive = ref(false)
|
||||||
const iconSize = ref(80)
|
const iconSize = ref(80)
|
||||||
const isFavoriteBusy = ref(false)
|
const isFavoriteBusy = ref(false)
|
||||||
const favoriteCount = ref(0)
|
const favoriteCount = ref(0)
|
||||||
const isFavorited = ref(false)
|
const isFavorited = ref(false)
|
||||||
const isRequestingTemplate = ref(false)
|
const isRequestingTemplate = ref(false)
|
||||||
|
const isDeleting = ref(false)
|
||||||
|
|
||||||
const boardEl = ref(null)
|
const boardEl = ref(null)
|
||||||
const exportBoardEl = ref(null)
|
const exportBoardEl = ref(null)
|
||||||
@@ -107,7 +114,9 @@ const templateRequestChecks = computed(() => [
|
|||||||
passed: !!(title.value || '').trim() && (title.value || '').trim() !== (gameName.value || '').trim(),
|
passed: !!(title.value || '').trim() && (title.value || '').trim() !== (gameName.value || '').trim(),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
const canSubmitTemplateCreateRequest = computed(() => templateRequestChecks.value.every((item) => item.passed))
|
const canSubmitTemplateCreateRequest = computed(() => templateRequestChecks.value.every((item) => item.passed) && !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||||
|
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||||
|
const templateRequestTargetLabel = computed(() => (gameId.value === 'freeform' ? '새로운 템플릿' : (gameName.value || gameId.value || '선택한 게임')))
|
||||||
|
|
||||||
watch(error, (message) => {
|
watch(error, (message) => {
|
||||||
if (!message) return
|
if (!message) return
|
||||||
@@ -304,14 +313,48 @@ function openThumbnailFile() {
|
|||||||
thumbnailFileEl.value?.click()
|
thumbnailFileEl.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onThumbnailChange(event) {
|
function applyThumbnailFile(file) {
|
||||||
const file = event.target.files?.[0]
|
if (!file || !file.type.startsWith('image/')) return
|
||||||
if (thumbnailPreviewUrl.value) {
|
if (thumbnailPreviewUrl.value) {
|
||||||
URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
||||||
thumbnailPreviewUrl.value = ''
|
thumbnailPreviewUrl.value = ''
|
||||||
}
|
}
|
||||||
pendingThumbnailFile.value = file || null
|
pendingThumbnailFile.value = file
|
||||||
if (file) thumbnailPreviewUrl.value = URL.createObjectURL(file)
|
thumbnailPreviewUrl.value = URL.createObjectURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onThumbnailDragEnter() {
|
||||||
|
if (!canEdit.value) return
|
||||||
|
isThumbnailDragActive.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onThumbnailDragLeave(event) {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||||
|
isThumbnailDragActive.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onThumbnailDrop(event) {
|
||||||
|
if (!canEdit.value) return
|
||||||
|
isThumbnailDragActive.value = false
|
||||||
|
const files = Array.from(event.dataTransfer?.files || []).filter((file) => file.type.startsWith('image/'))
|
||||||
|
if (!files.length) return
|
||||||
|
if (files.length > 1) {
|
||||||
|
toast.info('대표 썸네일은 하나만 설정할 수 있어요. 첫 번째 이미지를 사용할게요.')
|
||||||
|
}
|
||||||
|
applyThumbnailFile(files[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
function onThumbnailChange(event) {
|
||||||
|
const files = Array.from(event.target.files || []).filter((file) => file.type.startsWith('image/'))
|
||||||
|
if (!files.length) {
|
||||||
|
event.target.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (files.length > 1) {
|
||||||
|
toast.info('대표 썸네일은 하나만 설정할 수 있어요. 첫 번째 이미지를 사용할게요.')
|
||||||
|
}
|
||||||
|
applyThumbnailFile(files[0])
|
||||||
event.target.value = ''
|
event.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,6 +473,7 @@ function buildPayload(existingId) {
|
|||||||
thumbnailSrc: thumbnailSrc.value || '',
|
thumbnailSrc: thumbnailSrc.value || '',
|
||||||
description: (description.value || '').trim(),
|
description: (description.value || '').trim(),
|
||||||
isPublic: !!isPublic.value,
|
isPublic: !!isPublic.value,
|
||||||
|
showCharacterNames: !!showCharacterNames.value,
|
||||||
groups: groups.value.map((g) => ({ id: g.id, name: g.name, itemIds: g.itemIds })),
|
groups: groups.value.map((g) => ({ id: g.id, name: g.name, itemIds: g.itemIds })),
|
||||||
pool: Object.values(itemsById.value),
|
pool: Object.values(itemsById.value),
|
||||||
}
|
}
|
||||||
@@ -469,25 +513,52 @@ function closeSaveModal() {
|
|||||||
isSaveModalOpen.value = false
|
isSaveModalOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetTemplateRequestDrafts() {
|
||||||
|
templateRequestDraftTitle.value = ''
|
||||||
|
templateRequestDraftDescription.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
function openTemplateRequestModal() {
|
function openTemplateRequestModal() {
|
||||||
|
resetTemplateRequestDrafts()
|
||||||
isTemplateRequestModalOpen.value = true
|
isTemplateRequestModalOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeTemplateRequestModal() {
|
function closeTemplateRequestModal() {
|
||||||
isTemplateRequestModalOpen.value = false
|
isTemplateRequestModalOpen.value = false
|
||||||
|
resetTemplateRequestDrafts()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeTierList() {
|
function openTemplateUpdateModal() {
|
||||||
if (!canEdit.value || isNewTierList.value) return
|
resetTemplateRequestDrafts()
|
||||||
|
isTemplateUpdateModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTemplateUpdateModal() {
|
||||||
|
isTemplateUpdateModalOpen.value = false
|
||||||
|
resetTemplateRequestDrafts()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteModal() {
|
||||||
|
isDeleteModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteModal() {
|
||||||
|
isDeleteModalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteTierList() {
|
||||||
|
if (!canEdit.value || isNewTierList.value || isDeleting.value) return
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
const ok = window.confirm(`"${title.value || gameName.value || '이 티어표'}"를 삭제할까요?`)
|
isDeleting.value = true
|
||||||
if (!ok) return
|
|
||||||
await api.deleteTierList(tierListId.value)
|
await api.deleteTierList(tierListId.value)
|
||||||
|
closeDeleteModal()
|
||||||
toast.success('티어표를 삭제했어요.')
|
toast.success('티어표를 삭제했어요.')
|
||||||
router.push(gameId.value === 'freeform' ? '/me' : `/games/${gameId.value}`)
|
router.push(gameId.value === 'freeform' ? '/me' : `/games/${gameId.value}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '티어표 삭제에 실패했어요.'
|
error.value = '티어표 삭제에 실패했어요.'
|
||||||
|
} finally {
|
||||||
|
isDeleting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,8 +586,13 @@ async function requestTemplate(type) {
|
|||||||
try {
|
try {
|
||||||
isRequestingTemplate.value = true
|
isRequestingTemplate.value = true
|
||||||
const persisted = await persistTierList({ showModal: false })
|
const persisted = await persistTierList({ showModal: false })
|
||||||
await api.requestTierListTemplate(persisted.savedTierListId, { type })
|
await api.requestTierListTemplate(persisted.savedTierListId, {
|
||||||
|
type,
|
||||||
|
requestTitle: templateRequestDraftTitle.value.trim(),
|
||||||
|
requestDescription: templateRequestDraftDescription.value.trim(),
|
||||||
|
})
|
||||||
if (type === 'create') closeTemplateRequestModal()
|
if (type === 'create') closeTemplateRequestModal()
|
||||||
|
if (type === 'update') closeTemplateUpdateModal()
|
||||||
toast.success(type === 'create' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
|
toast.success(type === 'create' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e?.status === 400 && e?.data?.error === 'title_required') {
|
if (e?.status === 400 && e?.data?.error === 'title_required') {
|
||||||
@@ -574,6 +650,7 @@ onMounted(() => {
|
|||||||
thumbnailSrc.value = t.thumbnailSrc || ''
|
thumbnailSrc.value = t.thumbnailSrc || ''
|
||||||
description.value = t.description || ''
|
description.value = t.description || ''
|
||||||
isPublic.value = !!t.isPublic
|
isPublic.value = !!t.isPublic
|
||||||
|
showCharacterNames.value = !!t.showCharacterNames
|
||||||
authorName.value = t.authorName || ''
|
authorName.value = t.authorName || ''
|
||||||
authorAccountName.value = t.authorAccountName || ''
|
authorAccountName.value = t.authorAccountName || ''
|
||||||
updatedAt.value = Number(t.updatedAt || 0)
|
updatedAt.value = Number(t.updatedAt || 0)
|
||||||
@@ -615,6 +692,7 @@ onUnmounted(() => {
|
|||||||
<div class="previewOnly__drop">
|
<div class="previewOnly__drop">
|
||||||
<div v-for="id in g.itemIds" :key="id" class="previewOnly__cell">
|
<div v-for="id in g.itemIds" :key="id" class="previewOnly__cell">
|
||||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||||
|
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -660,7 +738,18 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="requestChecklist__hint">
|
<div class="requestChecklist__hint">
|
||||||
제목만 명확하게 적어두면 관리자가 어떤 게임 템플릿 요청인지 빠르게 파악할 수 있어요. 여러 사용자가 비슷한 주제로 요청할 수 있으니 게임 이름을 구체적으로 적어주세요.
|
제목과 설명을 함께 적어두면 관리자가 어떤 신규 템플릿인지 훨씬 빠르게 파악할 수 있어요.
|
||||||
|
예시: 제목 `템플릿 등록 요청`, 설명 `여름 이벤트 한정 캐릭터 중심으로 새 게임 템플릿이 필요합니다.`
|
||||||
|
</div>
|
||||||
|
<div class="templateRequestDraft">
|
||||||
|
<label class="templateRequestDraft__field">
|
||||||
|
<span class="templateRequestDraft__label">요청 제목</span>
|
||||||
|
<input v-model="templateRequestDraftTitle" class="input" maxlength="80" placeholder="예: 템플릿 등록 요청" />
|
||||||
|
</label>
|
||||||
|
<label class="templateRequestDraft__field">
|
||||||
|
<span class="templateRequestDraft__label">요청 설명</span>
|
||||||
|
<textarea v-model="templateRequestDraftDescription" class="textarea templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="modalCard__actions">
|
<div class="modalCard__actions">
|
||||||
<button class="btn btn--ghost" @click="closeTemplateRequestModal">취소</button>
|
<button class="btn btn--ghost" @click="closeTemplateRequestModal">취소</button>
|
||||||
@@ -671,6 +760,50 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isTemplateUpdateModalOpen" class="modalOverlay" @click.self="closeTemplateUpdateModal">
|
||||||
|
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="templateUpdateTitle">
|
||||||
|
<div id="templateUpdateTitle" class="modalCard__title">템플릿 요청하기</div>
|
||||||
|
<div class="modalCard__desc">
|
||||||
|
{{ templateRequestTargetLabel }}에 직접 추가한 아이템을 포함해 달라고 관리자에게 요청을 보냅니다.
|
||||||
|
</div>
|
||||||
|
<div class="modalCard__note">
|
||||||
|
모두가 사용하는 기본 템플릿이니 개인적인 항목이 아닌 공통된 항목만 추가한 뒤 신청해주세요.
|
||||||
|
예시: 제목 `템플릿 업데이트 요청`, 설명 `여름 이벤트 한정 캐릭터 추가`
|
||||||
|
</div>
|
||||||
|
<div class="templateRequestDraft">
|
||||||
|
<label class="templateRequestDraft__field">
|
||||||
|
<span class="templateRequestDraft__label">요청 제목</span>
|
||||||
|
<input v-model="templateRequestDraftTitle" class="input" maxlength="80" placeholder="예: 템플릿 업데이트 요청" />
|
||||||
|
</label>
|
||||||
|
<label class="templateRequestDraft__field">
|
||||||
|
<span class="templateRequestDraft__label">요청 설명</span>
|
||||||
|
<textarea v-model="templateRequestDraftDescription" class="textarea templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="modalCard__actions">
|
||||||
|
<button class="btn btn--ghost" @click="closeTemplateUpdateModal">요청 취소</button>
|
||||||
|
<button class="btn btn--save" :disabled="!canSubmitTemplateUpdateRequest || isRequestingTemplate" @click="requestTemplate('update')">
|
||||||
|
{{ isRequestingTemplate ? '요청중...' : '예, 요청할게요' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isDeleteModalOpen" class="modalOverlay" @click.self="closeDeleteModal">
|
||||||
|
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteTierListTitle">
|
||||||
|
<div id="deleteTierListTitle" class="modalCard__title">티어표 삭제</div>
|
||||||
|
<div class="modalCard__desc">
|
||||||
|
"{{ title || gameName || '이 티어표' }}"를 삭제할까요? 삭제 후에는 복구할 수 없어요.
|
||||||
|
</div>
|
||||||
|
<div class="modalCard__actions">
|
||||||
|
<button class="btn btn--ghost" @click="closeDeleteModal">취소</button>
|
||||||
|
<button class="btn btn--danger" :disabled="isDeleting" @click="confirmDeleteTierList">
|
||||||
|
{{ isDeleting ? '삭제중...' : '삭제하기' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="layout" :style="{ '--thumb-size': `${iconSize}px` }">
|
<section class="layout" :style="{ '--thumb-size': `${iconSize}px` }">
|
||||||
<div class="editorMain">
|
<div class="editorMain">
|
||||||
<section class="head">
|
<section class="head">
|
||||||
@@ -732,6 +865,7 @@ onUnmounted(() => {
|
|||||||
<div v-if="!isExporting" class="row__empty" v-show="g.itemIds.length === 0">여기로 드래그해서 배치</div>
|
<div v-if="!isExporting" class="row__empty" v-show="g.itemIds.length === 0">여기로 드래그해서 배치</div>
|
||||||
<div v-for="id in g.itemIds" :key="id" class="cell" :data-item-id="id">
|
<div v-for="id in g.itemIds" :key="id" class="cell" :data-item-id="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" />
|
||||||
|
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
||||||
<button
|
<button
|
||||||
v-if="canEdit && !isExporting"
|
v-if="canEdit && !isExporting"
|
||||||
class="cellRemoveBtn"
|
class="cellRemoveBtn"
|
||||||
@@ -751,12 +885,31 @@ onUnmounted(() => {
|
|||||||
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="canEdit"
|
||||||
|
class="dropzone dropzone--board"
|
||||||
|
:class="{ 'dropzone--active': isDragActive }"
|
||||||
|
@dragenter.prevent="onDragEnter"
|
||||||
|
@dragover.prevent="onDragEnter"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
|
@drop.prevent="onDropFiles"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="dropzone__title">커스텀 이미지 추가</div>
|
||||||
|
<div class="dropzone__desc">이곳으로 이미지를 드래그하거나 파일 선택으로 한 번에 추가할 수 있어요.</div>
|
||||||
|
</div>
|
||||||
|
<div class="dropzone__actions">
|
||||||
|
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
|
||||||
|
<button class="btn btn--ghost dropzone__button" @click="openFile">파일 선택</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<div class="sidebar__title">아이템</div>
|
<div class="sidebar__title">아이템</div>
|
||||||
<div class="sidebar__hint">
|
<div class="sidebar__hint">
|
||||||
{{ canEdit ? '보드 바로 옆에서 드래그해 넣을 수 있도록 아이템 풀을 고정합니다.' : '공개 티어표는 보기 전용입니다.' }}
|
{{ canEdit ? '등록된 아이템 리스트입니다. 드래그해서 표에 넣을 수 있습니다.' : '공개 티어표는 보기 전용입니다.' }}
|
||||||
</div>
|
</div>
|
||||||
<div ref="poolEl" class="pool" data-list-type="pool">
|
<div ref="poolEl" class="pool" data-list-type="pool">
|
||||||
<div v-for="id in pool" :key="id" class="poolItem" :data-item-id="id">
|
<div v-for="id in pool" :key="id" class="poolItem" :data-item-id="id">
|
||||||
@@ -764,22 +917,9 @@ onUnmounted(() => {
|
|||||||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
v-if="canEdit"
|
|
||||||
class="dropzone"
|
|
||||||
:class="{ 'dropzone--active': isDragActive }"
|
|
||||||
@dragenter.prevent="onDragEnter"
|
|
||||||
@dragover.prevent="onDragEnter"
|
|
||||||
@dragleave="onDragLeave"
|
|
||||||
@drop.prevent="onDropFiles"
|
|
||||||
>
|
|
||||||
<div class="dropzone__title">커스텀 이미지 추가</div>
|
|
||||||
<div class="dropzone__desc">여러 이미지를 한 번에 드래그하거나 파일 선택으로 추가할 수 있어요.</div>
|
|
||||||
</div>
|
|
||||||
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
|
|
||||||
<button v-if="canEdit" class="btn btn--ghost" @click="openFile">파일 선택</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
@@ -805,16 +945,24 @@ onUnmounted(() => {
|
|||||||
<div class="editorSidebar__section">
|
<div class="editorSidebar__section">
|
||||||
<div class="editorSidebar__label">대표 썸네일</div>
|
<div class="editorSidebar__label">대표 썸네일</div>
|
||||||
<input ref="thumbnailFileEl" type="file" accept="image/*" class="hidden" @change="onThumbnailChange" />
|
<input ref="thumbnailFileEl" type="file" accept="image/*" class="hidden" @change="onThumbnailChange" />
|
||||||
<div class="editorSidebar__thumbFrame">
|
<div
|
||||||
|
class="editorSidebar__thumbFrame"
|
||||||
|
:class="{ 'editorSidebar__thumbFrame--active': isThumbnailDragActive }"
|
||||||
|
@dragenter.prevent="onThumbnailDragEnter"
|
||||||
|
@dragover.prevent="onThumbnailDragEnter"
|
||||||
|
@dragleave="onThumbnailDragLeave"
|
||||||
|
@drop.prevent="onThumbnailDrop"
|
||||||
|
>
|
||||||
<img v-if="displayThumbnailUrl" class="editorSidebar__thumbImage" :src="displayThumbnailUrl" alt="썸네일 미리보기" />
|
<img v-if="displayThumbnailUrl" class="editorSidebar__thumbImage" :src="displayThumbnailUrl" alt="썸네일 미리보기" />
|
||||||
<div v-else class="editorSidebar__thumbEmpty">대표 썸네일</div>
|
<div v-else class="editorSidebar__thumbEmpty">대표 썸네일</div>
|
||||||
|
<div class="editorSidebar__thumbOverlay">드래그 또는 클릭으로 썸네일 추가</div>
|
||||||
</div>
|
</div>
|
||||||
<button v-if="canEdit" class="btn btn--ghost editorSidebar__button" @click="openThumbnailFile">파일 업로드</button>
|
<button v-if="canEdit" class="btn btn--ghost editorSidebar__button" @click="openThumbnailFile">파일 업로드</button>
|
||||||
<div v-if="pendingThumbnailFile" class="editorSidebar__fileName">{{ pendingThumbnailFile.name }}</div>
|
<div v-if="pendingThumbnailFile" class="editorSidebar__fileName">{{ pendingThumbnailFile.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editorSidebar__section">
|
<div v-if="canFavorite" class="editorSidebar__section">
|
||||||
<button v-if="canFavorite" class="editorSidebar__favorite" :disabled="isFavoriteBusy" @click="toggleFavorite">
|
<button class="editorSidebar__favorite" :disabled="isFavoriteBusy" @click="toggleFavorite">
|
||||||
<span>♡ 즐겨찾기</span>
|
<span>♡ 즐겨찾기</span>
|
||||||
<span>{{ favoriteCount }}</span>
|
<span>{{ favoriteCount }}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -846,27 +994,33 @@ onUnmounted(() => {
|
|||||||
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
|
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
|
||||||
<span>공개</span>
|
<span>공개</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
|
||||||
|
<input v-model="showCharacterNames" type="checkbox" :disabled="!canEdit" />
|
||||||
|
<span>캐릭터 이름 표시</span>
|
||||||
|
</label>
|
||||||
<div class="editorSidebar__actionGrid">
|
<div class="editorSidebar__actionGrid">
|
||||||
<button class="btn btn--ghost editorSidebar__button" @click="downloadImage">이미지 다운로드</button>
|
<button class="btn btn--ghost editorSidebar__button" @click="downloadImage">이미지 다운로드</button>
|
||||||
<button v-if="canEdit" class="btn btn--save editorSidebar__button" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
|
<button v-if="canEdit" class="btn btn--save editorSidebar__button" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
|
||||||
</div>
|
</div>
|
||||||
<button v-if="canEdit && !isNewTierList" class="btn btn--danger editorSidebar__button" @click="removeTierList">삭제</button>
|
<div class="editorSidebar__utilityLinks">
|
||||||
<button
|
<button v-if="canEdit && !isNewTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--danger" @click="openDeleteModal">삭제하기</button>
|
||||||
v-if="canRequestTemplateCreate"
|
<button
|
||||||
class="btn btn--ghost editorSidebar__button"
|
v-if="canRequestTemplateCreate"
|
||||||
:disabled="isRequestingTemplate"
|
class="editorSidebar__utilityLink"
|
||||||
@click="openTemplateRequestModal"
|
:disabled="isRequestingTemplate"
|
||||||
>
|
@click="openTemplateRequestModal"
|
||||||
템플릿 등록 요청
|
>
|
||||||
</button>
|
템플릿 등록 요청
|
||||||
<button
|
</button>
|
||||||
v-if="canRequestTemplateUpdate"
|
<button
|
||||||
class="btn btn--ghost editorSidebar__button"
|
v-if="canRequestTemplateUpdate"
|
||||||
:disabled="isRequestingTemplate"
|
class="editorSidebar__utilityLink"
|
||||||
@click="requestTemplate('update')"
|
:disabled="isRequestingTemplate"
|
||||||
>
|
@click="openTemplateUpdateModal"
|
||||||
{{ isRequestingTemplate ? '요청중...' : '템플릿 업데이트 요청' }}
|
>
|
||||||
</button>
|
{{ isRequestingTemplate ? '요청중...' : '템플릿 업데이트 요청' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
@@ -886,7 +1040,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.editorCanvas {
|
.editorCanvas {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 280px;
|
grid-template-columns: minmax(0, clamp(680px, 58vw, 960px)) minmax(280px, 1fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
@@ -958,6 +1112,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.previewOnly__cell {
|
.previewOnly__cell {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.previewOnly__pool {
|
.previewOnly__pool {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -975,6 +1130,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.previewOnly__poolItem {
|
.previewOnly__poolItem {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.toggle {
|
.toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1051,6 +1207,7 @@ onUnmounted(() => {
|
|||||||
background: rgba(239, 68, 68, 0.12);
|
background: rgba(239, 68, 68, 0.12);
|
||||||
}
|
}
|
||||||
.board {
|
.board {
|
||||||
|
width: min(100%, 960px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
background: linear-gradient(180deg, rgba(55, 55, 55, 0.86), rgba(42, 42, 42, 0.82));
|
background: linear-gradient(180deg, rgba(55, 55, 55, 0.86), rgba(42, 42, 42, 0.82));
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
@@ -1092,6 +1249,7 @@ onUnmounted(() => {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.modalCard__actions .btn {
|
.modalCard__actions .btn {
|
||||||
width: auto;
|
width: auto;
|
||||||
@@ -1133,6 +1291,23 @@ onUnmounted(() => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
opacity: 0.78;
|
opacity: 0.78;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
.templateRequestDraft {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.templateRequestDraft__field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.templateRequestDraft__label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.64);
|
||||||
|
}
|
||||||
|
.templateRequestDraft__textarea {
|
||||||
|
min-height: 92px;
|
||||||
|
resize: vertical;
|
||||||
}
|
}
|
||||||
.boardTools {
|
.boardTools {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1309,6 +1484,22 @@ onUnmounted(() => {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
.itemNameOverlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: auto 0 0 0;
|
||||||
|
padding: 16px 8px 6px;
|
||||||
|
border-radius: 0 0 10px 10px;
|
||||||
|
background: linear-gradient(180deg, rgba(7, 10, 18, 0), rgba(7, 10, 18, 0.92));
|
||||||
|
color: rgba(255, 255, 255, 0.96);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.cellRemoveBtn {
|
.cellRemoveBtn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -6px;
|
top: -6px;
|
||||||
@@ -1340,6 +1531,7 @@ onUnmounted(() => {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
.sidebar {
|
.sidebar {
|
||||||
|
min-width: 0;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
background: linear-gradient(180deg, rgba(52, 52, 52, 0.84), rgba(36, 36, 36, 0.8));
|
background: linear-gradient(180deg, rgba(52, 52, 52, 0.84), rgba(36, 36, 36, 0.8));
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
@@ -1348,6 +1540,25 @@ onUnmounted(() => {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 14px;
|
top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropzone--board {
|
||||||
|
margin-top: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone__button {
|
||||||
|
min-width: 148px;
|
||||||
|
}
|
||||||
.editorSidebar__section {
|
.editorSidebar__section {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -1381,11 +1592,13 @@ onUnmounted(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: rgba(255, 255, 255, 0.56);
|
color: rgba(255, 255, 255, 0.56);
|
||||||
|
word-break: keep-all;
|
||||||
}
|
}
|
||||||
.editorSidebar__hint--warn {
|
.editorSidebar__hint--warn {
|
||||||
color: rgba(251, 191, 36, 0.92);
|
color: rgba(251, 191, 36, 0.92);
|
||||||
}
|
}
|
||||||
.editorSidebar__thumbFrame {
|
.editorSidebar__thumbFrame {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@@ -1393,6 +1606,11 @@ onUnmounted(() => {
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
background: #4c4c4c;
|
background: #4c4c4c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editorSidebar__thumbFrame--active {
|
||||||
|
border-color: rgba(96, 165, 250, 0.8);
|
||||||
|
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.18);
|
||||||
|
}
|
||||||
.editorSidebar__thumbImage {
|
.editorSidebar__thumbImage {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -1406,6 +1624,16 @@ onUnmounted(() => {
|
|||||||
color: rgba(255, 255, 255, 0.36);
|
color: rgba(255, 255, 255, 0.36);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editorSidebar__thumbOverlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: auto 0 0 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.72));
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
.editorSidebar__button {
|
.editorSidebar__button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@@ -1437,6 +1665,33 @@ onUnmounted(() => {
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editorSidebar__utilityLinks {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorSidebar__utilityLink {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.74);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorSidebar__utilityLink:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorSidebar__utilityLink--danger {
|
||||||
|
color: rgba(248, 113, 113, 0.96);
|
||||||
|
}
|
||||||
.sidebar__title {
|
.sidebar__title {
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
@@ -1448,6 +1703,7 @@ onUnmounted(() => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
word-break: keep-all;
|
||||||
}
|
}
|
||||||
.customItemEditor {
|
.customItemEditor {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@@ -1504,7 +1760,6 @@ onUnmounted(() => {
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px dashed rgba(255, 255, 255, 0.18);
|
border: 1px dashed rgba(255, 255, 255, 0.18);
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
.dropzone--active {
|
.dropzone--active {
|
||||||
border-color: rgba(110, 231, 183, 0.6);
|
border-color: rgba(110, 231, 183, 0.6);
|
||||||
@@ -1521,21 +1776,35 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.pool {
|
.pool {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
align-content: start;
|
||||||
}
|
}
|
||||||
.poolItem {
|
.poolItem {
|
||||||
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--thumb-size, 80px) minmax(0, 1fr);
|
grid-template-columns: 1fr;
|
||||||
gap: 10px;
|
justify-items: center;
|
||||||
align-items: center;
|
align-content: start;
|
||||||
padding: 10px;
|
gap: 8px;
|
||||||
|
padding: 10px 8px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||||
background: rgba(0, 0, 0, 0.18);
|
background: rgba(0, 0, 0, 0.18);
|
||||||
}
|
}
|
||||||
|
.poolItem .thumb {
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--thumb-size, 80px);
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
.poolItem__label {
|
.poolItem__label {
|
||||||
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
text-align: center;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -1567,9 +1836,17 @@ onUnmounted(() => {
|
|||||||
.sidebar {
|
.sidebar {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
|
.pool {
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
.editorSidebar__actionGrid {
|
.editorSidebar__actionGrid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.editorSidebar__utilityLinks {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
.requestChecklist__item {
|
.requestChecklist__item {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -1587,5 +1864,13 @@ onUnmounted(() => {
|
|||||||
.previewOnly__row {
|
.previewOnly__row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.pool {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.pool {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"dev:frontend": "npm --prefix frontend run dev",
|
||||||
|
"dev:backend": "npm --prefix backend run dev",
|
||||||
|
"build": "npm --prefix frontend run build",
|
||||||
|
"start": "npm --prefix backend run start",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
|
|||||||
9
update.md
Normal file
9
update.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Update Log Entry Point
|
||||||
|
|
||||||
|
이 프로젝트의 상세 업데이트 로그는 [docs/update.md](/Users/bicute/Desktop/zenn.dev/tier-cursor/docs/update.md)에 계속 누적됩니다.
|
||||||
|
|
||||||
|
## 2026-03-30
|
||||||
|
|
||||||
|
- 루트 `package.json`에 공용 실행 스크립트(`dev:frontend`, `dev:backend`, `build`, `start`)를 추가했습니다.
|
||||||
|
- 루트에서도 바로 `npm run build` 같은 공용 명령을 사용할 수 있게 정리했습니다.
|
||||||
|
- 업데이트 로그 진입점을 루트 `update.md`로 추가해, 이후 작업 시 파일 위치를 바로 찾을 수 있게 했습니다.
|
||||||
Reference in New Issue
Block a user