Compare commits

...

10 Commits

11 changed files with 331 additions and 142 deletions

View File

@@ -1,5 +1,40 @@
# 의사결정 이력
## 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
- 관리자 공용 게임 선택 모달은 단순 검색만 제공하기보다, 현재 문맥에서 이미 선택 불가능한 대상을 `이미 추가됨`으로 명시하고 막아 주는 편이 운영 실수를 줄이는 데 더 효과적이라고 정리했다.
- 프로젝트 표기는 관리자 헤더 상단보다 사이드바 최하단의 작은 카피라이트 문구로 빼는 편이 정보 밀도를 덜 방해한다고 판단했다.
## 2026-04-02 v1.3.73
- 게임 선택이 여러 관리자 화면에 퍼지기 시작한 시점에서는 일부 화면만 셀렉트나 내부 리스트를 유지하기보다, 공용 검색 모달 하나로 통일하는 편이 장기적으로 더 일관되고 확장에 강하다고 정리했다.
- 검색 입력과 실행 버튼은 세로로 같은 문법으로 쌓기보다, 입력은 입력끼리 실행은 액션으로 읽히게 한 줄 배치로 적당히 구분해주는 편이 운영 화면에서 덜 답답하다고 판단했다.
## 2026-04-02 v1.3.72
- 라우트 복원용 watcher가 composable 반환값 초기화보다 먼저 돌 수 있는 구간에서는 직접 함수를 즉시 호출하기보다, 초기화 완료 뒤 실행되도록 한 템포 미루는 편이 안전하다고 정리했다.

View File

@@ -1,6 +1,20 @@
# 할 일 및 이슈
## 단기 확인
- 프리뷰 완성본 하단 메타는 새로 붙였으므로, 작성자/저장 시각이 공개 열람 화면과 이미지 다운로드 결과 기준에서 모두 자연스럽게 읽히는지 한 번 더 QA한다.
- 관리자 템플릿 요청 카드는 썸네일 클릭이 새창 열기 역할로 바뀌었으므로, 썸네일 클릭과 `확인하기` 액션이 서로 헷갈리지 않는지 한 번 더 QA한다.
- 티어표 만들기 화면의 `공유하기`는 저장된 티어표에서만 노출되므로, 저장 직후/수정 중/복사본/읽기 전용 상태 각각에서 노출 조건과 클립보드 복사가 자연스러운지 한 번 더 QA한다.
- 우측 카피라이트는 이제 공통 오른쪽 레일 footer이므로, 관리자 화면뿐 아니라 홈/프로필 등 오른쪽 사이드가 보이는 화면에서도 같은 최하단 위치에 유지되는지 한 번 더 QA한다.
- 왼쪽 레일 축소 상태의 하단 액션 아이콘은 홈과 게임 허브에서 서로 다른 아이콘을 쓰도록 나눴으므로, 실제로 두 문맥이 한눈에 구분되는지 한 번 더 QA한다.
- 왼쪽 레일 축소 상태 최하단의 `티어표 만들기` 아이콘 버튼은 새로 추가했으므로, 홈/게임 허브에서 실제로 같은 위치 감각으로 동작하는지 한 번 더 QA한다.
- 관리자 우측 카피라이트 문구는 사이드바 내부 최하단으로 다시 옮겼으므로, 실제 관리자 화면에서 스크롤/창 크기 변화에도 계속 보이는지 한 번 더 QA한다.
- 왼쪽 레일 축소 상태는 아이콘 줄 높이를 50px 기준으로 통일했으므로, 실제 데스크톱에서 아바타/검색/메뉴 아이콘이 시각적으로 같은 리듬으로 보이는지 한 번 더 QA한다.
- 왼쪽 레일 검색은 이제 항상 게임 검색으로 홈으로 이동하므로, 홈이 아닌 화면에서 사이드 검색 후 게임 목록 결과로 자연스럽게 이동하는지 한 번 더 QA한다.
- 앱 왼쪽 사이드 레일은 접힘 상태 레이아웃을 다시 손봤으므로, 데스크톱에서 접기/펼치기 반복 시 아바타 영역 높이, 아이콘 중앙 정렬, 검색 버튼 간격, 네비게이션 히트 영역이 모두 자연스러운지 한 번 더 QA한다.
- 관리자 우측 사이드바 하단 카피라이트 링크는 새 창 외부 링크로 바꿨으므로, 실제 클릭 시 `zenn.town` 연결과 hover 대비가 자연스러운지 한 번 더 QA한다.
- 관리자 아이템 상세 모달은 공통 패딩 예외 처리를 다시 넣었으므로, 대형 상세 모달과 일반 게임 선택 모달이 각각 기대한 크기로 보이는지 한 번 더 QA한다.
- 아이템 관리 모달의 공용 게임 선택기에서는 이미 연결된 게임이 비활성화되므로, 실제 운영 데이터에서 중복 연결 방지와 `이미 추가됨` 표시가 기대대로 읽히는지 한 번 더 QA한다.
- 공용 게임 선택 모달을 아이템 관리 모달에도 붙였으므로, 관리자 `게임 관리 / 전체 티어표 관리 / 아이템 관리` 세 흐름에서 같은 검색/선택 UX가 자연스럽게 느껴지는지 한 번 더 QA한다.
- 관리자 `/admin/games?gameId=...` 직접 진입과 새로고침에서 선택 게임이 정상 복원되고 콘솔 오류가 없는지 한 번 더 QA한다.
- 공용 `게임 선택` 검색 모달은 새로 붙였으므로, 게임 관리 선택/전체 티어표 관리 필터 양쪽에서 검색어 입력, 선택, 해제, 뒤로가기 흐름을 한 번 더 QA한다.
- 관리자 `전체 티어표 관리`의 게임 필터와 관리 모달은 새로 붙였으므로, 실제 운영 데이터에서 필터 전환 후 공개/비공개 집계, 제목 수정, 비공개 전환, 삭제 흐름이 자연스럽게 이어지는지 한 번 더 QA한다.
@@ -35,16 +49,10 @@
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
- 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다.
- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다.
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
- 관리자 템플릿 요청 카드와 전체 티어표 카드가 같은 문법으로 맞춰졌으므로, 이후에는 라이트모드까지 포함해 두 카드의 썸네일 높이·입력창 밀도·아이템 그리드를 한 번 더 비교 QA한다.
- 템플릿 요청 저장 흐름은 저장된 티어표 기준으로 바뀌었으므로, 이후 실제 데이터로 빈 제목 저장 시 자동 생성 제목·요청 버튼 노출 시점·관리자 요청 미리보기 밀도를 한 번 더 비교 QA한다.

