Compare commits

..

61 Commits

Author SHA1 Message Date
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
7fe4eff7b7 릴리스: v1.3.13 템플릿 요청 스냅샷과 저장 분리 2026-04-01 11:50:54 +09:00
b2a838ff34 릴리스: v1.3.12 회원 정렬 방향과 입력 길이 피드백 2026-04-01 11:15:49 +09:00
695c0bd4dd 릴리스: v1.3.11 회원 관리 모달과 최고 관리자 보호 2026-04-01 10:53:14 +09:00
7b1ba19572 릴리스: v1.3.10 게임 허브 카드 폭과 SVG 아이콘 렌더링 정리 2026-04-01 10:29:34 +09:00
b4ada4b9a2 릴리스: v1.3.9 관리자 최적화 패널 범위와 티어 행 삭제 UX 정리 2026-04-01 10:11:48 +09:00
7f9a7cc947 릴리스: v1.3.8 홈 즐겨찾기 아이콘과 레거시 업로드 정리 2026-03-31 18:53:39 +09:00
880c79bbc4 릴리스: v1.3.7 레거시 업로드 자산 마이그레이션 스크립트 추가 2026-03-31 18:44:10 +09:00
7967361cac 릴리스: v1.3.6 레거시 이미지 메타 백필 스크립트 추가 2026-03-31 18:37:51 +09:00
fde62dbb43 릴리스: v1.3.5 이미지 최적화 대시보드 기간 필터와 실사용 통계 2026-03-31 18:23:06 +09:00
a5c632d9ae 관리자 이미지 최적화 대시보드 추가 2026-03-31 17:58:21 +09:00
a19606c516 미사용 이미지 자산 정리 배치 추가 2026-03-31 17:50:11 +09:00
0581de6c17 이미지 최적화 작업 큐 추가 2026-03-31 17:44:03 +09:00
4db1b21ad5 중복 이미지 해시 재사용 추가 2026-03-31 17:39:54 +09:00
d760c7331a 업로드 이미지 WebP 최적화 1차 적용 2026-03-31 17:33:52 +09:00
ebe7a4408f 릴리스: v1.2.73 게임 허브 리스트 마감 보정 2026-03-31 17:13:28 +09:00
2fba826900 릴리스: v1.2.72 게임 허브 보기 전환과 카드 보정 2026-03-31 17:07:21 +09:00
5b047b0458 릴리스: v1.2.71 공개 티어표 복사와 보기 상태 보정 2026-03-31 16:52:17 +09:00
6dce53db7a 릴리스: v1.2.70 관리자 게임 관리 레이아웃 정리 2026-03-31 16:35:43 +09:00
3227181c24 릴리스: v1.2.69 관리자 게임 관리 재배치와 셸 전환 보정 2026-03-31 16:28:37 +09:00
fadfd0ba58 릴리스: v1.2.68 기본 티어 줄 수와 셸 아이콘 버튼 정리 2026-03-31 16:09:49 +09:00
f77ce2a580 릴리스: v1.2.67 홈 즐겨찾기 재정렬 애니메이션 추가 2026-03-31 16:06:20 +09:00
50773f799a 릴리스: v1.2.66 내 티어표 카드 밀도와 액션 단순화 2026-03-31 16:02:00 +09:00
0b283276ce 릴리스: v1.2.65 축소 검색 모달과 에디터 토글 동작 보정 2026-03-31 15:53:02 +09:00
6105208aef 릴리스: v1.2.64 에디터 토글과 요청 모달 입력 스타일 보정 2026-03-31 15:41:53 +09:00
f6dc64dfc8 릴리스: v1.2.63 메인 영역 잘림과 셸 높이 계산 보정 2026-03-31 15:36:01 +09:00
40a8dac7b6 릴리스: v1.2.62 셸 배경과 템플릿 입력 스타일 정리 2026-03-31 15:31:25 +09:00
faa2a01f6c 릴리스: v1.2.61 게임 템플릿 검색과 즐겨찾기 추가 2026-03-31 15:26:41 +09:00
2cdd627658 릴리스: v1.2.60 템플릿 요청 제목 설명 분리 2026-03-31 15:15:18 +09:00
34ddd1083d 릴리스: v1.2.59 관리자 아이템 모달 게임 표시 보강 2026-03-31 15:07:57 +09:00
b5ec579e5d 릴리스: v1.2.58 관리자 아이템 관리 compact 카드 전환 2026-03-31 15:00:07 +09:00
25b893407c 릴리스: v1.2.57 관리자 패널 정리와 업데이트 로그 정렬 2026-03-31 14:41:22 +09:00
ba6ad0593a 릴리스: v1.2.26 관리자 회원 관리와 셸 UI 개선 2026-03-31 14:17:19 +09:00
df46e43da5 릴리스: v1.2.25 홈 썸네일과 하단 액션 고정 보정 2026-03-30 18:41:48 +09:00
26d7e4c4a8 릴리스: v1.2.24 홈 카드와 하단 여백 조정 2026-03-30 18:39:05 +09:00
ed68b609bc 릴리스: v1.2.23 셸 하단 액션과 홈 카드 정리 2026-03-30 18:34:37 +09:00
876c13d99b 릴리스: v1.2.22 좌측 레일 축소형 토글 추가 2026-03-30 18:27:52 +09:00
1a7ec50a93 릴리스: v1.2.21 티어표 카드와 좌측 레일 밀도 조정 2026-03-30 17:58:51 +09:00
c1f0471f1f 릴리스: v1.2.20 패널 토글과 검색 결과 화면 정리 2026-03-30 17:46:38 +09:00
0812640ec1 릴리스: v1.2.19 왼쪽 레일 검색과 즐겨찾기 정리 2026-03-30 17:34:49 +09:00
285644bdde 릴리스: v1.2.18 공통 56px 셸 헤더 정리 2026-03-30 17:24:21 +09:00
ed4023d1bd 릴리스: v1.2.17 에디터 우측 패널 래퍼 제거 2026-03-30 17:15:50 +09:00
14a6823c3e 릴리스: v1.2.16 메인 화면 사이드와 헤더 단순화 2026-03-30 17:11:10 +09:00
adc697eb13 릴리스: v1.2.15 공통 3단 셸 구조 고정 2026-03-30 17:05:29 +09:00
5d7797925e 릴리스: v1.2.14 에디터 우측 패널 셸 컬럼 이관 2026-03-30 16:58:38 +09:00
69a8cd3600 릴리스: v1.2.13 에디터 우측 패널 회귀 수정 2026-03-30 16:55:10 +09:00
26a77bd3e1 릴리스: v1.2.12 에디터 우측 패널 토글 연결 2026-03-30 16:49:37 +09:00
d36502fe51 릴리스: v1.2.11 에디터 우측 패널 분리 보정 2026-03-30 16:46:57 +09:00
a6e78b29f1 릴리스: v1.2.10 목록 화면 툴바와 카드 반응 정리 2026-03-30 16:43:15 +09:00
2346b5fbe3 릴리스: v1.2.9 관리자 대시보드 디테일 정리 2026-03-30 16:40:09 +09:00
d724a64451 릴리스: v1.2.8 에디터 3열 구조와 SVG 아이콘 연결 2026-03-30 16:33:02 +09:00
781a131ade 릴리스: v1.2.7 공통 셸과 에디터 패널 감도 보정 2026-03-30 16:22:30 +09:00
6fceeaf15b 릴리스: v1.2.6 목록 화면 카드 레이아웃 정리 2026-03-30 16:08:00 +09:00
7886b98380 릴리스: v1.2.5 관리자 로컬 우측 패널 정리 2026-03-30 15:44:23 +09:00
b5d5f4b079 릴리스: v1.2.4 문서 버전 정리 2026-03-30 15:18:39 +09:00
fbd596bdd0 릴리스: v1.2.3 에디터 우측 편집 패널 정리 2026-03-30 15:11:50 +09:00
14607fbbbb 릴리스: v1.2.2 사이드 패널 폭과 토글 정리 2026-03-30 14:50:06 +09:00
28e23d6c26 릴리스: v1.2.1 리디자인 포커스 화면 안정화 2026-03-30 14:43:34 +09:00
48 changed files with 8263 additions and 1993 deletions

View File

@@ -1 +1 @@
모든 작업 시 프로젝트 루트의 .ai-rules.md 지침을 엄격히 준수하고, 작업 종료 시마다 docs/ 폴더 내 문서를 업데이트할 것.
모든 작업 시 프로젝트 루트의 /ai-rules.md 지침을 엄격히 준수하고, 작업 종료 시마다 docs/ 폴더 내 문서를 업데이트할 것.

2
.gitignore vendored
View File

@@ -10,3 +10,5 @@ backend/uploads/games/
backend/uploads/custom/
.DS_Store
.env.production
.vscode/

View File

