Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37b59c7ab6 | |||
| 464819571b | |||
| b1d5355123 | |||
| c1575783f0 | |||
| b58a641453 | |||
| b3f9f8e4d0 | |||
| 2374cd9272 |
@@ -46,6 +46,7 @@ function mapGameRow(row) {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
thumbnailSrc: row.thumbnail_src || '',
|
thumbnailSrc: row.thumbnail_src || '',
|
||||||
|
displayRank: row.display_rank == null ? null : Number(row.display_rank),
|
||||||
createdAt: Number(row.created_at),
|
createdAt: Number(row.created_at),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,6 +67,9 @@ function mapTierListRow(row) {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
authorId: row.author_id,
|
authorId: row.author_id,
|
||||||
|
authorName: getUserDisplayName(row),
|
||||||
|
authorAccountName: getUserAccountName(row),
|
||||||
|
authorAvatarSrc: row.avatar_src || '',
|
||||||
gameId: row.game_id,
|
gameId: row.game_id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
description: row.description || '',
|
description: row.description || '',
|
||||||
@@ -86,6 +90,13 @@ function getUserDisplayName(row) {
|
|||||||
return email.split('@')[0] || email
|
return email.split('@')[0] || email
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUserAccountName(row) {
|
||||||
|
if (!row) return ''
|
||||||
|
const email = (row.email || '').trim()
|
||||||
|
if (!email) return ''
|
||||||
|
return email.split('@')[0] || email
|
||||||
|
}
|
||||||
|
|
||||||
async function createPool() {
|
async function createPool() {
|
||||||
const rootConnection = await mysql.createConnection({
|
const rootConnection = await mysql.createConnection({
|
||||||
host: DB_HOST,
|
host: DB_HOST,
|
||||||
@@ -144,10 +155,16 @@ async function ensureSchema() {
|
|||||||
id VARCHAR(120) PRIMARY KEY,
|
id VARCHAR(120) PRIMARY KEY,
|
||||||
name VARCHAR(120) NOT NULL,
|
name VARCHAR(120) NOT NULL,
|
||||||
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
|
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
display_rank INT NULL DEFAULT NULL,
|
||||||
created_at BIGINT NOT NULL
|
created_at BIGINT NOT NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
const displayRankColumns = await query("SHOW COLUMNS FROM games LIKE 'display_rank'")
|
||||||
|
if (!displayRankColumns.length) {
|
||||||
|
await query('ALTER TABLE games ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src')
|
||||||
|
}
|
||||||
|
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS game_items (
|
CREATE TABLE IF NOT EXISTS game_items (
|
||||||
id VARCHAR(64) PRIMARY KEY,
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
@@ -316,14 +333,23 @@ async function adminDeleteUser(id) {
|
|||||||
|
|
||||||
async function listGames() {
|
async function listGames() {
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
'SELECT id, name, thumbnail_src, created_at FROM games WHERE id <> ? ORDER BY created_at ASC, name ASC',
|
`
|
||||||
|
SELECT id, name, thumbnail_src, display_rank, created_at
|
||||||
|
FROM games
|
||||||
|
WHERE id <> ?
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN display_rank IS NULL THEN 1 ELSE 0 END ASC,
|
||||||
|
display_rank ASC,
|
||||||
|
created_at DESC,
|
||||||
|
name ASC
|
||||||
|
`,
|
||||||
[FREEFORM_GAME_ID]
|
[FREEFORM_GAME_ID]
|
||||||
)
|
)
|
||||||
return rows.map(mapGameRow)
|
return rows.map(mapGameRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findGameById(id) {
|
async function findGameById(id) {
|
||||||
const rows = await query('SELECT id, name, thumbnail_src, created_at FROM games WHERE id = ? LIMIT 1', [id])
|
const rows = await query('SELECT id, name, thumbnail_src, display_rank, created_at FROM games WHERE id = ? LIMIT 1', [id])
|
||||||
return mapGameRow(rows[0])
|
return mapGameRow(rows[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +369,13 @@ async function getGameDetail(gameId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createGame({ id, name }) {
|
async function createGame({ id, name }) {
|
||||||
await query('INSERT INTO games (id, name, thumbnail_src, created_at) VALUES (?, ?, ?, ?)', [id, name, '', now()])
|
await query('INSERT INTO games (id, name, thumbnail_src, display_rank, created_at) VALUES (?, ?, ?, ?, ?)', [
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
'',
|
||||||
|
null,
|
||||||
|
now(),
|
||||||
|
])
|
||||||
return findGameById(id)
|
return findGameById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,6 +433,20 @@ async function deleteGame(gameId) {
|
|||||||
await query('DELETE FROM games WHERE id = ?', [gameId])
|
await query('DELETE FROM games WHERE id = ?', [gameId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateGameDisplayOrder(gameIds) {
|
||||||
|
const normalizedIds = Array.from(new Set((gameIds || []).filter((id) => id && id !== FREEFORM_GAME_ID))).slice(0, 50)
|
||||||
|
|
||||||
|
await query('UPDATE games SET display_rank = NULL WHERE id <> ?', [FREEFORM_GAME_ID])
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
normalizedIds.map((gameId, index) =>
|
||||||
|
query('UPDATE games SET display_rank = ? WHERE id = ? AND id <> ?', [index + 1, gameId, FREEFORM_GAME_ID])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return listGames()
|
||||||
|
}
|
||||||
|
|
||||||
async function createCustomItem({ id, ownerId, src, label }) {
|
async function createCustomItem({ id, ownerId, src, label }) {
|
||||||
const createdAt = now()
|
const createdAt = now()
|
||||||
await query('INSERT INTO custom_items (id, owner_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
|
await query('INSERT INTO custom_items (id, owner_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
|
||||||
@@ -565,6 +611,7 @@ async function listPublicTierLists(gameId) {
|
|||||||
updatedAt: Number(row.updated_at),
|
updatedAt: Number(row.updated_at),
|
||||||
authorId: row.author_id,
|
authorId: row.author_id,
|
||||||
authorName: getUserDisplayName(row),
|
authorName: getUserDisplayName(row),
|
||||||
|
authorAccountName: getUserAccountName(row),
|
||||||
authorAvatarSrc: row.avatar_src || '',
|
authorAvatarSrc: row.avatar_src || '',
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -598,6 +645,7 @@ async function listUserTierLists(userId) {
|
|||||||
updatedAt: Number(row.updated_at),
|
updatedAt: Number(row.updated_at),
|
||||||
isPublic: !!row.is_public,
|
isPublic: !!row.is_public,
|
||||||
authorName: getUserDisplayName(row),
|
authorName: getUserDisplayName(row),
|
||||||
|
authorAccountName: getUserAccountName(row),
|
||||||
authorAvatarSrc: row.avatar_src || '',
|
authorAvatarSrc: row.avatar_src || '',
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -605,9 +653,23 @@ async function listUserTierLists(userId) {
|
|||||||
async function findTierListById(id) {
|
async function findTierListById(id) {
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`
|
`
|
||||||
SELECT id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at
|
SELECT
|
||||||
FROM tierlists
|
t.id,
|
||||||
WHERE id = ?
|
t.author_id,
|
||||||
|
t.game_id,
|
||||||
|
t.title,
|
||||||
|
t.description,
|
||||||
|
t.is_public,
|
||||||
|
t.groups_json,
|
||||||
|
t.pool_json,
|
||||||
|
t.created_at,
|
||||||
|
t.updated_at,
|
||||||
|
u.nickname,
|
||||||
|
u.email,
|
||||||
|
u.avatar_src
|
||||||
|
FROM tierlists t
|
||||||
|
INNER JOIN users u ON u.id = t.author_id
|
||||||
|
WHERE t.id = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`,
|
`,
|
||||||
[id]
|
[id]
|
||||||
@@ -695,6 +757,7 @@ module.exports = {
|
|||||||
createGameItem,
|
createGameItem,
|
||||||
deleteGameItem,
|
deleteGameItem,
|
||||||
deleteGame,
|
deleteGame,
|
||||||
|
updateGameDisplayOrder,
|
||||||
createCustomItem,
|
createCustomItem,
|
||||||
listCustomItems,
|
listCustomItems,
|
||||||
findUnusedCustomItems,
|
findUnusedCustomItems,
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ const {
|
|||||||
findUserById,
|
findUserById,
|
||||||
findGameById,
|
findGameById,
|
||||||
createGame,
|
createGame,
|
||||||
|
listGames,
|
||||||
updateGameThumbnail,
|
updateGameThumbnail,
|
||||||
createGameItem,
|
createGameItem,
|
||||||
deleteGameItem,
|
deleteGameItem,
|
||||||
deleteGame,
|
deleteGame,
|
||||||
|
updateGameDisplayOrder,
|
||||||
listCustomItems,
|
listCustomItems,
|
||||||
findUnusedCustomItems,
|
findUnusedCustomItems,
|
||||||
findCustomItemsByIds,
|
findCustomItemsByIds,
|
||||||
@@ -50,6 +52,20 @@ router.post('/games', requireAdmin, async (req, res) => {
|
|||||||
res.json({ game })
|
res.json({ game })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.patch('/games/display-order', requireAdmin, async (req, res) => {
|
||||||
|
const schema = z.object({
|
||||||
|
gameIds: z.array(z.string().min(1)).max(50),
|
||||||
|
})
|
||||||
|
const parsed = schema.safeParse(req.body)
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
|
const games = await listGames()
|
||||||
|
const validGameIds = new Set(games.map((game) => game.id))
|
||||||
|
const filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId))
|
||||||
|
const updatedGames = await updateGameDisplayOrder(filteredIds)
|
||||||
|
res.json({ games: updatedGames })
|
||||||
|
})
|
||||||
|
|
||||||
router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail'), async (req, res) => {
|
router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail'), async (req, res) => {
|
||||||
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
||||||
const game = await findGameById(req.params.gameId)
|
const game = await findGameById(req.params.gameId)
|
||||||
|
|||||||
@@ -72,3 +72,29 @@
|
|||||||
- 에디터에서 저장 관련 행동은 좌우로 역할을 나눠 `다운로드/삭제`와 `공개/저장`으로 묶는 편이 더 빠르게 인지된다고 판단했다.
|
- 에디터에서 저장 관련 행동은 좌우로 역할을 나눠 `다운로드/삭제`와 `공개/저장`으로 묶는 편이 더 빠르게 인지된다고 판단했다.
|
||||||
- 공개 목록과 내 목록에서 제목만으로는 식별이 어려우므로, 제목 옆에 작성자 아바타와 표시명을 함께 노출하기로 결정했다.
|
- 공개 목록과 내 목록에서 제목만으로는 식별이 어려우므로, 제목 옆에 작성자 아바타와 표시명을 함께 노출하기로 결정했다.
|
||||||
- 티어표 카드 메타 정보는 저장 시각과 업데이트 시각을 동시에 노출하는 대신, 최종 업데이트 시각만 남겨 단순화하기로 결정했다.
|
- 티어표 카드 메타 정보는 저장 시각과 업데이트 시각을 동시에 노출하는 대신, 최종 업데이트 시각만 남겨 단순화하기로 결정했다.
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.21
|
||||||
|
- 목록 썸네일 fallback 문자는 닉네임보다 계정 기준이 더 일관되므로, 아바타 이미지가 없을 때는 계정명 첫 글자를 사용하기로 결정했다.
|
||||||
|
- 저장 성공은 화면 이동보다 현 위치 유지가 더 중요하므로, 편집을 계속할 수 있는 확인형 모달로 피드백을 제공하기로 결정했다.
|
||||||
|
- PNG export는 가장자리 여백이 없는 결과보다 중앙 정렬된 카드형 결과물이 더 완성도 있게 보이므로, export 전용 보드에 충분한 바깥 패딩을 포함하기로 했다.
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.22
|
||||||
|
- 무제목 저장은 게임 이름 기본값보다 `이름 없음 + 날짜`가 더 명확하다고 판단해 자동 제목 규칙을 변경했다.
|
||||||
|
- 제목이 비어 있는 티어표는 품질 관리 대상이 될 수 있으므로, 작성 중 단계에서 관리자 임의 삭제 가능성을 미리 안내하기로 결정했다.
|
||||||
|
- 다운로드 이미지에는 편집용 빈 칸 안내 문구를 제외하고, 더 넓은 보드 폭과 하단 작성자/날짜 메타를 포함해 공유용 완성도를 높이기로 결정했다.
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.23
|
||||||
|
- 홈 화면 정렬은 단순 생성순 하나보다 `관리자 상단 고정 순서 + 나머지 최신 생성순` 조합이 운영과 신규 노출을 함께 만족시킨다고 판단했다.
|
||||||
|
- 상단 고정은 전체 수동 정렬보다 최대 50개만 관리하는 방식이 운영 부담이 적으므로, 관리자에게는 제한된 상단 고정 목록만 직접 편집하게 하기로 결정했다.
|
||||||
|
- `커스텀 티어표 만들기`는 일반 게임 카드와 성격이 다르므로 카드형 목록에서 분리해 우측 상단 버튼으로 노출하기로 결정했다.
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.24
|
||||||
|
- 상단 고정 게임이 50개까지 늘어날 수 있으므로, 위/아래 버튼만으로는 불편하다고 판단해 드래그 정렬을 함께 제공하기로 결정했다.
|
||||||
|
- export 결과가 지나치게 넓으면 아이콘이 한 줄에 과도하게 몰리므로, 공유용 이미지 폭을 줄이고 pixel ratio도 낮춰 균형을 맞추기로 결정했다.
|
||||||
|
- 현재 업로드는 파일 크기 제한만 있고 서버 저장 전 최적화는 없으므로, 운영 문서에 현황을 명확히 남기고 향후 리사이즈/압축 도입 여부를 검토하기로 했다.
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.25
|
||||||
|
- 저장 결과물 기준 너비가 여전히 길다고 판단해, export 보드 폭을 추가로 `960px`까지 줄여 최종 PNG 밀도를 더 낮추기로 결정했다.
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.26
|
||||||
|
- 아이콘 크기는 사용자 취향 차이가 큰 요소이므로, 고정값 하나보다 기본 `80px`에 단계형 크기 선택을 제공하는 편이 더 낫다고 판단했다.
|
||||||
|
|||||||
13
docs/spec.md
13
docs/spec.md
@@ -96,6 +96,7 @@
|
|||||||
- 게임 기본 아이템 추가는 이름 입력, 파일 선택, 1:1 미리보기 확인 뒤 저장하는 흐름이다.
|
- 게임 기본 아이템 추가는 이름 입력, 파일 선택, 1:1 미리보기 확인 뒤 저장하는 흐름이다.
|
||||||
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
|
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
|
||||||
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
|
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
|
||||||
|
- 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 드래그 또는 위/아래 버튼으로 순서를 저장할 수 있다.
|
||||||
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
||||||
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
|
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
|
||||||
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
|
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
|
||||||
@@ -106,8 +107,20 @@
|
|||||||
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
|
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
|
||||||
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
|
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
|
||||||
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
||||||
|
- 제목이 비어 있는 상태로 저장하면 내부 제목은 `이름 없음 + 날짜` 형식으로 자동 생성한다.
|
||||||
|
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
|
||||||
|
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
||||||
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
||||||
|
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.
|
||||||
- 티어표 목록 메타 정보는 최종 업데이트 시각만 간략하게 표시한다.
|
- 티어표 목록 메타 정보는 최종 업데이트 시각만 간략하게 표시한다.
|
||||||
|
- 저장 성공 시에는 에디터 안에서 반투명 오버레이 기반 확인 모달을 띄우고, PNG export 이미지는 약 `960px` 보드 폭과 `pixelRatio 1.5`, 외곽 여백, 작성자/날짜 하단 메타를 포함해 생성한다.
|
||||||
|
- 홈 게임 목록은 관리자 상단 고정 순서가 있으면 그 순서를 먼저 적용하고, 그 외 게임은 최근 생성순으로 뒤에 이어진다.
|
||||||
|
- `커스텀 티어표 만들기`는 카드가 아니라 홈 우측 상단 버튼으로 진입한다.
|
||||||
|
|
||||||
|
## 업로드 제한 메모
|
||||||
|
- 프로필 아바타 업로드는 파일당 최대 `3MB`다.
|
||||||
|
- 관리자 게임 썸네일/기본 아이템 업로드와 사용자 커스텀 이미지 업로드는 파일당 최대 `6MB`다.
|
||||||
|
- 현재는 업로드 전에 이미지 리사이즈/압축을 하지 않고 원본 파일을 그대로 저장한다.
|
||||||
|
|
||||||
## 운영 환경 변수
|
## 운영 환경 변수
|
||||||
- 프런트엔드
|
- 프런트엔드
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
- 사용자 커스텀 아이템을 관리자 기본 템플릿으로 승격하는 승인/복제 흐름은 아직 없다.
|
- 사용자 커스텀 아이템을 관리자 기본 템플릿으로 승격하는 승인/복제 흐름은 아직 없다.
|
||||||
- 회원 관리에서 아바타/작성 티어표 수/최근 활동 같은 보조 정보는 아직 표시하지 않는다.
|
- 회원 관리에서 아바타/작성 티어표 수/최근 활동 같은 보조 정보는 아직 표시하지 않는다.
|
||||||
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
|
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
|
||||||
|
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
|
||||||
|
|
||||||
## 배포 전 작업
|
## 배포 전 작업
|
||||||
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
|
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
|
||||||
|
|||||||
@@ -1,5 +1,37 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.26
|
||||||
|
- **아이콘 크기 조절 추가**: 티어표 편집기에서 `48 / 60 / 80 / 100 / 120` 단계로 아이콘 크기를 직접 바꿀 수 있도록 추가
|
||||||
|
- **기본 아이콘 크기 상향**: 기본 `.thumb` 크기를 `80px` 기준으로 조정하고, 보드와 우측 아이템 목록에 함께 반영되도록 정리
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.25
|
||||||
|
- **export 폭 추가 축소**: 티어표 PNG export 보드 폭을 `960px`로 더 줄여 최종 저장 이미지가 지나치게 길어지지 않도록 조정
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.24
|
||||||
|
- **관리자 게임 순서 드래그 정렬 추가**: 상단 고정 게임 목록을 위/아래 버튼뿐 아니라 드래그로도 순서를 바꿀 수 있도록 보강
|
||||||
|
- **export 크기 재조정**: 티어표 PNG export를 약 `1360px` 폭과 `pixelRatio 1.5` 기준으로 낮춰 아이콘이 과도하게 한 줄에 몰리지 않도록 수정
|
||||||
|
- **업로드 정책 문서화**: 현재 아바타 `3MB`, 게임/커스텀 이미지 `6MB` 제한이 있으며 서버 저장 전 리사이즈/압축은 아직 하지 않는다는 점을 문서에 명시
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.23
|
||||||
|
- **홈 게임 정렬 규칙 변경**: 일반 게임 목록은 `상단 고정 순서 → 나머지 최신 생성순`으로 정렬되도록 변경
|
||||||
|
- **관리자 게임 순서 편집 추가**: 관리자 게임 관리 탭에서 최대 50개의 게임을 상단 고정 목록으로 선택하고 위/아래 순서를 저장할 수 있도록 추가
|
||||||
|
- **커스텀 티어표 진입점 변경**: 홈 화면의 `직접 티어표 만들기` 카드를 제거하고 우측 상단 버튼형 진입점으로 변경
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.22
|
||||||
|
- **무제목 저장 규칙 변경**: 제목을 비워두고 저장하면 내부 저장 제목을 `이름 없음 + 날짜` 형식으로 생성하도록 변경
|
||||||
|
- **무제목 안내 문구 추가**: 제목 입력이 비어 있는 동안 관리자 임의 삭제 가능성을 알리는 경고 문구를 제목 입력 아래에 표시
|
||||||
|
- **export 보드 확장**: 다운로드용 티어표 이미지는 빈 칸 안내 문구를 숨기고, 약 `1600px` 폭과 더 넉넉한 여백, 하단 작성자/날짜 메타 정보를 포함하도록 조정
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.21
|
||||||
|
- **아바타 fallback 기준 통일**: 티어표 목록에서 작성자 아바타 이미지가 없을 때 닉네임이 아니라 계정명 기준 첫 글자를 표시하도록 정리
|
||||||
|
- **저장 완료 모달 추가**: 에디터에서 저장 성공 시 반투명 오버레이와 확인 버튼이 있는 피드백 모달을 표시하도록 추가
|
||||||
|
- **다운로드 이미지 여백 보강**: PNG export 전용 보드에 외곽 패딩과 배경 여백을 넣어 콘텐츠가 가장자리에 붙어 보이지 않도록 조정
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.20
|
||||||
|
- **게임 선택 카드 순서 조정**: 홈 화면에서 일반 게임 카드를 먼저 보여주고 `직접 티어표 만들기` 카드는 마지막에 배치
|
||||||
|
- **게임 카드 3열 레이아웃**: PC 기준 게임 선택 화면 카드를 3열로 재구성하고, 썸네일을 16:9 비율로 통일
|
||||||
|
- **공개 티어표 카드 3열 레이아웃**: 게임 허브의 공개 티어표 목록도 PC 기준 3열 카드형으로 재배치하고 태블릿/모바일에서는 자동 줄바꿈되도록 조정
|
||||||
|
|
||||||
## 2026-03-19 v0.1.19
|
## 2026-03-19 v0.1.19
|
||||||
- **에디터 저장 영역 재정렬**: 공개 기본값을 `ON`으로 바꾸고, 액션 영역을 `이미지 다운로드 / 삭제 / 공개 ON·OFF / 저장` 흐름으로 재배치
|
- **에디터 저장 영역 재정렬**: 공개 기본값을 `ON`으로 바꾸고, 액션 영역을 `이미지 다운로드 / 삭제 / 공개 ON·OFF / 저장` 흐름으로 재배치
|
||||||
- **에디터 삭제 진입점 추가**: 기존 티어표는 편집 화면에서 바로 삭제할 수 있도록 버튼을 추가
|
- **에디터 삭제 진입점 추가**: 기존 티어표는 편집 화면에서 바로 삭제할 수 있도록 버튼을 추가
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ 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)}`),
|
||||||
|
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
|
||||||
listAdminCustomItems: ({ q = '', page = 1, limit = 50, orphanOnly = false } = {}) =>
|
listAdminCustomItems: ({ q = '', page = 1, limit = 50, orphanOnly = false } = {}) =>
|
||||||
request(
|
request(
|
||||||
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&orphanOnly=${encodeURIComponent(orphanOnly)}`
|
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&orphanOnly=${encodeURIComponent(orphanOnly)}`
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import Sortable from 'sortablejs'
|
||||||
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'
|
||||||
@@ -13,6 +14,7 @@ const gameMode = ref('existing')
|
|||||||
const games = ref([])
|
const games = ref([])
|
||||||
const selectedGameId = ref('')
|
const selectedGameId = ref('')
|
||||||
const selectedGame = ref(null)
|
const selectedGame = ref(null)
|
||||||
|
const featuredGameIds = ref([])
|
||||||
|
|
||||||
const customItems = ref([])
|
const customItems = ref([])
|
||||||
const customItemQuery = ref('')
|
const customItemQuery = ref('')
|
||||||
@@ -36,20 +38,30 @@ const itemPreviewUrl = ref('')
|
|||||||
const thumbPreviewUrl = ref('')
|
const thumbPreviewUrl = ref('')
|
||||||
const itemFileInput = ref(null)
|
const itemFileInput = ref(null)
|
||||||
const thumbFileInput = ref(null)
|
const thumbFileInput = ref(null)
|
||||||
|
const featuredListEl = ref(null)
|
||||||
|
const featuredSortable = ref(null)
|
||||||
|
|
||||||
const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id)
|
const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id)
|
||||||
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
|
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
|
||||||
const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value)
|
const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value)
|
||||||
const customItemPageCount = computed(() => Math.max(1, Math.ceil(customItemTotal.value / customItemLimit.value)))
|
const customItemPageCount = computed(() => Math.max(1, Math.ceil(customItemTotal.value / customItemLimit.value)))
|
||||||
|
const featuredGames = computed(() =>
|
||||||
|
featuredGameIds.value
|
||||||
|
.map((gameId) => games.value.find((game) => game.id === gameId))
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
const availableGamesForFeatured = computed(() => games.value.filter((game) => !featuredGameIds.value.includes(game.id)))
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await auth.refresh()
|
await auth.refresh()
|
||||||
await Promise.all([refreshGames(), refreshCustomItems(), refreshUsers()])
|
await Promise.all([refreshGames(), refreshCustomItems(), refreshUsers()])
|
||||||
|
await syncFeaturedSortable()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
clearPreviewUrl('item')
|
clearPreviewUrl('item')
|
||||||
clearPreviewUrl('thumb')
|
clearPreviewUrl('thumb')
|
||||||
|
destroyFeaturedSortable()
|
||||||
})
|
})
|
||||||
|
|
||||||
function resetMessages() {
|
function resetMessages() {
|
||||||
@@ -66,11 +78,44 @@ async function refreshGames() {
|
|||||||
try {
|
try {
|
||||||
const data = await api.listGames()
|
const data = await api.listGames()
|
||||||
games.value = data.games || []
|
games.value = data.games || []
|
||||||
|
featuredGameIds.value = games.value
|
||||||
|
.filter((game) => game.displayRank != null)
|
||||||
|
.sort((a, b) => a.displayRank - b.displayRank)
|
||||||
|
.map((game) => game.id)
|
||||||
|
await syncFeaturedSortable()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '게임 목록을 불러오지 못했어요.'
|
error.value = '게임 목록을 불러오지 못했어요.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function destroyFeaturedSortable() {
|
||||||
|
if (featuredSortable.value) {
|
||||||
|
featuredSortable.value.destroy()
|
||||||
|
featuredSortable.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncFeaturedSortable() {
|
||||||
|
await nextTick()
|
||||||
|
destroyFeaturedSortable()
|
||||||
|
if (!featuredListEl.value) return
|
||||||
|
|
||||||
|
featuredSortable.value = Sortable.create(featuredListEl.value, {
|
||||||
|
animation: 160,
|
||||||
|
draggable: '[data-featured-id]',
|
||||||
|
handle: '[data-featured-handle]',
|
||||||
|
ghostClass: 'ghost',
|
||||||
|
chosenClass: 'chosen',
|
||||||
|
onEnd: (evt) => {
|
||||||
|
if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
|
||||||
|
const nextIds = [...featuredGameIds.value]
|
||||||
|
const [moved] = nextIds.splice(evt.oldIndex, 1)
|
||||||
|
nextIds.splice(evt.newIndex, 0, moved)
|
||||||
|
featuredGameIds.value = nextIds
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshCustomItems() {
|
async function refreshCustomItems() {
|
||||||
if (!auth.user?.isAdmin) return
|
if (!auth.user?.isAdmin) return
|
||||||
try {
|
try {
|
||||||
@@ -416,13 +461,56 @@ function fmt(ts) {
|
|||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addFeaturedGame(gameId) {
|
||||||
|
resetMessages()
|
||||||
|
if (!gameId || featuredGameIds.value.includes(gameId)) return
|
||||||
|
if (featuredGameIds.value.length >= 50) {
|
||||||
|
error.value = '상단 고정 게임은 최대 50개까지만 설정할 수 있어요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
featuredGameIds.value = [...featuredGameIds.value, gameId]
|
||||||
|
syncFeaturedSortable()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFeaturedGame(gameId) {
|
||||||
|
resetMessages()
|
||||||
|
featuredGameIds.value = featuredGameIds.value.filter((id) => id !== gameId)
|
||||||
|
syncFeaturedSortable()
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveFeaturedGame(gameId, direction) {
|
||||||
|
const currentIndex = featuredGameIds.value.indexOf(gameId)
|
||||||
|
const nextIndex = currentIndex + direction
|
||||||
|
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= featuredGameIds.value.length) return
|
||||||
|
const nextIds = [...featuredGameIds.value]
|
||||||
|
const [moved] = nextIds.splice(currentIndex, 1)
|
||||||
|
nextIds.splice(nextIndex, 0, moved)
|
||||||
|
featuredGameIds.value = nextIds
|
||||||
|
syncFeaturedSortable()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFeaturedOrder() {
|
||||||
|
resetMessages()
|
||||||
|
try {
|
||||||
|
const data = await api.updateAdminGameDisplayOrder({ gameIds: featuredGameIds.value })
|
||||||
|
games.value = data.games || []
|
||||||
|
featuredGameIds.value = games.value
|
||||||
|
.filter((game) => game.displayRank != null)
|
||||||
|
.sort((a, b) => a.displayRank - b.displayRank)
|
||||||
|
.map((game) => game.id)
|
||||||
|
success.value = '홈 화면 게임 순서를 저장했어요.'
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '게임 순서 저장에 실패했어요.'
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="wrap">
|
<section class="wrap">
|
||||||
<h2 class="title">관리자</h2>
|
<!-- <h2 class="title">관리자</h2> -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="desc">기능이 많아진 만큼 관리 영역을 게임, 아이템, 회원 관리로 나눠서 정리합니다.</div>
|
<!-- <div class="desc">기능이 많아진 만큼 관리 영역을 게임, 아이템, 회원 관리로 나눠서 정리합니다.</div> -->
|
||||||
<div v-if="!auth.user" class="warn">로그인이 필요해요.</div>
|
<div v-if="!auth.user" class="warn">로그인이 필요해요.</div>
|
||||||
<div v-else-if="!isAdmin" class="warn">이 계정은 관리자 권한이 없어요.</div>
|
<div v-else-if="!isAdmin" class="warn">이 계정은 관리자 권한이 없어요.</div>
|
||||||
|
|
||||||
@@ -437,6 +525,56 @@ function fmt(ts) {
|
|||||||
<div v-if="success" class="success">{{ success }}</div>
|
<div v-if="success" class="success">{{ success }}</div>
|
||||||
|
|
||||||
<template v-if="activeTab === 'games'">
|
<template v-if="activeTab === 'games'">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="sectionHeader">
|
||||||
|
<div>
|
||||||
|
<div class="panel__title">홈 화면 상단 고정 순서</div>
|
||||||
|
<div class="hint hint--tight">여기에 넣은 게임은 지정한 순서대로 먼저 노출되고, 나머지 게임은 최근 생성순으로 뒤에 이어집니다. 최대 50개까지 설정할 수 있어요.</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--primary" @click="saveFeaturedOrder">순서 저장</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="featuredOrderPanel">
|
||||||
|
<div class="featuredOrderPanel__list">
|
||||||
|
<div class="section__title">상단 고정 목록</div>
|
||||||
|
<div v-if="!featuredGames.length" class="hint">아직 상단 고정 게임이 없어요.</div>
|
||||||
|
<div v-else ref="featuredListEl" class="featuredList">
|
||||||
|
<article v-for="(game, index) in featuredGames" :key="game.id" class="featuredCard" :data-featured-id="game.id">
|
||||||
|
<div class="featuredCard__meta">
|
||||||
|
<span class="featuredCard__rank">{{ index + 1 }}</span>
|
||||||
|
<div>
|
||||||
|
<div class="featuredCard__title">{{ game.name }}</div>
|
||||||
|
<div class="featuredCard__id">{{ game.id }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="featuredCard__actions">
|
||||||
|
<button class="btn btn--ghost btn--small" data-featured-handle>드래그</button>
|
||||||
|
<button class="btn btn--ghost btn--small" :disabled="index === 0" @click="moveFeaturedGame(game.id, -1)">위로</button>
|
||||||
|
<button class="btn btn--ghost btn--small" :disabled="index === featuredGames.length - 1" @click="moveFeaturedGame(game.id, 1)">아래로</button>
|
||||||
|
<button class="btn btn--danger btn--small" @click="removeFeaturedGame(game.id)">제외</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="featuredOrderPanel__picker">
|
||||||
|
<div class="section__title">게임 추가</div>
|
||||||
|
<div class="featuredPickerList">
|
||||||
|
<button
|
||||||
|
v-for="game in availableGamesForFeatured"
|
||||||
|
:key="game.id"
|
||||||
|
class="featuredPickerItem"
|
||||||
|
:disabled="featuredGameIds.length >= 50"
|
||||||
|
@click="addFeaturedGame(game.id)"
|
||||||
|
>
|
||||||
|
<span>{{ game.name }}</span>
|
||||||
|
<span class="featuredPickerItem__id">{{ game.id }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modeTabs">
|
<div class="modeTabs">
|
||||||
<button class="modeTab" :class="{ 'modeTab--active': gameMode === 'existing' }" @click="setGameMode('existing')">
|
<button class="modeTab" :class="{ 'modeTab--active': gameMode === 'existing' }" @click="setGameMode('existing')">
|
||||||
등록된 게임 선택
|
등록된 게임 선택
|
||||||
@@ -448,12 +586,12 @@ function fmt(ts) {
|
|||||||
|
|
||||||
<div class="panel panel--compact">
|
<div class="panel panel--compact">
|
||||||
<template v-if="gameMode === 'existing'">
|
<template v-if="gameMode === 'existing'">
|
||||||
<div class="panel__title">등록된 게임 선택</div>
|
<!-- <div class="panel__title">등록된 게임 선택</div> -->
|
||||||
<select v-model="selectedGameId" class="select" @change="loadGame">
|
<select v-model="selectedGameId" class="select" @change="loadGame">
|
||||||
<option value="">게임을 선택해주세요</option>
|
<option value="">게임을 선택해주세요</option>
|
||||||
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
|
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="hint">이 영역은 게임 자체와 관리자 기본 아이템만 관리합니다. 여기서 아이템을 삭제해도 사용자 커스텀 이미지는 삭제되지 않아요.</div>
|
<!-- <div class="hint">이 영역은 게임 자체와 관리자 기본 아이템만 관리합니다. 여기서 아이템을 삭제해도 사용자 커스텀 이미지는 삭제되지 않아요.</div> -->
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -692,7 +830,94 @@ function fmt(ts) {
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
.panel--compact {
|
.panel--compact {
|
||||||
max-width: 760px;
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
.featuredOrderPanel {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.95fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.featuredOrderPanel__list,
|
||||||
|
.featuredOrderPanel__picker {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.featuredList,
|
||||||
|
.featuredPickerList {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.featuredCard {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
.featuredCard__meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.featuredCard__rank {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(96, 165, 250, 0.18);
|
||||||
|
font-weight: 900;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.featuredCard__title {
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.featuredCard__id {
|
||||||
|
margin-top: 4px;
|
||||||
|
opacity: 0.68;
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.featuredCard__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.featuredCard [data-featured-handle] {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.featuredPickerItem {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(0, 0, 0, 0.16);
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.featuredPickerItem:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.featuredPickerItem__id {
|
||||||
|
opacity: 0.64;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.panel__title,
|
.panel__title,
|
||||||
.section__title {
|
.section__title {
|
||||||
@@ -761,7 +986,7 @@ function fmt(ts) {
|
|||||||
background: rgba(0, 0, 0, 0.18);
|
background: rgba(0, 0, 0, 0.18);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
outline: none;
|
outline: none;
|
||||||
margin-top: 10px;
|
/* margin-top: 10px; */
|
||||||
}
|
}
|
||||||
.input--compact {
|
.input--compact {
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
@@ -794,6 +1019,17 @@ function fmt(ts) {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
.btn--small {
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.ghost {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
.chosen {
|
||||||
|
outline: 2px solid rgba(96, 165, 250, 0.45);
|
||||||
|
}
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
@@ -1039,6 +1275,7 @@ function fmt(ts) {
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
|
.featuredOrderPanel,
|
||||||
.section--topGrid,
|
.section--topGrid,
|
||||||
.toolbar,
|
.toolbar,
|
||||||
.itemComposer {
|
.itemComposer {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function avatarSrcOf(tierList) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function avatarFallbackOf(tierList) {
|
function avatarFallbackOf(tierList) {
|
||||||
return displayNameOf(tierList).trim().charAt(0).toUpperCase() || '?'
|
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -148,17 +148,22 @@ function openTierList(id) {
|
|||||||
}
|
}
|
||||||
.list {
|
.list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
}
|
}
|
||||||
.row {
|
.row {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 12px;
|
padding: 14px;
|
||||||
border-radius: 14px;
|
border-radius: 16px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
align-content: start;
|
||||||
|
min-height: 168px;
|
||||||
}
|
}
|
||||||
.row:hover {
|
.row:hover {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
@@ -166,13 +171,13 @@ function openTierList(id) {
|
|||||||
.row__title {
|
.row__title {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
.row__head {
|
.row__head {
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-content: start;
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
.row__author {
|
.row__author {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -198,7 +203,17 @@ function openTierList(id) {
|
|||||||
}
|
}
|
||||||
.row__meta {
|
.row__meta {
|
||||||
opacity: 0.78;
|
opacity: 0.78;
|
||||||
margin-top: 6px;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.list {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const auth = useAuthStore()
|
|||||||
|
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const games = computed(() => items.value)
|
const games = computed(() => items.value.filter((item) => item.id !== 'freeform'))
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -41,22 +41,16 @@ function thumbUrl(g) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="hero">
|
<section class="topBar">
|
||||||
<h1 class="hero__title">티어표 메이커</h1>
|
<div class="topBar__copy">
|
||||||
<p class="hero__desc">
|
<h1 class="topBar__title">게임 선택</h1>
|
||||||
게임을 선택하면 새 티어표를 만들거나, 다른 사람들이 올린 티어표를 볼 수 있어요.
|
<p class="topBar__desc">관리자 고정 순서가 있으면 먼저 보여주고, 그 외 게임은 최근 생성순으로 정렬됩니다.</p>
|
||||||
</p>
|
</div>
|
||||||
|
<button class="customTierBtn" @click="goFreeform">{{ auth.user ? '커스텀 티어표 만들기' : '로그인 후 커스텀 티어표 만들기' }}</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
<section class="grid">
|
<section class="grid">
|
||||||
<button class="card card--freeform" @click="goFreeform">
|
|
||||||
<div class="thumbWrap thumbWrap--freeform">
|
|
||||||
<div class="thumbFallback">+</div>
|
|
||||||
</div>
|
|
||||||
<div class="card__eyebrow">{{ auth.user ? '템플릿 없이 시작' : '로그인 후 작성 가능' }}</div>
|
|
||||||
<div class="card__title">직접 티어표 만들기</div>
|
|
||||||
</button>
|
|
||||||
<button v-for="g in games" :key="g.id" class="card" @click="goGame(g.id)">
|
<button v-for="g in games" :key="g.id" class="card" @click="goGame(g.id)">
|
||||||
<div class="thumbWrap">
|
<div class="thumbWrap">
|
||||||
<img v-if="thumbUrl(g)" class="thumb" :src="thumbUrl(g)" :alt="g.name" />
|
<img v-if="thumbUrl(g)" class="thumb" :src="thumbUrl(g)" :alt="g.name" />
|
||||||
@@ -68,22 +62,40 @@ function thumbUrl(g) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.hero {
|
.topBar {
|
||||||
padding: 18px 2px 14px;
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
.hero__title {
|
.topBar__copy {
|
||||||
font-size: 34px;
|
display: grid;
|
||||||
letter-spacing: -0.03em;
|
gap: 6px;
|
||||||
margin: 0 0 8px;
|
|
||||||
}
|
}
|
||||||
.hero__desc {
|
.topBar__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
opacity: 0.86;
|
font-size: 30px;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
.topBar__desc {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.78;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
.customTierBtn {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(96, 165, 250, 0.28);
|
||||||
|
background: linear-gradient(135deg, rgba(96, 165, 250, 0.2), rgba(16, 185, 129, 0.16));
|
||||||
|
color: rgba(255, 255, 255, 0.96);
|
||||||
|
font-weight: 900;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
@@ -110,12 +122,9 @@ function thumbUrl(g) {
|
|||||||
background: rgba(255, 255, 255, 0.07);
|
background: rgba(255, 255, 255, 0.07);
|
||||||
border-color: rgba(255, 255, 255, 0.18);
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
}
|
}
|
||||||
.card--freeform {
|
|
||||||
background: linear-gradient(135deg, rgba(96, 165, 250, 0.12), rgba(16, 185, 129, 0.12));
|
|
||||||
}
|
|
||||||
.thumbWrap {
|
.thumbWrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 140px;
|
aspect-ratio: 16 / 9;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(0, 0, 0, 0.18);
|
background: rgba(0, 0, 0, 0.18);
|
||||||
@@ -123,11 +132,6 @@ function thumbUrl(g) {
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
.thumbWrap--freeform {
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top, rgba(96, 165, 250, 0.18), transparent 50%),
|
|
||||||
rgba(0, 0, 0, 0.18);
|
|
||||||
}
|
|
||||||
.thumb {
|
.thumb {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -142,16 +146,20 @@ function thumbUrl(g) {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
.card__eyebrow {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 800;
|
|
||||||
opacity: 0.7;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
.topBar {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.customTierBtn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.grid {
|
.grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@media (min-width: 721px) and (max-width: 1100px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function avatarSrcOf(tierList) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function avatarFallbackOf(tierList) {
|
function avatarFallbackOf(tierList) {
|
||||||
return displayNameOf(tierList).trim().charAt(0).toUpperCase() || '?'
|
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -31,8 +31,13 @@ const isPublic = ref(true)
|
|||||||
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 ownerId = ref('')
|
const ownerId = ref('')
|
||||||
|
const authorName = ref('')
|
||||||
|
const authorAccountName = ref('')
|
||||||
|
const updatedAt = ref(0)
|
||||||
const isDragActive = ref(false)
|
const isDragActive = ref(false)
|
||||||
|
const iconSize = ref(80)
|
||||||
|
|
||||||
const boardEl = ref(null)
|
const boardEl = ref(null)
|
||||||
const exportBoardEl = ref(null)
|
const exportBoardEl = ref(null)
|
||||||
@@ -46,6 +51,52 @@ const dropSortables = ref([])
|
|||||||
|
|
||||||
const isNewTierList = computed(() => tierListId.value === 'new')
|
const isNewTierList = computed(() => tierListId.value === 'new')
|
||||||
const canEdit = computed(() => !!auth.user && (!ownerId.value || ownerId.value === auth.user.id))
|
const canEdit = computed(() => !!auth.user && (!ownerId.value || ownerId.value === auth.user.id))
|
||||||
|
const iconSizeOptions = [48, 60, 80, 100, 120]
|
||||||
|
const hasCustomTitle = computed(() => !!(title.value || '').trim())
|
||||||
|
const fallbackTimestamp = computed(() => (updatedAt.value ? updatedAt.value : Date.now()))
|
||||||
|
const effectiveAuthorName = computed(() => {
|
||||||
|
const currentNickname = (auth.user?.nickname || '').trim()
|
||||||
|
if (currentNickname) return currentNickname
|
||||||
|
if ((authorName.value || '').trim()) return authorName.value.trim()
|
||||||
|
const currentEmail = (auth.user?.email || '').trim()
|
||||||
|
if (currentEmail) return currentEmail.split('@')[0] || currentEmail
|
||||||
|
return (authorAccountName.value || '').trim() || 'unknown'
|
||||||
|
})
|
||||||
|
const effectiveTitle = computed(() => {
|
||||||
|
const customTitle = (title.value || '').trim()
|
||||||
|
if (customTitle) return customTitle
|
||||||
|
return `이름 없음 ${formatTitleDate(fallbackTimestamp.value)}`
|
||||||
|
})
|
||||||
|
const untitledWarning = computed(
|
||||||
|
() =>
|
||||||
|
canEdit.value &&
|
||||||
|
!hasCustomTitle.value &&
|
||||||
|
'제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.'
|
||||||
|
)
|
||||||
|
|
||||||
|
function formatTitleDate(ts) {
|
||||||
|
const date = new Date(ts)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExportDate(ts) {
|
||||||
|
return new Date(ts).toLocaleString('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function setIconSize(nextSize) {
|
||||||
|
iconSize.value = nextSize
|
||||||
|
}
|
||||||
|
|
||||||
function setGroupDropEl(groupId, el) {
|
function setGroupDropEl(groupId, el) {
|
||||||
if (!el) {
|
if (!el) {
|
||||||
@@ -218,13 +269,13 @@ async function downloadImage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const targetEl = exportBoardEl.value || boardEl.value
|
const targetEl = exportBoardEl.value || boardEl.value
|
||||||
const blob = await htmlToImage.toBlob(targetEl, { pixelRatio: 2, backgroundColor: '#0b1220' })
|
const blob = await htmlToImage.toBlob(targetEl, { pixelRatio: 1.5, backgroundColor: '#0b1220' })
|
||||||
if (!blob) throw new Error('image_export_failed')
|
if (!blob) throw new Error('image_export_failed')
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = `${(title.value || gameName.value || 'tierlist').trim()}.png`
|
a.download = `${effectiveTitle.value.trim()}.png`
|
||||||
document.body.appendChild(a)
|
document.body.appendChild(a)
|
||||||
a.click()
|
a.click()
|
||||||
a.remove()
|
a.remove()
|
||||||
@@ -272,7 +323,7 @@ async function uploadPendingCustomItems() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildPayload(existingId) {
|
function buildPayload(existingId) {
|
||||||
const finalTitle = (title.value || '').trim() || `${(gameName.value || gameId.value).trim()} 티어표`
|
const finalTitle = effectiveTitle.value
|
||||||
return {
|
return {
|
||||||
id: existingId || undefined,
|
id: existingId || undefined,
|
||||||
gameId: gameId.value,
|
gameId: gameId.value,
|
||||||
@@ -292,6 +343,10 @@ async function save() {
|
|||||||
const payload = buildPayload(tierListId.value && tierListId.value !== 'new' ? tierListId.value : null)
|
const payload = buildPayload(tierListId.value && tierListId.value !== 'new' ? tierListId.value : null)
|
||||||
const res = await api.saveTierList(payload)
|
const res = await api.saveTierList(payload)
|
||||||
if (tierListId.value === 'new') history.replaceState({}, '', `/editor/${gameId.value}/${res.tierList.id}`)
|
if (tierListId.value === 'new') history.replaceState({}, '', `/editor/${gameId.value}/${res.tierList.id}`)
|
||||||
|
updatedAt.value = Number(res.tierList?.updatedAt || Date.now())
|
||||||
|
authorName.value = res.tierList?.authorName || effectiveAuthorName.value
|
||||||
|
authorAccountName.value = res.tierList?.authorAccountName || authorAccountName.value
|
||||||
|
isSaveModalOpen.value = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '저장 실패: 로그인 상태인지 확인해주세요.'
|
error.value = '저장 실패: 로그인 상태인지 확인해주세요.'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -299,6 +354,10 @@ async function save() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeSaveModal() {
|
||||||
|
isSaveModalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
async function removeTierList() {
|
async function removeTierList() {
|
||||||
if (!canEdit.value || isNewTierList.value) return
|
if (!canEdit.value || isNewTierList.value) return
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -315,6 +374,8 @@ async function removeTierList() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
;(async () => {
|
;(async () => {
|
||||||
await auth.refresh()
|
await auth.refresh()
|
||||||
|
authorName.value = (auth.user?.nickname || '').trim()
|
||||||
|
authorAccountName.value = ((auth.user?.email || '').trim().split('@')[0] || '').trim()
|
||||||
|
|
||||||
if (isNewTierList.value && !auth.user) {
|
if (isNewTierList.value && !auth.user) {
|
||||||
router.replace(`/login?redirect=/editor/${gameId.value}/new`)
|
router.replace(`/login?redirect=/editor/${gameId.value}/new`)
|
||||||
@@ -346,6 +407,9 @@ onMounted(() => {
|
|||||||
title.value = t.title
|
title.value = t.title
|
||||||
description.value = t.description || ''
|
description.value = t.description || ''
|
||||||
isPublic.value = !!t.isPublic
|
isPublic.value = !!t.isPublic
|
||||||
|
authorName.value = t.authorName || ''
|
||||||
|
authorAccountName.value = t.authorAccountName || ''
|
||||||
|
updatedAt.value = Number(t.updatedAt || 0)
|
||||||
groups.value = t.groups
|
groups.value = t.groups
|
||||||
const map = {}
|
const map = {}
|
||||||
;(t.pool || []).forEach((it) => (map[it.id] = it))
|
;(t.pool || []).forEach((it) => (map[it.id] = it))
|
||||||
@@ -375,6 +439,7 @@ onUnmounted(() => {
|
|||||||
<div class="head__meta">
|
<div class="head__meta">
|
||||||
<div class="kicker">{{ gameName || gameId }}</div>
|
<div class="kicker">{{ gameName || gameId }}</div>
|
||||||
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" :readonly="!canEdit" />
|
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" :readonly="!canEdit" />
|
||||||
|
<div v-if="untitledWarning" class="titleNotice">{{ untitledWarning }}</div>
|
||||||
<input
|
<input
|
||||||
v-model="description"
|
v-model="description"
|
||||||
class="descInput"
|
class="descInput"
|
||||||
@@ -407,13 +472,39 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
|
|
||||||
<section class="layout">
|
<div v-if="isSaveModalOpen" class="modalOverlay" @click.self="closeSaveModal">
|
||||||
|
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="saveModalTitle">
|
||||||
|
<div id="saveModalTitle" class="modalCard__title">저장 완료</div>
|
||||||
|
<div class="modalCard__desc">티어표가 저장되었어요. 이어서 더 수정한 뒤 다시 저장할 수도 있어요.</div>
|
||||||
|
<div class="modalCard__actions">
|
||||||
|
<button class="btn btn--save" @click="closeSaveModal">확인</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="layout" :style="{ '--thumb-size': `${iconSize}px` }">
|
||||||
<div ref="boardEl" class="board">
|
<div ref="boardEl" class="board">
|
||||||
<div v-if="canEdit && !isExporting" class="boardTools">
|
<div v-if="canEdit && !isExporting" class="boardTools">
|
||||||
<button class="btn btn--ghost" @click="addGroup">티어 추가</button>
|
<div class="boardTools__left">
|
||||||
|
<button class="btn btn--ghost" @click="addGroup">티어 추가</button>
|
||||||
|
</div>
|
||||||
|
<div class="boardTools__right">
|
||||||
|
<span class="boardTools__label">아이콘 크기</span>
|
||||||
|
<div class="sizePicker">
|
||||||
|
<button
|
||||||
|
v-for="size in iconSizeOptions"
|
||||||
|
:key="size"
|
||||||
|
class="sizePicker__button"
|
||||||
|
:class="{ 'sizePicker__button--active': iconSize === size }"
|
||||||
|
@click="setIconSize(size)"
|
||||||
|
>
|
||||||
|
{{ size }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
|
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
|
||||||
<div v-if="isExporting" class="exportBoard__title">{{ title || gameName || gameId }}</div>
|
<div v-if="isExporting" class="exportBoard__title">{{ effectiveTitle }}</div>
|
||||||
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</div>
|
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</div>
|
||||||
<div ref="groupListEl" class="rows">
|
<div ref="groupListEl" class="rows">
|
||||||
<div v-for="g in groups" :key="g.id" class="row">
|
<div v-for="g in groups" :key="g.id" class="row">
|
||||||
@@ -433,13 +524,17 @@ onUnmounted(() => {
|
|||||||
:data-group-id="g.id"
|
:data-group-id="g.id"
|
||||||
:ref="(el) => setGroupDropEl(g.id, el)"
|
:ref="(el) => setGroupDropEl(g.id, el)"
|
||||||
>
|
>
|
||||||
<div 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isExporting" class="exportBoard__footer">
|
||||||
|
<span>{{ effectiveAuthorName }}</span>
|
||||||
|
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -512,6 +607,11 @@ onUnmounted(() => {
|
|||||||
opacity: 0.78;
|
opacity: 0.78;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
.titleNotice {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: rgba(251, 191, 36, 0.94);
|
||||||
|
}
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
@@ -609,14 +709,94 @@ onUnmounted(() => {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
}
|
}
|
||||||
.boardTools {
|
.modalOverlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(4, 8, 16, 0.68);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
.modalCard {
|
||||||
|
width: min(100%, 420px);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
background: linear-gradient(180deg, rgba(17, 24, 39, 0.96), rgba(11, 18, 32, 0.96));
|
||||||
|
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.38);
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.modalCard__title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.modalCard__desc {
|
||||||
|
line-height: 1.6;
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
.modalCard__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.boardTools {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.boardTools__left,
|
||||||
|
.boardTools__right {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.boardTools__left {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.boardTools__label {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.76;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.sizePicker {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sizePicker__button {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 48px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.sizePicker__button--active {
|
||||||
|
background: rgba(96, 165, 250, 0.24);
|
||||||
|
border-color: rgba(96, 165, 250, 0.38);
|
||||||
}
|
}
|
||||||
.exportBoard--active {
|
.exportBoard--active {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
width: 960px;
|
||||||
|
max-width: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 44px 52px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(96, 165, 250, 0.14), transparent 38%),
|
||||||
|
rgba(11, 18, 32, 0.98);
|
||||||
}
|
}
|
||||||
.exportBoard__title {
|
.exportBoard__title {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
@@ -631,6 +811,16 @@ onUnmounted(() => {
|
|||||||
opacity: 0.74;
|
opacity: 0.74;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
.exportBoard__footer {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 18px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
.rows {
|
.rows {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -701,7 +891,7 @@ onUnmounted(() => {
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: rgba(0, 0, 0, 0.18);
|
background: rgba(0, 0, 0, 0.18);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||||
min-height: 74px;
|
min-height: calc(var(--thumb-size, 80px) + 24px);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -724,8 +914,8 @@ onUnmounted(() => {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.thumb {
|
.thumb {
|
||||||
width: 48px;
|
width: var(--thumb-size, 80px);
|
||||||
height: 48px;
|
height: var(--thumb-size, 80px);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
@@ -773,7 +963,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.poolItem {
|
.poolItem {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 52px 1fr;
|
grid-template-columns: var(--thumb-size, 80px) 1fr;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
|||||||
Reference in New Issue
Block a user