View File

@@ -1,5 +1,43 @@
# 업데이트 로그
## 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
- 아이템 관리 상세에서 템플릿 추가 대상 게임을 고를 때, 이미 해당 이미지가 연결된 게임은 공용 게임 선택 모달에서 `이미 추가됨`으로 표시하고 비활성화해 중복 추가 실수를 미리 막도록 정리함.
- 관리자 우측 사이드바 최하단에는 작은 카피라이트 문구를 추가해, 헤더에 관리 정보만 남기고 프로젝트 표기는 하단에서 조용히 보이도록 정리함.
## 2026-04-02 v1.3.73
- 전체 티어표 관리 카드 썸네일은 `draggable="false"`로 바꿔, 미리보기 진입 시 브라우저 기본 이미지 드래그가 클릭을 방해하지 않도록 정리함.
- 관리자 사이드바의 검색 입력과 검색 버튼은 한 줄로 묶어, 입력/선택/실행 버튼이 모두 같은 크기의 세로 스택처럼 보이던 답답함을 조금 줄이고 역할 구분을 더 분명하게 함.
- 아이템 관리 상세 모달의 템플릿 추가 대상 선택도 내부 전용 게임 리스트 대신 공용 `게임 선택` 검색 모달을 쓰도록 바꿔, 향후 게임 수가 많아져도 같은 선택 문법으로 이어지게 정리함.
## 2026-04-02 v1.3.72
- 관리자 화면 초기화 중 `/admin/games?gameId=...` 경로를 즉시 처리하는 watcher가 `loadGame` 초기화보다 먼저 실행되어 브라우저 콘솔에 `Cannot access 'loadGame' before initialization` 오류가 나던 문제를 수정함.
- 게임 라우트 진입 시 실제 게임 로딩 호출은 컴포넌트 초기화가 끝난 뒤 microtask로 미뤄 실행하도록 바꿔, 첫 진입/새로고침에서도 게임 선택 복원 흐름이 안전하게 이어지게 정리함.

View File

