Compare commits

..

32 Commits

Author SHA1 Message Date
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
38 changed files with 5590 additions and 1309 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()
})

View File

@@ -1,3 +1,5 @@
const fs = require('fs/promises')
const path = require('path')
const mysql = require('mysql2/promise')
const DB_HOST = process.env.DB_HOST || '127.0.0.1'
@@ -28,6 +30,29 @@ function serializeJson(value) {
return JSON.stringify(value || [])
}
function collectUploadSrcsFromItems(items, bucket) {
for (const item of items || []) {
if (typeof item?.src === 'string' && item.src.startsWith('/uploads/')) {
bucket.add(item.src)
}
}
}
function resolveMonthRange(month) {
if (typeof month !== 'string') return null
const match = month.trim().match(/^(\d{4})-(\d{2})$/)
if (!match) return null
const year = Number(match[1])
const monthIndex = Number(match[2]) - 1
if (!Number.isInteger(year) || monthIndex < 0 || monthIndex > 11) return null
return {
start: new Date(year, monthIndex, 1).getTime(),
end: new Date(year, monthIndex + 1, 1).getTime(),
}
}
function mapUserRow(row) {
if (!row) return null
return {
@@ -64,6 +89,37 @@ function mapGameItemRow(row) {
}
}
function mapImageAssetRow(row) {
if (!row) return null
return {
id: row.id,
contentHash: row.content_hash,
src: row.src || '',
mimeType: row.mime_type || 'image/webp',
byteSize: Number(row.byte_size || 0),
originalByteSize: Number(row.original_byte_size || 0),
width: Number(row.width || 0),
height: Number(row.height || 0),
createdAt: Number(row.created_at || 0),
}
}
function mapImageOptimizationJobRow(row) {
if (!row) return null
return {
id: row.id,
status: row.status,
sourceCategory: row.source_category || '',
targetDirectory: row.target_directory || '',
originalByteSize: Number(row.original_byte_size || 0),
optimizedByteSize: Number(row.optimized_byte_size || 0),
reusedAsset: !!row.reused_asset,
errorMessage: row.error_message || '',
queuedAt: Number(row.queued_at || 0),
startedAt: Number(row.started_at || 0),
finishedAt: Number(row.finished_at || 0),
}
}
function mapTierListRow(row) {
if (!row) return null
return {
@@ -78,6 +134,10 @@ function mapTierListRow(row) {
thumbnailSrc: row.thumbnail_src || '',
description: row.description || '',
isPublic: !!row.is_public,
showCharacterNames: !!row.show_character_names,
sourceTierListId: row.source_tierlist_id || '',
sourceSnapshotTitle: row.source_snapshot_title || '',
sourceSnapshotAuthor: row.source_snapshot_author || '',
groups: parseJson(row.groups_json, []),
pool: parseJson(row.pool_json, []),
createdAt: Number(row.created_at),
@@ -163,6 +223,14 @@ async function query(sql, params = []) {
return rows
}
async function closePool() {
if (!poolPromise) return
const pool = await poolPromise
await pool.end()
poolPromise = null
initPromise = null
}
async function ensureSchema() {
if (initPromise) return initPromise
initPromise = (async () => {
@@ -226,6 +294,10 @@ async function ensureSchema() {
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
description TEXT NOT NULL,
is_public TINYINT(1) NOT NULL DEFAULT 0,
show_character_names TINYINT(1) NOT NULL DEFAULT 0,
source_tierlist_id VARCHAR(64) NULL DEFAULT NULL,
source_snapshot_title VARCHAR(120) NOT NULL DEFAULT '',
source_snapshot_author VARCHAR(120) NOT NULL DEFAULT '',
groups_json LONGTEXT NOT NULL,
pool_json LONGTEXT NOT NULL,
created_at BIGINT NOT NULL,
@@ -250,6 +322,50 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS favorite_games (
user_id VARCHAR(64) NOT NULL,
game_id VARCHAR(120) NOT NULL,
created_at BIGINT NOT NULL,
PRIMARY KEY (user_id, game_id),
INDEX idx_favorite_games_game_id (game_id),
CONSTRAINT fk_favorite_games_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_favorite_games_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS image_assets (
id VARCHAR(64) PRIMARY KEY,
content_hash CHAR(64) NOT NULL UNIQUE,
src VARCHAR(255) NOT NULL UNIQUE,
mime_type VARCHAR(32) NOT NULL DEFAULT 'image/webp',
byte_size INT UNSIGNED NOT NULL,
original_byte_size INT UNSIGNED NOT NULL,
width INT UNSIGNED NOT NULL DEFAULT 0,
height INT UNSIGNED NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
INDEX idx_image_assets_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS image_optimization_jobs (
id VARCHAR(64) PRIMARY KEY,
status VARCHAR(20) NOT NULL DEFAULT 'queued',
source_category VARCHAR(40) NOT NULL DEFAULT '',
target_directory VARCHAR(40) NOT NULL DEFAULT '',
original_byte_size INT UNSIGNED NOT NULL DEFAULT 0,
optimized_byte_size INT UNSIGNED NOT NULL DEFAULT 0,
reused_asset TINYINT(1) NOT NULL DEFAULT 0,
error_message VARCHAR(255) NOT NULL DEFAULT '',
queued_at BIGINT NOT NULL,
started_at BIGINT NOT NULL DEFAULT 0,
finished_at BIGINT NOT NULL DEFAULT 0,
INDEX idx_image_optimization_jobs_status_queued (status, queued_at),
INDEX idx_image_optimization_jobs_finished_at (finished_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS template_requests (
id VARCHAR(64) PRIMARY KEY,
@@ -277,6 +393,22 @@ async function ensureSchema() {
if (!tierListThumbnailColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
}
const tierListShowNamesColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'show_character_names'")
if (!tierListShowNamesColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
}
const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'")
if (!tierListSourceIdColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER show_character_names")
}
const tierListSourceTitleColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_title'")
if (!tierListSourceTitleColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN source_snapshot_title VARCHAR(120) NOT NULL DEFAULT '' AFTER source_tierlist_id")
}
const tierListSourceAuthorColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_author'")
if (!tierListSourceAuthorColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN source_snapshot_author VARCHAR(120) NOT NULL DEFAULT '' AFTER source_snapshot_title")
}
await query(
`
@@ -374,35 +506,72 @@ async function updateUserProfile({ id, nickname, avatarSrc }) {
return findUserById(id)
}
async function listUsers() {
const rows = await query(`
SELECT
u.id,
u.email,
u.nickname,
u.is_admin,
u.avatar_src,
u.created_at,
COUNT(t.id) AS tierlist_count,
GREATEST(
async function findPrimaryAdminUser() {
const rows = await query(
'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1'
)
return mapUserRow(rows[0])
}
async function listUsers({ queryText = '', sort = 'recent' } = {}) {
const where = []
const params = []
const trimmedQuery = typeof queryText === 'string' ? queryText.trim() : ''
if (trimmedQuery) {
where.push('(u.email LIKE ? OR u.nickname LIKE ?)')
params.push(`%${trimmedQuery}%`, `%${trimmedQuery}%`)
}
const orderBy =
sort === 'created'
? 'u.created_at DESC, recent_activity_at DESC, u.email ASC'
: sort === 'tierlists'
? 'tierlist_count DESC, recent_activity_at DESC, u.email ASC'
: 'recent_activity_at DESC, u.created_at ASC, u.email ASC'
const rows = await query(
`
SELECT
u.id,
u.email,
u.nickname,
u.is_admin,
u.avatar_src,
u.created_at,
COALESCE(MAX(t.updated_at), 0)
) AS recent_activity_at
FROM users u
LEFT JOIN tierlists t ON t.author_id = u.id
GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at
ORDER BY recent_activity_at DESC, u.created_at ASC, u.email ASC
`)
COUNT(t.id) AS tierlist_count,
GREATEST(
u.created_at,
COALESCE(MAX(t.updated_at), 0)
) AS recent_activity_at
FROM users u
LEFT JOIN tierlists t ON t.author_id = u.id
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at
ORDER BY ${orderBy}
`,
params
)
return rows.map(mapUserRow)
}
async function adminUpdateUser({ id, email, nickname, isAdmin }) {
await query('UPDATE users SET email = ?, nickname = ?, is_admin = ? WHERE id = ?', [
email,
nickname || '',
isAdmin ? 1 : 0,
id,
])
async function adminUpdateUser({ id, email, nickname, isAdmin, avatarSrc }) {
if (typeof avatarSrc === 'string') {
await query('UPDATE users SET email = ?, nickname = ?, is_admin = ?, avatar_src = ? WHERE id = ?', [
email,
nickname || '',
isAdmin ? 1 : 0,
avatarSrc,
id,
])
} else {
await query('UPDATE users SET email = ?, nickname = ?, is_admin = ? WHERE id = ?', [
email,
nickname || '',
isAdmin ? 1 : 0,
id,
])
}
return findUserById(id)
}
@@ -415,7 +584,7 @@ async function adminDeleteUser(id) {
await query('DELETE FROM users WHERE id = ?', [id])
}
async function listGames() {
async function listGames(currentUserId = '') {
const rows = await query(
`
SELECT id, name, thumbnail_src, display_rank, created_at
@@ -429,7 +598,15 @@ async function listGames() {
`,
[FREEFORM_GAME_ID]
)
return rows.map(mapGameRow)
const games = rows.map(mapGameRow)
if (!currentUserId) return games.map((game) => ({ ...game, isFavorited: false }))
const favoriteRows = await query('SELECT game_id FROM favorite_games WHERE user_id = ?', [currentUserId])
const favoriteSet = new Set(favoriteRows.map((row) => row.game_id))
return games.map((game) => ({
...game,
isFavorited: favoriteSet.has(game.id),
}))
}
async function findGameById(id) {
@@ -468,6 +645,367 @@ async function updateGameThumbnail(gameId, thumbnailSrc) {
return findGameById(gameId)
}
async function findImageAssetByHash(contentHash) {
const rows = await query(
'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE content_hash = ? LIMIT 1',
[contentHash]
)
return mapImageAssetRow(rows[0])
}
async function findImageAssetBySrc(src) {
const rows = await query(
'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE src = ? LIMIT 1',
[src]
)
return mapImageAssetRow(rows[0])
}
async function createImageAsset({ id, contentHash, src, mimeType = "image/webp", byteSize, originalByteSize, width, height }) {
const createdAt = now()
await query(
'INSERT INTO image_assets (id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, contentHash, src, mimeType, byteSize, originalByteSize, width, height, createdAt]
)
return findImageAssetByHash(contentHash)
}
async function createImageOptimizationJob({ id, sourceCategory, targetDirectory, originalByteSize }) {
const queuedAt = now()
await query(
'INSERT INTO image_optimization_jobs (id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, 'queued', sourceCategory || '', targetDirectory || '', originalByteSize || 0, 0, 0, '', queuedAt, 0, 0]
)
return findImageOptimizationJobById(id)
}
async function findImageOptimizationJobById(id) {
const rows = await query(
'SELECT id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at FROM image_optimization_jobs WHERE id = ? LIMIT 1',
[id]
)
return mapImageOptimizationJobRow(rows[0])
}
async function updateImageOptimizationJobStatus({ id, status, optimizedByteSize = 0, reusedAsset = false, errorMessage = '', startedAt, finishedAt }) {
const fields = ['status = ?', 'optimized_byte_size = ?', 'reused_asset = ?', 'error_message = ?']
const params = [status, optimizedByteSize, reusedAsset ? 1 : 0, errorMessage.slice(0, 255)]
if (typeof startedAt === 'number') {
fields.push('started_at = ?')
params.push(startedAt)
}
if (typeof finishedAt === 'number') {
fields.push('finished_at = ?')
params.push(finishedAt)
}
params.push(id)
await query(`UPDATE image_optimization_jobs SET ${fields.join(', ')} WHERE id = ?`, params)
return findImageOptimizationJobById(id)
}
async function listRecentImageOptimizationJobs(limit = 20, { month } = {}) {
const safeLimit = Math.max(1, Math.min(100, Number(limit) || 20))
const range = resolveMonthRange(month)
const where = []
const params = []
if (range) {
where.push('queued_at >= ? AND queued_at < ?')
params.push(range.start, range.end)
}
const rows = await query(
`SELECT id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at
FROM image_optimization_jobs
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY queued_at DESC
LIMIT ${safeLimit}`,
params
)
return rows.map(mapImageOptimizationJobRow)
}
async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
const safeLimit = Math.max(1, Math.min(500, Number(limit) || 100))
const safeMinAgeHours = Math.max(0, Number(minAgeHours) || 24)
const cutoff = now() - safeMinAgeHours * 60 * 60 * 1000
const assets = (await query(
`SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE created_at <= ? ORDER BY created_at ASC LIMIT ${safeLimit}`,
[cutoff]
)).map(mapImageAssetRow)
if (!assets.length) return []
const referencedSrcs = new Set()
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
query("SELECT thumbnail_src FROM games WHERE thumbnail_src <> ''"),
query("SELECT src FROM game_items WHERE src <> ''"),
query("SELECT src FROM custom_items WHERE src <> ''"),
query("SELECT thumbnail_src, pool_json FROM tierlists"),
query("SELECT thumbnail_src_snapshot, items_json FROM template_requests"),
])
for (const row of userRows) if (row.avatar_src) referencedSrcs.add(row.avatar_src)
for (const row of gameRows) if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
for (const row of gameItemRows) if (row.src) referencedSrcs.add(row.src)
for (const row of customItemRows) if (row.src) referencedSrcs.add(row.src)
for (const row of tierListRows) {
if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
collectUploadSrcsFromItems(parseJson(row.pool_json, []), referencedSrcs)
}
for (const row of templateRequestRows) {
if (row.thumbnail_src_snapshot) referencedSrcs.add(row.thumbnail_src_snapshot)
collectUploadSrcsFromItems(parseJson(row.items_json, []), referencedSrcs)
}
return assets.filter((asset) => !referencedSrcs.has(asset.src))
}
async function deleteImageAssets(ids) {
const uniqueIds = Array.from(new Set((ids || []).filter(Boolean)))
if (!uniqueIds.length) return []
const placeholders = uniqueIds.map(() => '?').join(', ')
const rows = await query(
`SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id IN (${placeholders})`,
uniqueIds
)
await query(`DELETE FROM image_assets WHERE id IN (${placeholders})`, uniqueIds)
return rows.map(mapImageAssetRow)
}
async function listReferencedUploadSources() {
const usage = await listReferencedUploadUsage()
return usage.map((entry) => entry.src)
}
async function listReferencedUploadUsage() {
const usageMap = new Map()
const addUsage = (src, role) => {
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return
if (!usageMap.has(src)) usageMap.set(src, new Set())
usageMap.get(src).add(role)
}
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
query("SELECT thumbnail_src FROM games WHERE thumbnail_src <> ''"),
query("SELECT src FROM game_items WHERE src <> ''"),
query("SELECT src FROM custom_items WHERE src <> ''"),
query("SELECT id, thumbnail_src, pool_json FROM tierlists"),
query("SELECT id, thumbnail_src_snapshot, items_json FROM template_requests"),
])
for (const row of userRows) addUsage(row.avatar_src, 'avatar')
for (const row of gameRows) addUsage(row.thumbnail_src, 'game-thumbnail')
for (const row of gameItemRows) addUsage(row.src, 'game-item')
for (const row of customItemRows) addUsage(row.src, 'custom-item')
for (const row of tierListRows) {
addUsage(row.thumbnail_src, 'tierlist-thumbnail')
for (const item of parseJson(row.pool_json, [])) addUsage(item?.src, 'tierlist-pool')
}
for (const row of templateRequestRows) {
addUsage(row.thumbnail_src_snapshot, 'template-thumbnail')
for (const item of parseJson(row.items_json, [])) addUsage(item?.src, 'template-item')
}
return Array.from(usageMap.entries())
.map(([src, roles]) => ({ src, roles: Array.from(roles).sort() }))
.sort((a, b) => a.src.localeCompare(b.src))
}
function replaceItemSrc(items, fromSrc, toSrc) {
let changed = false
const nextItems = (items || []).map((item) => {
if (item?.src !== fromSrc) return item
changed = true
return { ...item, src: toSrc }
})
return { changed, items: nextItems }
}
async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 }
const [userResult, gameResult, gameItemResult, customItemResult] = await Promise.all([
query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]),
query('UPDATE games SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]),
query('UPDATE game_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
])
let updatedRows = Number(userResult.affectedRows || 0) + Number(gameResult.affectedRows || 0) + Number(gameItemResult.affectedRows || 0) + Number(customItemResult.affectedRows || 0)
const tierListRows = await query('SELECT id, thumbnail_src, pool_json FROM tierlists')
for (const row of tierListRows) {
let nextThumbnail = row.thumbnail_src
let changed = false
if (row.thumbnail_src === fromSrc) {
nextThumbnail = toSrc
changed = true
}
const replacedPool = replaceItemSrc(parseJson(row.pool_json, []), fromSrc, toSrc)
if (replacedPool.changed) changed = true
if (changed) {
await query('UPDATE tierlists SET thumbnail_src = ?, pool_json = ?, updated_at = ? WHERE id = ?', [
nextThumbnail || '',
serializeJson(replacedPool.items),
now(),
row.id,
])
updatedRows += 1
}
}
const requestRows = await query('SELECT id, thumbnail_src_snapshot, items_json FROM template_requests')
for (const row of requestRows) {
let nextThumbnail = row.thumbnail_src_snapshot
let changed = false
if (row.thumbnail_src_snapshot === fromSrc) {
nextThumbnail = toSrc
changed = true
}
const replacedItems = replaceItemSrc(parseJson(row.items_json, []), fromSrc, toSrc)
if (replacedItems.changed) changed = true
if (changed) {
await query('UPDATE template_requests SET thumbnail_src_snapshot = ?, items_json = ?, updated_at = ? WHERE id = ?', [
nextThumbnail || '',
serializeJson(replacedItems.items),
now(),
row.id,
])
updatedRows += 1
}
}
return { updatedRows }
}
async function listImageAssets() {
const rows = await query(
'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets ORDER BY created_at DESC'
)
return rows.map(mapImageAssetRow)
}
async function getReferencedUploadFootprint() {
const [referencedSrcs, assets] = await Promise.all([listReferencedUploadSources(), listImageAssets()])
const assetMap = new Map(assets.map((asset) => [asset.src, asset]))
let totalReferencedByteSize = 0
let trackedReferencedByteSize = 0
let legacyReferencedByteSize = 0
let trackedReferencedCount = 0
let legacyReferencedCount = 0
let missingCount = 0
for (const src of referencedSrcs) {
if (typeof src !== 'string' || !src.startsWith('/uploads/')) continue
const absolutePath = path.join(__dirname, '..', src.replace(/^\//, ''))
try {
const stat = await fs.stat(absolutePath)
const size = Number(stat.size || 0)
totalReferencedByteSize += size
if (assetMap.has(src)) {
trackedReferencedCount += 1
trackedReferencedByteSize += size
} else {
legacyReferencedCount += 1
legacyReferencedByteSize += size
}
} catch (error) {
if (error?.code === 'ENOENT') missingCount += 1
}
}
return {
referencedCount: referencedSrcs.length,
totalReferencedByteSize,
trackedReferencedCount,
trackedReferencedByteSize,
legacyReferencedCount,
legacyReferencedByteSize,
missingCount,
}
}
async function getImageAssetStats({ month } = {}) {
const range = resolveMonthRange(month)
const jobWhere = []
const jobParams = []
if (range) {
jobWhere.push('queued_at >= ? AND queued_at < ?')
jobParams.push(range.start, range.end)
}
const [assetRows, jobRows, footprint] = await Promise.all([
query(
`SELECT COUNT(*) AS asset_count, COALESCE(SUM(byte_size), 0) AS total_byte_size, COALESCE(SUM(original_byte_size), 0) AS total_original_byte_size FROM image_assets`
),
query(
`SELECT
COALESCE(SUM(CASE WHEN status = 'queued' THEN 1 ELSE 0 END), 0) AS queued_count,
COALESCE(SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END), 0) AS processing_count,
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0) AS completed_count,
COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS failed_count,
COALESCE(SUM(CASE WHEN status = 'completed' AND reused_asset = 1 THEN 1 ELSE 0 END), 0) AS reused_count
FROM image_optimization_jobs
${jobWhere.length ? `WHERE ${jobWhere.join(' AND ')}` : ''}`,
jobParams
),
getReferencedUploadFootprint(),
])
const asset = assetRows[0] || {}
const jobs = jobRows[0] || {}
const totalByteSize = Number(asset.total_byte_size || 0)
const totalOriginalByteSize = Number(asset.total_original_byte_size || 0)
const savedByteSize = Math.max(0, totalOriginalByteSize - totalByteSize)
return {
assetCount: Number(asset.asset_count || 0),
totalByteSize,
totalOriginalByteSize,
savedByteSize,
savingsRatio: totalOriginalByteSize > 0 ? savedByteSize / totalOriginalByteSize : 0,
referencedCount: Number(footprint.referencedCount || 0),
referencedByteSize: Number(footprint.totalReferencedByteSize || 0),
trackedReferencedCount: Number(footprint.trackedReferencedCount || 0),
trackedReferencedByteSize: Number(footprint.trackedReferencedByteSize || 0),
legacyReferencedCount: Number(footprint.legacyReferencedCount || 0),
legacyReferencedByteSize: Number(footprint.legacyReferencedByteSize || 0),
missingReferencedCount: Number(footprint.missingCount || 0),
queuedCount: Number(jobs.queued_count || 0),
processingCount: Number(jobs.processing_count || 0),
completedCount: Number(jobs.completed_count || 0),
failedCount: Number(jobs.failed_count || 0),
reusedCount: Number(jobs.reused_count || 0),
}
}
async function clearImageOptimizationJobs({ month } = {}) {
const range = resolveMonthRange(month)
if (range) {
const result = await query('DELETE FROM image_optimization_jobs WHERE queued_at >= ? AND queued_at < ?', [range.start, range.end])
return Number(result.affectedRows || 0)
}
const result = await query('DELETE FROM image_optimization_jobs')
return Number(result.affectedRows || 0)
}
async function createGameItem({ id, gameId, src, label }) {
const createdAt = now()
await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
@@ -589,28 +1127,51 @@ async function findCustomItemById(id) {
}
}
async function getCustomItemUsageMap() {
const rows = await query('SELECT groups_json, pool_json FROM tierlists')
async function getCustomItemUsageMeta() {
const rows = await query(
`
SELECT t.game_id, g.name AS game_name, t.groups_json, t.pool_json
FROM tierlists t
LEFT JOIN games g ON g.id = t.game_id
`
)
const usageMap = new Map()
const linkedGamesMap = new Map()
rows.forEach((row) => {
const groups = parseJson(row.groups_json, [])
const pool = parseJson(row.pool_json, [])
const seenItemIds = new Set()
groups.forEach((group) => {
;(group?.itemIds || []).forEach((itemId) => {
usageMap.set(itemId, (usageMap.get(itemId) || 0) + 1)
if (itemId) seenItemIds.add(itemId)
})
})
pool.forEach((item) => {
if (item?.id) {
usageMap.set(item.id, (usageMap.get(item.id) || 0) + 1)
seenItemIds.add(item.id)
}
})
if (!row.game_id) return
seenItemIds.forEach((itemId) => {
if (!linkedGamesMap.has(itemId)) linkedGamesMap.set(itemId, new Map())
linkedGamesMap.get(itemId).set(row.game_id, {
id: row.game_id,
name: row.game_name || row.game_id,
})
})
})
return usageMap
return {
usageMap,
linkedGamesMap: new Map(Array.from(linkedGamesMap.entries()).map(([itemId, gameMap]) => [itemId, Array.from(gameMap.values())])),
}
}
async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnly = false } = {}) {
@@ -639,7 +1200,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
params
)
const usageMap = await getCustomItemUsageMap()
const { usageMap, linkedGamesMap } = await getCustomItemUsageMeta()
const allItems = rows
.map((row) => ({
id: row.id,
@@ -650,6 +1211,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
ownerName: row.nickname || row.email,
ownerEmail: row.email,
usageCount: usageMap.get(row.id) || 0,
linkedGames: linkedGamesMap.get(row.id) || [],
}))
.filter((item) => (orphanOnly ? item.usageCount === 0 : true))
@@ -689,7 +1251,7 @@ async function findUnusedCustomItems({ queryText = '' } = {}) {
params
)
const usageMap = await getCustomItemUsageMap()
const { usageMap } = await getCustomItemUsageMeta()
return rows
.map((row) => ({
id: row.id,
@@ -833,6 +1395,10 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
t.thumbnail_src,
t.description,
t.is_public,
t.show_character_names,
t.source_tierlist_id,
t.source_snapshot_title,
t.source_snapshot_author,
t.groups_json,
t.pool_json,
t.created_at,
@@ -962,6 +1528,10 @@ async function listAdminTierLists({ queryText = '', page = 1, limit = 50, curren
t.thumbnail_src,
t.description,
t.is_public,
t.show_character_names,
t.source_tierlist_id,
t.source_snapshot_title,
t.source_snapshot_author,
t.groups_json,
t.pool_json,
t.created_at,
@@ -1017,6 +1587,10 @@ async function findTierListById(id, currentUserId = '') {
t.thumbnail_src,
t.description,
t.is_public,
t.show_character_names,
t.source_tierlist_id,
t.source_snapshot_title,
t.source_snapshot_author,
t.groups_json,
t.pool_json,
t.created_at,
@@ -1214,7 +1788,21 @@ async function deleteCustomItems(ids) {
await query(`DELETE FROM custom_items WHERE id IN (${placeholders})`, ids)
}
async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', description, isPublic, groups, pool }) {
async function saveTierList({
id,
authorId,
gameId,
title,
thumbnailSrc = '',
description,
isPublic,
showCharacterNames = false,
sourceTierListId = '',
sourceSnapshotTitle = '',
sourceSnapshotAuthor = '',
groups,
pool,
}) {
const existing = id ? await findTierListById(id, authorId) : null
await syncOwnedCustomItemLabels({ ownerId: authorId, items: pool })
const nextThumbnailSrc = (thumbnailSrc || '').trim() || getAutoThumbnailSrc(groups, pool)
@@ -1223,10 +1811,10 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de
await query(
`
UPDATE tierlists
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, groups_json = ?, pool_json = ?, updated_at = ?
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, show_character_names = ?, source_tierlist_id = ?, source_snapshot_title = ?, source_snapshot_author = ?, groups_json = ?, pool_json = ?, updated_at = ?
WHERE id = ?
`,
[title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id]
[title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), now(), existing.id]
)
return findTierListById(existing.id, authorId)
}
@@ -1235,15 +1823,37 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de
await query(
`
INSERT INTO tierlists (
id, author_id, game_id, title, thumbnail_src, description, is_public, groups_json, pool_json, created_at, updated_at
id, author_id, game_id, title, thumbnail_src, description, is_public, show_character_names, source_tierlist_id, source_snapshot_title, source_snapshot_author, groups_json, pool_json, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[id, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
[id, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt]
)
return findTierListById(id, authorId)
}
async function duplicateTierListForUser({ tierList, targetUserId }) {
const { nanoid } = require('nanoid')
const duplicateId = nanoid()
const baseTitle = (tierList.title || '티어표').trim() || '티어표'
const copyTitle = baseTitle.endsWith(' 복사본') ? baseTitle : `${baseTitle} 복사본`
return saveTierList({
id: duplicateId,
authorId: targetUserId,
gameId: tierList.gameId,
title: copyTitle,
thumbnailSrc: tierList.thumbnailSrc || '',
description: tierList.description || '',
isPublic: false,
showCharacterNames: !!tierList.showCharacterNames,
sourceTierListId: tierList.id,
sourceSnapshotTitle: tierList.title || '',
sourceSnapshotAuthor: tierList.authorName || tierList.authorAccountName || '',
groups: JSON.parse(JSON.stringify(tierList.groups || [])),
pool: JSON.parse(JSON.stringify(tierList.pool || [])),
})
}
async function favoriteTierList({ userId, tierListId }) {
await query('INSERT IGNORE INTO favorite_tierlists (user_id, tierlist_id, created_at) VALUES (?, ?, ?)', [userId, tierListId, now()])
}
@@ -1252,14 +1862,24 @@ async function unfavoriteTierList({ userId, tierListId }) {
await query('DELETE FROM favorite_tierlists WHERE user_id = ? AND tierlist_id = ?', [userId, tierListId])
}
async function favoriteGame({ userId, gameId }) {
await query('INSERT IGNORE INTO favorite_games (user_id, game_id, created_at) VALUES (?, ?, ?)', [userId, gameId, now()])
}
async function unfavoriteGame({ userId, gameId }) {
await query('DELETE FROM favorite_games WHERE user_id = ? AND game_id = ?', [userId, gameId])
}
module.exports = {
DB_NAME,
ensureData,
closePool,
countUsers,
findUserByEmail,
findUserById,
createUser,
updateUserProfile,
findPrimaryAdminUser,
listUsers,
adminUpdateUser,
adminUpdateUserPassword,
@@ -1270,6 +1890,20 @@ module.exports = {
getGameDetail,
createGame,
updateGameThumbnail,
findImageAssetByHash,
findImageAssetBySrc,
createImageAsset,
createImageOptimizationJob,
findImageOptimizationJobById,
updateImageOptimizationJobStatus,
listRecentImageOptimizationJobs,
listUnusedImageAssets,
deleteImageAssets,
listReferencedUploadSources,
listReferencedUploadUsage,
replaceUploadSourceReferences,
clearImageOptimizationJobs,
getImageAssetStats,
createGameItem,
updateGameItemLabel,
deleteGameItem,
@@ -1286,10 +1920,13 @@ module.exports = {
findTierListById,
favoriteTierList,
unfavoriteTierList,
favoriteGame,
unfavoriteGame,
deleteTierList,
findCustomItemsByIds,
deleteCustomItems,
saveTierList,
duplicateTierListForUser,
createTemplateRequest,
findTemplateRequestById,
listAdminTemplateRequests,

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,18 @@ 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'),
})
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 }),
findPrimaryAdminUser(),
])
res.json({ users: users.map((user) => decorateAdminUser(user, primaryAdmin)) })
})
router.patch('/users/:userId', requireAdmin, async (req, res) => {
@@ -471,21 +585,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 +621,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 +685,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,27 +55,8 @@ 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 = 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 upload = createMemoryUpload(multer, { fileSize: 6 * 1024 * 1024 })
const thumbnailUpload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024 })
const tierListUpsertSchema = z.object({
id: z.string().optional(),
@@ -83,6 +65,10 @@ 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),
@@ -130,6 +116,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 +160,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,12 +181,24 @@ 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']),
requestTitle: z.string().trim().min(1).max(80),
requestDescription: z.string().trim().min(1).max(240),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -196,9 +212,6 @@ router.post('/:id/template-request', requireAuth, async (req, res) => {
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' })
}
@@ -211,8 +224,8 @@ router.post('/:id/template-request', requireAuth, async (req, res) => {
sourceTierListId: tierList.id,
sourceGameId: tierList.gameId,
targetGameId: parsed.data.type === 'update' ? tierList.gameId : '',
title: tierList.title,
description: tierList.description || '',
title: parsed.data.requestTitle,
description: parsed.data.requestDescription,
thumbnailSrc: tierList.thumbnailSrc || '',
items: customItems,
})
@@ -244,6 +257,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 +275,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,14 @@
# 의사결정 이력
## 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는 우측 사이드 하나만 남기는 편이 맞다고 판단했다.

View File

@@ -2,7 +2,7 @@
## `/`
- 화면 파일: `frontend/src/views/HomeView.vue`
- 역할: 데스크톱 기본 4열 게임 카드 라이브러리 대시보드, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
- 역할: 데스크톱 기본 4열 게임 카드 라이브러리 대시보드, 상단 메인 썸네일과 `게임명 + 작은 ID` 메타를 가진 템플릿 선택 카드, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
- 연동 API: `GET /api/games`
## `/games/:gameId`
@@ -48,7 +48,7 @@
## 공통 레이아웃
- 앱 셸 파일: `frontend/src/App.vue`
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker by zenn`이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 영역에 배치된다.
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker by zenn`이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
## 백엔드 진입점
- 서버 엔트리: `backend/index.js`

View File

@@ -35,6 +35,7 @@
- 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다.
- 공통 `workspaceBody`는 별도 외곽 카드 테두리 없이 셸 여백만 제공하고, 실제 카드/패널 레이어는 각 화면 내부에서만 구성한다.
- 홈, 게임 허브, 내 티어표, 즐겨찾기, 검색 결과 화면은 같은 카드 문법(상단 16:9 썸네일, `제목+좋아요` 1행, `작성자+최종 수정일` 1행)을 공유하며, 데스크톱 기준 기본 4열 카드 그리드를 사용한다.
- 단, 홈 게임 선택 카드는 템플릿 선택용이므로 상단 메인 썸네일은 유지하되, 하단 메타는 `게임명 + 작은 ID`만 간결하게 표시한다.
- 목록 계열 화면의 상단 도구 영역은 통계 카드와 액션 버튼을 공통 높이/반경으로 맞춰, 같은 라이브러리 대시보드로 읽히도록 정리한다.
- 우측 패널
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
@@ -42,6 +43,7 @@
- 공통 토글 버튼은 패널이 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 각각 아이콘만 표시하는 방식으로 동작한다.
- 오른쪽 패널 토글은 열기/닫기 모두 `dock_to_left`, 왼쪽 패널 토글은 `dock_to_right` 아이콘으로 통일한다.
- 좌우 레일의 주요 CTA는 스크롤되는 본문과 분리된 하단 `56px` 액션 영역에 배치한다.
- 하단 액션은 화면 바닥에 바로 붙지 않도록 푸터 내부에 추가 하단 여백을 둔다.
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
- 티어표 편집 화면
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.

View File

@@ -1,38 +1,12 @@
# 할 일 및 이슈
## 즉시 확인 필요
- 피그마 기반 리디자인은 현재 공통 셸과 카드 목록 화면, 포커스 화면 안정화까지만 반영된 상태이므로, 에디터/관리자 우측 옵션 패널을 시안 구조에 맞게 실제 기능 패널로 이관하는 작업이 남아 있다.
- 홈/게임 허브/내 티어표/즐겨찾기 카드 문법은 어느 정도 통일됐지만, 아직 실제 SVG 아이콘, 미세 간격, hover/selection 상태 같은 디테일은 더 다듬을 필요가 있다.
- 목록 화면 상단 도구 막대는 공통 카드 문법으로 거의 맞췄지만, 실제 피그마처럼 필터 토글/정렬 상태를 시각적으로 더 강하게 드러내는 디테일은 남아 있다.
- 현재 공통 셸에는 임시 선형 SVG 아이콘을 사용하므로, 최종 머티리얼 아이콘 에셋을 받으면 교체하고 아이콘 크기/정렬을 다시 미세 조정할 필요가 있다.
- 공통 셸과 에디터에는 일부 실제 SVG 아이콘을 연결했지만, 아직 즐겨찾기/설정/관리자 등 나머지 내비 아이콘은 임시 선형 SVG이므로 추가 에셋 교체가 남아 있다.
- 공통 우측 패널의 토글과 독립 컬럼 구조는 반영되었지만, 현재는 안내 카드 중심이므로 실제 화면별 기능 컨트롤을 이 패널로 옮기는 작업이 남아 있다.
- 티어표 편집 화면과 관리자 화면 모두 로컬 우측 패널 구조로 옮겼지만, 아직 세부 카드 밀도와 아이콘/모션 디테일은 피그마 시안 수준으로 더 다듬을 필요가 있다.
- 에디터/관리자 로컬 우측 패널은 셸 카드에서 분리됐지만, 아직 실제 피그마처럼 패널 토글 전환 모션과 상태 강조가 더 필요하다.
- 에디터 로컬 우측 패널은 공통 토글과 연결됐지만, 아직 완전한 피그마 수준의 패널 애니메이션과 내부 카드 재배치는 더 다듬을 필요가 있다.
- 에디터 우측 패널은 셸의 세 번째 컬럼으로 옮겼지만, 내부 카드 간격과 섹션 구분선은 아직 첨부 시안처럼 더 촘촘하게 정리할 필요가 있다.
- 에디터 우측 패널 외곽 래퍼는 제거했으므로, 다음 단계는 공통 오른쪽 컬럼 안에서 입력/버튼/구분선 간격을 시안처럼 더 정교하게 다듬는 작업이다.
- 공통 56px 셸 헤더는 반영했으므로, 다음 단계는 좌/중앙/우 헤더 안에 실제 아이콘/상태 요소를 시안 순서에 맞게 하나씩 채워 넣는 작업이다.
- 좌측 레일은 최근 즐겨찾기와 전역 검색까지 붙었으므로, 다음 단계는 검색 자동완성이나 즐겨찾기 썸네일 품질 같은 디테일을 더 다듬는 작업이다.
- 좌측 레일 축소형은 반영했으므로, 다음 단계는 축소 상태에서 관리자/로그인 진입점과 hover 툴팁 같은 보조 UX를 더 다듬는 작업이다.
- 좌우 하단 액션 영역은 분리했으므로, 다음 단계는 축소된 왼쪽 레일에서도 관리자/로그인 버튼을 아이콘형으로 어떻게 유지할지 검토할 수 있다.
- 카드 목록은 4열 기준과 메타 줄 구성까지 통일했으므로, 다음 단계는 필터 상태 배지나 hover·selection 강조 같은 상호작용 디테일을 더 다듬는 작업이다.
- 검색 결과 화면은 좌측 전역 검색 입력만 쓰도록 정리됐으므로, 다음 단계는 결과 필터/정렬 여부를 검토하는 식으로 확장하면 된다.
- 공통 3단 셸 구조는 고정했지만, 관리자/에디터 우측 패널 내부에 아직 바디에 남아 있는 제어 요소를 더 옮겨야 한다.
- 홈 화면 우측 사이드는 CTA 하나만 남긴 상태이므로, 이후 필요할 때도 임시 정보 카드 다수를 다시 넣기보다 실제 필요한 기능만 선별해 추가해야 한다.
- 관리자 화면은 헤더 요약 통계와 카드 계층까지 정리됐지만, 아직 표준 SVG 아이콘 교체와 더 세밀한 상태 색상/선택 상태 표현은 남아 있다.
- 머티리얼 아이콘 SVG는 아직 임시 문자/배지 스타일로 대체된 부분이 있으므로, 최종 아이콘 에셋을 받아 반영하는 작업이 필요하다.
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
- 티어표 형식 추가 필요. 최근 게임들은 S, A, B,C 같은 랭크 뿐만 아니라 가로 열도 나누어진형태의 티어표를 원함 (공격, 방어, 지원 등 각 파트별 랭크를 보고싶어함)
- 레거시 파일 정리 스크립트는 준비됐으므로, 운영 단계에서는 cron 등으로 주기 실행할지와 삭제 전 보관 기간을 함께 정한다.
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
- 템플릿 등록/업데이트 요청은 현재 승인/반려만 지원하므로, 필요하면 관리자 코멘트나 사용자용 요청 상태 이력 화면을 추가 검토한다.
- 관리자 티어표 관리의 템플릿 생성은 현재 `freeform`만 직접 지원하므로, 필요하면 일반 게임 티어표의 전체 아이템을 복제한 파생 템플릿 생성 UX도 검토한다.
- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다.
- 즐겨찾기는 현재 `내 즐겨찾기` 목록과 정렬까지 지원하므로, 필요하면 폴더 분류나 메모 같은 개인 정리추가 검토한다.
- 전역 토스트는 중복 합치기와 페이드아웃까지 지원하므로, 필요하면 액션 링크나 수동 고정(pin) 같은 상호작용 확장을 검토한다.
- 공개 티어표 검색은 현재 게임별 허브 안에서만 제공하므로, 필요하면 홈 전역 통합 검색도 검토한다.
- 즐겨찾기 토글은 현재 상세 화면 중심이므로, 필요하면 카드 목록에서도 안전한 보조 인터랙션(예: 길게 누르기, 별도 메뉴)을 검토한다.
- 이미지 최적화 기록은 월별 조회/비우기까지 지원하므로, 운영 단계에서는 보관 기간 정책과 자동 아카이브한다.
## 배포 전 작업
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
@@ -42,8 +16,6 @@
- 로컬 docker compose와 NAS MariaDB 사이의 버전 차이가 크지 않도록 유지한다.
## 중기 개선
- 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다.
- 자동 테스트와 최소한의 배포 체크리스트를 만든다.
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다.
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.

View File

@@ -1,5 +1,279 @@
# 업데이트 로그
## 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만 유지하도록 정리
@@ -261,6 +535,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`) 라우트 추가
@@ -273,98 +642,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="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="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="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="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,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,11 +62,30 @@ 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' } = {}) =>
request(`/api/admin/users?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}`),
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) =>
@@ -70,6 +100,7 @@ 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' }),
duplicateTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/duplicate`, { method: 'POST' }),
requestTierListTemplate: (id, payload) => request(`/api/tierlists/${encodeURIComponent(id)}/template-request`, { method: 'POST', body: payload }),
saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }),
uploadTierListThumbnail: async (file) => {

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

@@ -54,14 +54,14 @@ onMounted(loadFavorites)
</script>
<template>
<section class="wrap">
<div class="head">
<div>
<div class="head__eyebrow">Collection</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>
@@ -101,34 +101,6 @@ onMounted(loadFavorites)
</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);
}
.toolbar {
display: flex;
gap: 10px;
@@ -210,15 +182,19 @@ onMounted(loadFavorites)
.boardCard__head {
padding: 16px 18px 18px;
display: grid;
gap: 10px;
gap: 6px;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 12px;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.boardCard__metaRow {
align-items: flex-end;
}
.boardCard__title {
min-width: 0;
font-weight: 800;
@@ -244,7 +220,7 @@ onMounted(loadFavorites)
.boardCard__avatar {
width: 22px;
height: 22px;
border-radius: 6px;
border-radius: 9999px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
flex: 0 0 auto;
@@ -262,6 +238,10 @@ onMounted(loadFavorites)
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
}
.boardCard__date {
font-size: 10px;
}
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));

View File

@@ -15,6 +15,8 @@ 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).toLocaleDateString(undefined, {
@@ -37,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()
})
@@ -51,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 = '게임 정보를 불러오지 못했어요.'
@@ -96,11 +105,11 @@ function submitSearch() {
</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="boardCard">
<button class="boardCard__body" @click="openTierList(t.id)">
<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="t.title" />
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" alt="" @error="handleThumbnailError(t.id)" />
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="boardCard__head">
@@ -213,10 +222,16 @@ 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;
}
.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);
@@ -233,6 +248,7 @@ function submitSearch() {
transform: translateY(-2px);
}
.boardCard__body {
min-width: 0;
text-align: left;
padding: 0;
border: 0;
@@ -242,12 +258,24 @@ function submitSearch() {
width: 100%;
display: grid;
}
.boardCard__body--list {
grid-template-columns: 76px minmax(0, 1fr);
align-items: center;
}
.boardCard__thumbWrap {
min-width: 0;
width: 100%;
aspect-ratio: 16 / 9;
padding: 14px 14px 0;
box-sizing: border-box;
}
.boardCard--list .boardCard__thumbWrap {
aspect-ratio: auto;
height: 100%;
padding: 14px 0 14px 14px;
}
.boardCard__thumb {
width: 100%;
height: 100%;
@@ -255,6 +283,14 @@ function submitSearch() {
display: block;
border-radius: 18px;
}
.boardCard--list .boardCard__thumb,
.boardCard--list .boardCard__thumbPlaceholder {
width: 48px;
height: 48px;
min-height: 48px;
border-radius: 12px;
}
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
@@ -271,29 +307,49 @@ function submitSearch() {
min-width: 0;
font-size: 18px;
line-height: 1.35;
white-space: nowrap;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
.boardCard__head {
min-width: 0;
padding: 16px 18px 18px;
display: grid;
gap: 10px;
gap: 8px;
}
.boardCard--list .boardCard__head {
height: 100%;
padding: 14px 16px 14px 0;
align-content: center;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
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: 8px;
gap: 7px;
align-items: center;
font-size: 13px;
opacity: 0.86;
overflow: hidden;
}
.boardCard__authorName {
min-width: 0;
@@ -304,7 +360,7 @@ function submitSearch() {
.boardCard__avatar {
width: 22px;
height: 22px;
border-radius: 6px;
border-radius: 9999px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
@@ -319,24 +375,37 @@ function submitSearch() {
.boardCard__date,
.favoriteStat {
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: 1400px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1024px) {
.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,106 +1,116 @@
<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="dashboardHero">
<div class="dashboardHero__copy">
<div class="dashboardHero__eyebrow">Workspace</div>
<h1 class="dashboardHero__title">Game Library</h1>
<p class="dashboardHero__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 있어요.</p>
<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="libraryGrid">
<button v-for="g in games" :key="g.id" class="libraryCard" @click="goGame(g.id)">
<div class="libraryCard__thumbWrap">
<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="libraryCard__body">
<div class="libraryCard__title">{{ g.name }}</div>
<div class="libraryCard__meta">
<span class="libraryCard__metaDot"></span>
<span>{{ g.id }}</span>
<div class="libraryCard__body">
<div class="libraryCard__title">{{ g.name }}</div>
<div class="libraryCard__meta">{{ g.id }}</div>
</div>
</div>
</button>
</section>
</button>
</article>
</TransitionGroup>
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 게임 템플릿이 없어요.' : '표시할 게임 템플릿이 없어요.' }}</div>
</template>
<style scoped>
.dashboardHero {
display: flex;
gap: 18px;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
margin-top: 2px;
margin-bottom: 18px;
padding: 6px 2px 18px;
}
.dashboardHero__copy {
display: grid;
gap: 8px;
max-width: 720px;
}
.dashboardHero__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
}
.dashboardHero__title {
margin: 0;
font-size: 34px;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
}
.dashboardHero__desc {
margin: 0;
color: rgba(255, 255, 255, 0.58);
line-height: 1.5;
max-width: 720px;
}
.libraryGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -114,7 +124,12 @@ function thumbUrl(g) {
background: rgba(239, 68, 68, 0.1);
color: rgba(255, 255, 255, 0.92);
}
.pageHead__searchState {
margin-top: 8px;
color: rgba(255, 255, 255, 0.62);
}
.libraryCard {
position: relative;
text-align: left;
padding: 14px;
border-radius: 22px;
@@ -125,14 +140,53 @@ function thumbUrl(g) {
display: grid;
gap: 12px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition:
transform 0.16s ease,
background 0.16s ease;
transition: transform 0.16s ease, background 0.16s ease;
will-change: transform, opacity;
}
.libraryCard:hover {
background: rgba(70, 70, 70, 0.96);
transform: translateY(-2px);
}
.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;
@@ -149,32 +203,45 @@ function thumbUrl(g) {
object-fit: cover;
}
.libraryCard__thumbFallback {
font-size: 13px;
font-weight: 700;
font-size: 14px;
color: rgba(255, 255, 255, 0.4);
}
.libraryCard__body {
display: grid;
gap: 8px;
}
.libraryCard__title {
font-weight: 800;
letter-spacing: -0.02em;
font-size: 16px;
font-size: 18px;
}
.libraryCard__meta {
display: inline-flex;
gap: 8px;
align-items: center;
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.libraryCard__metaDot {
width: 10px;
height: 10px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.9);
.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 {
@@ -192,17 +259,6 @@ function thumbUrl(g) {
}
}
@media (max-width: 720px) {
.dashboardHero {
align-items: stretch;
}
.dashboardToolbar {
width: 100%;
}
.dashboardToolbar__ghost,
.dashboardToolbar__stat,
.customTierBtn {
flex: 1 1 100%;
}
.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,184 @@ 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" />
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다.</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"
/>
<span class="field__hint">8 이상으로 설정하면 안전하게 사용할 있어요.</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"
/>
<span class="field__hint">같은 비밀번호를 입력해주세요.</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

@@ -54,31 +54,15 @@ 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">
<header class="head">
<div>
<div class="head__eyebrow">Library</div>
<h2 class="title"> 티어표</h2>
<div class="desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 있어요.</div>
</div>
<div class="head__stat">
<span class="head__statLabel">Saved Lists</span>
<strong class="head__statValue">{{ myLists.length }}</strong>
<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">
@@ -105,7 +89,6 @@ async function removeList(t) {
</div>
</div>
</button>
<button class="link link--danger" @click="removeList(t)">삭제</button>
</article>
</div>
</div>
@@ -113,77 +96,24 @@ async function removeList(t) {
</template>
<style scoped>
.wrap {
padding: 4px 2px;
}
.head {
display: flex;
gap: 16px;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
margin-bottom: 18px;
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 6px;
font-size: 32px;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
}
.desc {
color: rgba(255, 255, 255, 0.58);
}
.head__stat {
display: grid;
gap: 2px;
min-width: 112px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.045);
}
.head__statLabel {
font-size: 11px;
color: rgba(255, 255, 255, 0.48);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.head__statValue {
font-size: 18px;
font-weight: 900;
}
.card {
border: 0;
background: transparent;
border-radius: 0;
padding: 0;
}
.link {
padding: 10px 12px;
border-radius: 14px;
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;
}
.boardCard {
display: grid;
min-width: 0;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
@@ -234,29 +164,43 @@ async function removeList(t) {
border-radius: 18px;
}
.boardCard__title {
font-weight: 900;
flex: 1 1 auto;
min-width: 0;
font-weight: 900;
font-size: 18px;
white-space: nowrap;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__head {
padding: 16px 18px 18px;
display: grid;
gap: 10px;
gap: 8px;
min-width: 0;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 12px;
gap: 10px;
min-width: 0;
align-items: center;
justify-content: space-between;
}
.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;
@@ -270,7 +214,7 @@ async function removeList(t) {
.boardCard__avatar {
width: 22px;
height: 22px;
border-radius: 6px;
border-radius: 9999px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
@@ -289,25 +233,10 @@ async function removeList(t) {
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 18px 18px;
}
@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));
}
.boardCard__date {
font-size: 10px;
}
@media (max-width: 720px) {
.head__stat {
width: 100%;
}
.list {
grid-template-columns: 1fr;
}

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 = '프로필 저장에 실패했어요.'
@@ -79,145 +112,280 @@ async function logout() {
</script>
<template>
<section class="wrap">
<h2 class="title">프로필</h2>
<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>
</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>
<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="upload">
<label class="label">아바타 업로드</label>
<input class="file" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
<div class="hint">파일 선택은 미리보기만 바뀌고, 실제 반영은 저장 버튼을 눌렀을 진행됩니다.</div>
<div class="actions">
<button class="saveBtn" :disabled="saving" @click="saveProfile">
{{ saving ? '저장중...' : '프로필 저장' }}
<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>
<button class="logoutBtn" type="button" @click="logout">로그아웃</button>
</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>
<div class="settingsFields">
<label class="field">
<span class="field__label">닉네임</span>
<input v-model="nickname" class="field__input" placeholder="작성자 닉네임" />
<span class="field__hint">티어표 작성자 이름으로 표시됩니다.</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 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);
}
.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 {
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);
cursor: pointer;
font-weight: 800;
}
.actions {
margin-top: 12px;
.settingsActions {
display: flex;
gap: 10px;
gap: 12px;
flex-wrap: wrap;
padding-top: 8px;
}
.logoutBtn {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.24);
background: rgba(239, 68, 68, 0.12);
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

@@ -198,15 +198,19 @@ watch(
.boardCard__head {
padding: 16px 18px 18px;
display: grid;
gap: 10px;
gap: 6px;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 12px;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.boardCard__metaRow {
align-items: flex-end;
}
.boardCard__title {
min-width: 0;
font-weight: 800;
@@ -232,7 +236,7 @@ watch(
.boardCard__avatar {
width: 22px;
height: 22px;
border-radius: 6px;
border-radius: 9999px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
flex: 0 0 auto;
@@ -250,6 +254,13 @@ watch(
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));

View File

@@ -24,7 +24,6 @@ const groups = ref([
{ id: 'gA', name: 'A', itemIds: [] },
{ id: 'gB', name: 'B', itemIds: [] },
{ id: 'gC', name: 'C', itemIds: [] },
{ id: 'gD', name: 'D', itemIds: [] },
])
const pool = ref([])
@@ -36,21 +35,33 @@ const pendingThumbnailFile = ref(null)
const thumbnailPreviewUrl = ref('')
const description = ref('')
const isPublic = ref(true)
const showCharacterNames = ref(false)
const error = ref('')
const isSaving = ref(false)
const isExporting = ref(false)
const isSaveModalOpen = ref(false)
const isTemplateRequestModalOpen = ref(false)
const isTemplateUpdateModalOpen = ref(false)
const templateRequestDraftTitle = ref('')
const templateRequestDraftDescription = ref('')
const isDeleteModalOpen = ref(false)
const isGroupDeleteModalOpen = ref(false)
const pendingRemoveGroupId = ref('')
const ownerId = ref('')
const authorName = ref('')
const authorAccountName = ref('')
const updatedAt = ref(0)
const sourceTierListId = ref('')
const sourceSnapshotTitle = ref('')
const sourceSnapshotAuthor = ref('')
const isDragActive = ref(false)
const isThumbnailDragActive = ref(false)
const iconSize = ref(80)
const isFavoriteBusy = ref(false)
const favoriteCount = ref(0)
const isFavorited = ref(false)
const isRequestingTemplate = ref(false)
const isDeleting = ref(false)
const boardEl = ref(null)
const exportBoardEl = ref(null)
@@ -89,6 +100,14 @@ const untitledWarning = computed(
'제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.'
)
const canFavorite = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
const canDuplicate = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
const copiedFromLabel = computed(() => {
if (!sourceTierListId.value) return ''
const parts = []
if (sourceSnapshotTitle.value) parts.push(`원본 ${sourceSnapshotTitle.value}`)
if (sourceSnapshotAuthor.value) parts.push(sourceSnapshotAuthor.value)
return parts.join(' · ') || '복사해 온 티어표'
})
const customItems = computed(() =>
Object.values(itemsById.value)
.filter((item) => item?.origin === 'custom')
@@ -107,7 +126,9 @@ const templateRequestChecks = computed(() => [
passed: !!(title.value || '').trim() && (title.value || '').trim() !== (gameName.value || '').trim(),
},
])
const canSubmitTemplateCreateRequest = computed(() => templateRequestChecks.value.every((item) => item.passed))
const canSubmitTemplateCreateRequest = computed(() => templateRequestChecks.value.every((item) => item.passed) && !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const templateRequestTargetLabel = computed(() => (gameId.value === 'freeform' ? '새로운 템플릿' : (gameName.value || gameId.value || '선택한 게임')))
watch(error, (message) => {
if (!message) return
@@ -261,7 +282,7 @@ async function addGroup() {
await syncSortables()
}
async function removeGroup(groupId) {
async function performRemoveGroup(groupId) {
if (groups.value.length <= 1) return
const target = groups.value.find((group) => group.id === groupId)
if (!target) return
@@ -271,6 +292,24 @@ async function removeGroup(groupId) {
await syncSortables()
}
function openGroupDeleteModal(groupId) {
if (!canEdit.value || groups.value.length <= 1 || !groupId) return
pendingRemoveGroupId.value = groupId
isGroupDeleteModalOpen.value = true
}
function closeGroupDeleteModal() {
isGroupDeleteModalOpen.value = false
pendingRemoveGroupId.value = ''
}
async function confirmRemoveGroup() {
const groupId = pendingRemoveGroupId.value
closeGroupDeleteModal()
if (!groupId) return
await performRemoveGroup(groupId)
}
function addCustomImage(file) {
if (!file || !file.type.startsWith('image/')) return
const url = URL.createObjectURL(file)
@@ -304,14 +343,48 @@ function openThumbnailFile() {
thumbnailFileEl.value?.click()
}
function onThumbnailChange(event) {
const file = event.target.files?.[0]
function applyThumbnailFile(file) {
if (!file || !file.type.startsWith('image/')) return
if (thumbnailPreviewUrl.value) {
URL.revokeObjectURL(thumbnailPreviewUrl.value)
thumbnailPreviewUrl.value = ''
}
pendingThumbnailFile.value = file || null
if (file) thumbnailPreviewUrl.value = URL.createObjectURL(file)
pendingThumbnailFile.value = file
thumbnailPreviewUrl.value = URL.createObjectURL(file)
}
function onThumbnailDragEnter() {
if (!canEdit.value) return
isThumbnailDragActive.value = true
}
function onThumbnailDragLeave(event) {
if (!event.currentTarget.contains(event.relatedTarget)) {
isThumbnailDragActive.value = false
}
}
function onThumbnailDrop(event) {
if (!canEdit.value) return
isThumbnailDragActive.value = false
const files = Array.from(event.dataTransfer?.files || []).filter((file) => file.type.startsWith('image/'))
if (!files.length) return
if (files.length > 1) {
toast.info('대표 썸네일은 하나만 설정할 수 있어요. 첫 번째 이미지를 사용할게요.')
}
applyThumbnailFile(files[0])
}
function onThumbnailChange(event) {
const files = Array.from(event.target.files || []).filter((file) => file.type.startsWith('image/'))
if (!files.length) {
event.target.value = ''
return
}
if (files.length > 1) {
toast.info('대표 썸네일은 하나만 설정할 수 있어요. 첫 번째 이미지를 사용할게요.')
}
applyThumbnailFile(files[0])
event.target.value = ''
}
@@ -430,6 +503,10 @@ function buildPayload(existingId) {
thumbnailSrc: thumbnailSrc.value || '',
description: (description.value || '').trim(),
isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value,
sourceTierListId: sourceTierListId.value || '',
sourceSnapshotTitle: sourceSnapshotTitle.value || '',
sourceSnapshotAuthor: sourceSnapshotAuthor.value || '',
groups: groups.value.map((g) => ({ id: g.id, name: g.name, itemIds: g.itemIds })),
pool: Object.values(itemsById.value),
}
@@ -469,25 +546,65 @@ function closeSaveModal() {
isSaveModalOpen.value = false
}
function resetTemplateRequestDrafts() {
templateRequestDraftTitle.value = ''
templateRequestDraftDescription.value = ''
}
function openTemplateRequestModal() {
resetTemplateRequestDrafts()
isTemplateRequestModalOpen.value = true
}
function closeTemplateRequestModal() {
isTemplateRequestModalOpen.value = false
resetTemplateRequestDrafts()
}
async function removeTierList() {
if (!canEdit.value || isNewTierList.value) return
function openTemplateUpdateModal() {
resetTemplateRequestDrafts()
isTemplateUpdateModalOpen.value = true
}
function closeTemplateUpdateModal() {
isTemplateUpdateModalOpen.value = false
resetTemplateRequestDrafts()
}
function openDeleteModal() {
isDeleteModalOpen.value = true
}
function closeDeleteModal() {
isDeleteModalOpen.value = false
}
async function confirmDeleteTierList() {
if (!canEdit.value || isNewTierList.value || isDeleting.value) return
error.value = ''
try {
const ok = window.confirm(`"${title.value || gameName.value || '이 티어표'}"를 삭제할까요?`)
if (!ok) return
isDeleting.value = true
await api.deleteTierList(tierListId.value)
closeDeleteModal()
toast.success('티어표를 삭제했어요.')
router.push(gameId.value === 'freeform' ? '/me' : `/games/${gameId.value}`)
} catch (e) {
error.value = '티어표 삭제에 실패했어요.'
} finally {
isDeleting.value = false
}
}
async function duplicateCurrentTierList() {
if (!canDuplicate.value) return
try {
const data = await api.duplicateTierList(tierListId.value)
const duplicatedId = data.tierList?.id
if (!duplicatedId) throw new Error('duplicate_failed')
toast.success('티어표를 복사해 내 작업으로 가져왔어요.')
router.push(`/editor/${gameId.value}/${duplicatedId}`)
} catch (e) {
error.value = '티어표 복사에 실패했어요.'
}
}
@@ -515,8 +632,13 @@ async function requestTemplate(type) {
try {
isRequestingTemplate.value = true
const persisted = await persistTierList({ showModal: false })
await api.requestTierListTemplate(persisted.savedTierListId, { type })
await api.requestTierListTemplate(persisted.savedTierListId, {
type,
requestTitle: templateRequestDraftTitle.value.trim(),
requestDescription: templateRequestDraftDescription.value.trim(),
})
if (type === 'create') closeTemplateRequestModal()
if (type === 'update') closeTemplateUpdateModal()
toast.success(type === 'create' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
} catch (e) {
if (e?.status === 400 && e?.data?.error === 'title_required') {
@@ -574,9 +696,13 @@ onMounted(() => {
thumbnailSrc.value = t.thumbnailSrc || ''
description.value = t.description || ''
isPublic.value = !!t.isPublic
showCharacterNames.value = !!t.showCharacterNames
authorName.value = t.authorName || ''
authorAccountName.value = t.authorAccountName || ''
updatedAt.value = Number(t.updatedAt || 0)
sourceTierListId.value = t.sourceTierListId || ''
sourceSnapshotTitle.value = t.sourceSnapshotTitle || ''
sourceSnapshotAuthor.value = t.sourceSnapshotAuthor || ''
favoriteCount.value = Number(t.favoriteCount || 0)
isFavorited.value = !!t.isFavorited
groups.value = t.groups
@@ -615,6 +741,7 @@ onUnmounted(() => {
<div class="previewOnly__drop">
<div v-for="id in g.itemIds" :key="id" class="previewOnly__cell">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
</div>
</div>
</div>
@@ -622,7 +749,7 @@ onUnmounted(() => {
<div v-if="pool.length" class="previewOnly__pool">
<div class="previewOnly__poolTitle">남은 아이템</div>
<div class="previewOnly__poolGrid">
<div v-for="id in pool" :key="id" class="previewOnly__poolItem">
<div v-for="id in pool" :key="id" class="previewOnly__poolItem previewOnly__poolItem--inactive">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
</div>
</div>
@@ -660,7 +787,18 @@ onUnmounted(() => {
</div>
</div>
<div class="requestChecklist__hint">
제목 명확하게 적어두면 관리자가 어떤 게임 템플릿 요청인지 빠르게 파악할 있어요. 여러 사용자가 비슷한 주제로 요청할 있으니 게임 이름을 구체적으로 적어주세요.
제목 설명을 함께 적어두면 관리자가 어떤 신규 템플릿인지 훨씬 빠르게 파악할 있어요.
예시: 제목 `템플릿 등록 요청`, 설명 `여름 이벤트 한정 캐릭터 중심으로 새 게임 템플릿이 필요합니다.`
</div>
<div class="templateRequestDraft">
<label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 제목</span>
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 등록 요청" />
</label>
<label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 설명</span>
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." />
</label>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTemplateRequestModal">취소</button>
@@ -671,6 +809,63 @@ onUnmounted(() => {
</div>
</div>
<div v-if="isTemplateUpdateModalOpen" class="modalOverlay" @click.self="closeTemplateUpdateModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="templateUpdateTitle">
<div id="templateUpdateTitle" class="modalCard__title">템플릿 요청하기</div>
<div class="modalCard__desc">
{{ templateRequestTargetLabel }} 직접 추가한 아이템을 포함해 달라고 관리자에게 요청을 보냅니다.
</div>
<div class="modalCard__note">
모두가 사용하는 기본 템플릿이니 개인적인 항목이 아닌 공통된 항목만 추가한 신청해주세요.
예시: 제목 `템플릿 업데이트 요청`, 설명 `여름 이벤트 한정 캐릭터 추가`
</div>
<div class="templateRequestDraft">
<label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 제목</span>
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 업데이트 요청" />
</label>
<label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 설명</span>
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가" />
</label>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTemplateUpdateModal">요청 취소</button>
<button class="btn btn--save" :disabled="!canSubmitTemplateUpdateRequest || isRequestingTemplate" @click="requestTemplate('update')">
{{ isRequestingTemplate ? '요청중...' : ', 요청할게요' }}
</button>
</div>
</div>
</div>
<div v-if="isDeleteModalOpen" class="modalOverlay" @click.self="closeDeleteModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteTierListTitle">
<div id="deleteTierListTitle" class="modalCard__title">티어표 삭제</div>
<div class="modalCard__desc">
"{{ title || gameName || '이 티어표' }}" 삭제할까요? 삭제 후에는 복구할 없어요.
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeDeleteModal">취소</button>
<button class="btn btn--danger" :disabled="isDeleting" @click="confirmDeleteTierList">
{{ isDeleting ? '삭제중...' : '삭제하기' }}
</button>
</div>
</div>
</div>
<div v-if="isGroupDeleteModalOpen" class="modalOverlay" @click.self="closeGroupDeleteModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteGroupTitle">
<div id="deleteGroupTitle" class="modalCard__title">티어 라인 삭제</div>
<div class="modalCard__desc">
라인을 삭제하면 현재 들어 있는 아이템은 모두 아래 아이템 영역으로 이동합니다. 삭제 후에도 아이템 자체는 유지돼요.
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeGroupDeleteModal">취소</button>
<button class="btn btn--danger" @click="confirmRemoveGroup">라인 삭제</button>
</div>
</div>
</div>
<section class="layout" :style="{ '--thumb-size': `${iconSize}px` }">
<div class="editorMain">
<section class="head">
@@ -684,6 +879,10 @@ onUnmounted(() => {
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 있어요.
</template>
</div>
<div v-if="sourceTierListId" class="editorMain__sourceNote">
<span>복사본</span>
<button class="editorMain__sourceLink" type="button" @click="router.push(`/editor/${gameId}/${sourceTierListId}`)">{{ copiedFromLabel }}</button>
</div>
</div>
</section>
@@ -718,9 +917,18 @@ onUnmounted(() => {
<div class="row__exportName">{{ g.name }}</div>
</template>
<template v-else>
<button
v-if="canEdit"
class="rowRemoveText"
type="button"
title="티어 라인 삭제"
:disabled="groups.length <= 1"
@click="openGroupDeleteModal(g.id)"
>
삭제
</button>
<span class="grab" title="드래그로 순서 변경" data-group-handle></span>
<input v-model="g.name" class="groupName" :readonly="!canEdit" />
<button v-if="canEdit" class="rowRemoveBtn" :disabled="groups.length <= 1" @click="removeGroup(g.id)">삭제</button>
</template>
</div>
<div
@@ -732,6 +940,7 @@ onUnmounted(() => {
<div v-if="!isExporting" class="row__empty" v-show="g.itemIds.length === 0">여기로 드래그해서 배치</div>
<div v-for="id in g.itemIds" :key="id" class="cell" :data-item-id="id">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
<button
v-if="canEdit && !isExporting"
class="cellRemoveBtn"
@@ -751,35 +960,42 @@ onUnmounted(() => {
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
</div>
</div>
</div>
<div class="sidebar">
<div class="sidebar__title">아이템</div>
<div class="sidebar__hint">
{{ canEdit ? '보드 바로 옆에서 드래그해 넣을 수 있도록 아이템 풀을 고정합니다.' : '공개 티어표는 보기 전용입니다.' }}
</div>
<div ref="poolEl" class="pool" data-list-type="pool">
<div v-for="id in pool" :key="id" class="poolItem" :data-item-id="id">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
</div>
</div>
<div
v-if="canEdit"
class="dropzone"
class="dropzone dropzone--board"
:class="{ 'dropzone--active': isDragActive }"
@dragenter.prevent="onDragEnter"
@dragover.prevent="onDragEnter"
@dragleave="onDragLeave"
@drop.prevent="onDropFiles"
>
<div class="dropzone__title">커스텀 이미지 추가</div>
<div class="dropzone__desc">여러 이미지 번에 드래그하거나 파일 선택으로 추가할 있어요.</div>
<div>
<div class="dropzone__title">커스텀 이미지 추가</div>
<div class="dropzone__desc">이곳으로 이미지를 드래그하거나 파일 선택으로 번에 추가할 있어요.</div>
</div>
<div class="dropzone__actions">
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
<button class="btn btn--ghost dropzone__button" @click="openFile">파일 선택</button>
</div>
</div>
</div>
<div class="sidebar">
<div class="sidebar__title">아이템</div>
<div class="sidebar__hint">
{{ canEdit ? '등록된 아이템 리스트입니다. 드래그해서 표에 넣을 수 있습니다.' : '공개 티어표는 보기 전용입니다.' }}
</div>
<div ref="poolEl" class="pool" data-list-type="pool">
<div v-for="id in pool" :key="id" class="poolItem" :class="{ 'poolItem--readonly': !canEdit }" :data-item-id="id">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
<div v-if="!canEdit" class="poolItem__state">미배치</div>
</div>
</div>
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
<button v-if="canEdit" class="btn btn--ghost" @click="openFile">파일 선택</button>
</div>
</div>
</div>
</section>
@@ -805,16 +1021,24 @@ onUnmounted(() => {
<div class="editorSidebar__section">
<div class="editorSidebar__label">대표 썸네일</div>
<input ref="thumbnailFileEl" type="file" accept="image/*" class="hidden" @change="onThumbnailChange" />
<div class="editorSidebar__thumbFrame">
<div
class="editorSidebar__thumbFrame"
:class="{ 'editorSidebar__thumbFrame--active': isThumbnailDragActive }"
@dragenter.prevent="onThumbnailDragEnter"
@dragover.prevent="onThumbnailDragEnter"
@dragleave="onThumbnailDragLeave"
@drop.prevent="onThumbnailDrop"
>
<img v-if="displayThumbnailUrl" class="editorSidebar__thumbImage" :src="displayThumbnailUrl" alt="썸네일 미리보기" />
<div v-else class="editorSidebar__thumbEmpty">대표 썸네일</div>
<div class="editorSidebar__thumbOverlay">드래그 또는 클릭으로 썸네일 추가</div>
</div>
<button v-if="canEdit" class="btn btn--ghost editorSidebar__button" @click="openThumbnailFile">파일 업로드</button>
<div v-if="pendingThumbnailFile" class="editorSidebar__fileName">{{ pendingThumbnailFile.name }}</div>
</div>
<div class="editorSidebar__section">
<button v-if="canFavorite" class="editorSidebar__favorite" :disabled="isFavoriteBusy" @click="toggleFavorite">
<div v-if="canFavorite" class="editorSidebar__section">
<button class="editorSidebar__favorite" :disabled="isFavoriteBusy" @click="toggleFavorite">
<span> 즐겨찾기</span>
<span>{{ favoriteCount }}</span>
</button>
@@ -842,31 +1066,40 @@ onUnmounted(() => {
</div>
<div class="editorSidebar__section editorSidebar__section--footer">
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': !canEdit }">
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
<span>공개</span>
<span class="toggleSwitch__label">공개</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': !canEdit }">
<input v-model="showCharacterNames" type="checkbox" :disabled="!canEdit" />
<span class="toggleSwitch__label">캐릭터 이름 표시</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<div class="editorSidebar__actionGrid">
<button class="btn btn--ghost editorSidebar__button" @click="downloadImage">이미지 다운로드</button>
<button v-if="canEdit" class="btn btn--save editorSidebar__button" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
</div>
<button v-if="canEdit && !isNewTierList" class="btn btn--danger editorSidebar__button" @click="removeTierList">삭제</button>
<button
v-if="canRequestTemplateCreate"
class="btn btn--ghost editorSidebar__button"
:disabled="isRequestingTemplate"
@click="openTemplateRequestModal"
>
템플릿 등록 요청
</button>
<button
v-if="canRequestTemplateUpdate"
class="btn btn--ghost editorSidebar__button"
:disabled="isRequestingTemplate"
@click="requestTemplate('update')"
>
{{ isRequestingTemplate ? '요청중...' : '템플릿 업데이트 요청' }}
</button>
<div class="editorSidebar__utilityLinks">
<button v-if="canEdit && !isNewTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--danger" @click="openDeleteModal">삭제하기</button>
<button v-if="canDuplicate" class="editorSidebar__utilityLink" @click="duplicateCurrentTierList">복사해서 티어표로 가져오기</button>
<button
v-if="canRequestTemplateCreate"
class="editorSidebar__utilityLink"
:disabled="isRequestingTemplate"
@click="openTemplateRequestModal"
>
템플릿 등록 요청
</button>
<button
v-if="canRequestTemplateUpdate"
class="editorSidebar__utilityLink"
:disabled="isRequestingTemplate"
@click="openTemplateUpdateModal"
>
{{ isRequestingTemplate ? '요청중...' : '템플릿 업데이트 요청' }}
</button>
</div>
</div>
</template>
</Teleport>
@@ -886,7 +1119,7 @@ onUnmounted(() => {
}
.editorCanvas {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
grid-template-columns: minmax(0, clamp(680px, 58vw, 960px)) minmax(280px, 1fr);
gap: 16px;
align-items: start;
}
@@ -900,6 +1133,23 @@ onUnmounted(() => {
font-size: 13px;
line-height: 1.5;
}
.editorMain__sourceNote {
margin-top: 4px;
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 12px;
color: rgba(255, 255, 255, 0.62);
}
.editorMain__sourceLink {
border: 0;
padding: 0;
background: transparent;
color: rgba(191, 219, 254, 0.94);
font: inherit;
cursor: pointer;
}
.previewOnly {
min-height: 100vh;
padding: 20px;
@@ -938,7 +1188,7 @@ onUnmounted(() => {
.previewOnly__label {
display: grid;
place-items: center;
padding: 10px 8px;
padding: 10px 12px;
text-align: center;
font-weight: 900;
border-radius: 14px;
@@ -958,6 +1208,7 @@ onUnmounted(() => {
}
.previewOnly__cell {
display: inline-flex;
position: relative;
}
.previewOnly__pool {
display: grid;
@@ -975,24 +1226,62 @@ onUnmounted(() => {
}
.previewOnly__poolItem {
display: inline-flex;
position: relative;
}
.toggle {
.previewOnly__poolItem--inactive {
opacity: 0.52;
filter: grayscale(0.22) brightness(0.78);
}
.toggleSwitch {
display: inline-flex;
gap: 8px;
align-items: center;
padding: 8px 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.12);
font-weight: 800;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
cursor: pointer;
user-select: none;
}
.toggle input {
width: 16px;
height: 16px;
.toggleSwitch input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.toggle--disabled {
.toggleSwitch__track {
position: relative;
width: 42px;
height: 24px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.12);
transition: background 180ms ease, border-color 180ms ease;
flex: 0 0 auto;
}
.toggleSwitch__thumb {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.24);
transition: transform 180ms ease;
}
.toggleSwitch__label {
font-weight: 800;
color: rgba(255, 255, 255, 0.9);
}
.toggleSwitch input:checked ~ .toggleSwitch__track {
background: rgba(96, 165, 250, 0.34);
border-color: rgba(96, 165, 250, 0.42);
}
.toggleSwitch input:checked ~ .toggleSwitch__track .toggleSwitch__thumb {
transform: translateX(18px);
}
.toggleSwitch--disabled {
opacity: 0.55;
pointer-events: none;
}
@@ -1051,6 +1340,7 @@ onUnmounted(() => {
background: rgba(239, 68, 68, 0.12);
}
.board {
width: min(100%, 960px);
border: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(180deg, rgba(55, 55, 55, 0.86), rgba(42, 42, 42, 0.82));
border-radius: 22px;
@@ -1092,6 +1382,7 @@ onUnmounted(() => {
justify-content: flex-end;
margin-top: 8px;
flex-wrap: wrap;
gap: 8px;
}
.modalCard__actions .btn {
width: auto;
@@ -1133,6 +1424,42 @@ onUnmounted(() => {
font-size: 13px;
line-height: 1.6;
opacity: 0.78;
white-space: pre-line;
}
.templateRequestDraft {
display: grid;
gap: 12px;
}
.templateRequestDraft__field {
display: grid;
gap: 6px;
}
.templateRequestDraft__label {
font-size: 12px;
color: rgba(255, 255, 255, 0.64);
}
.templateRequestDraft__input {
width: 100%;
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;
font-size: 18px;
line-height: 1.5;
letter-spacing: -0.02em;
resize: none;
}
.templateRequestDraft__input:focus {
border-bottom-color: rgba(96, 165, 250, 0.9);
}
.templateRequestDraft__input::placeholder {
color: rgba(255, 255, 255, 0.34);
}
.templateRequestDraft__textarea {
min-height: 92px;
resize: vertical;
}
.boardTools {
display: flex;
@@ -1227,6 +1554,7 @@ onUnmounted(() => {
align-items: stretch;
}
.row__label {
position: relative;
border-radius: 16px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
@@ -1234,7 +1562,7 @@ onUnmounted(() => {
gap: 8px;
align-items: center;
justify-content: center;
padding: 10px 8px;
padding: 10px 12px 30px;
font-weight: 900;
overflow: hidden;
}
@@ -1262,26 +1590,32 @@ onUnmounted(() => {
outline: none;
min-width: 0;
}
.rowRemoveText {
position: absolute;
right: 12px;
bottom: 10px;
padding: 0;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
font-size: 12px;
line-height: 1;
font-weight: 800;
}
.rowRemoveText:hover {
color: rgba(255, 255, 255, 0.9);
}
.rowRemoveText:disabled {
opacity: 0.32;
cursor: not-allowed;
}
.row__exportName {
width: 100%;
text-align: center;
font-weight: 900;
word-break: break-word;
}
.rowRemoveBtn {
padding: 6px 10px;
border-radius: 10px;
border: 1px solid rgba(239, 68, 68, 0.28);
background: rgba(239, 68, 68, 0.12);
color: rgba(255, 255, 255, 0.92);
cursor: pointer;
font-weight: 800;
flex: 0 0 auto;
}
.rowRemoveBtn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.row__drop {
border-radius: 16px;
background: rgba(0, 0, 0, 0.18);
@@ -1309,6 +1643,22 @@ onUnmounted(() => {
flex: 0 0 auto;
position: relative;
}
.itemNameOverlay {
position: absolute;
inset: auto 0 0 0;
padding: 16px 8px 6px;
border-radius: 0 0 10px 10px;
background: linear-gradient(180deg, rgba(7, 10, 18, 0), rgba(7, 10, 18, 0.92));
color: rgba(255, 255, 255, 0.96);
font-size: 11px;
line-height: 1.25;
font-weight: 800;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.cellRemoveBtn {
position: absolute;
top: -6px;
@@ -1340,6 +1690,7 @@ onUnmounted(() => {
object-fit: cover;
}
.sidebar {
min-width: 0;
border: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(180deg, rgba(52, 52, 52, 0.84), rgba(36, 36, 36, 0.8));
border-radius: 22px;
@@ -1348,6 +1699,25 @@ onUnmounted(() => {
position: sticky;
top: 14px;
}
.dropzone--board {
margin-top: 18px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.dropzone__actions {
display: flex;
align-items: center;
gap: 12px;
flex: 0 0 auto;
}
.dropzone__button {
min-width: 148px;
}
.editorSidebar__section {
display: grid;
gap: 10px;
@@ -1381,11 +1751,13 @@ onUnmounted(() => {
font-size: 12px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.56);
word-break: keep-all;
}
.editorSidebar__hint--warn {
color: rgba(251, 191, 36, 0.92);
}
.editorSidebar__thumbFrame {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 16px;
@@ -1393,6 +1765,11 @@ onUnmounted(() => {
border: 1px solid rgba(255, 255, 255, 0.08);
background: #4c4c4c;
}
.editorSidebar__thumbFrame--active {
border-color: rgba(96, 165, 250, 0.8);
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.18);
}
.editorSidebar__thumbImage {
width: 100%;
height: 100%;
@@ -1406,6 +1783,16 @@ onUnmounted(() => {
color: rgba(255, 255, 255, 0.36);
font-size: 13px;
}
.editorSidebar__thumbOverlay {
position: absolute;
inset: auto 0 0 0;
padding: 10px 12px;
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.72));
color: rgba(255, 255, 255, 0.82);
font-size: 12px;
font-weight: 700;
}
.editorSidebar__button {
width: 100%;
margin-top: 0;
@@ -1437,6 +1824,33 @@ onUnmounted(() => {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.editorSidebar__utilityLinks {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding-top: 4px;
}
.editorSidebar__utilityLink {
border: 0;
padding: 0;
background: transparent;
color: rgba(255, 255, 255, 0.74);
font-size: 14px;
cursor: pointer;
}
.editorSidebar__utilityLink:disabled {
cursor: default;
opacity: 0.5;
}
.editorSidebar__utilityLink--danger {
color: rgba(248, 113, 113, 0.96);
}
.sidebar__title {
font-weight: 900;
margin-bottom: 8px;
@@ -1448,6 +1862,7 @@ onUnmounted(() => {
font-size: 13px;
margin-bottom: 12px;
line-height: 1.5;
word-break: keep-all;
}
.customItemEditor {
margin-top: 0;
@@ -1504,7 +1919,6 @@ onUnmounted(() => {
border-radius: 16px;
border: 1px dashed rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.04);
text-align: center;
}
.dropzone--active {
border-color: rgba(110, 231, 183, 0.6);
@@ -1521,26 +1935,51 @@ onUnmounted(() => {
}
.pool {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 10px;
align-content: start;
}
.poolItem {
min-width: 0;
display: grid;
grid-template-columns: var(--thumb-size, 80px) minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 10px;
grid-template-columns: 1fr;
justify-items: center;
align-content: start;
gap: 8px;
padding: 10px 8px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.10);
background: rgba(0, 0, 0, 0.18);
}
.poolItem--readonly {
opacity: 0.58;
filter: grayscale(0.25) brightness(0.78);
}
.poolItem .thumb {
width: 100%;
max-width: var(--thumb-size, 80px);
height: auto;
aspect-ratio: 1 / 1;
}
.poolItem__label {
width: 100%;
min-width: 0;
font-size: 11px;
line-height: 1.35;
font-weight: 800;
text-align: center;
opacity: 0.9;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.poolItem__state {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.58);
}
.hidden {
display: none;
}
@@ -1567,9 +2006,17 @@ onUnmounted(() => {
.sidebar {
position: static;
}
.pool {
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
}
.editorSidebar__actionGrid {
grid-template-columns: 1fr;
}
.editorSidebar__utilityLinks {
flex-direction: column;
align-items: flex-start;
}
.requestChecklist__item {
grid-template-columns: 1fr;
}
@@ -1587,5 +2034,13 @@ onUnmounted(() => {
.previewOnly__row {
grid-template-columns: 1fr;
}
.pool {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.pool {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
</style>

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`로 추가해, 이후 작업 시 파일 위치를 바로 찾을 수 있게 했습니다.