Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5621362f1 | |||
| caaddb8448 | |||
| 20186f7fe2 | |||
| 77605791fb | |||
| 9bb64b52f3 | |||
| c4d896ce36 | |||
| 1957f30341 | |||
| 8d257e21ff | |||
| 19fdf85dcc | |||
| 2626fe2335 | |||
| 074d028f04 | |||
| 208e9709f8 | |||
| 4ed7f275ba |
@@ -138,6 +138,7 @@ function mapTierListRow(row) {
|
|||||||
description: row.description || '',
|
description: row.description || '',
|
||||||
isPublic: !!row.is_public,
|
isPublic: !!row.is_public,
|
||||||
showCharacterNames: !!row.show_character_names,
|
showCharacterNames: !!row.show_character_names,
|
||||||
|
iconSize: Number(row.icon_size || 80),
|
||||||
sourceTierListId: row.source_tierlist_id || '',
|
sourceTierListId: row.source_tierlist_id || '',
|
||||||
sourceSnapshotTitle: row.source_snapshot_title || '',
|
sourceSnapshotTitle: row.source_snapshot_title || '',
|
||||||
sourceSnapshotAuthor: row.source_snapshot_author || '',
|
sourceSnapshotAuthor: row.source_snapshot_author || '',
|
||||||
@@ -314,6 +315,7 @@ async function ensureSchema() {
|
|||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
is_public TINYINT(1) NOT NULL DEFAULT 0,
|
is_public TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
show_character_names TINYINT(1) NOT NULL DEFAULT 0,
|
show_character_names TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
icon_size INT NOT NULL DEFAULT 80,
|
||||||
source_tierlist_id VARCHAR(64) NULL DEFAULT NULL,
|
source_tierlist_id VARCHAR(64) NULL DEFAULT NULL,
|
||||||
source_snapshot_title VARCHAR(120) NOT NULL DEFAULT '',
|
source_snapshot_title VARCHAR(120) NOT NULL DEFAULT '',
|
||||||
source_snapshot_author VARCHAR(120) NOT NULL DEFAULT '',
|
source_snapshot_author VARCHAR(120) NOT NULL DEFAULT '',
|
||||||
@@ -455,9 +457,13 @@ async function ensureSchema() {
|
|||||||
if (!tierListShowNamesColumns.length) {
|
if (!tierListShowNamesColumns.length) {
|
||||||
await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
|
await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
|
||||||
}
|
}
|
||||||
|
const tierListIconSizeColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'icon_size'")
|
||||||
|
if (!tierListIconSizeColumns.length) {
|
||||||
|
await query("ALTER TABLE tierlists ADD COLUMN icon_size INT NOT NULL DEFAULT 80 AFTER show_character_names")
|
||||||
|
}
|
||||||
const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'")
|
const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'")
|
||||||
if (!tierListSourceIdColumns.length) {
|
if (!tierListSourceIdColumns.length) {
|
||||||
await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER show_character_names")
|
await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER icon_size")
|
||||||
} else if (tierListSourceIdColumns[0]?.Null !== 'YES') {
|
} else if (tierListSourceIdColumns[0]?.Null !== 'YES') {
|
||||||
await query('ALTER TABLE tierlists MODIFY source_tierlist_id VARCHAR(64) NULL DEFAULT NULL')
|
await query('ALTER TABLE tierlists MODIFY source_tierlist_id VARCHAR(64) NULL DEFAULT NULL')
|
||||||
}
|
}
|
||||||
@@ -1847,6 +1853,7 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
|
|||||||
t.description,
|
t.description,
|
||||||
t.is_public,
|
t.is_public,
|
||||||
t.show_character_names,
|
t.show_character_names,
|
||||||
|
t.icon_size,
|
||||||
t.source_tierlist_id,
|
t.source_tierlist_id,
|
||||||
t.source_snapshot_title,
|
t.source_snapshot_title,
|
||||||
t.source_snapshot_author,
|
t.source_snapshot_author,
|
||||||
@@ -1990,6 +1997,7 @@ async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit
|
|||||||
t.description,
|
t.description,
|
||||||
t.is_public,
|
t.is_public,
|
||||||
t.show_character_names,
|
t.show_character_names,
|
||||||
|
t.icon_size,
|
||||||
t.source_tierlist_id,
|
t.source_tierlist_id,
|
||||||
t.source_snapshot_title,
|
t.source_snapshot_title,
|
||||||
t.source_snapshot_author,
|
t.source_snapshot_author,
|
||||||
@@ -2093,6 +2101,7 @@ async function findTierListById(id, currentUserId = '') {
|
|||||||
t.description,
|
t.description,
|
||||||
t.is_public,
|
t.is_public,
|
||||||
t.show_character_names,
|
t.show_character_names,
|
||||||
|
t.icon_size,
|
||||||
t.source_tierlist_id,
|
t.source_tierlist_id,
|
||||||
t.source_snapshot_title,
|
t.source_snapshot_title,
|
||||||
t.source_snapshot_author,
|
t.source_snapshot_author,
|
||||||
@@ -2346,6 +2355,7 @@ async function saveTierList({
|
|||||||
description,
|
description,
|
||||||
isPublic,
|
isPublic,
|
||||||
showCharacterNames = false,
|
showCharacterNames = false,
|
||||||
|
iconSize = 80,
|
||||||
sourceTierListId = '',
|
sourceTierListId = '',
|
||||||
sourceSnapshotTitle = '',
|
sourceSnapshotTitle = '',
|
||||||
sourceSnapshotAuthor = '',
|
sourceSnapshotAuthor = '',
|
||||||
@@ -2360,10 +2370,10 @@ async function saveTierList({
|
|||||||
await query(
|
await query(
|
||||||
`
|
`
|
||||||
UPDATE tierlists
|
UPDATE tierlists
|
||||||
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, show_character_names = ?, source_tierlist_id = ?, source_snapshot_title = ?, source_snapshot_author = ?, groups_json = ?, pool_json = ?, updated_at = ?
|
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, show_character_names = ?, icon_size = ?, source_tierlist_id = ?, source_snapshot_title = ?, source_snapshot_author = ?, groups_json = ?, pool_json = ?, updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`,
|
`,
|
||||||
[title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), now(), existing.id]
|
[title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, Number(iconSize) || 80, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), now(), existing.id]
|
||||||
)
|
)
|
||||||
return findTierListById(existing.id, authorId)
|
return findTierListById(existing.id, authorId)
|
||||||
}
|
}
|
||||||
@@ -2373,11 +2383,11 @@ async function saveTierList({
|
|||||||
await query(
|
await query(
|
||||||
`
|
`
|
||||||
INSERT INTO tierlists (
|
INSERT INTO tierlists (
|
||||||
id, author_id, game_id, title, thumbnail_src, description, is_public, show_character_names, source_tierlist_id, source_snapshot_title, source_snapshot_author, groups_json, pool_json, created_at, updated_at
|
id, author_id, game_id, title, thumbnail_src, description, is_public, show_character_names, icon_size, source_tierlist_id, source_snapshot_title, source_snapshot_author, groups_json, pool_json, created_at, updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
[nextId, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt]
|
[nextId, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, Number(iconSize) || 80, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt]
|
||||||
)
|
)
|
||||||
return findTierListById(nextId, authorId)
|
return findTierListById(nextId, authorId)
|
||||||
}
|
}
|
||||||
@@ -2396,6 +2406,7 @@ async function duplicateTierListForUser({ tierList, targetUserId }) {
|
|||||||
description: tierList.description || '',
|
description: tierList.description || '',
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
showCharacterNames: !!tierList.showCharacterNames,
|
showCharacterNames: !!tierList.showCharacterNames,
|
||||||
|
iconSize: Number(tierList.iconSize || 80),
|
||||||
sourceTierListId: tierList.id,
|
sourceTierListId: tierList.id,
|
||||||
sourceSnapshotTitle: tierList.title || '',
|
sourceSnapshotTitle: tierList.title || '',
|
||||||
sourceSnapshotAuthor: tierList.authorName || tierList.authorAccountName || '',
|
sourceSnapshotAuthor: tierList.authorName || tierList.authorAccountName || '',
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ const tierListUpsertSchema = z.object({
|
|||||||
description: z.string().max(1000).optional().default(''),
|
description: z.string().max(1000).optional().default(''),
|
||||||
isPublic: z.boolean().default(false),
|
isPublic: z.boolean().default(false),
|
||||||
showCharacterNames: z.boolean().optional().default(false),
|
showCharacterNames: z.boolean().optional().default(false),
|
||||||
|
iconSize: z.number().int().min(48).max(112).optional().default(80),
|
||||||
sourceTierListId: z.string().max(64).optional().default(''),
|
sourceTierListId: z.string().max(64).optional().default(''),
|
||||||
sourceSnapshotTitle: z.string().max(120).optional().default(''),
|
sourceSnapshotTitle: z.string().max(120).optional().default(''),
|
||||||
sourceSnapshotAuthor: z.string().max(120).optional().default(''),
|
sourceSnapshotAuthor: z.string().max(120).optional().default(''),
|
||||||
@@ -289,6 +290,7 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
description: payload.description || '',
|
description: payload.description || '',
|
||||||
isPublic: !!payload.isPublic,
|
isPublic: !!payload.isPublic,
|
||||||
showCharacterNames: !!payload.showCharacterNames,
|
showCharacterNames: !!payload.showCharacterNames,
|
||||||
|
iconSize: Number(payload.iconSize || 80),
|
||||||
sourceTierListId: payload.sourceTierListId || existing.sourceTierListId || '',
|
sourceTierListId: payload.sourceTierListId || existing.sourceTierListId || '',
|
||||||
sourceSnapshotTitle: payload.sourceSnapshotTitle || existing.sourceSnapshotTitle || '',
|
sourceSnapshotTitle: payload.sourceSnapshotTitle || existing.sourceSnapshotTitle || '',
|
||||||
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || existing.sourceSnapshotAuthor || '',
|
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || existing.sourceSnapshotAuthor || '',
|
||||||
@@ -307,6 +309,7 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
description: payload.description || '',
|
description: payload.description || '',
|
||||||
isPublic: !!payload.isPublic,
|
isPublic: !!payload.isPublic,
|
||||||
showCharacterNames: !!payload.showCharacterNames,
|
showCharacterNames: !!payload.showCharacterNames,
|
||||||
|
iconSize: Number(payload.iconSize || 80),
|
||||||
sourceTierListId: payload.sourceTierListId || '',
|
sourceTierListId: payload.sourceTierListId || '',
|
||||||
sourceSnapshotTitle: payload.sourceSnapshotTitle || '',
|
sourceSnapshotTitle: payload.sourceSnapshotTitle || '',
|
||||||
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || '',
|
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || '',
|
||||||
|
|||||||
@@ -1,7 +1,44 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-04-02 v1.4.2
|
||||||
|
- 용어 정리를 시작한 뒤에는 일부 화면만 바꾸는 것보다, 관리자 모달과 확인 메시지처럼 실제 운영 중 많이 보는 문구도 함께 맞춰 주는 편이 체감 일관성이 더 높다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.4.1
|
||||||
|
- 좌측 메뉴와 화면 타이틀의 명칭이 서로 다르면 사용자가 현재 위치를 직관적으로 매칭하기 어렵기 때문에, 메뉴 이름과 진입 타이틀을 같은 문구로 맞추는 편이 맞다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.4.0
|
||||||
|
- 서비스가 게임 외 주제 전반을 다룰 수 있는 단계에 온 만큼, 내부 모델명은 유지하더라도 사용자에게 보이는 주요 용어는 `주제 / 템플릿` 기준으로 먼저 정리하는 편이 맞다고 판단했다.
|
||||||
|
- 대규모 내부 리네이밍은 API와 DB까지 손대야 하므로, 이번 단계에서는 사용자 화면 문구만 우선 바꾸고 내부 `game` 모델은 그대로 두는 점진적 전환이 더 안전하다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.93
|
||||||
|
- 목록 카드 썸네일은 드래그 대상이 아니라 클릭 대상에 가깝기 때문에, 브라우저 기본 이미지 드래그 프리뷰는 전부 막아 두는 편이 UX 측면에서 맞다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.92
|
||||||
|
- 왼쪽 레일 활성 메뉴도 로그인 토글과 같은 이동형 배경 문법을 쓰는 편이 앱 전체 인터랙션 언어를 더 일관되게 만든다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.91
|
||||||
|
- 로그인/회원가입 탭은 즉시 배경 교체보다, 선택 배경이 실제로 이동하는 토글 문법이 더 직관적이고 상태 전환이 잘 읽힌다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.90
|
||||||
|
- 경고 수준의 CSS 진단이라도 실제 의미 없는 속성이나 벤더 전용 속성 누락이라면 바로 정리해 두는 편이 이후 유지보수 피로를 줄인다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.89
|
||||||
|
- 더 이상 참조되지 않는 Vite 기본 자산과 레거시 public 아이콘 묶음은 남겨둘수록 혼동만 커지므로, 실제 사용 파일만 남기고 정리하는 편이 맞다고 판단했다.
|
||||||
|
- 공유용 썸네일은 코드 수정과 별개로 시각 자산 손질이 자주 일어날 수 있으므로, 이번처럼 워크트리에 이미 반영된 최신 이미지 수정본은 함께 릴리스에 포함하는 편이 맞다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.88
|
||||||
|
- 헤더의 `by zenn`은 이미 공통 카피라이트 링크가 생긴 뒤 역할이 겹치므로, 브랜드 영역은 서비스명 중심으로 정리하는 편이 맞다고 판단했다.
|
||||||
|
- 외부 공유 미리보기는 메타 태그만 넣는 것보다 실제 전용 썸네일 자산을 함께 두는 편이 메신저/소셜/모바일 홈 화면까지 더 안정적으로 동작한다고 정리했다.
|
||||||
|
- 파비콘은 인라인 data URL 하나에 의존하기보다 `svg + png + apple-touch-icon` 조합으로 두는 편이 브라우저와 기기 호환성 측면에서 안전하다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.86
|
||||||
|
- 아이콘 크기는 이미지 다운로드 결과에만 반영되고 저장본에는 남지 않으면 사용자가 체감상 “저장되지 않는 설정”으로 느끼게 되므로, 티어표 본문 설정으로 저장하는 편이 맞다고 정리했다.
|
||||||
|
- 저장 경로를 고친 뒤에도 프리뷰 화면이 기본값으로 보인다면, 데이터보다 프런트 렌더링 루트에 동일 CSS 변수가 전달되는지 먼저 확인하는 편이 맞다고 정리했다.
|
||||||
|
|
||||||
## 2026-04-02 v1.3.83
|
## 2026-04-02 v1.3.83
|
||||||
- 모바일에서 열 헤더가 칸과 시각적으로 분리되는 문제는 전체 레이아웃을 다시 갈아엎기보다, 각 칸 안에 열 이름 배지를 같이 보여주는 편이 가장 적은 변경으로 효과를 낸다고 정리했다.
|
- 모바일에서 열 헤더가 칸과 시각적으로 분리되는 문제는 전체 레이아웃을 다시 갈아엎기보다, 각 칸 안에 열 이름 배지를 같이 보여주는 편이 가장 적은 변경으로 효과를 낸다고 정리했다.
|
||||||
|
- 배지를 쓰는 반응형 구간에서는 기존 상단 열 헤더까지 남겨두면 중복 정보가 되므로, 같은 브레이크포인트에서 헤더는 숨기고 칸 배지 하나만 남기는 편이 맞다고 정리했다.
|
||||||
|
- 반응형 보정은 한 미디어 구간 안에서 서로 다른 규칙이 다시 덮어쓰지 않게 정리해야 하므로, 모바일용 `1fr` 레이아웃을 선언한 뒤 예전 `140px/150px` 규칙은 제거하는 편이 맞다고 판단했다.
|
||||||
|
|
||||||
## 2026-04-02 v1.3.82
|
## 2026-04-02 v1.3.82
|
||||||
- 프리뷰 완성본도 결국 공유/열람용 결과물이므로, 이미지 다운로드 결과와 같은 작성자/저장 시각 메타를 같이 보여주는 편이 자연스럽다고 정리했다.
|
- 프리뷰 완성본도 결국 공유/열람용 결과물이므로, 이미지 다운로드 결과와 같은 작성자/저장 시각 메타를 같이 보여주는 편이 자연스럽다고 정리했다.
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
## 공통 레이아웃
|
## 공통 레이아웃
|
||||||
- 앱 셸 파일: `frontend/src/App.vue`
|
- 앱 셸 파일: `frontend/src/App.vue`
|
||||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
|
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
|
||||||
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker by zenn`이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
|
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker`와 서비스 설명이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
|
||||||
|
|
||||||
## 백엔드 진입점
|
## 백엔드 진입점
|
||||||
- 서버 엔트리: `backend/index.js`
|
- 서버 엔트리: `backend/index.js`
|
||||||
|
|||||||
@@ -196,7 +196,7 @@
|
|||||||
- 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다.
|
- 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다.
|
||||||
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
|
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
|
||||||
- 티어표 편집 화면의 우측 패널은 공통 `rightRail`의 `localRightRailRoot`에 직접 section들을 쌓는 구조이며, 별도 외곽 래퍼 카드 없이 공통 오른쪽 컬럼 문법을 그대로 따른다.
|
- 티어표 편집 화면의 우측 패널은 공통 `rightRail`의 `localRightRailRoot`에 직접 section들을 쌓는 구조이며, 별도 외곽 래퍼 카드 없이 공통 오른쪽 컬럼 문법을 그대로 따른다.
|
||||||
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 고정 사이트 타이틀 `Tier Maker by zenn`을 표시한다.
|
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 사이트 타이틀 `Tier Maker`와 현재 서비스 설명을 표시한다.
|
||||||
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
||||||
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
||||||
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.
|
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.
|
||||||
|
|||||||
15
docs/todo.md
@@ -1,7 +1,22 @@
|
|||||||
# 할 일 및 이슈
|
# 할 일 및 이슈
|
||||||
|
|
||||||
## 단기 확인
|
## 단기 확인
|
||||||
|
- 관리자 화면까지 포함한 사용자 노출 `게임` 문구를 2차로 정리했으므로, 실제 운영 흐름에서 뜨는 확인창/토스트/선택 모달까지 표현이 자연스러운지 한 번 더 QA한다.
|
||||||
|
- 왼쪽 사이드 메뉴와 각 화면 타이틀을 한글 기준으로 맞췄으므로, 홈/나의 티어표/즐겨찾기/설정 진입 시 실제 체감이 자연스럽고 중복 표현이 어색하지 않은지 한 번 더 QA한다.
|
||||||
|
- 용어 정리 1차는 사용자 노출 문구만 `주제 / 템플릿`으로 바꿨으므로, 홈/주제 화면/관리자 템플릿 관리에서 어색하게 남은 `게임` 문구가 없는지 한 번 더 QA한다.
|
||||||
|
- 내부 모델명은 아직 `game`을 유지하므로, 다음 단계에서는 문서와 보조 화면 문구를 더 정리할지, 아니면 내부 리네이밍 계획을 따로 잡을지 결정한다.
|
||||||
|
- 게임 목록과 티어표 카드 썸네일은 기본 이미지 드래그를 막았으므로, 데스크톱 브라우저에서 클릭/드래그 시 원본 이미지 프리뷰가 더 이상 뜨지 않는지 한 번 더 QA한다.
|
||||||
|
- 왼쪽 레일 활성 배경은 공용 인디케이터가 이동하는 방식으로 바뀌었으므로, 홈/내 티어표/즐겨찾기/설정 전환과 레일 접힘 상태 양쪽에서 위치 보정이 자연스러운지 한 번 더 QA한다.
|
||||||
|
- 로그인 화면 상단 토글은 이동형 인디케이터로 바뀌었으므로, 데스크톱과 모바일에서 `로그인 / 회원가입` 전환 애니메이션이 어색하지 않고 포커스/클릭 상태도 자연스러운지 한 번 더 QA한다.
|
||||||
|
- 관리자 카드 설명 줄임은 `line-clamp` 표준 속성까지 함께 선언했으므로, 실제 브라우저별 표시 차이가 없는지 한 번 더 QA한다.
|
||||||
|
- 사용하지 않는 기본 자산을 정리했으므로, 배포본에서 누락 참조 없이 파비콘/공유 썸네일/좌측 레일 아이콘이 정상 노출되는지 한 번 더 QA한다.
|
||||||
|
- 공유 썸네일 `og-card`는 이번에 이미지 수정본까지 함께 반영했으므로, 실제 메신저 미리보기에서 최신 그림이 캐시 갱신 후 정상 노출되는지 한 번 더 QA한다.
|
||||||
|
- 홈페이지 공유 메타와 새 `og-card.png`는 이번에 처음 붙였으므로, 카카오톡/디스코드/슬랙/모바일 브라우저에서 제목·설명·썸네일이 기대대로 보이는지 한 번 더 QA한다.
|
||||||
|
- 파비콘은 `svg + 32px png + apple-touch-icon` 조합으로 정리했으므로, 데스크톱 브라우저 탭과 iOS 홈 화면 추가에서 모두 정상 노출되는지 한 번 더 QA한다.
|
||||||
|
- 티어표 `아이콘 크기`는 이제 저장 데이터로 승격됐으므로, 저장 후 재진입/프리뷰/복사본 생성에서 같은 크기가 유지되는지 한 번 더 QA한다.
|
||||||
- 티어표 편집/프리뷰 모바일 열 배지는 새로 붙였으므로, 실제 좁은 화면에서 칸 상단 배지와 아이템 썸네일이 겹치지 않고 열 구분이 자연스러운지 한 번 더 QA한다.
|
- 티어표 편집/프리뷰 모바일 열 배지는 새로 붙였으므로, 실제 좁은 화면에서 칸 상단 배지와 아이템 썸네일이 겹치지 않고 열 구분이 자연스러운지 한 번 더 QA한다.
|
||||||
|
- 모바일 열 배지는 같은 구간에서 상단 열 제목을 숨기도록 다시 맞췄으므로, 720px 안팎뿐 아니라 980px 이하 전 구간에서 중복 표기 없이 자연스러운지 한 번 더 QA한다.
|
||||||
|
- 모바일 티어표 편집 레이아웃은 행 라벨 폭을 다시 덮어쓰던 규칙을 걷어냈으므로, 실제 980px 이하 구간에서 행 라벨이 과하게 넓지 않고 칸 폭을 충분히 남기는지 한 번 더 QA한다.
|
||||||
- 프리뷰 완성본 하단 메타는 새로 붙였으므로, 작성자/저장 시각이 공개 열람 화면과 이미지 다운로드 결과 기준에서 모두 자연스럽게 읽히는지 한 번 더 QA한다.
|
- 프리뷰 완성본 하단 메타는 새로 붙였으므로, 작성자/저장 시각이 공개 열람 화면과 이미지 다운로드 결과 기준에서 모두 자연스럽게 읽히는지 한 번 더 QA한다.
|
||||||
- 관리자 템플릿 요청 카드는 썸네일 클릭이 새창 열기 역할로 바뀌었으므로, 썸네일 클릭과 `확인하기` 액션이 서로 헷갈리지 않는지 한 번 더 QA한다.
|
- 관리자 템플릿 요청 카드는 썸네일 클릭이 새창 열기 역할로 바뀌었으므로, 썸네일 클릭과 `확인하기` 액션이 서로 헷갈리지 않는지 한 번 더 QA한다.
|
||||||
- 티어표 만들기 화면의 `공유하기`는 저장된 티어표에서만 노출되므로, 저장 직후/수정 중/복사본/읽기 전용 상태 각각에서 노출 조건과 클립보드 복사가 자연스러운지 한 번 더 QA한다.
|
- 티어표 만들기 화면의 `공유하기`는 저장된 티어표에서만 노출되므로, 저장 직후/수정 중/복사본/읽기 전용 상태 각각에서 노출 조건과 클립보드 복사가 자연스러운지 한 번 더 QA한다.
|
||||||
|
|||||||
@@ -1,8 +1,46 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-04-02 v1.4.2
|
||||||
|
- 관리자 화면과 보조 모달에 남아 있던 사용자 노출 `게임` 문구를 추가로 걷어내고, `템플릿 / 주제` 기준 표현으로 더 통일했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.4.1
|
||||||
|
- 왼쪽 사이드 메뉴를 `주제 선택 / 나의 티어표 / 즐겨찾기 / 설정` 한글 문구로 통일하고, 해당 화면 진입 시 헤더 타이틀도 같은 이름 기준으로 맞췄다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.4.0
|
||||||
|
- 사용자 노출 용어 1차 정리를 시작해 홈/좌측 레일/가이드/주제 화면에서는 `게임` 대신 `주제`, 관리자 핵심 화면에서는 `게임 관리` 대신 `템플릿 관리` 중심 표현으로 바꿨다.
|
||||||
|
- 내부 데이터 모델과 API의 `gameId`, `/games` 구조는 아직 유지하고, 이번 단계는 화면 문구와 안내 텍스트를 먼저 정리하는 안전한 1차 리네이밍 범위로 제한했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.93
|
||||||
|
- 게임 목록, 티어표 리스트, 사용자 아바타 버튼 등 목록성 썸네일 이미지에 `draggable=\"false\"`를 적용해 브라우저 기본 이미지 드래그 프리뷰가 뜨지 않도록 정리함.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.92
|
||||||
|
- 왼쪽 네비게이션의 활성 메뉴 배경은 개별 항목에 즉시 붙는 방식에서, 공용 인디케이터가 현재 메뉴 위치로 미끄러져 이동하는 토글형 인터랙션으로 정리함.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.91
|
||||||
|
- 로그인 화면 상단의 `로그인 / 회원가입` 전환은 선택된 버튼 배경이 즉시 바뀌던 방식에서, 뒤쪽 하이라이트가 토글처럼 좌우로 미끄러져 이동하는 인터랙션으로 정리함.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.90
|
||||||
|
- 관리자 화면 CSS 경고를 줄이기 위해 `display: block` 요소에 의미 없던 `vertical-align`을 제거하고, `line-clamp` 표준 속성을 함께 선언해 VS Code 진단을 정리함.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.89
|
||||||
|
- 현재 코드에서 참조되지 않던 `frontend/public/icons.svg`, `frontend/src/assets/hero.png`, `frontend/src/assets/vite.svg`, `frontend/src/assets/vue.svg`를 삭제해 템플릿 잔재 자산을 정리함.
|
||||||
|
- 홈페이지 공유용 `og-card.svg`, `og-card.png`는 이번 워크트리에서 직접 수정된 최신 이미지 상태를 그대로 반영해 함께 정리함.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.88
|
||||||
|
- 중앙 워크스페이스 헤더의 `by zenn` 링크는 공통 카피라이트 footer가 이미 역할을 대신하므로 제거하고, 기본 서브타이틀도 서비스 설명 문구로 정리함.
|
||||||
|
- 홈페이지 공유용 메타를 정리해 `title`, `description`, `canonical`, Open Graph, Twitter 카드 정보를 `tmaker.sori.studio` 기준으로 연결함.
|
||||||
|
- 외부 공유용 `og-card.svg`와 실제 썸네일 `og-card.png`, 브라우저/모바일용 `favicon-32x32.png`, `apple-touch-icon.png`를 추가해 링크 공유와 파비콘 노출을 함께 보강함.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.86
|
||||||
|
- 티어표 편집의 `아이콘 크기`는 이제 임시 화면 상태가 아니라 저장 데이터에 함께 포함되며, 저장 후 다시 열기와 프리뷰 화면에서도 같은 크기로 복원되도록 정리함.
|
||||||
|
- 이를 위해 티어표 저장 payload, 서버 검증, DB 저장/조회에 `iconSize`를 추가하고 기존 데이터는 기본값 `80`으로 안전하게 보정되게 맞춤.
|
||||||
|
- 이후 공유 프리뷰 화면이 여전히 80으로 고정되던 문제는 `previewOnly` 레이아웃에서 `--thumb-size` 스타일 바인딩이 빠져 있던 탓이었고, 프리뷰 루트에도 같은 값을 전달해 저장된 크기가 그대로 반영되게 보정함.
|
||||||
|
|
||||||
## 2026-04-02 v1.3.83
|
## 2026-04-02 v1.3.83
|
||||||
- 티어표 편집/프리뷰 화면에서 열을 여러 개 쓰는 경우, 모바일처럼 좁은 화면에서는 기존 상단 열 헤더만으로 각 칸의 의미를 읽기 어려웠으므로 각 칸 상단에 작은 열 이름 배지를 추가함.
|
- 티어표 편집/프리뷰 화면에서 열을 여러 개 쓰는 경우, 모바일처럼 좁은 화면에서는 기존 상단 열 헤더만으로 각 칸의 의미를 읽기 어려웠으므로 각 칸 상단에 작은 열 이름 배지를 추가함.
|
||||||
- 이 배지는 모바일 구간에서만 보이고 데스크톱 레이아웃은 그대로 유지되므로, 작은 화면에서는 `메인 / 밸런스 / 서포트` 같은 열 맥락을 스크롤 중에도 잃지 않게 정리함.
|
- 이 배지는 모바일 구간에서만 보이고 데스크톱 레이아웃은 그대로 유지되므로, 작은 화면에서는 `메인 / 밸런스 / 서포트` 같은 열 맥락을 스크롤 중에도 잃지 않게 정리함.
|
||||||
|
- 이후 배지가 칸 기준이 아니라 화면 한쪽에 겹치던 문제를 바로잡기 위해 각 칸을 기준점으로 다시 잡았고, 배지가 보이는 구간에서는 기존 상단 열 제목을 함께 숨겨 중복 표기를 제거함.
|
||||||
|
- 추가로 같은 미디어 구간 안에서 행/열 모바일 레이아웃을 다시 `140px/150px`로 덮어쓰던 중복 규칙을 제거해, 모바일에서는 행 라벨이 화면 절반을 차지하지 않고 실제로 한 줄 전체 폭 기준 레이아웃으로 정리되게 맞춤.
|
||||||
|
|
||||||
## 2026-04-02 v1.3.82
|
## 2026-04-02 v1.3.82
|
||||||
- 프리뷰 전용 완성본 화면에도 이미지 다운로드 결과와 같은 하단 메타를 붙여, 작성자 이름과 마지막 저장 시각을 바로 확인할 수 있게 정리함.
|
- 프리뷰 전용 완성본 화면에도 이미지 다운로드 결과와 같은 하단 메타를 붙여, 작성자 이름과 마지막 저장 시각을 바로 확인할 수 있게 정리함.
|
||||||
|
|||||||
@@ -1,13 +1,42 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link
|
|
||||||
rel="icon"
|
|
||||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%230b1220'/%3E%3Cpath d='M18 18h28v8H36v20h-8V26H18z' fill='%23f8fafc'/%3E%3C/svg%3E"
|
|
||||||
/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Tier Maker</title>
|
<title>Tier Maker | 게임 템플릿으로 만드는 티어표</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="게임 템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요. 관리자 요청과 템플릿 업데이트 흐름까지 한 번에 관리할 수 있습니다."
|
||||||
|
/>
|
||||||
|
<meta name="theme-color" content="#090d16" />
|
||||||
|
<meta name="application-name" content="Tier Maker" />
|
||||||
|
|
||||||
|
<link rel="canonical" href="https://tmaker.sori.studio/" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
|
||||||
|
<meta property="og:site_name" content="Tier Maker" />
|
||||||
|
<meta property="og:locale" content="ko_KR" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://tmaker.sori.studio/" />
|
||||||
|
<meta property="og:title" content="Tier Maker | 게임 템플릿으로 만드는 티어표" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="게임 템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요. 관리자 요청과 템플릿 업데이트 흐름까지 한 번에 관리할 수 있습니다."
|
||||||
|
/>
|
||||||
|
<meta property="og:image" content="https://tmaker.sori.studio/og-card.png" />
|
||||||
|
<meta property="og:image:alt" content="Tier Maker 공유 썸네일" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Tier Maker | 게임 템플릿으로 만드는 티어표" />
|
||||||
|
<meta
|
||||||
|
name="twitter:description"
|
||||||
|
content="게임 템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요."
|
||||||
|
/>
|
||||||
|
<meta name="twitter:image" content="https://tmaker.sori.studio/og-card.png" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
@@ -1,24 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
|
||||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
|
||||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
|
||||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.9 KiB |
BIN
frontend/public/og-card.png
Normal file
|
After Width: | Height: | Size: 613 KiB |
69
frontend/public/og-card.svg
Normal file
|
After Width: | Height: | Size: 32 KiB |
@@ -26,7 +26,7 @@ const RIGHT_RAIL_COPYRIGHT_URL = 'https://zenn.town/@murabito'
|
|||||||
const leftRailCollapsed = ref(false)
|
const leftRailCollapsed = ref(false)
|
||||||
const rightRailOpen = ref(true)
|
const rightRailOpen = ref(true)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const leftRailSearchPlaceholder = '게임 템플릿 검색'
|
const leftRailSearchPlaceholder = '주제 템플릿 검색'
|
||||||
const isCollapsedSearchOpen = ref(false)
|
const isCollapsedSearchOpen = ref(false)
|
||||||
const isGuideModalOpen = ref(false)
|
const isGuideModalOpen = ref(false)
|
||||||
const themeMode = ref('dark')
|
const themeMode = ref('dark')
|
||||||
@@ -60,22 +60,23 @@ const shellStyle = computed(() => ({
|
|||||||
}))
|
}))
|
||||||
const leftNavItems = computed(() => {
|
const leftNavItems = computed(() => {
|
||||||
const items = [
|
const items = [
|
||||||
{ key: 'home', label: 'Games', path: '/', iconSrc: iconGridView },
|
{ key: 'home', label: '주제 선택', path: '/', iconSrc: iconGridView },
|
||||||
{ key: 'me', label: 'My Lists', path: '/me', iconSrc: iconLists, requiresAuth: true },
|
{ key: 'me', label: '나의 티어표', path: '/me', iconSrc: iconLists, requiresAuth: true },
|
||||||
{ key: 'favorites', label: 'Favorites', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
|
{ key: 'favorites', label: '즐겨찾기', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
|
||||||
{ key: 'profile', label: 'Settings', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
|
{ key: 'profile', label: '설정', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
|
||||||
]
|
]
|
||||||
return items.filter((item) => !item.requiresAuth || (authReady.value && auth.user))
|
return items.filter((item) => !item.requiresAuth || (authReady.value && auth.user))
|
||||||
})
|
})
|
||||||
|
const activeLeftNavIndex = computed(() => leftNavItems.value.findIndex((item) => isRouteActive(item.path)))
|
||||||
const showRightRailAction = computed(() => false)
|
const showRightRailAction = computed(() => false)
|
||||||
const showSettingsGuideButton = computed(() => route.name === 'profile')
|
const showSettingsGuideButton = computed(() => route.name === 'profile')
|
||||||
const guideSteps = [
|
const guideSteps = [
|
||||||
{
|
{
|
||||||
id: 'select-game',
|
id: 'select-game',
|
||||||
title: '게임 또는 양식 선택',
|
title: '주제 또는 양식 선택',
|
||||||
summary: '게임 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
|
summary: '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
|
||||||
description:
|
description:
|
||||||
'홈 화면에서는 게임 템플릿을 선택하거나 커스텀 티어표 만들기로 바로 새 보드를 열 수 있어요. 게임을 먼저 고르면 해당 게임의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
|
'홈 화면에서는 주제 템플릿을 선택하거나 커스텀 티어표 만들기로 바로 새 보드를 열 수 있어요. 원하는 주제를 먼저 고르면 해당 주제의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'arrange-board',
|
id: 'arrange-board',
|
||||||
@@ -89,7 +90,7 @@ const guideSteps = [
|
|||||||
title: '아이템 배치와 커스텀 추가',
|
title: '아이템 배치와 커스텀 추가',
|
||||||
summary: '프리셋 아이템과 직접 올린 이미지를 드래그로 배치합니다.',
|
summary: '프리셋 아이템과 직접 올린 이미지를 드래그로 배치합니다.',
|
||||||
description:
|
description:
|
||||||
'오른쪽 아이템 영역의 이미지를 원하는 칸으로 끌어다 놓으면 바로 배치됩니다. 게임 템플릿에 없는 이미지는 커스텀 이미지로 추가해 같이 쓸 수 있고, 이름 표시 옵션을 켜면 결과 이미지를 더 설명적으로 정리할 수 있어요.',
|
'오른쪽 아이템 영역의 이미지를 원하는 칸으로 끌어다 놓으면 바로 배치됩니다. 주제 템플릿에 없는 이미지는 커스텀 이미지로 추가해 같이 쓸 수 있고, 이름 표시 옵션을 켜면 결과 이미지를 더 설명적으로 정리할 수 있어요.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'save-share',
|
id: 'save-share',
|
||||||
@@ -108,23 +109,23 @@ const guideSteps = [
|
|||||||
{
|
{
|
||||||
id: 'request-template-update',
|
id: 'request-template-update',
|
||||||
title: '템플릿 업그레이드 요청',
|
title: '템플릿 업그레이드 요청',
|
||||||
summary: '현재 게임 템플릿에 공통 아이템을 추가해 달라고 관리자에게 요청합니다.',
|
summary: '현재 템플릿에 공통 아이템을 추가해 달라고 관리자에게 요청합니다.',
|
||||||
description:
|
description:
|
||||||
'직접 추가한 아이템 중 여러 사람이 함께 써도 좋을 것 같은 항목이 있다면 템플릿 업데이트 요청을 보낼 수 있어요. 요청 모달에서는 현재 티어표 제목과 설명을 기본값으로 가져오고, 필요하면 요청 제목과 설명을 더 다듬어 공통 템플릿에 왜 필요한지 설명할 수 있습니다.',
|
'직접 추가한 아이템 중 여러 사람이 함께 써도 좋을 것 같은 항목이 있다면 템플릿 업데이트 요청을 보낼 수 있어요. 요청 모달에서는 현재 티어표 제목과 설명을 기본값으로 가져오고, 필요하면 요청 제목과 설명을 더 다듬어 공통 템플릿에 왜 필요한지 설명할 수 있습니다.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'request-new-template',
|
id: 'request-new-template',
|
||||||
title: '새 템플릿 추가 요청',
|
title: '새 템플릿 추가 요청',
|
||||||
summary: '아직 없는 게임이나 새로운 양식을 관리자에게 제안합니다.',
|
summary: '아직 없는 주제나 새로운 양식을 관리자에게 제안합니다.',
|
||||||
description:
|
description:
|
||||||
'원하는 게임 템플릿이 아직 없다면 새 템플릿 추가 요청으로 관리자에게 직접 제안할 수 있어요. 이때는 제목과 설명에 어떤 게임인지, 어떤 캐릭터나 항목이 기본으로 필요할지 적어두면 검토 속도가 훨씬 빨라집니다.',
|
'원하는 주제 템플릿이 아직 없다면 새 템플릿 추가 요청으로 관리자에게 직접 제안할 수 있어요. 이때는 제목과 설명에 어떤 주제인지, 어떤 캐릭터나 항목이 기본으로 필요할지 적어두면 검토 속도가 훨씬 빨라집니다.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'manage-library',
|
id: 'manage-library',
|
||||||
title: '즐겨찾기와 내 티어표 관리',
|
title: '즐겨찾기와 내 티어표 관리',
|
||||||
summary: '마음에 드는 템플릿과 저장한 결과물을 나중에 다시 쉽게 찾습니다.',
|
summary: '마음에 드는 템플릿과 저장한 결과물을 나중에 다시 쉽게 찾습니다.',
|
||||||
description:
|
description:
|
||||||
'게임 템플릿은 즐겨찾기로 상단에 고정해둘 수 있고, 저장한 보드는 내 티어표에서 다시 열어 이어서 수정할 수 있어요. 자주 보는 템플릿, 공개 티어표, 내가 만든 결과물을 각각 다른 화면에서 정리해두면 이후 작업이 훨씬 빨라집니다.',
|
'주제 템플릿은 즐겨찾기로 상단에 고정해둘 수 있고, 저장한 보드는 내 티어표에서 다시 열어 이어서 수정할 수 있어요. 자주 보는 템플릿, 공개 티어표, 내가 만든 결과물을 각각 다른 화면에서 정리해두면 이후 작업이 훨씬 빨라집니다.',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
|
const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
|
||||||
@@ -150,10 +151,10 @@ const leftBottomPrimaryAction = computed(() => {
|
|||||||
const routeMeta = computed(() => {
|
const routeMeta = computed(() => {
|
||||||
if (route.name === 'home') {
|
if (route.name === 'home') {
|
||||||
return {
|
return {
|
||||||
title: 'Tier Maker',
|
title: '주제 선택',
|
||||||
subtitle: '게임 템플릿 선택과 커스텀 보드 시작',
|
subtitle: '주제 템플릿 선택과 커스텀 보드 시작',
|
||||||
contextTitle: '빠른 시작',
|
contextTitle: '빠른 시작',
|
||||||
contextText: auth.user ? '커스텀 티어표를 만들거나 원하는 게임을 바로 선택할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
|
contextText: auth.user ? '커스텀 티어표를 만들거나 원하는 주제를 바로 선택할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
|
||||||
actionLabel: auth.user ? '커스텀 티어표 만들기' : '로그인하러 가기',
|
actionLabel: auth.user ? '커스텀 티어표 만들기' : '로그인하러 가기',
|
||||||
action: () => {
|
action: () => {
|
||||||
router.push(auth.user ? '/editor/freeform/new' : '/login')
|
router.push(auth.user ? '/editor/freeform/new' : '/login')
|
||||||
@@ -162,10 +163,10 @@ const routeMeta = computed(() => {
|
|||||||
}
|
}
|
||||||
if (route.name === 'gameHub') {
|
if (route.name === 'gameHub') {
|
||||||
return {
|
return {
|
||||||
title: 'Game Boards',
|
title: '주제 티어표',
|
||||||
subtitle: '게임별 공개 티어표 탐색',
|
subtitle: '주제별 공개 티어표 탐색',
|
||||||
contextTitle: '작성 작업',
|
contextTitle: '작성 작업',
|
||||||
contextText: auth.user ? '이 게임의 새 티어표를 만들거나 기존 공개 티어표를 확인할 수 있어요.' : '로그인 후 새 티어표를 만들 수 있어요.',
|
contextText: auth.user ? '이 주제의 새 티어표를 만들거나 기존 공개 티어표를 확인할 수 있어요.' : '로그인 후 새 티어표를 만들 수 있어요.',
|
||||||
actionLabel: auth.user ? '새 티어표 만들기' : '로그인하러 가기',
|
actionLabel: auth.user ? '새 티어표 만들기' : '로그인하러 가기',
|
||||||
action: () => {
|
action: () => {
|
||||||
const target = `/editor/${route.params.gameId}/new`
|
const target = `/editor/${route.params.gameId}/new`
|
||||||
@@ -179,24 +180,24 @@ const routeMeta = computed(() => {
|
|||||||
subtitle: '티어표 편집 및 공유',
|
subtitle: '티어표 편집 및 공유',
|
||||||
contextTitle: '편집 패널',
|
contextTitle: '편집 패널',
|
||||||
contextText: '현재 편집 옵션은 중앙 화면 안에 유지되어 있습니다. 다음 단계에서 우측 패널로 정리해갈게요.',
|
contextText: '현재 편집 옵션은 중앙 화면 안에 유지되어 있습니다. 다음 단계에서 우측 패널로 정리해갈게요.',
|
||||||
actionLabel: '게임 목록으로',
|
actionLabel: '주제 목록으로',
|
||||||
action: () => router.push('/'),
|
action: () => router.push('/'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isAdminRoute.value) {
|
if (isAdminRoute.value) {
|
||||||
return {
|
return {
|
||||||
title: 'Admin Workspace',
|
title: 'Admin Workspace',
|
||||||
subtitle: '게임·아이템·회원 관리',
|
subtitle: '템플릿·아이템·회원 관리',
|
||||||
contextTitle: '운영 노트',
|
contextTitle: '운영 노트',
|
||||||
contextText: '관리자 화면은 기능이 많아 우선 공통 셸 톤을 맞췄고, 세부 패널은 다음 단계에서 시안 방식으로 더 세밀하게 나눌 예정입니다.',
|
contextText: '관리자 화면은 기능이 많아 우선 공통 셸 톤을 맞췄고, 세부 패널은 다음 단계에서 시안 방식으로 더 세밀하게 나눌 예정입니다.',
|
||||||
actionLabel: '게임 목록으로',
|
actionLabel: '주제 목록으로',
|
||||||
action: () => router.push('/'),
|
action: () => router.push('/'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (route.name === 'me') {
|
if (route.name === 'me') {
|
||||||
return {
|
return {
|
||||||
title: 'My Lists',
|
title: '나의 티어표',
|
||||||
subtitle: '내가 저장한 티어표',
|
subtitle: '저장한 티어표 모아보기',
|
||||||
contextTitle: '작성 이력',
|
contextTitle: '작성 이력',
|
||||||
contextText: '최근 저장한 티어표를 열람하고 정리할 수 있어요.',
|
contextText: '최근 저장한 티어표를 열람하고 정리할 수 있어요.',
|
||||||
actionLabel: '즐겨찾기 보기',
|
actionLabel: '즐겨찾기 보기',
|
||||||
@@ -205,27 +206,27 @@ const routeMeta = computed(() => {
|
|||||||
}
|
}
|
||||||
if (route.name === 'favorites') {
|
if (route.name === 'favorites') {
|
||||||
return {
|
return {
|
||||||
title: 'Favorites',
|
title: '즐겨찾기',
|
||||||
subtitle: '마음에 드는 티어표 모음',
|
subtitle: '마음에 드는 티어표 모음',
|
||||||
contextTitle: '정리 도구',
|
contextTitle: '정리 도구',
|
||||||
contextText: '정렬과 검색으로 원하는 티어표를 빠르게 다시 찾을 수 있어요.',
|
contextText: '정렬과 검색으로 원하는 티어표를 빠르게 다시 찾을 수 있어요.',
|
||||||
actionLabel: '내 티어표 보기',
|
actionLabel: '나의 티어표 보기',
|
||||||
action: () => router.push('/me'),
|
action: () => router.push('/me'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (route.name === 'profile') {
|
if (route.name === 'profile') {
|
||||||
return {
|
return {
|
||||||
title: 'Profile',
|
title: '설정',
|
||||||
subtitle: '프로필 및 계정 설정',
|
subtitle: '프로필 및 계정 설정',
|
||||||
contextTitle: '계정 관리',
|
contextTitle: '계정 관리',
|
||||||
contextText: '아바타와 닉네임을 관리하고 표시 정보를 확인할 수 있어요.',
|
contextText: '아바타와 닉네임을 관리하고 표시 정보를 확인할 수 있어요.',
|
||||||
actionLabel: '내 티어표 보기',
|
actionLabel: '나의 티어표 보기',
|
||||||
action: () => router.push('/me'),
|
action: () => router.push('/me'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (route.name === 'search') {
|
if (route.name === 'search') {
|
||||||
return {
|
return {
|
||||||
title: 'Search',
|
title: '검색',
|
||||||
subtitle: '전체 공개 티어표 검색 결과',
|
subtitle: '전체 공개 티어표 검색 결과',
|
||||||
contextTitle: '검색',
|
contextTitle: '검색',
|
||||||
contextText: '제목, 작성자 기준으로 공개 티어표를 전체 검색할 수 있어요.',
|
contextText: '제목, 작성자 기준으로 공개 티어표를 전체 검색할 수 있어요.',
|
||||||
@@ -235,7 +236,7 @@ const routeMeta = computed(() => {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: 'Tier Maker',
|
title: 'Tier Maker',
|
||||||
subtitle: 'by zenn',
|
subtitle: '주제 템플릿으로 만드는 티어표',
|
||||||
contextTitle: 'Workspace',
|
contextTitle: 'Workspace',
|
||||||
contextText: '현재 화면에 맞는 도구와 안내를 여기에 배치할 수 있습니다.',
|
contextText: '현재 화면에 맞는 도구와 안내를 여기에 배치할 수 있습니다.',
|
||||||
actionLabel: '홈으로',
|
actionLabel: '홈으로',
|
||||||
@@ -428,7 +429,7 @@ function submitGlobalSearch() {
|
|||||||
<div class="leftRail__content">
|
<div class="leftRail__content">
|
||||||
<div v-if="authReady && auth.user" class="appUserCard">
|
<div v-if="authReady && auth.user" class="appUserCard">
|
||||||
<div class="appUserCard__button">
|
<div class="appUserCard__button">
|
||||||
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" />
|
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" draggable="false" />
|
||||||
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
|
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
|
||||||
<div class="appUserCard__meta">
|
<div class="appUserCard__meta">
|
||||||
<div class="appUserCard__name">{{ accountName }}</div>
|
<div class="appUserCard__name">{{ accountName }}</div>
|
||||||
@@ -446,7 +447,12 @@ function submitGlobalSearch() {
|
|||||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
|
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<nav class="leftNav">
|
<nav
|
||||||
|
class="leftNav"
|
||||||
|
:class="{ 'leftNav--hasActive': activeLeftNavIndex >= 0 }"
|
||||||
|
:style="{ '--left-nav-active-index': String(Math.max(activeLeftNavIndex, 0)) }"
|
||||||
|
>
|
||||||
|
<span class="leftNav__indicator" aria-hidden="true"></span>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="item in leftNavItems"
|
v-for="item in leftNavItems"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
@@ -491,15 +497,6 @@ function submitGlobalSearch() {
|
|||||||
<header class="workspaceHead railHeader">
|
<header class="workspaceHead railHeader">
|
||||||
<div class="workspaceHead__brand" @click="$router.push('/')">
|
<div class="workspaceHead__brand" @click="$router.push('/')">
|
||||||
<span class="workspaceHead__brandTitle">Tier Maker</span>
|
<span class="workspaceHead__brandTitle">Tier Maker</span>
|
||||||
<a
|
|
||||||
class="workspaceHead__brandSub"
|
|
||||||
href="https://zenn.town/@murabito"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
by zenn
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="workspaceHead__actions">
|
<div class="workspaceHead__actions">
|
||||||
<div v-if="showGameHubViewToggle" class="viewToggle" role="group" aria-label="티어표 보기 방식">
|
<div v-if="showGameHubViewToggle" class="viewToggle" role="group" aria-label="티어표 보기 방식">
|
||||||
@@ -920,19 +917,45 @@ function submitGlobalSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.leftNav {
|
.leftNav {
|
||||||
|
--left-nav-gap: 8px;
|
||||||
|
--left-nav-item-height: 50px;
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: var(--left-nav-gap);
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leftNav__indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: var(--left-nav-item-height);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--theme-surface-soft-3);
|
||||||
|
transform: translateY(calc(var(--left-nav-active-index, 0) * (var(--left-nav-item-height) + var(--left-nav-gap))));
|
||||||
|
transition: transform 240ms ease, opacity 200ms ease;
|
||||||
|
opacity: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leftNav--hasActive .leftNav__indicator {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leftNav__item {
|
.leftNav__item {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-height: var(--left-nav-item-height);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 11px 12px;
|
padding: 11px 12px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
color: var(--theme-text-muted);
|
color: var(--theme-text-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background 180ms ease, color 180ms ease, transform 180ms ease;
|
transition: background 180ms ease, color 180ms ease, transform 180ms ease;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leftNav__label {
|
.leftNav__label {
|
||||||
@@ -945,7 +968,6 @@ function submitGlobalSearch() {
|
|||||||
|
|
||||||
.leftNav__item--active,
|
.leftNav__item--active,
|
||||||
.leftNav__item.router-link-active {
|
.leftNav__item.router-link-active {
|
||||||
background: var(--theme-surface-soft-3);
|
|
||||||
color: var(--theme-text-strong);
|
color: var(--theme-text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1008,7 +1030,7 @@ function submitGlobalSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .leftNav {
|
.appShell--leftCollapsed .leftNav {
|
||||||
gap: 10px;
|
--left-nav-gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .leftNav__item {
|
.appShell--leftCollapsed .leftNav__item {
|
||||||
@@ -1137,18 +1159,6 @@ function submitGlobalSearch() {
|
|||||||
color: var(--theme-text-strong);
|
color: var(--theme-text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspaceHead__brandSub {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--theme-text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 180ms ease, opacity 180ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspaceHead__brandSub:hover {
|
|
||||||
color: var(--theme-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspaceHead__actions {
|
.workspaceHead__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 496 B |
@@ -16,7 +16,7 @@ const props = defineProps({
|
|||||||
<div class="sectionHeader">
|
<div class="sectionHeader">
|
||||||
<div>
|
<div>
|
||||||
<div class="panel__title">홈 화면 상단 고정 순서</div>
|
<div class="panel__title">홈 화면 상단 고정 순서</div>
|
||||||
<div class="hint hint--tight">여기에 넣은 게임은 지정한 순서대로 먼저 노출되고, 나머지 게임은 최근 생성순으로 뒤에 이어집니다. 최대 50개까지 설정할 수 있어요.</div>
|
<div class="hint hint--tight">여기에 넣은 템플릿은 지정한 순서대로 먼저 노출되고, 나머지 템플릿은 최근 생성순으로 뒤에 이어집니다. 최대 50개까지 설정할 수 있어요.</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn--primary" @click="props.saveFeaturedOrder">순서 저장</button>
|
<button class="btn btn--primary" @click="props.saveFeaturedOrder">순서 저장</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,7 +24,7 @@ const props = defineProps({
|
|||||||
<div class="featuredOrderPanel">
|
<div class="featuredOrderPanel">
|
||||||
<div class="featuredOrderPanel__list">
|
<div class="featuredOrderPanel__list">
|
||||||
<div class="section__title">상단 고정 목록</div>
|
<div class="section__title">상단 고정 목록</div>
|
||||||
<div v-if="!props.featuredGames.length" class="hint">아직 상단 고정 게임이 없어요.</div>
|
<div v-if="!props.featuredGames.length" class="hint">아직 상단 고정 템플릿이 없어요.</div>
|
||||||
<div v-else :ref="props.featuredListRef" class="featuredList">
|
<div v-else :ref="props.featuredListRef" class="featuredList">
|
||||||
<article v-for="(game, index) in props.featuredGames" :key="game.id" class="featuredCard" :data-featured-id="game.id">
|
<article v-for="(game, index) in props.featuredGames" :key="game.id" class="featuredCard" :data-featured-id="game.id">
|
||||||
<div class="featuredCard__meta">
|
<div class="featuredCard__meta">
|
||||||
@@ -45,7 +45,7 @@ const props = defineProps({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="featuredOrderPanel__picker">
|
<div class="featuredOrderPanel__picker">
|
||||||
<div class="section__title">게임 추가</div>
|
<div class="section__title">템플릿 추가</div>
|
||||||
<div class="featuredPickerList">
|
<div class="featuredPickerList">
|
||||||
<button
|
<button
|
||||||
v-for="game in props.availableGamesForFeatured"
|
v-for="game in props.availableGamesForFeatured"
|
||||||
|
|||||||
@@ -67,17 +67,17 @@ function setThumbFileElement(el) {
|
|||||||
props.activeTemplateRequest.type === 'create'
|
props.activeTemplateRequest.type === 'create'
|
||||||
? (props.activeTemplateRequest.targetGameId
|
? (props.activeTemplateRequest.targetGameId
|
||||||
? '이미 연결된 신규 템플릿이 있어요. 필요한 아이템만 골라 저장하세요.'
|
? '이미 연결된 신규 템플릿이 있어요. 필요한 아이템만 골라 저장하세요.'
|
||||||
: '새 게임을 한 번 만든 뒤 필요한 아이템만 골라 저장하세요.')
|
: '새 템플릿을 한 번 만든 뒤 필요한 아이템만 골라 저장하세요.')
|
||||||
: '필요한 아이템만 남긴 뒤 기본 아이템 추가 버튼으로 반영하세요.'
|
: '필요한 아이템만 남긴 뒤 기본 아이템 추가 버튼으로 반영하세요.'
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="requestWorkspace__stats">
|
<div class="requestWorkspace__stats">
|
||||||
<span class="pill pill--accent">{{ props.activeTemplateRequest.type === 'create' ? '신규 게임 요청' : '기존 게임 업데이트' }}</span>
|
<span class="pill pill--accent">{{ props.activeTemplateRequest.type === 'create' ? '신규 템플릿 요청' : '기존 템플릿 업데이트' }}</span>
|
||||||
<span class="pill">요청 아이템 {{ props.stagedRequestDraftCount }}개</span>
|
<span class="pill">요청 아이템 {{ props.stagedRequestDraftCount }}개</span>
|
||||||
<span v-if="props.appliedRequestItemCount" class="pill pill--soft">이미 반영 {{ props.appliedRequestItemCount }}개</span>
|
<span v-if="props.appliedRequestItemCount" class="pill pill--soft">이미 반영 {{ props.appliedRequestItemCount }}개</span>
|
||||||
<span v-if="props.activeTemplateRequest.type === 'create' && (props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId)" class="pill pill--soft">
|
<span v-if="props.activeTemplateRequest.type === 'create' && (props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId)" class="pill pill--soft">
|
||||||
연결된 게임 · {{ props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId }}
|
연결된 템플릿 · {{ props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,15 +97,15 @@ function setThumbFileElement(el) {
|
|||||||
type="button"
|
type="button"
|
||||||
@click="props.openGameCreateModal"
|
@click="props.openGameCreateModal"
|
||||||
>
|
>
|
||||||
새 게임 만들기
|
새 템플릿 만들기
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="props.isGameLoading" class="panel panel--empty">
|
<div v-if="props.isGameLoading" class="panel panel--empty">
|
||||||
<div class="emptyState">
|
<div class="emptyState">
|
||||||
<div class="emptyState__title">게임 정보를 불러오는 중이에요.</div>
|
<div class="emptyState__title">템플릿 정보를 불러오는 중이에요.</div>
|
||||||
<div class="emptyState__desc">선택한 게임의 썸네일과 기본 아이템을 곧 표시합니다.</div>
|
<div class="emptyState__desc">선택한 템플릿의 썸네일과 기본 아이템을 곧 표시합니다.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="props.hasSelectedGame" class="panel">
|
<div v-else-if="props.hasSelectedGame" class="panel">
|
||||||
@@ -133,7 +133,7 @@ function setThumbFileElement(el) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="gameSettingsCard__body">
|
<div class="gameSettingsCard__body">
|
||||||
<div class="panel__title">게임 설정</div>
|
<div class="panel__title">템플릿 설정</div>
|
||||||
<div class="gameSettingsCard__meta">{{ props.selectedGame.game.name }} · {{ props.selectedGame.game.id }}</div>
|
<div class="gameSettingsCard__meta">{{ props.selectedGame.game.name }} · {{ props.selectedGame.game.id }}</div>
|
||||||
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.gameVisibilitySaving }">
|
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.gameVisibilitySaving }">
|
||||||
<input :checked="!!props.selectedGame.game.isPublic" type="checkbox" @change="props.toggleSelectedGameVisibility($event.target.checked)" />
|
<input :checked="!!props.selectedGame.game.isPublic" type="checkbox" @change="props.toggleSelectedGameVisibility($event.target.checked)" />
|
||||||
@@ -142,7 +142,7 @@ function setThumbFileElement(el) {
|
|||||||
</label>
|
</label>
|
||||||
<div class="gameSettingsCard__actions">
|
<div class="gameSettingsCard__actions">
|
||||||
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
|
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
|
||||||
<button class="btn btn--danger" @click="props.removeGame">게임 삭제</button>
|
<button class="btn btn--danger" @click="props.removeGame">템플릿 삭제</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -236,9 +236,9 @@ function setThumbFileElement(el) {
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="panel panel--empty">
|
<div v-else class="panel panel--empty">
|
||||||
<div class="emptyState">
|
<div class="emptyState">
|
||||||
<div class="emptyState__title">게임을 선택해 주세요.</div>
|
<div class="emptyState__title">템플릿을 선택해 주세요.</div>
|
||||||
<div v-if="props.activeTemplateRequest?.type === 'create'" class="hint hint--tight">진행 중인 신규 게임 요청이 있어요. 위의 `새 게임 만들기`로 게임을 만든 뒤 아이템을 추가할 수 있습니다.</div>
|
<div v-if="props.activeTemplateRequest?.type === 'create'" class="hint hint--tight">진행 중인 신규 템플릿 요청이 있어요. 위의 `새 템플릿 만들기`로 템플릿을 만든 뒤 아이템을 추가할 수 있습니다.</div>
|
||||||
<div v-if="props.selectedGameId" class="hint hint--tight">선택한 게임을 찾지 못했거나 로딩 중 오류가 발생했어요. 다시 선택해보세요.</div>
|
<div v-if="props.selectedGameId" class="hint hint--tight">선택한 템플릿을 찾지 못했거나 로딩 중 오류가 발생했어요. 다시 선택해보세요.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -53,18 +53,18 @@ const props = defineProps({
|
|||||||
<div class="templateRequestCard__thumbMeta">
|
<div class="templateRequestCard__thumbMeta">
|
||||||
<template v-if="request.type === 'create'">
|
<template v-if="request.type === 'create'">
|
||||||
<label class="templateRequestField">
|
<label class="templateRequestField">
|
||||||
<span class="templateRequestField__label">게임 이름</span>
|
<span class="templateRequestField__label">템플릿 이름</span>
|
||||||
<input v-model="request.draftGameName" class="input" placeholder="새 게임 이름" />
|
<input v-model="request.draftGameName" class="input" placeholder="새 템플릿 이름" />
|
||||||
</label>
|
</label>
|
||||||
<label class="templateRequestField">
|
<label class="templateRequestField">
|
||||||
<span class="templateRequestField__label">게임 ID</span>
|
<span class="templateRequestField__label">템플릿 ID</span>
|
||||||
<input v-model="request.draftGameId" class="input" placeholder="임시 게임 ID" />
|
<input v-model="request.draftGameId" class="input" placeholder="임시 템플릿 ID" />
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="templateRequestCard__thumbLabel">게임 이름</div>
|
<div class="templateRequestCard__thumbLabel">템플릿 이름</div>
|
||||||
<div class="templateRequestCard__thumbValue">{{ request.draftGameName || request.sourceGameName || '-' }}</div>
|
<div class="templateRequestCard__thumbValue">{{ request.draftGameName || request.sourceGameName || '-' }}</div>
|
||||||
<div class="templateRequestCard__thumbLabel">게임 ID</div>
|
<div class="templateRequestCard__thumbLabel">템플릿 ID</div>
|
||||||
<div class="templateRequestCard__thumbValue">{{ request.draftGameId || request.sourceGameId || '-' }}</div>
|
<div class="templateRequestCard__thumbValue">{{ request.draftGameId || request.sourceGameId || '-' }}</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,7 +111,7 @@ const props = defineProps({
|
|||||||
request.isHandling
|
request.isHandling
|
||||||
? '이동중...'
|
? '이동중...'
|
||||||
: request.type === 'create' && (request.targetGameName || request.targetGameId)
|
: request.type === 'create' && (request.targetGameName || request.targetGameId)
|
||||||
? '연결된 게임 열기'
|
? '연결된 템플릿 열기'
|
||||||
: '확인하기'
|
: '확인하기'
|
||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ const customItemTargetGame = computed(() => games.value.find((game) => game.id =
|
|||||||
const importModalItemCount = computed(() => importModalItems.value.length)
|
const importModalItemCount = computed(() => importModalItems.value.length)
|
||||||
const activeTabTitle = computed(() => {
|
const activeTabTitle = computed(() => {
|
||||||
if (activeTab.value === 'featured') return '목록 관리'
|
if (activeTab.value === 'featured') return '목록 관리'
|
||||||
if (activeTab.value === 'game-admin') return '게임 관리'
|
if (activeTab.value === 'game-admin') return '템플릿 관리'
|
||||||
if (activeTab.value === 'items') return '아이템 관리'
|
if (activeTab.value === 'items') return '아이템 관리'
|
||||||
if (activeTab.value === 'tierlists') {
|
if (activeTab.value === 'tierlists') {
|
||||||
return tierlistsMode.value === 'requests' ? '템플릿 요청 관리' : '전체 티어표 관리'
|
return tierlistsMode.value === 'requests' ? '템플릿 요청 관리' : '전체 티어표 관리'
|
||||||
@@ -223,17 +223,17 @@ const activeTabTitle = computed(() => {
|
|||||||
})
|
})
|
||||||
const activeTabDescription = computed(() => {
|
const activeTabDescription = computed(() => {
|
||||||
if (activeTab.value === 'featured') {
|
if (activeTab.value === 'featured') {
|
||||||
return '홈 화면 상단에 고정 노출되는 게임 순서를 따로 관리합니다.'
|
return '홈 화면 상단에 고정 노출되는 템플릿 순서를 따로 관리합니다.'
|
||||||
}
|
}
|
||||||
if (activeTab.value === 'game-admin') {
|
if (activeTab.value === 'game-admin') {
|
||||||
return '게임 생성, 선택, 썸네일, 기본 아이템 관리를 전용 작업 화면으로 분리했습니다.'
|
return '템플릿 생성, 선택, 썸네일, 기본 아이템 관리를 전용 작업 화면으로 분리했습니다.'
|
||||||
}
|
}
|
||||||
if (activeTab.value === 'items') {
|
if (activeTab.value === 'items') {
|
||||||
return '사용자 업로드와 관리자 템플릿 이미지를 함께 검수하고, 필요한 게임에 직접 연결할 수 있어요.'
|
return '사용자 업로드와 관리자 템플릿 이미지를 함께 검수하고, 필요한 템플릿에 직접 연결할 수 있어요.'
|
||||||
}
|
}
|
||||||
if (activeTab.value === 'tierlists') {
|
if (activeTab.value === 'tierlists') {
|
||||||
return tierlistsMode.value === 'requests'
|
return tierlistsMode.value === 'requests'
|
||||||
? '사용자 요청을 확인하고, 게임 관리 화면에서 필요한 아이템만 선별 반영한 뒤 직접 완료 처리합니다.'
|
? '사용자 요청을 확인하고, 템플릿 관리 화면에서 필요한 아이템만 선별 반영한 뒤 직접 완료 처리합니다.'
|
||||||
: '공개/비공개 포함 전체 티어표를 확인하고, 추가 아이템을 템플릿으로 가져올 수 있어요.'
|
: '공개/비공개 포함 전체 티어표를 확인하고, 추가 아이템을 템플릿으로 가져올 수 있어요.'
|
||||||
}
|
}
|
||||||
return '계정 정보, 권한, 비밀번호와 최근 활동을 더 가볍게 확인하고 수정합니다.'
|
return '계정 정보, 권한, 비밀번호와 최근 활동을 더 가볍게 확인하고 수정합니다.'
|
||||||
@@ -245,14 +245,14 @@ const adminOverviewStats = computed(() => {
|
|||||||
|
|
||||||
if (activeTab.value === 'featured') {
|
if (activeTab.value === 'featured') {
|
||||||
return [
|
return [
|
||||||
{ label: '전체 게임', value: `${games.value.length}` },
|
{ label: '전체 템플릿', value: `${games.value.length}` },
|
||||||
{ label: '상단 고정', value: `${featuredGameIds.value.length}/50` },
|
{ label: '상단 고정', value: `${featuredGameIds.value.length}/50` },
|
||||||
{ label: '추가 가능', value: `${Math.max(0, 50 - featuredGameIds.value.length)}` },
|
{ label: '추가 가능', value: `${Math.max(0, 50 - featuredGameIds.value.length)}` },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
if (activeTab.value === 'game-admin') {
|
if (activeTab.value === 'game-admin') {
|
||||||
return [
|
return [
|
||||||
{ label: '전체 게임', value: `${games.value.length}` },
|
{ label: '전체 템플릿', value: `${games.value.length}` },
|
||||||
{ label: '티어표 전체', value: `${selectedGameTierListStats.value.total || 0}` },
|
{ label: '티어표 전체', value: `${selectedGameTierListStats.value.total || 0}` },
|
||||||
{ label: '공개', value: `${selectedGameTierListStats.value.publicCount || 0}` },
|
{ label: '공개', value: `${selectedGameTierListStats.value.publicCount || 0}` },
|
||||||
{ label: '비공개', value: `${selectedGameTierListStats.value.privateCount || 0}` },
|
{ label: '비공개', value: `${selectedGameTierListStats.value.privateCount || 0}` },
|
||||||
@@ -571,7 +571,7 @@ function formatImageJobSourceCategory(category) {
|
|||||||
case 'tierlists':
|
case 'tierlists':
|
||||||
return '티어표 썸네일'
|
return '티어표 썸네일'
|
||||||
case 'games':
|
case 'games':
|
||||||
return '게임/템플릿 이미지'
|
return '주제/템플릿 이미지'
|
||||||
case 'avatars':
|
case 'avatars':
|
||||||
return '프로필 아바타'
|
return '프로필 아바타'
|
||||||
default:
|
default:
|
||||||
@@ -599,7 +599,7 @@ function customItemDeleteImpactText(item) {
|
|||||||
if (item.sourceType === 'template') {
|
if (item.sourceType === 'template') {
|
||||||
return item.isAssetLibraryItem
|
return item.isAssetLibraryItem
|
||||||
? `"${item.label}" 보관 자산 항목을 정리할까요? 라이브러리 항목만 제거되고, 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.`
|
? `"${item.label}" 보관 자산 항목을 정리할까요? 라이브러리 항목만 제거되고, 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.`
|
||||||
: `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 게임의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.`
|
: `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 주제의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.`
|
||||||
}
|
}
|
||||||
|
|
||||||
return `"${item.label}" 사용자 업로드 이미지를 삭제할까요? 현재 항목만 정리됩니다.`
|
return `"${item.label}" 사용자 업로드 이미지를 삭제할까요? 현재 항목만 정리됩니다.`
|
||||||
@@ -703,7 +703,7 @@ async function confirmImageReset() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupMissingImageReferences() {
|
async function cleanupMissingImageReferences() {
|
||||||
const ok = window.confirm('파일이 실제로 없는 이미지 참조만 정리할까요? 누락된 썸네일은 비워지고, 누락된 게임/커스텀 아이템은 관련 참조와 함께 정리됩니다.')
|
const ok = window.confirm('파일이 실제로 없는 이미지 참조만 정리할까요? 누락된 썸네일은 비워지고, 누락된 템플릿/커스텀 아이템은 관련 참조와 함께 정리됩니다.')
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -714,10 +714,10 @@ async function cleanupMissingImageReferences() {
|
|||||||
success.value =
|
success.value =
|
||||||
`누락 참조를 정리했어요. ` +
|
`누락 참조를 정리했어요. ` +
|
||||||
`아바타 ${result.clearedAvatars || 0}건, ` +
|
`아바타 ${result.clearedAvatars || 0}건, ` +
|
||||||
`게임 썸네일 ${result.clearedGameThumbnails || 0}건, ` +
|
`템플릿 썸네일 ${result.clearedGameThumbnails || 0}건, ` +
|
||||||
`티어표 썸네일 ${result.clearedTierListThumbnails || 0}건, ` +
|
`티어표 썸네일 ${result.clearedTierListThumbnails || 0}건, ` +
|
||||||
`요청 썸네일 ${result.clearedTemplateRequestThumbnails || 0}건, ` +
|
`요청 썸네일 ${result.clearedTemplateRequestThumbnails || 0}건, ` +
|
||||||
`게임 아이템 ${result.deletedGameItems || 0}건, ` +
|
`템플릿 아이템 ${result.deletedGameItems || 0}건, ` +
|
||||||
`커스텀 아이템 ${result.deletedCustomItems || 0}건`
|
`커스텀 아이템 ${result.deletedCustomItems || 0}건`
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '누락 이미지 참조 정리에 실패했어요.'
|
error.value = '누락 이미지 참조 정리에 실패했어요.'
|
||||||
@@ -794,7 +794,7 @@ async function refreshGames() {
|
|||||||
.map((game) => game.id)
|
.map((game) => game.id)
|
||||||
await syncFeaturedSortable()
|
await syncFeaturedSortable()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '게임 목록을 불러오지 못했어요.'
|
error.value = '템플릿 목록을 불러오지 못했어요.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1180,10 +1180,10 @@ async function saveGameVisibility() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
await refreshGames()
|
await refreshGames()
|
||||||
success.value = data.game?.isPublic ? '게임을 공개 상태로 전환했어요.' : '게임을 비공개 상태로 전환했어요.'
|
success.value = data.game?.isPublic ? '템플릿을 공개 상태로 전환했어요.' : '템플릿을 비공개 상태로 전환했어요.'
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '게임 공개 상태를 저장하지 못했어요.'
|
error.value = '템플릿 공개 상태를 저장하지 못했어요.'
|
||||||
return false
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
gameVisibilitySaving.value = false
|
gameVisibilitySaving.value = false
|
||||||
@@ -1225,9 +1225,9 @@ async function removeGameItem(itemId) {
|
|||||||
if (!res.ok) throw new Error('failed')
|
if (!res.ok) throw new Error('failed')
|
||||||
|
|
||||||
await loadGame()
|
await loadGame()
|
||||||
success.value = '게임 기본 아이템을 삭제했어요.'
|
success.value = '템플릿 기본 아이템을 삭제했어요.'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '게임 기본 아이템 삭제에 실패했어요.'
|
error.value = '템플릿 기본 아이템 삭제에 실패했어요.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1258,7 +1258,7 @@ async function removeGame() {
|
|||||||
resetMessages()
|
resetMessages()
|
||||||
if (!selectedGameId.value || !selectedGame.value?.game) return
|
if (!selectedGameId.value || !selectedGame.value?.game) return
|
||||||
|
|
||||||
const ok = window.confirm(`"${selectedGame.value.game.name}" 게임을 삭제할까요? 관련 기본 아이템과 티어표도 함께 삭제됩니다.`)
|
const ok = window.confirm(`"${selectedGame.value.game.name}" 템플릿을 삭제할까요? 관련 기본 아이템과 티어표도 함께 삭제됩니다.`)
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1273,9 +1273,9 @@ async function removeGame() {
|
|||||||
selectedGame.value = null
|
selectedGame.value = null
|
||||||
resetUploadState()
|
resetUploadState()
|
||||||
await refreshGames()
|
await refreshGames()
|
||||||
success.value = `${deletedName} 게임을 삭제했어요.`
|
success.value = `${deletedName} 템플릿을 삭제했어요.`
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '게임 삭제에 실패했어요.'
|
error.value = '템플릿 삭제에 실패했어요.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1570,7 +1570,7 @@ async function confirmTierListImport() {
|
|||||||
try {
|
try {
|
||||||
if (importModalMode.value === 'existing') {
|
if (importModalMode.value === 'existing') {
|
||||||
if (!importModalTargetGameId.value) {
|
if (!importModalTargetGameId.value) {
|
||||||
error.value = '아이템을 추가할 기존 게임을 선택해주세요.'
|
error.value = '아이템을 추가할 기존 템플릿을 선택해주세요.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1584,7 +1584,7 @@ async function confirmTierListImport() {
|
|||||||
const nextGameId = (importModalNewGameId.value || '').trim()
|
const nextGameId = (importModalNewGameId.value || '').trim()
|
||||||
const nextGameName = (importModalNewGameName.value || '').trim()
|
const nextGameName = (importModalNewGameName.value || '').trim()
|
||||||
if (!nextGameId || !nextGameName) {
|
if (!nextGameId || !nextGameName) {
|
||||||
error.value = '새 게임 ID와 이름을 모두 입력해주세요.'
|
error.value = '새 템플릿 ID와 이름을 모두 입력해주세요.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1610,9 +1610,9 @@ function templateRequestTypeLabel(request) {
|
|||||||
function templateRequestTargetLabel(request) {
|
function templateRequestTargetLabel(request) {
|
||||||
if (request.type === 'create') {
|
if (request.type === 'create') {
|
||||||
if (request.targetGameName || request.targetGameId) {
|
if (request.targetGameName || request.targetGameId) {
|
||||||
return `연결된 게임 · ${request.targetGameName || request.targetGameId}`
|
return `연결된 템플릿 · ${request.targetGameName || request.targetGameId}`
|
||||||
}
|
}
|
||||||
return '연결된 게임 없음'
|
return '연결된 템플릿 없음'
|
||||||
}
|
}
|
||||||
return request.targetGameName || request.targetGameId || request.sourceGameName
|
return request.targetGameName || request.targetGameId || request.sourceGameName
|
||||||
}
|
}
|
||||||
@@ -1787,16 +1787,16 @@ function userAvatarFallback(user) {
|
|||||||
|
|
||||||
<div v-if="gameCreateModalOpen" class="modalOverlay" @click.self="closeGameCreateModal">
|
<div v-if="gameCreateModalOpen" class="modalOverlay" @click.self="closeGameCreateModal">
|
||||||
<div class="modalCard" role="dialog" aria-modal="true">
|
<div class="modalCard" role="dialog" aria-modal="true">
|
||||||
<div class="modalCard__title">새 게임 만들기</div>
|
<div class="modalCard__title">새 템플릿 만들기</div>
|
||||||
<div class="modalCard__desc">게임 이름과 고유 ID를 입력한 뒤 생성하면 바로 아래 상세 관리 화면으로 이어집니다.</div>
|
<div class="modalCard__desc">템플릿 이름과 고유 ID를 입력한 뒤 생성하면 바로 아래 상세 관리 화면으로 이어집니다.</div>
|
||||||
<div class="modalCard__form">
|
<div class="modalCard__form">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">게임 이름</span>
|
<span class="field__label">템플릿 이름</span>
|
||||||
<input v-model="newGameName" class="field__input" maxlength="60" placeholder="게임 이름" />
|
<input v-model="newGameName" class="field__input" maxlength="60" placeholder="템플릿 이름" />
|
||||||
<span class="field__hint">{{ newGameName.length }}/60자</span>
|
<span class="field__hint">{{ newGameName.length }}/60자</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">게임 ID</span>
|
<span class="field__label">템플릿 ID</span>
|
||||||
<input
|
<input
|
||||||
v-model="newGameId"
|
v-model="newGameId"
|
||||||
class="field__input"
|
class="field__input"
|
||||||
@@ -1814,7 +1814,7 @@ function userAvatarFallback(user) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="modalCard__actions">
|
<div class="modalCard__actions">
|
||||||
<button class="btn btn--ghost" @click="closeGameCreateModal">취소</button>
|
<button class="btn btn--ghost" @click="closeGameCreateModal">취소</button>
|
||||||
<button class="btn btn--primary" :disabled="!newGameId.trim() || !newGameName.trim()" @click="createGame">게임 생성</button>
|
<button class="btn btn--primary" :disabled="!newGameId.trim() || !newGameName.trim()" @click="createGame">템플릿 생성</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1925,14 +1925,14 @@ function userAvatarFallback(user) {
|
|||||||
|
|
||||||
<div v-if="importModalMode === 'existing'" class="modalCard__form">
|
<div v-if="importModalMode === 'existing'" class="modalCard__form">
|
||||||
<select v-model="importModalTargetGameId" class="select">
|
<select v-model="importModalTargetGameId" class="select">
|
||||||
<option value="">기존 게임 선택</option>
|
<option value="">기존 템플릿 선택</option>
|
||||||
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
|
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="modalCard__form">
|
<div v-else class="modalCard__form">
|
||||||
<input v-model="importModalNewGameId" class="input" placeholder="새 게임 ID" />
|
<input v-model="importModalNewGameId" class="input" placeholder="새 템플릿 ID" />
|
||||||
<input v-model="importModalNewGameName" class="input" placeholder="새 게임 이름" />
|
<input v-model="importModalNewGameName" class="input" placeholder="새 템플릿 이름" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modalCard__actions">
|
<div class="modalCard__actions">
|
||||||
@@ -1950,15 +1950,15 @@ function userAvatarFallback(user) {
|
|||||||
<aside class="customItemModal__pickerPanel">
|
<aside class="customItemModal__pickerPanel">
|
||||||
<div class="customItemModal__pickerHead">
|
<div class="customItemModal__pickerHead">
|
||||||
<div class="customItemModal__pickerEyebrow">GAME PICKER</div>
|
<div class="customItemModal__pickerEyebrow">GAME PICKER</div>
|
||||||
<div class="customItemModal__pickerTitle">템플릿으로 추가할 게임</div>
|
<div class="customItemModal__pickerTitle">아이템을 추가할 템플릿</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="adminSelectionCard">
|
<div class="adminSelectionCard">
|
||||||
<div class="adminSelectionCard__label">선택한 게임</div>
|
<div class="adminSelectionCard__label">선택한 템플릿</div>
|
||||||
<div class="adminSelectionCard__title">{{ customItemTargetGame?.name || '아직 선택하지 않음' }}</div>
|
<div class="adminSelectionCard__title">{{ customItemTargetGame?.name || '아직 선택하지 않음' }}</div>
|
||||||
<div class="adminSelectionCard__meta">{{ customItemTargetGame?.id || '게임을 골라 주세요.' }}</div>
|
<div class="adminSelectionCard__meta">{{ customItemTargetGame?.id || '템플릿을 골라 주세요.' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="customItemModal__pickerActions">
|
<div class="customItemModal__pickerActions">
|
||||||
<button class="btn btn--ghost" type="button" @click="openGamePickerModal('custom-item-target')">게임 선택</button>
|
<button class="btn btn--ghost" type="button" @click="openGamePickerModal('custom-item-target')">템플릿 선택</button>
|
||||||
<button class="btn btn--ghost btn--small customItemModal__createGameButton" type="button" @click="openGameCreateModal">새 템플릿 만들기</button>
|
<button class="btn btn--ghost btn--small customItemModal__createGameButton" type="button" @click="openGameCreateModal">새 템플릿 만들기</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -1983,15 +1983,15 @@ function userAvatarFallback(user) {
|
|||||||
<div class="customItemModal__metaList">
|
<div class="customItemModal__metaList">
|
||||||
<div class="customItemModal__metaRow"><span>파일</span><strong :title="modalTargetCustomItem.src.split('/').pop()">{{ modalTargetCustomItem.src.split('/').pop() }}</strong></div>
|
<div class="customItemModal__metaRow"><span>파일</span><strong :title="modalTargetCustomItem.src.split('/').pop()">{{ modalTargetCustomItem.src.split('/').pop() }}</strong></div>
|
||||||
<div class="customItemModal__metaRow"><span>업로더/출처</span><strong :title="modalTargetCustomItem.ownerName">{{ modalTargetCustomItem.ownerName }}</strong></div>
|
<div class="customItemModal__metaRow"><span>업로더/출처</span><strong :title="modalTargetCustomItem.ownerName">{{ modalTargetCustomItem.ownerName }}</strong></div>
|
||||||
<div class="customItemModal__metaRow"><span>템플릿 연결</span><strong>{{ visibleLinkedGames.length }}개 게임</strong></div>
|
<div class="customItemModal__metaRow"><span>템플릿 연결</span><strong>{{ visibleLinkedGames.length }}개 템플릿</strong></div>
|
||||||
<div class="customItemModal__metaRow"><span>등록일</span><strong>{{ fmt(modalTargetCustomItem.createdAt) }}</strong></div>
|
<div class="customItemModal__metaRow"><span>등록일</span><strong>{{ fmt(modalTargetCustomItem.createdAt) }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="customItemModal__linked">
|
<div class="customItemModal__linked">
|
||||||
<span class="customItemModal__label">템플릿에 사용 중인 게임</span>
|
<span class="customItemModal__label">이 이미지를 사용하는 템플릿</span>
|
||||||
<div v-if="visibleLinkedGames.length" class="customItemModal__chips">
|
<div v-if="visibleLinkedGames.length" class="customItemModal__chips">
|
||||||
<button v-for="game in visibleLinkedGames" :key="game.id" type="button" class="pill pill--link" @click="jumpToGameAdmin(game.id)">{{ game.name }}</button>
|
<button v-for="game in visibleLinkedGames" :key="game.id" type="button" class="pill pill--link" @click="jumpToGameAdmin(game.id)">{{ game.name }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="hint hint--tight">아직 템플릿에 연결된 게임이 없어요.</div>
|
<div v-else class="hint hint--tight">아직 템플릿에 연결된 항목이 없어요.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="customItemModal__actions">
|
<div class="customItemModal__actions">
|
||||||
<a class="btn btn--ghost customItemModal__action" :href="toApiUrl(modalTargetCustomItem.src)" :download="modalTargetCustomItem.label">이미지 다운로드</a>
|
<a class="btn btn--ghost customItemModal__action" :href="toApiUrl(modalTargetCustomItem.src)" :download="modalTargetCustomItem.label">이미지 다운로드</a>
|
||||||
@@ -2010,15 +2010,15 @@ function userAvatarFallback(user) {
|
|||||||
<div class="modalCard" role="dialog" aria-modal="true">
|
<div class="modalCard" role="dialog" aria-modal="true">
|
||||||
<div class="modalCard__titleRow">
|
<div class="modalCard__titleRow">
|
||||||
<div>
|
<div>
|
||||||
<div class="modalCard__title">게임 선택</div>
|
<div class="modalCard__title">템플릿 선택</div>
|
||||||
<div class="modalCard__desc">
|
<div class="modalCard__desc">
|
||||||
{{ gamePickerMode === 'tierlists-filter' ? '특정 게임의 티어표만 보려면 게임을 선택하세요.' : '관리할 게임을 검색해서 바로 열 수 있어요.' }}
|
{{ gamePickerMode === 'tierlists-filter' ? '특정 주제의 티어표만 보려면 템플릿을 선택하세요.' : '관리할 템플릿을 검색해서 바로 열 수 있어요.' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn--ghost btn--small" @click="closeGamePickerModal">닫기</button>
|
<button class="btn btn--ghost btn--small" @click="closeGamePickerModal">닫기</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modalCard__form">
|
<div class="modalCard__form">
|
||||||
<input v-model="gamePickerQuery" class="input" placeholder="게임 이름 또는 ID 검색" />
|
<input v-model="gamePickerQuery" class="input" placeholder="템플릿 이름 또는 ID 검색" />
|
||||||
<select v-model="gamePickerSort" class="select">
|
<select v-model="gamePickerSort" class="select">
|
||||||
<option value="recent">최신순</option>
|
<option value="recent">최신순</option>
|
||||||
<option value="oldest">오래된순</option>
|
<option value="oldest">오래된순</option>
|
||||||
@@ -2029,7 +2029,7 @@ function userAvatarFallback(user) {
|
|||||||
type="button"
|
type="button"
|
||||||
@click="setAdminTierListGameId(''); closeGamePickerModal()"
|
@click="setAdminTierListGameId(''); closeGamePickerModal()"
|
||||||
>
|
>
|
||||||
모든 게임 보기
|
모든 주제 보기
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="gamePickerModalList">
|
<div class="gamePickerModalList">
|
||||||
@@ -2201,7 +2201,7 @@ function userAvatarFallback(user) {
|
|||||||
<div class="adminSidebar__label">Mode</div>
|
<div class="adminSidebar__label">Mode</div>
|
||||||
<div class="adminSidebar__tabs">
|
<div class="adminSidebar__tabs">
|
||||||
<button class="tab" :class="{ 'tab--active': activeTab === 'featured' }" @click="setTab('featured')">목록 관리</button>
|
<button class="tab" :class="{ 'tab--active': activeTab === 'featured' }" @click="setTab('featured')">목록 관리</button>
|
||||||
<button class="tab" :class="{ 'tab--active': activeTab === 'game-admin' }" @click="setTab('game-admin')">게임 관리</button>
|
<button class="tab" :class="{ 'tab--active': activeTab === 'game-admin' }" @click="setTab('game-admin')">템플릿 관리</button>
|
||||||
<button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button>
|
<button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button>
|
||||||
<button class="tab" :class="{ 'tab--active': activeTab === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button>
|
<button class="tab" :class="{ 'tab--active': activeTab === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button>
|
||||||
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
|
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
|
||||||
@@ -2209,16 +2209,16 @@ function userAvatarFallback(user) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="activeTab === 'game-admin'" class="adminSidebar__panel">
|
<section v-if="activeTab === 'game-admin'" class="adminSidebar__panel">
|
||||||
<div class="adminSidebar__label">Game</div>
|
<div class="adminSidebar__label">Template</div>
|
||||||
<div class="adminSidebar__group">
|
<div class="adminSidebar__group">
|
||||||
<button class="btn btn--primary" @click="openGameCreateModal">새 게임 생성</button>
|
<button class="btn btn--primary" @click="openGameCreateModal">새 템플릿 생성</button>
|
||||||
<button class="btn btn--ghost" @click="openGamePickerModal('game-admin')">게임 선택</button>
|
<button class="btn btn--ghost" @click="openGamePickerModal('game-admin')">템플릿 선택</button>
|
||||||
<div v-if="selectedGame?.game" class="adminSelectionCard">
|
<div v-if="selectedGame?.game" class="adminSelectionCard">
|
||||||
<div class="adminSelectionCard__label">선택한 게임</div>
|
<div class="adminSelectionCard__label">선택한 템플릿</div>
|
||||||
<div class="adminSelectionCard__title">{{ selectedGame.game.name }}</div>
|
<div class="adminSelectionCard__title">{{ selectedGame.game.name }}</div>
|
||||||
<div class="adminSelectionCard__meta">{{ selectedGame.game.id }}</div>
|
<div class="adminSelectionCard__meta">{{ selectedGame.game.id }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 게임 ID: {{ selectedGameId }}</div>
|
<div v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 템플릿 ID: {{ selectedGameId }}</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -2277,14 +2277,14 @@ function userAvatarFallback(user) {
|
|||||||
<input
|
<input
|
||||||
v-model="adminTierListQuery"
|
v-model="adminTierListQuery"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="제목, 작성자, 게임 이름 검색"
|
placeholder="제목, 작성자, 주제 이름 검색"
|
||||||
@keydown.enter.prevent="submitAdminTierListSearch"
|
@keydown.enter.prevent="submitAdminTierListSearch"
|
||||||
/>
|
/>
|
||||||
<button class="btn btn--ghost" @click="submitAdminTierListSearch">검색</button>
|
<button class="btn btn--ghost" @click="submitAdminTierListSearch">검색</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn--ghost" @click="openGamePickerModal('tierlists-filter')">게임 선택</button>
|
<button class="btn btn--ghost" @click="openGamePickerModal('tierlists-filter')">주제 선택</button>
|
||||||
<div v-if="adminTierListGameId" class="adminSelectionCard">
|
<div v-if="adminTierListGameId" class="adminSelectionCard">
|
||||||
<div class="adminSelectionCard__label">필터된 게임</div>
|
<div class="adminSelectionCard__label">필터된 주제</div>
|
||||||
<div class="adminSelectionCard__title">{{ games.find((game) => game.id === adminTierListGameId)?.name || adminTierListGameId }}</div>
|
<div class="adminSelectionCard__title">{{ games.find((game) => game.id === adminTierListGameId)?.name || adminTierListGameId }}</div>
|
||||||
<div class="adminSelectionCard__meta">{{ adminTierListGameId }}</div>
|
<div class="adminSelectionCard__meta">{{ adminTierListGameId }}</div>
|
||||||
<button class="btn btn--ghost btn--small" @click="setAdminTierListGameId('')">필터 해제</button>
|
<button class="btn btn--ghost btn--small" @click="setAdminTierListGameId('')">필터 해제</button>
|
||||||
@@ -3891,7 +3891,6 @@ function userAvatarFallback(user) {
|
|||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
vertical-align: top;
|
|
||||||
}
|
}
|
||||||
.adminUiScope .templateRequestCard__thumbMeta {
|
.adminUiScope .templateRequestCard__thumbMeta {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -4145,6 +4144,7 @@ function userAvatarFallback(user) {
|
|||||||
color: var(--theme-text-muted);
|
color: var(--theme-text-muted);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
|
line-clamp: 2;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -58,11 +58,11 @@ onMounted(loadFavorites)
|
|||||||
<div class="pageHead">
|
<div class="pageHead">
|
||||||
<div class="pageHead__main">
|
<div class="pageHead__main">
|
||||||
<div class="pageHead__eyebrow">Collection</div>
|
<div class="pageHead__eyebrow">Collection</div>
|
||||||
<h2 class="pageHead__title">내 즐겨찾기</h2>
|
<h2 class="pageHead__title">즐겨찾기</h2>
|
||||||
<div class="pageHead__desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 수 있어요.</div>
|
<div class="pageHead__desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 수 있어요.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pageHead__aside toolbar">
|
<div class="pageHead__aside toolbar">
|
||||||
<input v-model="query" class="input" placeholder="제목, 게임, 작성자 검색" @keydown.enter.prevent="loadFavorites" />
|
<input v-model="query" class="input" placeholder="제목, 주제, 작성자 검색" @keydown.enter.prevent="loadFavorites" />
|
||||||
<select v-model="sort" class="select" @change="loadFavorites">
|
<select v-model="sort" class="select" @change="loadFavorites">
|
||||||
<option value="favorited">즐겨찾기한 순</option>
|
<option value="favorited">즐겨찾기한 순</option>
|
||||||
<option value="updated">최신 업데이트순</option>
|
<option value="updated">최신 업데이트순</option>
|
||||||
@@ -77,7 +77,7 @@ onMounted(loadFavorites)
|
|||||||
<article v-for="tierList in favorites" :key="tierList.id" class="boardCard">
|
<article v-for="tierList in favorites" :key="tierList.id" class="boardCard">
|
||||||
<button class="boardCard__body" @click="openTierList(tierList)">
|
<button class="boardCard__body" @click="openTierList(tierList)">
|
||||||
<div class="boardCard__thumbWrap">
|
<div class="boardCard__thumbWrap">
|
||||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
|
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" draggable="false" />
|
||||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="boardCard__head">
|
<div class="boardCard__head">
|
||||||
@@ -87,7 +87,7 @@ onMounted(loadFavorites)
|
|||||||
</div>
|
</div>
|
||||||
<div class="boardCard__metaRow">
|
<div class="boardCard__metaRow">
|
||||||
<div class="boardCard__author">
|
<div class="boardCard__author">
|
||||||
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
|
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" draggable="false" />
|
||||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
|
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
|
||||||
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
|
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ async function loadTierLists() {
|
|||||||
brokenThumbnailIds.value = {}
|
brokenThumbnailIds.value = {}
|
||||||
tierLists.value = listRes.tierLists || []
|
tierLists.value = listRes.tierLists || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '게임 정보를 불러오지 못했어요.'
|
error.value = '주제 정보를 불러오지 못했어요.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ function submitSearch() {
|
|||||||
<div class="dashboardHero__left">
|
<div class="dashboardHero__left">
|
||||||
<div class="dashboardHero__eyebrow">Collection</div>
|
<div class="dashboardHero__eyebrow">Collection</div>
|
||||||
<h2 class="dashboardHero__title">{{ gameName || gameId }}</h2>
|
<h2 class="dashboardHero__title">{{ gameName || gameId }}</h2>
|
||||||
<p class="dashboardHero__desc">이 게임의 공개 티어표를 탐색하고, 바로 새 보드를 만들어 같은 흐름으로 이어갈 수 있어요.</p>
|
<p class="dashboardHero__desc">이 주제의 공개 티어표를 탐색하고, 바로 새 보드를 만들어 같은 흐름으로 이어갈 수 있어요.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ function submitSearch() {
|
|||||||
<article v-for="t in tierLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
<article v-for="t in tierLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
||||||
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openTierList(t.id)">
|
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openTierList(t.id)">
|
||||||
<div class="boardCard__thumbWrap">
|
<div class="boardCard__thumbWrap">
|
||||||
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" alt="" @error="handleThumbnailError(t.id)" />
|
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" alt="" draggable="false" @error="handleThumbnailError(t.id)" />
|
||||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="boardCard__head">
|
<div class="boardCard__head">
|
||||||
@@ -121,7 +121,7 @@ function submitSearch() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="boardCard__metaRow">
|
<div class="boardCard__metaRow">
|
||||||
<div class="boardCard__author">
|
<div class="boardCard__author">
|
||||||
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
|
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" draggable="false" />
|
||||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
|
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
|
||||||
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
|
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ function thumbUrl(g) {
|
|||||||
<section class="pageHead">
|
<section class="pageHead">
|
||||||
<div class="pageHead__main">
|
<div class="pageHead__main">
|
||||||
<div class="pageHead__eyebrow">Workspace</div>
|
<div class="pageHead__eyebrow">Workspace</div>
|
||||||
<h1 class="pageHead__title">Game Library</h1>
|
<h1 class="pageHead__title">Topic Library</h1>
|
||||||
<p class="pageHead__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
<p class="pageHead__desc">자주 쓰는 주제 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
||||||
<p v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 게임 템플릿만 보고 있어요.</p>
|
<p v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 주제 템플릿만 보고 있어요.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ function thumbUrl(g) {
|
|||||||
</button>
|
</button>
|
||||||
<button class="libraryCard__main" type="button" @click="goGame(g.id)">
|
<button class="libraryCard__main" type="button" @click="goGame(g.id)">
|
||||||
<div class="libraryCard__thumbWrap">
|
<div class="libraryCard__thumbWrap">
|
||||||
<img v-if="thumbUrl(g)" class="libraryCard__thumb" :src="thumbUrl(g)" :alt="g.name" />
|
<img v-if="thumbUrl(g)" class="libraryCard__thumb" :src="thumbUrl(g)" :alt="g.name" draggable="false" />
|
||||||
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="libraryCard__body">
|
<div class="libraryCard__body">
|
||||||
@@ -107,7 +107,7 @@ function thumbUrl(g) {
|
|||||||
</button>
|
</button>
|
||||||
</article>
|
</article>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 게임 템플릿이 없어요.' : '표시할 게임 템플릿이 없어요.' }}</div>
|
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 주제 템플릿이 없어요.' : '표시할 주제 템플릿이 없어요.' }}</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ async function submit() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else class="authScreen">
|
<section v-else class="authScreen">
|
||||||
<div class="authTabs" role="tablist" aria-label="로그인 또는 회원가입">
|
<div class="authTabs" :class="{ 'authTabs--signup': mode === 'signup' }" role="tablist" aria-label="로그인 또는 회원가입">
|
||||||
|
<span class="authTabs__indicator" aria-hidden="true"></span>
|
||||||
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'login' }" @click="mode = 'login'">
|
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'login' }" @click="mode = 'login'">
|
||||||
로그인
|
로그인
|
||||||
</button>
|
</button>
|
||||||
@@ -159,16 +160,37 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.authTabs {
|
.authTabs {
|
||||||
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 8px;
|
gap: 0;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid var(--theme-border);
|
border: 1px solid var(--theme-border);
|
||||||
background: var(--theme-pill-bg);
|
background: var(--theme-pill-bg);
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authTabs__indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 6px;
|
||||||
|
width: calc(50% - 6px);
|
||||||
|
height: calc(100% - 12px);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(76, 133, 245, 0.22);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(120, 169, 255, 0.1);
|
||||||
|
transform: translateX(0);
|
||||||
|
transition: transform 220ms ease, background-color 220ms ease, box-shadow 220ms ease;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authTabs--signup .authTabs__indicator {
|
||||||
|
transform: translateX(100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.authTabs__button {
|
.authTabs__button {
|
||||||
|
position: relative;
|
||||||
min-width: 112px;
|
min-width: 112px;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -177,10 +199,11 @@ async function submit() {
|
|||||||
color: var(--theme-text-muted);
|
color: var(--theme-text-muted);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: color 180ms ease;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authTabs__button--active {
|
.authTabs__button--active {
|
||||||
background: rgba(76, 133, 245, 0.22);
|
|
||||||
color: var(--theme-text-strong);
|
color: var(--theme-text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ function openList(t) {
|
|||||||
<section class="pageHead">
|
<section class="pageHead">
|
||||||
<div class="pageHead__main">
|
<div class="pageHead__main">
|
||||||
<div class="pageHead__eyebrow">Library</div>
|
<div class="pageHead__eyebrow">Library</div>
|
||||||
<h2 class="pageHead__title">내 티어표</h2>
|
<h2 class="pageHead__title">나의 티어표</h2>
|
||||||
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</div>
|
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -85,6 +85,7 @@ function openList(t) {
|
|||||||
class="boardCard__thumb"
|
class="boardCard__thumb"
|
||||||
:src="tierListThumbnailUrl(t)"
|
:src="tierListThumbnailUrl(t)"
|
||||||
alt=""
|
alt=""
|
||||||
|
draggable="false"
|
||||||
@error="handleThumbnailError(t.id)"
|
@error="handleThumbnailError(t.id)"
|
||||||
/>
|
/>
|
||||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||||
@@ -96,7 +97,7 @@ function openList(t) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="boardCard__metaRow">
|
<div class="boardCard__metaRow">
|
||||||
<div class="boardCard__author">
|
<div class="boardCard__author">
|
||||||
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
|
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" draggable="false" />
|
||||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
|
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
|
||||||
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
|
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ async function logout() {
|
|||||||
<header class="pageHead">
|
<header class="pageHead">
|
||||||
<div class="pageHead__main">
|
<div class="pageHead__main">
|
||||||
<div class="pageHead__eyebrow">Account</div>
|
<div class="pageHead__eyebrow">Account</div>
|
||||||
<h2 class="pageHead__title">Settings</h2>
|
<h2 class="pageHead__title">설정</h2>
|
||||||
<div class="pageHead__desc">계정 정보를 간결하게 정리하고, 프로필 이미지를 클릭해서 바로 변경할 수 있어요.</div>
|
<div class="pageHead__desc">계정 정보를 간결하게 정리하고, 프로필 이미지를 클릭해서 바로 변경할 수 있어요.</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -134,7 +134,7 @@ async function logout() {
|
|||||||
<div class="settingsIdentity">
|
<div class="settingsIdentity">
|
||||||
<div class="avatarButtonWrap">
|
<div class="avatarButtonWrap">
|
||||||
<button class="avatarButton" type="button" @click="openAvatarPicker">
|
<button class="avatarButton" type="button" @click="openAvatarPicker">
|
||||||
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" />
|
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" draggable="false" />
|
||||||
<div v-else class="avatarButton__fallback">{{ displayInitial }}</div>
|
<div v-else class="avatarButton__fallback">{{ displayInitial }}</div>
|
||||||
<div class="avatarButton__overlay">
|
<div class="avatarButton__overlay">
|
||||||
<span>{{ avatarUrl ? '이미지 변경' : '이미지 추가' }}</span>
|
<span>{{ avatarUrl ? '이미지 변경' : '이미지 추가' }}</span>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ watch(
|
|||||||
<section class="wrap">
|
<section class="wrap">
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<div>
|
<div>
|
||||||
<div class="head__eyebrow">Search</div>
|
<div class="head__eyebrow">검색</div>
|
||||||
<h2 class="title">전체 티어표 검색</h2>
|
<h2 class="title">전체 티어표 검색</h2>
|
||||||
<div class="desc">공개된 모든 티어표를 제목과 작성자 기준으로 찾아볼 수 있어요.</div>
|
<div class="desc">공개된 모든 티어표를 제목과 작성자 기준으로 찾아볼 수 있어요.</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,7 +80,7 @@ watch(
|
|||||||
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
|
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
|
||||||
<button class="boardCard__body" @click="openTierList(tierList)">
|
<button class="boardCard__body" @click="openTierList(tierList)">
|
||||||
<div class="boardCard__thumbWrap">
|
<div class="boardCard__thumbWrap">
|
||||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
|
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" draggable="false" />
|
||||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="boardCard__head">
|
<div class="boardCard__head">
|
||||||
@@ -92,7 +92,7 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
<div class="boardCard__metaRow">
|
<div class="boardCard__metaRow">
|
||||||
<div class="boardCard__author">
|
<div class="boardCard__author">
|
||||||
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
|
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" draggable="false" />
|
||||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
|
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
|
||||||
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
|
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ const canRequestTemplateUpdate = computed(
|
|||||||
)
|
)
|
||||||
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||||
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||||
const templateRequestTargetLabel = computed(() => (gameId.value === 'freeform' ? '새로운 템플릿' : (gameName.value || gameId.value || '선택한 게임')))
|
const templateRequestTargetLabel = computed(() => (gameId.value === 'freeform' ? '새로운 템플릿' : (gameName.value || gameId.value || '선택한 주제')))
|
||||||
const shareTierListUrl = computed(() => {
|
const shareTierListUrl = computed(() => {
|
||||||
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
||||||
if (!savedTierListId) return ''
|
if (!savedTierListId) return ''
|
||||||
@@ -678,6 +678,7 @@ function buildPayload(existingId) {
|
|||||||
description: (description.value || '').trim(),
|
description: (description.value || '').trim(),
|
||||||
isPublic: !!isPublic.value,
|
isPublic: !!isPublic.value,
|
||||||
showCharacterNames: !!showCharacterNames.value,
|
showCharacterNames: !!showCharacterNames.value,
|
||||||
|
iconSize: Number(iconSize.value || 80),
|
||||||
sourceTierListId: sourceTierListId.value || '',
|
sourceTierListId: sourceTierListId.value || '',
|
||||||
sourceSnapshotTitle: sourceSnapshotTitle.value || '',
|
sourceSnapshotTitle: sourceSnapshotTitle.value || '',
|
||||||
sourceSnapshotAuthor: sourceSnapshotAuthor.value || '',
|
sourceSnapshotAuthor: sourceSnapshotAuthor.value || '',
|
||||||
@@ -909,7 +910,7 @@ onMounted(() => {
|
|||||||
itemsById.value = map
|
itemsById.value = map
|
||||||
pool.value = base.map((it) => it.id)
|
pool.value = base.map((it) => it.id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '게임 기본 이미지를 불러오지 못했어요.'
|
error.value = '기본 템플릿 이미지를 불러오지 못했어요.'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tierListId.value && tierListId.value !== 'new') {
|
if (tierListId.value && tierListId.value !== 'new') {
|
||||||
@@ -923,6 +924,7 @@ onMounted(() => {
|
|||||||
description.value = t.description || ''
|
description.value = t.description || ''
|
||||||
isPublic.value = !!t.isPublic
|
isPublic.value = !!t.isPublic
|
||||||
showCharacterNames.value = !!t.showCharacterNames
|
showCharacterNames.value = !!t.showCharacterNames
|
||||||
|
iconSize.value = Number(t.iconSize || 80)
|
||||||
authorName.value = t.authorName || ''
|
authorName.value = t.authorName || ''
|
||||||
authorAccountName.value = t.authorAccountName || ''
|
authorAccountName.value = t.authorAccountName || ''
|
||||||
updatedAt.value = Number(t.updatedAt || 0)
|
updatedAt.value = Number(t.updatedAt || 0)
|
||||||
@@ -958,7 +960,7 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section v-if="previewMode" class="previewOnly">
|
<section v-if="previewMode" class="previewOnly" :style="{ '--thumb-size': `${iconSize}px` }">
|
||||||
<div class="previewOnly__sheet">
|
<div class="previewOnly__sheet">
|
||||||
<div class="previewOnly__title">{{ effectiveTitle }}</div>
|
<div class="previewOnly__title">{{ effectiveTitle }}</div>
|
||||||
<div v-if="description" class="previewOnly__description">{{ description }}</div>
|
<div v-if="description" class="previewOnly__description">{{ description }}</div>
|
||||||
@@ -1004,7 +1006,7 @@ onUnmounted(() => {
|
|||||||
<div v-if="isSaveModalOpen" class="modalOverlay" @click.self="closeSaveModal">
|
<div v-if="isSaveModalOpen" class="modalOverlay" @click.self="closeSaveModal">
|
||||||
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="saveModalTitle">
|
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="saveModalTitle">
|
||||||
<div id="saveModalTitle" class="modalCard__title">저장 완료</div>
|
<div id="saveModalTitle" class="modalCard__title">저장 완료</div>
|
||||||
<div class="modalCard__desc">티어표가 저장되었어요. 이어서 더 수정한 뒤 다시 저장할 수도 있어요.</div>
|
<div class="modalCard__desc">티어표가 저장되었어요.<br />이어서 더 수정한 뒤 다시 저장할 수도 있어요.</div>
|
||||||
<div class="modalCard__actions">
|
<div class="modalCard__actions">
|
||||||
<button class="btn btn--save" @click="closeSaveModal">확인</button>
|
<button class="btn btn--save" @click="closeSaveModal">확인</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1019,7 +1021,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="requestChecklist__hint">
|
<div class="requestChecklist__hint">
|
||||||
제목과 설명을 함께 적어두면 관리자가 어떤 신규 템플릿인지 훨씬 빠르게 파악할 수 있어요.
|
제목과 설명을 함께 적어두면 관리자가 어떤 신규 템플릿인지 훨씬 빠르게 파악할 수 있어요.
|
||||||
예시: 제목 `템플릿 등록 요청`, 설명 `여름 이벤트 한정 캐릭터 중심으로 새 게임 템플릿이 필요합니다.`
|
예시: 제목 `템플릿 등록 요청`, 설명 `여름 이벤트 한정 캐릭터 중심으로 새 주제 템플릿이 필요합니다.`
|
||||||
</div>
|
</div>
|
||||||
<div class="templateRequestDraft">
|
<div class="templateRequestDraft">
|
||||||
<label class="templateRequestDraft__field">
|
<label class="templateRequestDraft__field">
|
||||||
@@ -1509,6 +1511,7 @@ onUnmounted(() => {
|
|||||||
border: 1px solid var(--theme-border-strong);
|
border: 1px solid var(--theme-border-strong);
|
||||||
}
|
}
|
||||||
.previewOnly__drop {
|
.previewOnly__drop {
|
||||||
|
position: relative;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: var(--theme-pill-bg);
|
background: var(--theme-pill-bg);
|
||||||
border: 1px solid var(--theme-border);
|
border: 1px solid var(--theme-border);
|
||||||
@@ -2497,54 +2500,13 @@ onUnmounted(() => {
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
}
|
}
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.previewOnly__row {
|
.previewOnly__row,
|
||||||
grid-template-columns: 140px 1fr;
|
|
||||||
}
|
|
||||||
.heroCard {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.editorCanvas {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.row__content {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.row {
|
.row {
|
||||||
grid-template-columns: 150px 1fr;
|
|
||||||
}
|
|
||||||
.sidebar {
|
|
||||||
position: static;
|
|
||||||
}
|
|
||||||
.pool {
|
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.editorSidebar__actionGrid {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.editorSidebar__utilityLinks {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.requestChecklist__item {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.requestChecklist__icon {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.modalCard__actions .btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.previewOnly {
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
.previewOnly__columns,
|
.previewOnly__columns,
|
||||||
.previewOnly__row,
|
.boardColumnsHeader {
|
||||||
.boardColumnsHeader,
|
display: none;
|
||||||
.row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
}
|
||||||
.previewOnly__columnsSpacer,
|
.previewOnly__columnsSpacer,
|
||||||
.boardColumnsHeader__spacer {
|
.boardColumnsHeader__spacer {
|
||||||
@@ -2578,6 +2540,43 @@ onUnmounted(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
.heroCard {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.editorCanvas {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.row__content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
.pool {
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.editorSidebar__actionGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.editorSidebar__utilityLinks {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.requestChecklist__item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.requestChecklist__icon {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.modalCard__actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.previewOnly {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
.pool {
|
.pool {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|||||||