@@ -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')
@@ -135,11 +138,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
})
@@ -391,11 +394,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)}` : '/')
}
@@ -444,7 +443,7 @@ 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">
@@ -468,6 +467,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>
@@ -513,12 +521,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 +629,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 +754,11 @@ function submitGlobalSearch() {
}
.rightRail__content {
flex: 0 0 auto;
flex: 1 1 auto;
min-height: 0;
overflow: visible;
display: flex;
flex-direction: column;
}
.ghostIcon {
@@ -953,21 +969,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,11 +995,16 @@ 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 {
@@ -988,14 +1012,24 @@ function submitGlobalSearch() {
}
.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 +1041,10 @@ function submitGlobalSearch() {
padding-top: 12px;
}
.leftRail__collapsedAction {
display: none;
}
.adminButton {
width: 100%;
display: inline-flex;
@@ -1031,6 +1069,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;
@@ -1145,13 +1206,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 +1654,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;
}

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="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

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="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

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">
<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)">
<img v-if="request.thumbnailSrc" class="tierAdminCard__thumb" :src="toApiUrl(request.thumbnailSrc)" :alt="request.sourceTierListTitle" />
<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">
@@ -97,17 +104,7 @@ 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)">
{{
@@ -142,7 +139,7 @@ const props = defineProps({
<div v-else class="tierAdminList">
<article v-for="tierList in props.adminTierLists" :key="tierList.id" class="tierAdminCard">
<button class="tierAdminCard__preview" type="button" @click="props.openAdminTierList(tierList)">
<img v-if="props.tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="props.tierListThumbUrl(tierList)" :alt="tierList.title" />
<img v-if="props.tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="props.tierListThumbUrl(tierList)" :alt="tierList.title" draggable="false" />
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
</button>

View File

@@ -16,8 +16,6 @@ export function useAdminCustomItems({
customItemModalDraftLabel,
customItemModalLabelSaving,
customItemModalTargetGameId,
customItemModalGameQuery,
customItemModalGameSort,
games,
selectedGameId,
refreshCustomItems,
@@ -62,8 +60,6 @@ export function useAdminCustomItems({
modalTargetCustomItem.value = item || null
customItemModalDraftLabel.value = item?.label || ''
customItemModalTargetGameId.value = ''
customItemModalGameQuery.value = ''
customItemModalGameSort.value = 'recent'
customItemModalOpen.value = true
pushCustomItemModalHistoryState()
}
@@ -75,8 +71,6 @@ export function useAdminCustomItems({
customItemModalDraftLabel.value = ''
customItemModalLabelSaving.value = false
customItemModalTargetGameId.value = ''
customItemModalGameQuery.value = ''
customItemModalGameSort.value = 'recent'
if (fromPopState) {
customItemModalHistoryActive.value = false

View File

@@ -46,8 +46,6 @@ const customItemLimit = ref(50)
const customItemTotal = ref(0)
const customItemFilter = ref('all')
const customItemModalTargetGameId = ref('')
const customItemModalGameQuery = ref('')
const customItemModalGameSort = ref('recent')
const adminTierLists = ref([])
const adminTierListQuery = ref('')
@@ -212,6 +210,7 @@ const filteredGamePickerGames = computed(() => {
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
})
})
const customItemTargetGame = computed(() => games.value.find((game) => game.id === customItemModalTargetGameId.value) || null)
const importModalItemCount = computed(() => importModalItems.value.length)
const activeTabTitle = computed(() => {
if (activeTab.value === 'featured') return '목록 관리'
@@ -490,7 +489,6 @@ watch(
customItemQuery.value = ''
customItemFilter.value = 'all'
customItemPage.value = 1
customItemModalGameQuery.value = ''
await refreshCustomItems()
return
}
@@ -622,21 +620,7 @@ const imageDiagnosticsCards = computed(() => {
const visibleLinkedGames = computed(() =>
(modalTargetCustomItem.value?.linkedGames || []).filter((game) => game?.id && game.id !== 'freeform')
)
const filteredCustomItemModalGames = computed(() => {
const query = customItemModalGameQuery.value.trim().toLowerCase()
const linkedIds = new Set(visibleLinkedGames.value.map((game) => game.id))
const list = games.value.filter((game) => {
if (!query) return true
return `${game.name || ''} ${game.id || ''}`.toLowerCase().includes(query)
})
return list.slice().sort((a, b) => {
const linkedDelta = Number(linkedIds.has(a.id)) - Number(linkedIds.has(b.id))
if (linkedDelta !== 0) return linkedDelta
if (customItemModalGameSort.value === 'oldest') return Number(a.createdAt || 0) - Number(b.createdAt || 0)
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
})
})
const linkedCustomItemGameIds = computed(() => new Set(visibleLinkedGames.value.map((game) => game.id).filter(Boolean)))
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
const imageStatsYearOptions = computed(() => {
@@ -1040,8 +1024,6 @@ const {
customItemModalDraftLabel,
customItemModalLabelSaving,
customItemModalTargetGameId,
customItemModalGameQuery,
customItemModalGameSort,
games,
selectedGameId,
refreshCustomItems,
@@ -1327,6 +1309,12 @@ async function chooseGameFromPicker(gameId) {
closeGamePickerModal()
return
}
if (gamePickerMode.value === 'custom-item-target') {
if (linkedCustomItemGameIds.value.has(gameId)) return
customItemModalTargetGameId.value = gameId
closeGamePickerModal()
return
}
await selectAdminGame(gameId)
closeGamePickerModal()
@@ -1963,32 +1951,16 @@ function userAvatarFallback(user) {
<div class="customItemModal__pickerHead">
<div class="customItemModal__pickerEyebrow">GAME PICKER</div>
<div class="customItemModal__pickerTitle">템플릿으로 추가할 게임</div>
</div>
<div class="adminSelectionCard">
<div class="adminSelectionCard__label">선택한 게임</div>
<div class="adminSelectionCard__title">{{ customItemTargetGame?.name || '아직 선택하지 않음' }}</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 btn--small customItemModal__createGameButton" type="button" @click="openGameCreateModal"> 템플릿 만들기</button>
</div>
<div class="customItemModal__pickerControls">
<input v-model="customItemModalGameQuery" class="input input--dense" placeholder="게임 이름, ID 검색" />
<select v-model="customItemModalGameSort" class="select">
<option value="recent">최신순</option>
<option value="oldest">오래된순</option>
</select>
</div>
<div class="customItemModal__gameList">
<button
v-for="game in filteredCustomItemModalGames"
:key="game.id"
type="button"
class="customItemModal__gameItem"
:class="{
'customItemModal__gameItem--active': customItemModalTargetGameId === game.id,
'customItemModal__gameItem--linked': visibleLinkedGames.some((entry) => entry.id === game.id),
}"
@click="customItemModalTargetGameId = game.id"
>
<span class="customItemModal__gameName">{{ game.name }}</span>
<span class="customItemModal__gameMeta">{{ game.id }}</span>
<span v-if="visibleLinkedGames.some((entry) => entry.id === game.id)" class="customItemModal__gameState">이미 포함됨</span>
</button>
</div>
</aside>
<div class="customItemModal__body">
<button class="customItemModal__close" type="button" @click="closeCustomItemModal">닫기</button>
@@ -2065,12 +2037,21 @@ function userAvatarFallback(user) {
v-for="game in filteredGamePickerGames"
:key="game.id"
class="adminGamePicker__item"
:class="{ 'adminGamePicker__item--active': gamePickerMode === 'tierlists-filter' ? adminTierListGameId === game.id : selectedGameId === game.id }"
:class="{
'adminGamePicker__item--active': gamePickerMode === 'tierlists-filter'
? adminTierListGameId === game.id
: gamePickerMode === 'custom-item-target'
? customItemModalTargetGameId === game.id
: selectedGameId === game.id,
'adminGamePicker__item--disabled': gamePickerMode === 'custom-item-target' && linkedCustomItemGameIds.has(game.id),
}"
type="button"
:disabled="gamePickerMode === 'custom-item-target' && linkedCustomItemGameIds.has(game.id)"
@click="chooseGameFromPicker(game.id)"
>
<span class="adminGamePicker__name">{{ game.name }}</span>
<span class="adminGamePicker__meta">{{ game.id }}</span>
<span v-if="gamePickerMode === 'custom-item-target' && linkedCustomItemGameIds.has(game.id)" class="adminGamePicker__state">이미 추가됨</span>
</button>
<div v-if="!filteredGamePickerGames.length" class="hint hint--tight">검색 결과가 없어요.</div>
</div>
@@ -2244,8 +2225,10 @@ function userAvatarFallback(user) {
<section v-else-if="activeTab === 'items'" class="adminSidebar__panel">
<div class="adminSidebar__label">Filters</div>
<div class="adminSidebar__group">
<input v-model="customItemQuery" class="input" placeholder="파일명, 라벨, 업로더 검색" @keydown.enter.prevent="submitCustomItemSearch" />
<button class="btn btn--ghost" @click="submitCustomItemSearch">검색</button>
<div class="adminSidebar__inlineRow">
<input v-model="customItemQuery" class="input" placeholder="파일명, 라벨, 업로더 검색" @keydown.enter.prevent="submitCustomItemSearch" />
<button class="btn btn--ghost" @click="submitCustomItemSearch">검색</button>
</div>
</div>
<div class="adminSidebar__group">
<select :value="customItemLimit" class="select" @change="changeCustomItemLimit(Number($event.target.value))">
@@ -2290,13 +2273,15 @@ function userAvatarFallback(user) {
<template v-if="tierlistsMode === 'requests'"></template>
<template v-else>
<div class="adminSidebar__group">
<input
v-model="adminTierListQuery"
class="input"
placeholder="제목, 작성자, 게임 이름 검색"
@keydown.enter.prevent="submitAdminTierListSearch"
/>
<button class="btn btn--ghost" @click="submitAdminTierListSearch">검색</button>
<div class="adminSidebar__inlineRow">
<input
v-model="adminTierListQuery"
class="input"
placeholder="제목, 작성자, 게임 이름 검색"
@keydown.enter.prevent="submitAdminTierListSearch"
/>
<button class="btn btn--ghost" @click="submitAdminTierListSearch">검색</button>
</div>
<button class="btn btn--ghost" @click="openGamePickerModal('tierlists-filter')">게임 선택</button>
<div v-if="adminTierListGameId" class="adminSelectionCard">
<div class="adminSelectionCard__label">필터된 게임</div>
@@ -2538,6 +2523,15 @@ function userAvatarFallback(user) {
display: grid;
gap: 10px;
}
.adminUiScope .adminSidebar__inlineRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.adminUiScope .adminSidebar__inlineRow .btn {
white-space: nowrap;
}
.adminUiScope .adminSidebar__group--monthPicker {
align-items: start;
}
@@ -2589,6 +2583,11 @@ function userAvatarFallback(user) {
border-color: rgba(77, 127, 233, 0.58);
background: rgba(77, 127, 233, 0.12);
}
.adminUiScope .adminGamePicker__item--disabled {
cursor: not-allowed;
opacity: 0.58;
border-style: dashed;
}
.adminUiScope .adminGamePicker__name {
font-size: 13px;
font-weight: 800;
@@ -2600,6 +2599,11 @@ function userAvatarFallback(user) {
overflow: hidden;
text-overflow: ellipsis;
}
.adminUiScope .adminGamePicker__state {
margin-top: 4px;
font-size: 11px;
color: var(--theme-text-faint);
}
.adminUiScope .gamePickerModalList {
margin-top: 14px;
display: grid;
@@ -3463,46 +3467,13 @@ function userAvatarFallback(user) {
font-size: 18px;
font-weight: 900;
}
.adminUiScope .customItemModal__pickerControls {
.adminUiScope .customItemModal__pickerActions {
display: grid;
gap: 10px;
}
.adminUiScope .customItemModal__gameList {
display: grid;
gap: 8px;
max-height: 440px;
overflow: auto;
}
.adminUiScope .customItemModal__createGameButton {
justify-self: start;
}
.adminUiScope .customItemModal__gameItem {
display: grid;
gap: 4px;
padding: 12px 13px;
border-radius: 16px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
text-align: left;
cursor: pointer;
}
.adminUiScope .customItemModal__gameItem--active {
border-color: rgba(96, 165, 250, 0.42);
background: rgba(96, 165, 250, 0.12);
}
.adminUiScope .customItemModal__gameItem--linked {
border-style: dashed;
}
.adminUiScope .customItemModal__gameName {
font-size: 13px;
font-weight: 800;
}
.adminUiScope .customItemModal__gameMeta,
.adminUiScope .customItemModal__gameState {
font-size: 11px;
color: var(--theme-text-soft);
}
.adminUiScope .customItemModal__body {
min-width: 0;
min-height: 0;
@@ -4320,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);

View File

@@ -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'
@@ -130,6 +131,12 @@ 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 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
@@ -712,6 +719,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
}
@@ -958,6 +991,10 @@ onUnmounted(() => {
</div>
</div>
</div>
<div class="previewOnly__footer">
<span>{{ effectiveAuthorName }}</span>
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
</div>
</div>
</section>
@@ -1324,6 +1361,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
@@ -1502,6 +1543,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 +2332,9 @@ onUnmounted(() => {
background: transparent;
color: var(--theme-text-muted);
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
@@ -2293,6 +2346,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;