Compare commits

...

1 Commits

Author SHA1 Message Date
2bee78ba5e 릴리스: v0.1.52 프리뷰 전용화와 썸네일 자동 생성 2026-03-27 12:15:16 +09:00
8 changed files with 232 additions and 85 deletions

View File

@@ -920,6 +920,20 @@ function uniqueTierListItems(poolItems) {
return Array.from(map.values())
}
function getAutoThumbnailSrc(groups = [], pool = []) {
const itemMap = new Map((pool || []).filter((item) => item?.id && item?.src).map((item) => [item.id, item]))
for (const group of groups || []) {
for (const itemId of group?.itemIds || []) {
const item = itemMap.get(itemId)
if (item?.src) return item.src
}
}
const fallbackItem = (pool || []).find((item) => item?.src)
return fallbackItem?.src || ''
}
async function listAdminTierLists({ queryText = '', page = 1, limit = 50, currentUserId = '' } = {}) {
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
const normalizedPage = Math.max(Number(page) || 1, 1)
@@ -1203,6 +1217,7 @@ async function deleteCustomItems(ids) {
async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', description, isPublic, groups, pool }) {
const existing = id ? await findTierListById(id, authorId) : null
await syncOwnedCustomItemLabels({ ownerId: authorId, items: pool })
const nextThumbnailSrc = (thumbnailSrc || '').trim() || getAutoThumbnailSrc(groups, pool)
if (existing) {
await query(
@@ -1211,7 +1226,7 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, groups_json = ?, pool_json = ?, updated_at = ?
WHERE id = ?
`,
[title, thumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id]
[title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id]
)
return findTierListById(existing.id, authorId)
}
@@ -1224,7 +1239,7 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[id, authorId, gameId, title, thumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
[id, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
)
return findTierListById(id, authorId)
}

View File

@@ -1,5 +1,10 @@
# 의사결정 이력
## 2026-03-27 v0.1.52
- 관리자 확인용 완성본은 사이트 전체가 아니라 보드만 보여주는 preview 전용 모드가 더 적합하다고 판단했다.
- 티어표 썸네일은 비어 있는 것보다 자동 기본값이 있는 편이 낫다고 보고, 사용자가 직접 지정하지 않으면 티어표 아이템 중 대표 이미지를 자동 썸네일로 채우기로 결정했다.
- 이력 문서는 날짜 역순이 깨지면 추적이 어렵기 때문에, 오래된 2026-03-19 항목을 최신 2026-03-26/27 항목 뒤로 다시 정렬해 흐름을 복구했다.
## 2026-03-27 v0.1.51
- 관리자 확인은 편집 화면으로 이동하는 것보다 관리 페이지 안에서 닫고 돌아올 수 있는 미리보기 모달이 더 적합하다고 판단했다.
- 템플릿 등록 요청은 실제로는 배치 상태보다 제목 식별성이 더 중요하므로, `보드 비움` 조건은 제거하고 제목 직접 입력 중심으로 단순화하기로 결정했다.
@@ -59,79 +64,6 @@
- 관리자 기본 아이템은 업로드 시점에만 이름을 정할 수 있으면 운영 중 수정이 어려우므로, 목록에서 직접 이름을 바꾸고 저장할 수 있게 하기로 결정했다.
- 게임별 티어표 목록도 식별성이 중요하므로, 사용자가 편집 시 별도 썸네일을 지정할 수 있게 하고 목록 카드에서는 게임 카드와 비슷한 상단 썸네일 구조를 사용하기로 결정했다.
## 2026-03-19
- 초기 저장소는 빠른 구현을 위해 `lowdb(JSON 파일)`를 채택했다.
- 인증은 JWT 대신 서버 세션(`express-session` + `session-file-store`)을 사용했다.
- 업로드 파일은 외부 스토리지 없이 로컬 디스크(`backend/uploads/`)에 저장하기로 했다.
## 2026-03-19 v0.1.3
- 배포 환경 호환성을 위해 프런트엔드의 API 기준 주소를 환경변수(`VITE_API_ORIGIN`)로 통합했다.
- NAS/리버스 프록시 환경을 고려해 CORS 및 세션 쿠키 옵션을 환경변수 기반으로 전환했다.
- 파일명 깨짐과 URL 이식성 문제를 줄이기 위해 업로드 파일명을 ASCII 기반으로 생성하도록 변경했다.
- 게임 이미지 경로는 저장 시 상대 경로(`/uploads/...`)를 유지하는 방향으로 정리했다.
- 현재 단계에서는 구조 변경 비용을 고려해 DB를 유지하되, 운영/확장성 요구가 커지기 전 RDB 이관 판단이 필요하다고 기록했다.
## 2026-03-19 v0.1.4
- 운영 편의성과 NAS 환경에서의 데이터 조회 필요성 때문에 저장소를 MariaDB(MySQL 호환) 기준으로 전환했다.
- 관리자 지정 아이템과 사용자 커스텀 이미지는 책임과 수명 주기가 다르므로 별도 테이블(`game_items`, `custom_items`)로 분리했다.
- 작성자 식별성을 위해 공개 티어표에 닉네임을 표시하고, 프로필에서 닉네임을 수정할 수 있게 했다.
- 아바타 업로드는 즉시 반영보다 “선택 후 저장” 흐름이 맞다고 판단해 미리보기와 실제 저장을 분리했다.
- 관리자 페이지는 게임 선택 후 상세 관리가 열리는 단계형 흐름으로 바꾸는 것이 실사용에 더 안전하다고 결정했다.
## 2026-03-19 v0.1.5
- 로컬 개발과 운영 환경의 차이를 줄이기 위해 기본 로컬 개발 DB도 MariaDB로 고정했다.
- 로컬 실행 편의를 위해 `docker-compose.yml``mariadb``phpMyAdmin` 서비스를 추가했다.
- 백엔드 기본 `dev/start/migrate` 스크립트는 로컬 MariaDB 기준 값으로 정리하고, lowdb는 예외용 fallback 스크립트로만 남겼다.
## 2026-03-19 v0.1.6
- 저장소 운영 규칙을 정리하면서 Git 작성자 정보는 프로젝트 기준 계정으로 통일하고, 커밋 메시지는 한국어로 남기기로 결정했다.
## 2026-03-19 v0.1.7
- 관리자 페이지는 여러 작업을 동시에 나열하는 구조보다 “하나의 작업 모드를 선택하고 그 작업에 집중하는 구조”가 더 적합하다고 판단해 단계형 UI로 전환했다.
- 관리자에게는 생성뿐 아니라 삭제 책임도 필요하므로 게임 삭제와 아이템 삭제 기능을 추가하기로 결정했다.
- 아이템 삭제는 단순 파일/레코드 삭제만으로 끝내면 안 되고, 기존 티어표 데이터의 참조까지 함께 정리해야 한다고 결정했다.
## 2026-03-19 v0.1.8
- 관리자 업로드 작업은 선택 즉시 결과를 예측할 수 있어야 하므로, 썸네일과 아이템 모두 “파일 선택 → 미리보기 → 실제 업로드” 흐름으로 보강했다.
- 게임 썸네일은 대표 이미지 성격이 강하므로 16:9 비율로, 아이템은 캐릭터/오브젝트 단위 식별이 중요하므로 1:1 비율로 보는 방향을 채택했다.
- 현재 `db.json`과 lowdb 관련 코드는 기본 운영 런타임이 아니라 마이그레이션/예외 fallback 성격임을 분명히 정리했다.
## 2026-03-19 v0.1.9
- 로컬과 운영 환경을 완전히 같은 DB 계층으로 맞추기 위해 lowdb fallback을 제거하고 MariaDB만 지원하는 코드베이스로 정리했다.
- 마이그레이션 종료 이후에는 레거시 JSON 저장소와 예외 실행 스크립트를 남겨두는 비용이 더 크다고 판단해 삭제하기로 결정했다.
## 2026-03-19 v0.1.10
- 관리자 업로드 작업은 "파일 선택 후 적용"이 더 정확하므로, 썸네일 버튼 문구와 활성화 조건을 그 흐름에 맞추기로 결정했다.
- 작은 화면에서 미리보기가 실제 작업 영역을 압박하지 않도록, 아이템 미리보기는 정사각형을 유지하되 최대 크기를 제한하는 방향을 채택했다.
- 파일 입력은 업로드 성공 후와 게임 전환 시 초기화해 같은 파일 재선택이 막히지 않도록 정리했다.
## 2026-03-19 v0.1.11
- 관리자 화면은 좌우 여백이 크게 남는 구조보다, 상단 2열 작업 카드와 하단 목록 영역으로 나누는 편이 더 안정적이라고 판단해 레이아웃을 재정리했다.
- 게임 목록에 없는 주제로도 바로 작업할 수 있도록, 시스템 전용 `freeform` 게임을 내부적으로 유지하고 홈 화면에서는 `직접 티어표 만들기` 카드로 노출하기로 결정했다.
- 게임 제안은 현재 운영 흐름과 맞지 않아 사용자 진입점과 프런트 API에서 제거하고, 대신 관리자에게는 사용자 커스텀 아이템 검토 기능을 제공하기로 했다.
## 2026-03-19 v0.1.12
- 앱 전체 배경은 화면 폭 전체를 사용하고, 개별 콘텐츠만 필요한 만큼 정렬하는 방향이 더 자연스럽다고 판단해 전역 최대 폭 제한을 제거했다.
- 비로그인 사용자가 새 티어표를 편집하다 저장 단계에서 막히는 경험은 손실 위험이 크므로, 작성 시작 자체를 로그인 사용자로 제한하고 공개 티어표는 읽기 전용으로 보여주기로 결정했다.
- 커스텀 이미지 업로드는 단일 파일 선택만으로는 불편하므로, 다중 선택과 드래그 앤 드롭을 기본 흐름으로 보강했다.
- 관리자에게는 게임 관리뿐 아니라 회원 관리 책임도 필요하므로, 회원 목록 조회/수정/삭제 기능을 추가하기로 결정했다.
## 2026-03-19 v0.1.13
- 관리자 페이지는 기능 수가 늘어난 만큼 게임, 아이템, 회원 관리 탭으로 나누는 편이 더 안전하다고 판단했다.
- 사용자 커스텀 아이템은 수량이 빠르게 커질 수 있으므로, 검색과 페이지네이션을 기본 제공하는 관리 화면으로 보는 방향을 채택했다.
- 메일 기반 복구가 없으므로, 관리자가 회원 비밀번호를 직접 초기화할 수 있는 기능을 제공하기로 결정했다.
- 티어표 구조는 게임마다 다르므로, 기본 5단은 시작 템플릿일 뿐 사용자가 행을 직접 추가/삭제할 수 있어야 한다고 결정했다.
## 2026-03-19 v0.1.17
- 작성자가 자기 티어표를 직접 삭제할 수 있어야 관리 흐름이 완결되므로, `내 티어표` 화면에 삭제 기능을 추가하기로 결정했다.
- 사용자 커스텀 이미지는 서버 용량을 계속 차지하므로, 참조가 하나도 남지 않은 이미지(미사용 항목)를 식별하고 관리자 화면에서 개별/일괄 정리할 수 있도록 하기로 결정했다.
## 2026-03-19 v0.1.19
- 티어표 공개 여부는 운영 기준상 대부분 공개 공유가 목적이므로, 신규 작성 시 기본값을 `공개 ON`으로 두기로 결정했다.
- 에디터에서 저장 관련 행동은 좌우로 역할을 나눠 `다운로드/삭제``공개/저장`으로 묶는 편이 더 빠르게 인지된다고 판단했다.
- 공개 목록과 내 목록에서 제목만으로는 식별이 어려우므로, 제목 옆에 작성자 아바타와 표시명을 함께 노출하기로 결정했다.
- 티어표 카드 메타 정보는 저장 시각과 업데이트 시각을 동시에 노출하는 대신, 최종 업데이트 시각만 남겨 단순화하기로 결정했다.
## 2026-03-26 v0.1.21
- 목록 썸네일 fallback 문자는 닉네임보다 계정 기준이 더 일관되므로, 아바타 이미지가 없을 때는 계정명 첫 글자를 사용하기로 결정했다.
- 저장 성공은 화면 이동보다 현 위치 유지가 더 중요하므로, 편집을 계속할 수 있는 확인형 모달로 피드백을 제공하기로 결정했다.
@@ -196,3 +128,76 @@
## 2026-03-26 v0.1.37
- 운영 포트 충돌을 피하기 위해 프로덕션 외부 포트는 `frontend=18080`, `phpMyAdmin=18081`로 고정하고, 리버스 프록시 문서도 그 기준으로 맞추기로 했다.
- 인증 장애 원인을 찾기 위한 디버그 로그는 문제 해결 후 제거하고, 실제 운영에는 세션 저장 보강과 프록시 헤더 설정만 유지하는 편이 낫다고 판단했다.
## 2026-03-19 v0.1.19
- 티어표 공개 여부는 운영 기준상 대부분 공개 공유가 목적이므로, 신규 작성 시 기본값을 `공개 ON`으로 두기로 결정했다.
- 에디터에서 저장 관련 행동은 좌우로 역할을 나눠 `다운로드/삭제``공개/저장`으로 묶는 편이 더 빠르게 인지된다고 판단했다.
- 공개 목록과 내 목록에서 제목만으로는 식별이 어려우므로, 제목 옆에 작성자 아바타와 표시명을 함께 노출하기로 결정했다.
- 티어표 카드 메타 정보는 저장 시각과 업데이트 시각을 동시에 노출하는 대신, 최종 업데이트 시각만 남겨 단순화하기로 결정했다.
## 2026-03-19 v0.1.17
- 작성자가 자기 티어표를 직접 삭제할 수 있어야 관리 흐름이 완결되므로, `내 티어표` 화면에 삭제 기능을 추가하기로 결정했다.
- 사용자 커스텀 이미지는 서버 용량을 계속 차지하므로, 참조가 하나도 남지 않은 이미지(미사용 항목)를 식별하고 관리자 화면에서 개별/일괄 정리할 수 있도록 하기로 결정했다.
## 2026-03-19 v0.1.13
- 관리자 페이지는 기능 수가 늘어난 만큼 게임, 아이템, 회원 관리 탭으로 나누는 편이 더 안전하다고 판단했다.
- 사용자 커스텀 아이템은 수량이 빠르게 커질 수 있으므로, 검색과 페이지네이션을 기본 제공하는 관리 화면으로 보는 방향을 채택했다.
- 메일 기반 복구가 없으므로, 관리자가 회원 비밀번호를 직접 초기화할 수 있는 기능을 제공하기로 결정했다.
- 티어표 구조는 게임마다 다르므로, 기본 5단은 시작 템플릿일 뿐 사용자가 행을 직접 추가/삭제할 수 있어야 한다고 결정했다.
## 2026-03-19 v0.1.12
- 앱 전체 배경은 화면 폭 전체를 사용하고, 개별 콘텐츠만 필요한 만큼 정렬하는 방향이 더 자연스럽다고 판단해 전역 최대 폭 제한을 제거했다.
- 비로그인 사용자가 새 티어표를 편집하다 저장 단계에서 막히는 경험은 손실 위험이 크므로, 작성 시작 자체를 로그인 사용자로 제한하고 공개 티어표는 읽기 전용으로 보여주기로 결정했다.
- 커스텀 이미지 업로드는 단일 파일 선택만으로는 불편하므로, 다중 선택과 드래그 앤 드롭을 기본 흐름으로 보강했다.
- 관리자에게는 게임 관리뿐 아니라 회원 관리 책임도 필요하므로, 회원 목록 조회/수정/삭제 기능을 추가하기로 결정했다.
## 2026-03-19 v0.1.11
- 관리자 화면은 좌우 여백이 크게 남는 구조보다, 상단 2열 작업 카드와 하단 목록 영역으로 나누는 편이 더 안정적이라고 판단해 레이아웃을 재정리했다.
- 게임 목록에 없는 주제로도 바로 작업할 수 있도록, 시스템 전용 `freeform` 게임을 내부적으로 유지하고 홈 화면에서는 `직접 티어표 만들기` 카드로 노출하기로 결정했다.
- 게임 제안은 현재 운영 흐름과 맞지 않아 사용자 진입점과 프런트 API에서 제거하고, 대신 관리자에게는 사용자 커스텀 아이템 검토 기능을 제공하기로 했다.
## 2026-03-19 v0.1.10
- 관리자 업로드 작업은 "파일 선택 후 적용"이 더 정확하므로, 썸네일 버튼 문구와 활성화 조건을 그 흐름에 맞추기로 결정했다.
- 작은 화면에서 미리보기가 실제 작업 영역을 압박하지 않도록, 아이템 미리보기는 정사각형을 유지하되 최대 크기를 제한하는 방향을 채택했다.
- 파일 입력은 업로드 성공 후와 게임 전환 시 초기화해 같은 파일 재선택이 막히지 않도록 정리했다.
## 2026-03-19 v0.1.9
- 로컬과 운영 환경을 완전히 같은 DB 계층으로 맞추기 위해 lowdb fallback을 제거하고 MariaDB만 지원하는 코드베이스로 정리했다.
- 마이그레이션 종료 이후에는 레거시 JSON 저장소와 예외 실행 스크립트를 남겨두는 비용이 더 크다고 판단해 삭제하기로 결정했다.
## 2026-03-19 v0.1.8
- 관리자 업로드 작업은 선택 즉시 결과를 예측할 수 있어야 하므로, 썸네일과 아이템 모두 “파일 선택 → 미리보기 → 실제 업로드” 흐름으로 보강했다.
- 게임 썸네일은 대표 이미지 성격이 강하므로 16:9 비율로, 아이템은 캐릭터/오브젝트 단위 식별이 중요하므로 1:1 비율로 보는 방향을 채택했다.
- 현재 `db.json`과 lowdb 관련 코드는 기본 운영 런타임이 아니라 마이그레이션/예외 fallback 성격임을 분명히 정리했다.
## 2026-03-19 v0.1.7
- 관리자 페이지는 여러 작업을 동시에 나열하는 구조보다 “하나의 작업 모드를 선택하고 그 작업에 집중하는 구조”가 더 적합하다고 판단해 단계형 UI로 전환했다.
- 관리자에게는 생성뿐 아니라 삭제 책임도 필요하므로 게임 삭제와 아이템 삭제 기능을 추가하기로 결정했다.
- 아이템 삭제는 단순 파일/레코드 삭제만으로 끝내면 안 되고, 기존 티어표 데이터의 참조까지 함께 정리해야 한다고 결정했다.
## 2026-03-19 v0.1.6
- 저장소 운영 규칙을 정리하면서 Git 작성자 정보는 프로젝트 기준 계정으로 통일하고, 커밋 메시지는 한국어로 남기기로 결정했다.
## 2026-03-19 v0.1.5
- 로컬 개발과 운영 환경의 차이를 줄이기 위해 기본 로컬 개발 DB도 MariaDB로 고정했다.
- 로컬 실행 편의를 위해 `docker-compose.yml``mariadb``phpMyAdmin` 서비스를 추가했다.
- 백엔드 기본 `dev/start/migrate` 스크립트는 로컬 MariaDB 기준 값으로 정리하고, lowdb는 예외용 fallback 스크립트로만 남겼다.
## 2026-03-19 v0.1.4
- 운영 편의성과 NAS 환경에서의 데이터 조회 필요성 때문에 저장소를 MariaDB(MySQL 호환) 기준으로 전환했다.
- 관리자 지정 아이템과 사용자 커스텀 이미지는 책임과 수명 주기가 다르므로 별도 테이블(`game_items`, `custom_items`)로 분리했다.
- 작성자 식별성을 위해 공개 티어표에 닉네임을 표시하고, 프로필에서 닉네임을 수정할 수 있게 했다.
- 아바타 업로드는 즉시 반영보다 “선택 후 저장” 흐름이 맞다고 판단해 미리보기와 실제 저장을 분리했다.
- 관리자 페이지는 게임 선택 후 상세 관리가 열리는 단계형 흐름으로 바꾸는 것이 실사용에 더 안전하다고 결정했다.
## 2026-03-19 v0.1.3
- 배포 환경 호환성을 위해 프런트엔드의 API 기준 주소를 환경변수(`VITE_API_ORIGIN`)로 통합했다.
- NAS/리버스 프록시 환경을 고려해 CORS 및 세션 쿠키 옵션을 환경변수 기반으로 전환했다.
- 파일명 깨짐과 URL 이식성 문제를 줄이기 위해 업로드 파일명을 ASCII 기반으로 생성하도록 변경했다.
- 게임 이미지 경로는 저장 시 상대 경로(`/uploads/...`)를 유지하는 방향으로 정리했다.
- 현재 단계에서는 구조 변경 비용을 고려해 DB를 유지하되, 운영/확장성 요구가 커지기 전 RDB 이관 판단이 필요하다고 기록했다.
## 2026-03-19
- 초기 저장소는 빠른 구현을 위해 `lowdb(JSON 파일)`를 채택했다.
- 인증은 JWT 대신 서버 세션(`express-session` + `session-file-store`)을 사용했다.
- 업로드 파일은 외부 스토리지 없이 로컬 디스크(`backend/uploads/`)에 저장하기로 했다.

View File

@@ -52,6 +52,7 @@
- `gameId`: string
- `title`: string
- `thumbnailSrc`: string
- 사용자가 직접 지정하지 않으면 저장 시 티어표 대표 아이템 이미지로 자동 채운다.
- `description`: string
- `isPublic`: boolean
- `groups`: `{ id, name, itemIds[] }[]`
@@ -124,7 +125,7 @@
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 모달 미리보기로 연다.
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
- `티어표 관리` 탭의 추가 아이템은 작은 그리드 카드로 표시하고, 클릭 시 `기존 템플릿에 추가 / 새 템플릿 만들기` 모달을 통해 목적지를 선택한다.
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다.
@@ -153,6 +154,7 @@
- 신규 티어표를 막 저장한 직후에도, 템플릿 요청은 새로 발급된 실제 티어표 ID를 기준으로 이어서 처리한다.
- 기존 게임 티어표는 커스텀 아이템이 포함되어 있으면 `템플릿 업데이트 요청`을 보낼 수 있다.
- 티어표는 편집 화면에서 16:9 썸네일 이미지를 별도로 선택해 저장할 수 있고, 목록 카드에서는 그 이미지를 상단 대표 썸네일로 사용한다.
- 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다.
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.

View File

@@ -4,7 +4,7 @@
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
- 티어표 썸네일은 현재 업로드/교체만 지원하므로, 필요하면 자동 썸네일 추출이나 업로드 이미지 크롭 UX 추가 검토한다.
- 티어표 썸네일은 이제 대표 아이템 기반 자동 생성까지 지원하므로, 필요하면 업로드 이미지 크롭 UX나 자동 후보 선택 개선을 추가 검토한다.
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
- 템플릿 등록/업데이트 요청은 현재 승인/반려만 지원하므로, 필요하면 관리자 코멘트나 사용자용 요청 상태 이력 화면을 추가 검토한다.
- 관리자 티어표 관리의 템플릿 생성은 현재 `freeform`만 직접 지원하므로, 필요하면 일반 게임 티어표의 전체 아이템을 복제한 파생 템플릿 생성 UX도 검토한다.

View File

@@ -1,5 +1,10 @@
# 업데이트 로그
## 2026-03-27 v0.1.52
- **관리자 완성본 프리뷰 전용화**: 관리자 모달의 완성본 확인은 이제 전용 preview 모드로 열려 전역 헤더와 편집/탐색 UI 없이 보드만 깔끔하게 확인할 수 있도록 정리
- **티어표 기본 썸네일 자동 생성**: 사용자가 별도 썸네일을 지정하지 않아도 저장 시 티어표에 포함된 아이템 중 대표 이미지를 골라 기본 썸네일을 자동으로 채우도록 보강
- **이력 문서 날짜순 재정리**: `docs/history.md`를 날짜 역순 기준으로 다시 정렬해 오래된 2026-03-19 항목이 중간에 끼어 보이던 흐름을 바로잡음
## 2026-03-27 v0.1.51
- **관리자 티어표 미리보기 모달 추가**: 템플릿 요청 관리와 전체 티어표 관리에서 `원본 보기 / 완성본 보기`를 눌러도 관리자 화면을 벗어나지 않도록, 확인용 미리보기를 모달 iframe으로 열도록 변경
- **템플릿 등록 요청 조건 단순화**: freeform 템플릿 등록 요청은 더 이상 `보드 비움`을 요구하지 않고, `제목 직접 입력 + 커스텀 아이템 존재` 조건 중심으로 단순화

View File

@@ -10,6 +10,7 @@ const router = useRouter()
const auth = useAuthStore()
const { toasts, dismissToast } = useToast()
const isAdmin = computed(() => !!auth.user?.isAdmin)
const isPreviewMode = computed(() => route.query.preview === '1')
const avatarUrl = computed(() => {
if (!auth.user?.avatarSrc) return ''
return toApiUrl(auth.user.avatarSrc)
@@ -57,7 +58,7 @@ async function logout() {
<template>
<div class="app-shell">
<header class="app-header">
<header v-if="!isPreviewMode" class="app-header">
<div class="brand" @click="$router.push('/')">
<span class="brand__title">Tier Maker</span>
<span class="brand__sub">by zenn</span>
@@ -81,10 +82,10 @@ async function logout() {
</div>
</nav>
</header>
<main class="app-main">
<main class="app-main" :class="{ 'app-main--preview': isPreviewMode }">
<RouterView />
</main>
<div class="toastStack" aria-live="polite" aria-atomic="true">
<div class="toastStack" :class="{ 'toastStack--preview': isPreviewMode }" aria-live="polite" aria-atomic="true">
<div v-for="item in toasts" :key="item.id" class="toast" :class="[`toast--${item.type}`, { 'toast--closing': item.isClosing }]">
<div class="toast__body">
<div class="toast__message">{{ item.message }}</div>
@@ -158,6 +159,10 @@ async function logout() {
width: 100%;
box-sizing: border-box;
}
.app-main--preview {
padding: 0;
min-height: 100vh;
}
.user {
position: relative;
@@ -219,6 +224,9 @@ async function logout() {
gap: 10px;
width: min(360px, calc(100vw - 24px));
}
.toastStack--preview {
top: 12px;
}
.toast {
display: flex;
gap: 12px;

View File

@@ -662,7 +662,7 @@ function closePreviewModal() {
function previewTierListUrl(tierList) {
if (!tierList?.gameId || !tierList?.id) return ''
return `/editor/${tierList.gameId}/${tierList.id}`
return `/editor/${tierList.gameId}/${tierList.id}?preview=1`
}
function openTierListImportModal(tierList, items) {
@@ -1274,10 +1274,7 @@ async function saveFeaturedOrder() {
<div v-if="previewModalOpen" class="modalOverlay" @click.self="closePreviewModal">
<div class="modalCard modalCard--preview" role="dialog" aria-modal="true">
<div class="modalCard__titleRow">
<div>
<div class="modalCard__title">{{ previewTierList?.title || '티어표 미리보기' }}</div>
<div class="modalCard__desc">관리 화면을 벗어나지 않고 완성본만 확인할 있어요.</div>
</div>
<div class="modalCard__title">{{ previewTierList?.title || '티어표 미리보기' }}</div>
<button class="btn btn--ghost btn--small" @click="closePreviewModal">닫기</button>
</div>
<iframe

View File

@@ -14,6 +14,7 @@ const auth = useAuthStore()
const toast = useToast()
const gameId = computed(() => route.params.gameId)
const tierListId = computed(() => route.params.tierListId)
const previewMode = computed(() => route.query.preview === '1')
const gameName = ref('')
const groups = ref([
@@ -602,6 +603,32 @@ onUnmounted(() => {
</script>
<template>
<section v-if="previewMode" class="previewOnly">
<div class="previewOnly__sheet">
<div class="previewOnly__title">{{ effectiveTitle }}</div>
<div v-if="description" class="previewOnly__description">{{ description }}</div>
<div class="previewOnly__rows">
<div v-for="g in groups" :key="g.id" class="previewOnly__row">
<div class="previewOnly__label">{{ g.name }}</div>
<div class="previewOnly__drop">
<div v-for="id in g.itemIds" :key="id" class="previewOnly__cell">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
</div>
</div>
</div>
</div>
<div v-if="pool.length" class="previewOnly__pool">
<div class="previewOnly__poolTitle">남은 아이템</div>
<div class="previewOnly__poolGrid">
<div v-for="id in pool" :key="id" class="previewOnly__poolItem">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
</div>
</div>
</div>
</div>
</section>
<template v-else>
<section class="head">
<div class="heroCard">
<div class="heroCard__main">
@@ -824,6 +851,7 @@ onUnmounted(() => {
<button v-if="canEdit" class="btn btn--ghost" @click="openFile">파일 선택</button>
</div>
</section>
</template>
</template>
<style scoped>
@@ -832,6 +860,82 @@ onUnmounted(() => {
gap: 14px;
padding: 6px 2px 14px;
}
.previewOnly {
min-height: 100vh;
padding: 20px;
box-sizing: border-box;
background:
radial-gradient(circle at top, rgba(96, 165, 250, 0.14), transparent 38%),
rgba(11, 18, 32, 0.98);
}
.previewOnly__sheet {
display: grid;
gap: 16px;
width: 100%;
max-width: 1280px;
margin: 0 auto;
}
.previewOnly__title {
font-size: 28px;
font-weight: 900;
letter-spacing: -0.03em;
}
.previewOnly__description {
margin-top: -8px;
font-size: 14px;
line-height: 1.6;
opacity: 0.76;
}
.previewOnly__rows {
display: grid;
gap: 10px;
}
.previewOnly__row {
display: grid;
grid-template-columns: 180px 1fr;
gap: 10px;
}
.previewOnly__label {
display: grid;
place-items: center;
padding: 10px 8px;
text-align: center;
font-weight: 900;
border-radius: 14px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.previewOnly__drop {
border-radius: 14px;
background: rgba(0, 0, 0, 0.18);
border: 1px solid rgba(255, 255, 255, 0.1);
min-height: calc(var(--thumb-size, 80px) + 24px);
padding: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-content: flex-start;
}
.previewOnly__cell {
display: inline-flex;
}
.previewOnly__pool {
display: grid;
gap: 10px;
padding-top: 8px;
}
.previewOnly__poolTitle {
font-weight: 900;
opacity: 0.82;
}
.previewOnly__poolGrid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.previewOnly__poolItem {
display: inline-flex;
}
.heroCard {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 360px);
@@ -1424,6 +1528,9 @@ onUnmounted(() => {
border-radius: 14px;
}
@media (max-width: 980px) {
.previewOnly__row {
grid-template-columns: 140px 1fr;
}
.heroCard {
grid-template-columns: 1fr;
}
@@ -1461,4 +1568,12 @@ onUnmounted(() => {
width: 100%;
}
}
@media (max-width: 720px) {
.previewOnly {
padding: 14px;
}
.previewOnly__row {
grid-template-columns: 1fr;
}
}
</style>