Compare commits

..

19 Commits

Author SHA1 Message Date
9ad985f7c5 릴리스: v1.3.32 라이트 다크 모드 1차 도입 2026-04-01 15:25:21 +09:00
3b5e744130 릴리스: v1.3.31 관리자 게임 선택 리스트 CSS 반영 2026-04-01 15:16:06 +09:00
28cf4fdfa0 릴리스: v1.3.30 헤더 브랜딩과 테마 할 일 정리 2026-04-01 15:13:45 +09:00
cf96e931e9 릴리스: v1.3.29 가이드 진입점과 인증 초기화 안정화 2026-04-01 15:07:58 +09:00
3a64dc44c8 릴리스: v1.3.28 사용법 모달 기능 안내 확장 2026-04-01 15:02:49 +09:00
91e16ba415 릴리스: v1.3.27 사용법 모달 구조 추가 2026-04-01 14:52:50 +09:00
a550385ed8 릴리스: v1.3.26 오른쪽 광고 슬롯 규격 정리 2026-04-01 14:40:50 +09:00
5b53c73b56 릴리스: v1.3.25 관리자 게임 선택 UX와 세션 보안 보강 2026-04-01 14:23:04 +09:00
7952f2f289 릴리스: v1.3.24 게임 허브 티어표 그리드 정렬 2026-04-01 14:11:10 +09:00
b851100c89 릴리스: v1.3.23 내 티어표 그리드 열 수 정렬 2026-04-01 14:07:56 +09:00
e70e685a06 릴리스: v1.3.22 내 티어표 카드 폭과 광고 프레임 정리 2026-04-01 14:04:15 +09:00
09acebc2d5 릴리스: v1.3.21 내 티어표 카드 레이아웃을 게임 목록과 통일 2026-04-01 13:53:48 +09:00
e3391b5f07 릴리스: v1.3.20 내 티어표 카드 그리드 밀도 보정 2026-04-01 13:49:54 +09:00
22220494d6 릴리스: v1.3.19 관리자 이미지 최적화 기간 선택 레이아웃 보정 2026-04-01 13:45:45 +09:00
909ed72502 릴리스: v1.3.18 템플릿 요청 실패 보완과 이미지 최적화 기간 선택 개선 2026-04-01 13:32:25 +09:00
c352bf459f 릴리스: v1.3.17 티어 에디터 열 정렬과 삭제 확인 흐름 보정 2026-04-01 12:29:49 +09:00
730a87b923 릴리스: v1.3.16 티어 에디터 행열 삭제 액션과 열 제목 정렬 보정 2026-04-01 12:22:21 +09:00
e9a049241d 릴리스: v1.3.15 티어 에디터 열 헤더와 액션 아이콘 정리 2026-04-01 12:16:06 +09:00
0fec84de13 릴리스: v1.3.14 티어 에디터 행열 보드 확장 2026-04-01 12:07:17 +09:00
20 changed files with 1700 additions and 351 deletions

View File

