Compare commits

...

8 Commits

10 changed files with 222 additions and 74 deletions

View File

@@ -138,6 +138,7 @@ function mapTierListRow(row) {
description: row.description || '', description: row.description || '',
isPublic: !!row.is_public, isPublic: !!row.is_public,
showCharacterNames: !!row.show_character_names, showCharacterNames: !!row.show_character_names,
iconSize: Number(row.icon_size || 80),
sourceTierListId: row.source_tierlist_id || '', sourceTierListId: row.source_tierlist_id || '',
sourceSnapshotTitle: row.source_snapshot_title || '', sourceSnapshotTitle: row.source_snapshot_title || '',
sourceSnapshotAuthor: row.source_snapshot_author || '', sourceSnapshotAuthor: row.source_snapshot_author || '',
@@ -314,6 +315,7 @@ async function ensureSchema() {
description TEXT NOT NULL, description TEXT NOT NULL,
is_public TINYINT(1) NOT NULL DEFAULT 0, is_public TINYINT(1) NOT NULL DEFAULT 0,
show_character_names TINYINT(1) NOT NULL DEFAULT 0, show_character_names TINYINT(1) NOT NULL DEFAULT 0,
icon_size INT NOT NULL DEFAULT 80,
source_tierlist_id VARCHAR(64) NULL DEFAULT NULL, source_tierlist_id VARCHAR(64) NULL DEFAULT NULL,
source_snapshot_title VARCHAR(120) NOT NULL DEFAULT '', source_snapshot_title VARCHAR(120) NOT NULL DEFAULT '',
source_snapshot_author VARCHAR(120) NOT NULL DEFAULT '', source_snapshot_author VARCHAR(120) NOT NULL DEFAULT '',
@@ -455,9 +457,13 @@ async function ensureSchema() {
if (!tierListShowNamesColumns.length) { if (!tierListShowNamesColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public") await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
} }
const tierListIconSizeColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'icon_size'")
if (!tierListIconSizeColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN icon_size INT NOT NULL DEFAULT 80 AFTER show_character_names")
}
const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'") const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'")
if (!tierListSourceIdColumns.length) { if (!tierListSourceIdColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER show_character_names") await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER icon_size")
} else if (tierListSourceIdColumns[0]?.Null !== 'YES') { } else if (tierListSourceIdColumns[0]?.Null !== 'YES') {
await query('ALTER TABLE tierlists MODIFY source_tierlist_id VARCHAR(64) NULL DEFAULT NULL') await query('ALTER TABLE tierlists MODIFY source_tierlist_id VARCHAR(64) NULL DEFAULT NULL')
} }
@@ -1847,6 +1853,7 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
t.description, t.description,
t.is_public, t.is_public,
t.show_character_names, t.show_character_names,
t.icon_size,
t.source_tierlist_id, t.source_tierlist_id,
t.source_snapshot_title, t.source_snapshot_title,
t.source_snapshot_author, t.source_snapshot_author,
@@ -1990,6 +1997,7 @@ async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit
t.description, t.description,
t.is_public, t.is_public,
t.show_character_names, t.show_character_names,
t.icon_size,
t.source_tierlist_id, t.source_tierlist_id,
t.source_snapshot_title, t.source_snapshot_title,
t.source_snapshot_author, t.source_snapshot_author,
@@ -2093,6 +2101,7 @@ async function findTierListById(id, currentUserId = '') {
t.description, t.description,
t.is_public, t.is_public,
t.show_character_names, t.show_character_names,
t.icon_size,
t.source_tierlist_id, t.source_tierlist_id,
t.source_snapshot_title, t.source_snapshot_title,
t.source_snapshot_author, t.source_snapshot_author,
@@ -2346,6 +2355,7 @@ async function saveTierList({
description, description,
isPublic, isPublic,
showCharacterNames = false, showCharacterNames = false,
iconSize = 80,
sourceTierListId = '', sourceTierListId = '',
sourceSnapshotTitle = '', sourceSnapshotTitle = '',
sourceSnapshotAuthor = '', sourceSnapshotAuthor = '',
@@ -2360,10 +2370,10 @@ async function saveTierList({
await query( await query(
` `
UPDATE tierlists UPDATE tierlists
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, show_character_names = ?, source_tierlist_id = ?, source_snapshot_title = ?, source_snapshot_author = ?, groups_json = ?, pool_json = ?, updated_at = ? SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, show_character_names = ?, icon_size = ?, source_tierlist_id = ?, source_snapshot_title = ?, source_snapshot_author = ?, groups_json = ?, pool_json = ?, updated_at = ?
WHERE id = ? WHERE id = ?
`, `,
[title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), now(), existing.id] [title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, Number(iconSize) || 80, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), now(), existing.id]
) )
return findTierListById(existing.id, authorId) return findTierListById(existing.id, authorId)
} }
@@ -2373,11 +2383,11 @@ async function saveTierList({
await query( await query(
` `
INSERT INTO tierlists ( INSERT INTO tierlists (
id, author_id, game_id, title, thumbnail_src, description, is_public, show_character_names, source_tierlist_id, source_snapshot_title, source_snapshot_author, groups_json, pool_json, created_at, updated_at id, author_id, game_id, title, thumbnail_src, description, is_public, show_character_names, icon_size, source_tierlist_id, source_snapshot_title, source_snapshot_author, groups_json, pool_json, created_at, updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
[nextId, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt] [nextId, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, Number(iconSize) || 80, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt]
) )
return findTierListById(nextId, authorId) return findTierListById(nextId, authorId)
} }
@@ -2396,6 +2406,7 @@ async function duplicateTierListForUser({ tierList, targetUserId }) {
description: tierList.description || '', description: tierList.description || '',
isPublic: false, isPublic: false,
showCharacterNames: !!tierList.showCharacterNames, showCharacterNames: !!tierList.showCharacterNames,
iconSize: Number(tierList.iconSize || 80),
sourceTierListId: tierList.id, sourceTierListId: tierList.id,
sourceSnapshotTitle: tierList.title || '', sourceSnapshotTitle: tierList.title || '',
sourceSnapshotAuthor: tierList.authorName || tierList.authorAccountName || '', sourceSnapshotAuthor: tierList.authorName || tierList.authorAccountName || '',

View File

@@ -92,6 +92,7 @@ const tierListUpsertSchema = z.object({
description: z.string().max(1000).optional().default(''), description: z.string().max(1000).optional().default(''),
isPublic: z.boolean().default(false), isPublic: z.boolean().default(false),
showCharacterNames: z.boolean().optional().default(false), showCharacterNames: z.boolean().optional().default(false),
iconSize: z.number().int().min(48).max(112).optional().default(80),
sourceTierListId: z.string().max(64).optional().default(''), sourceTierListId: z.string().max(64).optional().default(''),
sourceSnapshotTitle: z.string().max(120).optional().default(''), sourceSnapshotTitle: z.string().max(120).optional().default(''),
sourceSnapshotAuthor: z.string().max(120).optional().default(''), sourceSnapshotAuthor: z.string().max(120).optional().default(''),
@@ -289,6 +290,7 @@ router.post('/', requireAuth, async (req, res) => {
description: payload.description || '', description: payload.description || '',
isPublic: !!payload.isPublic, isPublic: !!payload.isPublic,
showCharacterNames: !!payload.showCharacterNames, showCharacterNames: !!payload.showCharacterNames,
iconSize: Number(payload.iconSize || 80),
sourceTierListId: payload.sourceTierListId || existing.sourceTierListId || '', sourceTierListId: payload.sourceTierListId || existing.sourceTierListId || '',
sourceSnapshotTitle: payload.sourceSnapshotTitle || existing.sourceSnapshotTitle || '', sourceSnapshotTitle: payload.sourceSnapshotTitle || existing.sourceSnapshotTitle || '',
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || existing.sourceSnapshotAuthor || '', sourceSnapshotAuthor: payload.sourceSnapshotAuthor || existing.sourceSnapshotAuthor || '',
@@ -307,6 +309,7 @@ router.post('/', requireAuth, async (req, res) => {
description: payload.description || '', description: payload.description || '',
isPublic: !!payload.isPublic, isPublic: !!payload.isPublic,
showCharacterNames: !!payload.showCharacterNames, showCharacterNames: !!payload.showCharacterNames,
iconSize: Number(payload.iconSize || 80),
sourceTierListId: payload.sourceTierListId || '', sourceTierListId: payload.sourceTierListId || '',
sourceSnapshotTitle: payload.sourceSnapshotTitle || '', sourceSnapshotTitle: payload.sourceSnapshotTitle || '',
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || '', sourceSnapshotAuthor: payload.sourceSnapshotAuthor || '',

View File

@@ -1,5 +1,24 @@
# 의사결정 이력 # 의사결정 이력
## 2026-04-02 v1.3.86
- 아이콘 크기는 이미지 다운로드 결과에만 반영되고 저장본에는 남지 않으면 사용자가 체감상 “저장되지 않는 설정”으로 느끼게 되므로, 티어표 본문 설정으로 저장하는 편이 맞다고 정리했다.
## 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.78
- 축소 상태에서는 텍스트가 사라지므로 같은 `티어표 만들기` 계열 액션이라도 커스텀 제작과 템플릿 기반 제작을 아이콘으로 구분해 주는 편이 맞다고 정리했다. - 축소 상태에서는 텍스트가 사라지므로 같은 `티어표 만들기` 계열 액션이라도 커스텀 제작과 템플릿 기반 제작을 아이콘으로 구분해 주는 편이 맞다고 정리했다.
- 관리자 우측 카피라이트처럼 “사이드바 하단”에 붙어야 하는 정보는 텔레포트 루트의 형제 노드로 두기보다, 실제 사이드바 컨테이너 내부의 마지막 행으로 두는 편이 레이아웃상 안전하다고 판단했다. - 관리자 우측 카피라이트처럼 “사이드바 하단”에 붙어야 하는 정보는 텔레포트 루트의 형제 노드로 두기보다, 실제 사이드바 컨테이너 내부의 마지막 행으로 두는 편이 레이아웃상 안전하다고 판단했다.

View File

@@ -1,6 +1,14 @@
# 할 일 및 이슈 # 할 일 및 이슈
## 단기 확인 ## 단기 확인
- 티어표 `아이콘 크기`는 이제 저장 데이터로 승격됐으므로, 저장 후 재진입/프리뷰/복사본 생성에서 같은 크기가 유지되는지 한 번 더 QA한다.
- 티어표 편집/프리뷰 모바일 열 배지는 새로 붙였으므로, 실제 좁은 화면에서 칸 상단 배지와 아이템 썸네일이 겹치지 않고 열 구분이 자연스러운지 한 번 더 QA한다.
- 모바일 열 배지는 같은 구간에서 상단 열 제목을 숨기도록 다시 맞췄으므로, 720px 안팎뿐 아니라 980px 이하 전 구간에서 중복 표기 없이 자연스러운지 한 번 더 QA한다.
- 모바일 티어표 편집 레이아웃은 행 라벨 폭을 다시 덮어쓰던 규칙을 걷어냈으므로, 실제 980px 이하 구간에서 행 라벨이 과하게 넓지 않고 칸 폭을 충분히 남기는지 한 번 더 QA한다.
- 프리뷰 완성본 하단 메타는 새로 붙였으므로, 작성자/저장 시각이 공개 열람 화면과 이미지 다운로드 결과 기준에서 모두 자연스럽게 읽히는지 한 번 더 QA한다.
- 관리자 템플릿 요청 카드는 썸네일 클릭이 새창 열기 역할로 바뀌었으므로, 썸네일 클릭과 `확인하기` 액션이 서로 헷갈리지 않는지 한 번 더 QA한다.
- 티어표 만들기 화면의 `공유하기`는 저장된 티어표에서만 노출되므로, 저장 직후/수정 중/복사본/읽기 전용 상태 각각에서 노출 조건과 클립보드 복사가 자연스러운지 한 번 더 QA한다.
- 우측 카피라이트는 이제 공통 오른쪽 레일 footer이므로, 관리자 화면뿐 아니라 홈/프로필 등 오른쪽 사이드가 보이는 화면에서도 같은 최하단 위치에 유지되는지 한 번 더 QA한다.
- 왼쪽 레일 축소 상태의 하단 액션 아이콘은 홈과 게임 허브에서 서로 다른 아이콘을 쓰도록 나눴으므로, 실제로 두 문맥이 한눈에 구분되는지 한 번 더 QA한다. - 왼쪽 레일 축소 상태의 하단 액션 아이콘은 홈과 게임 허브에서 서로 다른 아이콘을 쓰도록 나눴으므로, 실제로 두 문맥이 한눈에 구분되는지 한 번 더 QA한다.
- 왼쪽 레일 축소 상태 최하단의 `티어표 만들기` 아이콘 버튼은 새로 추가했으므로, 홈/게임 허브에서 실제로 같은 위치 감각으로 동작하는지 한 번 더 QA한다. - 왼쪽 레일 축소 상태 최하단의 `티어표 만들기` 아이콘 버튼은 새로 추가했으므로, 홈/게임 허브에서 실제로 같은 위치 감각으로 동작하는지 한 번 더 QA한다.
- 관리자 우측 카피라이트 문구는 사이드바 내부 최하단으로 다시 옮겼으므로, 실제 관리자 화면에서 스크롤/창 크기 변화에도 계속 보이는지 한 번 더 QA한다. - 관리자 우측 카피라이트 문구는 사이드바 내부 최하단으로 다시 옮겼으므로, 실제 관리자 화면에서 스크롤/창 크기 변화에도 계속 보이는지 한 번 더 QA한다.

View File

@@ -1,5 +1,27 @@
# 업데이트 로그 # 업데이트 로그
## 2026-04-02 v1.3.86
- 티어표 편집의 `아이콘 크기`는 이제 임시 화면 상태가 아니라 저장 데이터에 함께 포함되며, 저장 후 다시 열기와 프리뷰 화면에서도 같은 크기로 복원되도록 정리함.
- 이를 위해 티어표 저장 payload, 서버 검증, DB 저장/조회에 `iconSize`를 추가하고 기존 데이터는 기본값 `80`으로 안전하게 보정되게 맞춤.
## 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 ## 2026-04-02 v1.3.78
- 왼쪽 레일 축소 상태의 하단 액션 아이콘은 문맥에 따라 구분되도록 바꿔, 홈의 `커스텀 티어표 만들기``dashboard_customize` 아이콘을 쓰고 게임 허브의 일반 `티어표 만들기``add_notes` 아이콘을 유지하도록 정리함. - 왼쪽 레일 축소 상태의 하단 액션 아이콘은 문맥에 따라 구분되도록 바꿔, 홈의 `커스텀 티어표 만들기``dashboard_customize` 아이콘을 쓰고 게임 허브의 일반 `티어표 만들기``add_notes` 아이콘을 유지하도록 정리함.
- 관리자 우측 카피라이트 문구는 사이드바 바깥 형제로 밀려 보이지 않을 수 있었으므로, 다시 관리자 사이드바 `aside` 내부 최하단으로 옮겨 레이아웃 안에서 안정적으로 보이게 정리함. - 관리자 우측 카피라이트 문구는 사이드바 바깥 형제로 밀려 보이지 않을 수 있었으므로, 다시 관리자 사이드바 `aside` 내부 최하단으로 옮겨 레이아웃 안에서 안정적으로 보이게 정리함.

View File

@@ -21,6 +21,7 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const { toasts, dismissToast } = useToast() const { toasts, dismissToast } = useToast()
const RIGHT_RAIL_COPYRIGHT_URL = 'https://zenn.town/@murabito'
const leftRailCollapsed = ref(false) const leftRailCollapsed = ref(false)
const rightRailOpen = ref(true) const rightRailOpen = ref(true)
@@ -628,6 +629,11 @@ function submitGlobalSearch() {
</button> </button>
</section> </section>
</template> </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>
</div> </div>
</aside> </aside>
@@ -748,8 +754,11 @@ function submitGlobalSearch() {
} }
.rightRail__content { .rightRail__content {
flex: 0 0 auto; flex: 1 1 auto;
min-height: 0;
overflow: visible; overflow: visible;
display: flex;
flex-direction: column;
} }
.ghostIcon { .ghostIcon {
@@ -1197,13 +1206,31 @@ function submitGlobalSearch() {
} }
.rightRail__bottom { .rightRail__bottom {
display: flex; margin-top: auto;
align-items: flex-end; display: grid;
justify-content: flex-end;
gap: 10px; gap: 10px;
padding-top: 12px; 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 { .settingsThemePanel {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -1627,9 +1654,10 @@ function submitGlobalSearch() {
} }
.localRightRailRoot { .localRightRailRoot {
min-height: auto; flex: 1 1 auto;
display: grid; min-height: 100%;
align-content: start; display: flex;
flex-direction: column;
gap: 14px; gap: 14px;
} }

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="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

View File

@@ -39,10 +39,17 @@ const props = defineProps({
<div v-else class="templateRequestList"> <div v-else class="templateRequestList">
<article v-for="request in props.templateRequests" :key="request.id" class="tierAdminCard templateRequestCard templateRequestCard--aligned"> <article v-for="request in props.templateRequests" :key="request.id" class="tierAdminCard templateRequestCard templateRequestCard--aligned">
<div class="templateRequestCard__side"> <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" /> <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> <div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
</button> </a>
<div class="templateRequestCard__thumbMeta"> <div class="templateRequestCard__thumbMeta">
<template v-if="request.type === 'create'"> <template v-if="request.type === 'create'">
<label class="templateRequestField"> <label class="templateRequestField">
@@ -97,17 +104,7 @@ const props = defineProps({
</div> </div>
<div class="templateRequestCard__footer"> <div class="templateRequestCard__footer">
<div class="templateRequestCard__footerLeft"> <div class="templateRequestCard__footerLeft"></div>
<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__actions"> <div class="templateRequestCard__actions">
<button class="btn btn--primary" :disabled="request.isHandling" @click="props.startTemplateRequestReview(request)"> <button class="btn btn--primary" :disabled="request.isHandling" @click="props.startTemplateRequestReview(request)">
{{ {{

View File

@@ -26,7 +26,6 @@ const router = useRouter()
const globalRightRailOpen = inject('rightRailOpen', ref(true)) const globalRightRailOpen = inject('rightRailOpen', ref(true))
const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root') const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root')
const isAdmin = computed(() => !!auth.user?.isAdmin) const isAdmin = computed(() => !!auth.user?.isAdmin)
const ADMIN_COPYRIGHT_URL = 'https://zenn.town/@murabito'
const activeTab = ref('featured') const activeTab = ref('featured')
const tierlistsMode = ref('requests') const tierlistsMode = ref('requests')
@@ -2389,11 +2388,6 @@ function userAvatarFallback(user) {
</div> </div>
</div> </div>
</section> </section>
<div class="adminSidebarFooter">
<span>Copyright © 2026 </span>
<a :href="ADMIN_COPYRIGHT_URL" target="_blank" rel="noreferrer">zenn</a>
<span>. All rights reserved.</span>
</div>
</aside> </aside>
</Teleport> </Teleport>
</template> </template>
@@ -2473,27 +2467,6 @@ function userAvatarFallback(user) {
display: grid; display: grid;
gap: 12px; gap: 12px;
} }
.adminUiScope .adminSidebarFooter {
margin-top: auto;
padding-top: 4px;
}
.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 .adminSidebarFooter a {
color: #00ffff;
text-decoration: none;
}
.adminUiScope .adminSidebarFooter a:hover {
color: #00ffff;
text-decoration: underline;
}
.adminUiScope .adminSidebar__panel { .adminUiScope .adminSidebar__panel {
display: grid; display: grid;
gap: 12px; gap: 12px;

View File

@@ -7,6 +7,7 @@ import SvgIcon from '../components/SvgIcon.vue'
import addColumnRightIcon from '../assets/icons/add_column_right.svg' import addColumnRightIcon from '../assets/icons/add_column_right.svg'
import addRowBelowIcon from '../assets/icons/add_row_below.svg' import addRowBelowIcon from '../assets/icons/add_row_below.svg'
import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg' import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
import shareIcon from '../assets/icons/share.svg'
import { api } from '../lib/api' import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime' import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
@@ -130,6 +131,12 @@ const canRequestTemplateUpdate = computed(
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim()) const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim()) const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const templateRequestTargetLabel = computed(() => (gameId.value === 'freeform' ? '새로운 템플릿' : (gameName.value || gameId.value || '선택한 게임'))) const templateRequestTargetLabel = computed(() => (gameId.value === 'freeform' ? '새로운 템플릿' : (gameName.value || gameId.value || '선택한 게임')))
const shareTierListUrl = computed(() => {
const 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) => { watch(error, (message) => {
if (!message) return if (!message) return
@@ -671,6 +678,7 @@ function buildPayload(existingId) {
description: (description.value || '').trim(), description: (description.value || '').trim(),
isPublic: !!isPublic.value, isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value, showCharacterNames: !!showCharacterNames.value,
iconSize: Number(iconSize.value || 80),
sourceTierListId: sourceTierListId.value || '', sourceTierListId: sourceTierListId.value || '',
sourceSnapshotTitle: sourceSnapshotTitle.value || '', sourceSnapshotTitle: sourceSnapshotTitle.value || '',
sourceSnapshotAuthor: sourceSnapshotAuthor.value || '', sourceSnapshotAuthor: sourceSnapshotAuthor.value || '',
@@ -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() { function closeSaveModal() {
isSaveModalOpen.value = false isSaveModalOpen.value = false
} }
@@ -890,6 +924,7 @@ onMounted(() => {
description.value = t.description || '' description.value = t.description || ''
isPublic.value = !!t.isPublic isPublic.value = !!t.isPublic
showCharacterNames.value = !!t.showCharacterNames showCharacterNames.value = !!t.showCharacterNames
iconSize.value = Number(t.iconSize || 80)
authorName.value = t.authorName || '' authorName.value = t.authorName || ''
authorAccountName.value = t.authorAccountName || '' authorAccountName.value = t.authorAccountName || ''
updatedAt.value = Number(t.updatedAt || 0) updatedAt.value = Number(t.updatedAt || 0)
@@ -941,6 +976,7 @@ onUnmounted(() => {
<div class="previewOnly__dropGrid" :style="{ '--column-count': columns.length }"> <div class="previewOnly__dropGrid" :style="{ '--column-count': columns.length }">
<div v-for="(column, columnIndex) in columns" :key="column.id" class="previewOnly__dropColumn"> <div v-for="(column, columnIndex) in columns" :key="column.id" class="previewOnly__dropColumn">
<div class="previewOnly__drop"> <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"> <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" /> <img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div> <div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
@@ -958,6 +994,10 @@ onUnmounted(() => {
</div> </div>
</div> </div>
</div> </div>
<div class="previewOnly__footer">
<span>{{ effectiveAuthorName }}</span>
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
</div>
</div> </div>
</section> </section>
@@ -966,7 +1006,7 @@ onUnmounted(() => {
<div v-if="isSaveModalOpen" class="modalOverlay" @click.self="closeSaveModal"> <div v-if="isSaveModalOpen" class="modalOverlay" @click.self="closeSaveModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="saveModalTitle"> <div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="saveModalTitle">
<div id="saveModalTitle" class="modalCard__title">저장 완료</div> <div id="saveModalTitle" class="modalCard__title">저장 완료</div>
<div class="modalCard__desc">티어표가 저장되었어요. 이어서 수정한 다시 저장할 수도 있어요.</div> <div class="modalCard__desc">티어표가 저장되었어요.<br />이어서 수정한 다시 저장할 수도 있어요.</div>
<div class="modalCard__actions"> <div class="modalCard__actions">
<button class="btn btn--save" @click="closeSaveModal">확인</button> <button class="btn btn--save" @click="closeSaveModal">확인</button>
</div> </div>
@@ -1165,13 +1205,14 @@ onUnmounted(() => {
</div> </div>
<div class="row__content" :style="{ '--column-count': columns.length }"> <div class="row__content" :style="{ '--column-count': columns.length }">
<div v-for="(column, columnIndex) in columns" :key="column.id" class="row__column"> <div v-for="(column, columnIndex) in columns" :key="column.id" class="row__column">
<div <div
class="row__drop" class="row__drop"
:data-list-type="'group'" :data-list-type="'group'"
:data-group-id="g.id" :data-group-id="g.id"
:data-column-index="columnIndex" :data-column-index="columnIndex"
:ref="(el) => setGroupDropEl(g.id, columnIndex, el)" :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-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"> <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" /> <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> <button v-if="canEdit" class="btn btn--save editorSidebar__button" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
</div> </div>
<div class="editorSidebar__utilityLinks"> <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="canEdit && hasSavedTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--danger" @click="openDeleteModal">삭제하기</button>
<button v-if="canDuplicate" class="editorSidebar__utilityLink" @click="duplicateCurrentTierList">복사해서 티어표로 가져오기</button> <button v-if="canDuplicate" class="editorSidebar__utilityLink" @click="duplicateCurrentTierList">복사해서 티어표로 가져오기</button>
<button <button
@@ -1466,6 +1511,7 @@ onUnmounted(() => {
border: 1px solid var(--theme-border-strong); border: 1px solid var(--theme-border-strong);
} }
.previewOnly__drop { .previewOnly__drop {
position: relative;
border-radius: 14px; border-radius: 14px;
background: var(--theme-pill-bg); background: var(--theme-pill-bg);
border: 1px solid var(--theme-border); border: 1px solid var(--theme-border);
@@ -1476,6 +1522,10 @@ onUnmounted(() => {
gap: 8px; gap: 8px;
align-content: flex-start; align-content: flex-start;
} }
.previewOnly__columnBadge,
.row__columnBadge {
display: none;
}
.previewOnly__cell { .previewOnly__cell {
display: inline-flex; display: inline-flex;
position: relative; position: relative;
@@ -1502,6 +1552,15 @@ onUnmounted(() => {
opacity: 0.52; opacity: 0.52;
filter: grayscale(0.22) brightness(0.78); 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 { .toggleSwitch {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -2282,6 +2341,9 @@ onUnmounted(() => {
background: transparent; background: transparent;
color: var(--theme-text-muted); color: var(--theme-text-muted);
font-size: 14px; font-size: 14px;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer; cursor: pointer;
} }
@@ -2293,6 +2355,10 @@ onUnmounted(() => {
.editorSidebar__utilityLink--danger { .editorSidebar__utilityLink--danger {
color: rgba(248, 113, 113, 0.96); color: rgba(248, 113, 113, 0.96);
} }
.editorSidebar__utilityLink--share {
color: var(--theme-text-soft);
}
.sidebar__title { .sidebar__title {
font-weight: 900; font-weight: 900;
margin-bottom: 8px; margin-bottom: 8px;
@@ -2434,8 +2500,45 @@ onUnmounted(() => {
border-radius: 14px; border-radius: 14px;
} }
@media (max-width: 980px) { @media (max-width: 980px) {
.previewOnly__row { .previewOnly__row,
grid-template-columns: 140px 1fr; .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 { .heroCard {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -2446,9 +2549,6 @@ onUnmounted(() => {
.row__content { .row__content {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.row {
grid-template-columns: 150px 1fr;
}
.sidebar { .sidebar {
position: static; position: static;
} }
@@ -2477,20 +2577,6 @@ onUnmounted(() => {
.previewOnly { .previewOnly {
padding: 14px; 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 { .pool {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }