Compare commits

...

15 Commits

Author SHA1 Message Date
a550385ed8 릴리스: v1.3.26 오른쪽 광고 슬롯 규격 정리 2026-04-01 14:40:50 +09:00
5b53c73b56 릴리스: v1.3.25 관리자 게임 선택 UX와 세션 보안 보강 2026-04-01 14:23:04 +09:00
7952f2f289 릴리스: v1.3.24 게임 허브 티어표 그리드 정렬 2026-04-01 14:11:10 +09:00
b851100c89 릴리스: v1.3.23 내 티어표 그리드 열 수 정렬 2026-04-01 14:07:56 +09:00
e70e685a06 릴리스: v1.3.22 내 티어표 카드 폭과 광고 프레임 정리 2026-04-01 14:04:15 +09:00
09acebc2d5 릴리스: v1.3.21 내 티어표 카드 레이아웃을 게임 목록과 통일 2026-04-01 13:53:48 +09:00
e3391b5f07 릴리스: v1.3.20 내 티어표 카드 그리드 밀도 보정 2026-04-01 13:49:54 +09:00
22220494d6 릴리스: v1.3.19 관리자 이미지 최적화 기간 선택 레이아웃 보정 2026-04-01 13:45:45 +09:00
909ed72502 릴리스: v1.3.18 템플릿 요청 실패 보완과 이미지 최적화 기간 선택 개선 2026-04-01 13:32:25 +09:00
c352bf459f 릴리스: v1.3.17 티어 에디터 열 정렬과 삭제 확인 흐름 보정 2026-04-01 12:29:49 +09:00
730a87b923 릴리스: v1.3.16 티어 에디터 행열 삭제 액션과 열 제목 정렬 보정 2026-04-01 12:22:21 +09:00
e9a049241d 릴리스: v1.3.15 티어 에디터 열 헤더와 액션 아이콘 정리 2026-04-01 12:16:06 +09:00
0fec84de13 릴리스: v1.3.14 티어 에디터 행열 보드 확장 2026-04-01 12:07:17 +09:00
7fe4eff7b7 릴리스: v1.3.13 템플릿 요청 스냅샷과 저장 분리 2026-04-01 11:50:54 +09:00
b2a838ff34 릴리스: v1.3.12 회원 정렬 방향과 입력 길이 피드백 2026-04-01 11:15:49 +09:00
17 changed files with 1226 additions and 286 deletions

View File