@@ -26,6 +26,20 @@ const profileSchema = z.object({
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
})
function establishSession(req, user) {
return new Promise((resolve, reject) => {
req.session.regenerate((regenerateError) => {
if (regenerateError) return reject(regenerateError)
req.session.userId = user.id
req.session.isAdmin = !!user.isAdmin
req.session.save((saveError) => {
if (saveError) return reject(saveError)
resolve()
})
})
})
}
async function serializeUser(user) {
if (!user) return null
const primaryAdmin = await findPrimaryAdminUser()
@@ -56,12 +70,12 @@ router.post('/signup', async (req, res) => {
const isAdmin = (await countUsers()) === 0
const user = await createUser({ id: nanoid(), email, nickname: '', passwordHash, isAdmin })
req.session.userId = user.id
req.session.isAdmin = !!user.isAdmin
req.session.save(async (err) => {
if (err) return res.status(500).json({ error: 'session_save_failed' })
try {
await establishSession(req, user)
res.json(await serializeUser(user))
})
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
})
router.post('/login', async (req, res) => {
@@ -75,12 +89,12 @@ router.post('/login', async (req, res) => {
const ok = await bcrypt.compare(password, user.passwordHash)
if (!ok) return res.status(401).json({ error: 'invalid_credentials' })
req.session.userId = user.id
req.session.isAdmin = !!user.isAdmin
req.session.save(async (err) => {
if (err) return res.status(500).json({ error: 'session_save_failed' })
try {
await establishSession(req, user)
res.json(await serializeUser(user))
})
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
})
router.post('/logout', async (req, res) => {

View File

@@ -72,8 +72,8 @@ const templateRequestSchema = z.object({
z.object({
id: z.string().min(1),
name: z.string().min(1).max(16),
itemIds: z.array(z.string()),
})
itemIds: z.array(z.string()).optional().default([]),
}).passthrough()
),
boardItems: z.array(
z.object({
@@ -100,8 +100,8 @@ const tierListUpsertSchema = z.object({
z.object({
id: z.string().min(1),
name: z.string().min(1).max(16),
itemIds: z.array(z.string()),
})
itemIds: z.array(z.string()).optional().default([]),
}).passthrough()
),
pool: z.array(
z.object({

View File

@@ -1,24 +1,12 @@
# 할 일 및 이슈
## 즉시 확인 필요
- 최근 게임들은 S, A, B,C 같은 랭크 뿐만 아니라 가로 열도 나누어진형태의 티어표를 원함 (공격, 방어, 지원 등 각 파트별 랭크를 보고싶어함)
- 티어표에서 티어 추가(세로라인)를 행 추가 등의 단어로 변경. 열 추가 버튼도 표시. 열을 추가할경우 열에도 이름을 입력 가능. (1열만 있을 경우에는 삭제 불가, 열 제목 사용 안함)
- 드래그 아이콘의 위치를 이동시키거나, 제외하는 등(기능은 가능해야함) 적절한 판단을 통해 행 라벨의 너비가 큰편인데 조금 줄일 필요 있음. (실제로는 5~6자 이상 쓰는경우가 거의 없기 때문)
- 이미지 저장시 라벨 텍스트가 중앙 정렬이 아닌 살짝 상단으로 치우쳐진 상태라 이쁘지 않은데... 확인 필요함.
- 레거시 파일 정리 스크립트는 준비됐으므로, 운영 단계에서는 cron 등으로 주기 실행할지와 삭제 전 보관 기간을 함께 정한다.
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다.
- 이미지 최적화 기록은 월별 조회/비우기까지 지원하므로, 운영 단계에서는 보관 기간 정책과 자동 아카이브 기준을 정한다.
## 배포 전 작업
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
- MariaDB 접속 정보 `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`를 설정한다.
- HTTPS를 사용할 경우 `SESSION_COOKIE_SECURE=true`로 설정하고 리버스 프록시 헤더 전달을 확인한다.
- `backend/uploads/`, `backend/.sessions/`, MariaDB 백업 정책을 정한다.
- 로컬 docker compose와 NAS MariaDB 사이의 버전 차이가 크지 않도록 유지한다.
## 중기 개선
- 라이트모드/다크모드 1차 전환은 붙였으므로, 관리자 화면과 티어 에디터까지 세부 색상 균형을 더 정교하게 맞추는 후속 테마 보정 작업을 이어간다.
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
- 로그인/회원가입/관리자 비밀번호 초기화에 요청 횟수 제한을 추가한다.
- 업로드 파일은 MIME 타입뿐 아니라 파일 시그니처 기반 검증까지 확장한다.
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.

View File

@@ -1,5 +1,82 @@
# 업데이트 로그
## 2026-04-01 v1.3.32
- 전역 테마 변수와 로컬 저장 기반 테마 토글을 추가해, Settings 화면 오른쪽 사이드에서 라이트모드/다크모드를 전환하고 재방문 시 같은 테마를 유지할 수 있게 함.
- 앱 셸, 홈, 게임 허브, 내 티어표, 즐겨찾기, 검색, 로그인, 설정 화면의 공통 카드·입력·텍스트 색을 테마 변수 기준으로 바꿔, 주요 사용자 화면은 라이트/다크 전환이 자연스럽게 이어지도록 1차 정리함.
- 관리자 화면과 티어 에디터처럼 스타일 밀도가 높은 화면은 후속 단계에서 세부 톤을 더 정교하게 맞추도록 todo 기준도 갱신함.
## 2026-04-01 v1.3.31
- 관리자 게임 관리의 오른쪽 사이드 게임 선택 리스트는 더 많은 항목을 한 번에 볼 수 있도록 최대 높이를 늘리고, 게임 카드 내부 간격도 사용자가 조정한 CSS 기준으로 반영해 목록 밀도를 다시 다듬음.
## 2026-04-01 v1.3.30
- 헤더의 `Tier Maker` 로고는 레인보우 그라데이션 텍스트로 바꿔 서비스 첫인상이 더 또렷하게 보이도록 정리하고, `by zenn`은 새 창으로 프로필 페이지를 여는 외부 링크로 연결함.
- 다음 단계 작업용으로 라이트모드/다크모드 전환 항목을 todo 문서에 추가해, 현재의 다크 톤 UI를 유지하면서도 이후 테마 확장 흐름을 공식 작업 목록에 올림.
## 2026-04-01 v1.3.29
- 책 아이콘 사용법 모달 진입점은 항상 보이는 오른쪽 사이드 하단 버튼 대신, Settings 화면에서만 왼쪽 사이드 하단의 보조 액션 버튼으로 옮겨 더 필요할 때만 찾게 되는 문맥형 진입 방식으로 정리함.
- 인증 스토어에 초기 세션 동기화 완료 상태를 추가하고, 앱 셸·로그인 화면·프로필 화면은 세션 확인 전까지 비로그인 UI를 먼저 그리지 않도록 보강해 첫 진입 시 화면이 갑자기 로그인 상태로 뒤집히는 플래시를 줄임.
## 2026-04-01 v1.3.28
- 책 아이콘 기반 사용법 모달은 기존의 단순 제작 흐름 안내를 넘어, 다른 사람 티어표 복사, 템플릿 업그레이드 요청, 새 템플릿 추가 요청, 즐겨찾기/내 티어표 관리까지 포함한 전체 기능 안내 허브로 확장함.
- 사용법 모달 제목과 단계 표기를 더 넓은 개념의 `기능 안내` 기준으로 정리하고, 실제 스크린샷이 없어도 설명만으로 핵심 기능을 순서대로 이해할 수 있게 단계 문구를 전면 보강함.
## 2026-04-01 v1.3.27
- 오른쪽 사이드 하단에 책 아이콘 진입점을 추가하고, 중앙 대형 사용법 모달을 열어 좌측 기능 리스트와 우측 16:9 설명 영역, 좌우 이동, 하단 페이지네이션까지 포함한 기본 가이드 흐름을 붙임.
- 사용법 모달의 스크린샷 영역은 우선 16:9 플레이스홀더와 설명 텍스트만 배치해, 실제 이미지 자산은 나중에 채워 넣을 수 있게 구조를 먼저 준비함.
## 2026-04-01 v1.3.26
- 오른쪽 사이드는 실제 광고 슬롯 기준을 300x600 세로 비율로 잡고, 데스크톱 우측 레일 폭도 325px로 조정해 300px 광고가 내부 패딩과 보더를 제외한 실폭 안에 자연스럽게 들어가도록 보정함.
## 2026-04-01 v1.3.25
- todo 문서에서는 운영 정책/배포 체크 성격 항목을 우선 제거하고, 제품/보안 후속 작업 중심으로 다시 정리함.
- 관리자 게임 관리는 우측 셀렉트 박스 대신 검색 가능한 리스트와 최신순/오래된순 정렬로 바꿔, 게임 수가 많아져도 실제로 선택 가능한 구조로 개선함.
- 로그인과 회원가입은 기존 세션을 그대로 덮어쓰지 않고 세션을 재생성한 뒤 사용자 정보를 저장하도록 바꿔, 세션 고정 공격 방어를 보강함.
## 2026-04-01 v1.3.24
- 게임 선택 후 보이는 공개 티어표 목록 그리드도 auto-fit 최대폭 방식 대신 4/3/2/1열 고정 반응형 규칙으로 바꿔, 넓은 화면에서 카드 한 장이 애매하게 다음 줄로 넘어가며 공백이 크게 남던 문제를 줄임.
## 2026-04-01 v1.3.23
- 내 티어표 목록 그리드는 auto-fit 최대폭 방식 대신 게임 목록과 같은 4/3/2/1열 고정 반응형 규칙으로 맞춰, 넓은 화면에서 카드 한 장이 애매하게 다음 줄로 떨어지며 여백이 크게 남던 문제를 줄임.
## 2026-04-01 v1.3.22
- 내 티어표 카드는 게임 목록과 같은 상단 히어로/패널 문법으로 다시 맞추고, 깨진 썸네일은 alt 텍스트가 카드 폭을 밀지 않도록 플레이스홀더로 즉시 대체해 카드 수와 헤더 폭이 흔들리지 않게 보정함.
- 오른쪽 사이드 광고 프레임은 별도 보더·패딩·배경을 제거해, 광고 자체가 가진 각진 형태와 색이 그대로 보이도록 더 담백하게 정리함.
## 2026-04-01 v1.3.21
- 내 티어표 카드는 게임 목록 화면과 같은 카드 폭/헤더/메타 배치 문법으로 맞춰, 화면 간 카드 크기와 정보 정렬이 더 통일된 인상으로 보이도록 정리함.
## 2026-04-01 v1.3.20
- 내 티어표 카드 그리드는 카드 최대폭 우선 규칙 대신 더 촘촘한 auto-fill 기준으로 조정해, 넓은 화면에서도 한 줄에 더 많은 카드가 자연스럽게 배치되도록 보정함.
## 2026-04-01 v1.3.19
- 관리자 Image Optimization 기간 선택은 연도/월을 가로로 나란히 두고, 연도를 고르기 전에는 월 셀렉트를 숨겨 비어 있는 박스처럼 보이던 상태를 없앰.
- 전체 초기화 버튼도 실제 월이 선택된 경우에만 보이도록 정리해, 사이드바 상단 필터 줄이 더 단정하게 보이도록 보정함.
## 2026-04-01 v1.3.18
- 커스텀 아이템 기본 이름은 파일명 전체를 그대로 쓰지 않고 확장자 제거·공백 정리·60자 제한을 먼저 적용하도록 바꿔, 템플릿 요청 전에 커스텀 업로드가 길이 제한으로 실패하던 흐름을 줄임.
- 템플릿 요청 실패 안내는 커스텀 이미지 업로드 실패와 일반 bad request를 구분해, 사용자가 제목/설명/아이템 이름 길이 제한 문제를 더 쉽게 파악할 수 있게 보강함.
- 관리자 Image Optimization 월 필터는 기본 month input 대신 연도/월 셀렉트와 전체 초기화 버튼으로 바꿔, 기간 선택을 더 직관적으로 조작할 수 있게 정리함.
## 2026-04-01 v1.3.17
- 티어 에디터 열 헤더 입력창과 행 라벨은 좌우 패딩을 대칭으로 다시 잡아, 드래그 핸들과 삭제 아이콘이 있어도 제목이 한쪽으로 쏠려 보이지 않도록 보정함.
- 열 삭제도 이제 행 삭제와 같은 확인 모달을 거쳐 진행되도록 바꿔, 실수로 즉시 제거되던 문제를 막음.
- 내보내기 보드는 여전히 960px 고정 폭이라 열 수가 늘수록 각 칸 폭이 줄어드는 구조라는 점을 기준으로 정리했고, 현재 보정은 헤더 정렬 문제를 우선 해결하는 쪽에 맞춤.
## 2026-04-01 v1.3.16
- 티어 에디터의 행 삭제와 열 삭제는 다시 작은 X 아이콘 액션으로 정리해, 행/열 이름 주변의 반복 텍스트 때문에 보드가 답답해 보이던 문제를 줄임.
- 열 헤더 편집 영역은 입력창 오른쪽에 아이콘 삭제만 남기고, 행 라벨도 상단 우측의 작은 제거 버튼으로 맞춰 더 압축된 편집 밀도를 유지하도록 조정함.
- 저장 이미지에서 열 제목이 살짝 위로 떠 보이던 문제는 내보내기 헤더의 비대칭 패딩을 제거하고 flex 중앙 정렬로 바꿔, 시각적으로 정확한 중앙에 오도록 보정함.
## 2026-04-01 v1.3.15
- 티어 에디터의 열 이름은 각 행 안에서 반복 렌더링되지 않도록 공통 상단 헤더로 분리해, 행 제목과 같은 구조로 더 또렷하게 구분되도록 수정함.
- 행 추가/열 추가 액션은 새 SVG 아이콘 버튼으로 압축해, 텍스트 때문에 보드 상단 툴바 높이가 과하게 커지던 문제를 정리함.
- 미리보기와 삭제 모달 문구도 행/열 기준으로 함께 정리해, 전체 티어 에디터 흐름을 더 일관된 용어와 레이아웃으로 다듬음.
## 2026-04-01 v1.3.14
- 티어 에디터를 단일 세로 랭크형에서 행/열 혼합 보드로 확장해, 공격·방어·지원 같은 가로 열을 추가하고 각 열 이름도 직접 입력할 수 있게 함.
- 에디터 액션 문구를 `행 추가 / 열 추가` 기준으로 정리하고, 행 라벨 폭과 드래그 아이콘 위치를 다듬어 실제 사용 빈도에 맞는 더 압축된 보드 레이아웃으로 보정함.
- 이름 오버레이 정렬과 저장용 미리보기 보드도 함께 손봐서, 이미지 다운로드 시 라벨 텍스트가 하단 중앙에 더 안정적으로 배치되도록 수정함.
## 2026-04-01 v1.3.13
- 템플릿 등록/업데이트 요청 모달은 이제 현재 티어표 제목·설명을 기본값으로 가져오고, 비어 있더라도 모달 안에서 바로 작성해 요청할 수 있도록 흐름을 단순화함.
- 템플릿 요청 시 `내 티어 리스트에도 저장` 토글을 추가해, 요청 스냅샷만 관리자에게 전달할지 아니면 현재 양식도 내 티어표로 함께 저장할지 분리함.

View File

@@ -11,6 +11,7 @@ import iconFavorite from './assets/icons/favorite.svg'
import iconLists from './assets/icons/lists.svg'
import iconSearch from './assets/icons/search.svg'
import iconSettings from './assets/icons/settings.svg'
import iconMenuBook from './assets/icons/menu_book.svg'
import RightRailAd from './components/RightRailAd.vue'
import SvgIcon from './components/SvgIcon.vue'
@@ -24,11 +25,15 @@ const rightRailOpen = ref(true)
const searchQuery = ref('')
const searchPlaceholder = computed(() => (route.name === 'home' ? '게임 템플릿 검색' : '전체 티어표 검색'))
const isCollapsedSearchOpen = ref(false)
const isGuideModalOpen = ref(false)
const themeMode = ref('dark')
const guideStepIndex = ref(0)
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
provide('rightRailOpen', rightRailOpen)
provide('localRightRailTarget', '#local-right-rail-root')
const isAdmin = computed(() => !!auth.user?.isAdmin)
const authReady = computed(() => auth.hydrated)
const isAdmin = computed(() => authReady.value && !!auth.user?.isAdmin)
const isPreviewMode = computed(() => route.query.preview === '1')
const usesLocalRightRail = computed(() => ['editEditor', 'newEditor', 'admin'].includes(String(route.name || '')))
const isRightRailOverlay = computed(() => viewportWidth.value <= 1200)
@@ -41,10 +46,13 @@ const accountName = computed(() => {
if (email) return email.split('@')[0] || email
return 'Guest'
})
const accountEmail = computed(() => (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.')
const accountEmail = computed(() => {
if (!authReady.value) return '계정 상태를 확인하고 있어요.'
return (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.'
})
const shellStyle = computed(() => ({
'--left-rail-width': leftRailCollapsed.value ? '76px' : '248px',
'--right-rail-width': !isRightRailOverlay.value && rightRailOpen.value ? '320px' : '0px',
'--right-rail-width': !isRightRailOverlay.value && rightRailOpen.value ? '325px' : '0px',
}))
const leftNavItems = computed(() => {
const items = [
@@ -53,12 +61,78 @@ const leftNavItems = computed(() => {
{ key: 'favorites', label: 'Favorites', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
{ key: 'profile', label: 'Settings', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
]
return items.filter((item) => !item.requiresAuth || auth.user)
return items.filter((item) => !item.requiresAuth || (authReady.value && auth.user))
})
const showRightRailAction = computed(() => false)
const showSettingsGuideButton = computed(() => route.name === 'profile')
const guideSteps = [
{
id: 'select-game',
title: '게임 또는 양식 선택',
summary: '게임 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
description:
'홈 화면에서는 게임 템플릿을 선택하거나 커스텀 티어표 만들기로 바로 새 보드를 열 수 있어요. 게임을 먼저 고르면 해당 게임의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
},
{
id: 'arrange-board',
title: '행과 열 구성',
summary: '랭크 행과 가로 열을 정리해 보드 구조를 먼저 잡습니다.',
description:
'기본 랭크를 그대로 써도 되고, 행 이름을 바꾸거나 행과 열을 추가해 공격·방어·지원처럼 더 세밀한 구조로 나눌 수도 있어요. 먼저 판을 정리한 뒤 배치를 시작하면 뒤에서 크게 손댈 일이 줄어듭니다.',
},
{
id: 'drop-items',
title: '아이템 배치와 커스텀 추가',
summary: '프리셋 아이템과 직접 올린 이미지를 드래그로 배치합니다.',
description:
'오른쪽 아이템 영역의 이미지를 원하는 칸으로 끌어다 놓으면 바로 배치됩니다. 게임 템플릿에 없는 이미지는 커스텀 이미지로 추가해 같이 쓸 수 있고, 이름 표시 옵션을 켜면 결과 이미지를 더 설명적으로 정리할 수 있어요.',
},
{
id: 'save-share',
title: '저장과 이미지 다운로드',
summary: '완성한 티어표를 내 목록에 저장하거나 PNG 이미지로 내려받습니다.',
description:
'보드 작업이 끝나면 저장해서 내 티어표 목록에 남길 수 있고, 이미지 다운로드로 한 장의 결과물로 바로 공유할 수도 있어요. 공개 여부도 함께 정할 수 있어서 개인 메모용과 공유용 흐름을 나눠 쓰기 좋습니다.',
},
{
id: 'copy-existing',
title: '다른 사람 티어표 복사',
summary: '공개된 티어표를 그대로 가져와 내 이름의 새 작업본으로 이어서 수정합니다.',
description:
'누군가 만든 티어표가 거의 마음에 드는데 일부만 바꾸고 싶다면, 복사 기능으로 현재 배치 상태를 그대로 가져와 새 티어표로 시작할 수 있어요. 복사본에는 원본을 참고했다는 정보가 함께 남아서 출처도 자연스럽게 구분됩니다.',
},
{
id: 'request-template-update',
title: '템플릿 업그레이드 요청',
summary: '현재 게임 템플릿에 공통 아이템을 추가해 달라고 관리자에게 요청합니다.',
description:
'직접 추가한 아이템 중 여러 사람이 함께 써도 좋을 것 같은 항목이 있다면 템플릿 업데이트 요청을 보낼 수 있어요. 요청 모달에서는 현재 티어표 제목과 설명을 기본값으로 가져오고, 필요하면 요청 제목과 설명을 더 다듬어 공통 템플릿에 왜 필요한지 설명할 수 있습니다.',
},
{
id: 'request-new-template',
title: '새 템플릿 추가 요청',
summary: '아직 없는 게임이나 새로운 양식을 관리자에게 제안합니다.',
description:
'원하는 게임 템플릿이 아직 없다면 새 템플릿 추가 요청으로 관리자에게 직접 제안할 수 있어요. 이때는 제목과 설명에 어떤 게임인지, 어떤 캐릭터나 항목이 기본으로 필요할지 적어두면 검토 속도가 훨씬 빨라집니다.',
},
{
id: 'manage-library',
title: '즐겨찾기와 내 티어표 관리',
summary: '마음에 드는 템플릿과 저장한 결과물을 나중에 다시 쉽게 찾습니다.',
description:
'게임 템플릿은 즐겨찾기로 상단에 고정해둘 수 있고, 저장한 보드는 내 티어표에서 다시 열어 이어서 수정할 수 있어요. 자주 보는 템플릿, 공개 티어표, 내가 만든 결과물을 각각 다른 화면에서 정리해두면 이후 작업이 훨씬 빨라집니다.',
},
]
const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
const isGuidePrevDisabled = computed(() => guideStepIndex.value <= 0)
const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.length - 1)
const isLightTheme = computed(() => themeMode.value === 'light')
const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드'))
const showSettingsThemePanel = computed(() => route.name === 'profile')
const showGameHubViewToggle = computed(() => route.name === 'gameHub')
const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
const leftBottomPrimaryAction = computed(() => {
if (!authReady.value) return null
if (route.name === 'home' && auth.user) {
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new' }
}
@@ -170,7 +244,22 @@ function syncViewportWidth() {
viewportWidth.value = window.innerWidth
}
function applyTheme(mode) {
themeMode.value = mode === 'light' ? 'light' : 'dark'
if (typeof document !== 'undefined') document.documentElement.dataset.theme = themeMode.value
if (typeof window !== 'undefined') window.localStorage.setItem('tier-maker:theme', themeMode.value)
}
function toggleTheme() {
applyTheme(isLightTheme.value ? 'dark' : 'light')
}
onMounted(async () => {
if (typeof window !== 'undefined') {
const savedTheme = window.localStorage.getItem('tier-maker:theme')
if (savedTheme === 'light' || savedTheme === 'dark') applyTheme(savedTheme)
else applyTheme(window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
}
await auth.refresh()
if (typeof window !== 'undefined') {
syncViewportWidth()
@@ -185,6 +274,10 @@ onMounted(async () => {
})
function handleGlobalKeydown(event) {
if (event.key === 'Escape' && isGuideModalOpen.value) {
closeGuideModal()
return
}
if (event.key === 'Escape' && isCollapsedSearchOpen.value) {
closeCollapsedSearch()
}
@@ -202,6 +295,7 @@ watch(
() => {
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
isCollapsedSearchOpen.value = false
isGuideModalOpen.value = false
}
)
@@ -262,6 +356,29 @@ function closeCollapsedSearch() {
isCollapsedSearchOpen.value = false
}
function openGuideModal(stepIndex = 0) {
guideStepIndex.value = Math.min(Math.max(Number(stepIndex) || 0, 0), guideSteps.length - 1)
isGuideModalOpen.value = true
}
function closeGuideModal() {
isGuideModalOpen.value = false
}
function selectGuideStep(index) {
guideStepIndex.value = Math.min(Math.max(index, 0), guideSteps.length - 1)
}
function showPrevGuideStep() {
if (isGuidePrevDisabled.value) return
guideStepIndex.value -= 1
}
function showNextGuideStep() {
if (isGuideNextDisabled.value) return
guideStepIndex.value += 1
}
function handleLeftRailSearch() {
if (leftRailCollapsed.value && !isMobileLayout.value) {
openCollapsedSearch()
@@ -309,7 +426,7 @@ function submitGlobalSearch() {
<div class="leftRail__body">
<div class="leftRail__content">
<div v-if="auth.user" class="appUserCard">
<div v-if="authReady && auth.user" class="appUserCard">
<div class="appUserCard__button">
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" />
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
@@ -350,8 +467,12 @@ function submitGlobalSearch() {
</div>
<div class="leftRail__bottom">
<RouterLink v-if="leftBottomPrimaryAction" :to="leftBottomPrimaryAction.to" class="adminButton">{{ leftBottomPrimaryAction.label }}</RouterLink>
<RouterLink v-if="isAdmin" to="/admin" class="adminButton">관리자 메뉴</RouterLink>
<RouterLink v-else-if="!auth.user" to="/login" class="adminButton">로그인</RouterLink>
<button v-if="showSettingsGuideButton" class="adminButton adminButton--icon" type="button" @click="openGuideModal()">
<SvgIcon :src="iconMenuBook" :size="18" class="adminButton__icon" />
<span>가이드 보기</span>
</button>
<RouterLink v-if="authReady && isAdmin" to="/admin" class="adminButton">관리자 메뉴</RouterLink>
<RouterLink v-else-if="authReady && !auth.user" to="/login" class="adminButton">로그인</RouterLink>
</div>
</div>
</aside>
@@ -361,7 +482,15 @@ function submitGlobalSearch() {
<header class="workspaceHead railHeader">
<div class="workspaceHead__brand" @click="$router.push('/')">
<span class="workspaceHead__brandTitle">Tier Maker</span>
<span class="workspaceHead__brandSub">by zenn</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="티어표 보기 방식">
@@ -392,6 +521,66 @@ function submitGlobalSearch() {
</form>
</div>
<div v-if="isGuideModalOpen" class="guideModal" role="dialog" aria-modal="true" aria-label="티어 메이커 기능 안내" @click.self="closeGuideModal">
<div class="guideModal__dialog">
<div class="guideModal__sidebar">
<div class="guideModal__eyebrow">Guide</div>
<div class="guideModal__title">티어 메이커 기능 안내</div>
<div class="guideModal__list">
<button
v-for="(step, index) in guideSteps"
:key="step.id"
class="guideModal__listItem"
:class="{ 'guideModal__listItem--active': index === guideStepIndex }"
type="button"
@click="selectGuideStep(index)"
>
<span class="guideModal__listIndex">{{ index + 1 }}</span>
<span class="guideModal__listLabel">{{ step.title }}</span>
</button>
</div>
</div>
<div class="guideModal__main">
<button class="guideModal__close" type="button" aria-label="사용법 닫기" @click="closeGuideModal">닫기</button>
<div class="guideModal__content">
<button class="guideModal__arrow" type="button" aria-label="이전 단계" :disabled="isGuidePrevDisabled" @click="showPrevGuideStep"></button>
<div class="guideModal__body">
<div class="guideModal__media">
<div class="guideModal__mediaPlaceholder">
<div class="guideModal__mediaBadge">16:9</div>
<div class="guideModal__mediaTitle">{{ currentGuideStep.title }}</div>
<div class="guideModal__mediaHint">스크린샷 준비 </div>
</div>
</div>
<div class="guideModal__text">
<div class="guideModal__stepLabel">GUIDE {{ guideStepIndex + 1 }}</div>
<div class="guideModal__stepTitle">{{ currentGuideStep.title }}</div>
<div class="guideModal__stepSummary">{{ currentGuideStep.summary }}</div>
<p class="guideModal__stepDescription">{{ currentGuideStep.description }}</p>
</div>
<div class="guideModal__footer">
<div class="guideModal__pagination">
<button
v-for="(step, index) in guideSteps"
:key="step.id + '-dot'"
class="guideModal__dot"
:class="{ 'guideModal__dot--active': index === guideStepIndex }"
type="button"
:aria-label="step.title"
@click="selectGuideStep(index)"
></button>
</div>
<button class="guideModal__next" type="button" @click="isGuideNextDisabled ? closeGuideModal() : showNextGuideStep()">
{{ isGuideNextDisabled ? '닫기' : '다음' }}
</button>
</div>
</div>
<button class="guideModal__arrow" type="button" aria-label="다음 단계" :disabled="isGuideNextDisabled" @click="showNextGuideStep"></button>
</div>
</div>
</div>
</div>
<button v-if="rightRailOpen && isRightRailOverlay" class="rightRailBackdrop" type="button" aria-label="오른쪽 패널 닫기" @click="toggleRightRail"></button>
<aside class="rightRail" :class="{ 'rightRail--closed': !rightRailOpen, 'rightRail--overlay': isRightRailOverlay }" :aria-hidden="!rightRailOpen">
@@ -404,7 +593,17 @@ function submitGlobalSearch() {
<div class="rightRail__content">
<div v-if="usesLocalRightRail" id="local-right-rail-root" class="localRightRailRoot"></div>
<template v-else>
<RightRailAd />
<section v-if="showSettingsThemePanel" class="settingsThemePanel">
<div class="settingsThemePanel__eyebrow">Appearance</div>
<div class="settingsThemePanel__title">테마 설정</div>
<div class="settingsThemePanel__desc">밝은 톤과 어두운 원하는 작업 환경으로 전환할 있어요.</div>
<label class="toggleSwitch settingsThemePanel__toggle">
<input :checked="isLightTheme" type="checkbox" @change="toggleTheme" />
<span class="toggleSwitch__label">{{ isLightTheme ? '라이트 모드' : '다크 모드' }}</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
</section>
<RightRailAd v-else />
</template>
</div>
<div class="rightRail__bottom">
@@ -436,9 +635,9 @@ function submitGlobalSearch() {
.appShell {
min-height: 100dvh;
display: grid;
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr) var(--right-rail-width, 320px);
background: rgba(14, 14, 14, 0.96);
color: rgba(255, 255, 255, 0.92);
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr) var(--right-rail-width, 325px);
background: var(--theme-shell-bg);
color: var(--theme-text);
transition: grid-template-columns 220ms ease;
}
@@ -449,8 +648,8 @@ function submitGlobalSearch() {
.leftRail,
.rightRail {
min-height: 100dvh;
border-right: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(14, 14, 14, 0.92);
border-right: 1px solid var(--theme-border);
background: var(--theme-rail-bg);
box-sizing: border-box;
min-width: 0;
display: flex;
@@ -460,7 +659,7 @@ function submitGlobalSearch() {
.rightRail {
border-right: 0;
border-left: 1px solid rgba(255, 255, 255, 0.08);
border-left: 1px solid var(--theme-border);
transition:
opacity 220ms ease,
transform 220ms ease,
@@ -484,7 +683,7 @@ function submitGlobalSearch() {
display: flex;
align-items: center;
padding: 0 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: 1px solid var(--theme-border);
box-sizing: border-box;
}
@@ -543,8 +742,8 @@ function submitGlobalSearch() {
height: 28px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
color: rgba(255, 255, 255, 0.72);
cursor: pointer;
display: inline-flex;
@@ -613,7 +812,7 @@ function submitGlobalSearch() {
object-fit: cover;
flex: 0 0 auto;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.08);
background: var(--theme-surface-soft-3);
}
.appUserCard__avatar--fallback {
@@ -639,7 +838,7 @@ function submitGlobalSearch() {
.appUserCard__email {
font-size: 12px;
color: rgba(255, 255, 255, 0.56);
color: var(--theme-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -652,8 +851,8 @@ function submitGlobalSearch() {
gap: 10px;
padding: 11px 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
color: rgba(255, 255, 255, 0.62);
margin-bottom: 14px;
box-sizing: border-box;
@@ -666,7 +865,7 @@ function submitGlobalSearch() {
max-width: 100%;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.92);
color: var(--theme-text);
outline: none;
font: inherit;
overflow: hidden;
@@ -674,7 +873,7 @@ function submitGlobalSearch() {
}
.searchStub__input::placeholder {
color: rgba(255, 255, 255, 0.42);
color: var(--theme-text-soft);
}
.searchStub__iconButton {
@@ -805,23 +1004,32 @@ function submitGlobalSearch() {
display: inline-flex;
justify-content: center;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface-soft);
color: var(--theme-text);
text-decoration: none;
box-sizing: border-box;
font-weight: 800;
}
.adminButton--icon {
text-align: center;
}
.adminButton__icon {
flex: 0 0 auto;
}
.appMain {
min-width: 0;
min-height: 0;
box-sizing: border-box;
background: rgba(18, 18, 18, 0.98);
border-left: 1px solid rgba(255, 255, 255, 0.08);
border-right: 1px solid rgba(255, 255, 255, 0.08);
background: var(--theme-main-bg);
border-left: 1px solid var(--theme-border);
border-right: 1px solid var(--theme-border);
}
.appMain--preview {
@@ -857,12 +1065,22 @@ function submitGlobalSearch() {
font-size: 28px;
font-weight: 900;
letter-spacing: -0.05em;
background-image: linear-gradient(90deg, #ff75c3 0%, #ffa647 20%, #ffe83f 40%, #9fff5b 60%, #70e2ff 80%, #cd93ff 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.workspaceHead__brandSub {
font-size: 13px;
font-weight: 700;
color: rgba(255, 255, 255, 0.58);
color: var(--theme-text-muted);
text-decoration: none;
transition: color 180ms ease, opacity 180ms ease;
}
.workspaceHead__brandSub:hover {
color: var(--theme-text);
}
.workspaceHead__actions {
@@ -878,7 +1096,7 @@ function submitGlobalSearch() {
gap: 6px;
padding: 4px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.04);
background: var(--theme-surface-soft);
}
.viewToggle .ghostIcon--iconOnly {
@@ -889,7 +1107,7 @@ function submitGlobalSearch() {
}
.ghostIcon--active {
background: rgba(255, 255, 255, 0.08);
background: var(--theme-surface-soft-3);
}
.workspaceBody {
@@ -897,7 +1115,7 @@ function submitGlobalSearch() {
padding: 18px 18px 32px;
border: 0;
border-radius: 0;
background: rgba(24, 24, 24, 0.92);
background: var(--theme-workspace-bg);
box-shadow: none;
margin: 0;
}
@@ -907,7 +1125,7 @@ function submitGlobalSearch() {
padding: 18px 18px 32px;
border: 0;
border-radius: 0;
background: rgba(24, 24, 24, 0.92);
background: var(--theme-workspace-bg);
box-shadow: none;
margin: 0;
}
@@ -924,16 +1142,110 @@ function submitGlobalSearch() {
.rightRail__bottom {
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: 10px;
padding-top: 12px;
}
.settingsThemePanel {
display: grid;
gap: 10px;
padding: 18px;
border-radius: 22px;
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
}
.settingsThemePanel__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--theme-text-soft);
}
.settingsThemePanel__title {
font-size: 22px;
font-weight: 800;
color: var(--theme-text-strong);
}
.settingsThemePanel__desc {
color: var(--theme-text-muted);
line-height: 1.6;
}
.settingsThemePanel__toggle {
margin-top: 4px;
}
.toggleSwitch {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface-soft);
cursor: pointer;
user-select: none;
}
.toggleSwitch input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.toggleSwitch__track {
position: relative;
width: 42px;
height: 24px;
border-radius: 999px;
background: var(--theme-surface-soft-3);
border: 1px solid var(--theme-border-strong);
transition: background 180ms ease, border-color 180ms ease;
flex: 0 0 auto;
}
.toggleSwitch__thumb {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.24);
transition: transform 180ms ease;
}
:root[data-theme='light'] .toggleSwitch__thumb {
background: #fff;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.16);
}
.toggleSwitch__label {
font-weight: 800;
color: var(--theme-text);
}
.toggleSwitch input:checked ~ .toggleSwitch__track {
background: rgba(96, 165, 250, 0.34);
border-color: rgba(96, 165, 250, 0.42);
}
.toggleSwitch input:checked ~ .toggleSwitch__track .toggleSwitch__thumb {
transform: translateX(18px);
}
.rightRailAction__button {
width: 100%;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(77, 127, 233, 0.96);
background: rgba(77, 127, 233, 0.88);
color: #fff;
background: var(--theme-accent-bg);
color: var(--theme-accent-text);
font-weight: 800;
cursor: pointer;
}
@@ -942,6 +1254,240 @@ function submitGlobalSearch() {
display: none;
}
.guideModal {
position: fixed;
inset: 0;
z-index: 36;
display: flex;
align-items: center;
justify-content: center;
padding: 32px 20px;
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(10px);
}
.guideModal__dialog {
width: min(1180px, calc(100vw - 40px));
min-height: min(760px, calc(100dvh - 64px));
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
border-radius: 28px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(180deg, rgba(34, 34, 34, 0.98), rgba(18, 18, 18, 0.98));
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.42);
}
.guideModal__sidebar {
display: grid;
align-content: start;
gap: 18px;
padding: 28px 22px;
background: var(--theme-pill-bg);
border-right: 1px solid var(--theme-border);
}
.guideModal__eyebrow {
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.4);
}
.guideModal__title {
font-size: 28px;
font-weight: 900;
line-height: 1.1;
letter-spacing: -0.04em;
}
.guideModal__list {
display: grid;
gap: 8px;
}
.guideModal__listItem {
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid var(--theme-border);
background: rgba(255, 255, 255, 0.02);
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
text-align: left;
}
.guideModal__listItem--active {
border-color: rgba(77, 127, 233, 0.5);
background: rgba(77, 127, 233, 0.14);
color: rgba(255, 255, 255, 0.96);
}
.guideModal__listIndex {
font-size: 12px;
font-weight: 900;
color: rgba(255, 255, 255, 0.54);
}
.guideModal__listLabel {
min-width: 0;
font-size: 14px;
font-weight: 700;
}
.guideModal__main {
min-width: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
padding: 24px 28px 28px;
}
.guideModal__close {
justify-self: end;
border: 0;
background: transparent;
color: var(--theme-text-muted);
cursor: pointer;
font-size: 13px;
}
.guideModal__content {
min-width: 0;
display: grid;
grid-template-columns: 52px minmax(0, 1fr) 52px;
gap: 16px;
align-items: center;
}
.guideModal__body {
min-width: 0;
display: grid;
gap: 18px;
}
.guideModal__media {
width: 100%;
}
.guideModal__mediaPlaceholder {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 24px;
border: 1px solid var(--theme-border);
background: radial-gradient(circle at top, rgba(77, 127, 233, 0.18), rgba(255, 255, 255, 0.02) 52%), rgba(255, 255, 255, 0.03);
display: grid;
align-content: center;
justify-items: center;
gap: 8px;
text-align: center;
}
.guideModal__mediaBadge {
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.38);
}
.guideModal__mediaTitle {
font-size: 24px;
font-weight: 900;
}
.guideModal__mediaHint {
font-size: 13px;
color: rgba(255, 255, 255, 0.48);
}
.guideModal__text {
display: grid;
gap: 8px;
}
.guideModal__stepLabel {
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--theme-text-soft);
}
.guideModal__stepTitle {
font-size: 28px;
font-weight: 900;
letter-spacing: -0.04em;
}
.guideModal__stepSummary {
font-size: 16px;
font-weight: 700;
color: rgba(255, 255, 255, 0.86);
}
.guideModal__stepDescription {
margin: 0;
max-width: 720px;
line-height: 1.7;
color: rgba(255, 255, 255, 0.62);
}
.guideModal__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.guideModal__pagination {
display: flex;
align-items: center;
gap: 8px;
}
.guideModal__dot {
width: 10px;
height: 10px;
border-radius: 999px;
border: 0;
background: rgba(255, 255, 255, 0.18);
cursor: pointer;
}
.guideModal__dot--active {
width: 26px;
background: rgba(77, 127, 233, 0.9);
}
.guideModal__next {
padding: 12px 18px;
border-radius: 14px;
border: 1px solid rgba(77, 127, 233, 0.96);
background: var(--theme-accent-bg);
color: var(--theme-accent-text);
font-weight: 800;
cursor: pointer;
}
.guideModal__arrow {
width: 52px;
height: 52px;
border-radius: 999px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
font-size: 28px;
line-height: 1;
cursor: pointer;
}
.guideModal__arrow:disabled {
opacity: 0.28;
cursor: default;
}
.collapsedSearchModal {
position: fixed;
inset: 0;
@@ -987,7 +1533,7 @@ function submitGlobalSearch() {
flex: 1;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.92);
color: var(--theme-text);
font-size: 18px;
font-weight: 700;
outline: none;
@@ -1025,7 +1571,7 @@ function submitGlobalSearch() {
justify-content: space-between;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
border: 1px solid var(--theme-border-strong);
background: rgba(11, 18, 32, 0.94);
backdrop-filter: blur(12px);
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.28);
@@ -1059,7 +1605,7 @@ function submitGlobalSearch() {
.toast__count {
margin-top: 6px;
font-size: 12px;
color: rgba(255, 255, 255, 0.56);
color: var(--theme-text-muted);
}
.toast__close {
@@ -1071,6 +1617,20 @@ function submitGlobalSearch() {
}
@media (max-width: 1200px) {
.guideModal__dialog {
grid-template-columns: 1fr;
min-height: auto;
}
.guideModal__sidebar {
border-right: 0;
border-bottom: 1px solid var(--theme-border);
}
.guideModal__content {
grid-template-columns: 40px minmax(0, 1fr) 40px;
}
.appShell {
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr);
}
@@ -1091,8 +1651,8 @@ function submitGlobalSearch() {
width: min(360px, calc(100vw - 20px));
height: 100dvh;
z-index: 30;
border-left: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(14, 14, 14, 0.96);
border-left: 1px solid var(--theme-border);
background: var(--theme-shell-bg);
box-shadow: -18px 0 36px rgba(0, 0, 0, 0.34);
}
@@ -1104,6 +1664,45 @@ function submitGlobalSearch() {
}
}
@media (max-width: 860px) {
.guideModal {
padding: 20px 12px;
}
.guideModal__dialog {
width: min(100%, calc(100vw - 24px));
}
.guideModal__main {
padding: 20px 18px 18px;
}
.guideModal__content {
grid-template-columns: 1fr;
}
.guideModal__arrow {
display: none;
}
.guideModal__footer {
flex-direction: column;
align-items: stretch;
}
.guideModal__next {
width: 100%;
}
.guideDockButton {
display: none;
}
.guideModal__list {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 860px) {
.appShell {
grid-template-columns: 1fr;
@@ -1114,7 +1713,7 @@ function submitGlobalSearch() {
min-height: auto;
height: auto;
border-right: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: 1px solid var(--theme-border);
}
.leftRail__top {

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="M160-760v560h240v-560H160ZM80-120v-720h720v160h-80v-80H480v560h240v-80h80v160H80Zm400-360Zm-80 0h80-80Zm0 0Zm320 120v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 283 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="M200-560h560v-240H200v240Zm-80 400v-720h720v720H680v-80h80v-240H200v240h80v80H120Zm360-320Zm0-80v80-80Zm0 0ZM440-80v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 282 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="M560-564v-68q33-14 67.5-21t72.5-7q26 0 51 4t49 10v64q-24-9-48.5-13.5T700-600q-38 0-73 9.5T560-564Zm0 220v-68q33-14 67.5-21t72.5-7q26 0 51 4t49 10v64q-24-9-48.5-13.5T700-380q-38 0-73 9t-67 27Zm0-110v-68q33-14 67.5-21t72.5-7q26 0 51 4t49 10v64q-24-9-48.5-13.5T700-490q-38 0-73 9.5T560-454ZM260-320q47 0 91.5 10.5T440-278v-394q-41-24-87-36t-93-12q-36 0-71.5 7T120-692v396q35-12 69.5-18t70.5-6Zm260 42q44-21 88.5-31.5T700-320q36 0 70.5 6t69.5 18v-396q-33-14-68.5-21t-71.5-7q-47 0-93 12t-87 36v394Zm-40 118q-48-38-104-59t-116-21q-42 0-82.5 11T100-198q-21 11-40.5-1T40-234v-482q0-11 5.5-21T62-752q46-24 96-36t102-12q58 0 113.5 15T480-740q51-30 106.5-45T700-800q52 0 102 12t96 36q11 5 16.5 15t5.5 21v482q0 23-19.5 35t-40.5 1q-37-20-77.5-31T700-240q-60 0-116 21t-104 59ZM280-494Z"/></svg>

After

Width:  |  Height:  |  Size: 895 B

View File

@@ -77,15 +77,16 @@ onMounted(async () => {
}
.rightRailAd__frame {
min-height: 520px;
padding: 14px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.02);
width: min(100%, 300px);
min-height: 600px;
margin: 0 auto;
}
.rightRailAd__slot {
width: 100%;
min-height: 490px;
display: block;
width: 300px;
max-width: 100%;
min-height: 600px;
margin: 0 auto;
}
</style>

View File

@@ -5,30 +5,40 @@ export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
status: 'idle',
hydrated: false,
}),
actions: {
async refresh() {
if (this.status === 'loading') return this.user
this.status = 'loading'
try {
const data = await api.me()
this.user = data.user
return this.user
} catch (error) {
this.user = null
return null
} finally {
this.status = 'idle'
this.hydrated = true
}
},
async signup(email, password) {
const user = await api.signup({ email, password })
this.user = user
this.hydrated = true
return user
},
async login(email, password) {
const user = await api.login({ email, password })
this.user = user
this.hydrated = true
return user
},
async logout() {
await api.logout()
this.user = null
this.hydrated = true
},
},
})

View File

@@ -2,12 +2,67 @@
font-family: 'Pretendard', 'Inter', 'Segoe UI', sans-serif;
line-height: 1.5;
font-weight: 400;
color: rgba(255, 255, 255, 0.92);
background: #121212;
color: var(--theme-text);
background: var(--theme-body-bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--theme-body-bg: #121212;
--theme-shell-bg: rgba(14, 14, 14, 0.96);
--theme-rail-bg: rgba(14, 14, 14, 0.92);
--theme-main-bg: rgba(18, 18, 18, 0.98);
--theme-workspace-bg: rgba(24, 24, 24, 0.92);
--theme-card-bg: rgba(62, 62, 62, 0.82);
--theme-card-bg-hover: rgba(70, 70, 70, 0.96);
--theme-card-border: rgba(255, 255, 255, 0.16);
--theme-card-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
--theme-surface-soft: rgba(255, 255, 255, 0.05);
--theme-surface-soft-2: rgba(255, 255, 255, 0.06);
--theme-surface-soft-3: rgba(255, 255, 255, 0.08);
--theme-pill-bg: rgba(255, 255, 255, 0.03);
--theme-border: rgba(255, 255, 255, 0.08);
--theme-border-strong: rgba(255, 255, 255, 0.12);
--theme-text: rgba(255, 255, 255, 0.92);
--theme-text-strong: var(--theme-text-strong);
--theme-text-muted: var(--theme-text-muted);
--theme-text-soft: var(--theme-text-soft);
--theme-text-faint: rgba(255, 255, 255, 0.4);
--theme-thumb-fallback-bg: #555;
--theme-select-arrow: var(--theme-select-arrow);
--theme-danger-bg: rgba(239, 68, 68, 0.1);
--theme-danger-border: rgba(239, 68, 68, 0.18);
--theme-accent-bg: rgba(76, 133, 245, 0.92);
--theme-accent-text: #fff;
}
:root[data-theme='light'] {
--theme-body-bg: #edf1f7;
--theme-shell-bg: rgba(244, 247, 252, 0.98);
--theme-rail-bg: rgba(248, 250, 253, 0.96);
--theme-main-bg: rgba(241, 244, 249, 0.98);
--theme-workspace-bg: rgba(250, 252, 255, 0.95);
--theme-card-bg: var(--theme-text-strong);
--theme-card-bg-hover: rgba(245, 248, 255, 0.98);
--theme-card-border: rgba(26, 32, 44, 0.1);
--theme-card-shadow: 0 18px 34px rgba(31, 41, 55, 0.08);
--theme-surface-soft: rgba(15, 23, 42, 0.05);
--theme-surface-soft-2: rgba(15, 23, 42, 0.07);
--theme-surface-soft-3: rgba(15, 23, 42, 0.1);
--theme-pill-bg: rgba(15, 23, 42, 0.04);
--theme-border: rgba(15, 23, 42, 0.1);
--theme-border-strong: rgba(15, 23, 42, 0.14);
--theme-text: rgba(20, 27, 40, 0.9);
--theme-text-strong: rgba(10, 15, 28, 0.98);
--theme-text-muted: rgba(55, 65, 81, 0.74);
--theme-text-soft: rgba(75, 85, 99, 0.64);
--theme-text-faint: rgba(100, 116, 139, 0.82);
--theme-thumb-fallback-bg: #d8dde8;
--theme-select-arrow: rgba(55, 65, 81, 0.72);
--theme-danger-bg: rgba(239, 68, 68, 0.1);
--theme-danger-border: rgba(239, 68, 68, 0.22);
--theme-accent-bg: rgba(64, 110, 226, 0.94);
--theme-accent-text: #fff;
}
* {
@@ -22,7 +77,9 @@ body,
body {
margin: 0;
background: #121212;
background: var(--theme-body-bg);
color: var(--theme-text);
transition: background 220ms ease, color 220ms ease;
}
button,
@@ -43,7 +100,7 @@ a {
input,
select,
textarea {
color: rgba(255, 255, 255, 0.92);
color: var(--theme-text);
}
select {
@@ -51,8 +108,8 @@ select {
-webkit-appearance: none;
-moz-appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, rgba(255, 255, 255, 0.72) 50%),
linear-gradient(135deg, rgba(255, 255, 255, 0.72) 50%, transparent 50%);
linear-gradient(45deg, transparent 50%, var(--theme-select-arrow) 50%),
linear-gradient(135deg, var(--theme-select-arrow) 50%, transparent 50%);
background-position:
calc(100% - 20px) calc(50% - 2px),
calc(100% - 14px) calc(50% - 2px);
@@ -99,19 +156,19 @@ p {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
color: var(--theme-text-soft);
}
.pageHead__title {
font-size: 32px;
line-height: 1.05;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
color: var(--theme-text-strong);
}
.pageHead__desc {
max-width: 720px;
color: rgba(255, 255, 255, 0.58);
color: var(--theme-text-muted);
}
.pageHead__aside {

View File

@@ -22,6 +22,8 @@ const games = ref([])
const selectedGameId = ref('')
const selectedGame = ref(null)
const featuredGameIds = ref([])
const gameAdminQuery = ref('')
const gameAdminSort = ref('recent')
const customItems = ref([])
const customItemQuery = ref('')
@@ -104,6 +106,19 @@ const featuredGames = computed(() =>
.filter(Boolean)
)
const availableGamesForFeatured = computed(() => games.value.filter((game) => !featuredGameIds.value.includes(game.id)))
const filteredAdminGames = computed(() => {
const query = gameAdminQuery.value.trim().toLowerCase()
const list = games.value.filter((game) => {
if (!query) return true
const haystack = `${game.name || ''} ${game.id || ''}`.toLowerCase()
return haystack.includes(query)
})
return list.slice().sort((a, b) => {
if (gameAdminSort.value === 'oldest') return Number(a.createdAt || 0) - Number(b.createdAt || 0)
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
})
})
const importModalItemCount = computed(() => importModalItems.value.length)
const activeTabTitle = computed(() => {
if (activeTab.value === 'featured') return '목록 관리'
@@ -270,6 +285,49 @@ const visibleLinkedGames = computed(() =>
)
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
const imageStatsYearOptions = computed(() => {
const currentYear = new Date().getFullYear()
return Array.from({ length: 6 }, (_, index) => String(currentYear - index))
})
const imageStatsMonthOptions = [
{ value: '01', label: '1월' },
{ value: '02', label: '2월' },
{ value: '03', label: '3월' },
{ value: '04', label: '4월' },
{ value: '05', label: '5월' },
{ value: '06', label: '6월' },
{ value: '07', label: '7월' },
{ value: '08', label: '8월' },
{ value: '09', label: '9월' },
{ value: '10', label: '10월' },
{ value: '11', label: '11월' },
{ value: '12', label: '12월' },
]
const selectedImageStatsYear = computed({
get: () => (imageStatsMonth.value ? imageStatsMonth.value.slice(0, 4) : ''),
set: (year) => {
if (!year) {
imageStatsMonth.value = ''
return
}
const month = imageStatsMonth.value ? imageStatsMonth.value.slice(5, 7) : '01'
imageStatsMonth.value = `${year}-${month}`
},
})
const selectedImageStatsMonthNumber = computed({
get: () => (imageStatsMonth.value ? imageStatsMonth.value.slice(5, 7) : ''),
set: (month) => {
if (!month) {
imageStatsMonth.value = ''
return
}
const year = imageStatsMonth.value ? imageStatsMonth.value.slice(0, 4) : String(new Date().getFullYear())
imageStatsMonth.value = `${year}-${month}`
},
})
function clearImageStatsMonth() {
imageStatsMonth.value = ''
}
async function refreshImageDiagnostics() {
try {
@@ -335,6 +393,12 @@ async function handleSelectedGameChange(event) {
await loadGame()
}
async function selectAdminGame(gameId) {
if (!gameId || selectedGameId.value === gameId) return
selectedGameId.value = gameId
await loadGame()
}
async function refreshGames() {
try {
const data = await api.listGames()
@@ -1987,10 +2051,25 @@ async function saveFeaturedOrder() {
<div class="adminSidebar__label">Game</div>
<div class="adminSidebar__group">
<button class="btn btn--primary" @click="openGameCreateModal"> 게임 생성</button>
<select :value="selectedGameId" class="select" @change="handleSelectedGameChange">
<option value="">게임을 선택해주세요</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
<input v-model="gameAdminQuery" class="input" placeholder="게임 이름 또는 ID 검색" />
<select v-model="gameAdminSort" class="select">
<option value="recent">최신순</option>
<option value="oldest">오래된순</option>
</select>
<div class="adminGamePicker">
<button
v-for="game in filteredAdminGames"
:key="game.id"
class="adminGamePicker__item"
:class="{ 'adminGamePicker__item--active': selectedGameId === game.id }"
type="button"
@click="selectAdminGame(game.id)"
>
<span class="adminGamePicker__name">{{ game.name }}</span>
<span class="adminGamePicker__meta">{{ game.id }}</span>
</button>
<div v-if="!filteredAdminGames.length" class="hint hint--tight">검색 결과가 없어요.</div>
</div>
<div v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 게임 ID: {{ selectedGameId }}</div>
</div>
<div v-if="hasSelectedGame" class="adminSidebar__group">
@@ -2100,8 +2179,18 @@ async function saveFeaturedOrder() {
<section v-if="activeTab === 'featured'" class="adminSidebar__panel">
<div class="adminSidebar__label">Image Optimization</div>
<div class="adminSidebar__group">
<input v-model="imageStatsMonth" class="input" type="month" />
<div class="adminSidebar__group adminSidebar__group--monthPicker">
<div class="monthPicker">
<select v-model="selectedImageStatsYear" class="select monthPicker__select monthPicker__select--year">
<option value="">전체 기간</option>
<option v-for="year in imageStatsYearOptions" :key="year" :value="year">{{ year }}</option>
</select>
<select v-if="selectedImageStatsYear" v-model="selectedImageStatsMonthNumber" class="select monthPicker__select monthPicker__select--month">
<option value=""> 선택</option>
<option v-for="month in imageStatsMonthOptions" :key="month.value" :value="month.value">{{ month.label }}</option>
</select>
<button v-if="imageStatsMonth" class="btn btn--ghost btn--tiny" type="button" @click="clearImageStatsMonth">전체</button>
</div>
<select v-model.number="imageStatsLimit" class="select">
<option :value="6">최근 6</option>
<option :value="12">최근 12</option>
@@ -2288,6 +2377,24 @@ async function saveFeaturedOrder() {
display: grid;
gap: 10px;
}
.adminSidebar__group--monthPicker {
align-items: start;
}
.monthPicker {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.monthPicker__select {
min-width: 0;
}
.monthPicker__select--year {
flex: 1 1 132px;
}
.monthPicker__select--month {
flex: 1 1 108px;
}
.adminSidebar__actions--stack .btn {
width: 100%;
}
@@ -2299,6 +2406,39 @@ async function saveFeaturedOrder() {
font-weight: 800;
color: rgba(255, 255, 255, 0.84);
}
.adminGamePicker {
display: grid;
gap: 8px;
max-height: 640px;
overflow: auto;
padding-right: 4px;
}
.adminGamePicker__item {
display: grid;
/* gap: 2px; */
padding: 11px 12px;
text-align: left;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
}
.adminGamePicker__item--active {
border-color: rgba(77, 127, 233, 0.58);
background: rgba(77, 127, 233, 0.12);
}
.adminGamePicker__name {
font-size: 13px;
font-weight: 800;
}
.adminGamePicker__meta {
font-size: 11px;
color: rgba(255, 255, 255, 0.56);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebarStat {
display: grid;
gap: 4px;

View File

@@ -110,16 +110,16 @@ onMounted(loadFavorites)
.select {
padding: 11px 13px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
}
.btn {
padding: 11px 13px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft-2);
color: var(--theme-text);
font-weight: 800;
cursor: pointer;
}
@@ -133,18 +133,18 @@ onMounted(loadFavorites)
}
.boardCard {
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
overflow: hidden;
display: grid;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition:
transform 0.16s ease,
background 0.16s ease;
}
.boardCard:hover {
transform: translateY(-2px);
background: rgba(70, 70, 70, 0.96);
background: var(--theme-card-bg-hover);
}
.boardCard__body {
border: 0;
@@ -172,10 +172,10 @@ onMounted(loadFavorites)
object-fit: cover;
}
.boardCard__thumbPlaceholder {
background: #555;
background: var(--theme-thumb-fallback-bg);
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
color: var(--theme-text-faint);
font-size: 13px;
font-weight: 700;
}
@@ -222,7 +222,7 @@ onMounted(loadFavorites)
height: 22px;
border-radius: 9999px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
background: var(--theme-border);
flex: 0 0 auto;
}
.boardCard__avatar--fallback {
@@ -235,7 +235,7 @@ onMounted(loadFavorites)
.favoriteStat {
flex: 0 0 auto;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
color: var(--theme-text-faint);
white-space: nowrap;
}

View File

@@ -149,7 +149,7 @@ function submitSearch() {
}
.dashboardHero__eyebrow {
font-size: 12px;
color: rgba(255, 255, 255, 0.42);
color: var(--theme-text-soft);
text-transform: uppercase;
letter-spacing: 0.08em;
}
@@ -157,15 +157,15 @@ function submitSearch() {
margin: 4px 0 6px;
font-size: 32px;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
color: var(--theme-text-strong);
}
.dashboardHero__desc {
margin: 0;
color: rgba(255, 255, 255, 0.58);
color: var(--theme-text-muted);
max-width: 720px;
}
.panel {
/* border: 1px solid rgba(255, 255, 255, 0.08); */
/* border: 1px solid var(--theme-border); */
background: transparent;
border-radius: 0;
padding: 0;
@@ -174,8 +174,8 @@ function submitSearch() {
margin: 10px 0 14px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.3);
background: rgba(239, 68, 68, 0.12);
border: 1px solid var(--theme-danger-border);
background: var(--theme-danger-bg);
}
.panel__title {
font-weight: 800;
@@ -183,7 +183,7 @@ function submitSearch() {
}
.panel__sub {
margin-top: 6px;
color: rgba(255, 255, 255, 0.56);
color: var(--theme-text-muted);
font-size: 13px;
}
.panel__head {
@@ -204,16 +204,16 @@ function submitSearch() {
min-width: 240px;
padding: 11px 13px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
}
.searchBar__button {
padding: 11px 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft-2);
color: var(--theme-text);
font-weight: 800;
cursor: pointer;
}
@@ -222,8 +222,7 @@ function submitSearch() {
}
.list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 320px));
justify-content: start;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
@@ -233,18 +232,18 @@ function submitSearch() {
.boardCard {
min-width: 0;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
color: var(--theme-text);
display: grid;
overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition:
transform 0.16s ease,
background 0.16s ease;
}
.boardCard:hover {
background: rgba(70, 70, 70, 0.96);
background: var(--theme-card-bg-hover);
transform: translateY(-2px);
}
.boardCard__body {
@@ -294,10 +293,10 @@ function submitSearch() {
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
background: #555;
background: var(--theme-thumb-fallback-bg);
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
color: var(--theme-text-faint);
font-size: 13px;
font-weight: 700;
border-radius: 18px;
@@ -363,7 +362,7 @@ function submitSearch() {
border-radius: 9999px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
background: var(--theme-border);
flex: 0 0 auto;
}
.boardCard__avatar--fallback {
@@ -378,7 +377,7 @@ function submitSearch() {
min-width: 0;
max-width: 100%;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
color: var(--theme-text-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -120,31 +120,31 @@ function thumbUrl(g) {
margin: 0 0 16px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.18);
background: rgba(239, 68, 68, 0.1);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-danger-border);
background: var(--theme-danger-bg);
color: var(--theme-text);
}
.pageHead__searchState {
margin-top: 8px;
color: rgba(255, 255, 255, 0.62);
color: var(--theme-text-muted);
}
.libraryCard {
position: relative;
text-align: left;
padding: 14px;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
color: var(--theme-text);
cursor: pointer;
display: grid;
gap: 12px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition: transform 0.16s ease, background 0.16s ease;
will-change: transform, opacity;
}
.libraryCard:hover {
background: rgba(70, 70, 70, 0.96);
background: var(--theme-card-bg-hover);
transform: translateY(-2px);
}
.libraryCard__main {
@@ -191,8 +191,8 @@ function thumbUrl(g) {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: #555;
border: 1px solid var(--theme-surface-soft-2);
background: var(--theme-thumb-fallback-bg);
overflow: hidden;
display: grid;
place-items: center;
@@ -204,7 +204,7 @@ function thumbUrl(g) {
}
.libraryCard__thumbFallback {
font-size: 14px;
color: rgba(255, 255, 255, 0.4);
color: var(--theme-text-faint);
}
.libraryCard__body {
display: grid;
@@ -241,7 +241,7 @@ function thumbUrl(g) {
.libraryEmpty {
padding: 20px 0;
color: rgba(255, 255, 255, 0.62);
color: var(--theme-text-muted);
}
@media (max-width: 1400px) {
.libraryGrid {

View File

@@ -30,8 +30,15 @@ const description = computed(() =>
: '저장한 티어표와 즐겨찾기, 프로필 설정을 이어서 관리할 수 있어요.'
)
const submitLabel = computed(() => (mode.value === 'signup' ? '가입하기' : '로그인'))
const authReady = computed(() => auth.hydrated)
const checkingSession = computed(() => !authReady.value || auth.status === 'loading')
onMounted(async () => {
if (!auth.hydrated) await auth.refresh()
if (auth.user) {
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
return
}
try {
const meta = await api.authMeta()
hasUsers.value = !!meta.hasUsers
@@ -40,6 +47,15 @@ onMounted(async () => {
}
})
watch(
() => [auth.hydrated, auth.user],
([hydrated, user]) => {
if (!hydrated || !user) return
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
},
{ immediate: true }
)
async function submit() {
error.value = ''
if (mode.value === 'signup' && password.value !== passwordConfirm.value) {
@@ -66,7 +82,11 @@ async function submit() {
</div>
</header>
<section class="authScreen">
<section v-if="checkingSession" class="authScreen authScreen--loading">
<div class="authLoading">로그인 상태를 확인하고 있어요.</div>
</section>
<section v-else class="authScreen">
<div class="authTabs" role="tablist" aria-label="로그인 또는 회원가입">
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'login' }" @click="mode = 'login'">
로그인
@@ -128,14 +148,24 @@ async function submit() {
padding-top: 4px;
}
.authScreen--loading {
min-height: 220px;
align-items: center;
}
.authLoading {
color: var(--theme-text-muted);
font-size: 15px;
}
.authTabs {
display: inline-flex;
gap: 8px;
width: fit-content;
padding: 6px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
}
.authTabs__button {
@@ -144,14 +174,14 @@ async function submit() {
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.62);
color: var(--theme-text-muted);
font-weight: 700;
cursor: pointer;
}
.authTabs__button--active {
background: rgba(76, 133, 245, 0.22);
color: rgba(255, 255, 255, 0.96);
color: var(--theme-text-strong);
}
.authFields {
@@ -166,16 +196,16 @@ async function submit() {
.field__label {
font-size: 13px;
color: rgba(255, 255, 255, 0.62);
color: var(--theme-text-muted);
}
.field__input {
width: 100%;
padding: 14px 0;
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
border-bottom: 1px solid var(--theme-border-strong);
background: transparent;
color: rgba(255, 255, 255, 0.94);
color: var(--theme-text);
outline: none;
font-size: 18px;
letter-spacing: -0.02em;
@@ -187,7 +217,7 @@ async function submit() {
.field__hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.42);
color: var(--theme-text-soft);
}
.roleBadge {
@@ -196,7 +226,7 @@ async function submit() {
border-radius: 999px;
border: 1px solid rgba(96, 165, 250, 0.28);
background: rgba(96, 165, 250, 0.1);
color: rgba(191, 219, 254, 0.92);
color: var(--theme-text);
font-size: 12px;
font-weight: 700;
}
@@ -218,14 +248,14 @@ async function submit() {
.primaryAction {
border: 1px solid rgba(76, 133, 245, 0.96);
background: rgba(76, 133, 245, 0.92);
color: #fff;
background: var(--theme-accent-bg);
color: var(--theme-accent-text);
}
.secondaryAction {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.86);
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface-soft);
color: var(--theme-text);
}
@media (max-width: 720px) {

View File

@@ -9,6 +9,7 @@ const router = useRouter()
const toast = useToast()
const myLists = ref([])
const error = ref('')
const brokenThumbnailIds = ref({})
watch(error, (message) => {
if (!message) return
@@ -37,12 +38,19 @@ function avatarFallbackOf(tierList) {
}
function tierListThumbnailUrl(tierList) {
if (!tierList?.id || brokenThumbnailIds.value[tierList.id]) return ''
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
}
function handleThumbnailError(tierListId) {
if (!tierListId || brokenThumbnailIds.value[tierListId]) return
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
}
onMounted(async () => {
try {
const data = await api.listMyTierLists()
brokenThumbnailIds.value = {}
myLists.value = data.tierLists || []
} catch (e) {
toast.error('로그인이 필요해요.')
@@ -51,53 +59,87 @@ onMounted(async () => {
})
function openList(t) {
router.push(`/editor/${t.gameId}/${t.id}`)
router.push(
"/editor/" + t.gameId + "/" + t.id,
)
}
</script>
<template>
<section class="pageWrap">
<header class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Library</div>
<h2 class="pageHead__title"> 티어표</h2>
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 있어요.</div>
</div>
</header>
<div class="card">
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
<div v-else class="list">
<article v-for="t in myLists" :key="t.id" class="boardCard">
<button class="boardCard__body" @click="openList(t)">
<div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
<section class="dashboardHero">
<div class="dashboardHero__left">
<div class="dashboardHero__eyebrow">Library</div>
<h2 class="dashboardHero__title"> 티어표</h2>
<p class="dashboardHero__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 있어요.</p>
</div>
</section>
<section class="panel">
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
<div v-else class="list">
<article v-for="t in myLists" :key="t.id" class="boardCard">
<button class="boardCard__body" @click="openList(t)">
<div class="boardCard__thumbWrap">
<img
v-if="tierListThumbnailUrl(t)"
class="boardCard__thumb"
:src="tierListThumbnailUrl(t)"
alt=""
@error="handleThumbnailError(t.id)"
/>
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ t.title }}</div>
<div class="favoriteStat"> {{ t.favoriteCount || 0 }}</div>
</div>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ t.title }}</div>
<div class="favoriteStat"> {{ t.favoriteCount || 0 }}</div>
</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
</div>
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
</div>
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
</div>
</button>
</article>
</div>
</div>
</button>
</article>
</div>
</section>
</template>
<style scoped>
.card {
border: 0;
.dashboardHero {
display: flex;
gap: 18px;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
padding: 6px 2px 18px;
}
.dashboardHero__left {
display: grid;
gap: 8px;
}
.dashboardHero__eyebrow {
font-size: 12px;
color: var(--theme-text-soft);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.dashboardHero__title {
margin: 4px 0 6px;
font-size: 32px;
letter-spacing: -0.04em;
color: var(--theme-text-strong);
}
.dashboardHero__desc {
margin: 0;
color: var(--theme-text-muted);
max-width: 720px;
}
.panel {
background: transparent;
border-radius: 0;
padding: 0;
@@ -107,29 +149,26 @@ function openList(t) {
}
.list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 320px));
justify-content: start;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.boardCard {
display: grid;
min-width: 0;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
color: var(--theme-text);
overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition:
transform 0.16s ease,
background 0.16s ease;
}
.boardCard:hover {
transform: translateY(-2px);
background: rgba(70, 70, 70, 0.96);
background: var(--theme-card-bg-hover);
}
.boardCard__body {
flex: 1 1 auto;
min-width: 0;
text-align: left;
cursor: pointer;
@@ -137,9 +176,12 @@ function openList(t) {
background: transparent;
color: inherit;
padding: 0;
width: 100%;
display: grid;
overflow: hidden;
}
.boardCard__thumbWrap {
min-width: 0;
width: 100%;
aspect-ratio: 16 / 9;
padding: 14px 14px 0;
@@ -155,55 +197,55 @@ function openList(t) {
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
background: #555;
background: var(--theme-thumb-fallback-bg);
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
color: var(--theme-text-faint);
font-size: 13px;
font-weight: 700;
border-radius: 18px;
}
.boardCard__title {
flex: 1 1 auto;
font-weight: 800;
min-width: 0;
font-weight: 900;
font-size: 18px;
line-height: 1.3;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
.boardCard__head {
min-width: 0;
padding: 16px 18px 18px;
display: grid;
gap: 8px;
min-width: 0;
overflow: hidden;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 10px;
min-width: 0;
align-items: center;
justify-content: space-between;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.boardCard__titleRow {
align-items: flex-start;
}
.boardCard__metaRow {
align-items: flex-end;
}
.boardCard__author {
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
display: inline-flex;
gap: 7px;
align-items: center;
font-size: 13px;
opacity: 0.84;
opacity: 0.86;
overflow: hidden;
}
.boardCard__authorName {
min-width: 0;
@@ -217,7 +259,7 @@ function openList(t) {
border-radius: 9999px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
background: var(--theme-border);
flex: 0 0 auto;
}
.boardCard__avatar--fallback {
@@ -229,14 +271,28 @@ function openList(t) {
.boardCard__date,
.favoriteStat {
flex: 0 0 auto;
min-width: 0;
max-width: 100%;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
color: var(--theme-text-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__date {
font-size: 10px;
}
@media (max-width: 720px) {
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1200px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.list {
grid-template-columns: 1fr;
}

View File

@@ -30,14 +30,19 @@ const avatarUrl = computed(() => {
return toApiUrl(auth.user.avatarSrc)
})
const authReady = computed(() => auth.hydrated)
const displayInitial = computed(() => {
const email = auth.user?.email || 'U'
return email[0].toUpperCase()
})
onMounted(async () => {
await auth.refresh()
if (!auth.user) router.push('/login')
if (!auth.hydrated) await auth.refresh()
if (!auth.user) {
router.replace('/login')
return
}
nickname.value = auth.user?.nickname || ''
removeAvatar.value = false
})
@@ -121,7 +126,11 @@ async function logout() {
</div>
</header>
<section v-if="auth.user" class="settingsScreen">
<section v-if="!authReady" class="settingsScreen settingsScreen--loading">
<div class="settingsLoading">계정 정보를 불러오고 있어요.</div>
</section>
<section v-else-if="auth.user" class="settingsScreen">
<div class="settingsIdentity">
<div class="avatarButtonWrap">
<button class="avatarButton" type="button" @click="openAvatarPicker">
@@ -185,6 +194,16 @@ async function logout() {
padding-top: 4px;
}
.settingsScreen--loading {
min-height: 240px;
align-items: center;
}
.settingsLoading {
color: var(--theme-text-muted);
font-size: 15px;
}
.settingsIdentity {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
@@ -202,15 +221,15 @@ async function logout() {
position: relative;
width: 120px;
height: 120px;
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid var(--theme-border-strong);
border-radius: 9999px;
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.92);
background: var(--theme-pill-bg);
color: var(--theme-text);
overflow: hidden;
cursor: pointer;
display: grid;
place-items: center;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: var(--theme-card-shadow);
}
.avatarButton__image {
@@ -222,7 +241,7 @@ async function logout() {
.avatarButton__fallback {
font-size: 34px;
font-weight: 900;
color: rgba(255, 255, 255, 0.86);
color: var(--theme-text);
}
.avatarButton__overlay {
@@ -232,7 +251,7 @@ async function logout() {
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.72));
font-size: 12px;
font-weight: 700;
color: rgba(255, 255, 255, 0.82);
color: var(--theme-text);
}
.avatarButton__remove {
@@ -243,8 +262,8 @@ async function logout() {
height: 30px;
border: 0;
border-radius: 999px;
background: rgba(10, 10, 10, 0.72);
color: rgba(255, 255, 255, 0.88);
background: var(--theme-shell-bg);
color: var(--theme-text);
display: grid;
place-items: center;
cursor: pointer;
@@ -264,7 +283,7 @@ async function logout() {
.avatarButton__remove:hover {
background: rgba(190, 24, 24, 0.88);
color: #fff;
color: var(--theme-accent-text);
}
.identityMeta {
@@ -276,7 +295,7 @@ async function logout() {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.36);
color: var(--theme-text-soft);
}
.identityMeta__title {
@@ -286,7 +305,7 @@ async function logout() {
}
.identityMeta__desc {
color: rgba(255, 255, 255, 0.58);
color: var(--theme-text-muted);
line-height: 1.6;
}
@@ -307,16 +326,16 @@ async function logout() {
.field__label {
font-size: 13px;
color: rgba(255, 255, 255, 0.62);
color: var(--theme-text-muted);
}
.field__input {
width: 100%;
padding: 14px 0;
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
border-bottom: 1px solid var(--theme-border-strong);
background: transparent;
color: rgba(255, 255, 255, 0.94);
color: var(--theme-text);
outline: none;
font-size: 18px;
letter-spacing: -0.02em;
@@ -327,12 +346,12 @@ async function logout() {
}
.field__input--readonly {
color: rgba(255, 255, 255, 0.58);
color: var(--theme-text-muted);
}
.field__hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.42);
color: var(--theme-text-soft);
}
.roleBadge {
@@ -341,7 +360,7 @@ async function logout() {
border-radius: 999px;
border: 1px solid rgba(96, 165, 250, 0.28);
background: rgba(96, 165, 250, 0.1);
color: rgba(191, 219, 254, 0.92);
color: var(--theme-text);
font-size: 12px;
font-weight: 700;
}
@@ -363,14 +382,14 @@ async function logout() {
.primaryAction {
border: 1px solid rgba(76, 133, 245, 0.96);
background: rgba(76, 133, 245, 0.92);
color: #fff;
background: var(--theme-accent-bg);
color: var(--theme-accent-text);
}
.secondaryAction {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.86);
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface-soft);
color: var(--theme-text);
}
@media (max-width: 720px) {

View File

@@ -122,24 +122,24 @@ watch(
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
color: var(--theme-text-soft);
}
.title {
margin: 4px 0 0;
font-size: 32px;
color: rgba(255, 255, 255, 0.96);
color: var(--theme-text-strong);
letter-spacing: -0.04em;
}
.desc {
margin-top: 6px;
color: rgba(255, 255, 255, 0.58);
color: var(--theme-text-muted);
}
.error {
margin: 0 0 8px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.18);
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--theme-danger-border);
background: var(--theme-danger-bg);
}
.empty {
opacity: 0.76;
@@ -151,16 +151,16 @@ watch(
}
.boardCard {
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
overflow: hidden;
display: grid;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition: transform 0.16s ease, background 0.16s ease;
}
.boardCard:hover {
transform: translateY(-2px);
background: rgba(70, 70, 70, 0.96);
background: var(--theme-card-bg-hover);
}
.boardCard__body {
border: 0;
@@ -188,10 +188,10 @@ watch(
object-fit: cover;
}
.boardCard__thumbPlaceholder {
background: #555;
background: var(--theme-thumb-fallback-bg);
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
color: var(--theme-text-faint);
font-size: 13px;
font-weight: 700;
}
@@ -238,7 +238,7 @@ watch(
height: 22px;
border-radius: 9999px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
background: var(--theme-border);
flex: 0 0 auto;
}
.boardCard__avatar--fallback {
@@ -251,7 +251,7 @@ watch(
.favoriteStat {
flex: 0 0 auto;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
color: var(--theme-text-faint);
white-space: nowrap;
}

View File

@@ -3,6 +3,9 @@ import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watc
import { useRoute, useRouter } from 'vue-router'
import Sortable from 'sortablejs'
import * as htmlToImage from 'html-to-image'
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 { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth'
@@ -19,11 +22,12 @@ const tierListId = computed(() => route.params.tierListId)
const previewMode = computed(() => route.query.preview === '1')
const gameName = ref('')
const columns = ref([{ id: 'col-1', name: '' }])
const groups = ref([
{ id: 'gS', name: 'S', itemIds: [] },
{ id: 'gA', name: 'A', itemIds: [] },
{ id: 'gB', name: 'B', itemIds: [] },
{ id: 'gC', name: 'C', itemIds: [] },
{ id: 'gS', name: 'S', itemIds: [], cells: [[]] },
{ id: 'gA', name: 'A', itemIds: [], cells: [[]] },
{ id: 'gB', name: 'B', itemIds: [], cells: [[]] },
{ id: 'gC', name: 'C', itemIds: [], cells: [[]] },
])
const pool = ref([])
@@ -47,7 +51,9 @@ const templateRequestDraftDescription = ref('')
const templateRequestSaveToMyTierList = ref(true)
const isDeleteModalOpen = ref(false)
const isGroupDeleteModalOpen = ref(false)
const isColumnDeleteModalOpen = ref(false)
const pendingRemoveGroupId = ref('')
const pendingRemoveColumnIndex = ref(-1)
const ownerId = ref('')
const authorName = ref('')
const authorAccountName = ref('')
@@ -154,38 +160,107 @@ function setIconSize(nextSize) {
iconSize.value = nextSize
}
function removeItemFromGroup(groupId, itemId) {
if (!canEdit.value || !groupId || !itemId) return
function getGroupCellIds(group, columnIndex) {
return Array.isArray(group?.cells?.[columnIndex]) ? group.cells[columnIndex] : []
}
function syncGroupItemIds(group) {
group.itemIds = (group.cells || []).flat()
}
function normalizeLoadedColumns(rawGroups) {
const fromGroup = Array.isArray(rawGroups) ? rawGroups.find((group) => Array.isArray(group?.columnNames) && group.columnNames.length) : null
const rawColumns = Array.isArray(fromGroup?.columnNames) ? fromGroup.columnNames : []
const cellCount = Math.max(1, ...(Array.isArray(rawGroups) ? rawGroups.map((group) => (Array.isArray(group?.cells) ? group.cells.length : 0)) : [0]))
const size = Math.max(rawColumns.length || 0, cellCount)
return Array.from({ length: size || 1 }, (_, index) => ({
id: rawColumns[index]?.id || `col-${index + 1}` ,
name: typeof rawColumns[index]?.name === 'string' ? rawColumns[index].name.slice(0, 16) : '',
}))
}
function normalizeLoadedGroups(rawGroups, nextColumns = columns.value) {
if (!Array.isArray(rawGroups) || !rawGroups.length) {
return [
{ id: 'gS', name: 'S', itemIds: [], cells: nextColumns.map(() => []) },
{ id: 'gA', name: 'A', itemIds: [], cells: nextColumns.map(() => []) },
{ id: 'gB', name: 'B', itemIds: [], cells: nextColumns.map(() => []) },
{ id: 'gC', name: 'C', itemIds: [], cells: nextColumns.map(() => []) },
]
}
return rawGroups.map((group, index) => {
const cells = Array.from({ length: nextColumns.length }, (_, cellIndex) => {
if (Array.isArray(group?.cells?.[cellIndex])) return [...group.cells[cellIndex]]
if (cellIndex === 0 && Array.isArray(group?.itemIds)) return [...group.itemIds]
return []
})
return {
id: typeof group?.id === 'string' && group.id ? group.id : `g-${index + 1}` ,
name: typeof group?.name === 'string' && group.name ? group.name.slice(0, 16) : 'Tier',
itemIds: cells.flat(),
cells,
}
})
}
function buildGroupPayload() {
return groups.value.map((group) => ({
id: group.id,
name: group.name,
itemIds: (group.cells || []).flat(),
cells: (group.cells || []).map((cell) => [...cell]),
columnNames: columns.value.map((column) => ({ id: column.id, name: column.name || '' })),
}))
}
function removeItemFromGroup(groupId, columnIndex, itemId) {
if (!canEdit.value || !groupId || columnIndex == null || !itemId) return
const targetGroup = groups.value.find((group) => group.id === groupId)
if (!targetGroup) return
if (!targetGroup.itemIds.includes(itemId)) return
targetGroup.itemIds = targetGroup.itemIds.filter((id) => id !== itemId)
const nextCells = [...targetGroup.cells]
nextCells[columnIndex] = getGroupCellIds(targetGroup, columnIndex).filter((id) => id !== itemId)
targetGroup.cells = nextCells
syncGroupItemIds(targetGroup)
pool.value = [itemId, ...pool.value.filter((id) => id !== itemId)]
}
function setGroupDropEl(groupId, el) {
function setGroupDropEl(groupId, columnIndex, el) {
const key = `${groupId}::${columnIndex}`
if (!el) {
delete groupDropEls.value[groupId]
delete groupDropEls.value[key]
return
}
groupDropEls.value[groupId] = el
groupDropEls.value[key] = el
}
function getListByContainer(containerEl) {
if (!containerEl) return { type: null, groupId: null }
if (!containerEl) return { type: null, groupId: null, columnIndex: null }
const t = containerEl.getAttribute('data-list-type')
if (t === 'pool') return { type: 'pool', groupId: null }
if (t === 'group') return { type: 'group', groupId: containerEl.getAttribute('data-group-id') }
return { type: null, groupId: null }
if (t === 'pool') return { type: 'pool', groupId: null, columnIndex: null }
if (t === 'group') {
return {
type: 'group',
groupId: containerEl.getAttribute('data-group-id'),
columnIndex: Number(containerEl.getAttribute('data-column-index')),
}
}
return { type: null, groupId: null, columnIndex: null }
}
function normalizeSort(containerEl) {
const ids = Array.from(containerEl.querySelectorAll('[data-item-id]')).map((n) => n.getAttribute('data-item-id'))
const meta = getListByContainer(containerEl)
if (meta.type === 'pool') pool.value = ids
if (meta.type === 'pool') {
pool.value = ids
return
}
if (meta.type === 'group') {
const g = groups.value.find((x) => x.id === meta.groupId)
if (g) g.itemIds = ids
if (!g || !Number.isInteger(meta.columnIndex)) return
const nextCells = [...g.cells]
nextCells[meta.columnIndex] = ids
g.cells = nextCells
syncGroupItemIds(g)
}
}
@@ -224,7 +299,7 @@ async function initSortables() {
onAdd: () => normalizeSort(poolEl.value),
})
dropSortables.value = Object.entries(groupDropEls.value).map(([gid, el]) =>
dropSortables.value = Object.entries(groupDropEls.value).map(([, el]) =>
Sortable.create(el, {
group: 'tier-items',
animation: 160,
@@ -257,32 +332,96 @@ async function syncSortables() {
}
}
function createGroupName() {
function createGroupName(index = groups.value.length) {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
const index = groups.value.length
if (index < alphabet.length) return alphabet[index]
return `Tier ${index + 1}`
}
function createColumnName(index = columns.value.length) {
return `${index + 1}`
}
function createCustomItemLabel(fileName = '') {
const normalized = String(fileName || '')
.replace(/\.[^.]+$/, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
return (normalized || 'custom').slice(0, 60)
}
async function addGroup() {
groups.value = [
...groups.value,
{
id: `g-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
id: `g-${Date.now()}-${Math.random().toString(16).slice(2, 8)}` ,
name: createGroupName(),
itemIds: [],
cells: columns.value.map(() => []),
},
]
await syncSortables()
}
async function addColumn() {
columns.value = [
...columns.value,
{ id: `col-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, name: createColumnName() },
]
groups.value = groups.value.map((group) => ({
...group,
cells: [...group.cells, []],
itemIds: [...group.itemIds],
}))
await syncSortables()
}
async function removeColumn(columnIndex) {
if (!canEdit.value || columns.value.length <= 1) return
const nextColumns = columns.value.filter((_, index) => index !== columnIndex)
groups.value = groups.value.map((group) => {
const nextCells = group.cells.filter((_, index) => index !== columnIndex)
const removed = Array.isArray(group.cells[columnIndex]) ? group.cells[columnIndex] : []
if (nextCells[0] && removed.length) nextCells[0] = [...removed, ...nextCells[0]]
const nextGroup = { ...group, cells: nextCells }
syncGroupItemIds(nextGroup)
return nextGroup
})
Object.keys(groupDropEls.value).forEach((key) => {
if (key.endsWith(`::${columnIndex}`)) delete groupDropEls.value[key]
})
columns.value = nextColumns
await syncSortables()
}
function openColumnDeleteModal(columnIndex) {
if (!canEdit.value || columns.value.length <= 1) return
pendingRemoveColumnIndex.value = columnIndex
isColumnDeleteModalOpen.value = true
}
function closeColumnDeleteModal() {
isColumnDeleteModalOpen.value = false
pendingRemoveColumnIndex.value = -1
}
async function confirmRemoveColumn() {
const columnIndex = pendingRemoveColumnIndex.value
closeColumnDeleteModal()
if (columnIndex < 0) return
await removeColumn(columnIndex)
}
async function performRemoveGroup(groupId) {
if (groups.value.length <= 1) return
const target = groups.value.find((group) => group.id === groupId)
if (!target) return
pool.value = [...target.itemIds, ...pool.value]
groups.value = groups.value.filter((group) => group.id !== groupId)
delete groupDropEls.value[groupId]
Object.keys(groupDropEls.value).forEach((key) => {
if (key.startsWith(`${groupId}::`)) delete groupDropEls.value[key]
})
await syncSortables()
}
@@ -310,7 +449,7 @@ function addCustomImage(file) {
const id = `c-${Date.now()}-${Math.random().toString(16).slice(2)}`
itemsById.value = {
...itemsById.value,
[id]: { id, src: url, label: file.name || 'custom', origin: 'custom', pendingFile: file },
[id]: { id, src: url, label: createCustomItemLabel(file.name), origin: 'custom', pendingFile: file },
}
pool.value = [id, ...pool.value]
}
@@ -444,7 +583,7 @@ async function uploadPendingCustomItems() {
for (const item of entries) {
const fd = new FormData()
fd.append('label', item.label || 'custom')
fd.append('label', createCustomItemLabel(item.label || 'custom'))
fd.append('image', item.pendingFile)
const res = await fetch(toApiUrl('/api/tierlists/custom-items'), {
@@ -469,10 +608,14 @@ async function uploadPendingCustomItems() {
}
itemsById.value = nextItemsById
pool.value = pool.value.map((currentId) => (currentId === item.id ? uploaded.id : currentId))
groups.value = groups.value.map((group) => ({
...group,
itemIds: group.itemIds.map((currentId) => (currentId === item.id ? uploaded.id : currentId)),
}))
groups.value = groups.value.map((group) => {
const nextGroup = {
...group,
cells: group.cells.map((cell) => cell.map((currentId) => (currentId === item.id ? uploaded.id : currentId))),
}
syncGroupItemIds(nextGroup)
return nextGroup
})
}
}
@@ -501,7 +644,7 @@ function buildPayload(existingId) {
sourceTierListId: sourceTierListId.value || '',
sourceSnapshotTitle: sourceSnapshotTitle.value || '',
sourceSnapshotAuthor: sourceSnapshotAuthor.value || '',
groups: groups.value.map((g) => ({ id: g.id, name: g.name, itemIds: g.itemIds })),
groups: buildGroupPayload(),
pool: Object.values(itemsById.value),
}
}
@@ -633,11 +776,7 @@ async function requestTemplate(type) {
isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value,
saveToMyTierList: !!templateRequestSaveToMyTierList.value,
groups: groups.value.map((group) => ({
id: group.id,
name: group.name,
itemIds: [...group.itemIds],
})),
groups: buildGroupPayload(),
boardItems: Object.values(itemsById.value),
})
@@ -667,6 +806,10 @@ async function requestTemplate(type) {
: '템플릿 업데이트 요청을 보냈어요.'
)
} catch (e) {
if (e?.message === 'custom_upload_failed') {
toast.error('커스텀 이미지 이름이 너무 길거나 업로드 조건에 맞지 않아 요청 전에 저장하지 못했어요. 아이템 이름을 60자 이하로 줄인 뒤 다시 시도해주세요.')
return
}
if (e?.status === 409) {
toast.error('이미 처리 대기 중인 같은 요청이 있어요.')
return
@@ -675,6 +818,10 @@ async function requestTemplate(type) {
toast.error('먼저 커스텀 아이템을 추가한 뒤 요청해주세요.')
return
}
if (e?.status === 400 && e?.data?.error === 'bad_request') {
toast.error('요청 제목, 설명, 아이템 이름 중 길이 제한을 넘긴 값이 없는지 확인해주세요.')
return
}
toast.error(type === 'create' ? '템플릿 등록 요청에 실패했어요.' : '템플릿 업데이트 요청에 실패했어요.')
} finally {
isRequestingTemplate.value = false
@@ -727,12 +874,13 @@ onMounted(() => {
sourceSnapshotAuthor.value = t.sourceSnapshotAuthor || ''
favoriteCount.value = Number(t.favoriteCount || 0)
isFavorited.value = !!t.isFavorited
groups.value = t.groups
columns.value = normalizeLoadedColumns(t.groups)
groups.value = normalizeLoadedGroups(t.groups, columns.value)
const map = {}
;(t.pool || []).forEach((it) => (map[it.id] = it))
itemsById.value = map
const grouped = new Set()
groups.value.forEach((g) => g.itemIds.forEach((id) => grouped.add(id)))
groups.value.forEach((group) => group.itemIds.forEach((id) => grouped.add(id)))
pool.value = Object.keys(itemsById.value).filter((id) => !grouped.has(id))
} catch (e) {
error.value = '티어표를 불러오지 못했어요.'
@@ -757,13 +905,23 @@ onUnmounted(() => {
<div class="previewOnly__sheet">
<div class="previewOnly__title">{{ effectiveTitle }}</div>
<div v-if="description" class="previewOnly__description">{{ description }}</div>
<div v-if="columns.length > 1" class="previewOnly__columns">
<div class="previewOnly__columnsSpacer" aria-hidden="true"></div>
<div class="previewOnly__columnsGrid" :style="{ '--column-count': columns.length }">
<div v-for="(column, columnIndex) in columns" :key="column.id" class="previewOnly__columnHeader">{{ column.name || ' ' + (columnIndex + 1) }}</div>
</div>
</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 v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
<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-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>
</div>
</div>
</div>
</div>
</div>
@@ -886,13 +1044,26 @@ onUnmounted(() => {
<div v-if="isGroupDeleteModalOpen" class="modalOverlay" @click.self="closeGroupDeleteModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteGroupTitle">
<div id="deleteGroupTitle" class="modalCard__title">티어 라인 삭제</div>
<div id="deleteGroupTitle" class="modalCard__title">티어 삭제</div>
<div class="modalCard__desc">
라인 삭제하면 현재 들어 있는 아이템은 모두 아래 아이템 영역으로 이동합니다. 삭제 후에도 아이템 자체는 유지돼요.
삭제하면 현재 들어 있는 아이템은 모두 아래 아이템 영역으로 이동합니다. 삭제 후에도 아이템 자체는 유지돼요.
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeGroupDeleteModal">취소</button>
<button class="btn btn--danger" @click="confirmRemoveGroup">라인 삭제</button>
<button class="btn btn--danger" @click="confirmRemoveGroup"> 삭제</button>
</div>
</div>
</div>
<div v-if="isColumnDeleteModalOpen" class="modalOverlay" @click.self="closeColumnDeleteModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteColumnTitle">
<div id="deleteColumnTitle" class="modalCard__title">티어 삭제</div>
<div class="modalCard__desc">
열을 삭제하면 현재 들어 있는 아이템은 모두 번째 열로 이동합니다. 삭제 후에도 아이템 자체는 유지돼요.
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeColumnDeleteModal">취소</button>
<button class="btn btn--danger" @click="confirmRemoveColumn"> 삭제</button>
</div>
</div>
</div>
@@ -904,7 +1075,7 @@ onUnmounted(() => {
<div class="editorMain__title">{{ gameName || gameId }}</div>
<div class="editorMain__subtitle">
<template v-if="canEdit">
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다.
/ 이름 순서 바꾸고 아이템 드래그해서 배치할 있어요.
</template>
<template v-else>
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 있어요.
@@ -921,7 +1092,12 @@ onUnmounted(() => {
<div ref="boardEl" class="board">
<div v-if="canEdit && !isExporting" class="boardTools">
<div class="boardTools__left">
<button class="btn btn--ghost" @click="addGroup">티어 추가</button>
<button class="boardActionIcon" type="button" title="행 추가" aria-label=" 추가" @click="addGroup">
<SvgIcon :src="addRowBelowIcon" :size="18" />
</button>
<button class="boardActionIcon" type="button" title="열 추가" aria-label=" 추가" @click="addColumn">
<SvgIcon :src="addColumnRightIcon" :size="18" />
</button>
</div>
<div class="boardTools__right">
<span class="boardTools__label">아이콘 크기</span>
@@ -941,6 +1117,22 @@ onUnmounted(() => {
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
<div v-if="isExporting" class="exportBoard__title">{{ effectiveTitle }}</div>
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</div>
<div v-if="columns.length > 1" class="boardColumnsHeader" :class="{ 'boardColumnsHeader--export': isExporting }">
<div class="boardColumnsHeader__spacer" aria-hidden="true"></div>
<div class="boardColumnsHeader__grid" :style="{ '--column-count': columns.length }">
<div v-for="(column, columnIndex) in columns" :key="column.id" class="boardColumnsHeader__cell">
<template v-if="isExporting">
<div class="boardColumnsHeader__name">{{ column.name || '열 ' + (columnIndex + 1) }}</div>
</template>
<template v-else>
<div class="columnHeader">
<input v-model="column.name" class="columnName" maxlength="16" placeholder="열 이름" />
<button class="columnRemoveText" type="button" title="열 삭제" aria-label=" 삭제" :disabled="columns.length <= 1" @click="openColumnDeleteModal(columnIndex)">×</button>
</div>
</template>
</div>
</div>
</div>
<div ref="groupListEl" class="rows">
<div v-for="g in groups" :key="g.id" class="row">
<div class="row__label">
@@ -948,40 +1140,46 @@ onUnmounted(() => {
<div class="row__exportName">{{ g.name }}</div>
</template>
<template v-else>
<span class="grab" title="드래그로 순서 변경" data-group-handle></span>
<input v-model="g.name" class="groupName" maxlength="16" :readonly="!canEdit" />
<button
v-if="canEdit"
class="rowRemoveText"
type="button"
title="티어 라인 삭제"
title=" 삭제"
aria-label=" 삭제"
:disabled="groups.length <= 1"
@click="openGroupDeleteModal(g.id)"
>
삭제
</button>
<span class="grab" title="드래그로 순서 변경" data-group-handle></span>
<input v-model="g.name" class="groupName" maxlength="16" :readonly="!canEdit" />
</template>
</div>
<div
class="row__drop"
:data-list-type="'group'"
:data-group-id="g.id"
:ref="(el) => setGroupDropEl(g.id, el)"
>
<div v-if="!isExporting" class="row__empty" v-show="g.itemIds.length === 0">여기로 드래그해서 배치</div>
<div v-for="id in g.itemIds" :key="id" class="cell" :data-item-id="id">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
<button
v-if="canEdit && !isExporting"
class="cellRemoveBtn"
type="button"
title="아이템 빼내기"
@pointerdown.stop
@click.stop="removeItemFromGroup(g.id, id)"
>
×
</button>
</template>
</div>
<div class="row__content" :style="{ '--column-count': columns.length }">
<div v-for="(column, columnIndex) in columns" :key="column.id" class="row__column">
<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="!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" />
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
<button
v-if="canEdit && !isExporting"
class="cellRemoveBtn"
type="button"
title="아이템 빼내기"
@pointerdown.stop
@click.stop="removeItemFromGroup(g.id, columnIndex, id)"
>
×
</button>
</div>
</div>
</div>
</div>
</div>
@@ -1210,15 +1408,42 @@ onUnmounted(() => {
line-height: 1.6;
opacity: 0.76;
}
.previewOnly__columns {
display: grid;
grid-template-columns: 132px 1fr;
gap: 10px;
margin-bottom: 10px;
}
.previewOnly__columnsGrid {
display: grid;
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
gap: 10px;
}
.previewOnly__columnHeader {
min-height: 20px;
font-size: 12px;
font-weight: 800;
text-align: center;
opacity: 0.72;
}
.previewOnly__rows {
display: grid;
gap: 10px;
}
.previewOnly__row {
display: grid;
grid-template-columns: 180px 1fr;
grid-template-columns: 132px 1fr;
gap: 10px;
}
.previewOnly__dropGrid {
display: grid;
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
gap: 10px;
}
.previewOnly__dropColumn {
display: grid;
gap: 8px;
}
.previewOnly__label {
display: grid;
place-items: center;
@@ -1526,6 +1751,51 @@ onUnmounted(() => {
.boardTools__left {
margin-right: auto;
}
.boardActionIcon {
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
}
.boardActionIcon:hover {
background: rgba(96, 165, 250, 0.12);
border-color: rgba(96, 165, 250, 0.28);
color: rgba(255, 255, 255, 0.98);
}
.boardColumnsHeader {
display: grid;
grid-template-columns: 132px 1fr;
gap: 10px;
margin-bottom: 10px;
}
.boardColumnsHeader__grid {
display: grid;
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
gap: 10px;
}
.boardColumnsHeader__cell {
min-width: 0;
position: relative;
}
.boardColumnsHeader__name {
min-height: 38px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 12px;
text-align: center;
font-size: 12px;
line-height: 1.2;
font-weight: 800;
opacity: 0.74;
}
.boardTools__label {
font-size: 13px;
opacity: 0.76;
@@ -1592,7 +1862,7 @@ onUnmounted(() => {
}
.row {
display: grid;
grid-template-columns: 180px 1fr;
grid-template-columns: 132px 1fr;
gap: 10px;
align-items: stretch;
}
@@ -1602,24 +1872,86 @@ onUnmounted(() => {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
padding: 10px 12px 30px;
padding: 14px 28px;
font-weight: 900;
overflow: hidden;
}
.grab {
cursor: grab;
opacity: 0.85;
width: 26px;
height: 26px;
.row__content {
display: grid;
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
gap: 10px;
}
.row__column {
display: grid;
gap: 8px;
min-width: 0;
}
.columnHeader {
position: relative;
display: flex;
align-items: center;
min-height: 38px;
padding: 0 28px;
}
.columnName {
width: 100%;
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
background: transparent;
color: rgba(255, 255, 255, 0.88);
padding: 4px 0;
text-align: center;
font-size: 12px;
line-height: 1.2;
font-weight: 800;
outline: none;
}
.columnName::placeholder {
color: rgba(255, 255, 255, 0.34);
}
.columnRemoveText {
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
width: 20px;
height: 20px;
display: grid;
place-items: center;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.56);
font-size: 16px;
line-height: 1;
font-weight: 800;
cursor: pointer;
}
.columnRemoveText:hover {
color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.06);
}
.columnRemoveText:disabled {
opacity: 0.32;
cursor: not-allowed;
}
.grab {
position: absolute;
top: 10px;
left: 10px;
cursor: grab;
opacity: 0.72;
width: 22px;
height: 22px;
display: grid;
place-items: center;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.16);
flex: 0 0 auto;
font-size: 12px;
}
.groupName {
width: 100%;
@@ -1627,7 +1959,7 @@ onUnmounted(() => {
background: rgba(0, 0, 0, 0.18);
color: rgba(255, 255, 255, 0.92);
border-radius: 10px;
padding: 6px 8px;
padding: 8px 10px;
font-weight: 900;
text-align: center;
outline: none;
@@ -1635,19 +1967,25 @@ onUnmounted(() => {
}
.rowRemoveText {
position: absolute;
right: 12px;
bottom: 10px;
top: 10px;
right: 10px;
width: 20px;
height: 20px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
font-size: 12px;
font-size: 16px;
line-height: 1;
font-weight: 800;
}
.rowRemoveText:hover {
color: rgba(255, 255, 255, 0.9);
color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.06);
}
.rowRemoveText:disabled {
opacity: 0.32;
@@ -1689,17 +2027,21 @@ onUnmounted(() => {
.itemNameOverlay {
position: absolute;
inset: auto 0 0 0;
padding: 16px 8px 6px;
min-height: 26px;
padding: 18px 8px 6px;
border-radius: 0 0 10px 10px;
background: linear-gradient(180deg, rgba(7, 10, 18, 0), rgba(7, 10, 18, 0.92));
color: rgba(255, 255, 255, 0.96);
font-size: 11px;
line-height: 1.25;
line-height: 1.2;
font-weight: 800;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: flex-end;
justify-content: center;
pointer-events: none;
}
.cellRemoveBtn {
@@ -2043,6 +2385,9 @@ onUnmounted(() => {
.editorCanvas {
grid-template-columns: 1fr;
}
.row__content {
grid-template-columns: 1fr;
}
.row {
grid-template-columns: 150px 1fr;
}
@@ -2074,7 +2419,18 @@ onUnmounted(() => {
.previewOnly {
padding: 14px;
}
.previewOnly__row {
.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 {