@@ -17,12 +17,488 @@
"mysql2": "^3.20.0",
"nanoid": "^5.1.7",
"session-file-store": "^1.5.0",
"sharp": "^0.34.5",
"zod": "^4.3.6"
},
"devDependencies": {
"nodemon": "^3.1.14"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@types/node": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
@@ -368,6 +844,15 @@
"node": ">= 0.8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1381,7 +1866,6 @@
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -1458,6 +1942,50 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -1635,6 +2163,13 @@
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",

View File

@@ -5,7 +5,10 @@
"main": "index.js",
"scripts": {
"dev": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor nodemon --legacy-watch --watch index.js --watch src index.js",
"start": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js"
"start": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js",
"images:backfill": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/backfill-legacy-image-assets.js",
"images:migrate-legacy": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/migrate-legacy-uploads-to-assets.js",
"uploads:cleanup-legacy": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/cleanup-unreferenced-legacy-uploads.js"
},
"keywords": [],
"author": "",
@@ -20,6 +23,7 @@
"mysql2": "^3.20.0",
"nanoid": "^5.1.7",
"session-file-store": "^1.5.0",
"sharp": "^0.34.5",
"zod": "^4.3.6"
},
"devDependencies": {

View File

@@ -0,0 +1,106 @@
const fs = require('fs/promises')
const path = require('path')
const crypto = require('crypto')
const sharp = require('sharp')
const { nanoid } = require('nanoid')
const {
ensureData,
closePool,
listReferencedUploadSources,
findImageAssetBySrc,
createImageAsset,
} = require('../src/db')
const BACKEND_ROOT = path.join(__dirname, '..')
function inferMimeType(src, metadata) {
const format = String(metadata?.format || '').toLowerCase()
if (format === 'jpeg' || format === 'jpg') return 'image/jpeg'
if (format === 'png') return 'image/png'
if (format === 'gif') return 'image/gif'
if (format === 'webp') return 'image/webp'
if (format === 'svg' || format === 'svg+xml') return 'image/svg+xml'
if (format === 'avif') return 'image/avif'
const ext = path.extname(src || '').toLowerCase()
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
if (ext === '.png') return 'image/png'
if (ext === '.gif') return 'image/gif'
if (ext === '.webp') return 'image/webp'
if (ext === '.svg') return 'image/svg+xml'
if (ext === '.avif') return 'image/avif'
return 'application/octet-stream'
}
async function main() {
await ensureData()
const referencedSrcs = Array.from(new Set(await listReferencedUploadSources()))
.filter((src) => typeof src === 'string' && src.startsWith('/uploads/'))
.sort()
const summary = {
scanned: referencedSrcs.length,
skippedExisting: 0,
backfilled: 0,
missingFiles: 0,
failed: 0,
}
for (const src of referencedSrcs) {
const existing = await findImageAssetBySrc(src)
if (existing) {
summary.skippedExisting += 1
continue
}
const absolutePath = path.join(BACKEND_ROOT, src.replace(/^\//, ''))
try {
const [buffer, stat] = await Promise.all([fs.readFile(absolutePath), fs.stat(absolutePath)])
let metadata = {}
try {
metadata = await sharp(buffer, { failOn: 'none' }).metadata()
} catch (error) {
metadata = {}
}
const rawHash = crypto.createHash('sha256').update(buffer).digest('hex')
const contentHash = crypto.createHash('sha256').update(`${rawHash}|${src}`).digest('hex')
await createImageAsset({
id: nanoid(),
contentHash,
src,
mimeType: inferMimeType(src, metadata),
byteSize: Number(stat.size || 0),
originalByteSize: Number(stat.size || 0),
width: Number(metadata.width || 0),
height: Number(metadata.height || 0),
})
summary.backfilled += 1
} catch (error) {
if (error?.code === 'ENOENT') {
summary.missingFiles += 1
continue
}
if (error?.code === 'ER_DUP_ENTRY') {
summary.skippedExisting += 1
continue
}
summary.failed += 1
console.error('[backfill-legacy-image-assets] failed:', src, error?.message || error)
}
}
console.log(JSON.stringify(summary, null, 2))
}
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(async () => {
await closePool()
})

View File

@@ -0,0 +1,56 @@
const fs = require('fs/promises')
const path = require('path')
const {
ensureData,
closePool,
listReferencedUploadSources,
} = require('../src/db')
const BACKEND_ROOT = path.join(__dirname, '..')
const TARGET_DIRS = ['avatars', 'custom', 'games', 'tierlists']
async function main() {
await ensureData()
const referenced = new Set(await listReferencedUploadSources())
const deleted = []
const missing = []
let scanned = 0
for (const dir of TARGET_DIRS) {
const absoluteDir = path.join(BACKEND_ROOT, 'uploads', dir)
let entries = []
try {
entries = await fs.readdir(absoluteDir, { withFileTypes: true })
} catch (error) {
if (error?.code === 'ENOENT') continue
throw error
}
for (const entry of entries) {
if (!entry.isFile()) continue
scanned += 1
const src = `/uploads/${dir}/${entry.name}`
if (referenced.has(src)) continue
const absolutePath = path.join(absoluteDir, entry.name)
try {
await fs.unlink(absolutePath)
deleted.push(src)
} catch (error) {
if (error?.code === 'ENOENT') missing.push(src)
else throw error
}
}
}
console.log(JSON.stringify({ scanned, deletedCount: deleted.length, missingCount: missing.length, deleted, missing }, null, 2))
}
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(async () => {
await closePool()
})

View File

@@ -0,0 +1,112 @@
const fs = require('fs/promises')
const path = require('path')
const sharp = require('sharp')
const {
ensureData,
closePool,
listReferencedUploadUsage,
replaceUploadSourceReferences,
} = require('../src/db')
const { writeOptimizedImage } = require('../src/lib/image-storage')
const BACKEND_ROOT = path.join(__dirname, '..')
function inferMimeType(src, metadata) {
const format = String(metadata?.format || '').toLowerCase()
if (format === 'jpeg' || format === 'jpg') return 'image/jpeg'
if (format === 'png') return 'image/png'
if (format === 'gif') return 'image/gif'
if (format === 'webp') return 'image/webp'
if (format === 'svg' || format === 'svg+xml') return 'image/svg+xml'
if (format === 'avif') return 'image/avif'
const ext = path.extname(src || '').toLowerCase()
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
if (ext === '.png') return 'image/png'
if (ext === '.gif') return 'image/gif'
if (ext === '.webp') return 'image/webp'
if (ext === '.svg') return 'image/svg+xml'
if (ext === '.avif') return 'image/avif'
return 'application/octet-stream'
}
function getOptimizationConfig(roles) {
const roleSet = new Set(roles || [])
if (roleSet.has('avatar')) {
return { directory: 'avatars', width: 512, height: 512, fit: 'cover', quality: 82 }
}
if (roleSet.has('game-thumbnail') || roleSet.has('tierlist-thumbnail') || roleSet.has('template-thumbnail')) {
return { directory: 'legacy-thumbnails', width: 1280, height: 1280, fit: 'inside', quality: 84 }
}
return { directory: 'legacy-items', width: 512, height: 512, fit: 'inside', quality: 84 }
}
async function createFileLike(src) {
const absolutePath = path.join(BACKEND_ROOT, src.replace(/^\//, ''))
const [buffer, stat] = await Promise.all([fs.readFile(absolutePath), fs.stat(absolutePath)])
let metadata = {}
try {
metadata = await sharp(buffer, { failOn: 'none' }).metadata()
} catch (error) {
metadata = {}
}
return {
file: {
originalname: path.basename(src),
mimetype: inferMimeType(src, metadata),
size: Number(stat.size || 0),
buffer,
},
absolutePath,
}
}
async function main() {
await ensureData()
const usageEntries = await listReferencedUploadUsage()
const legacyEntries = usageEntries.filter((entry) => entry.src && entry.src.startsWith('/uploads/') && !entry.src.startsWith('/uploads/assets/'))
const summary = {
scanned: legacyEntries.length,
migrated: 0,
reusedAsset: 0,
unchanged: 0,
missingFiles: 0,
failed: 0,
updatedRows: 0,
}
for (const entry of legacyEntries) {
const config = getOptimizationConfig(entry.roles)
try {
const { file } = await createFileLike(entry.src)
const optimized = await writeOptimizedImage({ file, ...config })
if (optimized.src === entry.src) {
summary.unchanged += 1
continue
}
const replaced = await replaceUploadSourceReferences({ fromSrc: entry.src, toSrc: optimized.src })
summary.updatedRows += Number(replaced.updatedRows || 0)
if (optimized.reused) summary.reusedAsset += 1
else summary.migrated += 1
} catch (error) {
if (error?.code === 'ENOENT') {
summary.missingFiles += 1
continue
}
summary.failed += 1
console.error('[migrate-legacy-uploads-to-assets] failed:', entry.src, error?.message || error)
}
}
console.log(JSON.stringify(summary, null, 2))
}
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(async () => {
await closePool()
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,218 @@
const fs = require('fs/promises')
const path = require('path')
const crypto = require('crypto')
const sharp = require('sharp')
const { nanoid } = require('nanoid')
const {
findImageAssetByHash,
createImageAsset,
createImageOptimizationJob,
updateImageOptimizationJobStatus,
} = require('../db')
const UPLOAD_ROOT = path.join(__dirname, '..', '..', 'uploads')
const OPTIMIZED_DIR = 'assets'
const OPTIMIZATION_CONCURRENCY = Math.max(1, Number(process.env.IMAGE_OPTIMIZATION_CONCURRENCY || 1))
let activeCount = 0
const pendingJobs = []
function ensureImageMimeType(file) {
return typeof file?.mimetype === 'string' && file.mimetype.startsWith('image/')
}
function createMemoryUpload(multer, { fileSize = 6 * 1024 * 1024, maxCount } = {}) {
return multer({
storage: multer.memoryStorage(),
limits: {
fileSize,
...(typeof maxCount === 'number' ? { files: maxCount } : {}),
},
fileFilter: (req, file, cb) => {
if (ensureImageMimeType(file)) return cb(null, true)
cb(new Error('image_file_required'))
},
})
}
function scheduleQueue() {
while (activeCount < OPTIMIZATION_CONCURRENCY && pendingJobs.length) {
const job = pendingJobs.shift()
activeCount += 1
processQueuedJob(job)
.then(job.resolve)
.catch(job.reject)
.finally(() => {
activeCount = Math.max(0, activeCount - 1)
scheduleQueue()
})
}
}
async function optimizeAndPersist({ file, width, height, fit, quality }) {
const { data, info } = await sharp(file.buffer, { failOn: 'none' })
.rotate()
.resize({
width,
height,
fit,
withoutEnlargement: true,
})
.webp({ quality })
.toBuffer({ resolveWithObject: true })
const contentHash = crypto.createHash('sha256').update(data).digest('hex')
const existing = await findImageAssetByHash(contentHash)
if (existing) {
return {
src: existing.src,
size: existing.byteSize,
originalSize: existing.originalByteSize,
width: existing.width,
height: existing.height,
contentHash: existing.contentHash,
reused: true,
}
}
const filename = String(Date.now()) + '-' + nanoid() + '.webp'
const absoluteDir = path.join(UPLOAD_ROOT, OPTIMIZED_DIR)
const absolutePath = path.join(absoluteDir, filename)
const src = '/uploads/' + OPTIMIZED_DIR + '/' + filename
await fs.mkdir(absoluteDir, { recursive: true })
await fs.writeFile(absolutePath, data)
try {
const asset = await createImageAsset({
id: nanoid(),
contentHash,
src,
mimeType: 'image/webp',
byteSize: data.length,
originalByteSize: file.size || file.buffer.length,
width: info.width || 0,
height: info.height || 0,
})
return {
src: asset.src,
size: asset.byteSize,
originalSize: asset.originalByteSize,
width: asset.width,
height: asset.height,
contentHash: asset.contentHash,
reused: false,
}
} catch (error) {
try {
await fs.unlink(absolutePath)
} catch (unlinkError) {
if (unlinkError?.code !== 'ENOENT') throw unlinkError
}
if (error?.code === 'ER_DUP_ENTRY') {
const asset = await findImageAssetByHash(contentHash)
if (asset) {
return {
src: asset.src,
size: asset.byteSize,
originalSize: asset.originalByteSize,
width: asset.width,
height: asset.height,
contentHash: asset.contentHash,
reused: true,
}
}
}
throw error
}
}
async function processQueuedJob(job) {
await updateImageOptimizationJobStatus({
id: job.jobId,
status: 'processing',
startedAt: Date.now(),
})
try {
const result = await optimizeAndPersist(job)
await updateImageOptimizationJobStatus({
id: job.jobId,
status: 'completed',
optimizedByteSize: result.size,
reusedAsset: result.reused,
finishedAt: Date.now(),
})
return result
} catch (error) {
await updateImageOptimizationJobStatus({
id: job.jobId,
status: 'failed',
errorMessage: error?.message || 'optimization_failed',
finishedAt: Date.now(),
})
throw error
}
}
async function writeOptimizedImage({
file,
directory,
width,
height,
fit = 'inside',
quality = 82,
}) {
if (!file?.buffer?.length) {
const error = new Error('file_required')
error.code = 'file_required'
throw error
}
if (!ensureImageMimeType(file)) {
const error = new Error('image_file_required')
error.code = 'image_file_required'
throw error
}
const jobId = nanoid()
await createImageOptimizationJob({
id: jobId,
sourceCategory: directory,
targetDirectory: OPTIMIZED_DIR,
originalByteSize: file.size || file.buffer.length,
})
return new Promise((resolve, reject) => {
pendingJobs.push({
jobId,
file,
directory,
width,
height,
fit,
quality,
resolve: (result) => resolve({ ...result, directory }),
reject,
})
scheduleQueue()
})
}
function getImageOptimizationQueueState() {
return {
concurrency: OPTIMIZATION_CONCURRENCY,
activeCount,
pendingCount: pendingJobs.length,
}
}
module.exports = {
createMemoryUpload,
ensureImageMimeType,
writeOptimizedImage,
getImageOptimizationQueueState,
}

View File

@@ -22,6 +22,7 @@ const {
findCustomItemsByIds,
deleteCustomItems,
listUsers,
findPrimaryAdminUser,
listAdminTierLists,
findTierListById,
listAdminTemplateRequests,
@@ -30,8 +31,14 @@ const {
adminUpdateUser,
adminUpdateUserPassword,
adminDeleteUser,
listUnusedImageAssets,
deleteImageAssets,
getImageAssetStats,
listRecentImageOptimizationJobs,
clearImageOptimizationJobs,
} = require('../db')
const { requireAdmin } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
const router = express.Router()
@@ -53,13 +60,32 @@ function buildItemLabelFromFilename(file) {
return normalized || 'item'
}
const upload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'games')),
filename: (req, file, cb) => cb(null, buildUploadFilename(file)),
}),
limits: { fileSize: 6 * 1024 * 1024 },
})
const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 })
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
function decorateAdminUser(user, primaryAdmin) {
if (!user) return null
const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id
return {
...user,
isPrimaryAdmin,
isOperator: !!user.isAdmin && !isPrimaryAdmin,
role: isPrimaryAdmin ? 'owner' : user.isAdmin ? 'operator' : 'user',
}
}
async function getAdminUserContext(targetUserId, actingUserId) {
const [targetUser, actingUser, primaryAdmin] = await Promise.all([
findUserById(targetUserId),
findUserById(actingUserId),
findPrimaryAdminUser(),
])
return { targetUser, actingUser, primaryAdmin }
}
function canManageAdminRole(actingUser, primaryAdmin) {
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
}
router.post('/games', requireAdmin, async (req, res) => {
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
@@ -89,7 +115,17 @@ router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail')
if (!req.file) return res.status(400).json({ error: 'file_required' })
const game = await findGameById(req.params.gameId)
if (!game) return res.status(404).json({ error: 'not_found' })
const updated = await updateGameThumbnail(req.params.gameId, `/uploads/games/${req.file.filename}`)
const optimized = await writeOptimizedImage({
file: req.file,
directory: 'games',
width: 1280,
height: 1280,
fit: 'inside',
quality: 84,
})
const updated = await updateGameThumbnail(req.params.gameId, optimized.src)
res.json({ game: updated })
})
@@ -99,18 +135,29 @@ router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), a
const game = await findGameById(req.params.gameId)
if (!game) return res.status(404).json({ error: 'not_found' })
const manualLabel = typeof req.body?.label === 'string' ? req.body.label.trim() : ''
if (manualLabel && manualLabel.length > 60) return res.status(400).json({ error: 'bad_request' })
const labelsRaw = req.body?.labels
const labels = Array.isArray(labelsRaw) ? labelsRaw : labelsRaw ? [labelsRaw] : []
const normalizedLabels = labels.map((label) => (typeof label === 'string' ? label.trim().slice(0, 60) : ''))
if (normalizedLabels.some((label) => label.length > 60)) return res.status(400).json({ error: 'bad_request' })
const items = await Promise.all(
files.map((file, index) =>
createGameItem({
files.map(async (file, index) => {
const optimized = await writeOptimizedImage({
file,
directory: 'games',
width: 512,
height: 512,
fit: 'inside',
quality: 84,
})
return createGameItem({
id: nanoid(),
gameId: game.id,
src: `/uploads/games/${file.filename}`,
label: index === 0 && manualLabel ? manualLabel : buildItemLabelFromFilename(file),
src: optimized.src,
label: normalizedLabels[index] || buildItemLabelFromFilename(file),
})
)
})
)
res.json({ item: items[0], items })
@@ -189,6 +236,78 @@ router.get('/template-requests', requireAdmin, async (req, res) => {
res.json({ requests })
})
router.get('/image-assets/orphans', requireAdmin, async (req, res) => {
const schema = z.object({
limit: z.coerce.number().int().min(1).max(500).optional().default(100),
minAgeHours: z.coerce.number().min(0).max(24 * 365).optional().default(24),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const assets = await listUnusedImageAssets(parsed.data)
res.json({ assets })
})
async function removeImageAssetFiles(assets) {
await Promise.all(
(assets || []).map(async (asset) => {
if (!asset?.src || !asset.src.startsWith('/uploads/')) return
const absolutePath = path.join(__dirname, '..', '..', asset.src.replace(/^\//, ''))
try {
await fs.unlink(absolutePath)
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
})
)
}
router.post('/image-assets/cleanup', requireAdmin, async (req, res) => {
const schema = z.object({
limit: z.coerce.number().int().min(1).max(500).optional().default(100),
minAgeHours: z.coerce.number().min(0).max(24 * 365).optional().default(24),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const assets = await listUnusedImageAssets(parsed.data)
const deleted = await deleteImageAssets(assets.map((asset) => asset.id))
await removeImageAssetFiles(deleted)
res.json({ deletedCount: deleted.length, assets: deleted })
})
router.get('/image-assets/stats', requireAdmin, async (req, res) => {
const schema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
limit: z.coerce.number().int().min(1).max(24).optional().default(12),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const filters = { month: parsed.data.month }
const [stats, recentJobs] = await Promise.all([
getImageAssetStats(filters),
listRecentImageOptimizationJobs(parsed.data.limit, filters),
])
res.json({
stats,
filters,
queue: getImageOptimizationQueueState(),
recentJobs,
})
})
router.post('/image-assets/stats/reset', requireAdmin, async (req, res) => {
const schema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/).optional().nullable(),
})
const parsed = schema.safeParse(req.body || {})
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const deletedCount = await clearImageOptimizationJobs({ month: parsed.data.month || undefined })
res.json({ deletedCount })
})
async function removeCustomItemFiles(items) {
await Promise.all(
items.map(async (item) => {
@@ -204,33 +323,18 @@ async function removeCustomItemFiles(items) {
}
async function promoteCustomItemToGameItem({ customItem, gameId }) {
const originalName = path.basename(customItem.src || '')
const nextFilename = buildUploadFilename({ originalname: originalName })
const sourcePath = path.join(__dirname, '..', '..', customItem.src.replace(/^\//, ''))
const targetRelativePath = path.join('uploads', 'games', nextFilename)
const targetPath = path.join(__dirname, '..', '..', targetRelativePath)
await fs.copyFile(sourcePath, targetPath)
return createGameItem({
id: nanoid(),
gameId,
src: `/${targetRelativePath.replace(/\\/g, '/')}`,
src: customItem.src || '',
label: customItem.label,
})
}
async function copyUploadIntoGameAsset(src) {
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return src || ''
const originalName = path.basename(src)
const nextFilename = buildUploadFilename({ originalname: originalName })
const sourcePath = path.join(__dirname, '..', '..', src.replace(/^\//, ''))
const targetRelativePath = path.join('uploads', 'games', nextFilename)
const targetPath = path.join(__dirname, '..', '..', targetRelativePath)
await fs.copyFile(sourcePath, targetPath)
return `/${targetRelativePath.replace(/\\/g, '/')}`
if (src.startsWith('/uploads/assets/')) return src
return src
}
function uniqueTierListPoolItems(tierList) {
@@ -458,8 +562,19 @@ router.delete('/custom-items', requireAdmin, async (req, res) => {
})
router.get('/users', requireAdmin, async (req, res) => {
const users = await listUsers()
res.json({ users })
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
sort: z.enum(['recent', 'created', 'tierlists']).optional().default('recent'),
direction: z.enum(['asc', 'desc']).optional().default('desc'),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const [users, primaryAdmin] = await Promise.all([
listUsers({ queryText: parsed.data.q, sort: parsed.data.sort, direction: parsed.data.direction }),
findPrimaryAdminUser(),
])
res.json({ users: users.map((user) => decorateAdminUser(user, primaryAdmin)) })
})
router.patch('/users/:userId', requireAdmin, async (req, res) => {
@@ -471,21 +586,34 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
if (!targetUser) return res.status(404).json({ error: 'not_found' })
const actingIsPrimaryAdmin = canManageAdminRole(actingUser, primaryAdmin)
const targetIsPrimaryAdmin = primaryAdmin?.id === targetUser.id
const roleChanged = parsed.data.isAdmin !== !!targetUser.isAdmin
if (req.params.userId === req.session.userId && !parsed.data.isAdmin) {
return res.status(400).json({ error: 'self_admin_required' })
}
const user = await findUserById(req.params.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
if (targetIsPrimaryAdmin && !actingIsPrimaryAdmin) {
return res.status(403).json({ error: 'primary_admin_protected' })
}
if (targetIsPrimaryAdmin && !parsed.data.isAdmin) {
return res.status(400).json({ error: 'primary_admin_required' })
}
if (roleChanged && !actingIsPrimaryAdmin) {
return res.status(403).json({ error: 'primary_admin_only' })
}
try {
const updated = await adminUpdateUser({
id: user.id,
id: targetUser.id,
email: parsed.data.email,
nickname: parsed.data.nickname,
isAdmin: parsed.data.isAdmin,
})
res.json({ user: updated })
res.json({ user: decorateAdminUser(updated, primaryAdmin) })
} catch (e) {
if (e && e.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'email_taken' })
@@ -494,15 +622,60 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
}
})
router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar'), async (req, res) => {
const schema = z.object({
removeAvatar: z.union([z.literal('1'), z.undefined()]).optional(),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
if (!targetUser) return res.status(404).json({ error: 'not_found' })
if (primaryAdmin?.id === targetUser.id && !canManageAdminRole(actingUser, primaryAdmin)) {
return res.status(403).json({ error: 'primary_admin_protected' })
}
const optimized = req.file
? await writeOptimizedImage({
file: req.file,
directory: 'avatars',
width: 512,
height: 512,
fit: 'cover',
quality: 82,
})
: null
const shouldRemoveAvatar = parsed.data.removeAvatar === '1'
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || targetUser.avatarSrc || ''
const updated = await adminUpdateUser({
id: targetUser.id,
email: targetUser.email,
nickname: targetUser.nickname || '',
isAdmin: !!targetUser.isAdmin,
avatarSrc: nextAvatarSrc,
})
res.json({ user: decorateAdminUser(updated, primaryAdmin) })
})
router.delete('/users/:userId', requireAdmin, async (req, res) => {
if (req.params.userId === req.session.userId) {
return res.status(400).json({ error: 'cannot_delete_self' })
}
const user = await findUserById(req.params.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
if (!targetUser) return res.status(404).json({ error: 'not_found' })
await adminDeleteUser(user.id)
const actingIsPrimaryAdmin = canManageAdminRole(actingUser, primaryAdmin)
const targetIsPrimaryAdmin = primaryAdmin?.id === targetUser.id
if (targetIsPrimaryAdmin) {
return res.status(400).json({ error: 'cannot_delete_primary_admin' })
}
if (targetUser.isAdmin && !actingIsPrimaryAdmin) {
return res.status(403).json({ error: 'primary_admin_only' })
}
await adminDeleteUser(targetUser.id)
res.json({ ok: true })
})
@@ -513,11 +686,14 @@ router.patch('/users/:userId/password', requireAdmin, async (req, res) => {
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const user = await findUserById(req.params.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
if (!targetUser) return res.status(404).json({ error: 'not_found' })
if (primaryAdmin?.id === targetUser.id && !canManageAdminRole(actingUser, primaryAdmin)) {
return res.status(403).json({ error: 'primary_admin_protected' })
}
const passwordHash = await bcrypt.hash(parsed.data.password, 10)
await adminUpdateUserPassword({ id: user.id, passwordHash })
await adminUpdateUserPassword({ id: targetUser.id, passwordHash })
res.json({ ok: true })
})

View File

@@ -1,5 +1,4 @@
const express = require('express')
const path = require('path')
const bcrypt = require('bcryptjs')
const { z } = require('zod')
const { nanoid } = require('nanoid')
@@ -10,17 +9,13 @@ const {
findUserById,
createUser,
updateUserProfile,
findPrimaryAdminUser,
} = require('../db')
const { requireAuth } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
const router = express.Router()
function buildUploadFilename(file) {
const ext = path.extname(file.originalname || '').toLowerCase()
const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : ''
return `${Date.now()}-${nanoid()}${safeExt}`
}
const signupSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
@@ -28,8 +23,27 @@ const signupSchema = z.object({
const profileSchema = z.object({
nickname: z.string().trim().min(1).max(40),
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
})
async function serializeUser(user) {
if (!user) return null
const primaryAdmin = await findPrimaryAdminUser()
const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id
return {
id: user.id,
email: user.email,
nickname: user.nickname || '',
isAdmin: !!user.isAdmin,
isPrimaryAdmin,
isOperator: !!user.isAdmin && !isPrimaryAdmin,
role: isPrimaryAdmin ? 'owner' : user.isAdmin ? 'operator' : 'user',
avatarSrc: user.avatarSrc || '',
createdAt: user.createdAt,
}
}
router.post('/signup', async (req, res) => {
const parsed = signupSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -44,10 +58,9 @@ router.post('/signup', async (req, res) => {
req.session.userId = user.id
req.session.isAdmin = !!user.isAdmin
// 세션을 응답 전에 명시적으로 저장해 Set-Cookie가 확실히 내려오도록 보강
req.session.save((err) => {
req.session.save(async (err) => {
if (err) return res.status(500).json({ error: 'session_save_failed' })
res.json(user)
res.json(await serializeUser(user))
})
})
@@ -64,16 +77,9 @@ router.post('/login', async (req, res) => {
req.session.userId = user.id
req.session.isAdmin = !!user.isAdmin
req.session.save((err) => {
req.session.save(async (err) => {
if (err) return res.status(500).json({ error: 'session_save_failed' })
res.json({
id: user.id,
email: user.email,
nickname: user.nickname || '',
isAdmin: !!user.isAdmin,
avatarSrc: user.avatarSrc || '',
createdAt: user.createdAt,
})
res.json(await serializeUser(user))
})
})
@@ -86,20 +92,14 @@ router.get('/me', async (req, res) => {
if (!req.session || !req.session.userId) return res.json({ user: null })
const user = await findUserById(req.session.userId)
if (!user) return res.json({ user: null })
res.json({ user })
res.json({ user: await serializeUser(user) })
})
router.get('/meta', async (req, res) => {
res.json({ hasUsers: (await countUsers()) > 0 })
})
const upload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'avatars')),
filename: (req, file, cb) => cb(null, buildUploadFilename(file)),
}),
limits: { fileSize: 3 * 1024 * 1024 },
})
const upload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) => {
const parsed = profileSchema.safeParse(req.body)
@@ -108,14 +108,26 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
const user = await findUserById(req.session.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
const nextAvatarSrc = req.file ? `/uploads/avatars/${req.file.filename}` : user.avatarSrc || ''
const optimized = req.file
? await writeOptimizedImage({
file: req.file,
directory: 'avatars',
width: 512,
height: 512,
fit: 'cover',
quality: 82,
})
: null
const shouldRemoveAvatar = parsed.data.removeAvatar === '1'
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || ''
const updated = await updateUserProfile({
id: user.id,
nickname: parsed.data.nickname,
avatarSrc: nextAvatarSrc,
})
res.json({ user: updated })
res.json({ user: await serializeUser(updated) })
})
module.exports = router

View File

@@ -1,13 +1,32 @@
const express = require('express')
const { listGames, getGameDetail } = require('../db')
const { listGames, getGameDetail, findGameById, favoriteGame, unfavoriteGame } = require('../db')
const { requireAuth } = require('../middleware/auth')
const router = express.Router()
router.get('/', async (req, res) => {
const games = await listGames()
const games = await listGames(req.session?.userId || '')
res.json({ games })
})
router.post('/:gameId/favorite', requireAuth, async (req, res) => {
const game = await findGameById(req.params.gameId)
if (!game || game.id === 'freeform') return res.status(404).json({ error: 'not_found' })
await favoriteGame({ userId: req.session.userId, gameId: game.id })
const games = await listGames(req.session.userId)
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: true }
res.json({ game: updated })
})
router.delete('/:gameId/favorite', requireAuth, async (req, res) => {
const game = await findGameById(req.params.gameId)
if (!game || game.id === 'freeform') return res.status(404).json({ error: 'not_found' })
await unfavoriteGame({ userId: req.session.userId, gameId: game.id })
const games = await listGames(req.session.userId)
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: false }
res.json({ game: updated })
})
router.get('/:gameId', async (req, res) => {
const detail = await getGameDetail(req.params.gameId)
if (!detail) return res.status(404).json({ error: 'not_found' })

View File

@@ -1,5 +1,4 @@
const express = require('express')
const path = require('path')
const multer = require('multer')
const { z } = require('zod')
const { nanoid } = require('nanoid')
@@ -15,8 +14,10 @@ const {
findUserById,
favoriteTierList,
unfavoriteTierList,
duplicateTierListForUser,
} = require('../db')
const { requireAuth } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
const router = express.Router()
const FREEFORM_GAME_ID = 'freeform'
@@ -54,26 +55,34 @@ function getCustomTemplateItems(tierList) {
})
}
function buildUploadFilename(file) {
const ext = path.extname(file.originalname || '').toLowerCase()
const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : ''
return `${Date.now()}-${nanoid()}${safeExt}`
}
const upload = createMemoryUpload(multer, { fileSize: 6 * 1024 * 1024 })
const thumbnailUpload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024 })
const upload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'custom')),
filename: (req, file, cb) => cb(null, buildUploadFilename(file)),
}),
limits: { fileSize: 6 * 1024 * 1024 },
})
const thumbnailUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'tierlists')),
filename: (req, file, cb) => cb(null, buildUploadFilename(file)),
}),
limits: { fileSize: 6 * 1024 * 1024 },
const templateRequestSchema = z.object({
type: z.enum(['create', 'update']),
sourceTierListId: z.string().max(64).optional().default(''),
gameId: z.string().min(1).max(120),
requestTitle: z.string().trim().min(1).max(120),
requestDescription: z.string().trim().min(1).max(1000),
thumbnailSrc: z.string().max(255).optional().default(''),
isPublic: z.boolean().optional().default(false),
showCharacterNames: z.boolean().optional().default(false),
saveToMyTierList: z.boolean().optional().default(true),
groups: z.array(
z.object({
id: z.string().min(1),
name: z.string().min(1).max(16),
itemIds: z.array(z.string()).optional().default([]),
}).passthrough()
),
boardItems: z.array(
z.object({
id: z.string().min(1),
src: z.string().min(1),
label: z.string().min(1).max(60),
origin: z.enum(['game', 'custom']).default('game'),
})
),
})
const tierListUpsertSchema = z.object({
@@ -83,12 +92,16 @@ const tierListUpsertSchema = z.object({
thumbnailSrc: z.string().max(255).optional().default(''),
description: z.string().max(1000).optional().default(''),
isPublic: z.boolean().default(false),
showCharacterNames: z.boolean().optional().default(false),
sourceTierListId: z.string().max(64).optional().default(''),
sourceSnapshotTitle: z.string().max(120).optional().default(''),
sourceSnapshotAuthor: z.string().max(120).optional().default(''),
groups: z.array(
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({
@@ -130,6 +143,15 @@ router.get('/:id', async (req, res) => {
res.json({ tierList: normalizeTierList(t) })
})
router.post('/:id/duplicate', requireAuth, async (req, res) => {
const tierList = await findTierListById(req.params.id, req.session.userId)
if (!tierList) return res.status(404).json({ error: 'not_found' })
if (!tierList.isPublic && tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
const duplicated = await duplicateTierListForUser({ tierList, targetUserId: req.session.userId })
res.json({ tierList: normalizeTierList(duplicated) })
})
router.delete('/:id', requireAuth, async (req, res) => {
const tierList = await findTierListById(req.params.id, req.session.userId)
if (!tierList) return res.status(404).json({ error: 'not_found' })
@@ -165,10 +187,19 @@ router.post('/custom-items', requireAuth, upload.single('image'), async (req, re
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const optimized = await writeOptimizedImage({
file: req.file,
directory: 'custom',
width: 512,
height: 512,
fit: 'inside',
quality: 84,
})
const item = await createCustomItem({
id: nanoid(),
ownerId: req.session.userId,
src: `/uploads/custom/${req.file.filename}`,
src: optimized.src,
label: parsed.data.label,
})
@@ -177,46 +208,77 @@ router.post('/custom-items', requireAuth, upload.single('image'), async (req, re
router.post('/thumbnail', requireAuth, thumbnailUpload.single('thumbnail'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'file_required' })
res.json({ thumbnailSrc: `/uploads/tierlists/${req.file.filename}` })
const optimized = await writeOptimizedImage({
file: req.file,
directory: 'tierlists',
width: 1280,
height: 1280,
fit: 'inside',
quality: 84,
})
res.json({ thumbnailSrc: optimized.src })
})
router.post('/:id/template-request', requireAuth, async (req, res) => {
const schema = z.object({
type: z.enum(['create', 'update']),
})
const parsed = schema.safeParse(req.body)
router.post('/template-request', requireAuth, async (req, res) => {
const parsed = templateRequestSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const tierList = await findTierListById(req.params.id, req.session.userId)
if (!tierList) return res.status(404).json({ error: 'not_found' })
if (tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
const customItems = getCustomTemplateItems(tierList)
const payload = parsed.data
const normalizedBoardItems = payload.boardItems.map(normalizePoolItem)
const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom')
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
if (parsed.data.type === 'create') {
if (tierList.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
if (!(tierList.title || '').trim() || (tierList.title || '').trim() === FREEFORM_DEFAULT_TITLE) {
return res.status(400).json({ error: 'title_required' })
}
} else {
if (tierList.gameId === FREEFORM_GAME_ID) return res.status(400).json({ error: 'game_template_required' })
if (payload.type === 'create') {
if (payload.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
} else if (payload.gameId === FREEFORM_GAME_ID) {
return res.status(400).json({ error: 'game_template_required' })
}
let sourceTierList = null
if (payload.sourceTierListId) {
sourceTierList = await findTierListById(payload.sourceTierListId, req.session.userId)
if (!sourceTierList) return res.status(404).json({ error: 'not_found' })
if (sourceTierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
}
let savedTierList = null
if (payload.saveToMyTierList) {
savedTierList = await saveTierList({
id: sourceTierList?.id || undefined,
authorId: req.session.userId,
gameId: payload.gameId,
title: payload.requestTitle,
thumbnailSrc: payload.thumbnailSrc || '',
description: payload.requestDescription || '',
isPublic: !!payload.isPublic,
showCharacterNames: !!payload.showCharacterNames,
sourceTierListId: sourceTierList?.sourceTierListId || '',
sourceSnapshotTitle: sourceTierList?.sourceSnapshotTitle || '',
sourceSnapshotAuthor: sourceTierList?.sourceSnapshotAuthor || '',
groups: payload.groups,
pool: normalizedBoardItems,
})
}
try {
const request = await createTemplateRequest({
id: nanoid(),
type: parsed.data.type,
type: payload.type,
requesterId: req.session.userId,
sourceTierListId: tierList.id,
sourceGameId: tierList.gameId,
targetGameId: parsed.data.type === 'update' ? tierList.gameId : '',
title: tierList.title,
description: tierList.description || '',
thumbnailSrc: tierList.thumbnailSrc || '',
sourceTierListId: savedTierList?.id || sourceTierList?.id || '',
sourceGameId: payload.gameId,
targetGameId: payload.type === 'update' ? payload.gameId : '',
title: payload.requestTitle,
description: payload.requestDescription,
thumbnailSrc: payload.thumbnailSrc || '',
items: customItems,
groups: payload.groups,
boardItems: normalizedBoardItems,
showCharacterNames: !!payload.showCharacterNames,
})
return res.json({ request })
return res.json({ request, savedTierList: savedTierList ? normalizeTierList(savedTierList) : null })
} catch (e) {
if (e?.code === 'TEMPLATE_REQUEST_EXISTS') {
return res.status(409).json({ error: 'template_request_exists' })
@@ -244,6 +306,10 @@ router.post('/', requireAuth, async (req, res) => {
thumbnailSrc: payload.thumbnailSrc || '',
description: payload.description || '',
isPublic: !!payload.isPublic,
showCharacterNames: !!payload.showCharacterNames,
sourceTierListId: payload.sourceTierListId || existing.sourceTierListId || '',
sourceSnapshotTitle: payload.sourceSnapshotTitle || existing.sourceSnapshotTitle || '',
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || existing.sourceSnapshotAuthor || '',
groups: payload.groups,
pool: normalizedPool,
})
@@ -258,6 +324,10 @@ router.post('/', requireAuth, async (req, res) => {
thumbnailSrc: payload.thumbnailSrc || '',
description: payload.description || '',
isPublic: !!payload.isPublic,
showCharacterNames: !!payload.showCharacterNames,
sourceTierListId: payload.sourceTierListId || '',
sourceSnapshotTitle: payload.sourceSnapshotTitle || '',
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || '',
groups: payload.groups,
pool: normalizedPool,
})

View File

@@ -1,5 +1,117 @@
# 의사결정 이력
## 2026-03-30 v1.2.25
- 홈 게임 카드는 메인 썸네일까지 없애는 것보다, 큰 썸네일은 유지하고 ID 옆의 작은 보조 표시만 제거하는 편이 원래 의도와 맞다고 정리했다.
- 좌우 하단 액션 여백은 `margin`으로 푸터 전체를 밀기보다, 푸터 내부 `padding-bottom`으로 확보해야 버튼 자체는 항상 보이고 여백만 남는다고 판단했다.
## 2026-03-30 v1.2.24
- `내 티어표` 헤더의 저장 개수 stat은 정보 가치보다 시각 잡음이 더 크다고 보고, 제목/설명 중심 헤더로 단순화하는 편이 낫다고 정리했다.
- 게임 선택 카드는 티어표 카드와 달리 템플릿 선택 진입점이므로, 썸네일까지 반복하기보다 제목과 ID만 간결하게 보여주는 편이 더 적합하다고 판단했다.
- 좌우 하단 액션 버튼은 푸터 블록 안에 있더라도 화면 바닥에 너무 붙으면 무거워 보이므로, 추가 하단 여백을 두어 숨을 쉬게 하는 편이 낫다고 정리했다.
## 2026-03-30 v1.2.23
- 홈 화면의 게임 카드도 다른 목록 카드와 같은 밀도를 따라가야 하므로, 메인 라이브러리 역시 데스크톱 기본 4열을 기준으로 두는 편이 더 일관되다고 정리했다.
- 게임 허브에서 새 티어표 만들기 버튼이 본문과 우측 패널에 동시에 있으면 역할이 겹치므로, 생성 CTA는 우측 사이드 하나만 남기는 편이 맞다고 판단했다.
- 좌우 레일 액션 버튼은 스크롤되는 본문 안보다 독립된 하단 `56px` 푸터 영역에 놓는 편이 위치 인지가 더 안정적이라고 정리했다.
## 2026-03-30 v1.2.22
- 왼쪽 레일은 홈/목록/에디터 어디서든 “사라지는 패널”보다 “축소된 내비 레일”로 읽히는 편이 구조적으로 더 일관되므로, 완전 숨김 대신 아이콘 중심 축소 상태를 유지하기로 했다.
- 좌우 패널 토글은 상태마다 다른 아이콘이 바뀌기보다 방향만 고정하는 편이 덜 혼란스러우므로, 우측은 `dock_to_left`, 좌측은 `dock_to_right` 하나로 통일하기로 정리했다.
- 좌측 검색도 임시 선형 SVG보다 실제 에셋을 쓰는 편이 전체 레일 완성도가 높으므로, 사용자가 추가한 `search.svg`를 우선 적용하기로 했다.
## 2026-03-30 v1.2.21
- 티어표 목록 카드는 페이지마다 다른 메타 구성을 두기보다, `제목+좋아요 / 작성자+최종 수정일` 두 줄 문법으로 통일하는 편이 시안과 사용성 모두에 더 맞다고 정리했다.
- `내 즐겨찾기` 화면에서 “즐겨찾기한 날짜”는 컬렉션 내부 정보일 뿐 카드 핵심 정보는 아니므로, 정렬은 유지하되 카드에는 마지막 수정일만 보여주는 편이 더 읽기 쉽다고 판단했다.
- 좌측 `Favorites`는 메인 내비보다 보조 영역이어야 하므로, 같은 공간 안에서도 더 작은 썸네일·더 작은 텍스트·더 약한 대비로 눌러두는 편이 맞다고 정리했다.
## 2026-03-30 v1.2.20
- 전역 검색 입력이 이미 좌측 레일에 고정되어 있으므로, 검색 결과 화면 안에 검색 폼을 또 두는 것은 중복이라고 판단해 `/search` 화면은 결과 표시 자체에만 집중시키기로 했다.
- 중앙 워크스페이스는 셸 여백 위에 다시 큰 카드 테두리와 배경을 씌우면 시안보다 한 겹 더 두꺼워 보이므로, `workspaceBody`는 외곽 카드 없이 단일 여백 레이어만 유지하는 편이 더 맞다고 정리했다.
- 우측 패널 토글은 같은 위치에 서로 다른 상태의 버튼이 번갈아 보여야 인지가 쉬우므로, 패널이 닫혀 있을 때는 중앙 헤더에 열기 버튼을 두고 열려 있을 때는 우측 헤더에 닫기 버튼을 두는 방식으로 정리했다.
## 2026-03-30 v1.2.19
- 사용자 카드에서 프로필/로그아웃 팝업을 또 띄우는 구조는 좌측 `Settings` 메뉴와 역할이 겹치므로, 설정 진입은 메뉴 하나로만 통일하고 로그아웃은 설정 화면 안쪽에서 마무리하는 편이 더 명확하다고 정리했다.
- 좌측 `Favorites`는 단순 링크보다 “내가 최근 좋아요한 실제 티어표 바로가기”를 보여주는 쪽이 시안과 사용성 모두에 더 가깝다고 판단해, 최근 10개만 노출하고 나머지는 `즐겨찾기 더 보기`로 보내기로 했다.
- 좌측 검색은 페이지 내부 국소 검색보다 서비스 전체 공개 티어표 검색 진입점으로 쓰는 편이 더 자연스럽다고 판단해, 별도 `/search` 결과 화면을 두는 방향으로 정리했다.
## 2026-03-30 v1.2.18
- 피그마 기준 상단 구조는 페이지마다 다르게 보이면 안 되므로, 좌/중앙/우 컬럼 모두 `56px` 헤더를 고정으로 두고 내용이 없을 때도 빈 헤더 공간을 유지하는 편이 맞다고 정리했다.
- 사이트 브랜드는 좌측 레일 안쪽 카드가 아니라 중앙 워크스페이스 상단의 고정 헤더에 두는 쪽이 시안과 더 가깝고, 페이지 이동 시에도 더 일관되게 읽힌다고 판단했다.
- 에디터 화면 안에서 `.layout`이 다시 좌우 컬럼을 만들면 공통 3단 셸과 충돌하므로, 에디터 본문은 셸이 제공한 중앙 컬럼 안에서만 레이아웃을 잡아야 한다고 정리했다.
## 2026-03-30 v1.2.17
- 공통 오른쪽 레일을 쓰는 화면에서는 로컬 패널이 다시 외곽 래퍼 카드로 감싸지면 “오른쪽 레일 안의 또 다른 사이드”처럼 읽히므로, 에디터 우측 패널은 섹션들만 공통 레일 루트에 직접 배치하는 쪽이 더 일관적이라고 정리했다.
- 에디터/관리자 공통 오른쪽 컬럼은 컨테이너를 화면별로 따로 꾸미기보다, 셸의 `localRightRailRoot`가 기본 스택 문법을 제공하고 각 화면은 내부 section만 채우는 방식으로 맞추기로 했다.
## 2026-03-30 v1.2.16
- 홈 화면은 이동 경로가 이미 좌측/우측 사이드에 충분히 있으므로, 중앙 바디 상단에 상태 카드와 중복 버튼을 다시 두기보다 본문은 게임 카드에만 집중시키는 편이 더 낫다고 정리했다.
- 오른쪽 사이드도 정보가 막막하다고 해서 임시 카드를 많이 넣기보다, 우선 핵심 CTA 하나만 남기고 나중에 필요한 항목만 추가하는 편이 시안과 운영 흐름 모두에 더 적합하다고 판단했다.
## 2026-03-30 v1.2.15
- 리디자인 기준 구조는 화면마다 달라지면 안 되므로, 홈에서 보이는 `좌측 레일 / 중앙 / 우측 레일` 3단 셸을 일반 페이지 공통 뼈대로 고정하고 안쪽 콘텐츠만 바꾸는 방식으로 정리하기로 했다.
- 에디터와 관리자의 우측 패널도 예외적인 바디 내부 aside가 아니라 공통 셸의 세 번째 컬럼을 공유해야 전체 제품 구조가 일관된다고 판단했다.
## 2026-03-30 v1.2.14
- 에디터 우측 패널은 본문 내부 그리드의 일부가 아니라 공통 셸의 세 번째 컬럼이어야 메인 화면과 같은 구조로 읽히므로, Teleport로 셸 aside에 직접 붙이는 편이 맞다고 정리했다.
- 로컬 우측 패널 화면에서 “메인 안쪽 2단 레이아웃”과 “셸 3단 레이아웃”을 섞으면 계속 혼선이 생기므로, 에디터는 셸 레벨 3단 구조를 우선 기준으로 삼기로 결정했다.
## 2026-03-30 v1.2.13
- 공통 상태를 로컬 우측 패널에 연결할 때는 템플릿의 ref 자동 언래핑을 고려해야 하므로, 템플릿에서는 `.value` 없이 직접 참조하는 편이 안전하다고 다시 정리했다.
- 이번 회귀처럼 편집 화면이 통째로 무너질 수 있는 연결점은 작은 레이아웃 수정이어도 바로 복구 릴리스로 끊는 편이 낫다고 판단했다.
## 2026-03-30 v1.2.12
- 공통 상단의 패널 토글은 로컬 우측 패널 화면에서도 같은 의미로 동작해야 하므로, 에디터의 `editorSidebar`도 같은 상태를 공유해 접고 펴는 편이 일관된다고 판단했다.
- 로컬 우측 패널 화면에 공통 `rightClosed` 그리드 계산이 다시 들어오면 컬럼 수가 꼬일 수 있으므로, 에디터/관리자 화면은 셸 차원에서 별도 예외 컬럼 규칙을 유지하기로 결정했다.
## 2026-03-30 v1.2.11
- 에디터와 관리자처럼 자체 우측 패널이 있는 화면은 공통 `workspaceBody` 카드 배경 안에 다시 넣기보다, 셸 레벨에서 중앙 본문을 투명하게 풀어주는 편이 우측 사이드바 독립성이 더 잘 살아난다고 판단했다.
- 로컬 우측 패널의 핵심은 “본문 안쪽 보조 박스”가 아니라 “진짜 오른쪽 컬럼”처럼 읽히는 것이므로, 에디터에서는 본문 카드보다 패널 분리감을 먼저 확보하기로 결정했다.
## 2026-03-30 v1.2.10
- 목록 화면도 결국 같은 제품의 라이브러리 레이어이므로, 상단 통계 카드와 버튼의 높이·반경·배경을 공통 셸과 같은 문법으로 맞추는 편이 일관성이 높다고 정리했다.
- 홈 화면의 빠른 액션은 중복 의미 버튼보다 `즐겨찾기 / 내 리스트 / 커스텀 시작`처럼 실제 이동 동선이 분명한 버튼 구성이 더 적합하다고 판단했다.
- 카드 hover 반응은 화면마다 조금씩 다르게 두기보다, 모두 얕은 위로 이동과 배경 변화로 통일하는 편이 대시보드 감도를 유지하기 쉽다고 결정했다.
## 2026-03-30 v1.2.9
- 관리자 화면은 기능보다 먼저 정보 계층이 읽혀야 하므로, 현재 탭에 맞는 요약 통계를 헤더에서 먼저 보여주는 편이 운영 판단에 더 유리하다고 정리했다.
- 게임/아이템/티어표/회원 카드는 기능이 다른 대신 같은 제품 안에 있으므로, 배경층·반경·패딩은 하나의 대시보드 문법으로 맞춰 시안 톤을 더 강하게 유지하기로 결정했다.
- 우측 운영 패널은 단순 필터 모음보다 “현재 상태를 짧게 읽고 바로 액션하는 패널”에 가까워야 하므로, 입력과 통계 카드를 더 단단한 카드형 레이어로 정리하는 편이 맞다고 판단했다.
## 2026-03-30 v1.2.8
- 에디터는 “보드 편집”과 “옵션 편집”의 역할이 다르므로, 보드 옆에는 드래그용 아이템 풀을 두고 제목/설명/썸네일/저장 같은 설정은 최우측 사이드바에만 남기는 편이 맞다고 판단했다.
- 커스텀 아이템 이름 정리는 배치 중에 계속 보는 정보보다 저장 전 정리용 정보에 가까우므로, 아이템 풀 아래보다 우측 편집 패널 내부가 더 적합하다고 정리했다.
- 실제 SVG 에셋이 들어오기 시작한 만큼, 공통 셸은 새 아이콘을 우선 적용하고 나머지는 점진적으로 교체하는 방식이 안전하다고 판단했다.
## 2026-03-30 v1.2.7
- 피그마 감도는 개별 화면보다 공통 셸의 밀도와 아이콘 체계가 먼저 맞아야 하므로, 좌측/우측 레일을 먼저 아이콘형 카드 문법으로 정리하기로 했다.
- 실제 머티리얼 SVG 자산을 받기 전까지는 간단한 선형 SVG 아이콘으로 정보 구조를 먼저 맞추고, 이후 에셋 교체만으로 다듬을 수 있게 하는 편이 안전하다고 판단했다.
- 에디터는 기능은 이미 많은 상태이므로 구조를 더 바꾸기보다 보드, 툴바, 우측 편집 패널의 카드 톤을 공통 셸과 맞추는 방식으로 단계적으로 다듬기로 했다.
## 2026-03-30 v1.2.6
- 홈, 게임 허브, 내 티어표, 즐겨찾기처럼 카드 중심 화면은 한 번에 같은 카드 문법으로 맞춰야 전체 앱이 하나의 제품처럼 보이므로, 목록 화면을 우선 통일하기로 했다.
- 홈 화면은 단순 게임 버튼 모음보다 상태 카드와 CTA가 있는 라이브러리 대시보드 쪽이 피그마 톤에 더 가깝다고 판단했다.
- 게임 허브와 개인 목록도 썸네일/작성자/메타의 비중이 비슷하므로, 화면마다 다른 카드 구조를 유지하기보다 동일한 정보 계층을 반복하는 편이 더 읽기 쉽다고 정리했다.
## 2026-03-30 v1.2.5
- 관리자 화면도 에디터와 마찬가지로 공통 우측 패널보다 전용 로컬 운영 패널이 더 중요하므로, `/admin` 역시 화면 내부 `320px` 패널을 사용하는 포커스 화면으로 정리하기로 결정했다.
- 관리자 기능은 탭, 검색, 필터, 빠른 액션이 본문에 섞이면 밀도가 너무 높아지므로, 우측 패널에는 제어 요소를 모으고 중앙에는 실제 관리 대상 목록과 상세만 남기는 편이 낫다고 판단했다.
- 새 셸 단계에서는 기능을 줄이기보다 위치를 재배치하는 것이 안전하므로, 기존 게임/아이템/티어표/회원 관리 로직은 유지한 채 정보 구조만 피그마 방향으로 옮기기로 했다.
## 2026-03-30 v1.2.4
- 로그인 유도는 좌측 하단의 단일 버튼이면 충분하므로, 비로그인 상태에서 사이드 상단에 별도 안내 카드를 또 보여주는 구조는 제거하는 편이 더 깔끔하다고 판단했다.
- 티어표 편집 화면은 공통 우측 패널의 generic 문맥 카드보다 실제 편집 필드가 우측에 있는 편이 훨씬 중요하므로, 이 화면은 전용 로컬 우측 패널을 두는 쪽으로 정리했다.
- 좌측 내비가 이미 라우팅 역할을 하므로, 에디터 우측 패널에서는 “게임 목록으로” 같은 중복 이동 CTA보다 저장과 편집 자체에 집중하는 것이 맞다고 판단했다.
## 2026-03-30 v1.2.2
- 우측 패널은 본문 내부 보조 박스가 아니라 별도 컬럼으로 보이는 것이 핵심이므로, 폭을 `320px`로 고정하고 접힘/펼침도 레이아웃 레벨에서 처리하는 편이 맞다고 판단했다.
- 좌측 패널도 시안 기준 인지 폭이 중요하므로 `248px`로 고정하고, 중앙 콘텐츠는 나머지 공간을 유동적으로 쓰게 하는 구조로 정리했다.
- 우측 패널 토글은 라우트별 개별 구현보다 공통 셸의 상단 컨트롤로 두는 편이 모든 화면에서 일관된 사용성을 제공한다고 판단했다.
## 2026-03-30 v1.2.1
- 공통 셸을 먼저 올린 직후에는 에디터와 관리자처럼 자체 패널이 많은 화면이 가장 크게 깨지므로, 이 화면들은 우선 공통 우측 패널을 숨기고 중앙 폭을 회복시키는 편이 안정적이라고 판단했다.
- 목록형 카드 화면은 셸 안쪽 폭이 줄어든 상태에서 이전보다 더 많은 컬럼을 유지하면 즉시 사용성이 무너지므로, 기본 컬럼 수를 줄여 먼저 읽히는 상태를 만드는 쪽을 우선하기로 했다.
- 리디자인 초기 단계에서는 “완벽한 시안 재현”보다 먼저 실제 조작 가능한 상태를 되찾는 것이 중요하므로, 이번 단계는 안정화 릴리스로 짧게 끊어 가기로 정리했다.
## 2026-03-30 v1.2.0
- 피그마 시안은 단순 컴포넌트 교체보다 앱 전체의 정보 구조를 바꾸는 성격이 강하므로, 우선 공통 앱 셸부터 `좌측 내비 / 중앙 워크스페이스 / 우측 컨텍스트 패널`로 올리는 단계적 리디자인이 더 안전하다고 판단했다.
- 홈, 게임 허브, 내 티어표, 즐겨찾기처럼 카드 목록 중심 화면은 시안 톤을 먼저 맞추고, 에디터와 관리자처럼 상호작용이 무거운 화면은 같은 셸 안에서 후속 이관하는 방식이 리스크가 적다고 정리했다.

View File

@@ -2,17 +2,17 @@
## `/`
- 화면 파일: `frontend/src/views/HomeView.vue`
- 역할: 게임 목록 표시, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
- 역할: 데스크톱 기본 4열 게임 카드 라이브러리 대시보드, 상단 메인 썸네일과 `게임명 + 작은 ID` 메타를 가진 템플릿 선택 카드, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
- 연동 API: `GET /api/games`
## `/games/:gameId`
- 화면 파일: `frontend/src/views/GameHubView.vue`
- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 상단 썸네일/작성자 표시, 즐겨찾기 토글, 새 티어표 작성 진입
- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
- 화면 파일: `frontend/src/views/TierEditorView.vue`
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 티어표 썸네일 선택, 작성 권한 제어, 저장, 공개 여부 설정, 즐겨찾기 토글, PNG 다운로드
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`
## `/login`
@@ -22,27 +22,33 @@
## `/me`
- 화면 파일: `frontend/src/views/MyTierListsView.vue`
- 역할: 내 티어표 목록 조회, 상단 썸네일 카드 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제
- 역할: 내 티어표 목록 조회, 4열 라이브러리 카드형 썸네일 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제
- 연동 API: `GET /api/tierlists/me`, `DELETE /api/tierlists/:id`
## `/favorites`
- 화면 파일: `frontend/src/views/FavoriteTierListsView.vue`
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 편집 화면 이동, 즐겨찾기 해제
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 라이브러리 카드형 표시, 편집 화면 이동, 즐겨찾기 상태 확인
- 연동 API: `GET /api/tierlists/favorites/me`, `DELETE /api/tierlists/:id/favorite`
## `/search`
- 화면 파일: `frontend/src/views/SearchResultsView.vue`
- 역할: 좌측 전역 검색 입력에서 넘긴 키워드로 공개 티어표 전체를 검색하고, 자체 검색 툴바 없이 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 목록으로 표시
- 연동 API: `GET /api/tierlists/public?q=...`
## `/admin`
- 화면 파일: `frontend/src/views/AdminView.vue`
- 역할: `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리`과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-game-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
## `/profile`
- 화면 파일: `frontend/src/views/ProfileView.vue`
- 역할: 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장
- 역할: 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장, 설정 화면 하단 로그아웃 처리
- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`
## 공통 레이아웃
- 앱 셸 파일: `frontend/src/App.vue`
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 사용자 메뉴, 관리자 메뉴 노출 제어, 전역 우측 상단 토스트 렌더링
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker by zenn`이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
## 백엔드 진입점
- 서버 엔트리: `backend/index.js`

View File

@@ -10,6 +10,11 @@
- NAS HTTPS 리버스 프록시 운영 시 프런트 Nginx는 백엔드로 `X-Forwarded-Proto: https`를 전달하고, Express 세션은 프록시 환경에서 `secure` 쿠키를 허용하도록 설정한다.
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, preview 모드에서는 이 셸을 숨기고 콘텐츠만 렌더링한다.
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
- 좌측 패널은 필요 시 축소형 레일로 접을 수 있으며, 접힌 상태에서는 아이콘 중심 내비게이션과 축약된 바로가기만 유지한다.
- 이 3단 셸 구조는 홈, 게임 허브, 에디터, 관리자 등 일반 페이지 전반의 공통 뼈대로 유지하고, 페이지별 차이는 중앙/우측에 어떤 콘텐츠를 넣는지만 달라지도록 관리한다.
- 비로그인 상태의 로그인 유도는 좌측 하단 버튼으로만 노출하고, 좌측 상단 사용자 카드 영역에는 별도 게스트 안내 카드를 렌더링하지 않는다.
- 공통 셸의 좌측 내비, 우측 패널, 빠른 점프 버튼은 간단한 선형 SVG 아이콘과 두꺼운 카드형 버튼 문법을 공유한다.
## 데이터 저장 구조
- 메인 데이터베이스: MariaDB `tier_cursor` (기본값)
@@ -22,12 +27,35 @@
## 화면 구조
- 좌측 패널
- 사용자 요약, 빠른 검색 버튼, 주요 라우트 내비게이션, 즐겨찾기 성격의 빠른 링크, 관리자 진입 버튼을 배치한다.
- 사용자 요약, 전체 공개 티어표 검색 입력, 주요 라우트 내비게이션, 최근 즐겨찾기 티어표 바로가기, 관리자 진입 버튼을 배치한다.
- 상단 토글 버튼은 항상 고정되어 있고, 패널을 축소하면 텍스트를 숨기고 아이콘 중심 레일로 전환한다.
- `Settings`는 별도 메뉴 항목으로만 진입하며, 사용자 카드 자체는 정보 표시 용도로만 사용한다.
- 사용자 아바타는 원형 보더 스타일을 유지하고, `Favorites` 영역은 최근 즐겨찾기 티어표 최대 10개를 메인 메뉴보다 작은 밀도의 바로가기 목록으로 보여준 뒤 하단 `즐겨찾기 더 보기` 링크로 전체 즐겨찾기 화면에 연결한다.
- 중앙 워크스페이스
- 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다.
- 공통 `workspaceBody`는 별도 외곽 카드 테두리 없이 셸 여백만 제공하고, 실제 카드/패널 레이어는 각 화면 내부에서만 구성한다.
- 홈, 게임 허브, 내 티어표, 즐겨찾기, 검색 결과 화면은 같은 카드 문법(상단 16:9 썸네일, `제목+좋아요` 1행, `작성자+최종 수정일` 1행)을 공유하며, 데스크톱 기준 기본 4열 카드 그리드를 사용한다.
- 단, 홈 게임 선택 카드는 템플릿 선택용이므로 상단 메인 썸네일은 유지하되, 하단 메타는 `게임명 + 작은 ID`만 간결하게 표시한다.
- 목록 계열 화면의 상단 도구 영역은 통계 카드와 액션 버튼을 공통 높이/반경으로 맞춰, 같은 라이브러리 대시보드로 읽히도록 정리한다.
- 우측 패널
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
- 공통 토글 버튼은 패널이 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 각각 아이콘만 표시하는 방식으로 동작한다.
- 오른쪽 패널 토글은 열기/닫기 모두 `dock_to_left`, 왼쪽 패널 토글은 `dock_to_right` 아이콘으로 통일한다.
- 좌우 레일의 주요 CTA는 스크롤되는 본문과 분리된 하단 `56px` 액션 영역에 배치한다.
- 하단 액션은 화면 바닥에 바로 붙지 않도록 푸터 내부에 추가 하단 여백을 둔다.
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
- 티어표 편집 화면
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
- 공통 상단 토글 버튼은 Teleport로 이동한 로컬 편집 패널의 접힘/펼침 상태와도 연결되어, 우측 패널을 숨기면 중앙 보드 영역이 확장된다.
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다.
- 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다.
- 관리자 화면
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록은 메인 컬럼에, 우측 운영 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다.
- 상단 헤더에는 현재 탭 기준 요약 통계 카드를 배치해 운영 상태를 먼저 읽고, 각 관리 카드는 공통 대시보드 카드 문법(두꺼운 반경, 얕은 레이어 배경, 강조된 액션 버튼)을 공유한다.
## DB 스키마
- `users`
@@ -91,6 +119,7 @@
- `GET /api/games/:gameId`
- 티어표
- `GET /api/tierlists/public`
- `gameId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
- `GET /api/tierlists/me`
- `GET /api/tierlists/favorites/me`
- `GET /api/tierlists/:id`
@@ -166,6 +195,8 @@
- 티어표는 편집 화면에서 16:9 썸네일 이미지를 별도로 선택해 저장할 수 있고, 목록 카드에서는 그 이미지를 상단 대표 썸네일로 사용한다.
- 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다.
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
- 티어표 편집 화면의 우측 패널은 공통 `rightRail``localRightRailRoot`에 직접 section들을 쌓는 구조이며, 별도 외곽 래퍼 카드 없이 공통 오른쪽 컬럼 문법을 그대로 따른다.
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 고정 사이트 타이틀 `Tier Maker by zenn`을 표시한다.
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.

View File

@@ -1,19 +1,11 @@
# 할 일 및 이슈
## 즉시 확인 필요
- 피그마 기반 리디자인은 현재 공통 셸과 카드 목록 화면까지만 1차 적용된 상태이므로, 에디터/관리자 우측 옵션 패널을 시안 구조에 맞게 실제 기능 패널로 이관하는 작업이 남아 있다.
- 머티리얼 아이콘 SVG는 아직 임시 문자/배지 스타일로 대체된 부분이 있으므로, 최종 아이콘 에셋을 받아 반영하는 작업이 필요하다.
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
- 레거시 파일 정리 스크립트는 준비됐으므로, 운영 단계에서는 cron 등으로 주기 실행할지와 삭제 전 보관 기간을 함께 정한다.
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
- 템플릿 등록/업데이트 요청은 현재 승인/반려만 지원하므로, 필요하면 관리자 코멘트나 사용자용 요청 상태 이력 화면을 추가 검토한다.
- 관리자 티어표 관리의 템플릿 생성은 현재 `freeform`만 직접 지원하므로, 필요하면 일반 게임 티어표의 전체 아이템을 복제한 파생 템플릿 생성 UX도 검토한다.
- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다.
- 즐겨찾기는 현재 `내 즐겨찾기` 목록과 정렬까지 지원하므로, 필요하면 폴더 분류나 메모 같은 개인 정리추가 검토한다.
- 전역 토스트는 중복 합치기와 페이드아웃까지 지원하므로, 필요하면 액션 링크나 수동 고정(pin) 같은 상호작용 확장을 검토한다.
- 공개 티어표 검색은 현재 게임별 허브 안에서만 제공하므로, 필요하면 홈 전역 통합 검색도 검토한다.
- 즐겨찾기 토글은 현재 상세 화면 중심이므로, 필요하면 카드 목록에서도 안전한 보조 인터랙션(예: 길게 누르기, 별도 메뉴)을 검토한다.
- 이미지 최적화 기록은 월별 조회/비우기까지 지원하므로, 운영 단계에서는 보관 기간 정책과 자동 아카이브한다.
## 배포 전 작업
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
@@ -23,8 +15,6 @@
- 로컬 docker compose와 NAS MariaDB 사이의 버전 차이가 크지 않도록 유지한다.
## 중기 개선
- 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다.
- 자동 테스트와 최소한의 배포 체크리스트를 만든다.
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다.
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.

View File

@@ -1,5 +1,414 @@
# 업데이트 로그
## 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
- 템플릿 등록/업데이트 요청 모달은 이제 현재 티어표 제목·설명을 기본값으로 가져오고, 비어 있더라도 모달 안에서 바로 작성해 요청할 수 있도록 흐름을 단순화함.
- 템플릿 요청 시 `내 티어 리스트에도 저장` 토글을 추가해, 요청 스냅샷만 관리자에게 전달할지 아니면 현재 양식도 내 티어표로 함께 저장할지 분리함.
- 관리자 템플릿 요청 관리는 더 이상 원본 티어표 링크에 의존하지 않고, 요청 시점의 그룹/아이템/이름표시 상태를 그대로 담은 스냅샷 미리보기를 직접 열어 확인할 수 있게 확장함.
## 2026-04-01 v1.3.12
- 관리자 회원 관리 상단에 정렬 방향 선택을 추가해, 최근 활동순·가입순·작성 티어표순을 각각 오름차순/내림차순으로 다시 볼 수 있게 확장함.
- 회원 정보 수정, 새 게임 생성, 비밀번호 초기화 모달은 Settings 톤 입력 스타일을 유지하면서 각 입력칸에 글자 수 피드백을 함께 보여주도록 정리함.
- 로그인, 설정, 티어 에디터 제목·설명·요청 제목·요청 설명·티어 행 이름에도 최대 길이와 현재 입력 길이 안내를 붙여, 제출 전에 제한을 바로 인지할 수 있게 개선함.
## 2026-04-01 v1.3.11
- **회원 관리 편집 모달 전환**: 관리자 회원 카드를 읽기 전용 정보 카드로 바꾸고, `회원 정보 수정` 버튼으로 Settings 톤의 편집 모달에서 이메일/닉네임/운영자 권한을 저장하도록 재구성
- **회원 검색/정렬 추가**: 회원 관리 상단에 이메일/닉네임 검색과 `최근 활동순`, `가입순`, `작성 티어표 많은 순` 정렬을 추가해 운영자가 원하는 기준으로 목록을 다시 볼 수 있도록 확장
- **최고 관리자 보호 도입**: 가장 먼저 생성된 관리자 계정을 `최고 관리자`로 구분하고, 운영자는 최고 관리자 권한/아바타/비밀번호/삭제를 변경할 수 없도록 백엔드 보호 로직과 역할 메타데이터를 추가
## 2026-04-01 v1.3.10
- 게임 허브 공개 티어표 카드 그리드는 최소/최대 폭을 고정해, 목록이 1~2장뿐일 때도 카드가 화면 전체를 먹으며 과하게 커지지 않도록 보정함.
- 티어표 행 삭제는 상단 아이콘 대신 우측 하단의 작은 텍스트 액션으로 바꿔, 랭크 카드 안에서 더 조용하고 정돈된 편집 흐름으로 정리함.
- 공통 `SvgIcon` 컴포넌트를 추가하고 앱 셸, 홈 즐겨찾기, 관리자 회원 액션 같은 UI 아이콘은 `img` 대신 SVG 아이콘 컴포넌트로 렌더링하도록 전환함.
## 2026-04-01 v1.3.9
- 관리자 오른쪽 사이드의 Image Optimization 패널은 이제 기본 탭인 목록 관리에서만 노출되도록 줄여, 게임/아이템/티어표/회원 관리 화면에서는 실제 작업 패널에 더 집중할 수 있게 정리함.
- 커스텀 아이템 상세의 '이미 사용 중인 게임' 목록에서는 개인 보드용 freeform 템플릿을 제외하고, 실제 템플릿에 연결된 게임만 보이도록 다듬음.
- 티어표 행 삭제는 큰 버튼 대신 우측 상단의 작은 x 아이콘으로 바꾸고, 삭제 시 아이템이 풀 영역으로 돌아간다는 안내를 포함한 확인 모달을 거친 뒤 삭제되도록 개선함.
## 2026-03-31 v1.3.8
- 홈 화면 게임 즐겨찾기 버튼은 일반 문자 별 대신 'kid_star.svg' 아이콘을 사용하도록 바꿔, 기존 아이콘 시스템과 같은 문법으로 정리함.
- 실제로 더 이상 참조되지 않는 예전 업로드 파일을 정리하는 레거시 업로드 클린업 스크립트를 추가하고, 루트/백엔드 실행 스크립트도 함께 연결함.
- todo 문서도 이제 운영 반영 후 레거시 파일 정리 배치를 주기화하는 쪽으로 기준을 갱신함.
## 2026-03-31 v1.3.7
- 현재 참조 중인 레거시 업로드를 다시 최적화 자산 경로로 편입하고 DB 참조를 일괄 교체하는 1회 마이그레이션 스크립트를 추가함.
- 아바타/썸네일/아이템 역할에 따라 기존 업로드를 512px 또는 1280px 규격으로 다시 정리해, 실제 참조 경로도 '/uploads/assets/' 체계에 점진적으로 수렴시킬 수 있게 함.
- 루트와 백엔드에 레거시 마이그레이션 실행 스크립트를 연결하고, todo 문서도 다음 단계 기준으로 갱신함.
## 2026-03-31 v1.3.6
- 현재 참조 중인 레거시 업로드 파일을 'image_assets' 메타에 안전하게 편입하는 1회 백필 스크립트를 추가해, 과거 이미지도 최적화 대시보드와 같은 통계 체계 안에서 집계할 수 있게 함.
- 루트와 백엔드에 백필 실행 스크립트를 연결해 운영 중 필요할 때 즉시 재실행할 수 있도록 정리함.
- todo 문서의 즉시 확인 항목도 백필 완료 상태에 맞춰 후속 마이그레이션 과제로 갱신함.
## 2026-03-31 v1.3.5
- 관리자 이미지 최적화 대시보드는 이제 'image_assets'만이 아니라 현재 실제로 참조 중인 업로드 파일 전체를 합산해, 기존 레거시 업로드까지 포함한 실사용 용량을 함께 보여주도록 확장함.
- 최근 최적화 작업은 기본 12건으로 늘리고 6/12/24건 선택과 월 단위 필터를 지원해, 특정 기간 사용량과 최적화 이력을 운영 관점에서 바로 확인할 수 있게 정리함.
- 관리자에서 월별 또는 전체 최적화 기록을 비우는 정리 액션을 추가하고, todo 문서도 현재 이미지 최적화 흐름에 맞게 갱신함.
## 2026-03-31 v1.3.4
- 관리자 API에 이미지 자산 통계 엔드포인트를 추가해 총 자산 수, 현재 용량, 원본 대비 절감 용량/절감률, 작업 누적 상태를 조회할 수 있게 확장함.
- 관리자 오른쪽 사이드 하단에 `Image Optimization` 패널을 추가해 큐 상태, 절감 통계, 최근 최적화 작업을 바로 확인할 수 있도록 대시보드를 구성함.
- 미사용 자산 정리 API와 작업 기록 큐를 기반으로, 운영 중 이미지 스토리지 상태를 관리자 화면에서 직접 점검할 수 있는 흐름을 완성함.
## 2026-03-31 v1.3.3
- `image_assets` 참조를 전수 점검해 아무 곳에서도 사용하지 않는 최적화 이미지 자산만 추려내는 정리 배치 로직을 추가함.
- 관리자용 미사용 자산 조회/정리 API를 추가해 오래된 고아 이미지 자산을 미리 확인하거나 실제로 삭제할 수 있도록 확장함.
- 관리자 승격/템플릿 생성 과정은 기존 `/uploads/assets/` 자산을 그대로 재사용하도록 바꿔, 불필요한 복제 파일이 다시 생기지 않게 정리함.
## 2026-03-31 v1.3.2
- 업로드 최적화는 이제 백엔드 내부 대기열을 통해 처리되어, 다수 이미지가 한 번에 들어와도 설정된 동시성 안에서 순차적으로 안정적으로 변환되도록 정리함.
- `image_optimization_jobs` 작업 기록 테이블을 추가해 queued/processing/completed/failed 상태와 원본·최적화 용량, 재사용 여부, 시작/종료 시각을 저장하도록 확장함.
- 현재 라우트 응답 방식은 유지하면서도 내부적으로는 큐를 타도록 구조를 바꿔, 이후 관리자 대시보드와 작업 통계 화면을 바로 얹을 수 있는 기반을 마련함.
## 2026-03-31 v1.3.1
- 최적화된 WebP 결과물 기준으로 SHA-256 해시를 계산해, 같은 이미지가 다시 업로드되면 새 파일을 저장하지 않고 기존 자산을 재사용하도록 중복 이미지 해시 검사를 추가함.
- 이미지 자산 메타데이터를 저장하는 `image_assets` 테이블을 도입해 파일 경로, 해시, 원본 대비 최적화 용량, 해상도를 함께 기록하도록 확장함.
- 중복 업로드 경쟁 상황에서도 고유 해시 충돌을 안전하게 처리하고, 새 파일 저장에 실패하면 즉시 정리하도록 업로드 헬퍼를 보강함.
## 2026-03-31 v1.3.0
- 백엔드 업로드 파이프라인을 메모리 기반으로 전환하고, 대표 썸네일·게임 썸네일·커스텀 아이템·게임 기본 아이템·아바타를 서버에서 즉시 WebP로 변환해 저장하도록 정리함.
- 아이템 이미지는 최대 512px 규격으로 리사이즈하고, 티어표/게임 썸네일은 긴 변 기준 1280px 안쪽으로 최적화해 원본 이미지를 별도로 보관하지 않는 흐름으로 전환함.
- 업로드 최적화 공통 헬퍼를 추가해 앞으로 중복 해시 검사, 비동기 최적화 큐, 용량 통계 대시보드를 같은 경로 위에 확장할 수 있는 기반을 마련함.
## 2026-03-31 v1.2.73
- 게임 허브 리스트형 보기의 썸네일을 48px 밀도로 축소해 한 줄이 과하게 커 보이던 인상을 줄이고, 더 많은 티어표를 한눈에 볼 수 있게 조정함.
- 깨진 대표 썸네일은 `img` alt 텍스트가 길게 노출되지 않도록 에러 시 즉시 플레이스홀더로 대체하고, 제목/메타 말줄임을 더 보강해 레이아웃 붕괴를 막음.
## 2026-03-31 v1.2.72
- 게임 허브 공개 티어표 목록은 카드 폭과 제목/메타 줄 계산을 다시 조정해, 브라우저 폭에 따라 썸네일과 정보가 카드 밖으로 넘치던 레이아웃 깨짐을 보정함.
- 상단 워크스페이스 헤더에 grid/list 보기 토글을 추가하고, 게임 허브는 그리드 카드형과 가로 리스트형을 즉시 전환해 볼 수 있도록 확장함.
## 2026-03-31 v1.2.71
- 게임 허브 공개 티어표 카드는 자동 폭 그리드와 2줄 제목/유연한 메타 배치로 보정해, 브라우저 폭이 줄어들어도 썸네일과 텍스트가 카드 밖으로 넘치지 않도록 정리함.
- 공개 티어표 상세에서는 다른 사용자의 티어표를 복사해 내 작업본으로 가져오는 기능을 추가하고, 복사본에는 원본 제목/작성자 정보를 작은 출처 메모로 남기도록 확장함.
- 보기 전용 티어표의 미배치 아이템은 더 어둡고 흐리게 표시하고 `미배치` 상태를 붙여, 내 보드처럼 조작 가능한 인상을 줄이도록 보정함.
## 2026-03-31 v1.2.70
- 관리자 게임 관리의 썸네일 드롭존을 카드 안 카드 구조 대신, 썸네일 전체 위에 하단 오버레이 문구를 얹는 단일 미디어 영역으로 정리함.
- 게임 관리 본문 상단 안내 패널과 과한 설명 문구를 제거하고, 비선택 상태는 `게임을 선택해 주세요.` 한 줄 중심의 empty 상태로 단순화함.
- 새 게임 생성 버튼은 게임 선택과 함께 오른쪽 사이드로 옮겨, 게임 관리 흐름을 선택·생성·썸네일 지정까지 한쪽 패널에서 처리하도록 정리함.
## 2026-03-31 v1.2.69
- 좌우 사이드 축소/확대 시 텍스트를 즉시 `display:none` 처리하던 방식을 줄이고, 폭·투명도 기반 전환으로 바꿔 아이콘이 떨리는 듯한 느낌을 완화함.
- 관리자 게임 관리는 오른쪽 사이드에서 게임 선택과 썸네일 지정을 담당하도록 재배치하고, 본문은 기본 아이템 추가/이름 입력/목록 관리에 집중하도록 정리함.
- 게임 기본 아이템 추가는 업로드 직후 각 파일 이름을 바로 수정할 수 있는 draft 입력 행을 넣고, 선택한 이름이 서버에 함께 저장되도록 관리자 업로드 API를 확장함.
## 2026-03-31 v1.2.68
- 내 티어표 카드 그리드는 각 카드가 화면 전체 너비를 과도하게 먹지 않도록 최대 폭을 제한해, 1~2개만 있을 때도 적당한 카드 크기를 유지하도록 조정함.
- 새 티어표 기본 그룹은 기존 S/A/B/C/D 5줄 대신 S/A/B/C 4줄로 시작하게 바꾸고, 좌우 사이드 토글 아이콘 버튼은 외곽선과 배경을 제거해 더 가볍게 정리함.
## 2026-03-31 v1.2.67
- 홈 화면 게임 템플릿 즐겨찾기 버튼 위치 변경은 유지하면서, 즐겨찾기 on/off 시 카드가 즉시 튀지 않고 부드럽게 재정렬되도록 이동/페이드 전환을 추가함.
- 별 아이콘을 눌렀을 때 카드가 즐겨찾기 우선순위 위치로 자연스럽게 이동해 전체 라이브러리 전환감이 덜 거칠게 보이도록 보정함.
## 2026-03-31 v1.2.66
- 내 티어표 카드 하단의 큰 삭제 버튼은 제거하고, 삭제는 상세 편집 화면에서만 하도록 흐름을 단순화함.
- 내 티어표 카드 그리드를 고정 4/3/2열에서 `auto-fit` 기반 최소 폭 카드로 바꾸고, 제목/메타가 좁은 화면에서도 말줄임과 유연한 폭 계산을 유지하도록 보정함.
## 2026-03-31 v1.2.65
- 에디터 옵션 토글의 라벨과 스위치 순서를 바꾼 뒤 체크 상태 셀렉터가 끊긴 문제를 보정해, 왼쪽 라벨·오른쪽 스위치 배치에서도 정상 동작하도록 수정함.
- 왼쪽 사이드 축소 상태 검색은 전용 모달의 기본 스타일이 빠져 있던 문제를 복구해, 다시 중앙 상단 검색 오버레이로 열리도록 정리함.
## 2026-03-31 v1.2.64
- 메인 콘텐츠가 길어질 때 스크롤 끝이 화면 바닥에 붙지 않도록 중앙 워크스페이스 하단 여백을 추가하고, 긴 작업 화면에서도 마감선이 답답하지 않게 보정함.
- 템플릿 요청 모달 입력창을 Settings 화면과 같은 어두운 언더라인 입력 문법으로 통일하고, 에디터의 공개/이름 표시 옵션은 체크박스 대신 스위치형 토글로 재구성함.
## 2026-03-31 v1.2.63
- 앱 셸과 워크스페이스에 걸려 있던 고정 `100dvh` 높이를 풀어, 본문이 길어질 때 중앙 `main` 영역이 잘리거나 접히는 현상을 보정함.
- 좌우 레일은 그대로 화면 기준 높이를 유지하되, 중앙 작업 영역은 내용만큼 자연스럽게 늘어나도록 높이 계산을 다시 정리함.
## 2026-03-31 v1.2.62
- 템플릿 요청 모달의 제목/설명 입력을 Settings 화면과 같은 어두운 입력 문법으로 맞춰 흰 배경/흰 글자처럼 보이던 문제를 정리함.
- 앱 셸은 사이드 기본 바탕색을 중심으로 재정리하고, 중앙 바디에 배경과 좌우 보더를 줘 긴 스크롤에서도 사이드가 잘리는 듯한 인상을 줄이도록 조정함.
## 2026-03-31 v1.2.61
- Game Library 왼쪽 검색을 전체 티어표 검색이 아니라 게임 템플릿 검색으로 바꾸고, 홈 화면에서 검색어에 맞는 게임만 필터링하도록 조정함.
- 게임 템플릿에 사용자별 즐겨찾기 별 아이콘을 추가하고, 즐겨찾기한 게임이 관리자 고정 순서보다 우선 노출되도록 백엔드와 홈 화면을 함께 확장함.
- 앱 셸의 100vh 높이 계산을 100dvh와 고정 행 구조로 정리해, 콘텐츠가 없어도 생기던 불필요한 세로 스크롤을 줄임.
## 2026-03-31 v1.2.60
- 관리자 티어표 관리 카드에서 사용자가 입력한 설명을 제목 아래에 함께 노출해 요청 의도를 더 빨리 파악할 수 있게 함.
- 템플릿 등록/업데이트 요청은 이제 에디터 모달에서 제목과 설명을 별도로 입력받고, 예시 문구와 함께 전송하도록 정리함.
## 2026-03-31 v1.2.59
- 관리자 아이템 상세 모달의 게임 선택을 전용 상태로 분리해 기본 선택값이 비어 있도록 바꾸고, 썸네일 아래에 배치해 정보/액션과 시각적으로 분리함.
- 커스텀 아이템이 실제로 사용 중인 게임 목록을 백엔드에서 함께 내려주고, 템플릿 요청 생성 폼에는 게임 ID와 게임 이름 라벨을 추가해 구분을 명확히 함.
## 2026-03-31 v1.2.58
- 관리자 아이템 관리 카드를 썸네일과 제목만 보이는 compact 카드로 줄여, 대량 업로드된 이미지도 훨씬 높은 밀도로 탐색할 수 있게 정리함.
- 카드 클릭 시 상세 정보를 모달로 열고 이미지 다운로드, 기본 템플릿 추가, 삭제를 모달 안에서 결정하는 흐름으로 바꿈.
## 2026-03-31 v1.2.57
- 관리자 오른쪽 사이드에서 Featured, Game Summary, Users 패널을 완전히 제거하고, 티어표 요청 모드에는 모드 전환 탭만 남기도록 정리함.
## 2026-03-31 v1.2.56
- 관리자 아이템 관리 카드 그리드에 최대 폭을 줘서 결과가 1~2개일 때 카드가 과하게 늘어나지 않도록 조정함.
- 관리자 오른쪽 사이드에서 Featured, Game Summary, Users 요약 패널과 티어표 요청 새로고침/대기 개수 영역을 제거해 중복 정보를 정리함.
## 2026-03-31 v1.2.55
- 관리자 게임 관리 썸네일 입력을 파일 버튼 대신 클릭/드래그형 드롭존으로 바꿔 에디터 쪽 업로드 경험과 맞춤.
- 관리자 아이템 관리 카드를 세로 카드 구조로 재정리해 긴 파일명과 버튼 문구에도 레이아웃이 무너지지 않도록 보정함.
## 2026-03-31 v1.2.54
- 관리자 게임 상세 로딩 전에 호출되던 preview reset helper를 복구해, 게임 선택 시 런타임 오류로 상세 패널이 비어 있던 문제를 보정함.
- 선택 실패 시 원인을 더 쉽게 확인할 수 있도록 로딩 실패 안내와 콘솔 에러 로그를 추가함.
## 2026-03-31 v1.2.53
- 관리자 게임 관리에서 새 게임 만들기 카드를 제거하고, 헤더 버튼으로 여는 모달 기반 생성 흐름으로 정리함.
- 게임 선택은 명시적인 변경 핸들러로 다시 묶어 선택 즉시 상세 정보를 불러오도록 보강함.
## 2026-03-31 v1.2.52
- 관리자 게임 관리에서 선택 이벤트를 놓치지 않도록 `selectedGameId`와 탭 진입 시점을 감시해 상세 정보를 자동으로 다시 불러오도록 보정함.
- 선택 후 잠시 비어 보이던 구간을 줄이기 위해 로딩 상태와 선택된 게임 ID 안내를 추가함.
## 2026-03-31 v1.2.51
- 운영 비밀값이 들어 있는 `.env.production`과 로컬 에디터 설정 `.vscode/``.gitignore`에 추가해 푸시 대상에서 제외함.
## 2026-03-31 v1.2.50
- 관리자 회원 아바타 삭제 버튼 조건을 명확히 하고 hover 표시를 visibility까지 포함해 보정해 다른 사용자 카드에서도 안정적으로 노출되도록 조정함.
- 삭제 배지 아이콘을 흰색으로 보정하고 어두운 배경 위에서 더 잘 보이도록 스타일을 다듬음.
## 2026-03-31 v1.2.49
- 관리자 회원 저장 후 통계 정보가 흔들리던 문제를 줄이기 위해 저장/아바타 변경 뒤 회원 목록을 다시 동기화하도록 보정함.
- 회원 아바타 액션을 hover 기반으로 재배치해 평소에는 숨기고, 마우스 오버 시에만 수정 오버레이와 삭제 버튼이 나타나도록 조정함.
## 2026-03-31 v1.2.48
- 관리자 회원 관리 배지를 Settings 화면의 Administrator 스타일로 통일하고, 카드 우측 상단에 걸치는 형태로 재배치함.
- 관리자 권한 체크박스를 제거하고 작은 텍스트 액션과 확인 모달을 거쳐 draft 상태만 바꾸는 흐름으로 정리함.
## 2026-03-31 v1.2.47
- 관리자 회원 관리에서 비밀번호 초기화와 삭제를 실제 모달 플로우로 연결하고, 저장 버튼은 회원 정보 변경 시에만 활성화되도록 정리함.
- 상단 휴지통 아이콘과 불필요 문구를 제거하고, 관리자도 회원 썸네일을 카드 안에서 바로 수정/삭제할 수 있게 보완함.
## 2026-03-31 v1.2.46
- **회원 액션 플로우 수정**: 회원 카드의 불필요한 안내 문구와 상단 삭제 아이콘을 제거하고, 비밀번호 초기화/회원 삭제를 각각 전용 확인 모달로 재구성
- **저장 버튼 활성 조건 정리**: 회원정보 저장은 필드가 실제로 바뀐 경우에만 활성화되고, 비밀번호 초기화와 삭제 아이콘은 즉시 사용할 수 있도록 조정
## 2026-03-31 v1.2.45
- **회원 카드 액션 재구성**: 비밀번호 초기화와 회원 삭제를 아이콘 액션으로 축소하고, `회원정보 저장` 버튼은 실제 변경이 있을 때만 활성화되도록 조정
- **관리자 아바타 편집 지원**: 관리자도 회원 아바타를 클릭해 변경하거나 삭제할 수 있도록 전용 업로드 API와 카드 UI를 추가
## 2026-03-31 v1.2.44
- **관리자 탭 구조 재정리**: `목록 관리``게임 관리`를 분리하고, 게임 생성/선택 흐름을 우측 사이드가 아닌 본문 전용 작업 화면으로 이동
- **회원/액션 레이아웃 정리**: 회원 카드의 작성 수/최근 활동을 텍스트형 정보로 단순화하고, 관리 버튼의 줄바꿈이 어색하지 않도록 액션 그리드를 보정
## 2026-03-31 v1.2.43
- **이름 표시 옵션 추가**: 티어 에디터 우측 옵션에 `캐릭터 이름 표시` 토글을 추가하고, 보드 안에서는 이미지 하단 오버레이 라벨로 표시되도록 개선
- **저장/불러오기 연동**: 이름 표시 옵션이 저장된 티어표와 다운로드 이미지에도 그대로 반영되도록 프런트/백엔드 저장 구조를 확장
## 2026-03-31 v1.2.42
- **에디터 보드 폭 기준 정리**: 티어표 보드 영역을 저장 이미지 기준에 맞춰 최대 약 `960px` 폭으로 묶고, 넓은 화면에서는 아이템 풀이 남는 공간을 더 가져가도록 조정
- **아이템 풀 카드형 통일**: 넓은 화면에서도 우측 아이템 목록을 카드형 그리드로 바꿔 한 번에 더 많은 아이템을 보고 드래그할 수 있도록 개선
## 2026-03-31 v1.2.41
- **에디터 하단 아이템 풀 카드형 전환**: 브라우저 폭이 `980px` 이하로 줄어 아이템 풀이 티어표 아래로 내려오면, 세로 리스트 대신 `이미지 위 / 이름 아래` 카드형 그리드로 전환되도록 조정
- **소형 폭 열 수 최적화**: 약 `800px` 전후에서는 6열 그리드가 유지되고, 더 작은 폭에서는 4열/3열로 자연스럽게 줄어들며 긴 이름은 가운데 정렬된 말줄임 형태로 보이도록 정리
## 2026-03-31 v1.2.40
- **목록 카드 메타 정리**: `내 티어표`, `즐겨찾기`, `검색 결과`, `게임 목록` 카드의 작성자 썸네일을 원형으로 통일하고, 메타 행 간격과 날짜 크기(`10px`)를 조정했으며 날짜 정렬을 위해 `boardCard__metaRow``align-items: flex-end`로 보정
- **게임 허브 CTA 좌측 하단 이동**: 게임 목록 화면의 `새 티어표 만들기` 버튼을 오른쪽 사이드에서 제거하고, 왼쪽 하단 액션 영역으로 옮겨 관리자 메뉴와 같은 버튼 문법으로 정리
- **필수 우측 패널 자동 열기**: 티어 메이커/관리자처럼 오른쪽 사이드 사용이 필요한 페이지는 패널이 닫혀 있더라도 진입 시 자동으로 열리게 해, 도구 접근성과 이후 광고 노출 흐름을 함께 보정
## 2026-03-31 v1.2.39
- **홈 하단 액션 재배치**: 홈 오른쪽 사이드의 `커스텀 티어표 만들기` CTA를 제거하고, 로그인/관리자 메뉴가 있던 왼쪽 하단 액션 영역으로 옮겨 같은 버튼 문법으로 정리
- **우측 중복 액션 축소**: 일반 화면에서 중복되던 `로그인 하러가기` 계열 우측 CTA는 제거하고, 오른쪽 레일은 광고/도구 용도로만 유지하도록 단순화
- **회원가입 확인 입력 추가**: 로그인 화면 회원가입 모드에 비밀번호 확인 필드를 추가하고, 버튼 문구를 `로그인 / 가입하기 / 취소` 같은 한글 흐름으로 정리
## 2026-03-31 v1.2.38
- **로그인 화면 문법 통일**: 로그인/회원가입 화면을 기존 카드형에서 Settings와 같은 단일 컬럼 계정 설정 스타일로 재구성해 두 화면의 톤을 통일
- **일반 우측 레일 광고 슬롯 전환**: 에디터/관리자처럼 실제 도구가 필요한 화면을 제외하면 오른쪽 레일은 중복 액션 버튼 대신 AdSense 수직형 반응형 슬롯을 기본으로 표시하도록 정리
## 2026-03-31 v1.2.37
- **대표 썸네일 드래그 업로드 추가**: 우측 대표 썸네일 영역도 드래그앤드롭으로 이미지를 받을 수 있게 하고, 여러 파일을 드롭하면 첫 번째만 사용된다는 안내 토스트를 표시하도록 수정
- **삭제/업데이트 요청 액션 경량화**: 우측 하단의 삭제와 템플릿 업데이트 요청을 무거운 정식 버튼 대신 작은 보조 링크형 액션으로 정리해 실제 주 행동과 시각적으로 분리
- **확인 모달 보강**: 템플릿 업데이트 요청과 티어표 삭제는 이제 브라우저 기본 얼럿 대신 전용 확인 모달을 통해 안내 후 진행되도록 변경
## 2026-03-31 v1.2.36
- **축소 검색 모달 재정의**: 좌측 레일 축소 상태에서는 검색 아이콘 클릭 시 카드형 다이얼로그 대신, 화면 중앙보다 약간 위에 뜨는 단일 검색 바와 은은한 암전 오버레이로 재구성하고 `ESC`/바깥 클릭으로 닫을 수 있게 보정
- **드롭 영역 위치 재조정**: 커스텀 이미지 추가 영역을 전체 `editorCanvas` 하단이 아니라 왼쪽 티어표 컬럼 내부의 보드 바로 아래로 옮겨, 오른쪽 아이템 목록 길이와 무관하게 가까운 위치에서 추가할 수 있도록 수정
## 2026-03-31 v1.2.35
- **축소 좌측 검색 동작 수정**: 접힌 상태의 검색 아이콘은 이제 즉시 모달을 열고, 일반 상태에서만 폼 제출이 되도록 분기해 실제 팝업이 보이도록 수정
- **우측 레일 높이 제한 해제**: 공통 `max-height: calc(100vh - 56px)` 규칙은 왼쪽 레일에만 남기고, 오버레이 상태를 포함한 오른쪽 레일은 별도 높이 제한 없이 내용 전체가 자연스럽게 흐르도록 조정
- **커스텀 업로드 영역 하단 이동**: 커스텀 이미지 드래그 영역과 파일 선택 버튼을 아이템 풀 아래가 아니라 티어표 섹션 하단으로 옮겨, 긴 아이템 목록과 충돌하지 않도록 정리
## 2026-03-31 v1.2.34
- **축소 좌측 검색 팝업 추가**: 왼쪽 레일이 접힌 상태에서 검색 아이콘을 누르면 즉시 검색 입력이 가능한 모달 팝업이 뜨도록 바꾸고, 셸 톤에 맞는 블러/글래스 스타일로 정리
- **에디터 빈 우측 섹션 제거**: 티어 메이커 우측 패널의 네 번째 빈 박스는 `즐겨찾기` 버튼 래퍼였고, 조건이 맞지 않을 때 박스만 남지 않도록 섹션 자체를 조건부 렌더링으로 수정
- **우측 레일 스크롤 구조 완화**: 오른쪽 패널은 이제 본문 전체가 자연스럽게 세로 스크롤되고, 로컬 패널 루트의 불필요한 최소 높이를 제거해 내용이 늘어나도 잘려 보이는 느낌을 줄임
## 2026-03-31 v1.2.33
- **우측 패널 토글 위치 보정**: 소형 해상도에서도 오른쪽 패널 열기 버튼이 본문 아래로 내려가지 않도록 워크스페이스 헤더 최상단 액션 영역으로 이동
- **모바일 좌측 레일 단순화**: 모바일에서는 좌측 레일 접기 버튼을 숨기고, 축소 상태가 남아 있더라도 텍스트와 사용자 메타를 다시 보여주도록 보정해 아이콘만 덩그러니 남는 상황을 제거
- **모바일 축소 상태 자동 해제**: 화면 폭이 모바일 범위로 들어오면 좌측 레일 축소 상태를 자동으로 풀어, 작은 화면에서는 항상 읽을 수 있는 메뉴 형태를 유지
## 2026-03-31 v1.2.32
- **왼쪽 레일 축소 상태 재정의**: 축소 시 사용자 정보는 아바타만 남기고, 메뉴는 아이콘만 보이도록 숨김 처리해 중앙 정렬이 자연스럽게 되도록 정리
- **축소 레일 검색/관리자 처리 보정**: 접힌 상태에서는 검색 입력을 숨기고 아이콘 중심으로 단순화했으며, 아이콘이 없는 하단 관리자 버튼은 축소 모드에서 숨김 유지
- **우측 패널 소형 해상도 오버레이 전환**: `1200px` 이하에서는 오른쪽 패널을 고정 컬럼 대신 오버레이 패널로 띄우고, 본문 상단 쪽에 다시 열기 버튼을 배치해 패널을 잃어버리지 않도록 수정
## 2026-03-31 v1.2.31
- **사이드 아이콘 에셋 정리**: 좌측 `Favorites` 메뉴도 제공된 `favorite.svg`를 사용하도록 바꿔, 다른 사이드 아이콘 및 패널 토글 SVG와 같은 자산 흐름으로 통일
- **프로필 아바타 삭제 UX 개선**: `Settings`에서 텍스트형 `이미지 제거` 버튼을 없애고, 아바타 썸네일 우측 상단의 고정 아이콘 버튼으로 삭제하도록 변경해 레이아웃 흔들림을 제거
- **셸 코드 정리**: `App.vue`의 비어 있던 감시 코드를 제거해 현재 사용자 수정 위에 불필요한 잔여 로직이 남지 않도록 정리
## 2026-03-31 v1.2.30
- **왼쪽 즐겨찾기 섹션 제거**: 좌측 레일의 `즐겨찾기 보기` 섹션을 삭제하고, 상단 내비의 즐겨찾기 메뉴만 진입점으로 유지
- **Settings 화면 리디자인**: 프로필 설정 화면을 카드형 대신 단일 컬럼의 미니멀한 계정 설정 레이아웃으로 재구성
- **아바타 클릭 업로드/삭제 UX**: 파일 input 노출을 없애고, 아바타를 클릭해 이미지 업로드와 제거를 처리하는 최근 앱 스타일 인터랙션으로 변경
- **백엔드 아바타 제거 지원**: 프로필 저장 API가 아바타 삭제 요청도 함께 처리하도록 확장
## 2026-03-30 v1.2.29
- **왼쪽 즐겨찾기 목록 제거**: 좌측 레일의 최근 즐겨찾기 목록과 관련 데이터 로딩 로직을 제거하고, `즐겨찾기 보기` 링크만 유지하도록 단순화
- **불필요한 즐겨찾기 API 호출 제거**: 사이드바 표시만을 위해 수행되던 즐겨찾기 목록 요청을 없애 초기 렌더 비용을 줄임
## 2026-03-30 v1.2.28
- **사이드 스크롤 영역 재분리**: 좌우 레일에서 스크롤되는 콘텐츠 영역과 하단 액션 영역을 분리해, 상단 헤더 높이와 무관하게 버튼이 항상 최초 화면 안에 보이도록 수정
- **레일 바디 overflow 구조 수정**: 레일 전체가 아니라 내부 콘텐츠만 스크롤되게 바꿔, 하단 버튼이 다시 스크롤 아래로 밀리는 문제를 해소
## 2026-03-30 v1.2.27
- **사이드 하단 버튼 즉시 노출**: 좌우 하단 액션 버튼을 별도 푸터가 아니라 각 레일의 스크롤 바디 안으로 옮기고, 남는 공간을 밀어내는 spacer 구조로 바꿔 스크롤 없이도 처음부터 하단에 보이도록 수정
- **56px 하단 여백 제거**: 기존 고정 푸터 높이와 추가 하단 패딩을 제거해, 하단 액션이 자연스럽게 레일 마지막 줄에 붙도록 정리
## 2026-03-30 v1.2.26
- **페이지 헤더 정렬 통일**: `Games`, `내 리스트`, `즐겨찾기`, `Settings` 화면이 모두 같은 전역 헤더 문법과 높이를 사용하도록 정리해, 페이지 이동 시 상단 블록 위치가 미묘하게 흔들리던 문제를 완화
- **헤더 내부 패딩 제거**: 워크스페이스 본문에 이미 좌우 여백이 있는 점을 반영해, 각 페이지 헤더 내부의 작은 추가 패딩을 제거하고 동일한 배치 규칙으로 맞춤
- **Settings 헤더 문법 통일**: 프로필 화면도 다른 목록 화면과 동일한 eyebrow/title/description 구조를 갖도록 보강해 전체 화면 톤을 통일
## 2026-03-30 v1.2.25
- **홈 게임 카드 썸네일 복구**: 메인 게임 선택 카드는 상단 메인 썸네일을 다시 표시하고, 하단 ID 라인 옆의 작은 보조 표시만 제거하도록 보정
- **사이드 하단 버튼 고정 가시성 보정**: 좌우 하단 액션 버튼이 스크롤을 해야 보이지 않던 문제를 수정하고, 버튼 자체는 항상 보이면서 아래쪽 여백만 확보되도록 조정
## 2026-03-30 v1.2.24
- **내 티어표 상단 stat 제거**: `내 티어표` 화면 헤더 오른쪽의 저장 개수 stat 카드를 제거해 제목/설명만 남도록 단순화
- **홈 게임 카드 메타 단순화**: 게임 선택 카드에서 썸네일과 점형 메타를 제거하고, 한글 게임 제목과 아래 작은 ID만 보이는 형태로 정리
- **좌우 하단 액션 여백 보정**: 왼쪽 로그인/관리자 버튼과 오른쪽 빠른 액션 버튼은 바닥에 바로 붙지 않도록 하단에 추가 여백을 확보
## 2026-03-30 v1.2.23
- **홈 게임 카드 4열 정리**: 메인 게임 목록 화면도 카드형 레이아웃에서 데스크톱 기준 기본 4열로 보이도록 그리드를 조정
- **게임 허브 중복 생성 CTA 제거**: 게임 선택 화면 본문 상단의 `새로운 티어표 만들기` 버튼을 제거하고, 우측 사이드 하단 CTA만 유지하도록 정리
- **좌우 하단 액션 영역 분리**: 왼쪽 `관리자 메뉴/로그인`과 오른쪽 빠른 액션 버튼을 각각 독립된 하단 `56px` 영역에 배치해, 본문/스크롤 영역과 분리된 고정 액션 위치로 통일
## 2026-03-30 v1.2.22
- **왼쪽 사이드 축소/확대 추가**: 좌측 레일을 완전히 숨기지 않고 축소형 내비로 접었다 펼 수 있게 바꾸고, 접힌 상태에서는 아이콘 중심으로만 보이도록 레이아웃을 정리
- **좌우 패널 토글 아이콘 통일**: 오른쪽 패널 열기/닫기는 모두 `dock_to_left`, 왼쪽 패널 토글은 `dock_to_right` 아이콘만 사용하도록 통일
- **전역 검색 아이콘 교체**: 좌측 전역 검색 입력에 사용자가 추가한 `search.svg`를 실제 아이콘으로 연결
## 2026-03-30 v1.2.21
- **티어표 카드 문법 통일**: 게임 허브, 검색 결과, 내 티어표, 즐겨찾기 목록의 카드 레이아웃을 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 2줄 메타 구조로 통일하고, 데스크톱 기준 한 줄 4개 카드가 보이도록 재배치
- **즐겨찾기 화면 날짜 기준 단순화**: `내 즐겨찾기` 화면은 더 이상 즐겨찾기한 시각을 표시하지 않고, 정렬 기준과 무관하게 덱의 마지막 수정일만 카드에 노출하도록 정리
- **좌측 사용자 카드/즐겨찾기 밀도 보정**: 좌측 사용자 아바타를 원형 보더 스타일로 통일하고, `Favorites` 바로가기 섹션은 메인 메뉴보다 덜 강조되도록 썸네일·텍스트·간격을 한 단계 축소
## 2026-03-30 v1.2.20
- **검색 결과 상단 툴바 제거**: `/search` 화면의 중복 검색 폼을 제거하고, 좌측 전역 검색 입력만 검색 진입점으로 사용하도록 단순화
- **왼쪽 즐겨찾기 더보기 아이콘 교체**: 사용자가 추가한 `more.svg`를 좌측 `즐겨찾기 더 보기` 링크 아이콘에 연결
- **중앙 본문 외곽 레이어 제거**: `workspaceBody`의 추가 패딩, 테두리, 둥근 카드 배경을 제거해 중앙 콘텐츠가 한 겹만 안쪽으로 들어온 것처럼 보이도록 셸 여백을 단순화
- **게임 허브 상단 통계 제거**: 게임별 티어표 목록 화면의 `dashboardStat` 카드를 제거해 상단 헤더를 CTA 중심으로 정리
- **우측 패널 토글 동작 정리**: 중앙 헤더에는 패널이 닫혀 있을 때만 열기 아이콘 버튼을, 우측 헤더에는 패널이 열려 있을 때만 닫기 아이콘 버튼을 표시하도록 토글 흐름을 재구성
## 2026-03-30 v1.2.19
- **왼쪽 레일 설정 흐름 단순화**: 사용자 카드 클릭 팝업을 제거하고, 설정은 좌측 `Settings` 메뉴에서만 진입하도록 정리했으며 프로필 화면 하단에 로그아웃 버튼을 추가
- **좌측 즐겨찾기 바로가기 추가**: 좌측 `Favorites` 영역에 최근 즐겨찾기 티어표 최대 10개를 바로가기 형태로 표시하고, 하단 `즐겨찾기 더 보기` 링크로 전체 즐겨찾기 화면에 연결
- **전역 공개 티어표 검색 추가**: 좌측 검색 입력은 이제 전체 공개 티어표를 대상으로 검색하며, 새 `/search` 결과 화면에서 제목/작성자 기준 검색 결과를 카드 목록으로 표시
- **설정 아이콘 반영 및 중복 관리자 버튼 제거**: 사용자가 추가한 `settings.svg`를 좌측 `Settings` 메뉴에 연결하고, 상단 내비에 중복되던 관리자 메뉴 항목은 제거
## 2026-03-30 v1.2.18
- **공통 56px 셸 헤더 도입**: 좌측 사이드, 중앙 워크스페이스, 우측 사이드 상단에 각각 높이 `56px`의 고정 헤더 블록을 두고, 사이트 타이틀 `Tier Maker by zenn`은 중앙 상단 헤더에만 표시되도록 셸 구조를 재정리
- **에디터 메인 래퍼 단순화**: 티어표 편집 화면의 `.layout` 2열 그리드를 제거해 공통 3단 셸 바깥에 중복 컬럼이 생기지 않도록 정리
- **아이템 라벨 overflow 수정**: 편집 화면 우측 아이템 풀에서 긴 아이템 이름이 화면 밖으로 밀려나지 않도록 `minmax(0, 1fr)`와 말줄임 처리 기준을 추가
## 2026-03-30 v1.2.17
- **에디터 우측 패널 래퍼 제거**: 티어표 편집 화면의 `editorSidebar` 외곽 래퍼를 제거하고, 공통 오른쪽 레일 루트에 편집 섹션들이 직접 쌓이도록 구조를 단순화
- **공통 우측 레일 정렬 통일**: `App.vue``localRightRailRoot`에 섹션 스택 정렬을 부여해, 에디터/관리자 같은 로컬 패널 화면도 공통 레일 안에서 같은 방식으로 콘텐츠가 배치되도록 정리
## 2026-03-30 v1.2.16
- **메인 오른쪽 사이드 단순화**: 홈 화면 기준 오른쪽 컬럼의 컨텍스트/계정/점프 카드 3종을 제거하고, 시안에 맞춰 핵심 CTA 버튼만 남기는 구조로 단순화
- **홈 상단 중복 도구 제거**: 중앙 바디 상단에 추가돼 있던 `Visible Games`, `Account`, `즐겨찾기 보기`, `내 리스트 보기`, `커스텀 티어표 만들기` 도구 막대를 제거해, 왼쪽/오른쪽 사이드와 중복되는 이동 요소를 정리
## 2026-03-30 v1.2.15
- **3단 셸 구조 고정**: 홈 화면처럼 `왼쪽 사이드 | 중앙 컨텐츠 | 오른쪽 사이드` 3단 레이아웃을 모든 일반 페이지의 공통 구조로 고정하고, 페이지 이동 시 오른쪽 컬럼이 사라졌다 나타나는 구조를 제거
- **에디터/관리자 우측 패널 공통 컬럼 통합**: 티어표 편집과 관리자 화면의 로컬 우측 패널을 Teleport로 공통 오른쪽 컬럼에 배치해, 바디 내부 2단 레이아웃 대신 셸의 세 번째 컬럼을 공유하도록 재정리
## 2026-03-30 v1.2.14
- **에디터 우측 패널 셸 컬럼 이관**: 티어표 편집 화면의 `editorSidebar``workspaceBody` 내부 보조 칼럼이 아니라 공통 셸의 세 번째 컬럼으로 옮겨, 메인 화면과 같은 `왼쪽 사이드 | 메인 | 오른쪽 사이드` 구조를 사용하도록 재배치
- **공통 토글과 실제 aside 연결**: 상단 패널 토글 버튼은 이제 Teleport로 이동한 에디터 우측 aside를 직접 접고 펴며, 본문 내부 2단 레이아웃처럼 보이던 구조를 제거
## 2026-03-30 v1.2.13
- **에디터 우측 패널 회귀 수정**: 공통 패널 상태를 템플릿에서 잘못 참조해 `editorSidebar`가 항상 닫힌 상태로 계산되던 문제를 수정해, 제목/설명/썸네일/저장 패널이 다시 정상 표시되도록 복구
## 2026-03-30 v1.2.12
- **에디터 우측 패널 토글 연결**: 공통 상단의 패널 토글 버튼이 이제 티어표 편집 화면의 `editorSidebar`에도 직접 연결되어, 숨기면 우측 패널이 접히고 중앙 보드 영역이 넓어지도록 수정
- **로컬 우측 패널 컬럼 충돌 방지**: 에디터/관리자처럼 로컬 우측 패널을 쓰는 화면에서는 공통 `rightClosed` 셸 컬럼 계산이 다시 끼어들지 않도록 예외 처리를 추가해 레이아웃이 다시 틀어지지 않게 보정
## 2026-03-30 v1.2.11
- **에디터 로컬 우측 패널 분리 보정**: 에디터/관리자처럼 로컬 우측 패널을 쓰는 화면은 공통 `workspaceBody` 카드 컨테이너를 벗기고, 로컬 패널이 중앙 본문 안쪽이 아니라 독립 컬럼처럼 보이도록 셸 구조를 조정
- **에디터 우측 컬럼 간격 보정**: 티어표 편집 화면의 `editorSidebar`가 본문 내부 보조 박스처럼 눌리지 않도록 간격과 최소 폭을 정리해 우측 사이드바 역할이 더 분명하게 보이도록 수정
## 2026-03-30 v1.2.10
- **목록 화면 상단 툴바 밀도 통일**: 홈, 게임 허브, 내 티어표, 즐겨찾기 상단 영역의 통계 카드와 액션 버튼 높이/반경/배경을 맞춰 공통 셸과 같은 도구 막대 문법으로 정리
- **홈 빠른 진입 흐름 보정**: 홈 화면 툴바에서 중복되던 버튼 흐름을 `즐겨찾기 / 내 리스트 / 커스텀 티어표 만들기` 중심으로 재구성해 실제 사용 동선에 맞게 정리
- **목록 카드 인터랙션 보강**: 주요 카드 목록에 일관된 hover 이동과 배경 전환을 넣어, 대시보드 카드가 더 또렷하게 반응하도록 조정
## 2026-03-30 v1.2.9
- **관리자 대시보드 헤더 보강**: 관리자 화면 상단에 현재 탭 기준 요약 통계 카드를 추가해, 게임/아이템/티어표/회원 상태를 즉시 읽을 수 있게 정리
- **운영 패널 질감 정리**: 우측 `320px` 운영 패널의 탭, 입력, 통계 카드, 버튼 라운드/배경/호버 상태를 공통 셸 톤에 맞춰 더 두꺼운 대시보드 카드 문법으로 통일
- **관리 카드 밀도 개선**: 게임 상세, 커스텀 아이템, 템플릿 요청, 전체 티어표, 회원 카드의 배경층·패딩·반경을 함께 다듬어 시안에 가까운 평평한 관리용 레이아웃으로 보정
## 2026-03-30 v1.2.8
- **실제 SVG 아이콘 연결 시작**: 사용자가 추가한 `grid_view`, `lists`, `dock_to_left`, `dock_to_right` 아이콘을 공통 셸 내비와 우측 패널 토글에 연결해 문자 기반 아이콘을 일부 실제 에셋으로 교체
- **에디터 3열 구조 복구**: 티어표 편집 화면을 `보드 / 아이템 풀 / 우측 편집 사이드바` 구조로 재배치해, 아이템 풀은 보드 옆에서 바로 드래그 가능하고 편집 옵션은 최우측 패널에만 남도록 수정
- **커스텀 아이템 이름 정리 위치 조정**: 커스텀 아이템 이름 수정 목록은 드래그용 아이템 풀 아래가 아니라 우측 편집 사이드바 안으로 옮겨, 보드 배치 흐름과 옵션 정리 흐름을 분리
## 2026-03-30 v1.2.7
- **공통 셸 아이콘형 정리**: 좌측 내비와 우측 보조 패널의 임시 문자 배지를 간단한 SVG 아이콘형으로 바꾸고, 버튼/카드 라운드와 밀도를 통일
- **좌측 레일 정보 밀도 개선**: 사용자 카드, 빠른 검색, 내비 버튼, 하단 로그인/관리자 버튼을 더 두꺼운 카드 문법으로 맞춰 피그마 톤에 가까운 레일 형태로 재정리
- **에디터 패널 감도 보정**: 티어표 편집 화면의 보드, 보드 툴바, 우측 편집 패널, 아이템 풀/드롭존 카드의 배경·경계·라운드를 함께 정리해 공통 셸과 시각 언어를 맞춤
## 2026-03-30 v1.2.6
- **목록형 화면 카드 문법 통일**: 홈, 게임 허브, 내 티어표, 즐겨찾기 화면의 카드형 목록을 동일한 썸네일/제목/작성자/메타 구조로 정리해 대시보드 톤을 맞춤
- **홈 화면 대시보드 재정렬**: 메인 게임 라이브러리 화면에 상단 상태 카드와 CTA를 추가하고, 게임 카드는 `16:9` 썸네일 + ID 메타를 갖는 라이브러리 카드 형태로 재배치
- **게임 허브 헤더/검색 정리**: 게임 허브는 상단 통계와 생성 버튼, 보조 설명을 포함한 헤더로 재구성하고, 공개 티어표 카드도 같은 카드 밀도로 재정리
## 2026-03-30 v1.2.5
- **관리자 로컬 우측 패널 이관**: 관리자 화면도 공통 우측 패널 대신 화면 내부의 `320px` 전용 운영 패널을 사용하도록 정리하고, 탭·검색·필터·빠른 액션을 우측으로 이동
- **관리 화면 본문 집중도 개선**: 중앙 영역은 상단 고정 게임 순서, 선택된 게임 상세, 커스텀 아이템 카드, 템플릿 요청/전체 티어표, 회원 카드 같은 실제 관리 대상만 남기고 빈 상태 안내도 별도 패널로 정리
- **관리자 셸 예외 확장**: 공통 앱 셸에서 `/admin`도 전용 로컬 우측 패널을 사용하는 포커스 화면으로 분류해 generic 우측 문맥 카드가 중복 표시되지 않게 조정
## 2026-03-30 v1.2.4
- **비로그인 중복 안내 제거**: 좌측 사이드 상단의 별도 로그인 안내 카드를 제거하고, 비로그인 상태에서는 좌측 하단 버튼만 `로그인` 진입점으로 사용하도록 단순화
- **에디터 우측 편집 패널 이관**: 티어표 편집 화면의 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 중앙 상단이 아니라 독립 우측 편집 패널로 이동
- **공통 우측 패널 예외 처리**: 티어표 편집 화면은 공통 우측 패널 대신 화면 내부 전용 편집 패널을 사용하도록 조정해, generic 안내 카드가 중복 표시되지 않게 정리
## 2026-03-30 v1.2.2
- **사이드 패널 폭 고정**: 공통 앱 셸의 좌측 패널 폭을 `248px`, 우측 패널 폭을 `320px` 기준으로 재정의해 피그마 시안과 더 가깝게 맞춤
- **우측 패널 토글 추가**: 상단 우측 토글 버튼으로 우측 패널을 접고 펼칠 수 있게 하고, 접힐 때는 중앙 작업 영역이 자연스럽게 확장되도록 전환 애니메이션을 추가
- **우측 패널 독립성 강화**: 우측 패널은 본문과 별도 컬럼으로 유지하고, 닫힐 때도 본문 레이아웃과 분리된 독립 패널처럼 동작하도록 셸 구조를 조정
## 2026-03-30 v1.2.1
- **포커스 화면 폭 복구**: 에디터·관리자·프로필·로그인 화면은 공통 우측 패널을 잠시 숨기고 중앙 작업 폭을 넓혀, 기존 기능 UI가 3단 셸과 충돌하며 깨지던 문제를 완화
- **목록 카드 밀도 재조정**: 홈, 게임 허브, 내 티어표, 즐겨찾기 화면의 기본 컬럼 수를 줄여 현재 셸 폭 안에서도 카드가 과도하게 눌리지 않도록 정리
- **에디터/관리자 패널 안정화**: 내부 작업 패널 색상과 폭을 새 셸 톤에 맞춰 다시 정리해, 중첩 패널 때문에 사용성이 무너지던 부분을 우선 복구
## 2026-03-30 v1.2.0
- **피그마 기반 공통 앱 셸 1차 적용**: 상단 헤더 중심 구조를 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 앱 셸로 재구성하고, 데스크톱 기준의 어두운 대시보드형 톤으로 전환
- **홈/목록 화면 카드 UI 리디자인**: 홈, 게임 허브, 내 티어표, 즐겨찾기 화면의 카드 그리드와 툴바를 시안에 맞춰 더 조밀한 대시보드 형태로 재배치
@@ -156,6 +565,101 @@
- **미사용 아이콘 필터 수정**: 관리자 아이템 관리의 `미사용 아이콘 보기` 체크 상태가 실제 API 요청의 `orphanOnly` 파라미터로 전달되도록 수정
- **삭제 활성화 흐름 정상화**: 미사용 아이콘만 조회했을 때 `usageCount = 0` 항목의 개별 삭제 버튼이 의도대로 활성화되도록 정리
## 2026-03-19 v0.1.17
- **내 티어표 삭제 추가**: `내 티어표` 목록에서 작성자가 자신의 티어표를 직접 삭제할 수 있도록 삭제 버튼과 API를 추가
- **미사용 커스텀 이미지 관리 추가**: 관리자 아이템 탭에서 커스텀 이미지의 사용 횟수를 표시하고, 미사용 항목만 따로 필터링해 개별/일괄 삭제할 수 있도록 보강
## 2026-03-19 v0.1.16
- **티어표 헤더 마감 정리**: 제목/설명 입력을 각각 한 줄 폭으로 정리하고, 액션 영역과 분리해 헤더 가독성을 개선
- **export 정보 보강**: 이미지 저장 시 제목 아래에 설명이 함께 표시되도록 보강
- **보드 여백/정렬 정리**: 보드 내부 패딩을 늘리고, 티어 그룹 제목을 중앙 정렬로 조정해 완성본 느낌을 개선
## 2026-03-19 v0.1.15
- **셀렉트 화살표 여백 정리**: 전역 `select` 스타일에 커스텀 화살표 위치와 오른쪽 여백을 추가해 텍스트와 화살표가 지나치게 붙지 않도록 조정
- **티어표 다운로드 결과 개선**: `TierEditorView`의 이미지 저장을 Blob 다운로드 방식으로 바꾸고, 캡처 대상을 보드 영역만 포함하는 전용 export 뷰로 분리해 우측 아이템 영역과 편집용 버튼/입력 UI가 저장 이미지에 섞이지 않도록 수정
## 2026-03-19 v0.1.14
- **커스텀 아이템 카드 반응형 수정**: 관리자 아이템 관리 탭의 커스텀 아이템 카드에서 이미지 폭을 유동값으로 조정하고, 텍스트 영역에 `min-width: 0`과 강제 줄바꿈 기준을 추가해 카드 바깥 overflow를 방지
## 2026-03-19 v0.1.13
- **관리자 탭 구조 정리**: 관리자 페이지를 `게임 관리 / 아이템 관리 / 회원 관리` 탭으로 분리하고 기능별 작업 영역을 명확히 분리
- **커스텀 아이템 조회 강화**: 사용자 커스텀 아이템 목록에 파일명 검색, `50/200` 단위 페이지네이션, 다운로드 흐름 추가
- **회원 비밀번호 초기화 추가**: 관리자 페이지와 API에서 회원 비밀번호를 직접 재설정할 수 있도록 기능 추가
- **가변 티어 행 지원**: 티어표 에디터에서 `S~D` 고정 5단이 아니라 티어 행을 직접 추가/삭제할 수 있도록 보강
## 2026-03-19 v0.1.12
- **전역 레이아웃 폭 정리**: 앱 메인 영역의 고정 최대 너비를 제거해 배경과 페이지 폭이 잘린 듯 보이지 않도록 조정
- **작성 권한 제한**: 비로그인 사용자는 새 티어표 작성 화면으로 직접 진입할 수 없도록 하고, 공개된 티어표는 읽기 전용으로만 보이게 조정
- **커스텀 이미지 업로드 개선**: 에디터의 커스텀 이미지 추가 영역에 다중 파일 선택과 드래그 앤 드롭 업로드를 추가
- **회원 관리 추가**: 관리자 페이지에서 가입 회원 목록 조회, 이메일/닉네임/권한 수정, 계정 삭제가 가능한 관리 영역과 API를 추가
## 2026-03-19 v0.1.11
- **관리자 레이아웃 재구성**: 인라인 스타일을 제거하고, 썸네일 적용과 아이템 추가를 상단 2열 카드로 재배치한 뒤 아이템 목록은 하단 리스트로 분리
- **직접 티어표 만들기 추가**: 홈 화면에 게임 카드와 동일한 형태의 `직접 티어표 만들기` 진입점을 추가하고, 내부 전용 `freeform` 게임 레코드로 1회성 빈 티어표 저장 흐름을 지원
- **게임 제안 흐름 제거**: 홈 화면의 `새로운 게임 제안` 버튼/모달과 관련 프런트 API를 제거해 현재 운영 흐름에 맞게 단순화
- **커스텀 아이템 검토 영역 추가**: 관리자 페이지에서 사용자 업로드 커스텀 아이템을 목록으로 보고 다운로드할 수 있는 검토 영역과 조회 API를 추가
## 2026-03-19 v0.1.10
- **관리자 썸네일 액션 정리**: 썸네일 버튼 문구를 `썸네일 적용`으로 바꾸고, 파일 선택 전에는 비활성화되도록 조정
- **아이템 추가 폼 정리**: 아이템 이름 입력 너비를 줄이고, 과한 미리보기 안내 문구를 제거해 작업 집중도를 높임
- **반응형 미리보기 보정**: 태블릿 이하 화면에서도 아이템 1:1 미리보기가 최대 `192px` 범위 안에서 보이도록 조정
- **파일 재선택 버그 수정**: 아이템 추가나 게임 전환 뒤 파일 입력 값을 초기화해 같은 이미지를 다시 선택해도 정상 인식되도록 수정
## 2026-03-19 v0.1.9
- **MariaDB 전용 전환 완료**: `backend/src/db.js`에서 lowdb 분기와 `DB_CLIENT` 기반 fallback을 제거하고 MariaDB 전용 저장 계층으로 정리
- **레거시 파일 제거**: `backend/data/db.json`, `backend/scripts/migrate-lowdb-to-mariadb.js`, `dev:lowdb/start:lowdb/migrate:lowdb` 스크립트 및 `lowdb` 의존성 제거
- **실행 문서 정리**: `README.md`, `docs/local-mariadb.md`, `docs/spec.md`, `docs/todo.md`, `docs/history.md`를 현재 MariaDB 전용 개발/배포 흐름 기준으로 갱신
## 2026-03-19 v0.1.8
- **관리자 업로드 UX 개선**: 썸네일과 아이템 추가 시 파일 선택 직후 미리보기 표시
- **썸네일 비율 정리**: 관리자 썸네일 미리보기와 대표 썸네일 표시를 16:9, 약 256px 폭 기준으로 조정
- **아이템 카드 레이아웃 개선**: 아이템 목록과 추가 미리보기를 1:1 비율 기준으로 재구성하고 더 촘촘한 카드 그리드로 조정
- **레거시 파일 역할 정리**: `db.json`과 lowdb 관련 코드는 현재 MariaDB 기본 런타임에는 필수가 아니며, 마이그레이션/예외 fallback 용도임을 문서에 명시
## 2026-03-19 v0.1.7
- **AI 작업 규칙 보강**: `ai-rules.md`에 Git 작성자 정보, 한국어 커밋 메시지, 버전/태그 동기화, 민감 정보 확인 규칙 추가
- **관리자 화면 재구성**: `/admin`을 좌우 병렬 구조에서 `모드 선택 → 게임 선택/생성 → 선택된 게임 상세 관리` 흐름으로 재구성
- **관리자 삭제 기능 추가**: 등록된 게임 자체 삭제 및 등록된 아이템 개별 삭제 기능 추가
- **데이터 정합성 보강**: 관리자 아이템 삭제 시 관련 티어표의 `groups/pool` 참조를 함께 정리하도록 백엔드 로직 보강
## 2026-03-19 v0.1.6
- **저장소 메타데이터 정리**: Git 작성자 정보를 프로젝트 계정 기준으로 통일하고, 초기 릴리스 커밋 메시지를 한국어 기준으로 재작성
- **버전 관리 규칙 보강**: 커밋 메시지 한국어 작성 및 문서 버전과 Git 태그를 함께 맞추는 규칙을 문서에 반영
## 2026-03-19 v0.1.5
- **로컬 개발 환경 정렬**: 기본 백엔드 실행 기준을 lowdb가 아닌 로컬 MariaDB로 전환
- **개발용 인프라 추가**: 루트 `docker-compose.yml``MariaDB + phpMyAdmin` 추가
- **실행 문서 정리**: `README.md`, `docs/local-mariadb.md`, `docs/spec.md`에 로컬 MariaDB 실행 절차 반영
- **Fallback 분리**: `backend/package.json``dev:lowdb`, `start:lowdb` 예외 스크립트 추가
## 2026-03-19 v0.1.4
- **DB 마이그레이션 준비**: 런타임 저장소를 `MariaDB(MySQL 호환)` 기준으로 재구성하고 `backend/scripts/migrate-lowdb-to-mariadb.js` 마이그레이션 스크립트 추가
- **데이터 구조 분리**: 관리자 지정 아이템은 `game_items`, 유저 커스텀 이미지는 `custom_items`로 분리
- **프로필 개선**: 작성자 닉네임 저장 지원, 아바타는 파일 선택 시 미리보기만 변경되고 저장 버튼 클릭 시 실제 반영되도록 수정
- **공개 티어표 목록 개선**: 공개 티어표 목록에 작성자 닉네임(없으면 이메일) 표시
- **관리자 UI 개편**: 게임 선택 전에는 우측 관리 패널을 숨기고, 선택 후에만 썸네일/아이템 관리가 보이도록 단계형 흐름으로 수정
- **관리자 레이아웃 수정**: 새 게임 입력 필드와 카드 셀 overflow 문제를 줄이도록 `box-sizing`, 썸네일/아이템 카드 레이아웃 정리
- **커스텀 아이템 저장 흐름 수정**: 에디터의 커스텀 이미지는 저장 시 서버 업로드 후 티어표에 반영되도록 변경
## 2026-03-19 v0.1.3
- **배포 설정 개선**: 프런트엔드의 API/정적 파일 주소 하드코딩(`http://localhost:5179`)을 `VITE_API_ORIGIN` 기반으로 통합
- **백엔드 운영 설정 추가**: `CORS_ORIGINS`, `TRUST_PROXY`, `SESSION_COOKIE_SECURE`, `SESSION_COOKIE_SAME_SITE`, `SESSION_SECRET` 환경변수 기반으로 NAS/리버스 프록시 배포 대응
- **업로드 파일명 안정화**: 한글 원본 파일명 기반 저장을 제거하고 ASCII 안전 파일명으로 저장하도록 변경
- **티어표 데이터 정규화**: 게임 이미지 경로가 절대 로컬 URL로 저장되지 않도록 저장/조회 시 `/uploads/...` 상대 경로로 정규화
- **프로젝트 점검 결과 문서화**: DB 구조, 화면-파일 매핑, 코딩 규칙, 기술 명세, 남은 위험 요소를 `docs/`에 신규 정리
## 2026-03-19 v0.1.2
- **로그인 UI 개선**: 로그인 카드 중앙 배치, 중복 타이틀 제거, 입력 overflow 수정, 엔터로 로그인/회원가입 제출
- **안내문 조건화**: “첫 회원가입 계정은 admin” 문구는 유저가 0명일 때만 표시(`/api/auth/meta`)
- **게임 목록 UI 개선**: 게임 카드에 썸네일 표시, 중복 텍스트 제거, “새로운 게임 제안” 모달 추가
- **관리자 기능 추가**: 게임 썸네일 업로드 API(`/api/admin/games/:gameId/thumbnail`) 및 UI 추가
- **에디터 레이아웃 개선**: 등급(그룹) 라벨 칼럼 확장으로 텍스트 잘림 방지, 설명 입력 1줄, 정렬을 좌측 기준으로 조정
## 2026-03-19 v0.1.1
- **티어표 메타데이터 개선**: 제목 미입력 시 저장 시점에 게임 이름 기반 자동 제목 적용, 설명(선택) 필드 추가
- **시간 정보 표시**: 내 티어표/공개 목록에서 저장 시간(createdAt)과 업데이트 시간(updatedAt)을 시:분:초까지 표시
- **에디터 UX 수정**: 빈 티어 칸 안내 문구가 첫 드래그 배치를 가리던 문제 수정(오버레이 처리), 제목 상단에 게임 이름 표시
## 2026-03-19 v0.1.0
- **초기 스캐폴딩**: `frontend/`에 Vue3(Vite, JavaScript) 프로젝트 생성
- **라우팅/화면 골격**: 게임 선택(`/`), 게임 허브(`/games/:gameId`), 에디터(`/editor/:gameId/...`), 로그인(`/login`), 내 티어표(`/me`), 관리자(`/admin`) 라우트 추가
@@ -168,98 +672,3 @@
- **네비/권한 UX**: 관리자 메뉴는 admin 로그인 시에만 노출, 로그인 대신 아바타 버튼/메뉴 노출
- **프로필**: `/profile` 페이지 추가, 아바타 업로드 API(`/api/auth/avatar`) 및 표시 지원
- **에디터 버그 수정**: 드래그 시 아이템들이 “묶음”으로 같이 움직이던 문제 해결(드롭 영역 DOM 구조/Sortable 옵션 수정), 드롭 영역 overflow/배치 레이아웃 개선
## 2026-03-19 v0.1.1
- **티어표 메타데이터 개선**: 제목 미입력 시 저장 시점에 게임 이름 기반 자동 제목 적용, 설명(선택) 필드 추가
- **시간 정보 표시**: 내 티어표/공개 목록에서 저장 시간(createdAt)과 업데이트 시간(updatedAt)을 시:분:초까지 표시
- **에디터 UX 수정**: 빈 티어 칸 안내 문구가 첫 드래그 배치를 가리던 문제 수정(오버레이 처리), 제목 상단에 게임 이름 표시
## 2026-03-19 v0.1.2
- **로그인 UI 개선**: 로그인 카드 중앙 배치, 중복 타이틀 제거, 입력 overflow 수정, 엔터로 로그인/회원가입 제출
- **안내문 조건화**: “첫 회원가입 계정은 admin” 문구는 유저가 0명일 때만 표시(`/api/auth/meta`)
- **게임 목록 UI 개선**: 게임 카드에 썸네일 표시, 중복 텍스트 제거, “새로운 게임 제안” 모달 추가
- **관리자 기능 추가**: 게임 썸네일 업로드 API(`/api/admin/games/:gameId/thumbnail`) 및 UI 추가
- **에디터 레이아웃 개선**: 등급(그룹) 라벨 칼럼 확장으로 텍스트 잘림 방지, 설명 입력 1줄, 정렬을 좌측 기준으로 조정
## 2026-03-19 v0.1.3
- **배포 설정 개선**: 프런트엔드의 API/정적 파일 주소 하드코딩(`http://localhost:5179`)을 `VITE_API_ORIGIN` 기반으로 통합
- **백엔드 운영 설정 추가**: `CORS_ORIGINS`, `TRUST_PROXY`, `SESSION_COOKIE_SECURE`, `SESSION_COOKIE_SAME_SITE`, `SESSION_SECRET` 환경변수 기반으로 NAS/리버스 프록시 배포 대응
- **업로드 파일명 안정화**: 한글 원본 파일명 기반 저장을 제거하고 ASCII 안전 파일명으로 저장하도록 변경
- **티어표 데이터 정규화**: 게임 이미지 경로가 절대 로컬 URL로 저장되지 않도록 저장/조회 시 `/uploads/...` 상대 경로로 정규화
- **프로젝트 점검 결과 문서화**: DB 구조, 화면-파일 매핑, 코딩 규칙, 기술 명세, 남은 위험 요소를 `docs/`에 신규 정리
## 2026-03-19 v0.1.4
- **DB 마이그레이션 준비**: 런타임 저장소를 `MariaDB(MySQL 호환)` 기준으로 재구성하고 `backend/scripts/migrate-lowdb-to-mariadb.js` 마이그레이션 스크립트 추가
- **데이터 구조 분리**: 관리자 지정 아이템은 `game_items`, 유저 커스텀 이미지는 `custom_items`로 분리
- **프로필 개선**: 작성자 닉네임 저장 지원, 아바타는 파일 선택 시 미리보기만 변경되고 저장 버튼 클릭 시 실제 반영되도록 수정
- **공개 티어표 목록 개선**: 공개 티어표 목록에 작성자 닉네임(없으면 이메일) 표시
- **관리자 UI 개편**: 게임 선택 전에는 우측 관리 패널을 숨기고, 선택 후에만 썸네일/아이템 관리가 보이도록 단계형 흐름으로 수정
- **관리자 레이아웃 수정**: 새 게임 입력 필드와 카드 셀 overflow 문제를 줄이도록 `box-sizing`, 썸네일/아이템 카드 레이아웃 정리
- **커스텀 아이템 저장 흐름 수정**: 에디터의 커스텀 이미지는 저장 시 서버 업로드 후 티어표에 반영되도록 변경
## 2026-03-19 v0.1.5
- **로컬 개발 환경 정렬**: 기본 백엔드 실행 기준을 lowdb가 아닌 로컬 MariaDB로 전환
- **개발용 인프라 추가**: 루트 `docker-compose.yml``MariaDB + phpMyAdmin` 추가
- **실행 문서 정리**: `README.md`, `docs/local-mariadb.md`, `docs/spec.md`에 로컬 MariaDB 실행 절차 반영
- **Fallback 분리**: `backend/package.json``dev:lowdb`, `start:lowdb` 예외 스크립트 추가
## 2026-03-19 v0.1.6
- **저장소 메타데이터 정리**: Git 작성자 정보를 프로젝트 계정 기준으로 통일하고, 초기 릴리스 커밋 메시지를 한국어 기준으로 재작성
- **버전 관리 규칙 보강**: 커밋 메시지 한국어 작성 및 문서 버전과 Git 태그를 함께 맞추는 규칙을 문서에 반영
## 2026-03-19 v0.1.7
- **AI 작업 규칙 보강**: `ai-rules.md`에 Git 작성자 정보, 한국어 커밋 메시지, 버전/태그 동기화, 민감 정보 확인 규칙 추가
- **관리자 화면 재구성**: `/admin`을 좌우 병렬 구조에서 `모드 선택 → 게임 선택/생성 → 선택된 게임 상세 관리` 흐름으로 재구성
- **관리자 삭제 기능 추가**: 등록된 게임 자체 삭제 및 등록된 아이템 개별 삭제 기능 추가
- **데이터 정합성 보강**: 관리자 아이템 삭제 시 관련 티어표의 `groups/pool` 참조를 함께 정리하도록 백엔드 로직 보강
## 2026-03-19 v0.1.8
- **관리자 업로드 UX 개선**: 썸네일과 아이템 추가 시 파일 선택 직후 미리보기 표시
- **썸네일 비율 정리**: 관리자 썸네일 미리보기와 대표 썸네일 표시를 16:9, 약 256px 폭 기준으로 조정
- **아이템 카드 레이아웃 개선**: 아이템 목록과 추가 미리보기를 1:1 비율 기준으로 재구성하고 더 촘촘한 카드 그리드로 조정
- **레거시 파일 역할 정리**: `db.json`과 lowdb 관련 코드는 현재 MariaDB 기본 런타임에는 필수가 아니며, 마이그레이션/예외 fallback 용도임을 문서에 명시
## 2026-03-19 v0.1.9
- **MariaDB 전용 전환 완료**: `backend/src/db.js`에서 lowdb 분기와 `DB_CLIENT` 기반 fallback을 제거하고 MariaDB 전용 저장 계층으로 정리
- **레거시 파일 제거**: `backend/data/db.json`, `backend/scripts/migrate-lowdb-to-mariadb.js`, `dev:lowdb/start:lowdb/migrate:lowdb` 스크립트 및 `lowdb` 의존성 제거
- **실행 문서 정리**: `README.md`, `docs/local-mariadb.md`, `docs/spec.md`, `docs/todo.md`, `docs/history.md`를 현재 MariaDB 전용 개발/배포 흐름 기준으로 갱신
## 2026-03-19 v0.1.10
- **관리자 썸네일 액션 정리**: 썸네일 버튼 문구를 `썸네일 적용`으로 바꾸고, 파일 선택 전에는 비활성화되도록 조정
- **아이템 추가 폼 정리**: 아이템 이름 입력 너비를 줄이고, 과한 미리보기 안내 문구를 제거해 작업 집중도를 높임
- **반응형 미리보기 보정**: 태블릿 이하 화면에서도 아이템 1:1 미리보기가 최대 `192px` 범위 안에서 보이도록 조정
- **파일 재선택 버그 수정**: 아이템 추가나 게임 전환 뒤 파일 입력 값을 초기화해 같은 이미지를 다시 선택해도 정상 인식되도록 수정
## 2026-03-19 v0.1.11
- **관리자 레이아웃 재구성**: 인라인 스타일을 제거하고, 썸네일 적용과 아이템 추가를 상단 2열 카드로 재배치한 뒤 아이템 목록은 하단 리스트로 분리
- **직접 티어표 만들기 추가**: 홈 화면에 게임 카드와 동일한 형태의 `직접 티어표 만들기` 진입점을 추가하고, 내부 전용 `freeform` 게임 레코드로 1회성 빈 티어표 저장 흐름을 지원
- **게임 제안 흐름 제거**: 홈 화면의 `새로운 게임 제안` 버튼/모달과 관련 프런트 API를 제거해 현재 운영 흐름에 맞게 단순화
- **커스텀 아이템 검토 영역 추가**: 관리자 페이지에서 사용자 업로드 커스텀 아이템을 목록으로 보고 다운로드할 수 있는 검토 영역과 조회 API를 추가
## 2026-03-19 v0.1.12
- **전역 레이아웃 폭 정리**: 앱 메인 영역의 고정 최대 너비를 제거해 배경과 페이지 폭이 잘린 듯 보이지 않도록 조정
- **작성 권한 제한**: 비로그인 사용자는 새 티어표 작성 화면으로 직접 진입할 수 없도록 하고, 공개된 티어표는 읽기 전용으로만 보이게 조정
- **커스텀 이미지 업로드 개선**: 에디터의 커스텀 이미지 추가 영역에 다중 파일 선택과 드래그 앤 드롭 업로드를 추가
- **회원 관리 추가**: 관리자 페이지에서 가입 회원 목록 조회, 이메일/닉네임/권한 수정, 계정 삭제가 가능한 관리 영역과 API를 추가
## 2026-03-19 v0.1.13
- **관리자 탭 구조 정리**: 관리자 페이지를 `게임 관리 / 아이템 관리 / 회원 관리` 탭으로 분리하고 기능별 작업 영역을 명확히 분리
- **커스텀 아이템 조회 강화**: 사용자 커스텀 아이템 목록에 파일명 검색, `50/200` 단위 페이지네이션, 다운로드 흐름 추가
- **회원 비밀번호 초기화 추가**: 관리자 페이지와 API에서 회원 비밀번호를 직접 재설정할 수 있도록 기능 추가
- **가변 티어 행 지원**: 티어표 에디터에서 `S~D` 고정 5단이 아니라 티어 행을 직접 추가/삭제할 수 있도록 보강
## 2026-03-19 v0.1.14
- **커스텀 아이템 카드 반응형 수정**: 관리자 아이템 관리 탭의 커스텀 아이템 카드에서 이미지 폭을 유동값으로 조정하고, 텍스트 영역에 `min-width: 0`과 강제 줄바꿈 기준을 추가해 카드 바깥 overflow를 방지
## 2026-03-19 v0.1.15
- **셀렉트 화살표 여백 정리**: 전역 `select` 스타일에 커스텀 화살표 위치와 오른쪽 여백을 추가해 텍스트와 화살표가 지나치게 붙지 않도록 조정
- **티어표 다운로드 결과 개선**: `TierEditorView`의 이미지 저장을 Blob 다운로드 방식으로 바꾸고, 캡처 대상을 보드 영역만 포함하는 전용 export 뷰로 분리해 우측 아이템 영역과 편집용 버튼/입력 UI가 저장 이미지에 섞이지 않도록 수정
## 2026-03-19 v0.1.16
- **티어표 헤더 마감 정리**: 제목/설명 입력을 각각 한 줄 폭으로 정리하고, 액션 영역과 분리해 헤더 가독성을 개선
- **export 정보 보강**: 이미지 저장 시 제목 아래에 설명이 함께 표시되도록 보강
- **보드 여백/정렬 정리**: 보드 내부 패딩을 늘리고, 티어 그룹 제목을 중앙 정렬로 조정해 완성본 느낌을 개선
## 2026-03-19 v0.1.17
- **내 티어표 삭제 추가**: `내 티어표` 목록에서 작성자가 자신의 티어표를 직접 삭제할 수 있도록 삭제 버튼과 API를 추가
- **미사용 커스텀 이미지 관리 추가**: 관리자 아이템 탭에서 커스텀 이미지의 사용 횟수를 표시하고, 미사용 항목만 따로 필터링해 개별/일괄 삭제할 수 있도록 보강

File diff suppressed because it is too large Load Diff

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="M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z"/></svg>

After

Width:  |  Height:  |  Size: 318 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-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm440-80h120v-560H640v560Zm-80 0v-560H200v560h360Zm80 0h120-120Z"/></svg>

After

Width:  |  Height:  |  Size: 306 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-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm120-80v-560H200v560h120Zm80 0h360v-560H400v560Zm-80 0H200h120Z"/></svg>

After

Width:  |  Height:  |  Size: 306 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="m480-120-58-52q-101-91-167-157T150-447.5Q111-500 95.5-544T80-634q0-94 63-157t157-63q52 0 99 22t81 62q34-40 81-62t99-22q94 0 157 63t63 157q0 46-15.5 90T810-447.5Q771-395 705-329T538-172l-58 52Zm0-108q96-86 158-147.5t98-107q36-45.5 50-81t14-70.5q0-60-40-100t-100-40q-47 0-87 26.5T518-680h-76q-15-41-55-67.5T300-774q-60 0-100 40t-40 100q0 35 14 70.5t50 81q36 45.5 98 107T480-228Zm0-273Z"/></svg>

After

Width:  |  Height:  |  Size: 507 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M120-520v-320h320v320H120Zm0 400v-320h320v320H120Zm400-400v-320h320v320H520Zm0 400v-320h320v320H520ZM200-600h160v-160H200v160Zm400 0h160v-160H600v160Zm0 400h160v-160H600v160Zm-400 0h160v-160H200v160Zm400-400Zm0 240Zm-240 0Zm0-240Z"/></svg>

After

Width:  |  Height:  |  Size: 354 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="m305-704 112-145q12-16 28.5-23.5T480-880q18 0 34.5 7.5T543-849l112 145 170 57q26 8 41 29.5t15 47.5q0 12-3.5 24T866-523L756-367l4 164q1 35-23 59t-56 24q-2 0-22-3l-179-50-179 50q-5 2-11 2.5t-11 .5q-32 0-56-24t-23-59l4-165L95-523q-8-11-11.5-23T80-570q0-25 14.5-46.5T135-647l170-57Zm49 69-194 64 124 179-4 191 200-55 200 56-4-192 124-177-194-66-126-165-126 165Zm126 135Z"/></svg>

After

Width:  |  Height:  |  Size: 490 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="M80-160v-160h160v160H80Zm240 0v-160h560v160H320ZM80-400v-160h160v160H80Zm240 0v-160h560v160H320ZM80-640v-160h160v160H80Zm240 0v-160h560v160H320Z"/></svg>

After

Width:  |  Height:  |  Size: 268 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="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480h80q0 66 25 124.5t68.5 102q43.5 43.5 102 69T480-159q134 0 227-93t93-227q0-134-93-227t-227-93q-89 0-161.5 43.5T204-640h116v80H80v-240h80v80q55-73 138-116.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm-80-240q-17 0-28.5-11.5T360-360v-120q0-17 11.5-28.5T400-520v-40q0-33 23.5-56.5T480-640q33 0 56.5 23.5T560-560v40q17 0 28.5 11.5T600-480v120q0 17-11.5 28.5T560-320H400Zm40-200h80v-40q0-17-11.5-28.5T480-600q-17 0-28.5 11.5T440-560v40Z"/></svg>

After

Width:  |  Height:  |  Size: 663 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="M360-160q-19 0-36-8.5T296-192L80-480l216-288q11-15 28-23.5t36-8.5h440q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H360ZM180-480l180 240h440v-480H360L180-480Zm248.5 28.5Q440-463 440-480t-11.5-28.5Q417-520 400-520t-28.5 11.5Q360-497 360-480t11.5 28.5Q383-440 400-440t28.5-11.5Zm140 0Q580-463 580-480t-11.5-28.5Q557-520 540-520t-28.5 11.5Q500-497 500-480t11.5 28.5Q523-440 540-440t28.5-11.5Zm140 0Q720-463 720-480t-11.5-28.5Q697-520 680-520t-28.5 11.5Q640-497 640-480t11.5 28.5Q663-440 680-440t28.5-11.5ZM580-480Z"/></svg>

After

Width:  |  Height:  |  Size: 639 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="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z"/></svg>

After

Width:  |  Height:  |  Size: 375 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="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z"/></svg>

After

Width:  |  Height:  |  Size: 770 B

View File

@@ -0,0 +1,91 @@
<script setup>
import { computed, nextTick, onMounted, ref } from 'vue'
const props = defineProps({
className: {
type: String,
default: '',
},
})
const adEl = ref(null)
const client = 'ca-pub-4516420168710424'
const slot = '1236919061'
const panelClass = computed(() => ['rightRailAd', props.className].filter(Boolean).join(' '))
function ensureAdScript() {
if (typeof window === 'undefined' || typeof document === 'undefined') return Promise.resolve()
const existing = document.querySelector(`script[data-ad-client="${client}"]`)
if (existing) return Promise.resolve()
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.async = true
script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${client}`
script.crossOrigin = 'anonymous'
script.dataset.adClient = client
script.onload = () => resolve()
script.onerror = () => reject(new Error('adsense_load_failed'))
document.head.appendChild(script)
})
}
onMounted(async () => {
if (typeof window === 'undefined') return
try {
await ensureAdScript()
await nextTick()
if (!adEl.value) return
window.adsbygoogle = window.adsbygoogle || []
if (!adEl.value.dataset.adsbygoogleStatus) {
window.adsbygoogle.push({})
}
} catch (e) {
// Keep the slot quiet when ad blockers or network policies block the script.
}
})
</script>
<template>
<section :class="panelClass">
<div class="rightRailAd__eyebrow">Sponsored</div>
<div class="rightRailAd__frame">
<ins
ref="adEl"
class="adsbygoogle rightRailAd__slot"
style="display:block"
:data-ad-client="client"
:data-ad-slot="slot"
data-ad-format="auto"
data-full-width-responsive="true"
></ins>
</div>
</section>
</template>
<style scoped>
.rightRailAd {
display: grid;
gap: 12px;
}
.rightRailAd__eyebrow {
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.34);
}
.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);
}
.rightRailAd__slot {
width: 100%;
min-height: 490px;
}
</style>

View File

@@ -0,0 +1,38 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
src: { type: String, required: true },
size: { type: [Number, String], default: 20 },
color: { type: String, default: 'currentColor' },
})
const normalizedSize = computed(() => (typeof props.size === "number" ? `${props.size}px` : props.size))
const iconStyle = computed(() => ({
"--svg-icon-src": `url("${props.src}")`,
"--svg-icon-size": normalizedSize.value,
"--svg-icon-color": props.color,
}))
</script>
<template>
<span class="svgIcon" :style="iconStyle" aria-hidden="true"></span>
</template>
<style scoped>
.svgIcon {
display: inline-block;
width: var(--svg-icon-size);
height: var(--svg-icon-size);
background-color: var(--svg-icon-color);
-webkit-mask-image: var(--svg-icon-src);
mask-image: var(--svg-icon-src);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-size: contain;
mask-size: contain;
flex: 0 0 auto;
}
</style>

View File

@@ -32,6 +32,8 @@ export const api = {
listGames: () => request('/api/games'),
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
favoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'POST' }),
unfavoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'DELETE' }),
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
updateAdminGameItem: (gameId, itemId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
@@ -42,6 +44,15 @@ export const api = {
listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) =>
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
listAdminTemplateRequests: () => request('/api/admin/template-requests'),
getAdminImageAssetStats: ({ month = '', limit = 12 } = {}) => {
const query = new URLSearchParams()
if (month) query.set('month', month)
query.set('limit', String(limit))
return request(`/api/admin/image-assets/stats?${query.toString()}`)
},
resetAdminImageAssetStats: (payload) => request('/api/admin/image-assets/stats/reset', { method: 'POST', body: payload || {} }),
listAdminUnusedImageAssets: ({ limit = 100, minAgeHours = 24 } = {}) => request(`/api/admin/image-assets/orphans?limit=${encodeURIComponent(limit)}&minAgeHours=${encodeURIComponent(minAgeHours)}`),
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
promoteAdminCustomItem: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
promoteAdminTierListItems: (tierListId, payload) =>
@@ -51,17 +62,37 @@ export const api = {
approveAdminTemplateRequest: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/approve`, { method: 'POST', body: payload || {} }),
rejectAdminTemplateRequest: (requestId) => request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/reject`, { method: 'POST', body: {} }),
listAdminUsers: () => request('/api/admin/users'),
listAdminUsers: ({ q = '', sort = 'recent', direction = 'desc' } = {}) =>
request(`/api/admin/users?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}&direction=${encodeURIComponent(direction)}`),
updateAdminUser: (userId, payload) =>
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
updateAdminUserPassword: (userId, payload) =>
request(`/api/admin/users/${encodeURIComponent(userId)}/password`, { method: 'PATCH', body: payload }),
updateAdminUserAvatar: async (userId, { file, removeAvatar = false } = {}) => {
const fd = new FormData()
if (file) fd.append('avatar', file)
if (removeAvatar) fd.append('removeAvatar', '1')
const res = await fetch(toApiUrl(`/api/admin/users/${encodeURIComponent(userId)}/avatar`), {
method: 'POST',
credentials: 'include',
body: fd,
})
const data = await res.json()
if (!res.ok) {
const err = new Error('request_failed')
err.status = res.status
err.data = data
throw err
}
return data
},
deleteAdminUser: (userId) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
listPublicTierLists: (gameId) =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`),
searchPublicTierLists: (gameId, q = '') =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`),
searchAllPublicTierLists: (q = '') => request(`/api/tierlists/public?q=${encodeURIComponent(q || '')}`),
listMyTierLists: () => request('/api/tierlists/me'),
listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) =>
request(`/api/tierlists/favorites/me?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}`),
@@ -69,7 +100,8 @@ export const api = {
favoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'POST' }),
unfavoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'DELETE' }),
deleteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`, { method: 'DELETE' }),
requestTierListTemplate: (id, payload) => request(`/api/tierlists/${encodeURIComponent(id)}/template-request`, { method: 'POST', body: payload }),
duplicateTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/duplicate`, { method: 'POST' }),
requestTierListTemplate: (payload) => request('/api/tierlists/template-request', { method: 'POST', body: payload }),
saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }),
uploadTierListThumbnail: async (file) => {
const fd = new FormData()

View File

@@ -8,6 +8,7 @@ import MyTierListsView from '../views/MyTierListsView.vue'
import FavoriteTierListsView from '../views/FavoriteTierListsView.vue'
import AdminView from '../views/AdminView.vue'
import ProfileView from '../views/ProfileView.vue'
import SearchResultsView from '../views/SearchResultsView.vue'
export function createRouter() {
return _createRouter({
@@ -20,6 +21,7 @@ export function createRouter() {
{ path: '/login', name: 'login', component: LoginView },
{ path: '/me', name: 'me', component: MyTierListsView },
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView },
{ path: '/search', name: 'search', component: SearchResultsView },
{ path: '/admin', name: 'admin', component: AdminView },
{ path: '/profile', name: 'profile', component: ProfileView },
],

View File

@@ -72,3 +72,52 @@ p {
#app {
width: 100%;
}
.pageWrap {
display: grid;
gap: 18px;
}
.pageHead {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
min-height: 96px;
padding: 0;
}
.pageHead__main {
display: grid;
align-content: start;
gap: 6px;
min-width: 0;
}
.pageHead__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
}
.pageHead__title {
font-size: 32px;
line-height: 1.05;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
}
.pageHead__desc {
max-width: 720px;
color: rgba(255, 255, 255, 0.58);
}
.pageHead__aside {
display: flex;
align-items: flex-start;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}

File diff suppressed because it is too large Load Diff

View File

@@ -11,17 +11,12 @@ const toast = useToast()
const favorites = ref([])
const query = ref('')
const sort = ref('favorited')
const sortLabel = computed(() =>
sort.value === 'favorited' ? '즐겨찾기한 날짜' : sort.value === 'updated' ? '최종 업데이트' : '즐겨찾기 수'
)
function fmt(ts) {
return new Date(ts).toLocaleString(undefined, {
return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
@@ -59,13 +54,14 @@ onMounted(loadFavorites)
</script>
<template>
<section class="wrap">
<div class="head">
<div>
<h2 class="title"> 즐겨찾기</h2>
<div class="desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 있어요.</div>
<section class="pageWrap">
<div class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Collection</div>
<h2 class="pageHead__title"> 즐겨찾기</h2>
<div class="pageHead__desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 있어요.</div>
</div>
<div class="toolbar">
<div class="pageHead__aside toolbar">
<input v-model="query" class="input" placeholder="제목, 게임, 작성자 검색" @keydown.enter.prevent="loadFavorites" />
<select v-model="sort" class="select" @change="loadFavorites">
<option value="favorited">즐겨찾기한 </option>
@@ -78,55 +74,33 @@ onMounted(loadFavorites)
<div v-if="favorites.length === 0" class="empty">즐겨찾기한 티어표가 없어요.</div>
<div v-else class="list">
<article v-for="tierList in favorites" :key="tierList.id" class="row">
<button class="row__body" @click="openTierList(tierList)">
<div class="row__thumbWrap">
<img v-if="tierListThumbnailUrl(tierList)" class="row__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
<div v-else class="row__thumbPlaceholder"></div>
<article v-for="tierList in favorites" :key="tierList.id" class="boardCard">
<button class="boardCard__body" @click="openTierList(tierList)">
<div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="row__head">
<div class="row__title">{{ tierList.title }}</div>
<div class="row__author">
<img v-if="avatarSrcOf(tierList)" class="row__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
<span>by {{ displayNameOf(tierList) }}</span>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ tierList.title }}</div>
<div class="favoriteStat"> {{ tierList.favoriteCount || 0 }}</div>
</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
</div>
<div class="boardCard__date">{{ fmt(tierList.updatedAt) }}</div>
</div>
</div>
</button>
<div class="row__foot">
<div class="row__meta">
<div>{{ tierList.gameName || tierList.gameId }}</div>
<div>{{ sortLabel }}: {{ fmt(sort === 'favorited' ? tierList.favoritedAt : tierList.updatedAt) }}</div>
</div>
<div class="favoriteStat"> {{ tierList.favoriteCount || 0 }}</div>
</div>
</article>
</div>
</section>
</template>
<style scoped>
.wrap {
display: grid;
gap: 18px;
}
.head {
display: flex;
gap: 14px;
justify-content: space-between;
align-items: flex-end;
flex-wrap: wrap;
}
.title {
margin: 0;
font-size: 30px;
color: rgba(255, 255, 255, 0.96);
letter-spacing: -0.04em;
}
.desc {
margin-top: 6px;
color: rgba(255, 255, 255, 0.58);
}
.toolbar {
display: flex;
gap: 10px;
@@ -134,15 +108,15 @@ onMounted(loadFavorites)
}
.input,
.select {
padding: 10px 12px;
border-radius: 10px;
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);
}
.btn {
padding: 10px 12px;
border-radius: 10px;
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);
@@ -157,15 +131,22 @@ onMounted(loadFavorites)
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.row {
border-radius: 14px;
.boardCard {
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
overflow: hidden;
display: grid;
gap: 10px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition:
transform 0.16s ease,
background 0.16s ease;
}
.row__body {
.boardCard:hover {
transform: translateY(-2px);
background: rgba(70, 70, 70, 0.96);
}
.boardCard__body {
border: 0;
background: transparent;
color: inherit;
@@ -173,81 +154,100 @@ onMounted(loadFavorites)
text-align: left;
cursor: pointer;
display: grid;
gap: 10px;
}
.row__thumbWrap {
.boardCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
background: #555;
padding: 14px 14px 0;
box-sizing: border-box;
}
.row__thumb,
.row__thumbPlaceholder {
.boardCard__thumb,
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
display: block;
border-radius: 18px;
}
.row__thumb {
.boardCard__thumb {
object-fit: cover;
}
.row__thumbPlaceholder {
.boardCard__thumbPlaceholder {
background: #555;
}
.row__head {
padding: 14px 14px 0;
display: grid;
gap: 10px;
place-items: center;
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
font-weight: 700;
}
.row__title {
.boardCard__head {
padding: 16px 18px 18px;
display: grid;
gap: 6px;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.boardCard__metaRow {
align-items: flex-end;
}
.boardCard__title {
min-width: 0;
font-weight: 800;
font-size: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row__author {
.boardCard__author {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
opacity: 0.86;
}
.row__avatar {
width: 28px;
height: 28px;
border-radius: 999px;
.boardCard__authorName {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__avatar {
width: 22px;
height: 22px;
border-radius: 9999px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
flex: 0 0 auto;
}
.row__avatar--fallback {
.boardCard__avatar--fallback {
display: grid;
place-items: center;
font-size: 12px;
font-size: 11px;
font-weight: 900;
}
.row__foot {
padding: 0 14px 14px;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.row__meta {
display: grid;
gap: 4px;
opacity: 0.78;
font-size: 13px;
}
.boardCard__date,
.favoriteStat {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.92);
border-radius: 999px;
padding: 7px 10px;
font-weight: 800;
flex: 0 0 auto;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
}
@media (max-width: 1100px) {
.boardCard__date {
font-size: 10px;
}
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 960px) {
@media (max-width: 1024px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}

View File

@@ -15,14 +15,14 @@ const gameName = ref('')
const tierLists = ref([])
const error = ref('')
const query = ref('')
const brokenThumbnailIds = ref({})
const isListView = computed(() => route.query.view === 'list')
function fmt(ts) {
return new Date(ts).toLocaleString(undefined, {
return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
@@ -39,9 +39,15 @@ 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 () => {
await loadTierLists()
})
@@ -53,6 +59,7 @@ async function loadTierLists() {
api.searchPublicTierLists(gameId.value, query.value),
])
gameName.value = gameRes.game?.name || gameId.value
brokenThumbnailIds.value = {}
tierLists.value = listRes.tierLists || []
} catch (e) {
error.value = '게임 정보를 불러오지 못했어요.'
@@ -77,93 +84,88 @@ function submitSearch() {
</script>
<template>
<section class="head">
<div class="head__left">
<div class="kicker">Collection</div>
<h2 class="title">{{ gameName || gameId }}</h2>
<p class="desc"> 티어표를 만들거나, 다른 사람들이 올린 티어표를 카드형 목록으로 탐색할 있어요.</p>
</div>
<div class="head__right">
<button class="primary" @click="createNew">{{ auth.user ? '새로운 티어표 만들기' : '로그인 티어표 만들기' }}</button>
<section class="dashboardHero">
<div class="dashboardHero__left">
<div class="dashboardHero__eyebrow">Collection</div>
<h2 class="dashboardHero__title">{{ gameName || gameId }}</h2>
<p class="dashboardHero__desc"> 게임의 공개 티어표를 탐색하고, 바로 보드를 만들어 같은 흐름으로 이어갈 있어요.</p>
</div>
</section>
<div v-if="error" class="error">{{ error }}</div>
<section class="panel">
<div class="panel__head">
<div class="panel__title">공개 티어표</div>
<div>
<div class="panel__title">공개 티어표</div>
<div class="panel__sub">제목이나 작성자로 빠르게 좁혀볼 있어요.</div>
</div>
<div class="searchBar">
<input v-model="query" class="searchBar__input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
<button class="searchBar__button" @click="submitSearch">검색</button>
</div>
</div>
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
<div v-else class="list">
<article v-for="t in tierLists" :key="t.id" class="row">
<button class="row__body" @click="openTierList(t.id)">
<div class="row__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="row__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
<div v-else class="row__thumbPlaceholder"></div>
<div v-else class="list" :class="{ 'list--table': isListView }">
<article v-for="t in tierLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openTierList(t.id)">
<div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" alt="" @error="handleThumbnailError(t.id)" />
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="row__head">
<div class="row__title">{{ t.title }}</div>
<div class="row__author">
<img v-if="avatarSrcOf(t)" class="row__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span>by {{ displayNameOf(t) }}</span>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ t.title }}</div>
<div class="favoriteStat" :title="t.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
{{ t.isFavorited ? '♥' : '♡' }} {{ 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>
</div>
</button>
<div class="row__foot">
<div class="row__meta">{{ fmt(t.updatedAt) }}</div>
<div class="favoriteStat" :title="t.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
{{ t.isFavorited ? '★' : '☆' }} {{ t.favoriteCount || 0 }}
</div>
</div>
</article>
</div>
</section>
</template>
<style scoped>
.head {
.dashboardHero {
display: flex;
gap: 18px;
align-items: flex-end;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
padding: 4px 2px 18px;
padding: 6px 2px 18px;
}
.kicker {
.dashboardHero__left {
display: grid;
gap: 8px;
}
.dashboardHero__eyebrow {
font-size: 12px;
color: rgba(255, 255, 255, 0.42);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.title {
.dashboardHero__title {
margin: 4px 0 6px;
font-size: 30px;
font-size: 32px;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
}
.desc {
.dashboardHero__desc {
margin: 0;
color: rgba(255, 255, 255, 0.58);
}
.primary {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.92);
cursor: pointer;
font-weight: 700;
}
.primary:hover {
background: rgba(255, 255, 255, 0.12);
max-width: 720px;
}
.panel {
border: 1px solid rgba(255, 255, 255, 0.08);
/* border: 1px solid rgba(255, 255, 255, 0.08); */
background: transparent;
border-radius: 0;
padding: 0;
@@ -177,6 +179,12 @@ function submitSearch() {
}
.panel__title {
font-weight: 800;
font-size: 18px;
}
.panel__sub {
margin-top: 6px;
color: rgba(255, 255, 255, 0.56);
font-size: 13px;
}
.panel__head {
display: flex;
@@ -194,15 +202,15 @@ function submitSearch() {
}
.searchBar__input {
min-width: 240px;
padding: 10px 12px;
border-radius: 10px;
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);
}
.searchBar__button {
padding: 10px 12px;
border-radius: 10px;
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);
@@ -214,24 +222,33 @@ function submitSearch() {
}
.list {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(260px, 320px));
justify-content: start;
gap: 18px;
}
.row {
border-radius: 14px;
.list--table {
grid-template-columns: 1fr;
}
.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);
display: grid;
gap: 10px;
align-content: start;
min-height: 168px;
overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition:
transform 0.16s ease,
background 0.16s ease;
}
.row:hover {
background: rgba(255, 255, 255, 0.05);
.boardCard:hover {
background: rgba(70, 70, 70, 0.96);
transform: translateY(-2px);
}
.row__body {
.boardCard__body {
min-width: 0;
text-align: left;
padding: 0;
border: 0;
@@ -240,92 +257,155 @@ function submitSearch() {
cursor: pointer;
width: 100%;
display: grid;
gap: 10px;
}
.row__thumbWrap {
.boardCard__body--list {
grid-template-columns: 76px minmax(0, 1fr);
align-items: center;
}
.boardCard__thumbWrap {
min-width: 0;
width: 100%;
aspect-ratio: 16 / 9;
background: #555;
padding: 14px 14px 0;
box-sizing: border-box;
}
.row__thumb {
.boardCard--list .boardCard__thumbWrap {
aspect-ratio: auto;
height: 100%;
padding: 14px 0 14px 14px;
}
.boardCard__thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: 18px;
}
.row__thumbPlaceholder {
.boardCard--list .boardCard__thumb,
.boardCard--list .boardCard__thumbPlaceholder {
width: 48px;
height: 48px;
min-height: 48px;
border-radius: 12px;
}
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
background: #555;
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
font-weight: 700;
border-radius: 18px;
}
.row__title {
.boardCard__title {
font-weight: 800;
min-width: 0;
font-size: 18px;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
.row__head {
padding: 14px 14px 0;
.boardCard__head {
min-width: 0;
padding: 16px 18px 18px;
display: grid;
gap: 12px;
align-content: start;
}
.row__author {
display: inline-flex;
gap: 8px;
}
.boardCard--list .boardCard__head {
height: 100%;
padding: 14px 16px 14px 0;
align-content: center;
}
.boardCard__titleRow,
.boardCard__metaRow {
min-width: 0;
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 {
min-width: 0;
max-width: 100%;
display: inline-flex;
gap: 7px;
align-items: center;
font-size: 13px;
opacity: 0.86;
flex: 0 0 auto;
overflow: hidden;
}
.row__avatar {
width: 28px;
height: 28px;
border-radius: 999px;
.boardCard__authorName {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__avatar {
width: 22px;
height: 22px;
border-radius: 9999px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
flex: 0 0 auto;
}
.row__avatar--fallback {
.boardCard__avatar--fallback {
display: grid;
place-items: center;
font-size: 12px;
font-size: 11px;
font-weight: 900;
}
.row__meta {
opacity: 0.78;
font-size: 13px;
}
.row__foot {
padding: 0 14px 14px;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-top: auto;
}
.boardCard__date,
.favoriteStat {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.92);
border-radius: 999px;
padding: 7px 10px;
font-weight: 800;
flex: 0 0 auto;
min-width: 0;
max-width: 100%;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 1100px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1100px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
.boardCard__date {
font-size: 10px;
}
@media (max-width: 900px) {
.boardCard__body--list {
grid-template-columns: 1fr;
}
.boardCard--list .boardCard__head {
padding: 0 18px 18px;
}
.boardCard--list .boardCard__thumbWrap {
padding: 14px 14px 0;
}
}
@media (max-width: 720px) {
.list {
grid-template-columns: 1fr;
}
.searchBar__input {
min-width: 0;
width: 100%;

View File

@@ -1,116 +1,117 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api'
import SvgIcon from '../components/SvgIcon.vue'
import kidStarIcon from '../assets/icons/kid_star.svg'
import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const items = ref([])
const error = ref('')
const games = computed(() => items.value.filter((item) => item.id !== 'freeform'))
const loadingFavoriteId = ref('')
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
const games = computed(() => {
const filtered = items.value
.filter((item) => item.id !== 'freeform')
.filter((item) => {
if (!query.value) return true
const haystack = `${item.name || ''} ${item.id || ''}`.toLowerCase()
return haystack.includes(query.value)
})
onMounted(async () => {
return filtered.slice().sort((a, b) => {
if (!!a.isFavorited !== !!b.isFavorited) return a.isFavorited ? -1 : 1
const rankA = a.displayRank == null ? Number.MAX_SAFE_INTEGER : a.displayRank
const rankB = b.displayRank == null ? Number.MAX_SAFE_INTEGER : b.displayRank
if (rankA !== rankB) return rankA - rankB
return (a.name || '').localeCompare(b.name || '', 'ko')
})
})
async function loadGames() {
try {
const data = await api.listGames()
items.value = data.games || []
} catch (e) {
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
}
})
}
onMounted(loadGames)
watch(() => auth.user?.id, loadGames)
function goGame(gameId) {
router.push(`/games/${gameId}`)
}
function goFreeform() {
async function toggleFavorite(game, event) {
event?.stopPropagation()
if (!auth.user) {
router.push('/login?redirect=/editor/freeform/new')
router.push(`/login?redirect=${encodeURIComponent(route.fullPath || '/')}`)
return
}
router.push('/editor/freeform/new')
if (!game?.id || loadingFavoriteId.value === game.id) return
try {
loadingFavoriteId.value = game.id
const res = game.isFavorited ? await api.unfavoriteGame(game.id) : await api.favoriteGame(game.id)
items.value = items.value.map((entry) => (entry.id === game.id ? { ...entry, ...res.game } : entry))
} catch (e) {
error.value = '즐겨찾기 변경에 실패했어요.'
} finally {
loadingFavoriteId.value = ''
}
}
function thumbUrl(g) {
if (!g.thumbnailSrc) return ''
return toApiUrl(g.thumbnailSrc)
return g.thumbnailSrc ? toApiUrl(g.thumbnailSrc) : ''
}
</script>
<template>
<section class="topBar">
<div class="topBar__copy">
<h1 class="topBar__title">Main Title</h1>
<p class="topBar__desc">게임 선택과 커스텀 티어표 진입을 하나의 대시보드처럼 정리했습니다.</p>
</div>
<div class="toolbar">
<button class="toolbar__ghost" @click="goFreeform">Toggle Filter</button>
<button class="toolbar__select" @click="goFreeform">Select Filter</button>
<button class="customTierBtn" @click="goFreeform">{{ auth.user ? '+ 커스텀 티어표 만들기' : '+ 로그인 커스텀 티어표 만들기' }}</button>
<section class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Workspace</div>
<h1 class="pageHead__title">Game Library</h1>
<p class="pageHead__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 있어요.</p>
<p v-if="query" class="pageHead__searchState">"{{ query }}" 맞는 게임 템플릿만 보고 있어요.</p>
</div>
</section>
<div v-if="error" class="error">{{ error }}</div>
<section class="grid">
<button v-for="g in games" :key="g.id" class="card" @click="goGame(g.id)">
<div class="thumbWrap">
<img v-if="thumbUrl(g)" class="thumb" :src="thumbUrl(g)" :alt="g.name" />
<div v-else class="thumbFallback">{{ g.name[0] }}</div>
<TransitionGroup v-if="games.length" name="libraryCard" tag="section" class="libraryGrid">
<article v-for="g in games" :key="g.id" class="libraryCard">
<button
class="libraryCard__favorite"
type="button"
:class="{ 'libraryCard__favorite--active': g.isFavorited }"
:disabled="loadingFavoriteId === g.id"
@click.stop="toggleFavorite(g, $event)"
>
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
</button>
<button class="libraryCard__main" type="button" @click="goGame(g.id)">
<div class="libraryCard__thumbWrap">
<img v-if="thumbUrl(g)" class="libraryCard__thumb" :src="thumbUrl(g)" :alt="g.name" />
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
</div>
<div class="card__title">{{ g.name }}</div>
</button>
</section>
<div class="libraryCard__body">
<div class="libraryCard__title">{{ g.name }}</div>
<div class="libraryCard__meta">{{ g.id }}</div>
</div>
</button>
</article>
</TransitionGroup>
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 게임 템플릿이 없어요.' : '표시할 게임 템플릿이 없어요.' }}</div>
</template>
<style scoped>
.topBar {
display: flex;
gap: 18px;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
margin-top: 2px;
margin-bottom: 18px;
}
.topBar__copy {
display: grid;
gap: 8px;
}
.topBar__title {
margin: 0;
font-size: 32px;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
}
.topBar__desc {
margin: 0;
color: rgba(255, 255, 255, 0.58);
line-height: 1.5;
}
.toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.toolbar__ghost,
.toolbar__select,
.customTierBtn {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.84);
font-weight: 700;
cursor: pointer;
}
.customTierBtn {
background: rgba(255, 255, 255, 0.08);
}
.grid {
.libraryGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
@@ -123,69 +124,142 @@ function thumbUrl(g) {
background: rgba(239, 68, 68, 0.1);
color: rgba(255, 255, 255, 0.92);
}
.card {
.pageHead__searchState {
margin-top: 8px;
color: rgba(255, 255, 255, 0.62);
}
.libraryCard {
position: relative;
text-align: left;
padding: 12px;
border-radius: 14px;
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);
cursor: pointer;
display: grid;
gap: 12px;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition: transform 0.16s ease, background 0.16s ease;
will-change: transform, opacity;
}
.card:hover {
background: rgba(72, 72, 72, 0.92);
.libraryCard:hover {
background: rgba(70, 70, 70, 0.96);
transform: translateY(-2px);
}
.thumbWrap {
.libraryCard__main {
display: grid;
gap: 12px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.libraryCard__favorite {
position: absolute;
bottom: 24px;
right: 14px;
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(15, 15, 15, 0.72);
color: rgba(255, 255, 255, 0.82);
font-size: 17px;
line-height: 1;
cursor: pointer;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
}
.libraryCard__favorite--active {
background: rgba(54, 45, 10, 0.92);
border-color: rgba(255, 216, 107, 0.28);
}
.libraryCard__favoriteIcon {
opacity: 0.76;
color: rgba(255, 255, 255, 0.94);
}
.libraryCard__favorite--active .libraryCard__favoriteIcon {
opacity: 1;
color: #ffd86b;
}
.libraryCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 10px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: #555;
overflow: hidden;
display: grid;
place-items: center;
}
.thumb {
.libraryCard__thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbFallback {
font-weight: 700;
.libraryCard__thumbFallback {
font-size: 14px;
color: rgba(255, 255, 255, 0.4);
}
.card__title {
.libraryCard__body {
display: grid;
}
.libraryCard__title {
font-weight: 800;
letter-spacing: -0.02em;
font-size: 15px;
font-size: 18px;
}
@media (max-width: 1200px) {
.grid {
.libraryCard__meta {
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.libraryCard-move,
.libraryCard-enter-active,
.libraryCard-leave-active {
transition: transform 280ms ease, opacity 220ms ease;
}
.libraryCard-enter-from,
.libraryCard-leave-to {
opacity: 0;
transform: translateY(10px) scale(0.985);
}
.libraryCard-leave-active {
position: absolute;
width: calc(100% - 0px);
pointer-events: none;
}
.libraryEmpty {
padding: 20px 0;
color: rgba(255, 255, 255, 0.62);
}
@media (max-width: 1400px) {
.libraryGrid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.grid {
@media (max-width: 1200px) {
.libraryGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.libraryGrid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.topBar {
align-items: stretch;
}
.toolbar {
width: 100%;
}
.toolbar__ghost,
.toolbar__select,
.customTierBtn {
flex: 1 1 100%;
}
.grid {
.libraryGrid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,5 +1,5 @@
<script setup>
import { onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { api } from '../lib/api'
@@ -12,6 +12,7 @@ const toast = useToast()
const email = ref('')
const password = ref('')
const passwordConfirm = ref('')
const mode = ref('login')
const error = ref('')
const hasUsers = ref(true)
@@ -22,6 +23,14 @@ watch(error, (message) => {
error.value = ''
})
const title = computed(() => (mode.value === 'signup' ? '회원가입' : '로그인'))
const description = computed(() =>
mode.value === 'signup'
? '티어표를 저장하고 즐겨찾기, 개인 설정을 관리할 수 있도록 계정을 만들어요.'
: '저장한 티어표와 즐겨찾기, 프로필 설정을 이어서 관리할 수 있어요.'
)
const submitLabel = computed(() => (mode.value === 'signup' ? '가입하기' : '로그인'))
onMounted(async () => {
try {
const meta = await api.authMeta()
@@ -33,6 +42,10 @@ onMounted(async () => {
async function submit() {
error.value = ''
if (mode.value === 'signup' && password.value !== passwordConfirm.value) {
error.value = '비밀번호 확인이 일치하지 않아요.'
return
}
try {
if (mode.value === 'signup') await auth.signup(email.value, password.value)
else await auth.login(email.value, password.value)
@@ -44,104 +57,186 @@ async function submit() {
</script>
<template>
<section class="wrap">
<form class="card" @submit.prevent="submit">
<div class="tabs">
<button type="button" class="tab" :class="{ 'tab--active': mode === 'login' }" @click="mode = 'login'">
<section class="pageWrap">
<header class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Account</div>
<h2 class="pageHead__title">{{ title }}</h2>
<div class="pageHead__desc">{{ description }}</div>
</div>
</header>
<section class="authScreen">
<div class="authTabs" role="tablist" aria-label="로그인 또는 회원가입">
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'login' }" @click="mode = 'login'">
로그인
</button>
<button type="button" class="tab" :class="{ 'tab--active': mode === 'signup' }" @click="mode = 'signup'">
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'signup' }" @click="mode = 'signup'">
회원가입
</button>
</div>
<label class="label">이메일</label>
<input v-model="email" class="input" placeholder="you@example.com" autocomplete="email" />
<label class="label">비밀번호</label>
<input
v-model="password"
class="input"
type="password"
placeholder="********"
autocomplete="current-password"
/>
<form class="authFields" @submit.prevent="submit">
<label class="field">
<span class="field__label">이메일</span>
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" maxlength="255" />
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다. {{ email.length }}/255</span>
</label>
<button class="btn" type="submit">{{ mode === 'signup' ? '회원가입' : '로그인' }}</button>
<label class="field">
<span class="field__label">비밀번호</span>
<input
v-model="password"
class="field__input"
type="password"
placeholder="********"
autocomplete="current-password"
maxlength="120"
/>
<span class="field__hint">6~120 입력 가능 · {{ password.length }}/120</span>
</label>
<div v-if="!hasUsers" class="hint"> 회원가입 계정은 자동으로 admin 권한이 부여됩니다(개발용).</div>
</form>
<label v-if="mode === 'signup'" class="field">
<span class="field__label">비밀번호 확인</span>
<input
v-model="passwordConfirm"
class="field__input"
type="password"
placeholder="********"
autocomplete="new-password"
maxlength="120"
/>
<span class="field__hint">같은 비밀번호를 입력해주세요. {{ passwordConfirm.length }}/120</span>
</label>
<div v-if="!hasUsers" class="roleBadge"> 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
<div class="authActions">
<button class="secondaryAction" type="button" @click="router.push('/')">취소</button>
<button class="primaryAction" type="submit">{{ submitLabel }}</button>
</div>
</form>
</section>
</section>
</template>
<style scoped>
.wrap {
min-height: calc(100vh - 74px);
.authScreen {
display: grid;
place-items: center;
padding: 14px 2px;
gap: 28px;
max-width: 620px;
padding-top: 4px;
}
.card {
max-width: 420px;
width: min(420px, 92vw);
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
border-radius: 16px;
padding: 14px;
box-sizing: border-box;
}
.tabs {
display: grid;
grid-template-columns: 1fr 1fr;
.authTabs {
display: inline-flex;
gap: 8px;
margin-bottom: 10px;
width: fit-content;
padding: 6px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
.tab {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.12);
color: rgba(255, 255, 255, 0.9);
font-weight: 800;
.authTabs__button {
min-width: 112px;
padding: 10px 16px;
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.62);
font-weight: 700;
cursor: pointer;
}
.tab--active {
background: rgba(96, 165, 250, 0.18);
border-color: rgba(255, 255, 255, 0.16);
.authTabs__button--active {
background: rgba(76, 133, 245, 0.22);
color: rgba(255, 255, 255, 0.96);
}
.label {
display: block;
.authFields {
display: grid;
gap: 20px;
}
.field {
display: grid;
gap: 8px;
}
.field__label {
font-size: 13px;
opacity: 0.78;
margin-top: 10px;
margin-bottom: 6px;
color: rgba(255, 255, 255, 0.62);
}
.input {
.field__input {
width: 100%;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
color: rgba(255, 255, 255, 0.92);
padding: 14px 0;
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
background: transparent;
color: rgba(255, 255, 255, 0.94);
outline: none;
box-sizing: border-box;
font-size: 18px;
letter-spacing: -0.02em;
}
.btn {
margin-top: 12px;
width: 100%;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(96, 165, 250, 0.2);
color: rgba(255, 255, 255, 0.92);
.field__input:focus {
border-bottom-color: rgba(96, 165, 250, 0.9);
}
.field__hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.42);
}
.roleBadge {
width: fit-content;
padding: 6px 10px;
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);
font-size: 12px;
font-weight: 700;
}
.authActions {
display: flex;
gap: 12px;
flex-wrap: wrap;
padding-top: 8px;
}
.primaryAction,
.secondaryAction {
padding: 12px 18px;
border-radius: 999px;
font-weight: 700;
cursor: pointer;
font-weight: 800;
}
.btn:hover {
background: rgba(96, 165, 250, 0.26);
.primaryAction {
border: 1px solid rgba(76, 133, 245, 0.96);
background: rgba(76, 133, 245, 0.92);
color: #fff;
}
.hint {
margin-top: 10px;
opacity: 0.72;
font-size: 13px;
.secondaryAction {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.86);
}
@media (max-width: 720px) {
.authTabs {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.authTabs__button {
min-width: 0;
}
}
</style>

View File

@@ -17,12 +17,10 @@ watch(error, (message) => {
})
function fmt(ts) {
return new Date(ts).toLocaleString(undefined, {
return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
@@ -56,43 +54,41 @@ function openList(t) {
router.push(`/editor/${t.gameId}/${t.id}`)
}
async function removeList(t) {
error.value = ''
try {
const ok = window.confirm(`"${t.title}" 티어표를 삭제할까요?`)
if (!ok) return
await api.deleteTierList(t.id)
myLists.value = myLists.value.filter((entry) => entry.id !== t.id)
toast.success('티어표를 삭제했어요.')
} catch (e) {
error.value = '티어표 삭제에 실패했어요.'
}
}
</script>
<template>
<section class="wrap">
<h2 class="title"> 티어표</h2>
<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="row">
<button class="row__body" @click="openList(t)">
<div class="row__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="row__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
<div v-else class="row__thumbPlaceholder"></div>
<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>
</div>
<div class="row__head">
<div class="row__title">{{ t.title }}</div>
<div class="row__author">
<img v-if="avatarSrcOf(t)" class="row__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span>by {{ displayNameOf(t) }}</span>
<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>
</div>
<div class="row__meta">{{ fmt(t.updatedAt) }}</div>
</button>
<button class="link link--danger" @click="removeList(t)">삭제</button>
</article>
</div>
</div>
@@ -100,48 +96,39 @@ async function removeList(t) {
</template>
<style scoped>
.wrap {
padding: 4px 2px;
}
.title {
margin: 0 0 18px;
font-size: 30px;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
}
.card {
border: 0;
background: transparent;
border-radius: 0;
padding: 0;
}
.link {
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.92);
cursor: pointer;
font-weight: 800;
}
.empty {
opacity: 0.75;
}
.list {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(260px, 320px));
justify-content: start;
gap: 18px;
}
.row {
.boardCard {
display: grid;
gap: 10px;
border-radius: 14px;
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);
overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition:
transform 0.16s ease,
background 0.16s ease;
}
.row__body {
.boardCard:hover {
transform: translateY(-2px);
background: rgba(70, 70, 70, 0.96);
}
.boardCard__body {
flex: 1 1 auto;
min-width: 0;
text-align: left;
@@ -151,77 +138,103 @@ async function removeList(t) {
color: inherit;
padding: 0;
display: grid;
gap: 10px;
}
.row__thumbWrap {
.boardCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
background: #555;
padding: 14px 14px 0;
box-sizing: border-box;
}
.row__thumb {
.boardCard__thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: 18px;
}
.row__thumbPlaceholder {
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
background: #555;
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
font-weight: 700;
border-radius: 18px;
}
.row__title {
.boardCard__title {
flex: 1 1 auto;
min-width: 0;
font-weight: 900;
font-size: 18px;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.boardCard__head {
padding: 16px 18px 18px;
display: grid;
gap: 8px;
min-width: 0;
}
.row__head {
padding: 0 14px;
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 12px;
gap: 10px;
min-width: 0;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.row__author {
.boardCard__titleRow {
align-items: flex-start;
}
.boardCard__metaRow {
align-items: flex-end;
}
.boardCard__author {
flex: 1 1 auto;
min-width: 0;
display: inline-flex;
gap: 8px;
gap: 7px;
align-items: center;
font-size: 13px;
opacity: 0.84;
}
.row__avatar {
width: 28px;
height: 28px;
border-radius: 999px;
.boardCard__authorName {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__avatar {
width: 22px;
height: 22px;
border-radius: 9999px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
flex: 0 0 auto;
}
.row__avatar--fallback {
.boardCard__avatar--fallback {
display: grid;
place-items: center;
font-size: 12px;
font-size: 11px;
font-weight: 900;
}
.row__meta {
padding: 0 14px;
margin-top: 6px;
opacity: 0.76;
.boardCard__date,
.favoriteStat {
flex: 0 0 auto;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
}
.link--danger {
background: rgba(239, 68, 68, 0.14);
border-color: rgba(239, 68, 68, 0.28);
margin: 0 14px 14px;
}
@media (max-width: 1100px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 960px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.boardCard__date {
font-size: 10px;
}
@media (max-width: 720px) {
.list {

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { toApiUrl } from '../lib/runtime'
@@ -14,6 +14,8 @@ const saving = ref(false)
const nickname = ref('')
const previewUrl = ref('')
const avatarFile = ref(null)
const removeAvatar = ref(false)
const fileInput = ref(null)
watch(error, (message) => {
if (!message) return
@@ -23,26 +25,53 @@ watch(error, (message) => {
const avatarUrl = computed(() => {
if (previewUrl.value) return previewUrl.value
if (removeAvatar.value) return ''
if (!auth.user?.avatarSrc) return ''
return toApiUrl(auth.user.avatarSrc)
})
const displayInitial = computed(() => {
const email = auth.user?.email || 'U'
return email[0].toUpperCase()
})
onMounted(async () => {
await auth.refresh()
if (!auth.user) router.push('/login')
nickname.value = auth.user?.nickname || ''
removeAvatar.value = false
})
onBeforeUnmount(() => {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
})
function openAvatarPicker() {
fileInput.value?.click()
}
function onAvatarChange(e) {
const file = e.target.files && e.target.files[0]
if (!file) return
error.value = ''
removeAvatar.value = false
avatarFile.value = file
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = URL.createObjectURL(file)
}
function clearAvatar() {
error.value = ''
avatarFile.value = null
removeAvatar.value = true
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
if (fileInput.value) fileInput.value.value = ''
}
async function saveProfile() {
error.value = ''
saving.value = true
@@ -50,6 +79,8 @@ async function saveProfile() {
const fd = new FormData()
fd.append('nickname', nickname.value)
if (avatarFile.value) fd.append('avatar', avatarFile.value)
if (removeAvatar.value) fd.append('removeAvatar', '1')
const res = await fetch(toApiUrl('/api/auth/profile'), {
method: 'POST',
credentials: 'include',
@@ -59,10 +90,12 @@ async function saveProfile() {
const data = await res.json()
auth.user = data.user
avatarFile.value = null
removeAvatar.value = false
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
if (fileInput.value) fileInput.value.value = ''
toast.success('프로필을 저장했어요.')
} catch (e2) {
error.value = '프로필 저장에 실패했어요.'
@@ -70,131 +103,289 @@ async function saveProfile() {
saving.value = false
}
}
async function logout() {
await auth.logout()
toast.success('로그아웃했어요.')
router.push('/')
}
</script>
<template>
<section class="wrap">
<h2 class="title">프로필</h2>
<section class="pageWrap">
<header class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Account</div>
<h2 class="pageHead__title">Settings</h2>
<div class="pageHead__desc">계정 정보를 간결하게 정리하고, 프로필 이미지를 클릭해서 바로 변경할 있어요.</div>
</div>
</header>
<div class="card" v-if="auth.user">
<div class="row">
<div class="avatar">
<img v-if="avatarUrl" :src="avatarUrl" class="avatarImg" alt="avatar" />
<div v-else class="avatarFallback">{{ (auth.user.email || 'U')[0].toUpperCase() }}</div>
<section v-if="auth.user" class="settingsScreen">
<div class="settingsIdentity">
<div class="avatarButtonWrap">
<button class="avatarButton" type="button" @click="openAvatarPicker">
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" />
<div v-else class="avatarButton__fallback">{{ displayInitial }}</div>
<div class="avatarButton__overlay">
<span>{{ avatarUrl ? '이미지 변경' : '이미지 추가' }}</span>
</div>
</button>
<button
v-if="avatarUrl || previewUrl"
class="avatarButton__remove"
type="button"
aria-label="프로필 이미지 삭제"
@click="clearAvatar"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div class="meta">
<div class="email">{{ auth.user.email }}</div>
<input v-model="nickname" class="nicknameInput" placeholder="작성자 닉네임" />
<div class="badge" v-if="auth.user.isAdmin">admin</div>
<div class="identityMeta">
<div class="identityMeta__eyebrow">Profile Photo</div>
<div class="identityMeta__title">프로필 이미지</div>
<div class="identityMeta__desc">아바타를 클릭해서 이미지를 추가하거나 교체할 있습니다.</div>
</div>
<input ref="fileInput" class="hiddenInput" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
</div>
<div class="upload">
<label class="label">아바타 업로드</label>
<input class="file" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
<div class="hint">파일 선택은 미리보기만 바뀌고, 실제 반영은 저장 버튼을 눌렀을 진행됩니다.</div>
<button class="saveBtn" :disabled="saving" @click="saveProfile">
{{ saving ? '저장중...' : '프로필 저장' }}
</button>
<div class="settingsFields">
<label class="field">
<span class="field__label">닉네임</span>
<input v-model="nickname" class="field__input" maxlength="40" placeholder="작성자 닉네임" />
<span class="field__hint">티어표 작성자 이름으로 표시됩니다. {{ nickname.length }}/40</span>
</label>
<label class="field">
<span class="field__label">이메일</span>
<input :value="auth.user.email" class="field__input field__input--readonly" readonly />
<span class="field__hint">현재 로그인에 사용 중인 계정입니다.</span>
</label>
<div v-if="auth.user.isAdmin" class="roleBadge">Administrator</div>
</div>
</div>
<div class="settingsActions">
<button class="primaryAction" :disabled="saving" @click="saveProfile">{{ saving ? '저장 중...' : '변경사항 저장' }}</button>
<button class="secondaryAction" type="button" @click="logout">로그아웃</button>
</div>
</section>
</section>
</template>
<style scoped>
.wrap {
padding: 10px 2px;
.settingsScreen {
display: grid;
gap: 32px;
max-width: 620px;
padding-top: 4px;
}
.title {
margin: 0 0 10px;
font-size: 26px;
letter-spacing: -0.02em;
}
.card {
max-width: 520px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
border-radius: 16px;
padding: 14px;
}
.row {
display: flex;
gap: 12px;
.settingsIdentity {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 24px;
align-items: center;
}
.avatar {
width: 68px;
height: 68px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.16);
.avatarButtonWrap {
position: relative;
width: 120px;
height: 120px;
}
.avatarButton {
position: relative;
width: 120px;
height: 120px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 9999px;
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.92);
overflow: hidden;
cursor: pointer;
display: grid;
place-items: center;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.avatarImg {
.avatarButton__image {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatarFallback {
.avatarButton__fallback {
font-size: 34px;
font-weight: 900;
font-size: 20px;
opacity: 0.9;
color: rgba(255, 255, 255, 0.86);
}
.meta {
.avatarButton__overlay {
position: absolute;
inset: auto 0 0 0;
padding: 12px 10px;
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.72));
font-size: 12px;
font-weight: 700;
color: rgba(255, 255, 255, 0.82);
}
.avatarButton__remove {
position: absolute;
top: 0;
right: 0;
width: 30px;
height: 30px;
border: 0;
border-radius: 999px;
background: rgba(10, 10, 10, 0.72);
color: rgba(255, 255, 255, 0.88);
display: grid;
place-items: center;
cursor: pointer;
z-index: 2;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(10px);
}
.avatarButton__remove svg {
width: 14px;
height: 14px;
stroke: currentColor;
stroke-width: 2.1;
fill: none;
stroke-linecap: round;
}
.avatarButton__remove:hover {
background: rgba(190, 24, 24, 0.88);
color: #fff;
}
.identityMeta {
display: grid;
gap: 6px;
flex: 1;
}
.email {
font-weight: 900;
.identityMeta__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.36);
}
.nicknameInput {
.identityMeta__title {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.03em;
}
.identityMeta__desc {
color: rgba(255, 255, 255, 0.58);
line-height: 1.6;
}
.hiddenInput {
display: none;
}
.settingsFields {
display: grid;
gap: 20px;
}
.field {
display: grid;
gap: 8px;
}
.field__label {
font-size: 13px;
color: rgba(255, 255, 255, 0.62);
}
.field__input {
width: 100%;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
color: rgba(255, 255, 255, 0.92);
padding: 14px 0;
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
background: transparent;
color: rgba(255, 255, 255, 0.94);
outline: none;
box-sizing: border-box;
font-size: 18px;
letter-spacing: -0.02em;
}
.badge {
.field__input:focus {
border-bottom-color: rgba(96, 165, 250, 0.9);
}
.field__input--readonly {
color: rgba(255, 255, 255, 0.58);
}
.field__hint {
font-size: 12px;
padding: 2px 8px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 999px;
color: rgba(255, 255, 255, 0.42);
}
.roleBadge {
width: fit-content;
opacity: 0.9;
padding: 6px 10px;
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);
font-size: 12px;
font-weight: 700;
}
.upload {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
.settingsActions {
display: flex;
gap: 12px;
flex-wrap: wrap;
padding-top: 8px;
}
.label {
display: block;
font-size: 13px;
opacity: 0.78;
margin-bottom: 6px;
}
.file {
width: 100%;
}
.hint {
margin-top: 8px;
opacity: 0.72;
font-size: 13px;
}
.saveBtn {
margin-top: 12px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(96, 165, 250, 0.2);
color: rgba(255, 255, 255, 0.92);
.primaryAction,
.secondaryAction {
padding: 12px 18px;
border-radius: 999px;
font-weight: 700;
cursor: pointer;
font-weight: 800;
}
.primaryAction {
border: 1px solid rgba(76, 133, 245, 0.96);
background: rgba(76, 133, 245, 0.92);
color: #fff;
}
.secondaryAction {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.86);
}
@media (max-width: 720px) {
.settingsIdentity {
grid-template-columns: 1fr;
}
.avatarButtonWrap {
width: 108px;
height: 108px;
}
.avatarButton {
width: 108px;
height: 108px;
}
}
</style>

View File

@@ -0,0 +1,279 @@
<script setup>
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
const route = useRoute()
const router = useRouter()
const tierLists = ref([])
const loading = ref(false)
const error = ref('')
const query = ref('')
function fmt(ts) {
return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
function displayNameOf(tierList) {
return tierList.authorName || '알 수 없음'
}
function avatarSrcOf(tierList) {
return tierList.authorAvatarSrc ? toApiUrl(tierList.authorAvatarSrc) : ''
}
function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
}
function tierListThumbnailUrl(tierList) {
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
}
function openTierList(tierList) {
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
}
async function loadResults() {
loading.value = true
error.value = ''
try {
const data = await api.searchAllPublicTierLists(query.value)
tierLists.value = data.tierLists || []
} catch (e) {
error.value = '검색 결과를 불러오지 못했어요.'
} finally {
loading.value = false
}
}
watch(
() => route.query.q,
async (nextQuery) => {
query.value = typeof nextQuery === 'string' ? nextQuery : ''
await loadResults()
},
{ immediate: true }
)
</script>
<template>
<section class="wrap">
<div class="head">
<div>
<div class="head__eyebrow">Search</div>
<h2 class="title">전체 티어표 검색</h2>
<div class="desc">공개된 모든 티어표를 제목과 작성자 기준으로 찾아볼 있어요.</div>
</div>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-else-if="loading" class="empty">검색 중이에요.</div>
<div v-else-if="tierLists.length === 0" class="empty">검색 결과가 없어요.</div>
<div v-else class="list">
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
<button class="boardCard__body" @click="openTierList(tierList)">
<div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ tierList.title }}</div>
<div class="favoriteStat" :title="tierList.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
{{ tierList.isFavorited ? '♥' : '♡' }} {{ tierList.favoriteCount || 0 }}
</div>
</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
</div>
<div class="boardCard__date">{{ fmt(tierList.updatedAt) }}</div>
</div>
</div>
</button>
</article>
</div>
</section>
</template>
<style scoped>
.wrap {
display: grid;
gap: 18px;
}
.head {
display: flex;
gap: 14px;
justify-content: space-between;
align-items: flex-end;
flex-wrap: wrap;
padding: 6px 2px 8px;
}
.head__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
}
.title {
margin: 4px 0 0;
font-size: 32px;
color: rgba(255, 255, 255, 0.96);
letter-spacing: -0.04em;
}
.desc {
margin-top: 6px;
color: rgba(255, 255, 255, 0.58);
}
.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);
}
.empty {
opacity: 0.76;
}
.list {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.boardCard {
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
overflow: hidden;
display: grid;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition: transform 0.16s ease, background 0.16s ease;
}
.boardCard:hover {
transform: translateY(-2px);
background: rgba(70, 70, 70, 0.96);
}
.boardCard__body {
border: 0;
background: transparent;
color: inherit;
padding: 0;
text-align: left;
cursor: pointer;
display: grid;
}
.boardCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
padding: 14px 14px 0;
box-sizing: border-box;
}
.boardCard__thumb,
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
display: block;
border-radius: 18px;
}
.boardCard__thumb {
object-fit: cover;
}
.boardCard__thumbPlaceholder {
background: #555;
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
font-weight: 700;
}
.boardCard__head {
padding: 16px 18px 18px;
display: grid;
gap: 6px;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.boardCard__metaRow {
align-items: flex-end;
}
.boardCard__title {
min-width: 0;
font-weight: 800;
font-size: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__author {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
opacity: 0.86;
}
.boardCard__authorName {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__avatar {
width: 22px;
height: 22px;
border-radius: 9999px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
flex: 0 0 auto;
}
.boardCard__avatar--fallback {
display: grid;
place-items: center;
font-size: 11px;
font-weight: 900;
}
.boardCard__date,
.favoriteStat {
flex: 0 0 auto;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
}
.boardCard__date {
font-size: 10px;
}
.boardCard__date {
font-size: 10px;
}
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1024px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.list {
grid-template-columns: 1fr;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,14 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"dev:frontend": "npm --prefix frontend run dev",
"dev:backend": "npm --prefix backend run dev",
"build": "npm --prefix frontend run build",
"start": "npm --prefix backend run start",
"test": "echo \"Error: no test specified\" && exit 1",
"images:backfill": "npm --prefix backend run images:backfill",
"images:migrate-legacy": "npm --prefix backend run images:migrate-legacy",
"uploads:cleanup-legacy": "npm --prefix backend run uploads:cleanup-legacy"
},
"keywords": [],
"author": "",

9
update.md Normal file
View File

@@ -0,0 +1,9 @@
# Update Log Entry Point
이 프로젝트의 상세 업데이트 로그는 [docs/update.md](/Users/bicute/Desktop/zenn.dev/tier-cursor/docs/update.md)에 계속 누적됩니다.
## 2026-03-30
- 루트 `package.json`에 공용 실행 스크립트(`dev:frontend`, `dev:backend`, `build`, `start`)를 추가했습니다.
- 루트에서도 바로 `npm run build` 같은 공용 명령을 사용할 수 있게 정리했습니다.
- 업데이트 로그 진입점을 루트 `update.md`로 추가해, 이후 작업 시 파일 위치를 바로 찾을 수 있게 했습니다.