Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5621362f1 | |||
| caaddb8448 | |||
| 20186f7fe2 | |||
| 77605791fb | |||
| 9bb64b52f3 | |||
| c4d896ce36 | |||
| 1957f30341 | |||
| 8d257e21ff | |||
| 19fdf85dcc | |||
| 2626fe2335 | |||
| 074d028f04 | |||
| 208e9709f8 | |||
| 4ed7f275ba | |||
| 88ce413c31 | |||
| 7f7475fb20 | |||
| 8a44b51cce | |||
| 9d63ed2e76 | |||
| 99eb79f2c3 | |||
| 6b8abea203 | |||
| d692798358 | |||
| 49d4946735 | |||
| bd53cf96dc |
@@ -138,6 +138,7 @@ function mapTierListRow(row) {
|
||||
description: row.description || '',
|
||||
isPublic: !!row.is_public,
|
||||
showCharacterNames: !!row.show_character_names,
|
||||
iconSize: Number(row.icon_size || 80),
|
||||
sourceTierListId: row.source_tierlist_id || '',
|
||||
sourceSnapshotTitle: row.source_snapshot_title || '',
|
||||
sourceSnapshotAuthor: row.source_snapshot_author || '',
|
||||
@@ -314,6 +315,7 @@ async function ensureSchema() {
|
||||
description TEXT NOT NULL,
|
||||
is_public 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_snapshot_title VARCHAR(120) NOT NULL DEFAULT '',
|
||||
source_snapshot_author VARCHAR(120) NOT NULL DEFAULT '',
|
||||
@@ -455,9 +457,13 @@ async function ensureSchema() {
|
||||
if (!tierListShowNamesColumns.length) {
|
||||
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'")
|
||||
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') {
|
||||
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.is_public,
|
||||
t.show_character_names,
|
||||
t.icon_size,
|
||||
t.source_tierlist_id,
|
||||
t.source_snapshot_title,
|
||||
t.source_snapshot_author,
|
||||
@@ -1990,6 +1997,7 @@ async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit
|
||||
t.description,
|
||||
t.is_public,
|
||||
t.show_character_names,
|
||||
t.icon_size,
|
||||
t.source_tierlist_id,
|
||||
t.source_snapshot_title,
|
||||
t.source_snapshot_author,
|
||||
@@ -2093,6 +2101,7 @@ async function findTierListById(id, currentUserId = '') {
|
||||
t.description,
|
||||
t.is_public,
|
||||
t.show_character_names,
|
||||
t.icon_size,
|
||||
t.source_tierlist_id,
|
||||
t.source_snapshot_title,
|
||||
t.source_snapshot_author,
|
||||
@@ -2346,6 +2355,7 @@ async function saveTierList({
|
||||
description,
|
||||
isPublic,
|
||||
showCharacterNames = false,
|
||||
iconSize = 80,
|
||||
sourceTierListId = '',
|
||||
sourceSnapshotTitle = '',
|
||||
sourceSnapshotAuthor = '',
|
||||
@@ -2360,10 +2370,10 @@ async function saveTierList({
|
||||
await query(
|
||||
`
|
||||
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 = ?
|
||||
`,
|
||||
[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)
|
||||
}
|
||||
@@ -2373,11 +2383,11 @@ async function saveTierList({
|
||||
await query(
|
||||
`
|
||||
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)
|
||||
}
|
||||
@@ -2396,6 +2406,7 @@ async function duplicateTierListForUser({ tierList, targetUserId }) {
|
||||
description: tierList.description || '',
|
||||
isPublic: false,
|
||||
showCharacterNames: !!tierList.showCharacterNames,
|
||||
iconSize: Number(tierList.iconSize || 80),
|
||||
sourceTierListId: tierList.id,
|
||||
sourceSnapshotTitle: tierList.title || '',
|
||||
sourceSnapshotAuthor: tierList.authorName || tierList.authorAccountName || '',
|
||||
|
||||
@@ -92,6 +92,7 @@ const tierListUpsertSchema = z.object({
|
||||
description: z.string().max(1000).optional().default(''),
|
||||
isPublic: z.boolean().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(''),
|
||||
sourceSnapshotTitle: 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 || '',
|
||||
isPublic: !!payload.isPublic,
|
||||
showCharacterNames: !!payload.showCharacterNames,
|
||||
iconSize: Number(payload.iconSize || 80),
|
||||
sourceTierListId: payload.sourceTierListId || existing.sourceTierListId || '',
|
||||
sourceSnapshotTitle: payload.sourceSnapshotTitle || existing.sourceSnapshotTitle || '',
|
||||
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || existing.sourceSnapshotAuthor || '',
|
||||
@@ -307,6 +309,7 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
description: payload.description || '',
|
||||
isPublic: !!payload.isPublic,
|
||||
showCharacterNames: !!payload.showCharacterNames,
|
||||
iconSize: Number(payload.iconSize || 80),
|
||||
sourceTierListId: payload.sourceTierListId || '',
|
||||
sourceSnapshotTitle: payload.sourceSnapshotTitle || '',
|
||||
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || '',
|
||||
|
||||
@@ -1,5 +1,72 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 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
|
||||
- 모바일에서 열 헤더가 칸과 시각적으로 분리되는 문제는 전체 레이아웃을 다시 갈아엎기보다, 각 칸 안에 열 이름 배지를 같이 보여주는 편이 가장 적은 변경으로 효과를 낸다고 정리했다.
|
||||
- 배지를 쓰는 반응형 구간에서는 기존 상단 열 헤더까지 남겨두면 중복 정보가 되므로, 같은 브레이크포인트에서 헤더는 숨기고 칸 배지 하나만 남기는 편이 맞다고 정리했다.
|
||||
- 반응형 보정은 한 미디어 구간 안에서 서로 다른 규칙이 다시 덮어쓰지 않게 정리해야 하므로, 모바일용 `1fr` 레이아웃을 선언한 뒤 예전 `140px/150px` 규칙은 제거하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.82
|
||||
- 프리뷰 완성본도 결국 공유/열람용 결과물이므로, 이미지 다운로드 결과와 같은 작성자/저장 시각 메타를 같이 보여주는 편이 자연스럽다고 정리했다.
|
||||
- 관리자 템플릿 요청 카드는 “요청 티어표 보기”가 실제로 새창 이동용이라면 하단 버튼과 썸네일 클릭을 둘 다 유지하기보다, 썸네일 클릭 하나로 통합하는 편이 더 단순하고 직관적이라고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.81
|
||||
- 저장된 티어표 공유는 별도 새 페이지를 만들기보다, 이미 완성본 열람에 쓰고 있는 `preview=1` 주소를 그대로 공유 링크로 재사용하는 편이 가장 단순하고 일관적이라고 정리했다.
|
||||
- 공유 액션은 저장/삭제처럼 저장본 전제의 보조 기능이므로, 메인 저장 버튼 영역보다 하단 유틸리티 링크 영역에 두는 편이 더 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.79
|
||||
- 카피라이트처럼 앱 전체 브랜딩 성격의 footer는 관리자 텔레포트 안에 두기보다, `App.vue`의 공통 오른쪽 레일 footer로 두는 편이 위치도 안정적이고 화면 간 일관성도 높다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.78
|
||||
- 축소 상태에서는 텍스트가 사라지므로 같은 `티어표 만들기` 계열 액션이라도 커스텀 제작과 템플릿 기반 제작을 아이콘으로 구분해 주는 편이 맞다고 정리했다.
|
||||
- 관리자 우측 카피라이트처럼 “사이드바 하단”에 붙어야 하는 정보는 텔레포트 루트의 형제 노드로 두기보다, 실제 사이드바 컨테이너 내부의 마지막 행으로 두는 편이 레이아웃상 안전하다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.77
|
||||
- 왼쪽 레일을 접었을 때 하단 액션을 완전히 숨기면 `새 티어표 만들기` 진입점이 사라지므로, 펼친 상태의 하단 위치는 유지하되 축소 상태에서는 같은 위치의 아이콘 전용 버튼으로 바꿔 남겨두는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.76
|
||||
- 왼쪽 사이드 레일을 접었을 때는 텍스트가 사라진 뒤에도 행 높이가 제각각이면 아이콘 전용 탐색기로 읽히지 않으므로, 아바타/검색/내비 항목의 높이를 같은 규격으로 통일하는 편이 맞다고 정리했다.
|
||||
- 왼쪽 레일 검색은 화면에 따라 티어표 검색으로 바뀌면 사용자가 사이드 검색과 메인 검색 역할을 구분하기 어려우므로, 사이드는 게임 검색으로 고정하고 티어표 검색은 메인 화면 문맥에 맡기는 편이 더 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.75
|
||||
- 관리자 공용 모달은 기본 카드 여백을 계속 쓰되, 내부에 자체 셸을 가진 대형 상세 모달까지 같은 패딩을 강제로 받으면 오히려 레이아웃이 무너지므로 예외 클래스로 분리하는 편이 맞다고 정리했다.
|
||||
- 관리자 표기 링크는 텍스트만 두기보다, 추후 주소 변경이 쉬운 한 곳짜리 상수와 새 창 링크로 관리하는 편이 운영 측면에서 더 낫다고 판단했다.
|
||||
- 왼쪽 사이드 레일 접힘 상태는 요소를 좁히는 것만으로는 높이와 정렬 문제가 계속 남으므로, 메타 텍스트는 실제로 숨기고 아이콘 중심 문법으로 따로 정리하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.74
|
||||
- 관리자 공용 게임 선택 모달은 단순 검색만 제공하기보다, 현재 문맥에서 이미 선택 불가능한 대상을 `이미 추가됨`으로 명시하고 막아 주는 편이 운영 실수를 줄이는 데 더 효과적이라고 정리했다.
|
||||
- 프로젝트 표기는 관리자 헤더 상단보다 사이드바 최하단의 작은 카피라이트 문구로 빼는 편이 정보 밀도를 덜 방해한다고 판단했다.
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
## 공통 레이아웃
|
||||
- 앱 셸 파일: `frontend/src/App.vue`
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
|
||||
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker by zenn`이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
|
||||
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker`와 서비스 설명이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
|
||||
|
||||
## 백엔드 진입점
|
||||
- 서버 엔트리: `backend/index.js`
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
- 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다.
|
||||
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
|
||||
- 티어표 편집 화면의 우측 패널은 공통 `rightRail`의 `localRightRailRoot`에 직접 section들을 쌓는 구조이며, 별도 외곽 래퍼 카드 없이 공통 오른쪽 컬럼 문법을 그대로 따른다.
|
||||
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 고정 사이트 타이틀 `Tier Maker by zenn`을 표시한다.
|
||||
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 사이트 타이틀 `Tier Maker`와 현재 서비스 설명을 표시한다.
|
||||
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
||||
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
||||
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.
|
||||
|
||||
34
docs/todo.md
@@ -1,6 +1,34 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- 관리자 화면까지 포함한 사용자 노출 `게임` 문구를 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한다.
|
||||
- 모바일 열 배지는 같은 구간에서 상단 열 제목을 숨기도록 다시 맞췄으므로, 720px 안팎뿐 아니라 980px 이하 전 구간에서 중복 표기 없이 자연스러운지 한 번 더 QA한다.
|
||||
- 모바일 티어표 편집 레이아웃은 행 라벨 폭을 다시 덮어쓰던 규칙을 걷어냈으므로, 실제 980px 이하 구간에서 행 라벨이 과하게 넓지 않고 칸 폭을 충분히 남기는지 한 번 더 QA한다.
|
||||
- 프리뷰 완성본 하단 메타는 새로 붙였으므로, 작성자/저장 시각이 공개 열람 화면과 이미지 다운로드 결과 기준에서 모두 자연스럽게 읽히는지 한 번 더 QA한다.
|
||||
- 관리자 템플릿 요청 카드는 썸네일 클릭이 새창 열기 역할로 바뀌었으므로, 썸네일 클릭과 `확인하기` 액션이 서로 헷갈리지 않는지 한 번 더 QA한다.
|
||||
- 티어표 만들기 화면의 `공유하기`는 저장된 티어표에서만 노출되므로, 저장 직후/수정 중/복사본/읽기 전용 상태 각각에서 노출 조건과 클립보드 복사가 자연스러운지 한 번 더 QA한다.
|
||||
- 우측 카피라이트는 이제 공통 오른쪽 레일 footer이므로, 관리자 화면뿐 아니라 홈/프로필 등 오른쪽 사이드가 보이는 화면에서도 같은 최하단 위치에 유지되는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 축소 상태의 하단 액션 아이콘은 홈과 게임 허브에서 서로 다른 아이콘을 쓰도록 나눴으므로, 실제로 두 문맥이 한눈에 구분되는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 축소 상태 최하단의 `티어표 만들기` 아이콘 버튼은 새로 추가했으므로, 홈/게임 허브에서 실제로 같은 위치 감각으로 동작하는지 한 번 더 QA한다.
|
||||
- 관리자 우측 카피라이트 문구는 사이드바 내부 최하단으로 다시 옮겼으므로, 실제 관리자 화면에서 스크롤/창 크기 변화에도 계속 보이는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 축소 상태는 아이콘 줄 높이를 50px 기준으로 통일했으므로, 실제 데스크톱에서 아바타/검색/메뉴 아이콘이 시각적으로 같은 리듬으로 보이는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 검색은 이제 항상 게임 검색으로 홈으로 이동하므로, 홈이 아닌 화면에서 사이드 검색 후 게임 목록 결과로 자연스럽게 이동하는지 한 번 더 QA한다.
|
||||
- 앱 왼쪽 사이드 레일은 접힘 상태 레이아웃을 다시 손봤으므로, 데스크톱에서 접기/펼치기 반복 시 아바타 영역 높이, 아이콘 중앙 정렬, 검색 버튼 간격, 네비게이션 히트 영역이 모두 자연스러운지 한 번 더 QA한다.
|
||||
- 관리자 우측 사이드바 하단 카피라이트 링크는 새 창 외부 링크로 바꿨으므로, 실제 클릭 시 `zenn.town` 연결과 hover 대비가 자연스러운지 한 번 더 QA한다.
|
||||
- 관리자 아이템 상세 모달은 공통 패딩 예외 처리를 다시 넣었으므로, 대형 상세 모달과 일반 게임 선택 모달이 각각 기대한 크기로 보이는지 한 번 더 QA한다.
|
||||
- 아이템 관리 모달의 공용 게임 선택기에서는 이미 연결된 게임이 비활성화되므로, 실제 운영 데이터에서 중복 연결 방지와 `이미 추가됨` 표시가 기대대로 읽히는지 한 번 더 QA한다.
|
||||
- 공용 게임 선택 모달을 아이템 관리 모달에도 붙였으므로, 관리자 `게임 관리 / 전체 티어표 관리 / 아이템 관리` 세 흐름에서 같은 검색/선택 UX가 자연스럽게 느껴지는지 한 번 더 QA한다.
|
||||
- 관리자 `/admin/games?gameId=...` 직접 진입과 새로고침에서 선택 게임이 정상 복원되고 콘솔 오류가 없는지 한 번 더 QA한다.
|
||||
@@ -37,16 +65,10 @@
|
||||
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
|
||||
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
|
||||
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
|
||||
|
||||
- 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다.
|
||||
|
||||
- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다.
|
||||
|
||||
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
|
||||
|
||||
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
|
||||
|
||||
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
|
||||
- 관리자 템플릿 요청 카드와 전체 티어표 카드가 같은 문법으로 맞춰졌으므로, 이후에는 라이트모드까지 포함해 두 카드의 썸네일 높이·입력창 밀도·아이템 그리드를 한 번 더 비교 QA한다.
|
||||
|
||||
- 템플릿 요청 저장 흐름은 저장된 티어표 기준으로 바뀌었으므로, 이후 실제 데이터로 빈 제목 저장 시 자동 생성 제목·요청 버튼 노출 시점·관리자 요청 미리보기 밀도를 한 번 더 비교 QA한다.
|
||||
|
||||
@@ -1,5 +1,76 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 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
|
||||
- 티어표 편집/프리뷰 화면에서 열을 여러 개 쓰는 경우, 모바일처럼 좁은 화면에서는 기존 상단 열 헤더만으로 각 칸의 의미를 읽기 어려웠으므로 각 칸 상단에 작은 열 이름 배지를 추가함.
|
||||
- 이 배지는 모바일 구간에서만 보이고 데스크톱 레이아웃은 그대로 유지되므로, 작은 화면에서는 `메인 / 밸런스 / 서포트` 같은 열 맥락을 스크롤 중에도 잃지 않게 정리함.
|
||||
- 이후 배지가 칸 기준이 아니라 화면 한쪽에 겹치던 문제를 바로잡기 위해 각 칸을 기준점으로 다시 잡았고, 배지가 보이는 구간에서는 기존 상단 열 제목을 함께 숨겨 중복 표기를 제거함.
|
||||
- 추가로 같은 미디어 구간 안에서 행/열 모바일 레이아웃을 다시 `140px/150px`로 덮어쓰던 중복 규칙을 제거해, 모바일에서는 행 라벨이 화면 절반을 차지하지 않고 실제로 한 줄 전체 폭 기준 레이아웃으로 정리되게 맞춤.
|
||||
|
||||
## 2026-04-02 v1.3.82
|
||||
- 프리뷰 전용 완성본 화면에도 이미지 다운로드 결과와 같은 하단 메타를 붙여, 작성자 이름과 마지막 저장 시각을 바로 확인할 수 있게 정리함.
|
||||
- 관리자 `티어표 관리 > 템플릿 요청 관리`에서는 더 이상 썸네일 클릭으로 요청 미리보기 모달을 열지 않고, 썸네일 자체가 `요청 티어표 보기` 새창 링크 역할을 하도록 바꿨으며, 하단의 중복 `요청 티어표 보기` 버튼은 제거함.
|
||||
|
||||
## 2026-04-02 v1.3.81
|
||||
- 티어표 만들기 화면에는 저장된 티어표에서만 보이는 `공유하기` 액션을 추가하고, 누르면 현재 티어표의 완성본 링크(`preview=1`)를 클립보드에 복사한 뒤 토스트로 안내하도록 정리함.
|
||||
- 공유 링크는 관리자가 새 창에서 보던 완성본 주소와 같은 문법을 사용하므로, 저장된 티어표를 그대로 외부에 전달하거나 다시 열람하는 흐름으로 바로 이어짐.
|
||||
|
||||
## 2026-04-02 v1.3.79
|
||||
- 우측 카피라이트는 관리자 전용 레이아웃에서 분리해 앱 공통 `rightRail` footer로 올렸고, 이제 관리자 페이지뿐 아니라 오른쪽 사이드가 보이는 모든 화면에서 같은 최하단 위치에 표시됨.
|
||||
- 따라서 관리자 패널 길이나 페이지별 로컬 사이드바 내용과 무관하게, 카피라이트는 항상 오른쪽 레일 전체 기준 바닥에 고정되는 공통 footer 역할로 정리됨.
|
||||
|
||||
## 2026-04-02 v1.3.78
|
||||
- 왼쪽 레일 축소 상태의 하단 액션 아이콘은 문맥에 따라 구분되도록 바꿔, 홈의 `커스텀 티어표 만들기`는 `dashboard_customize` 아이콘을 쓰고 게임 허브의 일반 `티어표 만들기`만 `add_notes` 아이콘을 유지하도록 정리함.
|
||||
- 관리자 우측 카피라이트 문구는 사이드바 바깥 형제로 밀려 보이지 않을 수 있었으므로, 다시 관리자 사이드바 `aside` 내부 최하단으로 옮겨 레이아웃 안에서 안정적으로 보이게 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.77
|
||||
- 왼쪽 사이드 레일을 축소했을 때도 홈과 게임 허브에서 바로 새 티어표를 만들 수 있도록, 최하단 액션 영역에 `add_notes` 아이콘 기반의 축소 전용 `티어표 만들기` 버튼을 추가함.
|
||||
- 펼친 상태에서는 기존 텍스트 버튼을 그대로 유지하고, 축소 상태에서는 같은 위치에 아이콘 버튼만 남기도록 분기해 하단 액션 위치 감각은 유지하면서도 좁은 레일 폭에 맞게 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.76
|
||||
- 앱 왼쪽 사이드 레일은 축소 상태에서 아바타, 검색 버튼, 네비게이션 아이콘 버튼 높이를 모두 50px 기준으로 맞추고 검색 아래 여백도 정리해, 아이콘만 보이는 상태에서도 각 줄 높이가 제각각처럼 보이지 않게 정리함.
|
||||
- 왼쪽 사이드 검색은 라우트에 따라 의미가 바뀌지 않도록 `게임 템플릿 검색`으로 고정하고, 축소 검색 모달 역시 같은 플레이스홀더와 같은 동작으로 홈 게임 목록 검색을 수행하도록 통일함.
|
||||
|
||||
## 2026-04-02 v1.3.75
|
||||
- 관리자 공용 모달 카드의 기본 `padding: 20px`는 그대로 두되, 아이템 상세처럼 내부 레이아웃이 이미 큰 셸을 가진 모달은 `modalCard--customItem`에서 다시 덮어쓰지 않도록 분리해 상세 모달 크기와 내부 배치가 무너지지 않게 정리함.
|
||||
- 관리자 우측 사이드바 최하단의 카피라이트 문구는 이제 별도 상수 URL을 참조하는 외부 링크로 바꿔 새 창에서 열리게 했고, 추후 주소를 바꿔야 할 때 한 곳만 수정하면 되도록 정리함.
|
||||
- 앱 왼쪽 사이드 레일의 접힘 상태는 메타 텍스트를 단순히 투명하게 남겨두는 대신 실제로 숨기고, 아바타/검색/내비 아이콘을 다시 중앙 정렬해 접었을 때 높이가 비정상적으로 늘어나거나 간격이 남아 보이던 레이아웃을 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.74
|
||||
- 아이템 관리 상세에서 템플릿 추가 대상 게임을 고를 때, 이미 해당 이미지가 연결된 게임은 공용 게임 선택 모달에서 `이미 추가됨`으로 표시하고 비활성화해 중복 추가 실수를 미리 막도록 정리함.
|
||||
- 관리자 우측 사이드바 최하단에는 작은 카피라이트 문구를 추가해, 헤더에 관리 정보만 남기고 프로젝트 표기는 하단에서 조용히 보이도록 정리함.
|
||||
|
||||
@@ -1,13 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<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" />
|
||||
<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>
|
||||
<body>
|
||||
<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 |
@@ -9,6 +9,8 @@ import iconDockToRight from './assets/icons/dock_to_right.svg'
|
||||
import iconGridView from './assets/icons/grid_view.svg'
|
||||
import iconFavorite from './assets/icons/favorite.svg'
|
||||
import iconLists from './assets/icons/lists.svg'
|
||||
import iconAddNotes from './assets/icons/add_notes.svg'
|
||||
import iconDashboardCustomize from './assets/icons/dashboard_customize.svg'
|
||||
import iconSearch from './assets/icons/search.svg'
|
||||
import iconSettings from './assets/icons/settings.svg'
|
||||
import iconMenuBook from './assets/icons/menu_book.svg'
|
||||
@@ -19,11 +21,12 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const { toasts, dismissToast } = useToast()
|
||||
const RIGHT_RAIL_COPYRIGHT_URL = 'https://zenn.town/@murabito'
|
||||
|
||||
const leftRailCollapsed = ref(false)
|
||||
const rightRailOpen = ref(true)
|
||||
const searchQuery = ref('')
|
||||
const searchPlaceholder = computed(() => (route.name === 'home' ? '게임 템플릿 검색' : '전체 티어표 검색'))
|
||||
const leftRailSearchPlaceholder = '주제 템플릿 검색'
|
||||
const isCollapsedSearchOpen = ref(false)
|
||||
const isGuideModalOpen = ref(false)
|
||||
const themeMode = ref('dark')
|
||||
@@ -57,22 +60,23 @@ const shellStyle = computed(() => ({
|
||||
}))
|
||||
const leftNavItems = computed(() => {
|
||||
const items = [
|
||||
{ key: 'home', label: 'Games', path: '/', iconSrc: iconGridView },
|
||||
{ key: 'me', label: 'My Lists', path: '/me', iconSrc: iconLists, requiresAuth: true },
|
||||
{ key: 'favorites', label: 'Favorites', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
|
||||
{ key: 'profile', label: 'Settings', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
|
||||
{ key: 'home', label: '주제 선택', path: '/', iconSrc: iconGridView },
|
||||
{ key: 'me', label: '나의 티어표', path: '/me', iconSrc: iconLists, requiresAuth: true },
|
||||
{ key: 'favorites', label: '즐겨찾기', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
|
||||
{ key: 'profile', label: '설정', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
|
||||
]
|
||||
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 showSettingsGuideButton = computed(() => route.name === 'profile')
|
||||
const guideSteps = [
|
||||
{
|
||||
id: 'select-game',
|
||||
title: '게임 또는 양식 선택',
|
||||
summary: '게임 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
|
||||
title: '주제 또는 양식 선택',
|
||||
summary: '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
|
||||
description:
|
||||
'홈 화면에서는 게임 템플릿을 선택하거나 커스텀 티어표 만들기로 바로 새 보드를 열 수 있어요. 게임을 먼저 고르면 해당 게임의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
|
||||
'홈 화면에서는 주제 템플릿을 선택하거나 커스텀 티어표 만들기로 바로 새 보드를 열 수 있어요. 원하는 주제를 먼저 고르면 해당 주제의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
|
||||
},
|
||||
{
|
||||
id: 'arrange-board',
|
||||
@@ -86,7 +90,7 @@ const guideSteps = [
|
||||
title: '아이템 배치와 커스텀 추가',
|
||||
summary: '프리셋 아이템과 직접 올린 이미지를 드래그로 배치합니다.',
|
||||
description:
|
||||
'오른쪽 아이템 영역의 이미지를 원하는 칸으로 끌어다 놓으면 바로 배치됩니다. 게임 템플릿에 없는 이미지는 커스텀 이미지로 추가해 같이 쓸 수 있고, 이름 표시 옵션을 켜면 결과 이미지를 더 설명적으로 정리할 수 있어요.',
|
||||
'오른쪽 아이템 영역의 이미지를 원하는 칸으로 끌어다 놓으면 바로 배치됩니다. 주제 템플릿에 없는 이미지는 커스텀 이미지로 추가해 같이 쓸 수 있고, 이름 표시 옵션을 켜면 결과 이미지를 더 설명적으로 정리할 수 있어요.',
|
||||
},
|
||||
{
|
||||
id: 'save-share',
|
||||
@@ -105,23 +109,23 @@ const guideSteps = [
|
||||
{
|
||||
id: 'request-template-update',
|
||||
title: '템플릿 업그레이드 요청',
|
||||
summary: '현재 게임 템플릿에 공통 아이템을 추가해 달라고 관리자에게 요청합니다.',
|
||||
summary: '현재 템플릿에 공통 아이템을 추가해 달라고 관리자에게 요청합니다.',
|
||||
description:
|
||||
'직접 추가한 아이템 중 여러 사람이 함께 써도 좋을 것 같은 항목이 있다면 템플릿 업데이트 요청을 보낼 수 있어요. 요청 모달에서는 현재 티어표 제목과 설명을 기본값으로 가져오고, 필요하면 요청 제목과 설명을 더 다듬어 공통 템플릿에 왜 필요한지 설명할 수 있습니다.',
|
||||
},
|
||||
{
|
||||
id: 'request-new-template',
|
||||
title: '새 템플릿 추가 요청',
|
||||
summary: '아직 없는 게임이나 새로운 양식을 관리자에게 제안합니다.',
|
||||
summary: '아직 없는 주제나 새로운 양식을 관리자에게 제안합니다.',
|
||||
description:
|
||||
'원하는 게임 템플릿이 아직 없다면 새 템플릿 추가 요청으로 관리자에게 직접 제안할 수 있어요. 이때는 제목과 설명에 어떤 게임인지, 어떤 캐릭터나 항목이 기본으로 필요할지 적어두면 검토 속도가 훨씬 빨라집니다.',
|
||||
'원하는 주제 템플릿이 아직 없다면 새 템플릿 추가 요청으로 관리자에게 직접 제안할 수 있어요. 이때는 제목과 설명에 어떤 주제인지, 어떤 캐릭터나 항목이 기본으로 필요할지 적어두면 검토 속도가 훨씬 빨라집니다.',
|
||||
},
|
||||
{
|
||||
id: 'manage-library',
|
||||
title: '즐겨찾기와 내 티어표 관리',
|
||||
summary: '마음에 드는 템플릿과 저장한 결과물을 나중에 다시 쉽게 찾습니다.',
|
||||
description:
|
||||
'게임 템플릿은 즐겨찾기로 상단에 고정해둘 수 있고, 저장한 보드는 내 티어표에서 다시 열어 이어서 수정할 수 있어요. 자주 보는 템플릿, 공개 티어표, 내가 만든 결과물을 각각 다른 화면에서 정리해두면 이후 작업이 훨씬 빨라집니다.',
|
||||
'주제 템플릿은 즐겨찾기로 상단에 고정해둘 수 있고, 저장한 보드는 내 티어표에서 다시 열어 이어서 수정할 수 있어요. 자주 보는 템플릿, 공개 티어표, 내가 만든 결과물을 각각 다른 화면에서 정리해두면 이후 작업이 훨씬 빨라집니다.',
|
||||
},
|
||||
]
|
||||
const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
|
||||
@@ -135,11 +139,11 @@ const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : '
|
||||
const leftBottomPrimaryAction = computed(() => {
|
||||
if (!authReady.value) return null
|
||||
if (route.name === 'home' && auth.user) {
|
||||
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new' }
|
||||
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new', iconSrc: iconDashboardCustomize }
|
||||
}
|
||||
if (route.name === 'gameHub') {
|
||||
const target = `/editor/${route.params.gameId}/new`
|
||||
return { label: '새 티어표 만들기', to: auth.user ? target : `/login?redirect=${target}` }
|
||||
return { label: '새 티어표 만들기', to: auth.user ? target : `/login?redirect=${target}`, iconSrc: iconAddNotes }
|
||||
}
|
||||
return null
|
||||
})
|
||||
@@ -147,10 +151,10 @@ const leftBottomPrimaryAction = computed(() => {
|
||||
const routeMeta = computed(() => {
|
||||
if (route.name === 'home') {
|
||||
return {
|
||||
title: 'Tier Maker',
|
||||
subtitle: '게임 템플릿 선택과 커스텀 보드 시작',
|
||||
title: '주제 선택',
|
||||
subtitle: '주제 템플릿 선택과 커스텀 보드 시작',
|
||||
contextTitle: '빠른 시작',
|
||||
contextText: auth.user ? '커스텀 티어표를 만들거나 원하는 게임을 바로 선택할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
|
||||
contextText: auth.user ? '커스텀 티어표를 만들거나 원하는 주제를 바로 선택할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
|
||||
actionLabel: auth.user ? '커스텀 티어표 만들기' : '로그인하러 가기',
|
||||
action: () => {
|
||||
router.push(auth.user ? '/editor/freeform/new' : '/login')
|
||||
@@ -159,10 +163,10 @@ const routeMeta = computed(() => {
|
||||
}
|
||||
if (route.name === 'gameHub') {
|
||||
return {
|
||||
title: 'Game Boards',
|
||||
subtitle: '게임별 공개 티어표 탐색',
|
||||
title: '주제 티어표',
|
||||
subtitle: '주제별 공개 티어표 탐색',
|
||||
contextTitle: '작성 작업',
|
||||
contextText: auth.user ? '이 게임의 새 티어표를 만들거나 기존 공개 티어표를 확인할 수 있어요.' : '로그인 후 새 티어표를 만들 수 있어요.',
|
||||
contextText: auth.user ? '이 주제의 새 티어표를 만들거나 기존 공개 티어표를 확인할 수 있어요.' : '로그인 후 새 티어표를 만들 수 있어요.',
|
||||
actionLabel: auth.user ? '새 티어표 만들기' : '로그인하러 가기',
|
||||
action: () => {
|
||||
const target = `/editor/${route.params.gameId}/new`
|
||||
@@ -176,24 +180,24 @@ const routeMeta = computed(() => {
|
||||
subtitle: '티어표 편집 및 공유',
|
||||
contextTitle: '편집 패널',
|
||||
contextText: '현재 편집 옵션은 중앙 화면 안에 유지되어 있습니다. 다음 단계에서 우측 패널로 정리해갈게요.',
|
||||
actionLabel: '게임 목록으로',
|
||||
actionLabel: '주제 목록으로',
|
||||
action: () => router.push('/'),
|
||||
}
|
||||
}
|
||||
if (isAdminRoute.value) {
|
||||
return {
|
||||
title: 'Admin Workspace',
|
||||
subtitle: '게임·아이템·회원 관리',
|
||||
subtitle: '템플릿·아이템·회원 관리',
|
||||
contextTitle: '운영 노트',
|
||||
contextText: '관리자 화면은 기능이 많아 우선 공통 셸 톤을 맞췄고, 세부 패널은 다음 단계에서 시안 방식으로 더 세밀하게 나눌 예정입니다.',
|
||||
actionLabel: '게임 목록으로',
|
||||
actionLabel: '주제 목록으로',
|
||||
action: () => router.push('/'),
|
||||
}
|
||||
}
|
||||
if (route.name === 'me') {
|
||||
return {
|
||||
title: 'My Lists',
|
||||
subtitle: '내가 저장한 티어표',
|
||||
title: '나의 티어표',
|
||||
subtitle: '저장한 티어표 모아보기',
|
||||
contextTitle: '작성 이력',
|
||||
contextText: '최근 저장한 티어표를 열람하고 정리할 수 있어요.',
|
||||
actionLabel: '즐겨찾기 보기',
|
||||
@@ -202,27 +206,27 @@ const routeMeta = computed(() => {
|
||||
}
|
||||
if (route.name === 'favorites') {
|
||||
return {
|
||||
title: 'Favorites',
|
||||
title: '즐겨찾기',
|
||||
subtitle: '마음에 드는 티어표 모음',
|
||||
contextTitle: '정리 도구',
|
||||
contextText: '정렬과 검색으로 원하는 티어표를 빠르게 다시 찾을 수 있어요.',
|
||||
actionLabel: '내 티어표 보기',
|
||||
actionLabel: '나의 티어표 보기',
|
||||
action: () => router.push('/me'),
|
||||
}
|
||||
}
|
||||
if (route.name === 'profile') {
|
||||
return {
|
||||
title: 'Profile',
|
||||
title: '설정',
|
||||
subtitle: '프로필 및 계정 설정',
|
||||
contextTitle: '계정 관리',
|
||||
contextText: '아바타와 닉네임을 관리하고 표시 정보를 확인할 수 있어요.',
|
||||
actionLabel: '내 티어표 보기',
|
||||
actionLabel: '나의 티어표 보기',
|
||||
action: () => router.push('/me'),
|
||||
}
|
||||
}
|
||||
if (route.name === 'search') {
|
||||
return {
|
||||
title: 'Search',
|
||||
title: '검색',
|
||||
subtitle: '전체 공개 티어표 검색 결과',
|
||||
contextTitle: '검색',
|
||||
contextText: '제목, 작성자 기준으로 공개 티어표를 전체 검색할 수 있어요.',
|
||||
@@ -232,7 +236,7 @@ const routeMeta = computed(() => {
|
||||
}
|
||||
return {
|
||||
title: 'Tier Maker',
|
||||
subtitle: 'by zenn',
|
||||
subtitle: '주제 템플릿으로 만드는 티어표',
|
||||
contextTitle: 'Workspace',
|
||||
contextText: '현재 화면에 맞는 도구와 안내를 여기에 배치할 수 있습니다.',
|
||||
actionLabel: '홈으로',
|
||||
@@ -391,11 +395,7 @@ function handleLeftRailSearch() {
|
||||
function submitGlobalSearch() {
|
||||
const query = (searchQuery.value || '').trim()
|
||||
isCollapsedSearchOpen.value = false
|
||||
if (route.name === 'home') {
|
||||
router.push(query ? `/?q=${encodeURIComponent(query)}` : '/')
|
||||
return
|
||||
}
|
||||
router.push(query ? `/search?q=${encodeURIComponent(query)}` : '/search')
|
||||
router.push(query ? `/?q=${encodeURIComponent(query)}` : '/')
|
||||
}
|
||||
|
||||
|
||||
@@ -429,7 +429,7 @@ function submitGlobalSearch() {
|
||||
<div class="leftRail__content">
|
||||
<div v-if="authReady && auth.user" class="appUserCard">
|
||||
<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 class="appUserCard__meta">
|
||||
<div class="appUserCard__name">{{ accountName }}</div>
|
||||
@@ -444,10 +444,15 @@ function submitGlobalSearch() {
|
||||
<SvgIcon :src="iconSearch" :size="24" />
|
||||
</span>
|
||||
</button>
|
||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : searchPlaceholder" />
|
||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
|
||||
</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
|
||||
v-for="item in leftNavItems"
|
||||
:key="item.key"
|
||||
@@ -468,6 +473,15 @@ function submitGlobalSearch() {
|
||||
</div>
|
||||
<div class="leftRail__bottom">
|
||||
<RouterLink v-if="leftBottomPrimaryAction" :to="leftBottomPrimaryAction.to" class="adminButton">{{ leftBottomPrimaryAction.label }}</RouterLink>
|
||||
<RouterLink
|
||||
v-if="leftBottomPrimaryAction"
|
||||
:to="leftBottomPrimaryAction.to"
|
||||
class="leftRail__collapsedAction"
|
||||
:title="leftBottomPrimaryAction.label"
|
||||
:aria-label="leftBottomPrimaryAction.label"
|
||||
>
|
||||
<SvgIcon :src="leftBottomPrimaryAction.iconSrc || iconAddNotes" :size="24" />
|
||||
</RouterLink>
|
||||
<button v-if="showSettingsGuideButton" class="adminButton adminButton--icon" type="button" @click="openGuideModal()">
|
||||
<SvgIcon :src="iconMenuBook" :size="18" class="adminButton__icon" />
|
||||
<span>가이드 보기</span>
|
||||
@@ -483,15 +497,6 @@ function submitGlobalSearch() {
|
||||
<header class="workspaceHead railHeader">
|
||||
<div class="workspaceHead__brand" @click="$router.push('/')">
|
||||
<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 class="workspaceHead__actions">
|
||||
<div v-if="showGameHubViewToggle" class="viewToggle" role="group" aria-label="티어표 보기 방식">
|
||||
@@ -513,12 +518,12 @@ function submitGlobalSearch() {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" :aria-label="searchPlaceholder" @click.self="closeCollapsedSearch">
|
||||
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" :aria-label="leftRailSearchPlaceholder" @click.self="closeCollapsedSearch">
|
||||
<form class="collapsedSearchBar" @submit.prevent="submitGlobalSearch">
|
||||
<span class="collapsedSearchBar__icon">
|
||||
<SvgIcon :src="iconSearch" :size="24" />
|
||||
</span>
|
||||
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="searchPlaceholder" autofocus />
|
||||
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="leftRailSearchPlaceholder" autofocus />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -621,6 +626,11 @@ function submitGlobalSearch() {
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
<div class="rightRail__footer">
|
||||
<span>Copyright © 2026 </span>
|
||||
<a :href="RIGHT_RAIL_COPYRIGHT_URL" target="_blank" rel="noreferrer">zenn</a>
|
||||
<span>. All rights reserved.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -741,8 +751,11 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.rightRail__content {
|
||||
flex: 0 0 auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ghostIcon {
|
||||
@@ -904,19 +917,45 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.leftNav {
|
||||
--left-nav-gap: 8px;
|
||||
--left-nav-item-height: 50px;
|
||||
position: relative;
|
||||
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 {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--left-nav-item-height);
|
||||
gap: 12px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 14px;
|
||||
color: var(--theme-text-muted);
|
||||
text-decoration: none;
|
||||
transition: background 180ms ease, color 180ms ease, transform 180ms ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.leftNav__label {
|
||||
@@ -929,7 +968,6 @@ function submitGlobalSearch() {
|
||||
|
||||
.leftNav__item--active,
|
||||
.leftNav__item.router-link-active {
|
||||
background: var(--theme-surface-soft-3);
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
|
||||
@@ -953,21 +991,24 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .appUserCard {
|
||||
margin-bottom: 10px;
|
||||
min-height: 50px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .appUserCard__button,
|
||||
.appShell--leftCollapsed .appUserCard__guest {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .appUserCard__meta,
|
||||
.appShell--leftCollapsed .leftNav__label,
|
||||
.appShell--leftCollapsed .searchStub__input {
|
||||
opacity: 0;
|
||||
max-width: 0;
|
||||
transform: translateX(-4px);
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .appUserCard__avatar {
|
||||
@@ -976,26 +1017,41 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .searchStub {
|
||||
height: 50px;
|
||||
margin-bottom: 0;
|
||||
padding: 11px 0;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .searchStub__iconButton {
|
||||
width: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftNav {
|
||||
gap: 10px;
|
||||
--left-nav-gap: 10px;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftNav__item {
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
height: 50px;
|
||||
padding: 11px 0;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftRail__bottom {
|
||||
display: none;
|
||||
.appShell--leftCollapsed .leftNav__glyph {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftRail__content {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
justify-items: stretch;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1007,6 +1063,10 @@ function submitGlobalSearch() {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.leftRail__collapsedAction {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.adminButton {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
@@ -1031,6 +1091,29 @@ function submitGlobalSearch() {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftRail__bottom {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftRail__bottom .adminButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftRail__bottom .leftRail__collapsedAction {
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
height: 50px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border-strong);
|
||||
background: var(--theme-surface-soft);
|
||||
color: var(--theme-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.appMain {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -1076,18 +1159,6 @@ function submitGlobalSearch() {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -1145,13 +1216,31 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.rightRail__bottom {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.rightRail__footer {
|
||||
padding: 0 4px 2px;
|
||||
font-size: 9px;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
color: var(--theme-text-faint);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.rightRail__footer a {
|
||||
color: #00ffff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rightRail__footer a:hover {
|
||||
color: #00ffff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.settingsThemePanel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -1575,9 +1664,10 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.localRightRailRoot {
|
||||
min-height: auto;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
flex: 1 1 auto;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 44 KiB |
1
frontend/src/assets/icons/add_notes.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v268q-19-9-39-15.5t-41-9.5v-243H200v560h242q3 22 9.5 42t15.5 38H200Zm0-120v40-560 243-3 280Zm80-40h163q3-21 9.5-41t14.5-39H280v80Zm0-160h244q32-30 71.5-50t84.5-27v-3H280v80Zm0-160h400v-80H280v80ZM720-40q-83 0-141.5-58.5T520-240q0-83 58.5-141.5T720-440q83 0 141.5 58.5T920-240q0 83-58.5 141.5T720-40Zm-20-80h40v-100h100v-40H740v-100h-40v100H600v40h100v100Z"/></svg>
|
||||
|
After Width: | Height: | Size: 566 B |
1
frontend/src/assets/icons/dashboard_customize.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M120-840h320v320H120v-320Zm80 80v160-160Zm320-80h320v320H520v-320Zm80 80v160-160ZM120-440h320v320H120v-320Zm80 80v160-160Zm440-80h80v120h120v80H720v120h-80v-120H520v-80h120v-120Zm-40-320v160h160v-160H600Zm-400 0v160h160v-160H200Zm0 400v160h160v-160H200Z"/></svg>
|
||||
|
After Width: | Height: | Size: 377 B |
1
frontend/src/assets/icons/share.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M680-80q-50 0-85-35t-35-85q0-6 3-28L282-392q-16 15-37 23.5t-45 8.5q-50 0-85-35t-35-85q0-50 35-85t85-35q24 0 45 8.5t37 23.5l281-164q-2-7-2.5-13.5T560-760q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35q-24 0-45-8.5T598-672L317-508q2 7 2.5 13.5t.5 14.5q0 8-.5 14.5T317-452l281 164q16-15 37-23.5t45-8.5q50 0 85 35t35 85q0 50-35 85t-85 35Zm0-80q17 0 28.5-11.5T720-200q0-17-11.5-28.5T680-240q-17 0-28.5 11.5T640-200q0 17 11.5 28.5T680-160ZM200-440q17 0 28.5-11.5T240-480q0-17-11.5-28.5T200-520q-17 0-28.5 11.5T160-480q0 17 11.5 28.5T200-440Zm508.5-291.5Q720-743 720-760t-11.5-28.5Q697-800 680-800t-28.5 11.5Q640-777 640-760t11.5 28.5Q663-720 680-720t28.5-11.5ZM680-200ZM200-480Zm480-280Z"/></svg>
|
||||
|
After Width: | Height: | Size: 810 B |
|
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>
|
||||
<div class="panel__title">홈 화면 상단 고정 순서</div>
|
||||
<div class="hint hint--tight">여기에 넣은 게임은 지정한 순서대로 먼저 노출되고, 나머지 게임은 최근 생성순으로 뒤에 이어집니다. 최대 50개까지 설정할 수 있어요.</div>
|
||||
<div class="hint hint--tight">여기에 넣은 템플릿은 지정한 순서대로 먼저 노출되고, 나머지 템플릿은 최근 생성순으로 뒤에 이어집니다. 최대 50개까지 설정할 수 있어요.</div>
|
||||
</div>
|
||||
<button class="btn btn--primary" @click="props.saveFeaturedOrder">순서 저장</button>
|
||||
</div>
|
||||
@@ -24,7 +24,7 @@ const props = defineProps({
|
||||
<div class="featuredOrderPanel">
|
||||
<div class="featuredOrderPanel__list">
|
||||
<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">
|
||||
<article v-for="(game, index) in props.featuredGames" :key="game.id" class="featuredCard" :data-featured-id="game.id">
|
||||
<div class="featuredCard__meta">
|
||||
@@ -45,7 +45,7 @@ const props = defineProps({
|
||||
</div>
|
||||
|
||||
<div class="featuredOrderPanel__picker">
|
||||
<div class="section__title">게임 추가</div>
|
||||
<div class="section__title">템플릿 추가</div>
|
||||
<div class="featuredPickerList">
|
||||
<button
|
||||
v-for="game in props.availableGamesForFeatured"
|
||||
|
||||
@@ -67,17 +67,17 @@ function setThumbFileElement(el) {
|
||||
props.activeTemplateRequest.type === 'create'
|
||||
? (props.activeTemplateRequest.targetGameId
|
||||
? '이미 연결된 신규 템플릿이 있어요. 필요한 아이템만 골라 저장하세요.'
|
||||
: '새 게임을 한 번 만든 뒤 필요한 아이템만 골라 저장하세요.')
|
||||
: '새 템플릿을 한 번 만든 뒤 필요한 아이템만 골라 저장하세요.')
|
||||
: '필요한 아이템만 남긴 뒤 기본 아이템 추가 버튼으로 반영하세요.'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<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 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">
|
||||
연결된 게임 · {{ props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId }}
|
||||
연결된 템플릿 · {{ props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,15 +97,15 @@ function setThumbFileElement(el) {
|
||||
type="button"
|
||||
@click="props.openGameCreateModal"
|
||||
>
|
||||
새 게임 만들기
|
||||
새 템플릿 만들기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.isGameLoading" class="panel panel--empty">
|
||||
<div class="emptyState">
|
||||
<div class="emptyState__title">게임 정보를 불러오는 중이에요.</div>
|
||||
<div class="emptyState__desc">선택한 게임의 썸네일과 기본 아이템을 곧 표시합니다.</div>
|
||||
<div class="emptyState__title">템플릿 정보를 불러오는 중이에요.</div>
|
||||
<div class="emptyState__desc">선택한 템플릿의 썸네일과 기본 아이템을 곧 표시합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="props.hasSelectedGame" class="panel">
|
||||
@@ -133,7 +133,7 @@ function setThumbFileElement(el) {
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.gameVisibilitySaving }">
|
||||
<input :checked="!!props.selectedGame.game.isPublic" type="checkbox" @change="props.toggleSelectedGameVisibility($event.target.checked)" />
|
||||
@@ -142,7 +142,7 @@ function setThumbFileElement(el) {
|
||||
</label>
|
||||
<div class="gameSettingsCard__actions">
|
||||
<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>
|
||||
</section>
|
||||
@@ -236,9 +236,9 @@ function setThumbFileElement(el) {
|
||||
</div>
|
||||
<div v-else class="panel panel--empty">
|
||||
<div class="emptyState">
|
||||
<div class="emptyState__title">게임을 선택해 주세요.</div>
|
||||
<div v-if="props.activeTemplateRequest?.type === 'create'" class="hint hint--tight">진행 중인 신규 게임 요청이 있어요. 위의 `새 게임 만들기`로 게임을 만든 뒤 아이템을 추가할 수 있습니다.</div>
|
||||
<div v-if="props.selectedGameId" class="hint hint--tight">선택한 게임을 찾지 못했거나 로딩 중 오류가 발생했어요. 다시 선택해보세요.</div>
|
||||
<div class="emptyState__title">템플릿을 선택해 주세요.</div>
|
||||
<div v-if="props.activeTemplateRequest?.type === 'create'" class="hint hint--tight">진행 중인 신규 템플릿 요청이 있어요. 위의 `새 템플릿 만들기`로 템플릿을 만든 뒤 아이템을 추가할 수 있습니다.</div>
|
||||
<div v-if="props.selectedGameId" class="hint hint--tight">선택한 템플릿을 찾지 못했거나 로딩 중 오류가 발생했어요. 다시 선택해보세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -39,25 +39,32 @@ const props = defineProps({
|
||||
<div v-else class="templateRequestList">
|
||||
<article v-for="request in props.templateRequests" :key="request.id" class="tierAdminCard templateRequestCard templateRequestCard--aligned">
|
||||
<div class="templateRequestCard__side">
|
||||
<button class="tierAdminCard__preview templateRequestCard__preview" type="button" @click="props.openTemplateRequestPreview(request)">
|
||||
<a
|
||||
class="tierAdminCard__preview templateRequestCard__preview"
|
||||
:href="props.templateRequestSourceUrl(request) || undefined"
|
||||
:target="props.templateRequestSourceUrl(request) ? '_blank' : undefined"
|
||||
:rel="props.templateRequestSourceUrl(request) ? 'noreferrer' : undefined"
|
||||
:aria-disabled="!props.templateRequestSourceUrl(request)"
|
||||
@click.prevent="props.templateRequestSourceUrl(request) && window.open(props.templateRequestSourceUrl(request), '_blank', 'noopener,noreferrer')"
|
||||
>
|
||||
<img v-if="request.thumbnailSrc" class="tierAdminCard__thumb" :src="toApiUrl(request.thumbnailSrc)" :alt="request.sourceTierListTitle" draggable="false" />
|
||||
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
|
||||
</button>
|
||||
</a>
|
||||
<div class="templateRequestCard__thumbMeta">
|
||||
<template v-if="request.type === 'create'">
|
||||
<label class="templateRequestField">
|
||||
<span class="templateRequestField__label">게임 이름</span>
|
||||
<input v-model="request.draftGameName" class="input" placeholder="새 게임 이름" />
|
||||
<span class="templateRequestField__label">템플릿 이름</span>
|
||||
<input v-model="request.draftGameName" class="input" placeholder="새 템플릿 이름" />
|
||||
</label>
|
||||
<label class="templateRequestField">
|
||||
<span class="templateRequestField__label">게임 ID</span>
|
||||
<input v-model="request.draftGameId" class="input" placeholder="임시 게임 ID" />
|
||||
<span class="templateRequestField__label">템플릿 ID</span>
|
||||
<input v-model="request.draftGameId" class="input" placeholder="임시 템플릿 ID" />
|
||||
</label>
|
||||
</template>
|
||||
<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__thumbLabel">게임 ID</div>
|
||||
<div class="templateRequestCard__thumbLabel">템플릿 ID</div>
|
||||
<div class="templateRequestCard__thumbValue">{{ request.draftGameId || request.sourceGameId || '-' }}</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -97,24 +104,14 @@ const props = defineProps({
|
||||
</div>
|
||||
|
||||
<div class="templateRequestCard__footer">
|
||||
<div class="templateRequestCard__footerLeft">
|
||||
<a
|
||||
v-if="props.templateRequestSourceUrl(request)"
|
||||
class="btn btn--ghost btn--small"
|
||||
:href="props.templateRequestSourceUrl(request)"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
요청 티어표 보기
|
||||
</a>
|
||||
</div>
|
||||
<div class="templateRequestCard__footerLeft"></div>
|
||||
<div class="templateRequestCard__actions">
|
||||
<button class="btn btn--primary" :disabled="request.isHandling" @click="props.startTemplateRequestReview(request)">
|
||||
{{
|
||||
request.isHandling
|
||||
? '이동중...'
|
||||
: request.type === 'create' && (request.targetGameName || request.targetGameId)
|
||||
? '연결된 게임 열기'
|
||||
? '연결된 템플릿 열기'
|
||||
: '확인하기'
|
||||
}}
|
||||
</button>
|
||||
|
||||
@@ -214,7 +214,7 @@ const customItemTargetGame = computed(() => games.value.find((game) => game.id =
|
||||
const importModalItemCount = computed(() => importModalItems.value.length)
|
||||
const activeTabTitle = computed(() => {
|
||||
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 === 'tierlists') {
|
||||
return tierlistsMode.value === 'requests' ? '템플릿 요청 관리' : '전체 티어표 관리'
|
||||
@@ -223,17 +223,17 @@ const activeTabTitle = computed(() => {
|
||||
})
|
||||
const activeTabDescription = computed(() => {
|
||||
if (activeTab.value === 'featured') {
|
||||
return '홈 화면 상단에 고정 노출되는 게임 순서를 따로 관리합니다.'
|
||||
return '홈 화면 상단에 고정 노출되는 템플릿 순서를 따로 관리합니다.'
|
||||
}
|
||||
if (activeTab.value === 'game-admin') {
|
||||
return '게임 생성, 선택, 썸네일, 기본 아이템 관리를 전용 작업 화면으로 분리했습니다.'
|
||||
return '템플릿 생성, 선택, 썸네일, 기본 아이템 관리를 전용 작업 화면으로 분리했습니다.'
|
||||
}
|
||||
if (activeTab.value === 'items') {
|
||||
return '사용자 업로드와 관리자 템플릿 이미지를 함께 검수하고, 필요한 게임에 직접 연결할 수 있어요.'
|
||||
return '사용자 업로드와 관리자 템플릿 이미지를 함께 검수하고, 필요한 템플릿에 직접 연결할 수 있어요.'
|
||||
}
|
||||
if (activeTab.value === 'tierlists') {
|
||||
return tierlistsMode.value === 'requests'
|
||||
? '사용자 요청을 확인하고, 게임 관리 화면에서 필요한 아이템만 선별 반영한 뒤 직접 완료 처리합니다.'
|
||||
? '사용자 요청을 확인하고, 템플릿 관리 화면에서 필요한 아이템만 선별 반영한 뒤 직접 완료 처리합니다.'
|
||||
: '공개/비공개 포함 전체 티어표를 확인하고, 추가 아이템을 템플릿으로 가져올 수 있어요.'
|
||||
}
|
||||
return '계정 정보, 권한, 비밀번호와 최근 활동을 더 가볍게 확인하고 수정합니다.'
|
||||
@@ -245,14 +245,14 @@ const adminOverviewStats = computed(() => {
|
||||
|
||||
if (activeTab.value === 'featured') {
|
||||
return [
|
||||
{ label: '전체 게임', value: `${games.value.length}` },
|
||||
{ label: '전체 템플릿', value: `${games.value.length}` },
|
||||
{ label: '상단 고정', value: `${featuredGameIds.value.length}/50` },
|
||||
{ label: '추가 가능', value: `${Math.max(0, 50 - featuredGameIds.value.length)}` },
|
||||
]
|
||||
}
|
||||
if (activeTab.value === 'game-admin') {
|
||||
return [
|
||||
{ label: '전체 게임', value: `${games.value.length}` },
|
||||
{ label: '전체 템플릿', value: `${games.value.length}` },
|
||||
{ label: '티어표 전체', value: `${selectedGameTierListStats.value.total || 0}` },
|
||||
{ label: '공개', value: `${selectedGameTierListStats.value.publicCount || 0}` },
|
||||
{ label: '비공개', value: `${selectedGameTierListStats.value.privateCount || 0}` },
|
||||
@@ -571,7 +571,7 @@ function formatImageJobSourceCategory(category) {
|
||||
case 'tierlists':
|
||||
return '티어표 썸네일'
|
||||
case 'games':
|
||||
return '게임/템플릿 이미지'
|
||||
return '주제/템플릿 이미지'
|
||||
case 'avatars':
|
||||
return '프로필 아바타'
|
||||
default:
|
||||
@@ -599,7 +599,7 @@ function customItemDeleteImpactText(item) {
|
||||
if (item.sourceType === 'template') {
|
||||
return item.isAssetLibraryItem
|
||||
? `"${item.label}" 보관 자산 항목을 정리할까요? 라이브러리 항목만 제거되고, 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.`
|
||||
: `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 게임의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.`
|
||||
: `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 주제의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.`
|
||||
}
|
||||
|
||||
return `"${item.label}" 사용자 업로드 이미지를 삭제할까요? 현재 항목만 정리됩니다.`
|
||||
@@ -703,7 +703,7 @@ async function confirmImageReset() {
|
||||
}
|
||||
|
||||
async function cleanupMissingImageReferences() {
|
||||
const ok = window.confirm('파일이 실제로 없는 이미지 참조만 정리할까요? 누락된 썸네일은 비워지고, 누락된 게임/커스텀 아이템은 관련 참조와 함께 정리됩니다.')
|
||||
const ok = window.confirm('파일이 실제로 없는 이미지 참조만 정리할까요? 누락된 썸네일은 비워지고, 누락된 템플릿/커스텀 아이템은 관련 참조와 함께 정리됩니다.')
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
@@ -714,10 +714,10 @@ async function cleanupMissingImageReferences() {
|
||||
success.value =
|
||||
`누락 참조를 정리했어요. ` +
|
||||
`아바타 ${result.clearedAvatars || 0}건, ` +
|
||||
`게임 썸네일 ${result.clearedGameThumbnails || 0}건, ` +
|
||||
`템플릿 썸네일 ${result.clearedGameThumbnails || 0}건, ` +
|
||||
`티어표 썸네일 ${result.clearedTierListThumbnails || 0}건, ` +
|
||||
`요청 썸네일 ${result.clearedTemplateRequestThumbnails || 0}건, ` +
|
||||
`게임 아이템 ${result.deletedGameItems || 0}건, ` +
|
||||
`템플릿 아이템 ${result.deletedGameItems || 0}건, ` +
|
||||
`커스텀 아이템 ${result.deletedCustomItems || 0}건`
|
||||
} catch (e) {
|
||||
error.value = '누락 이미지 참조 정리에 실패했어요.'
|
||||
@@ -794,7 +794,7 @@ async function refreshGames() {
|
||||
.map((game) => game.id)
|
||||
await syncFeaturedSortable()
|
||||
} catch (e) {
|
||||
error.value = '게임 목록을 불러오지 못했어요.'
|
||||
error.value = '템플릿 목록을 불러오지 못했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1180,10 +1180,10 @@ async function saveGameVisibility() {
|
||||
},
|
||||
}
|
||||
await refreshGames()
|
||||
success.value = data.game?.isPublic ? '게임을 공개 상태로 전환했어요.' : '게임을 비공개 상태로 전환했어요.'
|
||||
success.value = data.game?.isPublic ? '템플릿을 공개 상태로 전환했어요.' : '템플릿을 비공개 상태로 전환했어요.'
|
||||
return true
|
||||
} catch (e) {
|
||||
error.value = '게임 공개 상태를 저장하지 못했어요.'
|
||||
error.value = '템플릿 공개 상태를 저장하지 못했어요.'
|
||||
return false
|
||||
} finally {
|
||||
gameVisibilitySaving.value = false
|
||||
@@ -1225,9 +1225,9 @@ async function removeGameItem(itemId) {
|
||||
if (!res.ok) throw new Error('failed')
|
||||
|
||||
await loadGame()
|
||||
success.value = '게임 기본 아이템을 삭제했어요.'
|
||||
success.value = '템플릿 기본 아이템을 삭제했어요.'
|
||||
} catch (e) {
|
||||
error.value = '게임 기본 아이템 삭제에 실패했어요.'
|
||||
error.value = '템플릿 기본 아이템 삭제에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1258,7 +1258,7 @@ async function removeGame() {
|
||||
resetMessages()
|
||||
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
|
||||
|
||||
try {
|
||||
@@ -1273,9 +1273,9 @@ async function removeGame() {
|
||||
selectedGame.value = null
|
||||
resetUploadState()
|
||||
await refreshGames()
|
||||
success.value = `${deletedName} 게임을 삭제했어요.`
|
||||
success.value = `${deletedName} 템플릿을 삭제했어요.`
|
||||
} catch (e) {
|
||||
error.value = '게임 삭제에 실패했어요.'
|
||||
error.value = '템플릿 삭제에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1570,7 +1570,7 @@ async function confirmTierListImport() {
|
||||
try {
|
||||
if (importModalMode.value === 'existing') {
|
||||
if (!importModalTargetGameId.value) {
|
||||
error.value = '아이템을 추가할 기존 게임을 선택해주세요.'
|
||||
error.value = '아이템을 추가할 기존 템플릿을 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1584,7 +1584,7 @@ async function confirmTierListImport() {
|
||||
const nextGameId = (importModalNewGameId.value || '').trim()
|
||||
const nextGameName = (importModalNewGameName.value || '').trim()
|
||||
if (!nextGameId || !nextGameName) {
|
||||
error.value = '새 게임 ID와 이름을 모두 입력해주세요.'
|
||||
error.value = '새 템플릿 ID와 이름을 모두 입력해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1610,9 +1610,9 @@ function templateRequestTypeLabel(request) {
|
||||
function templateRequestTargetLabel(request) {
|
||||
if (request.type === 'create') {
|
||||
if (request.targetGameName || request.targetGameId) {
|
||||
return `연결된 게임 · ${request.targetGameName || request.targetGameId}`
|
||||
return `연결된 템플릿 · ${request.targetGameName || request.targetGameId}`
|
||||
}
|
||||
return '연결된 게임 없음'
|
||||
return '연결된 템플릿 없음'
|
||||
}
|
||||
return request.targetGameName || request.targetGameId || request.sourceGameName
|
||||
}
|
||||
@@ -1787,16 +1787,16 @@ function userAvatarFallback(user) {
|
||||
|
||||
<div v-if="gameCreateModalOpen" class="modalOverlay" @click.self="closeGameCreateModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">새 게임 만들기</div>
|
||||
<div class="modalCard__desc">게임 이름과 고유 ID를 입력한 뒤 생성하면 바로 아래 상세 관리 화면으로 이어집니다.</div>
|
||||
<div class="modalCard__title">새 템플릿 만들기</div>
|
||||
<div class="modalCard__desc">템플릿 이름과 고유 ID를 입력한 뒤 생성하면 바로 아래 상세 관리 화면으로 이어집니다.</div>
|
||||
<div class="modalCard__form">
|
||||
<label class="field">
|
||||
<span class="field__label">게임 이름</span>
|
||||
<input v-model="newGameName" class="field__input" maxlength="60" placeholder="게임 이름" />
|
||||
<span class="field__label">템플릿 이름</span>
|
||||
<input v-model="newGameName" class="field__input" maxlength="60" placeholder="템플릿 이름" />
|
||||
<span class="field__hint">{{ newGameName.length }}/60자</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">게임 ID</span>
|
||||
<span class="field__label">템플릿 ID</span>
|
||||
<input
|
||||
v-model="newGameId"
|
||||
class="field__input"
|
||||
@@ -1814,7 +1814,7 @@ function userAvatarFallback(user) {
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<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>
|
||||
@@ -1925,14 +1925,14 @@ function userAvatarFallback(user) {
|
||||
|
||||
<div v-if="importModalMode === 'existing'" class="modalCard__form">
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-else class="modalCard__form">
|
||||
<input v-model="importModalNewGameId" class="input" placeholder="새 게임 ID" />
|
||||
<input v-model="importModalNewGameName" class="input" placeholder="새 게임 이름" />
|
||||
<input v-model="importModalNewGameId" class="input" placeholder="새 템플릿 ID" />
|
||||
<input v-model="importModalNewGameName" class="input" placeholder="새 템플릿 이름" />
|
||||
</div>
|
||||
|
||||
<div class="modalCard__actions">
|
||||
@@ -1950,15 +1950,15 @@ function userAvatarFallback(user) {
|
||||
<aside class="customItemModal__pickerPanel">
|
||||
<div class="customItemModal__pickerHead">
|
||||
<div class="customItemModal__pickerEyebrow">GAME PICKER</div>
|
||||
<div class="customItemModal__pickerTitle">템플릿으로 추가할 게임</div>
|
||||
<div class="customItemModal__pickerTitle">아이템을 추가할 템플릿</div>
|
||||
</div>
|
||||
<div class="adminSelectionCard">
|
||||
<div class="adminSelectionCard__label">선택한 게임</div>
|
||||
<div class="adminSelectionCard__label">선택한 템플릿</div>
|
||||
<div class="adminSelectionCard__title">{{ customItemTargetGame?.name || '아직 선택하지 않음' }}</div>
|
||||
<div class="adminSelectionCard__meta">{{ customItemTargetGame?.id || '게임을 골라 주세요.' }}</div>
|
||||
<div class="adminSelectionCard__meta">{{ customItemTargetGame?.id || '템플릿을 골라 주세요.' }}</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -1983,15 +1983,15 @@ function userAvatarFallback(user) {
|
||||
<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.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>
|
||||
<div class="customItemModal__linked">
|
||||
<span class="customItemModal__label">템플릿에 사용 중인 게임</span>
|
||||
<span class="customItemModal__label">이 이미지를 사용하는 템플릿</span>
|
||||
<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>
|
||||
</div>
|
||||
<div v-else class="hint hint--tight">아직 템플릿에 연결된 게임이 없어요.</div>
|
||||
<div v-else class="hint hint--tight">아직 템플릿에 연결된 항목이 없어요.</div>
|
||||
</div>
|
||||
<div class="customItemModal__actions">
|
||||
<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__titleRow">
|
||||
<div>
|
||||
<div class="modalCard__title">게임 선택</div>
|
||||
<div class="modalCard__title">템플릿 선택</div>
|
||||
<div class="modalCard__desc">
|
||||
{{ gamePickerMode === 'tierlists-filter' ? '특정 게임의 티어표만 보려면 게임을 선택하세요.' : '관리할 게임을 검색해서 바로 열 수 있어요.' }}
|
||||
{{ gamePickerMode === 'tierlists-filter' ? '특정 주제의 티어표만 보려면 템플릿을 선택하세요.' : '관리할 템플릿을 검색해서 바로 열 수 있어요.' }}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn--ghost btn--small" @click="closeGamePickerModal">닫기</button>
|
||||
</div>
|
||||
<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">
|
||||
<option value="recent">최신순</option>
|
||||
<option value="oldest">오래된순</option>
|
||||
@@ -2029,7 +2029,7 @@ function userAvatarFallback(user) {
|
||||
type="button"
|
||||
@click="setAdminTierListGameId(''); closeGamePickerModal()"
|
||||
>
|
||||
모든 게임 보기
|
||||
모든 주제 보기
|
||||
</button>
|
||||
</div>
|
||||
<div class="gamePickerModalList">
|
||||
@@ -2201,7 +2201,7 @@ function userAvatarFallback(user) {
|
||||
<div class="adminSidebar__label">Mode</div>
|
||||
<div class="adminSidebar__tabs">
|
||||
<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 === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button>
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
|
||||
@@ -2209,16 +2209,16 @@ function userAvatarFallback(user) {
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<button class="btn btn--primary" @click="openGameCreateModal">새 게임 생성</button>
|
||||
<button class="btn btn--ghost" @click="openGamePickerModal('game-admin')">게임 선택</button>
|
||||
<button class="btn btn--primary" @click="openGameCreateModal">새 템플릿 생성</button>
|
||||
<button class="btn btn--ghost" @click="openGamePickerModal('game-admin')">템플릿 선택</button>
|
||||
<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__meta">{{ selectedGame.game.id }}</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>
|
||||
</section>
|
||||
|
||||
@@ -2277,14 +2277,14 @@ function userAvatarFallback(user) {
|
||||
<input
|
||||
v-model="adminTierListQuery"
|
||||
class="input"
|
||||
placeholder="제목, 작성자, 게임 이름 검색"
|
||||
placeholder="제목, 작성자, 주제 이름 검색"
|
||||
@keydown.enter.prevent="submitAdminTierListSearch"
|
||||
/>
|
||||
<button class="btn btn--ghost" @click="submitAdminTierListSearch">검색</button>
|
||||
</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 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__meta">{{ adminTierListGameId }}</div>
|
||||
<button class="btn btn--ghost btn--small" @click="setAdminTierListGameId('')">필터 해제</button>
|
||||
@@ -2389,7 +2389,6 @@ function userAvatarFallback(user) {
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
<div v-show="globalRightRailOpen" class="adminSidebarFooter adminUiScope">Copyright © 2026 zenn. All rights reserved.</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
@@ -2468,15 +2467,6 @@ function userAvatarFallback(user) {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.adminUiScope.adminSidebarFooter {
|
||||
margin-top: 6px;
|
||||
padding: 0 4px 2px;
|
||||
font-size: 9px;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
color: var(--theme-text-faint);
|
||||
opacity: 0.72;
|
||||
}
|
||||
.adminUiScope .adminSidebar__panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -3901,7 +3891,6 @@ function userAvatarFallback(user) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
line-height: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.adminUiScope .templateRequestCard__thumbMeta {
|
||||
display: grid;
|
||||
@@ -4155,6 +4144,7 @@ function userAvatarFallback(user) {
|
||||
color: var(--theme-text-muted);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
line-clamp: 2;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
@@ -4301,11 +4291,17 @@ function userAvatarFallback(user) {
|
||||
width: min(560px, 100%);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 20px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: color-mix(in srgb, var(--theme-main-bg) 96%, transparent);
|
||||
}
|
||||
.adminUiScope .modalCard:not(.modalCard--customItem) {
|
||||
padding: 20px;
|
||||
}
|
||||
.adminUiScope .modalCard.modalCard--customItem {
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.adminUiScope .modalCard--preview {
|
||||
width: min(1200px, 100%);
|
||||
max-height: calc(100dvh - 40px);
|
||||
|
||||
@@ -58,11 +58,11 @@ onMounted(loadFavorites)
|
||||
<div class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Collection</div>
|
||||
<h2 class="pageHead__title">내 즐겨찾기</h2>
|
||||
<h2 class="pageHead__title">즐겨찾기</h2>
|
||||
<div class="pageHead__desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 수 있어요.</div>
|
||||
</div>
|
||||
<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">
|
||||
<option value="favorited">즐겨찾기한 순</option>
|
||||
<option value="updated">최신 업데이트순</option>
|
||||
@@ -77,7 +77,7 @@ onMounted(loadFavorites)
|
||||
<article v-for="tierList in favorites" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openTierList(tierList)">
|
||||
<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>
|
||||
<div class="boardCard__head">
|
||||
@@ -87,7 +87,7 @@ onMounted(loadFavorites)
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<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>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@ async function loadTierLists() {
|
||||
brokenThumbnailIds.value = {}
|
||||
tierLists.value = listRes.tierLists || []
|
||||
} catch (e) {
|
||||
error.value = '게임 정보를 불러오지 못했어요.'
|
||||
error.value = '주제 정보를 불러오지 못했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ function submitSearch() {
|
||||
<div class="dashboardHero__left">
|
||||
<div class="dashboardHero__eyebrow">Collection</div>
|
||||
<h2 class="dashboardHero__title">{{ gameName || gameId }}</h2>
|
||||
<p class="dashboardHero__desc">이 게임의 공개 티어표를 탐색하고, 바로 새 보드를 만들어 같은 흐름으로 이어갈 수 있어요.</p>
|
||||
<p class="dashboardHero__desc">이 주제의 공개 티어표를 탐색하고, 바로 새 보드를 만들어 같은 흐름으로 이어갈 수 있어요.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -109,7 +109,7 @@ function submitSearch() {
|
||||
<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)">
|
||||
<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>
|
||||
<div class="boardCard__head">
|
||||
@@ -121,7 +121,7 @@ function submitSearch() {
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<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>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
|
||||
</div>
|
||||
|
||||
@@ -77,9 +77,9 @@ function thumbUrl(g) {
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Workspace</div>
|
||||
<h1 class="pageHead__title">Game Library</h1>
|
||||
<p class="pageHead__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
||||
<p v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 게임 템플릿만 보고 있어요.</p>
|
||||
<h1 class="pageHead__title">Topic Library</h1>
|
||||
<p class="pageHead__desc">자주 쓰는 주제 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
||||
<p v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 주제 템플릿만 보고 있어요.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -97,7 +97,7 @@ function thumbUrl(g) {
|
||||
</button>
|
||||
<button class="libraryCard__main" type="button" @click="goGame(g.id)">
|
||||
<div class="libraryCard__thumbWrap">
|
||||
<img v-if="thumbUrl(g)" class="libraryCard__thumb" :src="thumbUrl(g)" :alt="g.name" />
|
||||
<img v-if="thumbUrl(g)" class="libraryCard__thumb" :src="thumbUrl(g)" :alt="g.name" draggable="false" />
|
||||
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="libraryCard__body">
|
||||
@@ -107,7 +107,7 @@ function thumbUrl(g) {
|
||||
</button>
|
||||
</article>
|
||||
</TransitionGroup>
|
||||
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 게임 템플릿이 없어요.' : '표시할 게임 템플릿이 없어요.' }}</div>
|
||||
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 주제 템플릿이 없어요.' : '표시할 주제 템플릿이 없어요.' }}</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -87,7 +87,8 @@ async function submit() {
|
||||
</section>
|
||||
|
||||
<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>
|
||||
@@ -159,16 +160,37 @@ async function submit() {
|
||||
}
|
||||
|
||||
.authTabs {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
gap: 0;
|
||||
width: fit-content;
|
||||
padding: 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--theme-border);
|
||||
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 {
|
||||
position: relative;
|
||||
min-width: 112px;
|
||||
padding: 10px 16px;
|
||||
border: 0;
|
||||
@@ -177,10 +199,11 @@ async function submit() {
|
||||
color: var(--theme-text-muted);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: color 180ms ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.authTabs__button--active {
|
||||
background: rgba(76, 133, 245, 0.22);
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ function openList(t) {
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Library</div>
|
||||
<h2 class="pageHead__title">내 티어표</h2>
|
||||
<h2 class="pageHead__title">나의 티어표</h2>
|
||||
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -85,6 +85,7 @@ function openList(t) {
|
||||
class="boardCard__thumb"
|
||||
:src="tierListThumbnailUrl(t)"
|
||||
alt=""
|
||||
draggable="false"
|
||||
@error="handleThumbnailError(t.id)"
|
||||
/>
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
@@ -96,7 +97,7 @@ function openList(t) {
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<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>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
|
||||
</div>
|
||||
|
||||
@@ -121,7 +121,7 @@ async function logout() {
|
||||
<header class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Account</div>
|
||||
<h2 class="pageHead__title">Settings</h2>
|
||||
<h2 class="pageHead__title">설정</h2>
|
||||
<div class="pageHead__desc">계정 정보를 간결하게 정리하고, 프로필 이미지를 클릭해서 바로 변경할 수 있어요.</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -134,7 +134,7 @@ async function logout() {
|
||||
<div class="settingsIdentity">
|
||||
<div class="avatarButtonWrap">
|
||||
<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 class="avatarButton__overlay">
|
||||
<span>{{ avatarUrl ? '이미지 변경' : '이미지 추가' }}</span>
|
||||
|
||||
@@ -67,7 +67,7 @@ watch(
|
||||
<section class="wrap">
|
||||
<div class="head">
|
||||
<div>
|
||||
<div class="head__eyebrow">Search</div>
|
||||
<div class="head__eyebrow">검색</div>
|
||||
<h2 class="title">전체 티어표 검색</h2>
|
||||
<div class="desc">공개된 모든 티어표를 제목과 작성자 기준으로 찾아볼 수 있어요.</div>
|
||||
</div>
|
||||
@@ -80,7 +80,7 @@ watch(
|
||||
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openTierList(tierList)">
|
||||
<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>
|
||||
<div class="boardCard__head">
|
||||
@@ -92,7 +92,7 @@ watch(
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<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>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import SvgIcon from '../components/SvgIcon.vue'
|
||||
import addColumnRightIcon from '../assets/icons/add_column_right.svg'
|
||||
import addRowBelowIcon from '../assets/icons/add_row_below.svg'
|
||||
import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
|
||||
import shareIcon from '../assets/icons/share.svg'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
@@ -129,7 +130,13 @@ const canRequestTemplateUpdate = computed(
|
||||
)
|
||||
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||
const templateRequestTargetLabel = computed(() => (gameId.value === 'freeform' ? '새로운 템플릿' : (gameName.value || gameId.value || '선택한 게임')))
|
||||
const templateRequestTargetLabel = computed(() => (gameId.value === 'freeform' ? '새로운 템플릿' : (gameName.value || gameId.value || '선택한 주제')))
|
||||
const shareTierListUrl = computed(() => {
|
||||
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
||||
if (!savedTierListId) return ''
|
||||
if (typeof window === 'undefined') return `/editor/${gameId.value}/${savedTierListId}?preview=1`
|
||||
return new URL(`/editor/${gameId.value}/${savedTierListId}?preview=1`, window.location.origin).toString()
|
||||
})
|
||||
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
@@ -671,6 +678,7 @@ function buildPayload(existingId) {
|
||||
description: (description.value || '').trim(),
|
||||
isPublic: !!isPublic.value,
|
||||
showCharacterNames: !!showCharacterNames.value,
|
||||
iconSize: Number(iconSize.value || 80),
|
||||
sourceTierListId: sourceTierListId.value || '',
|
||||
sourceSnapshotTitle: sourceSnapshotTitle.value || '',
|
||||
sourceSnapshotAuthor: sourceSnapshotAuthor.value || '',
|
||||
@@ -712,6 +720,32 @@ async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyShareUrl() {
|
||||
if (!shareTierListUrl.value) {
|
||||
toast.error('먼저 티어표를 저장한 뒤 공유할 수 있어요.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(shareTierListUrl.value)
|
||||
} else {
|
||||
const helper = document.createElement('textarea')
|
||||
helper.value = shareTierListUrl.value
|
||||
helper.setAttribute('readonly', '')
|
||||
helper.style.position = 'absolute'
|
||||
helper.style.left = '-9999px'
|
||||
document.body.appendChild(helper)
|
||||
helper.select()
|
||||
document.execCommand('copy')
|
||||
helper.remove()
|
||||
}
|
||||
toast.success('공유 링크를 클립보드에 복사했어요.')
|
||||
} catch (e) {
|
||||
toast.error('공유 링크를 복사하지 못했어요.')
|
||||
}
|
||||
}
|
||||
|
||||
function closeSaveModal() {
|
||||
isSaveModalOpen.value = false
|
||||
}
|
||||
@@ -876,7 +910,7 @@ onMounted(() => {
|
||||
itemsById.value = map
|
||||
pool.value = base.map((it) => it.id)
|
||||
} catch (e) {
|
||||
error.value = '게임 기본 이미지를 불러오지 못했어요.'
|
||||
error.value = '기본 템플릿 이미지를 불러오지 못했어요.'
|
||||
}
|
||||
|
||||
if (tierListId.value && tierListId.value !== 'new') {
|
||||
@@ -890,6 +924,7 @@ onMounted(() => {
|
||||
description.value = t.description || ''
|
||||
isPublic.value = !!t.isPublic
|
||||
showCharacterNames.value = !!t.showCharacterNames
|
||||
iconSize.value = Number(t.iconSize || 80)
|
||||
authorName.value = t.authorName || ''
|
||||
authorAccountName.value = t.authorAccountName || ''
|
||||
updatedAt.value = Number(t.updatedAt || 0)
|
||||
@@ -925,7 +960,7 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<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__title">{{ effectiveTitle }}</div>
|
||||
<div v-if="description" class="previewOnly__description">{{ description }}</div>
|
||||
@@ -941,6 +976,7 @@ onUnmounted(() => {
|
||||
<div class="previewOnly__dropGrid" :style="{ '--column-count': columns.length }">
|
||||
<div v-for="(column, columnIndex) in columns" :key="column.id" class="previewOnly__dropColumn">
|
||||
<div class="previewOnly__drop">
|
||||
<div v-if="columns.length > 1" class="previewOnly__columnBadge">{{ column.name || '열 ' + (columnIndex + 1) }}</div>
|
||||
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="previewOnly__cell">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
||||
@@ -958,6 +994,10 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="previewOnly__footer">
|
||||
<span>{{ effectiveAuthorName }}</span>
|
||||
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -966,7 +1006,7 @@ onUnmounted(() => {
|
||||
<div v-if="isSaveModalOpen" class="modalOverlay" @click.self="closeSaveModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="saveModalTitle">
|
||||
<div id="saveModalTitle" class="modalCard__title">저장 완료</div>
|
||||
<div class="modalCard__desc">티어표가 저장되었어요. 이어서 더 수정한 뒤 다시 저장할 수도 있어요.</div>
|
||||
<div class="modalCard__desc">티어표가 저장되었어요.<br />이어서 더 수정한 뒤 다시 저장할 수도 있어요.</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--save" @click="closeSaveModal">확인</button>
|
||||
</div>
|
||||
@@ -981,7 +1021,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="requestChecklist__hint">
|
||||
제목과 설명을 함께 적어두면 관리자가 어떤 신규 템플릿인지 훨씬 빠르게 파악할 수 있어요.
|
||||
예시: 제목 `템플릿 등록 요청`, 설명 `여름 이벤트 한정 캐릭터 중심으로 새 게임 템플릿이 필요합니다.`
|
||||
예시: 제목 `템플릿 등록 요청`, 설명 `여름 이벤트 한정 캐릭터 중심으로 새 주제 템플릿이 필요합니다.`
|
||||
</div>
|
||||
<div class="templateRequestDraft">
|
||||
<label class="templateRequestDraft__field">
|
||||
@@ -1165,13 +1205,14 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="row__content" :style="{ '--column-count': columns.length }">
|
||||
<div v-for="(column, columnIndex) in columns" :key="column.id" class="row__column">
|
||||
<div
|
||||
<div
|
||||
class="row__drop"
|
||||
:data-list-type="'group'"
|
||||
:data-group-id="g.id"
|
||||
:data-column-index="columnIndex"
|
||||
:ref="(el) => setGroupDropEl(g.id, columnIndex, el)"
|
||||
>
|
||||
<div v-if="columns.length > 1" class="row__columnBadge">{{ column.name || '열 ' + (columnIndex + 1) }}</div>
|
||||
<div v-if="!isExporting" class="row__empty" v-show="getGroupCellIds(g, columnIndex).length === 0">여기로 드래그해서 배치</div>
|
||||
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="cell" :data-item-id="id">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
@@ -1324,6 +1365,10 @@ onUnmounted(() => {
|
||||
<button v-if="canEdit" class="btn btn--save editorSidebar__button" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
|
||||
</div>
|
||||
<div class="editorSidebar__utilityLinks">
|
||||
<button v-if="hasSavedTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--share" @click="copyShareUrl">
|
||||
<SvgIcon :src="shareIcon" :size="16" />
|
||||
<span>공유하기</span>
|
||||
</button>
|
||||
<button v-if="canEdit && hasSavedTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--danger" @click="openDeleteModal">삭제하기</button>
|
||||
<button v-if="canDuplicate" class="editorSidebar__utilityLink" @click="duplicateCurrentTierList">복사해서 내 티어표로 가져오기</button>
|
||||
<button
|
||||
@@ -1466,6 +1511,7 @@ onUnmounted(() => {
|
||||
border: 1px solid var(--theme-border-strong);
|
||||
}
|
||||
.previewOnly__drop {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
background: var(--theme-pill-bg);
|
||||
border: 1px solid var(--theme-border);
|
||||
@@ -1476,6 +1522,10 @@ onUnmounted(() => {
|
||||
gap: 8px;
|
||||
align-content: flex-start;
|
||||
}
|
||||
.previewOnly__columnBadge,
|
||||
.row__columnBadge {
|
||||
display: none;
|
||||
}
|
||||
.previewOnly__cell {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
@@ -1502,6 +1552,15 @@ onUnmounted(() => {
|
||||
opacity: 0.52;
|
||||
filter: grayscale(0.22) brightness(0.78);
|
||||
}
|
||||
.previewOnly__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 8px;
|
||||
color: var(--theme-text-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
.toggleSwitch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -2282,6 +2341,9 @@ onUnmounted(() => {
|
||||
background: transparent;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -2293,6 +2355,10 @@ onUnmounted(() => {
|
||||
.editorSidebar__utilityLink--danger {
|
||||
color: rgba(248, 113, 113, 0.96);
|
||||
}
|
||||
|
||||
.editorSidebar__utilityLink--share {
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
.sidebar__title {
|
||||
font-weight: 900;
|
||||
margin-bottom: 8px;
|
||||
@@ -2434,8 +2500,45 @@ onUnmounted(() => {
|
||||
border-radius: 14px;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.previewOnly__row {
|
||||
grid-template-columns: 140px 1fr;
|
||||
.previewOnly__row,
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.previewOnly__columns,
|
||||
.boardColumnsHeader {
|
||||
display: none;
|
||||
}
|
||||
.previewOnly__columnsSpacer,
|
||||
.boardColumnsHeader__spacer {
|
||||
display: none;
|
||||
}
|
||||
.previewOnly__dropGrid,
|
||||
.boardColumnsHeader__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.previewOnly__drop,
|
||||
.row__drop {
|
||||
padding-top: 40px;
|
||||
}
|
||||
.previewOnly__columnBadge,
|
||||
.row__columnBadge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: calc(100% - 20px);
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--theme-text-soft);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.heroCard {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -2446,9 +2549,6 @@ onUnmounted(() => {
|
||||
.row__content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.row {
|
||||
grid-template-columns: 150px 1fr;
|
||||
}
|
||||
.sidebar {
|
||||
position: static;
|
||||
}
|
||||
@@ -2477,20 +2577,6 @@ onUnmounted(() => {
|
||||
.previewOnly {
|
||||
padding: 14px;
|
||||
}
|
||||
.previewOnly__columns,
|
||||
.previewOnly__row,
|
||||
.boardColumnsHeader,
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.previewOnly__columnsSpacer,
|
||||
.boardColumnsHeader__spacer {
|
||||
display: none;
|
||||
}
|
||||
.previewOnly__dropGrid,
|
||||
.boardColumnsHeader__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.pool {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||