@@ -154,7 +154,7 @@ function mapTemplateRequestRow(row) {
requesterName: getUserDisplayName(row),
requesterAccountName: getUserAccountName(row),
requesterAvatarSrc: row.requester_avatar_src || '',
sourceTierListId: row.source_tierlist_id,
sourceTierListId: row.source_tierlist_id || '',
sourceGameId: row.source_game_id,
sourceGameName: row.source_game_name || '',
sourceTierListTitle: row.title_snapshot || '',
@@ -164,6 +164,9 @@ function mapTemplateRequestRow(row) {
targetGameName: row.target_game_name || '',
status: row.status,
items: parseJson(row.items_json, []),
snapshotGroups: parseJson(row.groups_json, []),
snapshotItems: parseJson(row.board_items_json, []),
snapshotShowCharacterNames: !!row.show_character_names_snapshot,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
}
@@ -389,6 +392,23 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const templateRequestSourceTierListColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_tierlist_id'")
if (templateRequestSourceTierListColumns[0]?.Null !== 'YES') {
await query('ALTER TABLE template_requests MODIFY source_tierlist_id VARCHAR(64) NULL')
}
const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'")
if (!templateRequestGroupsColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN groups_json LONGTEXT NOT NULL AFTER items_json")
}
const templateRequestBoardItemsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'board_items_json'")
if (!templateRequestBoardItemsColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN board_items_json LONGTEXT NOT NULL AFTER groups_json")
}
const templateRequestShowNamesColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'show_character_names_snapshot'")
if (!templateRequestShowNamesColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN show_character_names_snapshot TINYINT(1) NOT NULL DEFAULT 0 AFTER board_items_json")
}
const tierListThumbnailColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'thumbnail_src'")
if (!tierListThumbnailColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
@@ -513,7 +533,7 @@ async function findPrimaryAdminUser() {
return mapUserRow(rows[0])
}
async function listUsers({ queryText = '', sort = 'recent' } = {}) {
async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' } = {}) {
const where = []
const params = []
const trimmedQuery = typeof queryText === 'string' ? queryText.trim() : ''
@@ -523,12 +543,19 @@ async function listUsers({ queryText = '', sort = 'recent' } = {}) {
params.push(`%${trimmedQuery}%`, `%${trimmedQuery}%`)
}
const isAsc = direction === 'asc'
const orderBy =
sort === 'created'
? 'u.created_at DESC, recent_activity_at DESC, u.email ASC'
? isAsc
? 'u.created_at ASC, recent_activity_at ASC, u.email ASC'
: 'u.created_at DESC, recent_activity_at DESC, u.email ASC'
: sort === 'tierlists'
? 'tierlist_count DESC, recent_activity_at DESC, u.email ASC'
: 'recent_activity_at DESC, u.created_at ASC, u.email ASC'
? isAsc
? 'tierlist_count ASC, recent_activity_at ASC, u.email ASC'
: 'tierlist_count DESC, recent_activity_at DESC, u.email ASC'
: isAsc
? 'recent_activity_at ASC, u.created_at ASC, u.email ASC'
: 'recent_activity_at DESC, u.created_at ASC, u.email ASC'
const rows = await query(
`
@@ -747,7 +774,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
query("SELECT src FROM game_items WHERE src <> ''"),
query("SELECT src FROM custom_items WHERE src <> ''"),
query("SELECT thumbnail_src, pool_json FROM tierlists"),
query("SELECT thumbnail_src_snapshot, items_json FROM template_requests"),
query("SELECT thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"),
])
for (const row of userRows) if (row.avatar_src) referencedSrcs.add(row.avatar_src)
@@ -763,6 +790,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
for (const row of templateRequestRows) {
if (row.thumbnail_src_snapshot) referencedSrcs.add(row.thumbnail_src_snapshot)
collectUploadSrcsFromItems(parseJson(row.items_json, []), referencedSrcs)
collectUploadSrcsFromItems(parseJson(row.board_items_json, []), referencedSrcs)
}
return assets.filter((asset) => !referencedSrcs.has(asset.src))
@@ -799,7 +827,7 @@ async function listReferencedUploadUsage() {
query("SELECT src FROM game_items WHERE src <> ''"),
query("SELECT src FROM custom_items WHERE src <> ''"),
query("SELECT id, thumbnail_src, pool_json FROM tierlists"),
query("SELECT id, thumbnail_src_snapshot, items_json FROM template_requests"),
query("SELECT id, thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"),
])
for (const row of userRows) addUsage(row.avatar_src, 'avatar')
@@ -815,6 +843,7 @@ async function listReferencedUploadUsage() {
for (const row of templateRequestRows) {
addUsage(row.thumbnail_src_snapshot, 'template-thumbnail')
for (const item of parseJson(row.items_json, [])) addUsage(item?.src, 'template-item')
for (const item of parseJson(row.board_items_json, [])) addUsage(item?.src, 'template-board-item')
}
return Array.from(usageMap.entries())
@@ -867,7 +896,7 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
}
}
const requestRows = await query('SELECT id, thumbnail_src_snapshot, items_json FROM template_requests')
const requestRows = await query('SELECT id, thumbnail_src_snapshot, items_json, board_items_json FROM template_requests')
for (const row of requestRows) {
let nextThumbnail = row.thumbnail_src_snapshot
let changed = false
@@ -877,12 +906,14 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
}
const replacedItems = replaceItemSrc(parseJson(row.items_json, []), fromSrc, toSrc)
if (replacedItems.changed) changed = true
const replacedBoardItems = replaceItemSrc(parseJson(row.board_items_json, []), fromSrc, toSrc)
if (replacedItems.changed || replacedBoardItems.changed) changed = true
if (changed) {
await query('UPDATE template_requests SET thumbnail_src_snapshot = ?, items_json = ?, updated_at = ? WHERE id = ?', [
await query('UPDATE template_requests SET thumbnail_src_snapshot = ?, items_json = ?, board_items_json = ?, updated_at = ? WHERE id = ?', [
nextThumbnail || '',
serializeJson(replacedItems.items),
serializeJson(replacedBoardItems.items),
now(),
row.id,
])
@@ -1629,19 +1660,24 @@ async function createTemplateRequest({
id,
type,
requesterId,
sourceTierListId,
sourceTierListId = '',
sourceGameId,
targetGameId = '',
title,
description = '',
thumbnailSrc = '',
items = [],
groups = [],
boardItems = [],
showCharacterNames = false,
}) {
const existing = await findPendingTemplateRequestByTierList({ sourceTierListId, type })
if (existing) {
const err = new Error('template_request_exists')
err.code = 'TEMPLATE_REQUEST_EXISTS'
throw err
if (sourceTierListId) {
const existing = await findPendingTemplateRequestByTierList({ sourceTierListId, type })
if (existing) {
const err = new Error('template_request_exists')
err.code = 'TEMPLATE_REQUEST_EXISTS'
throw err
}
}
const createdAt = now()
@@ -1659,22 +1695,28 @@ async function createTemplateRequest({
description_snapshot,
thumbnail_src_snapshot,
items_json,
groups_json,
board_items_json,
show_character_names_snapshot,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[
id,
type,
requesterId,
sourceTierListId,
sourceTierListId || null,
sourceGameId,
targetGameId,
title,
description,
thumbnailSrc,
serializeJson(items),
serializeJson(groups),
serializeJson(boardItems),
showCharacterNames ? 1 : 0,
createdAt,
createdAt,
]
@@ -1697,6 +1739,9 @@ async function findTemplateRequestById(id) {
tr.description_snapshot,
tr.thumbnail_src_snapshot,
tr.items_json,
tr.groups_json,
tr.board_items_json,
tr.show_character_names_snapshot,
tr.created_at,
tr.updated_at,
u.nickname,
@@ -1732,6 +1777,9 @@ async function listAdminTemplateRequests({ status = 'pending' } = {}) {
tr.description_snapshot,
tr.thumbnail_src_snapshot,
tr.items_json,
tr.groups_json,
tr.board_items_json,
tr.show_character_names_snapshot,
tr.created_at,
tr.updated_at,
u.nickname,

View File

@@ -565,12 +565,13 @@ router.get('/users', requireAdmin, async (req, res) => {
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
sort: z.enum(['recent', 'created', 'tierlists']).optional().default('recent'),
direction: z.enum(['asc', 'desc']).optional().default('desc'),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const [users, primaryAdmin] = await Promise.all([
listUsers({ queryText: parsed.data.q, sort: parsed.data.sort }),
listUsers({ queryText: parsed.data.q, sort: parsed.data.sort, direction: parsed.data.direction }),
findPrimaryAdminUser(),
])
res.json({ users: users.map((user) => decorateAdminUser(user, primaryAdmin)) })

View File

@@ -26,6 +26,20 @@ const profileSchema = z.object({
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
})
function establishSession(req, user) {
return new Promise((resolve, reject) => {
req.session.regenerate((regenerateError) => {
if (regenerateError) return reject(regenerateError)
req.session.userId = user.id
req.session.isAdmin = !!user.isAdmin
req.session.save((saveError) => {
if (saveError) return reject(saveError)
resolve()
})
})
})
}
async function serializeUser(user) {
if (!user) return null
const primaryAdmin = await findPrimaryAdminUser()
@@ -56,12 +70,12 @@ router.post('/signup', async (req, res) => {
const isAdmin = (await countUsers()) === 0
const user = await createUser({ id: nanoid(), email, nickname: '', passwordHash, isAdmin })
req.session.userId = user.id
req.session.isAdmin = !!user.isAdmin
req.session.save(async (err) => {
if (err) return res.status(500).json({ error: 'session_save_failed' })
try {
await establishSession(req, user)
res.json(await serializeUser(user))
})
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
})
router.post('/login', async (req, res) => {
@@ -75,12 +89,12 @@ router.post('/login', async (req, res) => {
const ok = await bcrypt.compare(password, user.passwordHash)
if (!ok) return res.status(401).json({ error: 'invalid_credentials' })
req.session.userId = user.id
req.session.isAdmin = !!user.isAdmin
req.session.save(async (err) => {
if (err) return res.status(500).json({ error: 'session_save_failed' })
try {
await establishSession(req, user)
res.json(await serializeUser(user))
})
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
})
router.post('/logout', async (req, res) => {

View File

@@ -58,6 +58,33 @@ function getCustomTemplateItems(tierList) {
const upload = createMemoryUpload(multer, { fileSize: 6 * 1024 * 1024 })
const thumbnailUpload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024 })
const templateRequestSchema = z.object({
type: z.enum(['create', 'update']),
sourceTierListId: z.string().max(64).optional().default(''),
gameId: z.string().min(1).max(120),
requestTitle: z.string().trim().min(1).max(120),
requestDescription: z.string().trim().min(1).max(1000),
thumbnailSrc: z.string().max(255).optional().default(''),
isPublic: z.boolean().optional().default(false),
showCharacterNames: z.boolean().optional().default(false),
saveToMyTierList: z.boolean().optional().default(true),
groups: z.array(
z.object({
id: z.string().min(1),
name: z.string().min(1).max(16),
itemIds: z.array(z.string()).optional().default([]),
}).passthrough()
),
boardItems: z.array(
z.object({
id: z.string().min(1),
src: z.string().min(1),
label: z.string().min(1).max(60),
origin: z.enum(['game', 'custom']).default('game'),
})
),
})
const tierListUpsertSchema = z.object({
id: z.string().optional(),
gameId: z.string().min(1),
@@ -73,8 +100,8 @@ const tierListUpsertSchema = z.object({
z.object({
id: z.string().min(1),
name: z.string().min(1).max(16),
itemIds: z.array(z.string()),
})
itemIds: z.array(z.string()).optional().default([]),
}).passthrough()
),
pool: z.array(
z.object({
@@ -194,42 +221,64 @@ router.post('/thumbnail', requireAuth, thumbnailUpload.single('thumbnail'), asyn
res.json({ thumbnailSrc: optimized.src })
})
router.post('/:id/template-request', requireAuth, async (req, res) => {
const schema = z.object({
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)
router.post('/template-request', requireAuth, async (req, res) => {
const parsed = templateRequestSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const tierList = await findTierListById(req.params.id, req.session.userId)
if (!tierList) return res.status(404).json({ error: 'not_found' })
if (tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
const customItems = getCustomTemplateItems(tierList)
const payload = parsed.data
const normalizedBoardItems = payload.boardItems.map(normalizePoolItem)
const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom')
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
if (parsed.data.type === 'create') {
if (tierList.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
} else {
if (tierList.gameId === FREEFORM_GAME_ID) return res.status(400).json({ error: 'game_template_required' })
if (payload.type === 'create') {
if (payload.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
} else if (payload.gameId === FREEFORM_GAME_ID) {
return res.status(400).json({ error: 'game_template_required' })
}
let sourceTierList = null
if (payload.sourceTierListId) {
sourceTierList = await findTierListById(payload.sourceTierListId, req.session.userId)
if (!sourceTierList) return res.status(404).json({ error: 'not_found' })
if (sourceTierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
}
let savedTierList = null
if (payload.saveToMyTierList) {
savedTierList = await saveTierList({
id: sourceTierList?.id || undefined,
authorId: req.session.userId,
gameId: payload.gameId,
title: payload.requestTitle,
thumbnailSrc: payload.thumbnailSrc || '',
description: payload.requestDescription || '',
isPublic: !!payload.isPublic,
showCharacterNames: !!payload.showCharacterNames,
sourceTierListId: sourceTierList?.sourceTierListId || '',
sourceSnapshotTitle: sourceTierList?.sourceSnapshotTitle || '',
sourceSnapshotAuthor: sourceTierList?.sourceSnapshotAuthor || '',
groups: payload.groups,
pool: normalizedBoardItems,
})
}
try {
const request = await createTemplateRequest({
id: nanoid(),
type: parsed.data.type,
type: payload.type,
requesterId: req.session.userId,
sourceTierListId: tierList.id,
sourceGameId: tierList.gameId,
targetGameId: parsed.data.type === 'update' ? tierList.gameId : '',
title: parsed.data.requestTitle,
description: parsed.data.requestDescription,
thumbnailSrc: tierList.thumbnailSrc || '',
sourceTierListId: savedTierList?.id || sourceTierList?.id || '',
sourceGameId: payload.gameId,
targetGameId: payload.type === 'update' ? payload.gameId : '',
title: payload.requestTitle,
description: payload.requestDescription,
thumbnailSrc: payload.thumbnailSrc || '',
items: customItems,
groups: payload.groups,
boardItems: normalizedBoardItems,
showCharacterNames: !!payload.showCharacterNames,
})
return res.json({ request })
return res.json({ request, savedTierList: savedTierList ? normalizeTierList(savedTierList) : null })
} catch (e) {
if (e?.code === 'TEMPLATE_REQUEST_EXISTS') {
return res.status(409).json({ error: 'template_request_exists' })

View File

@@ -1,21 +1,10 @@
# 할 일 및 이슈
## 즉시 확인 필요
- 티어표 형식 추가 필요. 최근 게임들은 S, A, B,C 같은 랭크 뿐만 아니라 가로 열도 나누어진형태의 티어표를 원함 (공격, 방어, 지원 등 각 파트별 랭크를 보고싶어함)
- 레거시 파일 정리 스크립트는 준비됐으므로, 운영 단계에서는 cron 등으로 주기 실행할지와 삭제 전 보관 기간을 함께 정한다.
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다.
- 이미지 최적화 기록은 월별 조회/비우기까지 지원하므로, 운영 단계에서는 보관 기간 정책과 자동 아카이브 기준을 정한다.
## 배포 전 작업
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
- MariaDB 접속 정보 `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`를 설정한다.
- HTTPS를 사용할 경우 `SESSION_COOKIE_SECURE=true`로 설정하고 리버스 프록시 헤더 전달을 확인한다.
- `backend/uploads/`, `backend/.sessions/`, MariaDB 백업 정책을 정한다.
- 로컬 docker compose와 NAS MariaDB 사이의 버전 차이가 크지 않도록 유지한다.
## 중기 개선
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
- 로그인/회원가입/관리자 비밀번호 초기화에 요청 횟수 제한을 추가한다.
- 업로드 파일은 MIME 타입뿐 아니라 파일 시그니처 기반 검증까지 확장한다.
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.

View File

@@ -1,5 +1,68 @@
# 업데이트 로그
## 2026-04-01 v1.3.26
- 오른쪽 사이드는 실제 광고 슬롯 기준을 300x600 세로 비율로 잡고, 데스크톱 우측 레일 폭도 325px로 조정해 300px 광고가 내부 패딩과 보더를 제외한 실폭 안에 자연스럽게 들어가도록 보정함.
## 2026-04-01 v1.3.25
- todo 문서에서는 운영 정책/배포 체크 성격 항목을 우선 제거하고, 제품/보안 후속 작업 중심으로 다시 정리함.
- 관리자 게임 관리는 우측 셀렉트 박스 대신 검색 가능한 리스트와 최신순/오래된순 정렬로 바꿔, 게임 수가 많아져도 실제로 선택 가능한 구조로 개선함.
- 로그인과 회원가입은 기존 세션을 그대로 덮어쓰지 않고 세션을 재생성한 뒤 사용자 정보를 저장하도록 바꿔, 세션 고정 공격 방어를 보강함.
## 2026-04-01 v1.3.24
- 게임 선택 후 보이는 공개 티어표 목록 그리드도 auto-fit 최대폭 방식 대신 4/3/2/1열 고정 반응형 규칙으로 바꿔, 넓은 화면에서 카드 한 장이 애매하게 다음 줄로 넘어가며 공백이 크게 남던 문제를 줄임.
## 2026-04-01 v1.3.23
- 내 티어표 목록 그리드는 auto-fit 최대폭 방식 대신 게임 목록과 같은 4/3/2/1열 고정 반응형 규칙으로 맞춰, 넓은 화면에서 카드 한 장이 애매하게 다음 줄로 떨어지며 여백이 크게 남던 문제를 줄임.
## 2026-04-01 v1.3.22
- 내 티어표 카드는 게임 목록과 같은 상단 히어로/패널 문법으로 다시 맞추고, 깨진 썸네일은 alt 텍스트가 카드 폭을 밀지 않도록 플레이스홀더로 즉시 대체해 카드 수와 헤더 폭이 흔들리지 않게 보정함.
- 오른쪽 사이드 광고 프레임은 별도 보더·패딩·배경을 제거해, 광고 자체가 가진 각진 형태와 색이 그대로 보이도록 더 담백하게 정리함.
## 2026-04-01 v1.3.21
- 내 티어표 카드는 게임 목록 화면과 같은 카드 폭/헤더/메타 배치 문법으로 맞춰, 화면 간 카드 크기와 정보 정렬이 더 통일된 인상으로 보이도록 정리함.
## 2026-04-01 v1.3.20
- 내 티어표 카드 그리드는 카드 최대폭 우선 규칙 대신 더 촘촘한 auto-fill 기준으로 조정해, 넓은 화면에서도 한 줄에 더 많은 카드가 자연스럽게 배치되도록 보정함.
## 2026-04-01 v1.3.19
- 관리자 Image Optimization 기간 선택은 연도/월을 가로로 나란히 두고, 연도를 고르기 전에는 월 셀렉트를 숨겨 비어 있는 박스처럼 보이던 상태를 없앰.
- 전체 초기화 버튼도 실제 월이 선택된 경우에만 보이도록 정리해, 사이드바 상단 필터 줄이 더 단정하게 보이도록 보정함.
## 2026-04-01 v1.3.18
- 커스텀 아이템 기본 이름은 파일명 전체를 그대로 쓰지 않고 확장자 제거·공백 정리·60자 제한을 먼저 적용하도록 바꿔, 템플릿 요청 전에 커스텀 업로드가 길이 제한으로 실패하던 흐름을 줄임.
- 템플릿 요청 실패 안내는 커스텀 이미지 업로드 실패와 일반 bad request를 구분해, 사용자가 제목/설명/아이템 이름 길이 제한 문제를 더 쉽게 파악할 수 있게 보강함.
- 관리자 Image Optimization 월 필터는 기본 month input 대신 연도/월 셀렉트와 전체 초기화 버튼으로 바꿔, 기간 선택을 더 직관적으로 조작할 수 있게 정리함.
## 2026-04-01 v1.3.17
- 티어 에디터 열 헤더 입력창과 행 라벨은 좌우 패딩을 대칭으로 다시 잡아, 드래그 핸들과 삭제 아이콘이 있어도 제목이 한쪽으로 쏠려 보이지 않도록 보정함.
- 열 삭제도 이제 행 삭제와 같은 확인 모달을 거쳐 진행되도록 바꿔, 실수로 즉시 제거되던 문제를 막음.
- 내보내기 보드는 여전히 960px 고정 폭이라 열 수가 늘수록 각 칸 폭이 줄어드는 구조라는 점을 기준으로 정리했고, 현재 보정은 헤더 정렬 문제를 우선 해결하는 쪽에 맞춤.
## 2026-04-01 v1.3.16
- 티어 에디터의 행 삭제와 열 삭제는 다시 작은 X 아이콘 액션으로 정리해, 행/열 이름 주변의 반복 텍스트 때문에 보드가 답답해 보이던 문제를 줄임.
- 열 헤더 편집 영역은 입력창 오른쪽에 아이콘 삭제만 남기고, 행 라벨도 상단 우측의 작은 제거 버튼으로 맞춰 더 압축된 편집 밀도를 유지하도록 조정함.
- 저장 이미지에서 열 제목이 살짝 위로 떠 보이던 문제는 내보내기 헤더의 비대칭 패딩을 제거하고 flex 중앙 정렬로 바꿔, 시각적으로 정확한 중앙에 오도록 보정함.
## 2026-04-01 v1.3.15
- 티어 에디터의 열 이름은 각 행 안에서 반복 렌더링되지 않도록 공통 상단 헤더로 분리해, 행 제목과 같은 구조로 더 또렷하게 구분되도록 수정함.
- 행 추가/열 추가 액션은 새 SVG 아이콘 버튼으로 압축해, 텍스트 때문에 보드 상단 툴바 높이가 과하게 커지던 문제를 정리함.
- 미리보기와 삭제 모달 문구도 행/열 기준으로 함께 정리해, 전체 티어 에디터 흐름을 더 일관된 용어와 레이아웃으로 다듬음.
## 2026-04-01 v1.3.14
- 티어 에디터를 단일 세로 랭크형에서 행/열 혼합 보드로 확장해, 공격·방어·지원 같은 가로 열을 추가하고 각 열 이름도 직접 입력할 수 있게 함.
- 에디터 액션 문구를 `행 추가 / 열 추가` 기준으로 정리하고, 행 라벨 폭과 드래그 아이콘 위치를 다듬어 실제 사용 빈도에 맞는 더 압축된 보드 레이아웃으로 보정함.
- 이름 오버레이 정렬과 저장용 미리보기 보드도 함께 손봐서, 이미지 다운로드 시 라벨 텍스트가 하단 중앙에 더 안정적으로 배치되도록 수정함.
## 2026-04-01 v1.3.13
- 템플릿 등록/업데이트 요청 모달은 이제 현재 티어표 제목·설명을 기본값으로 가져오고, 비어 있더라도 모달 안에서 바로 작성해 요청할 수 있도록 흐름을 단순화함.
- 템플릿 요청 시 `내 티어 리스트에도 저장` 토글을 추가해, 요청 스냅샷만 관리자에게 전달할지 아니면 현재 양식도 내 티어표로 함께 저장할지 분리함.
- 관리자 템플릿 요청 관리는 더 이상 원본 티어표 링크에 의존하지 않고, 요청 시점의 그룹/아이템/이름표시 상태를 그대로 담은 스냅샷 미리보기를 직접 열어 확인할 수 있게 확장함.
## 2026-04-01 v1.3.12
- 관리자 회원 관리 상단에 정렬 방향 선택을 추가해, 최근 활동순·가입순·작성 티어표순을 각각 오름차순/내림차순으로 다시 볼 수 있게 확장함.
- 회원 정보 수정, 새 게임 생성, 비밀번호 초기화 모달은 Settings 톤 입력 스타일을 유지하면서 각 입력칸에 글자 수 피드백을 함께 보여주도록 정리함.
- 로그인, 설정, 티어 에디터 제목·설명·요청 제목·요청 설명·티어 행 이름에도 최대 길이와 현재 입력 길이 안내를 붙여, 제출 전에 제한을 바로 인지할 수 있게 개선함.
## 2026-04-01 v1.3.11
- **회원 관리 편집 모달 전환**: 관리자 회원 카드를 읽기 전용 정보 카드로 바꾸고, `회원 정보 수정` 버튼으로 Settings 톤의 편집 모달에서 이메일/닉네임/운영자 권한을 저장하도록 재구성
- **회원 검색/정렬 추가**: 회원 관리 상단에 이메일/닉네임 검색과 `최근 활동순`, `가입순`, `작성 티어표 많은 순` 정렬을 추가해 운영자가 원하는 기준으로 목록을 다시 볼 수 있도록 확장

View File

@@ -44,7 +44,7 @@ const accountName = computed(() => {
const accountEmail = computed(() => (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.')
const shellStyle = computed(() => ({
'--left-rail-width': leftRailCollapsed.value ? '76px' : '248px',
'--right-rail-width': !isRightRailOverlay.value && rightRailOpen.value ? '320px' : '0px',
'--right-rail-width': !isRightRailOverlay.value && rightRailOpen.value ? '325px' : '0px',
}))
const leftNavItems = computed(() => {
const items = [
@@ -436,7 +436,7 @@ function submitGlobalSearch() {
.appShell {
min-height: 100dvh;
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, 325px);
background: rgba(14, 14, 14, 0.96);
color: rgba(255, 255, 255, 0.92);
transition: grid-template-columns 220ms ease;

View 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="M160-760v560h240v-560H160ZM80-120v-720h720v160h-80v-80H480v560h240v-80h80v160H80Zm400-360Zm-80 0h80-80Zm0 0Zm320 120v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 283 B

View 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="M200-560h560v-240H200v240Zm-80 400v-720h720v720H680v-80h80v-240H200v240h80v80H120Zm360-320Zm0-80v80-80Zm0 0ZM440-80v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 282 B

View File

@@ -77,15 +77,16 @@ onMounted(async () => {
}
.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);
width: min(100%, 300px);
min-height: 600px;
margin: 0 auto;
}
.rightRailAd__slot {
width: 100%;
min-height: 490px;
display: block;
width: 300px;
max-width: 100%;
min-height: 600px;
margin: 0 auto;
}
</style>

View File

@@ -62,8 +62,8 @@ export const api = {
approveAdminTemplateRequest: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/approve`, { method: 'POST', body: payload || {} }),
rejectAdminTemplateRequest: (requestId) => request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/reject`, { method: 'POST', body: {} }),
listAdminUsers: ({ q = '', sort = 'recent' } = {}) =>
request(`/api/admin/users?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}`),
listAdminUsers: ({ q = '', sort = 'recent', direction = 'desc' } = {}) =>
request(`/api/admin/users?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}&direction=${encodeURIComponent(direction)}`),
updateAdminUser: (userId, payload) =>
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
updateAdminUserPassword: (userId, payload) =>
@@ -101,7 +101,7 @@ export const api = {
unfavoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'DELETE' }),
deleteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`, { method: 'DELETE' }),
duplicateTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/duplicate`, { method: 'POST' }),
requestTierListTemplate: (id, payload) => request(`/api/tierlists/${encodeURIComponent(id)}/template-request`, { method: 'POST', body: payload }),
requestTierListTemplate: (payload) => request('/api/tierlists/template-request', { method: 'POST', body: payload }),
saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }),
uploadTierListThumbnail: async (file) => {
const fd = new FormData()

View File

@@ -22,6 +22,8 @@ const games = ref([])
const selectedGameId = ref('')
const selectedGame = ref(null)
const featuredGameIds = ref([])
const gameAdminQuery = ref('')
const gameAdminSort = ref('recent')
const customItems = ref([])
const customItemQuery = ref('')
@@ -64,6 +66,7 @@ const modalTargetCustomItem = ref(null)
const users = ref([])
const userQuery = ref('')
const userSort = ref('recent')
const userSortDirection = ref('desc')
const imageStats = ref(null)
const imageQueue = ref({ concurrency: 1, activeCount: 0, pendingCount: 0 })
const imageRecentJobs = ref([])
@@ -103,6 +106,19 @@ const featuredGames = computed(() =>
.filter(Boolean)
)
const availableGamesForFeatured = computed(() => games.value.filter((game) => !featuredGameIds.value.includes(game.id)))
const filteredAdminGames = computed(() => {
const query = gameAdminQuery.value.trim().toLowerCase()
const list = games.value.filter((game) => {
if (!query) return true
const haystack = `${game.name || ''} ${game.id || ''}`.toLowerCase()
return haystack.includes(query)
})
return list.slice().sort((a, b) => {
if (gameAdminSort.value === 'oldest') return Number(a.createdAt || 0) - Number(b.createdAt || 0)
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
})
})
const importModalItemCount = computed(() => importModalItems.value.length)
const activeTabTitle = computed(() => {
if (activeTab.value === 'featured') return '목록 관리'
@@ -269,6 +285,49 @@ const visibleLinkedGames = computed(() =>
)
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
const imageStatsYearOptions = computed(() => {
const currentYear = new Date().getFullYear()
return Array.from({ length: 6 }, (_, index) => String(currentYear - index))
})
const imageStatsMonthOptions = [
{ value: '01', label: '1월' },
{ value: '02', label: '2월' },
{ value: '03', label: '3월' },
{ value: '04', label: '4월' },
{ value: '05', label: '5월' },
{ value: '06', label: '6월' },
{ value: '07', label: '7월' },
{ value: '08', label: '8월' },
{ value: '09', label: '9월' },
{ value: '10', label: '10월' },
{ value: '11', label: '11월' },
{ value: '12', label: '12월' },
]
const selectedImageStatsYear = computed({
get: () => (imageStatsMonth.value ? imageStatsMonth.value.slice(0, 4) : ''),
set: (year) => {
if (!year) {
imageStatsMonth.value = ''
return
}
const month = imageStatsMonth.value ? imageStatsMonth.value.slice(5, 7) : '01'
imageStatsMonth.value = `${year}-${month}`
},
})
const selectedImageStatsMonthNumber = computed({
get: () => (imageStatsMonth.value ? imageStatsMonth.value.slice(5, 7) : ''),
set: (month) => {
if (!month) {
imageStatsMonth.value = ''
return
}
const year = imageStatsMonth.value ? imageStatsMonth.value.slice(0, 4) : String(new Date().getFullYear())
imageStatsMonth.value = `${year}-${month}`
},
})
function clearImageStatsMonth() {
imageStatsMonth.value = ''
}
async function refreshImageDiagnostics() {
try {
@@ -334,6 +393,12 @@ async function handleSelectedGameChange(event) {
await loadGame()
}
async function selectAdminGame(gameId) {
if (!gameId || selectedGameId.value === gameId) return
selectedGameId.value = gameId
await loadGame()
}
async function refreshGames() {
try {
const data = await api.listGames()
@@ -515,7 +580,7 @@ async function refreshTemplateRequests() {
async function refreshUsers() {
if (!auth.user?.isAdmin) return
try {
const data = await api.listAdminUsers({ q: userQuery.value, sort: userSort.value })
const data = await api.listAdminUsers({ q: userQuery.value, sort: userSort.value, direction: userSortDirection.value })
users.value = (data.users || []).map((user) => ({
...user,
isAvatarBusy: false,
@@ -1064,6 +1129,38 @@ function openAdminTierList(tierList) {
previewModalOpen.value = true
}
function previewRequestItemsById(preview) {
const items = Array.isArray(preview?.snapshotItems) ? preview.snapshotItems : []
return items.reduce((acc, item) => {
if (item?.id) acc[item.id] = item
return acc
}, {})
}
function previewRequestGroupItems(preview, group) {
const itemsById = previewRequestItemsById(preview)
return (group?.itemIds || []).map((itemId) => itemsById[itemId]).filter(Boolean)
}
function previewRequestPoolItems(preview) {
const groupedIds = new Set((preview?.snapshotGroups || []).flatMap((group) => group.itemIds || []))
return (preview?.snapshotItems || []).filter((item) => !groupedIds.has(item.id))
}
function openTemplateRequestPreview(request) {
previewTierList.value = {
id: request.id,
title: request.sourceTierListTitle || '템플릿 요청 미리보기',
description: request.sourceDescription || '',
thumbnailSrc: request.thumbnailSrc || '',
requestPreview: true,
snapshotGroups: request.snapshotGroups || [],
snapshotItems: request.snapshotItems || [],
snapshotShowCharacterNames: !!request.snapshotShowCharacterNames,
}
previewModalOpen.value = true
}
function closePreviewModal() {
previewModalOpen.value = false
previewTierList.value = null
@@ -1469,13 +1566,14 @@ async function saveFeaturedOrder() {
<div class="templateRequestCard__head">
<div>
<div class="templateRequestCard__title">{{ request.sourceTierListTitle }}</div>
<div v-if="request.sourceDescription" class="templateRequestCard__desc">{{ request.sourceDescription }}</div>
<div class="templateRequestCard__meta">
{{ templateRequestTypeLabel(request) }} · {{ request.requesterName }} · {{ fmt(request.createdAt) }}
</div>
<div class="templateRequestCard__meta">{{ templateRequestTargetLabel(request) }}</div>
</div>
<button class="btn btn--ghost btn--small" @click="openAdminTierList({ id: request.sourceTierListId, gameId: request.sourceGameId })">
원본 보기
<button class="btn btn--ghost btn--small" @click="openTemplateRequestPreview(request)">
요청 미리보기
</button>
</div>
@@ -1589,6 +1687,10 @@ async function saveFeaturedOrder() {
<option value="created">가입순</option>
<option value="tierlists">작성 티어표 많은 </option>
</select>
<select v-model="userSortDirection" class="select toolbar__select" @change="submitUserFilters">
<option value="desc">내림차순</option>
<option value="asc">오름차순</option>
</select>
<button class="btn btn--ghost toolbar__button" type="button" @click="submitUserFilters">조회</button>
</div>
@@ -1658,8 +1760,22 @@ async function saveFeaturedOrder() {
<div class="modalCard__title"> 게임 만들기</div>
<div class="modalCard__desc">게임 이름과 고유 ID를 입력한 생성하면 바로 아래 상세 관리 화면으로 이어집니다.</div>
<div class="modalCard__form">
<input v-model="newGameName" class="input" placeholder="게임 이름" />
<input v-model="newGameId" class="input" placeholder="game id (영문/숫자)" @keydown.enter.prevent="createGame" />
<label class="field">
<span class="field__label">게임 이름</span>
<input v-model="newGameName" class="field__input" maxlength="60" placeholder="게임 이름" />
<span class="field__hint">{{ newGameName.length }}/60</span>
</label>
<label class="field">
<span class="field__label">게임 ID</span>
<input
v-model="newGameId"
class="field__input"
maxlength="120"
placeholder="game id (영문/숫자)"
@keydown.enter.prevent="createGame"
/>
<span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newGameId.length }}/120</span>
</label>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeGameCreateModal">취소</button>
@@ -1675,14 +1791,14 @@ async function saveFeaturedOrder() {
<div class="userEditForm">
<label class="field">
<span class="field__label">이메일</span>
<input v-model="modalUserDraftEmail" class="field__input" placeholder="계정 이메일" />
<span class="field__hint">로그인 계정으로 사용하는 이메일입니다.</span>
<input v-model="modalUserDraftEmail" class="field__input" maxlength="255" placeholder="계정 이메일" />
<span class="field__hint">로그인 계정으로 사용하는 이메일입니다. {{ modalUserDraftEmail.length }}/255</span>
</label>
<label class="field">
<span class="field__label">닉네임</span>
<input v-model="modalUserDraftNickname" class="field__input" placeholder="표시용 닉네임" />
<span class="field__hint">티어표 작성자명과 프로필에 표시됩니다.</span>
<input v-model="modalUserDraftNickname" class="field__input" maxlength="40" placeholder="표시용 닉네임" />
<span class="field__hint">티어표 작성자명과 프로필에 표시됩니다. {{ modalUserDraftNickname.length }}/40</span>
</label>
<button
@@ -1706,7 +1822,18 @@ async function saveFeaturedOrder() {
<div class="modalCard__title">비밀번호 초기화</div>
<div class="modalCard__desc">{{ modalTargetUser ? `${userDisplayName(modalTargetUser)} 계정에 설정할 새 비밀번호를 입력해주세요.` : '' }}</div>
<div class="modalCard__form">
<input v-model="modalPasswordDraft" class="input" type="password" placeholder="초기화할 비밀번호 입력" @keydown.enter.prevent="confirmUserPasswordReset" />
<label class="field">
<span class="field__label"> 비밀번호</span>
<input
v-model="modalPasswordDraft"
class="field__input"
type="password"
maxlength="120"
placeholder="초기화할 비밀번호 입력"
@keydown.enter.prevent="confirmUserPasswordReset"
/>
<span class="field__hint">6~120 권장 · {{ modalPasswordDraft.length }}/120</span>
</label>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeUserPasswordModal">취소</button>
@@ -1856,8 +1983,45 @@ async function saveFeaturedOrder() {
<div class="modalCard__title">{{ previewTierList?.title || '티어표 미리보기' }}</div>
<button class="btn btn--ghost btn--small" @click="closePreviewModal">닫기</button>
</div>
<div v-if="previewTierList?.requestPreview" class="requestPreview">
<img
v-if="previewTierList.thumbnailSrc"
class="requestPreview__thumb"
:src="toApiUrl(previewTierList.thumbnailSrc)"
:alt="previewTierList.title"
/>
<div v-if="previewTierList.description" class="requestPreview__desc">{{ previewTierList.description }}</div>
<div class="requestPreview__rows">
<div v-for="group in previewTierList.snapshotGroups" :key="group.id" class="requestPreview__row">
<div class="requestPreview__rowLabel">{{ group.name }}</div>
<div class="requestPreview__rowItems">
<div
v-for="item in previewRequestGroupItems(previewTierList, group)"
:key="item.id"
class="requestPreview__item"
>
<img class="requestPreview__itemThumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div v-if="previewTierList.snapshotShowCharacterNames" class="requestPreview__itemLabel">{{ item.label }}</div>
</div>
</div>
</div>
</div>
<div v-if="previewRequestPoolItems(previewTierList).length" class="requestPreview__pool">
<div class="requestPreview__poolLabel">남은 아이템</div>
<div class="requestPreview__rowItems">
<div
v-for="item in previewRequestPoolItems(previewTierList)"
:key="item.id"
class="requestPreview__item requestPreview__item--muted"
>
<img class="requestPreview__itemThumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div v-if="previewTierList.snapshotShowCharacterNames" class="requestPreview__itemLabel">{{ item.label }}</div>
</div>
</div>
</div>
</div>
<iframe
v-if="previewTierList"
v-else-if="previewTierList"
class="previewFrame"
:src="previewTierListUrl(previewTierList)"
title="티어표 미리보기"
@@ -1887,10 +2051,25 @@ async function saveFeaturedOrder() {
<div class="adminSidebar__label">Game</div>
<div class="adminSidebar__group">
<button class="btn btn--primary" @click="openGameCreateModal"> 게임 생성</button>
<select :value="selectedGameId" class="select" @change="handleSelectedGameChange">
<option value="">게임을 선택해주세요</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
<input v-model="gameAdminQuery" class="input" placeholder="게임 이름 또는 ID 검색" />
<select v-model="gameAdminSort" class="select">
<option value="recent">최신순</option>
<option value="oldest">오래된순</option>
</select>
<div class="adminGamePicker">
<button
v-for="game in filteredAdminGames"
:key="game.id"
class="adminGamePicker__item"
:class="{ 'adminGamePicker__item--active': selectedGameId === game.id }"
type="button"
@click="selectAdminGame(game.id)"
>
<span class="adminGamePicker__name">{{ game.name }}</span>
<span class="adminGamePicker__meta">{{ game.id }}</span>
</button>
<div v-if="!filteredAdminGames.length" class="hint hint--tight">검색 결과가 없어요.</div>
</div>
<div v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 게임 ID: {{ selectedGameId }}</div>
</div>
<div v-if="hasSelectedGame" class="adminSidebar__group">
@@ -2000,8 +2179,18 @@ async function saveFeaturedOrder() {
<section v-if="activeTab === 'featured'" class="adminSidebar__panel">
<div class="adminSidebar__label">Image Optimization</div>
<div class="adminSidebar__group">
<input v-model="imageStatsMonth" class="input" type="month" />
<div class="adminSidebar__group adminSidebar__group--monthPicker">
<div class="monthPicker">
<select v-model="selectedImageStatsYear" class="select monthPicker__select monthPicker__select--year">
<option value="">전체 기간</option>
<option v-for="year in imageStatsYearOptions" :key="year" :value="year">{{ year }}</option>
</select>
<select v-if="selectedImageStatsYear" v-model="selectedImageStatsMonthNumber" class="select monthPicker__select monthPicker__select--month">
<option value=""> 선택</option>
<option v-for="month in imageStatsMonthOptions" :key="month.value" :value="month.value">{{ month.label }}</option>
</select>
<button v-if="imageStatsMonth" class="btn btn--ghost btn--tiny" type="button" @click="clearImageStatsMonth">전체</button>
</div>
<select v-model.number="imageStatsLimit" class="select">
<option :value="6">최근 6</option>
<option :value="12">최근 12</option>
@@ -2188,6 +2377,24 @@ async function saveFeaturedOrder() {
display: grid;
gap: 10px;
}
.adminSidebar__group--monthPicker {
align-items: start;
}
.monthPicker {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.monthPicker__select {
min-width: 0;
}
.monthPicker__select--year {
flex: 1 1 132px;
}
.monthPicker__select--month {
flex: 1 1 108px;
}
.adminSidebar__actions--stack .btn {
width: 100%;
}
@@ -2199,6 +2406,39 @@ async function saveFeaturedOrder() {
font-weight: 800;
color: rgba(255, 255, 255, 0.84);
}
.adminGamePicker {
display: grid;
gap: 8px;
max-height: 320px;
overflow: auto;
padding-right: 4px;
}
.adminGamePicker__item {
display: grid;
gap: 2px;
padding: 11px 12px;
text-align: left;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
}
.adminGamePicker__item--active {
border-color: rgba(77, 127, 233, 0.58);
background: rgba(77, 127, 233, 0.12);
}
.adminGamePicker__name {
font-size: 13px;
font-weight: 800;
}
.adminGamePicker__meta {
font-size: 11px;
color: rgba(255, 255, 255, 0.56);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebarStat {
display: grid;
gap: 4px;
@@ -2528,11 +2768,11 @@ async function saveFeaturedOrder() {
border: 0;
}
.btn {
height: 100%;
font-size: 12px;
line-height: 1.2;
white-space: nowrap;
word-break: keep-all;
margin-top: 12px;
padding: 11px 13px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.14);
@@ -3110,6 +3350,7 @@ async function saveFeaturedOrder() {
.userCard__actions--compact {
grid-template-columns: auto auto minmax(0, 1fr);
align-items: center;
margin-top: 12px;
}
.roleBadge {
width: fit-content;
@@ -3193,6 +3434,12 @@ async function saveFeaturedOrder() {
font-weight: 900;
font-size: 18px;
}
.templateRequestCard__desc {
margin-top: 6px;
color: rgba(255, 255, 255, 0.74);
line-height: 1.55;
white-space: pre-line;
}
.templateRequestCard__meta {
margin-top: 4px;
font-size: 13px;
@@ -3241,6 +3488,76 @@ async function saveFeaturedOrder() {
justify-content: flex-end;
flex-wrap: wrap;
}
.requestPreview {
display: grid;
gap: 18px;
}
.requestPreview__thumb {
width: 100%;
max-height: 240px;
object-fit: cover;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
}
.requestPreview__desc {
color: rgba(255, 255, 255, 0.74);
line-height: 1.6;
white-space: pre-line;
}
.requestPreview__rows,
.requestPreview__pool {
display: grid;
gap: 12px;
}
.requestPreview__row {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.requestPreview__rowLabel,
.requestPreview__poolLabel {
font-size: 13px;
font-weight: 800;
color: rgba(255, 255, 255, 0.86);
}
.requestPreview__rowItems {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 10px;
}
.requestPreview__item {
position: relative;
overflow: hidden;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
min-height: 72px;
}
.requestPreview__item--muted {
opacity: 0.52;
filter: grayscale(0.2) brightness(0.78);
}
.requestPreview__itemThumb {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
display: block;
}
.requestPreview__itemLabel {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
padding: 4px 6px;
border-radius: 8px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.02), rgba(0, 0, 0, 0.7));
font-size: 11px;
font-weight: 700;
text-align: center;
line-height: 1.3;
}
.tierAdminList {
margin-top: 14px;
display: grid;

View File

@@ -222,8 +222,7 @@ function submitSearch() {
}
.list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 320px));
justify-content: start;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}

View File

@@ -79,8 +79,8 @@ async function submit() {
<form class="authFields" @submit.prevent="submit">
<label class="field">
<span class="field__label">이메일</span>
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" />
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다.</span>
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" maxlength="255" />
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다. {{ email.length }}/255</span>
</label>
<label class="field">
@@ -91,8 +91,9 @@ async function submit() {
type="password"
placeholder="********"
autocomplete="current-password"
maxlength="120"
/>
<span class="field__hint">8 이상으로 설정하면 안전하게 사용할 있어요.</span>
<span class="field__hint">6~120 입력 가능 · {{ password.length }}/120</span>
</label>
<label v-if="mode === 'signup'" class="field">
@@ -103,8 +104,9 @@ async function submit() {
type="password"
placeholder="********"
autocomplete="new-password"
maxlength="120"
/>
<span class="field__hint">같은 비밀번호를 입력해주세요.</span>
<span class="field__hint">같은 비밀번호를 입력해주세요. {{ passwordConfirm.length }}/120</span>
</label>
<div v-if="!hasUsers" class="roleBadge"> 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>

View File

@@ -9,6 +9,7 @@ const router = useRouter()
const toast = useToast()
const myLists = ref([])
const error = ref('')
const brokenThumbnailIds = ref({})
watch(error, (message) => {
if (!message) return
@@ -37,12 +38,19 @@ function avatarFallbackOf(tierList) {
}
function tierListThumbnailUrl(tierList) {
if (!tierList?.id || brokenThumbnailIds.value[tierList.id]) return ''
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
}
function handleThumbnailError(tierListId) {
if (!tierListId || brokenThumbnailIds.value[tierListId]) return
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
}
onMounted(async () => {
try {
const data = await api.listMyTierLists()
brokenThumbnailIds.value = {}
myLists.value = data.tierLists || []
} catch (e) {
toast.error('로그인이 필요해요.')
@@ -51,53 +59,87 @@ onMounted(async () => {
})
function openList(t) {
router.push(`/editor/${t.gameId}/${t.id}`)
router.push(
"/editor/" + t.gameId + "/" + t.id,
)
}
</script>
<template>
<section class="pageWrap">
<header class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Library</div>
<h2 class="pageHead__title"> 티어표</h2>
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 있어요.</div>
</div>
</header>
<div class="card">
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
<div v-else class="list">
<article v-for="t in myLists" :key="t.id" class="boardCard">
<button class="boardCard__body" @click="openList(t)">
<div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
<section class="dashboardHero">
<div class="dashboardHero__left">
<div class="dashboardHero__eyebrow">Library</div>
<h2 class="dashboardHero__title"> 티어표</h2>
<p class="dashboardHero__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 있어요.</p>
</div>
</section>
<section class="panel">
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
<div v-else class="list">
<article v-for="t in myLists" :key="t.id" class="boardCard">
<button class="boardCard__body" @click="openList(t)">
<div class="boardCard__thumbWrap">
<img
v-if="tierListThumbnailUrl(t)"
class="boardCard__thumb"
:src="tierListThumbnailUrl(t)"
alt=""
@error="handleThumbnailError(t.id)"
/>
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ t.title }}</div>
<div class="favoriteStat"> {{ t.favoriteCount || 0 }}</div>
</div>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ t.title }}</div>
<div class="favoriteStat"> {{ t.favoriteCount || 0 }}</div>
</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
</div>
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
</div>
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
</div>
</button>
</article>
</div>
</div>
</button>
</article>
</div>
</section>
</template>
<style scoped>
.card {
border: 0;
.dashboardHero {
display: flex;
gap: 18px;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
padding: 6px 2px 18px;
}
.dashboardHero__left {
display: grid;
gap: 8px;
}
.dashboardHero__eyebrow {
font-size: 12px;
color: rgba(255, 255, 255, 0.42);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.dashboardHero__title {
margin: 4px 0 6px;
font-size: 32px;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
}
.dashboardHero__desc {
margin: 0;
color: rgba(255, 255, 255, 0.58);
max-width: 720px;
}
.panel {
background: transparent;
border-radius: 0;
padding: 0;
@@ -107,12 +149,10 @@ function openList(t) {
}
.list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 320px));
justify-content: start;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.boardCard {
display: grid;
min-width: 0;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
@@ -129,7 +169,6 @@ function openList(t) {
background: rgba(70, 70, 70, 0.96);
}
.boardCard__body {
flex: 1 1 auto;
min-width: 0;
text-align: left;
cursor: pointer;
@@ -137,9 +176,12 @@ function openList(t) {
background: transparent;
color: inherit;
padding: 0;
width: 100%;
display: grid;
overflow: hidden;
}
.boardCard__thumbWrap {
min-width: 0;
width: 100%;
aspect-ratio: 16 / 9;
padding: 14px 14px 0;
@@ -164,46 +206,46 @@ function openList(t) {
border-radius: 18px;
}
.boardCard__title {
flex: 1 1 auto;
font-weight: 800;
min-width: 0;
font-weight: 900;
font-size: 18px;
line-height: 1.3;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
.boardCard__head {
min-width: 0;
padding: 16px 18px 18px;
display: grid;
gap: 8px;
min-width: 0;
overflow: hidden;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 10px;
min-width: 0;
align-items: center;
justify-content: space-between;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.boardCard__titleRow {
align-items: flex-start;
}
.boardCard__metaRow {
align-items: flex-end;
}
.boardCard__author {
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
display: inline-flex;
gap: 7px;
align-items: center;
font-size: 13px;
opacity: 0.84;
opacity: 0.86;
overflow: hidden;
}
.boardCard__authorName {
min-width: 0;
@@ -229,14 +271,28 @@ function openList(t) {
.boardCard__date,
.favoriteStat {
flex: 0 0 auto;
min-width: 0;
max-width: 100%;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__date {
font-size: 10px;
}
@media (max-width: 720px) {
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1200px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.list {
grid-template-columns: 1fr;
}

View File

@@ -156,8 +156,8 @@ async function logout() {
<div class="settingsFields">
<label class="field">
<span class="field__label">닉네임</span>
<input v-model="nickname" class="field__input" placeholder="작성자 닉네임" />
<span class="field__hint">티어표 작성자 이름으로 표시됩니다.</span>
<input v-model="nickname" class="field__input" maxlength="40" placeholder="작성자 닉네임" />
<span class="field__hint">티어표 작성자 이름으로 표시됩니다. {{ nickname.length }}/40</span>
</label>
<label class="field">

View File

@@ -3,6 +3,9 @@ import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watc
import { useRoute, useRouter } from 'vue-router'
import Sortable from 'sortablejs'
import * as htmlToImage from 'html-to-image'
import SvgIcon from '../components/SvgIcon.vue'
import addColumnRightIcon from '../assets/icons/add_column_right.svg'
import addRowBelowIcon from '../assets/icons/add_row_below.svg'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth'
@@ -19,11 +22,12 @@ const tierListId = computed(() => route.params.tierListId)
const previewMode = computed(() => route.query.preview === '1')
const gameName = ref('')
const columns = ref([{ id: 'col-1', name: '' }])
const groups = ref([
{ id: 'gS', name: 'S', itemIds: [] },
{ id: 'gA', name: 'A', itemIds: [] },
{ id: 'gB', name: 'B', itemIds: [] },
{ id: 'gC', name: 'C', itemIds: [] },
{ id: 'gS', name: 'S', itemIds: [], cells: [[]] },
{ id: 'gA', name: 'A', itemIds: [], cells: [[]] },
{ id: 'gB', name: 'B', itemIds: [], cells: [[]] },
{ id: 'gC', name: 'C', itemIds: [], cells: [[]] },
])
const pool = ref([])
@@ -44,9 +48,12 @@ const isTemplateRequestModalOpen = ref(false)
const isTemplateUpdateModalOpen = ref(false)
const templateRequestDraftTitle = ref('')
const templateRequestDraftDescription = ref('')
const templateRequestSaveToMyTierList = ref(true)
const isDeleteModalOpen = ref(false)
const isGroupDeleteModalOpen = ref(false)
const isColumnDeleteModalOpen = ref(false)
const pendingRemoveGroupId = ref('')
const pendingRemoveColumnIndex = ref(-1)
const ownerId = ref('')
const authorName = ref('')
const authorAccountName = ref('')
@@ -119,14 +126,7 @@ const canRequestTemplateCreate = computed(
const canRequestTemplateUpdate = computed(
() => canEdit.value && !isNewTierList.value && gameId.value !== 'freeform' && customItems.value.length > 0
)
const templateRequestChecks = computed(() => [
{
id: 'title',
label: '티어표 이름(게임 이름)을 직접 입력했는지',
passed: !!(title.value || '').trim() && (title.value || '').trim() !== (gameName.value || '').trim(),
},
])
const canSubmitTemplateCreateRequest = computed(() => templateRequestChecks.value.every((item) => item.passed) && !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const templateRequestTargetLabel = computed(() => (gameId.value === 'freeform' ? '새로운 템플릿' : (gameName.value || gameId.value || '선택한 게임')))
@@ -160,38 +160,107 @@ function setIconSize(nextSize) {
iconSize.value = nextSize
}
function removeItemFromGroup(groupId, itemId) {
if (!canEdit.value || !groupId || !itemId) return
function getGroupCellIds(group, columnIndex) {
return Array.isArray(group?.cells?.[columnIndex]) ? group.cells[columnIndex] : []
}
function syncGroupItemIds(group) {
group.itemIds = (group.cells || []).flat()
}
function normalizeLoadedColumns(rawGroups) {
const fromGroup = Array.isArray(rawGroups) ? rawGroups.find((group) => Array.isArray(group?.columnNames) && group.columnNames.length) : null
const rawColumns = Array.isArray(fromGroup?.columnNames) ? fromGroup.columnNames : []
const cellCount = Math.max(1, ...(Array.isArray(rawGroups) ? rawGroups.map((group) => (Array.isArray(group?.cells) ? group.cells.length : 0)) : [0]))
const size = Math.max(rawColumns.length || 0, cellCount)
return Array.from({ length: size || 1 }, (_, index) => ({
id: rawColumns[index]?.id || `col-${index + 1}` ,
name: typeof rawColumns[index]?.name === 'string' ? rawColumns[index].name.slice(0, 16) : '',
}))
}
function normalizeLoadedGroups(rawGroups, nextColumns = columns.value) {
if (!Array.isArray(rawGroups) || !rawGroups.length) {
return [
{ id: 'gS', name: 'S', itemIds: [], cells: nextColumns.map(() => []) },
{ id: 'gA', name: 'A', itemIds: [], cells: nextColumns.map(() => []) },
{ id: 'gB', name: 'B', itemIds: [], cells: nextColumns.map(() => []) },
{ id: 'gC', name: 'C', itemIds: [], cells: nextColumns.map(() => []) },
]
}
return rawGroups.map((group, index) => {
const cells = Array.from({ length: nextColumns.length }, (_, cellIndex) => {
if (Array.isArray(group?.cells?.[cellIndex])) return [...group.cells[cellIndex]]
if (cellIndex === 0 && Array.isArray(group?.itemIds)) return [...group.itemIds]
return []
})
return {
id: typeof group?.id === 'string' && group.id ? group.id : `g-${index + 1}` ,
name: typeof group?.name === 'string' && group.name ? group.name.slice(0, 16) : 'Tier',
itemIds: cells.flat(),
cells,
}
})
}
function buildGroupPayload() {
return groups.value.map((group) => ({
id: group.id,
name: group.name,
itemIds: (group.cells || []).flat(),
cells: (group.cells || []).map((cell) => [...cell]),
columnNames: columns.value.map((column) => ({ id: column.id, name: column.name || '' })),
}))
}
function removeItemFromGroup(groupId, columnIndex, itemId) {
if (!canEdit.value || !groupId || columnIndex == null || !itemId) return
const targetGroup = groups.value.find((group) => group.id === groupId)
if (!targetGroup) return
if (!targetGroup.itemIds.includes(itemId)) return
targetGroup.itemIds = targetGroup.itemIds.filter((id) => id !== itemId)
const nextCells = [...targetGroup.cells]
nextCells[columnIndex] = getGroupCellIds(targetGroup, columnIndex).filter((id) => id !== itemId)
targetGroup.cells = nextCells
syncGroupItemIds(targetGroup)
pool.value = [itemId, ...pool.value.filter((id) => id !== itemId)]
}
function setGroupDropEl(groupId, el) {
function setGroupDropEl(groupId, columnIndex, el) {
const key = `${groupId}::${columnIndex}`
if (!el) {
delete groupDropEls.value[groupId]
delete groupDropEls.value[key]
return
}
groupDropEls.value[groupId] = el
groupDropEls.value[key] = el
}
function getListByContainer(containerEl) {
if (!containerEl) return { type: null, groupId: null }
if (!containerEl) return { type: null, groupId: null, columnIndex: null }
const t = containerEl.getAttribute('data-list-type')
if (t === 'pool') return { type: 'pool', groupId: null }
if (t === 'group') return { type: 'group', groupId: containerEl.getAttribute('data-group-id') }
return { type: null, groupId: null }
if (t === 'pool') return { type: 'pool', groupId: null, columnIndex: null }
if (t === 'group') {
return {
type: 'group',
groupId: containerEl.getAttribute('data-group-id'),
columnIndex: Number(containerEl.getAttribute('data-column-index')),
}
}
return { type: null, groupId: null, columnIndex: null }
}
function normalizeSort(containerEl) {
const ids = Array.from(containerEl.querySelectorAll('[data-item-id]')).map((n) => n.getAttribute('data-item-id'))
const meta = getListByContainer(containerEl)
if (meta.type === 'pool') pool.value = ids
if (meta.type === 'pool') {
pool.value = ids
return
}
if (meta.type === 'group') {
const g = groups.value.find((x) => x.id === meta.groupId)
if (g) g.itemIds = ids
if (!g || !Number.isInteger(meta.columnIndex)) return
const nextCells = [...g.cells]
nextCells[meta.columnIndex] = ids
g.cells = nextCells
syncGroupItemIds(g)
}
}
@@ -230,7 +299,7 @@ async function initSortables() {
onAdd: () => normalizeSort(poolEl.value),
})
dropSortables.value = Object.entries(groupDropEls.value).map(([gid, el]) =>
dropSortables.value = Object.entries(groupDropEls.value).map(([, el]) =>
Sortable.create(el, {
group: 'tier-items',
animation: 160,
@@ -263,32 +332,96 @@ async function syncSortables() {
}
}
function createGroupName() {
function createGroupName(index = groups.value.length) {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
const index = groups.value.length
if (index < alphabet.length) return alphabet[index]
return `Tier ${index + 1}`
}
function createColumnName(index = columns.value.length) {
return `${index + 1}`
}
function createCustomItemLabel(fileName = '') {
const normalized = String(fileName || '')
.replace(/\.[^.]+$/, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
return (normalized || 'custom').slice(0, 60)
}
async function addGroup() {
groups.value = [
...groups.value,
{
id: `g-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
id: `g-${Date.now()}-${Math.random().toString(16).slice(2, 8)}` ,
name: createGroupName(),
itemIds: [],
cells: columns.value.map(() => []),
},
]
await syncSortables()
}
async function addColumn() {
columns.value = [
...columns.value,
{ id: `col-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, name: createColumnName() },
]
groups.value = groups.value.map((group) => ({
...group,
cells: [...group.cells, []],
itemIds: [...group.itemIds],
}))
await syncSortables()
}
async function removeColumn(columnIndex) {
if (!canEdit.value || columns.value.length <= 1) return
const nextColumns = columns.value.filter((_, index) => index !== columnIndex)
groups.value = groups.value.map((group) => {
const nextCells = group.cells.filter((_, index) => index !== columnIndex)
const removed = Array.isArray(group.cells[columnIndex]) ? group.cells[columnIndex] : []
if (nextCells[0] && removed.length) nextCells[0] = [...removed, ...nextCells[0]]
const nextGroup = { ...group, cells: nextCells }
syncGroupItemIds(nextGroup)
return nextGroup
})
Object.keys(groupDropEls.value).forEach((key) => {
if (key.endsWith(`::${columnIndex}`)) delete groupDropEls.value[key]
})
columns.value = nextColumns
await syncSortables()
}
function openColumnDeleteModal(columnIndex) {
if (!canEdit.value || columns.value.length <= 1) return
pendingRemoveColumnIndex.value = columnIndex
isColumnDeleteModalOpen.value = true
}
function closeColumnDeleteModal() {
isColumnDeleteModalOpen.value = false
pendingRemoveColumnIndex.value = -1
}
async function confirmRemoveColumn() {
const columnIndex = pendingRemoveColumnIndex.value
closeColumnDeleteModal()
if (columnIndex < 0) return
await removeColumn(columnIndex)
}
async function performRemoveGroup(groupId) {
if (groups.value.length <= 1) return
const target = groups.value.find((group) => group.id === groupId)
if (!target) return
pool.value = [...target.itemIds, ...pool.value]
groups.value = groups.value.filter((group) => group.id !== groupId)
delete groupDropEls.value[groupId]
Object.keys(groupDropEls.value).forEach((key) => {
if (key.startsWith(`${groupId}::`)) delete groupDropEls.value[key]
})
await syncSortables()
}
@@ -316,7 +449,7 @@ function addCustomImage(file) {
const id = `c-${Date.now()}-${Math.random().toString(16).slice(2)}`
itemsById.value = {
...itemsById.value,
[id]: { id, src: url, label: file.name || 'custom', origin: 'custom', pendingFile: file },
[id]: { id, src: url, label: createCustomItemLabel(file.name), origin: 'custom', pendingFile: file },
}
pool.value = [id, ...pool.value]
}
@@ -450,7 +583,7 @@ async function uploadPendingCustomItems() {
for (const item of entries) {
const fd = new FormData()
fd.append('label', item.label || 'custom')
fd.append('label', createCustomItemLabel(item.label || 'custom'))
fd.append('image', item.pendingFile)
const res = await fetch(toApiUrl('/api/tierlists/custom-items'), {
@@ -475,10 +608,14 @@ async function uploadPendingCustomItems() {
}
itemsById.value = nextItemsById
pool.value = pool.value.map((currentId) => (currentId === item.id ? uploaded.id : currentId))
groups.value = groups.value.map((group) => ({
...group,
itemIds: group.itemIds.map((currentId) => (currentId === item.id ? uploaded.id : currentId)),
}))
groups.value = groups.value.map((group) => {
const nextGroup = {
...group,
cells: group.cells.map((cell) => cell.map((currentId) => (currentId === item.id ? uploaded.id : currentId))),
}
syncGroupItemIds(nextGroup)
return nextGroup
})
}
}
@@ -507,7 +644,7 @@ function buildPayload(existingId) {
sourceTierListId: sourceTierListId.value || '',
sourceSnapshotTitle: sourceSnapshotTitle.value || '',
sourceSnapshotAuthor: sourceSnapshotAuthor.value || '',
groups: groups.value.map((g) => ({ id: g.id, name: g.name, itemIds: g.itemIds })),
groups: buildGroupPayload(),
pool: Object.values(itemsById.value),
}
}
@@ -547,8 +684,9 @@ function closeSaveModal() {
}
function resetTemplateRequestDrafts() {
templateRequestDraftTitle.value = ''
templateRequestDraftDescription.value = ''
templateRequestDraftTitle.value = (title.value || '').trim()
templateRequestDraftDescription.value = (description.value || '').trim()
templateRequestSaveToMyTierList.value = true
}
function openTemplateRequestModal() {
@@ -624,25 +762,52 @@ async function toggleFavorite() {
}
async function requestTemplate(type) {
if (isNewTierList.value) {
toast.error('요청 전에 먼저 티어표를 저장해주세요.')
return
}
try {
isRequestingTemplate.value = true
const persisted = await persistTierList({ showModal: false })
await api.requestTierListTemplate(persisted.savedTierListId, {
await uploadPendingCustomItems()
const uploadedThumbnailSrc = await uploadPendingThumbnail()
const response = await api.requestTierListTemplate({
type,
sourceTierListId: tierListId.value !== 'new' ? tierListId.value : '',
gameId: gameId.value,
requestTitle: templateRequestDraftTitle.value.trim(),
requestDescription: templateRequestDraftDescription.value.trim(),
thumbnailSrc: uploadedThumbnailSrc || thumbnailSrc.value || '',
isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value,
saveToMyTierList: !!templateRequestSaveToMyTierList.value,
groups: buildGroupPayload(),
boardItems: Object.values(itemsById.value),
})
const savedTierList = response?.savedTierList
if (savedTierList) {
title.value = savedTierList.title || title.value
description.value = savedTierList.description || ''
updatedAt.value = Number(savedTierList.updatedAt || Date.now())
authorName.value = savedTierList.authorName || effectiveAuthorName.value
authorAccountName.value = savedTierList.authorAccountName || authorAccountName.value
favoriteCount.value = Number(savedTierList.favoriteCount || favoriteCount.value || 0)
isFavorited.value = !!savedTierList.isFavorited
if (tierListId.value === 'new' && savedTierList.id) {
await router.replace(`/editor/${gameId.value}/${savedTierList.id}`)
}
}
if (type === 'create') closeTemplateRequestModal()
if (type === 'update') closeTemplateUpdateModal()
toast.success(type === 'create' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
toast.success(
type === 'create'
? templateRequestSaveToMyTierList.value
? '템플릿 등록 요청과 내 티어표 저장을 함께 완료했어요.'
: '템플릿 등록 요청을 보냈어요.'
: templateRequestSaveToMyTierList.value
? '템플릿 업데이트 요청과 내 티어표 저장을 함께 완료했어요.'
: '템플릿 업데이트 요청을 보냈어요.'
)
} catch (e) {
if (e?.status === 400 && e?.data?.error === 'title_required') {
toast.error('템플릿 등록 요청 전에는 티어표 이름을 직접 입력해주세요.')
if (e?.message === 'custom_upload_failed') {
toast.error('커스텀 이미지 이름이 너무 길거나 업로드 조건에 맞지 않아 요청 전에 저장하지 못했어요. 아이템 이름을 60자 이하로 줄인 뒤 다시 시도해주세요.')
return
}
if (e?.status === 409) {
@@ -653,6 +818,10 @@ async function requestTemplate(type) {
toast.error('먼저 커스텀 아이템을 추가한 뒤 요청해주세요.')
return
}
if (e?.status === 400 && e?.data?.error === 'bad_request') {
toast.error('요청 제목, 설명, 아이템 이름 중 길이 제한을 넘긴 값이 없는지 확인해주세요.')
return
}
toast.error(type === 'create' ? '템플릿 등록 요청에 실패했어요.' : '템플릿 업데이트 요청에 실패했어요.')
} finally {
isRequestingTemplate.value = false
@@ -705,12 +874,13 @@ onMounted(() => {
sourceSnapshotAuthor.value = t.sourceSnapshotAuthor || ''
favoriteCount.value = Number(t.favoriteCount || 0)
isFavorited.value = !!t.isFavorited
groups.value = t.groups
columns.value = normalizeLoadedColumns(t.groups)
groups.value = normalizeLoadedGroups(t.groups, columns.value)
const map = {}
;(t.pool || []).forEach((it) => (map[it.id] = it))
itemsById.value = map
const grouped = new Set()
groups.value.forEach((g) => g.itemIds.forEach((id) => grouped.add(id)))
groups.value.forEach((group) => group.itemIds.forEach((id) => grouped.add(id)))
pool.value = Object.keys(itemsById.value).filter((id) => !grouped.has(id))
} catch (e) {
error.value = '티어표를 불러오지 못했어요.'
@@ -735,13 +905,23 @@ onUnmounted(() => {
<div class="previewOnly__sheet">
<div class="previewOnly__title">{{ effectiveTitle }}</div>
<div v-if="description" class="previewOnly__description">{{ description }}</div>
<div v-if="columns.length > 1" class="previewOnly__columns">
<div class="previewOnly__columnsSpacer" aria-hidden="true"></div>
<div class="previewOnly__columnsGrid" :style="{ '--column-count': columns.length }">
<div v-for="(column, columnIndex) in columns" :key="column.id" class="previewOnly__columnHeader">{{ column.name || ' ' + (columnIndex + 1) }}</div>
</div>
</div>
<div class="previewOnly__rows">
<div v-for="g in groups" :key="g.id" class="previewOnly__row">
<div class="previewOnly__label">{{ g.name }}</div>
<div class="previewOnly__drop">
<div v-for="id in g.itemIds" :key="id" class="previewOnly__cell">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
<div class="previewOnly__dropGrid" :style="{ '--column-count': columns.length }">
<div v-for="(column, columnIndex) in columns" :key="column.id" class="previewOnly__dropColumn">
<div class="previewOnly__drop">
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="previewOnly__cell">
<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>
@@ -773,18 +953,7 @@ onUnmounted(() => {
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="templateRequestTitle">
<div id="templateRequestTitle" class="modalCard__title">템플릿 등록 요청</div>
<div class="modalCard__desc">
여러 사용자가 비슷한 주제로 요청할 있으니, 관리자에게 전달되기 전에 아래 조건을 먼저 확인해주세.
</div>
<div class="requestChecklist">
<div
v-for="check in templateRequestChecks"
:key="check.id"
class="requestChecklist__item"
:class="{ 'requestChecklist__item--passed': check.passed }"
>
<span class="requestChecklist__label">{{ check.label }}</span>
<span class="requestChecklist__icon">{{ check.passed ? '완료' : '확인 필요' }}</span>
</div>
티어표에 적어둔 제목과 설명이 있다면 그대로 가져와 두었어요. 비어 있다면 여기서 바로 작성해도 괜찮아.
</div>
<div class="requestChecklist__hint">
제목과 설명을 함께 적어두면 관리자가 어떤 신규 템플릿인지 훨씬 빠르게 파악할 있어요.
@@ -792,13 +961,23 @@ onUnmounted(() => {
</div>
<div class="templateRequestDraft">
<label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 제목</span>
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 등록 요청" />
<span class="templateRequestDraft__label">티어표 제목</span>
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="120" placeholder="예: 템플릿 등록 요청" />
<span class="templateRequestDraft__hint">{{ templateRequestDraftTitle.length }}/120</span>
</label>
<label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 설명</span>
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." />
<span class="templateRequestDraft__label">티어표 설명</span>
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="1000" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." />
<span class="templateRequestDraft__hint">{{ templateRequestDraftDescription.length }}/1000</span>
</label>
<label class="toggleSwitch">
<input v-model="templateRequestSaveToMyTierList" type="checkbox" />
<span class="toggleSwitch__label"> 티어 리스트에도 저장</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<div class="templateRequestDraft__note">
저장을 끄면 요청 시점 스냅샷만 관리자에게 전달되고, 티어 리스트에는 별도로 남기지 않아요.
</div>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTemplateRequestModal">취소</button>
@@ -813,7 +992,7 @@ onUnmounted(() => {
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="templateUpdateTitle">
<div id="templateUpdateTitle" class="modalCard__title">템플릿 요청하기</div>
<div class="modalCard__desc">
{{ templateRequestTargetLabel }} 직접 추가한 아이템을 포함해 달라고 관리자에게 요청 보냅니다.
{{ templateRequestTargetLabel }} 직접 추가한 아이템을 포함해 달라고 관리자에게 요청합니다. 현재 티어표 제목과 설명은 그대로 가져와 두었어요.
</div>
<div class="modalCard__note">
모두가 사용하는 기본 템플릿이니 개인적인 항목이 아닌 공통된 항목만 추가한 신청해주세요.
@@ -821,13 +1000,23 @@ onUnmounted(() => {
</div>
<div class="templateRequestDraft">
<label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 제목</span>
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 업데이트 요청" />
<span class="templateRequestDraft__label">티어표 제목</span>
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="120" placeholder="예: 템플릿 업데이트 요청" />
<span class="templateRequestDraft__hint">{{ templateRequestDraftTitle.length }}/120</span>
</label>
<label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 설명</span>
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가" />
<span class="templateRequestDraft__label">티어표 설명</span>
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="1000" placeholder="예: 여름 이벤트 한정 캐릭터 추가" />
<span class="templateRequestDraft__hint">{{ templateRequestDraftDescription.length }}/1000</span>
</label>
<label class="toggleSwitch">
<input v-model="templateRequestSaveToMyTierList" type="checkbox" />
<span class="toggleSwitch__label"> 티어 리스트에도 저장</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<div class="templateRequestDraft__note">
저장을 끄면 관리자 확인용 요청 스냅샷만 남고, 현재 작업 중인 티어표는 따로 저장하지 않아요.
</div>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTemplateUpdateModal">요청 취소</button>
@@ -855,13 +1044,26 @@ onUnmounted(() => {
<div v-if="isGroupDeleteModalOpen" class="modalOverlay" @click.self="closeGroupDeleteModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteGroupTitle">
<div id="deleteGroupTitle" class="modalCard__title">티어 라인 삭제</div>
<div id="deleteGroupTitle" class="modalCard__title">티어 삭제</div>
<div class="modalCard__desc">
라인 삭제하면 현재 들어 있는 아이템은 모두 아래 아이템 영역으로 이동합니다. 삭제 후에도 아이템 자체는 유지돼요.
삭제하면 현재 들어 있는 아이템은 모두 아래 아이템 영역으로 이동합니다. 삭제 후에도 아이템 자체는 유지돼요.
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeGroupDeleteModal">취소</button>
<button class="btn btn--danger" @click="confirmRemoveGroup">라인 삭제</button>
<button class="btn btn--danger" @click="confirmRemoveGroup"> 삭제</button>
</div>
</div>
</div>
<div v-if="isColumnDeleteModalOpen" class="modalOverlay" @click.self="closeColumnDeleteModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteColumnTitle">
<div id="deleteColumnTitle" class="modalCard__title">티어 삭제</div>
<div class="modalCard__desc">
열을 삭제하면 현재 들어 있는 아이템은 모두 번째 열로 이동합니다. 삭제 후에도 아이템 자체는 유지돼요.
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeColumnDeleteModal">취소</button>
<button class="btn btn--danger" @click="confirmRemoveColumn"> 삭제</button>
</div>
</div>
</div>
@@ -873,7 +1075,7 @@ onUnmounted(() => {
<div class="editorMain__title">{{ gameName || gameId }}</div>
<div class="editorMain__subtitle">
<template v-if="canEdit">
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다.
/ 이름 순서 바꾸고 아이템 드래그해서 배치할 있어요.
</template>
<template v-else>
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 있어요.
@@ -890,7 +1092,12 @@ onUnmounted(() => {
<div ref="boardEl" class="board">
<div v-if="canEdit && !isExporting" class="boardTools">
<div class="boardTools__left">
<button class="btn btn--ghost" @click="addGroup">티어 추가</button>
<button class="boardActionIcon" type="button" title="행 추가" aria-label=" 추가" @click="addGroup">
<SvgIcon :src="addRowBelowIcon" :size="18" />
</button>
<button class="boardActionIcon" type="button" title="열 추가" aria-label=" 추가" @click="addColumn">
<SvgIcon :src="addColumnRightIcon" :size="18" />
</button>
</div>
<div class="boardTools__right">
<span class="boardTools__label">아이콘 크기</span>
@@ -910,6 +1117,22 @@ onUnmounted(() => {
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
<div v-if="isExporting" class="exportBoard__title">{{ effectiveTitle }}</div>
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</div>
<div v-if="columns.length > 1" class="boardColumnsHeader" :class="{ 'boardColumnsHeader--export': isExporting }">
<div class="boardColumnsHeader__spacer" aria-hidden="true"></div>
<div class="boardColumnsHeader__grid" :style="{ '--column-count': columns.length }">
<div v-for="(column, columnIndex) in columns" :key="column.id" class="boardColumnsHeader__cell">
<template v-if="isExporting">
<div class="boardColumnsHeader__name">{{ column.name || '열 ' + (columnIndex + 1) }}</div>
</template>
<template v-else>
<div class="columnHeader">
<input v-model="column.name" class="columnName" maxlength="16" placeholder="열 이름" />
<button class="columnRemoveText" type="button" title="열 삭제" aria-label=" 삭제" :disabled="columns.length <= 1" @click="openColumnDeleteModal(columnIndex)">×</button>
</div>
</template>
</div>
</div>
</div>
<div ref="groupListEl" class="rows">
<div v-for="g in groups" :key="g.id" class="row">
<div class="row__label">
@@ -917,40 +1140,46 @@ onUnmounted(() => {
<div class="row__exportName">{{ g.name }}</div>
</template>
<template v-else>
<span class="grab" title="드래그로 순서 변경" data-group-handle></span>
<input v-model="g.name" class="groupName" maxlength="16" :readonly="!canEdit" />
<button
v-if="canEdit"
class="rowRemoveText"
type="button"
title="티어 라인 삭제"
title=" 삭제"
aria-label=" 삭제"
:disabled="groups.length <= 1"
@click="openGroupDeleteModal(g.id)"
>
삭제
</button>
<span class="grab" title="드래그로 순서 변경" data-group-handle></span>
<input v-model="g.name" class="groupName" :readonly="!canEdit" />
</template>
</div>
<div
class="row__drop"
:data-list-type="'group'"
:data-group-id="g.id"
:ref="(el) => setGroupDropEl(g.id, el)"
>
<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">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
<button
v-if="canEdit && !isExporting"
class="cellRemoveBtn"
type="button"
title="아이템 빼내기"
@pointerdown.stop
@click.stop="removeItemFromGroup(g.id, id)"
>
×
</button>
</template>
</div>
<div class="row__content" :style="{ '--column-count': columns.length }">
<div v-for="(column, columnIndex) in columns" :key="column.id" class="row__column">
<div
class="row__drop"
:data-list-type="'group'"
:data-group-id="g.id"
:data-column-index="columnIndex"
:ref="(el) => setGroupDropEl(g.id, columnIndex, el)"
>
<div v-if="!isExporting" class="row__empty" v-show="getGroupCellIds(g, columnIndex).length === 0">여기로 드래그해서 배치</div>
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="cell" :data-item-id="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
v-if="canEdit && !isExporting"
class="cellRemoveBtn"
type="button"
title="아이템 빼내기"
@pointerdown.stop
@click.stop="removeItemFromGroup(g.id, columnIndex, id)"
>
×
</button>
</div>
</div>
</div>
</div>
</div>
@@ -1004,7 +1233,8 @@ onUnmounted(() => {
<template v-if="globalRightRailOpen">
<div class="editorSidebar__section">
<div class="editorSidebar__label">Title</div>
<input v-model="title" class="editorSidebar__input" placeholder="Title Text" :readonly="!canEdit" />
<input v-model="title" class="editorSidebar__input" maxlength="120" placeholder="Title Text" :readonly="!canEdit" />
<div class="editorSidebar__hint">{{ title.length }}/120</div>
<div v-if="untitledWarning" class="editorSidebar__hint editorSidebar__hint--warn">{{ untitledWarning }}</div>
</div>
@@ -1014,8 +1244,10 @@ onUnmounted(() => {
v-model="description"
class="editorSidebar__textarea"
placeholder="Description Text"
maxlength="1000"
:readonly="!canEdit"
></textarea>
<div class="editorSidebar__hint">{{ description.length }}/1000</div>
</div>
<div class="editorSidebar__section">
@@ -1176,15 +1408,42 @@ onUnmounted(() => {
line-height: 1.6;
opacity: 0.76;
}
.previewOnly__columns {
display: grid;
grid-template-columns: 132px 1fr;
gap: 10px;
margin-bottom: 10px;
}
.previewOnly__columnsGrid {
display: grid;
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
gap: 10px;
}
.previewOnly__columnHeader {
min-height: 20px;
font-size: 12px;
font-weight: 800;
text-align: center;
opacity: 0.72;
}
.previewOnly__rows {
display: grid;
gap: 10px;
}
.previewOnly__row {
display: grid;
grid-template-columns: 180px 1fr;
grid-template-columns: 132px 1fr;
gap: 10px;
}
.previewOnly__dropGrid {
display: grid;
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
gap: 10px;
}
.previewOnly__dropColumn {
display: grid;
gap: 8px;
}
.previewOnly__label {
display: grid;
place-items: center;
@@ -1438,6 +1697,15 @@ onUnmounted(() => {
font-size: 12px;
color: rgba(255, 255, 255, 0.64);
}
.templateRequestDraft__hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.46);
}
.templateRequestDraft__note {
font-size: 12px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.6);
}
.templateRequestDraft__input {
width: 100%;
padding: 14px 0;
@@ -1483,6 +1751,51 @@ onUnmounted(() => {
.boardTools__left {
margin-right: auto;
}
.boardActionIcon {
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
}
.boardActionIcon:hover {
background: rgba(96, 165, 250, 0.12);
border-color: rgba(96, 165, 250, 0.28);
color: rgba(255, 255, 255, 0.98);
}
.boardColumnsHeader {
display: grid;
grid-template-columns: 132px 1fr;
gap: 10px;
margin-bottom: 10px;
}
.boardColumnsHeader__grid {
display: grid;
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
gap: 10px;
}
.boardColumnsHeader__cell {
min-width: 0;
position: relative;
}
.boardColumnsHeader__name {
min-height: 38px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 12px;
text-align: center;
font-size: 12px;
line-height: 1.2;
font-weight: 800;
opacity: 0.74;
}
.boardTools__label {
font-size: 13px;
opacity: 0.76;
@@ -1549,7 +1862,7 @@ onUnmounted(() => {
}
.row {
display: grid;
grid-template-columns: 180px 1fr;
grid-template-columns: 132px 1fr;
gap: 10px;
align-items: stretch;
}
@@ -1559,24 +1872,86 @@ onUnmounted(() => {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
padding: 10px 12px 30px;
padding: 14px 28px;
font-weight: 900;
overflow: hidden;
}
.grab {
cursor: grab;
opacity: 0.85;
width: 26px;
height: 26px;
.row__content {
display: grid;
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
gap: 10px;
}
.row__column {
display: grid;
gap: 8px;
min-width: 0;
}
.columnHeader {
position: relative;
display: flex;
align-items: center;
min-height: 38px;
padding: 0 28px;
}
.columnName {
width: 100%;
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
background: transparent;
color: rgba(255, 255, 255, 0.88);
padding: 4px 0;
text-align: center;
font-size: 12px;
line-height: 1.2;
font-weight: 800;
outline: none;
}
.columnName::placeholder {
color: rgba(255, 255, 255, 0.34);
}
.columnRemoveText {
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
width: 20px;
height: 20px;
display: grid;
place-items: center;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.56);
font-size: 16px;
line-height: 1;
font-weight: 800;
cursor: pointer;
}
.columnRemoveText:hover {
color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.06);
}
.columnRemoveText:disabled {
opacity: 0.32;
cursor: not-allowed;
}
.grab {
position: absolute;
top: 10px;
left: 10px;
cursor: grab;
opacity: 0.72;
width: 22px;
height: 22px;
display: grid;
place-items: center;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.16);
flex: 0 0 auto;
font-size: 12px;
}
.groupName {
width: 100%;
@@ -1584,7 +1959,7 @@ onUnmounted(() => {
background: rgba(0, 0, 0, 0.18);
color: rgba(255, 255, 255, 0.92);
border-radius: 10px;
padding: 6px 8px;
padding: 8px 10px;
font-weight: 900;
text-align: center;
outline: none;
@@ -1592,19 +1967,25 @@ onUnmounted(() => {
}
.rowRemoveText {
position: absolute;
right: 12px;
bottom: 10px;
top: 10px;
right: 10px;
width: 20px;
height: 20px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
font-size: 12px;
font-size: 16px;
line-height: 1;
font-weight: 800;
}
.rowRemoveText:hover {
color: rgba(255, 255, 255, 0.9);
color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.06);
}
.rowRemoveText:disabled {
opacity: 0.32;
@@ -1646,17 +2027,21 @@ onUnmounted(() => {
.itemNameOverlay {
position: absolute;
inset: auto 0 0 0;
padding: 16px 8px 6px;
min-height: 26px;
padding: 18px 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;
line-height: 1.2;
font-weight: 800;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: flex-end;
justify-content: center;
pointer-events: none;
}
.cellRemoveBtn {
@@ -2000,6 +2385,9 @@ onUnmounted(() => {
.editorCanvas {
grid-template-columns: 1fr;
}
.row__content {
grid-template-columns: 1fr;
}
.row {
grid-template-columns: 150px 1fr;
}
@@ -2031,7 +2419,18 @@ onUnmounted(() => {
.previewOnly {
padding: 14px;
}
.previewOnly__row {
.previewOnly__columns,
.previewOnly__row,
.boardColumnsHeader,
.row {
grid-template-columns: 1fr;
}
.previewOnly__columnsSpacer,
.boardColumnsHeader__spacer {
display: none;
}
.previewOnly__dropGrid,
.boardColumnsHeader__grid {
grid-template-columns: 1fr;
}
.pool {