Compare commits

...

22 Commits

Author SHA1 Message Date
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
22 changed files with 2698 additions and 348 deletions

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 {
@@ -79,6 +135,9 @@ function mapTierListRow(row) {
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),
@@ -164,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 () => {
@@ -228,6 +295,9 @@ async function ensureSchema() {
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,
@@ -264,6 +334,38 @@ async function ensureSchema() {
) 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,
@@ -295,6 +397,18 @@ async function ensureSchema() {
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(
`
@@ -504,6 +618,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 (?, ?, ?, ?, ?)', [
@@ -894,6 +1369,9 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
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,
@@ -1024,6 +1502,9 @@ async function listAdminTierLists({ queryText = '', page = 1, limit = 50, curren
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,
@@ -1080,6 +1561,9 @@ async function findTierListById(id, currentUserId = '') {
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,
@@ -1277,7 +1761,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, showCharacterNames = false, 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)
@@ -1286,10 +1784,10 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de
await query(
`
UPDATE tierlists
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, show_character_names = ?, 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, showCharacterNames ? 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)
}
@@ -1298,15 +1796,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, show_character_names, 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, showCharacterNames ? 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()])
}
@@ -1326,6 +1846,7 @@ async function unfavoriteGame({ userId, gameId }) {
module.exports = {
DB_NAME,
ensureData,
closePool,
countUsers,
findUserByEmail,
findUserById,
@@ -1341,6 +1862,20 @@ module.exports = {
getGameDetail,
createGame,
updateGameThumbnail,
findImageAssetByHash,
findImageAssetBySrc,
createImageAsset,
createImageOptimizationJob,
findImageOptimizationJobById,
updateImageOptimizationJobStatus,
listRecentImageOptimizationJobs,
listUnusedImageAssets,
deleteImageAssets,
listReferencedUploadSources,
listReferencedUploadUsage,
replaceUploadSourceReferences,
clearImageOptimizationJobs,
getImageAssetStats,
createGameItem,
updateGameItemLabel,
deleteGameItem,
@@ -1363,6 +1898,7 @@ module.exports = {
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

@@ -30,8 +30,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,21 +59,8 @@ 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 avatarUpload = 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: 8 * 1024 * 1024, maxCount: 50 })
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
router.post('/games', requireAdmin, async (req, res) => {
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
@@ -97,7 +90,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 })
})
@@ -107,18 +110,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 })
@@ -197,6 +211,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) => {
@@ -212,33 +298,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) {
@@ -512,8 +583,18 @@ router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar')
const user = await findUserById(req.params.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
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 ? '' : req.file ? `/uploads/avatars/${req.file.filename}` : user.avatarSrc || ''
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || ''
const updated = await adminUpdateUser({
id: user.id,
email: user.email,

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')
@@ -12,15 +11,10 @@ const {
updateUserProfile,
} = 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),
@@ -45,7 +39,6 @@ router.post('/signup', async (req, res) => {
req.session.userId = user.id
req.session.isAdmin = !!user.isAdmin
// 세션을 응답 전에 명시적으로 저장해 Set-Cookie가 확실히 내려오도록 보강
req.session.save((err) => {
if (err) return res.status(500).json({ error: 'session_save_failed' })
res.json(user)
@@ -94,13 +87,7 @@ 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)
@@ -109,12 +96,19 @@ 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 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
? ''
: req.file
? `/uploads/avatars/${req.file.filename}`
: user.avatarSrc || ''
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || ''
const updated = await updateUserProfile({
id: user.id,
nickname: parsed.data.nickname,

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(),
@@ -84,6 +66,9 @@ const tierListUpsertSchema = z.object({
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),
@@ -131,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' })
@@ -166,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,
})
@@ -178,7 +181,17 @@ 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) => {
@@ -245,6 +258,9 @@ router.post('/', requireAuth, async (req, res) => {
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,
})
@@ -260,6 +276,9 @@ router.post('/', requireAuth, async (req, res) => {
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,40 +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` 값을 설정한다.
@@ -44,8 +16,6 @@
- 로컬 docker compose와 NAS MariaDB 사이의 버전 차이가 크지 않도록 유지한다.
## 중기 개선
- 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다.
- 자동 테스트와 최소한의 배포 체크리스트를 만든다.
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다.
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.

View File

@@ -1,5 +1,107 @@
# 업데이트 로그
## 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 화면과 같은 어두운 입력 문법으로 맞춰 흰 배경/흰 글자처럼 보이던 문제를 정리함.
- 앱 셸은 사이드 기본 바탕색을 중심으로 재정리하고, 중앙 바디에 배경과 좌우 보더를 줘 긴 스크롤에서도 사이드가 잘리는 듯한 인상을 줄이도록 조정함.

View File

@@ -12,6 +12,7 @@ import iconLists from './assets/icons/lists.svg'
import iconSearch from './assets/icons/search.svg'
import iconSettings from './assets/icons/settings.svg'
import RightRailAd from './components/RightRailAd.vue'
import SvgIcon from './components/SvgIcon.vue'
const route = useRoute()
const router = useRouter()
@@ -55,6 +56,8 @@ const leftNavItems = computed(() => {
return items.filter((item) => !item.requiresAuth || auth.user)
})
const showRightRailAction = computed(() => false)
const showGameHubViewToggle = computed(() => route.name === 'gameHub')
const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
const leftBottomPrimaryAction = computed(() => {
if (route.name === 'home' && auth.user) {
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new' }
@@ -242,6 +245,14 @@ function toggleRightRail() {
}
}
function setGameHubViewMode(mode) {
if (route.name !== 'gameHub') return
const nextQuery = { ...route.query }
if (mode === 'list') nextQuery.view = 'list'
else delete nextQuery.view
router.replace({ path: route.path, query: nextQuery })
}
function openCollapsedSearch() {
if (!leftRailCollapsed.value || isMobileLayout.value) return
isCollapsedSearchOpen.value = true
@@ -292,7 +303,7 @@ function submitGlobalSearch() {
<aside class="leftRail">
<div class="leftRail__top railHeader">
<button v-if="!isMobileLayout" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="왼쪽 패널 토글" @click="toggleLeftRail">
<img :src="iconDockToRight" alt="" />
<SvgIcon :src="iconDockToRight" :size="24" />
</button>
</div>
@@ -312,7 +323,7 @@ function submitGlobalSearch() {
<form class="searchStub" @submit.prevent="submitGlobalSearch">
<button class="searchStub__iconButton" type="button" :aria-label="leftRailCollapsed ? '검색 열기' : '검색'" @click="handleLeftRailSearch">
<span class="searchStub__icon">
<img :src="iconSearch" alt="" />
<SvgIcon :src="iconSearch" :size="24" />
</span>
</button>
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : searchPlaceholder" />
@@ -329,7 +340,7 @@ function submitGlobalSearch() {
:aria-label="leftRailCollapsed ? item.label : undefined"
>
<span class="leftNav__glyph">
<img v-if="item.iconSrc" :src="item.iconSrc" alt="" />
<SvgIcon v-if="item.iconSrc" :src="item.iconSrc" :size="24" />
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg>
</span>
<span class="leftNav__label">{{ item.label }}</span>
@@ -353,8 +364,16 @@ function submitGlobalSearch() {
<span class="workspaceHead__brandSub">by zenn</span>
</div>
<div class="workspaceHead__actions">
<div v-if="showGameHubViewToggle" class="viewToggle" role="group" aria-label="티어표 보기 방식">
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': gameHubViewMode === 'grid' }" type="button" aria-label="그리드 보기" @click="setGameHubViewMode('grid')">
<SvgIcon :src="iconGridView" :size="24" />
</button>
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': gameHubViewMode === 'list' }" type="button" aria-label="리스트 보기" @click="setGameHubViewMode('list')">
<SvgIcon :src="iconLists" :size="24" />
</button>
</div>
<button v-if="!rightRailOpen" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="패널 열기" @click="toggleRightRail">
<img :src="iconDockToLeft" alt="" />
<SvgIcon :src="iconDockToLeft" :size="24" />
</button>
</div>
</header>
@@ -367,7 +386,7 @@ function submitGlobalSearch() {
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" :aria-label="searchPlaceholder" @click.self="closeCollapsedSearch">
<form class="collapsedSearchBar" @submit.prevent="submitGlobalSearch">
<span class="collapsedSearchBar__icon">
<img :src="iconSearch" alt="" />
<SvgIcon :src="iconSearch" :size="24" />
</span>
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="searchPlaceholder" autofocus />
</form>
@@ -378,7 +397,7 @@ function submitGlobalSearch() {
<aside class="rightRail" :class="{ 'rightRail--closed': !rightRailOpen, 'rightRail--overlay': isRightRailOverlay }" :aria-hidden="!rightRailOpen">
<div class="rightRail__top railHeader">
<button v-if="rightRailOpen" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="패널 닫기" @click="toggleRightRail">
<img :src="iconDockToLeft" alt="" />
<SvgIcon :src="iconDockToLeft" :size="24" />
</button>
</div>
<div class="rightRail__body">
@@ -416,7 +435,6 @@ function submitGlobalSearch() {
<style scoped>
.appShell {
min-height: 100dvh;
height: 100dvh;
display: grid;
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr) var(--right-rail-width, 320px);
background: rgba(14, 14, 14, 0.96);
@@ -431,7 +449,6 @@ function submitGlobalSearch() {
.leftRail,
.rightRail {
min-height: 100dvh;
height: 100dvh;
border-right: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(14, 14, 14, 0.92);
box-sizing: border-box;
@@ -562,6 +579,9 @@ function submitGlobalSearch() {
min-width: 32px;
width: 32px;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.appUserCard {
@@ -607,7 +627,9 @@ function submitGlobalSearch() {
min-width: 0;
display: grid;
gap: 4px;
transition: opacity 180ms ease;
max-width: 180px;
overflow: hidden;
transition: opacity 180ms ease, max-width 220ms ease, transform 220ms ease;
}
.appUserCard__name {
@@ -641,12 +663,14 @@ function submitGlobalSearch() {
.searchStub__input {
min-width: 0;
flex: 1;
max-width: 100%;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.92);
outline: none;
font: inherit;
transition: opacity 180ms ease, width 180ms ease;
overflow: hidden;
transition: opacity 180ms ease, max-width 220ms ease, transform 220ms ease;
}
.searchStub__input::placeholder {
@@ -690,7 +714,10 @@ function submitGlobalSearch() {
.leftNav__label {
min-width: 0;
max-width: 140px;
white-space: nowrap;
overflow: hidden;
transition: opacity 180ms ease, max-width 220ms ease, transform 220ms ease;
}
.leftNav__item--active,
@@ -725,13 +752,15 @@ function submitGlobalSearch() {
.appShell--leftCollapsed .appUserCard__button,
.appShell--leftCollapsed .appUserCard__guest {
justify-content: center;
padding: 6px 0;
}
.appShell--leftCollapsed .appUserCard__meta,
.appShell--leftCollapsed .leftNav__label,
.appShell--leftCollapsed .searchStub__input {
display: none;
opacity: 0;
max-width: 0;
transform: translateX(-4px);
pointer-events: none;
}
.appShell--leftCollapsed .appUserCard__avatar {
@@ -741,11 +770,10 @@ function submitGlobalSearch() {
.appShell--leftCollapsed .searchStub {
justify-content: center;
padding: 10px 0;
}
.appShell--leftCollapsed .searchStub__iconButton {
width: 100%;
width: auto;
}
.appShell--leftCollapsed .leftNav {
@@ -754,7 +782,6 @@ function submitGlobalSearch() {
.appShell--leftCollapsed .leftNav__item {
justify-content: center;
padding: 11px 0;
}
.appShell--leftCollapsed .leftRail__bottom {
@@ -790,7 +817,7 @@ function submitGlobalSearch() {
.appMain {
min-width: 0;
min-height: 100dvh;
min-height: 0;
box-sizing: border-box;
background: rgba(18, 18, 18, 0.98);
border-left: 1px solid rgba(255, 255, 255, 0.08);
@@ -806,7 +833,6 @@ function submitGlobalSearch() {
grid-template-rows: 56px minmax(0, 1fr);
gap: 0;
min-height: 100dvh;
height: 100dvh;
}
.workspace--localRail {
@@ -846,9 +872,29 @@ function submitGlobalSearch() {
flex-wrap: wrap;
}
.viewToggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.04);
}
.viewToggle .ghostIcon--iconOnly {
width: 36px;
height: 36px;
min-width: 36px;
border-radius: 10px;
}
.ghostIcon--active {
background: rgba(255, 255, 255, 0.08);
}
.workspaceBody {
min-height: 0;
padding: 18px 18px 0;
padding: 18px 18px 32px;
border: 0;
border-radius: 0;
background: rgba(24, 24, 24, 0.92);
@@ -858,7 +904,7 @@ function submitGlobalSearch() {
.workspaceBody--localRail {
min-height: 0;
padding: 18px 18px 0;
padding: 18px 18px 32px;
border: 0;
border-radius: 0;
background: rgba(24, 24, 24, 0.92);
@@ -896,6 +942,60 @@ function submitGlobalSearch() {
display: none;
}
.collapsedSearchModal {
position: fixed;
inset: 0;
z-index: 35;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 88px 20px 20px;
background: rgba(0, 0, 0, 0.44);
backdrop-filter: blur(6px);
}
.collapsedSearchBar {
width: min(520px, calc(100vw - 32px));
display: flex;
align-items: center;
gap: 14px;
padding: 18px 22px;
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(26, 26, 26, 0.96);
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.34);
}
.collapsedSearchBar__icon {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.collapsedSearchBar__icon img {
width: 28px;
height: 28px;
display: block;
filter: brightness(0) saturate(100%) invert(94%) sepia(6%) saturate(207%) hue-rotate(186deg) brightness(96%) contrast(92%);
}
.collapsedSearchBar__input {
min-width: 0;
flex: 1;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.92);
font-size: 18px;
font-weight: 700;
outline: none;
}
.collapsedSearchBar__input::placeholder {
color: rgba(255, 255, 255, 0.46);
}
.localRightRailRoot {
min-height: auto;
@@ -1007,7 +1107,6 @@ function submitGlobalSearch() {
@media (max-width: 860px) {
.appShell {
grid-template-columns: 1fr;
height: auto;
min-height: 100dvh;
}
@@ -1091,10 +1190,11 @@ function submitGlobalSearch() {
}
.collapsedSearchModal {
padding-top: 72px;
padding: 72px 16px 16px;
}
.collapsedSearchBar {
width: min(100%, calc(100vw - 24px));
padding: 16px 18px;
border-radius: 20px;
}

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,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

@@ -44,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) =>
@@ -90,6 +99,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

@@ -5,6 +5,7 @@ import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import lockResetIcon from '../assets/icons/lock_reset.svg'
import deleteIcon from '../assets/icons/delete.svg'
import SvgIcon from '../components/SvgIcon.vue'
import { useAuthStore } from '../stores/auth'
import { useToast } from '../composables/useToast'
@@ -57,6 +58,12 @@ const modalRoleNextAdmin = ref(false)
const modalTargetCustomItem = ref(null)
const users = ref([])
const imageStats = ref(null)
const imageQueue = ref({ concurrency: 1, activeCount: 0, pendingCount: 0 })
const imageRecentJobs = ref([])
const imageStatsMonth = ref('')
const imageStatsLimit = ref(12)
const imageResetModalOpen = ref(false)
const error = ref('')
const success = ref('')
@@ -65,6 +72,7 @@ const newGameId = ref('')
const newGameName = ref('')
const uploadFiles = ref([])
const uploadItemDrafts = ref([])
const thumbFile = ref(null)
const itemPreviewUrls = ref([])
const isItemDragOver = ref(false)
@@ -80,7 +88,7 @@ const gameCreateModalOpen = ref(false)
const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id)
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
const canAddItem = computed(() => uploadFiles.value.length > 0 && !!selectedGameId.value)
const canAddItem = computed(() => uploadItemDrafts.value.length > 0 && uploadItemDrafts.value.every((item) => !!item.label.trim()) && !!selectedGameId.value)
const customItemPageCount = computed(() => Math.max(1, Math.ceil(customItemTotal.value / customItemLimit.value)))
const adminTierListPageCount = computed(() => Math.max(1, Math.ceil(adminTierListTotal.value / adminTierListLimit.value)))
const featuredGames = computed(() =>
@@ -165,7 +173,7 @@ const adminOverviewStats = computed(() => {
onMounted(async () => {
await auth.refresh()
await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests()])
await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests(), refreshImageDiagnostics()])
await syncFeaturedSortable()
})
@@ -225,6 +233,72 @@ function resetMessages() {
success.value = ''
}
function formatBytes(value) {
const size = Number(value || 0)
if (!size) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let current = size
let unitIndex = 0
while (current >= 1024 && unitIndex < units.length - 1) {
current /= 1024
unitIndex += 1
}
return `${current >= 10 || unitIndex === 0 ? current.toFixed(0) : current.toFixed(1)} ${units[unitIndex]}`
}
const imageDiagnosticsCards = computed(() => {
const stats = imageStats.value
if (!stats) return []
return [
{ label: '실사용 파일', value: `${stats.referencedCount || 0}` },
{ label: '현재 용량', value: formatBytes(stats.referencedByteSize) },
{ label: '추적 자산', value: `${stats.trackedReferencedCount || 0}` },
{ label: '레거시 참조', value: `${stats.legacyReferencedCount || 0}` },
{ label: '절감 용량', value: formatBytes(stats.savedByteSize) },
{ label: '절감률', value: `${Math.round((stats.savingsRatio || 0) * 100)}%` },
]
})
const visibleLinkedGames = computed(() =>
(modalTargetCustomItem.value?.linkedGames || []).filter((game) => game?.id && game.id !== 'freeform')
)
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
async function refreshImageDiagnostics() {
try {
const data = await api.getAdminImageAssetStats({
month: imageStatsMonth.value || '',
limit: imageStatsLimit.value,
})
imageStats.value = data.stats || null
imageQueue.value = data.queue || { concurrency: 1, activeCount: 0, pendingCount: 0 }
imageRecentJobs.value = data.recentJobs || []
} catch (e) {
error.value = '이미지 최적화 현황을 불러오지 못했어요.'
}
}
function openImageResetModal() {
imageResetModalOpen.value = true
}
function closeImageResetModal() {
imageResetModalOpen.value = false
}
async function confirmImageReset() {
try {
const data = await api.resetAdminImageAssetStats({ month: imageStatsMonth.value || null })
success.value = imageStatsMonth.value
? `${imageStatsMonth.value} 기록 ${data.deletedCount || 0}건을 정리했어요.`
: `전체 최적화 기록 ${data.deletedCount || 0}건을 정리했어요.`
closeImageResetModal()
await refreshImageDiagnostics()
} catch (e) {
error.value = '이미지 최적화 기록을 정리하지 못했어요.'
}
}
function setTab(tab) {
resetMessages()
activeTab.value = tab
@@ -437,6 +511,7 @@ async function refreshUsers() {
function resetUploadState() {
uploadFiles.value = []
uploadItemDrafts.value = []
thumbFile.value = null
resetFileInput('item')
resetFileInput('thumb')
@@ -537,8 +612,14 @@ function handleItemFiles(fileList) {
const files = Array.from(fileList || []).filter((file) => (file.type || '').startsWith('image/'))
uploadFiles.value = files
clearPreviewUrl('item')
uploadItemDrafts.value = []
if (!files.length) return
itemPreviewUrls.value = files.map((file) => URL.createObjectURL(file))
uploadItemDrafts.value = files.map((file, index) => ({
file,
previewUrl: itemPreviewUrls.value[index],
label: (file.name || '').replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60),
}))
resetFileInput('item')
}
@@ -548,6 +629,7 @@ function openItemFilePicker() {
function clearItemFiles() {
uploadFiles.value = []
uploadItemDrafts.value = []
clearPreviewUrl('item')
resetFileInput('item')
}
@@ -602,15 +684,18 @@ async function uploadThumbnail() {
async function uploadItem() {
resetMessages()
if (!uploadFiles.value.length || !selectedGameId.value) {
if (!uploadItemDrafts.value.length || !selectedGameId.value) {
error.value = '아이템 파일을 선택해주세요.'
return
}
try {
const fd = new FormData()
uploadFiles.value.forEach((file) => fd.append('images', file))
const uploadCount = uploadFiles.value.length
uploadItemDrafts.value.forEach((entry) => {
fd.append('images', entry.file)
fd.append('labels', entry.label.trim())
})
const uploadCount = uploadItemDrafts.value.length
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedGameId.value)}/images`), {
method: 'POST',
credentials: 'include',
@@ -1231,29 +1316,6 @@ async function saveFeaturedOrder() {
</template>
<template v-else-if="activeTab === 'game-admin'">
<div class="panel">
<div class="sectionHeader">
<div>
<div class="panel__title">게임 관리</div>
<div class="hint hint--tight">등록된 게임을 선택하면 아래에서 썸네일과 기본 아이템을 바로 수정할 있어요.</div>
</div>
<button class="btn btn--primary" @click="openGameCreateModal"> 게임 생성</button>
</div>
<div class="gameManagerGrid">
<section class="adminCard">
<div class="section__title">등록된 게임 선택</div>
<div class="gameManagerCard__body">
<select :value="selectedGameId" class="select" @change="handleSelectedGameChange">
<option value="">게임을 선택해주세요</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
</select>
<div class="hint hint--tight">선택하면 아래 상세 영역에서 썸네일과 기본 아이템을 바로 수정할 있어요.</div>
<div v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 게임 ID: {{ selectedGameId }}</div>
</div>
</section>
</div>
</div>
<div v-if="isGameLoading" class="panel panel--empty">
<div class="emptyState">
@@ -1268,37 +1330,9 @@ async function saveFeaturedOrder() {
<div class="selectedGame__name">{{ selectedGame.game.name }}</div>
<div class="selectedGame__id">{{ selectedGame.game.id }}</div>
</div>
<div class="detailHead__actions">
<button class="btn btn--danger" @click="removeGame">게임 삭제</button>
</div>
</div>
<div class="section section--topGrid">
<section class="adminCard">
<div class="section__title">썸네일 적용</div>
<input ref="thumbFileInput" type="file" accept="image/*" class="srOnlyInput" @change="onThumb" />
<button
class="thumbDropZone"
:class="{ 'thumbDropZone--active': isThumbDragOver }"
type="button"
@click="openThumbFilePicker"
@dragenter="onThumbDragEnter"
@dragover="onThumbDragOver"
@dragleave="onThumbDragLeave"
@drop="onThumbDrop"
>
<img v-if="displayThumbnailUrl" class="selectedThumb" :src="displayThumbnailUrl" :alt="selectedGame.game.name" />
<div v-else class="selectedThumb selectedThumb--empty">대표 썸네일</div>
<div class="thumbDropZone__copy">
<div class="thumbDropZone__title">클릭하거나 드래그해서 썸네일 추가</div>
<div class="thumbDropZone__desc">다른 업로드 화면처럼 이미지 장을 바로 지정할 있어요.</div>
</div>
</button>
<div class="uploadControls">
<button class="btn" :disabled="!canApplyThumbnail" @click="uploadThumbnail">썸네일 적용</button>
</div>
</section>
<div class="section">
<section class="adminCard">
<div class="section__title">기본 아이템 추가</div>
<div class="itemComposer">
@@ -1320,18 +1354,24 @@ async function saveFeaturedOrder() {
</div>
</div>
<button class="btn" :disabled="!canAddItem" @click="uploadItem">
아이템 {{ uploadFiles.length || 0 }} 추가
아이템 {{ uploadItemDrafts.length || 0 }} 추가
</button>
</div>
<div class="itemPreviewCard">
<div v-if="itemPreviewUrls.length" class="itemPreviewGrid">
<div v-for="(previewUrl, index) in itemPreviewUrls.slice(0, 6)" :key="previewUrl" class="itemPreviewFrame">
<img class="itemPreviewImage" :src="previewUrl" :alt="uploadFiles[index]?.name || 'item preview'" />
<div v-if="uploadItemDrafts.length" class="itemDraftList">
<div v-for="draft in uploadItemDrafts" :key="draft.previewUrl || draft.file.name" class="itemDraftRow">
<div class="itemDraftRow__preview">
<img class="itemPreviewImage" :src="draft.previewUrl" :alt="draft.file.name || 'item preview'" />
</div>
<div class="itemDraftRow__body">
<input v-model="draft.label" class="input input--labelEdit input--dense" maxlength="60" placeholder="아이템 이름" />
<div class="hint hint--tight">{{ draft.file.name }}</div>
</div>
</div>
</div>
<div v-else class="itemPreviewEmpty">선택한 기본 아이템 미리보기가 여기에 표시됩니다.</div>
<div class="thumbLabel thumbLabel--preview">
{{ uploadFiles.length ? `선택된 파일 ${uploadFiles.length}` : '아직 선택된 파일이 없어요.' }}
{{ uploadItemDrafts.length ? `추가 예정 아이템 ${uploadItemDrafts.length}` : '아직 선택된 파일이 없어요.' }}
</div>
</div>
</div>
@@ -1361,10 +1401,7 @@ async function saveFeaturedOrder() {
</div>
<div v-else class="panel panel--empty">
<div class="emptyState">
<div class="emptyState__title">게임을 선택하면 상세 관리가 열려.</div>
<div class="emptyState__desc">
위에서 기존 게임을 선택하거나 게임을 만든 , 같은 화면에서 바로 썸네일과 기본 아이템을 정리할 있어요.
</div>
<div class="emptyState__title">게임을 선택 주세.</div>
<div v-if="selectedGameId" class="hint hint--tight">선택한 게임을 찾지 못했거나 로딩 오류가 발생했어요. 다시 선택해보세요.</div>
</div>
</div>
@@ -1537,7 +1574,7 @@ async function saveFeaturedOrder() {
:disabled="user.isAvatarBusy"
@click.stop="removeUserAvatar(user)"
>
<img :src="deleteIcon" alt="" />
<SvgIcon class="userAvatarRemoveIcon" :src="deleteIcon" :size="12" />
</button>
</div>
<div class="userCard__identityMeta">
@@ -1568,10 +1605,10 @@ async function saveFeaturedOrder() {
<div class="userCard__actions userCard__actions--compact">
<button class="iconActionButton" type="button" title="비밀번호 초기화" @click="openUserPasswordModal(user)">
<img :src="lockResetIcon" alt="" />
<SvgIcon class="iconActionButton__icon" :src="lockResetIcon" :size="18" />
</button>
<button class="iconActionButton iconActionButton--danger" type="button" title="회원 삭제" @click="openUserDeleteModal(user)">
<img :src="deleteIcon" alt="" />
<SvgIcon class="iconActionButton__icon" :src="deleteIcon" :size="18" />
</button>
<button class="btn btn--ghost userSaveButton" :disabled="!isUserDirty(user)" @click="saveUser(user)">회원정보 저장</button>
</div>
@@ -1693,11 +1730,11 @@ async function saveFeaturedOrder() {
</select>
</div>
<div class="customItemModal__linked">
<span class="customItemModal__label">이미 사용 중인 게임</span>
<div v-if="modalTargetCustomItem.linkedGames?.length" class="customItemModal__chips">
<span v-for="game in modalTargetCustomItem.linkedGames" :key="game.id" class="pill">{{ game.name }}</span>
<span class="customItemModal__label">템플릿에 사용 중인 게임</span>
<div v-if="visibleLinkedGames.length" class="customItemModal__chips">
<span v-for="game in visibleLinkedGames" :key="game.id" class="pill">{{ game.name }}</span>
</div>
<div v-else class="hint hint--tight">아직 연결된 게임이 없어요.</div>
<div v-else class="hint hint--tight">아직 템플릿에 연결된 게임이 없어요.</div>
</div>
</div>
<div class="customItemModal__body">
@@ -1731,6 +1768,19 @@ async function saveFeaturedOrder() {
</div>
</div>
<div v-if="imageResetModalOpen" class="modalOverlay" @click.self="closeImageResetModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">최적화 기록 비우기</div>
<div class="modalCard__desc">
{{ imageStatsMonth ? `${imageStatsMonth} 기간의 최적화 기록만 삭제합니다.` : '전체 최적화 작업 기록을 비웁니다. 실제 이미지 파일은 삭제되지 않아요.' }}
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeImageResetModal">취소</button>
<button class="btn btn--danger" @click="confirmImageReset">기록 비우기</button>
</div>
</div>
</div>
<div v-if="previewModalOpen" class="modalOverlay" @click.self="closePreviewModal">
<div class="modalCard modalCard--preview" role="dialog" aria-modal="true">
<div class="modalCard__titleRow">
@@ -1764,7 +1814,44 @@ async function saveFeaturedOrder() {
</div>
</section>
<section v-if="activeTab === 'items'" class="adminSidebar__panel">
<section v-if="activeTab === 'game-admin'" class="adminSidebar__panel">
<div class="adminSidebar__label">Game</div>
<div class="adminSidebar__group">
<button class="btn btn--primary" @click="openGameCreateModal"> 게임 생성</button>
<select :value="selectedGameId" class="select" @change="handleSelectedGameChange">
<option value="">게임을 선택해주세요</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
</select>
<div v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 게임 ID: {{ selectedGameId }}</div>
</div>
<div v-if="hasSelectedGame" class="adminSidebar__group">
<div class="selectedGameSidebar__name">{{ selectedGame.game.name }}</div>
<div class="selectedGameSidebar__id">{{ selectedGame.game.id }}</div>
<input ref="thumbFileInput" type="file" accept="image/*" class="srOnlyInput" @change="onThumb" />
<button
class="thumbDropZone"
:class="{ 'thumbDropZone--active': isThumbDragOver }"
type="button"
@click="openThumbFilePicker"
@dragenter="onThumbDragEnter"
@dragover="onThumbDragOver"
@dragleave="onThumbDragLeave"
@drop="onThumbDrop"
>
<img v-if="displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="displayThumbnailUrl" :alt="selectedGame.game.name" />
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
<div class="thumbDropZone__copy">
<div class="thumbDropZone__title">클릭 or 드래그</div>
</div>
</button>
<div class="adminSidebar__actions adminSidebar__actions--stack">
<button class="btn" :disabled="!canApplyThumbnail" @click="uploadThumbnail">썸네일 적용</button>
<button class="btn btn--danger" @click="removeGame">게임 삭제</button>
</div>
</div>
</section>
<section v-else-if="activeTab === 'items'" class="adminSidebar__panel">
<div class="adminSidebar__label">Filters</div>
<div class="adminSidebar__group">
<input v-model="customItemQuery" class="input" placeholder="파일명, 라벨, 업로더 검색" @keydown.enter.prevent="submitCustomItemSearch" />
@@ -1841,6 +1928,62 @@ async function saveFeaturedOrder() {
</template>
</section>
<section v-if="activeTab === 'featured'" class="adminSidebar__panel">
<div class="adminSidebar__label">Image Optimization</div>
<div class="adminSidebar__group">
<input v-model="imageStatsMonth" class="input" type="month" />
<select v-model.number="imageStatsLimit" class="select">
<option :value="6">최근 6</option>
<option :value="12">최근 12</option>
<option :value="24">최근 24</option>
</select>
</div>
<div class="adminSidebar__actions adminSidebar__actions--split">
<button class="btn btn--ghost" @click="refreshImageDiagnostics">현황 새로고침</button>
<button class="btn btn--ghost" @click="openImageResetModal">기록 비우기</button>
</div>
<div class="hint hint--tight">{{ imageStatsPeriodLabel }}</div>
<div v-if="imageDiagnosticsCards.length" class="adminSidebar__stats adminSidebar__stats--grid">
<article v-for="stat in imageDiagnosticsCards" :key="stat.label" class="sidebarStat">
<span class="sidebarStat__label">{{ stat.label }}</span>
<strong class="sidebarStat__value">{{ stat.value }}</strong>
</article>
</div>
<div class="adminSidebar__stats">
<div class="sidebarStat">
<span class="sidebarStat__label"> 상태</span>
<strong class="sidebarStat__value">{{ imageQueue.activeCount }} 실행 / {{ imageQueue.pendingCount }} 대기</strong>
</div>
<div class="sidebarStat">
<span class="sidebarStat__label">작업 누적</span>
<strong class="sidebarStat__value">{{ imageStats?.completedCount || 0 }} 완료 · {{ imageStats?.failedCount || 0 }} 실패</strong>
</div>
<div class="sidebarStat">
<span class="sidebarStat__label">중복 재사용</span>
<strong class="sidebarStat__value">{{ imageStats?.reusedCount || 0 }}</strong>
</div>
<div class="sidebarStat">
<span class="sidebarStat__label">누락 파일</span>
<strong class="sidebarStat__value">{{ imageStats?.missingReferencedCount || 0 }}</strong>
</div>
</div>
<div class="adminSidebar__group">
<div class="section__title">최근 최적화 작업</div>
<div class="hint hint--tight">현재 {{ imageRecentJobs.length }} 표시 </div>
<div v-if="!imageRecentJobs.length" class="hint hint--tight">아직 기록된 최적화 작업이 없어요.</div>
<div v-else class="imageJobList">
<article v-for="job in imageRecentJobs" :key="job.id" class="imageJobRow">
<div class="imageJobRow__head">
<strong>{{ job.sourceCategory || 'asset' }}</strong>
<span class="imageJobRow__status">{{ job.status }}</span>
</div>
<div class="hint hint--tight">{{ formatBytes(job.originalByteSize) }} {{ formatBytes(job.optimizedByteSize) }}</div>
<div class="hint hint--tight">{{ fmt(job.queuedAt) }}</div>
</article>
</div>
</div>
</section>
</aside>
</Teleport>
</template>
@@ -1931,6 +2074,38 @@ async function saveFeaturedOrder() {
rgba(13, 13, 13, 0.94);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.18);
}
.adminSidebar__stats--grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.imageJobList {
display: grid;
gap: 8px;
}
.imageJobRow {
border: 1px solid var(--line);
border-radius: 14px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.02);
display: grid;
gap: 4px;
}
.imageJobRow__head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
font-size: 12px;
}
.imageJobRow__status {
color: var(--text-muted);
text-transform: capitalize;
}
.adminSidebar__label {
font-size: 11px;
letter-spacing: 0.12em;
@@ -1944,6 +2119,12 @@ async function saveFeaturedOrder() {
display: grid;
gap: 10px;
}
.adminSidebar__actions--stack .btn {
width: 100%;
}
.adminSidebar__actions--split {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.adminSidebar__groupTitle {
font-size: 13px;
font-weight: 800;
@@ -2174,6 +2355,9 @@ async function saveFeaturedOrder() {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.gameManagerGrid--single {
grid-template-columns: minmax(0, 1fr);
}
.gameManagerCard__body {
margin-top: 10px;
display: grid;
@@ -2186,6 +2370,9 @@ async function saveFeaturedOrder() {
padding: 16px;
min-width: 0;
}
.adminCard--muted {
background: rgba(255, 255, 255, 0.02);
}
.sectionHeader {
display: flex;
gap: 12px;
@@ -2240,6 +2427,11 @@ async function saveFeaturedOrder() {
.input--labelEdit {
margin-top: 10px;
}
.input--dense {
margin-top: 0;
padding-top: 9px;
padding-bottom: 9px;
}
.hint {
margin-top: 10px;
opacity: 0.78;
@@ -2351,34 +2543,48 @@ async function saveFeaturedOrder() {
place-items: center;
color: rgba(255, 255, 255, 0.62);
}
.thumbDropZone {
.selectedThumb--sidebar {
width: 100%;
display: grid;
gap: 14px;
justify-items: start;
padding: 16px;
}
.selectedGameSidebar__name {
font-size: 18px;
font-weight: 900;
}
.selectedGameSidebar__id {
font-size: 12px;
opacity: 0.68;
word-break: break-all;
}
.thumbDropZone {
position: relative;
width: 100%;
display: block;
padding: 0;
overflow: hidden;
border-radius: 18px;
border: 1px dashed rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.03);
text-align: left;
transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.thumbDropZone--active {
border-color: rgba(96, 165, 250, 0.56);
background: rgba(96, 165, 250, 0.08);
box-shadow: 0 0 0 1px rgba(96, 165, 250, 0.18);
transform: translateY(-1px);
}
.thumbDropZone__copy {
position: absolute;
inset: auto 0 0 0;
display: grid;
gap: 6px;
place-items: center;
min-height: 52px;
padding: 12px 16px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(6, 9, 16, 0.86) 46%, rgba(6, 9, 16, 0.94) 100%);
}
.thumbDropZone__title {
font-weight: 900;
}
.thumbDropZone__desc {
color: rgba(255, 255, 255, 0.68);
font-size: 13px;
line-height: 1.5;
letter-spacing: 0.01em;
}
.itemComposer {
margin-top: 10px;
@@ -2433,6 +2639,29 @@ async function saveFeaturedOrder() {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.itemDraftList {
display: grid;
gap: 10px;
}
.itemDraftRow {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 12px;
align-items: center;
}
.itemDraftRow__preview {
width: 72px;
height: 72px;
overflow: hidden;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
}
.itemDraftRow__body {
min-width: 0;
display: grid;
gap: 6px;
}
.itemPreviewFrame {
aspect-ratio: 1 / 1;
border-radius: 12px;
@@ -2700,10 +2929,8 @@ async function saveFeaturedOrder() {
transform: translateY(2px) scale(0.96);
transition: opacity 160ms ease, transform 160ms ease, background 160ms ease, visibility 160ms ease;
}
.userAvatarRemoveButton img {
width: 12px;
height: 12px;
filter: brightness(0) invert(1);
.userAvatarRemoveIcon {
color: rgba(255, 255, 255, 0.96);
}
.userAvatarRemoveButton:disabled {
opacity: 0.45;
@@ -2805,9 +3032,8 @@ async function saveFeaturedOrder() {
background: rgba(255, 255, 255, 0.04);
cursor: pointer;
}
.iconActionButton img {
width: 18px;
height: 18px;
.iconActionButton__icon {
color: rgba(255, 255, 255, 0.92);
}
.iconActionButton:disabled {
cursor: not-allowed;

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,21 +307,35 @@ 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: 6px;
gap: 8px;
}
.boardCard--list .boardCard__head {
height: 100%;
padding: 14px 16px 14px 0;
align-content: center;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.boardCard__titleRow {
align-items: flex-start;
}
.boardCard__metaRow {
@@ -293,11 +343,13 @@ function submitSearch() {
}
.boardCard__author {
min-width: 0;
max-width: 100%;
display: inline-flex;
gap: 7px;
align-items: center;
font-size: 13px;
opacity: 0.86;
overflow: hidden;
}
.boardCard__authorName {
min-width: 0;
@@ -323,28 +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;
}
.boardCard__date {
font-size: 10px;
}
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1024px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
@media (max-width: 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

@@ -2,6 +2,8 @@
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'
@@ -82,7 +84,7 @@ function thumbUrl(g) {
</section>
<div v-if="error" class="error">{{ error }}</div>
<section v-if="games.length" class="libraryGrid">
<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"
@@ -91,7 +93,7 @@ function thumbUrl(g) {
:disabled="loadingFavoriteId === g.id"
@click.stop="toggleFavorite(g, $event)"
>
{{ g.isFavorited ? '★' : '☆' }}
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
</button>
<button class="libraryCard__main" type="button" @click="goGame(g.id)">
<div class="libraryCard__thumbWrap">
@@ -104,7 +106,7 @@ function thumbUrl(g) {
</div>
</button>
</article>
</section>
</TransitionGroup>
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 게임 템플릿이 없어요.' : '표시할 게임 템플릿이 없어요.' }}</div>
</template>
@@ -139,6 +141,7 @@ function thumbUrl(g) {
gap: 12px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition: transform 0.16s ease, background 0.16s ease;
will-change: transform, opacity;
}
.libraryCard:hover {
background: rgba(70, 70, 70, 0.96);
@@ -156,8 +159,8 @@ function thumbUrl(g) {
}
.libraryCard__favorite {
position: absolute;
top: 12px;
right: 12px;
bottom: 24px;
right: 14px;
width: 34px;
height: 34px;
border-radius: 999px;
@@ -168,8 +171,20 @@ function thumbUrl(g) {
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 {
@@ -206,6 +221,24 @@ function thumbUrl(g) {
overflow: hidden;
text-overflow: ellipsis;
}
.libraryCard-move,
.libraryCard-enter-active,
.libraryCard-leave-active {
transition: transform 280ms ease, opacity 220ms ease;
}
.libraryCard-enter-from,
.libraryCard-leave-to {
opacity: 0;
transform: translateY(10px) scale(0.985);
}
.libraryCard-leave-active {
position: absolute;
width: calc(100% - 0px);
pointer-events: none;
}
.libraryEmpty {
padding: 20px 0;
color: rgba(255, 255, 255, 0.62);

View File

@@ -54,18 +54,6 @@ 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>
@@ -101,7 +89,6 @@ async function removeList(t) {
</div>
</div>
</button>
<button class="link link--danger" @click="removeList(t)">삭제</button>
</article>
</div>
</div>
@@ -115,25 +102,18 @@ async function removeList(t) {
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);
@@ -184,30 +164,40 @@ 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: 6px;
gap: 8px;
min-width: 0;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
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: 7px;
@@ -246,21 +236,6 @@ async function removeList(t) {
.boardCard__date {
font-size: 10px;
}
.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));
}
}
@media (max-width: 720px) {
.list {
grid-template-columns: 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([])
@@ -46,10 +45,15 @@ 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)
@@ -96,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')
@@ -270,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
@@ -280,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)
@@ -474,6 +504,9 @@ function buildPayload(existingId) {
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),
}
@@ -562,6 +595,19 @@ async function confirmDeleteTierList() {
}
}
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 = '티어표 복사에 실패했어요.'
}
}
async function toggleFavorite() {
if (!canFavorite.value || isFavoriteBusy.value) return
try {
@@ -654,6 +700,9 @@ onMounted(() => {
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
@@ -700,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>
@@ -744,11 +793,11 @@ onUnmounted(() => {
<div class="templateRequestDraft">
<label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 제목</span>
<input v-model="templateRequestDraftTitle" class="input" maxlength="80" placeholder="예: 템플릿 등록 요청" />
<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="textarea templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." />
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." />
</label>
</div>
<div class="modalCard__actions">
@@ -773,11 +822,11 @@ onUnmounted(() => {
<div class="templateRequestDraft">
<label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 제목</span>
<input v-model="templateRequestDraftTitle" class="input" maxlength="80" placeholder="예: 템플릿 업데이트 요청" />
<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="textarea templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가" />
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가" />
</label>
</div>
<div class="modalCard__actions">
@@ -804,6 +853,19 @@ onUnmounted(() => {
</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">
@@ -817,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>
@@ -851,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
@@ -912,9 +987,10 @@ onUnmounted(() => {
{{ 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">
<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>
</div>
@@ -990,13 +1066,15 @@ 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="toggle" :class="{ 'toggle--disabled': !canEdit }">
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': !canEdit }">
<input v-model="showCharacterNames" type="checkbox" :disabled="!canEdit" />
<span>캐릭터 이름 표시</span>
<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>
@@ -1004,6 +1082,7 @@ onUnmounted(() => {
</div>
<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"
@@ -1054,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;
@@ -1092,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;
@@ -1132,23 +1228,60 @@ onUnmounted(() => {
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;
}
@@ -1305,6 +1438,25 @@ onUnmounted(() => {
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;
@@ -1402,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);
@@ -1409,7 +1562,7 @@ onUnmounted(() => {
gap: 8px;
align-items: center;
justify-content: center;
padding: 10px 8px;
padding: 10px 12px 30px;
font-weight: 900;
overflow: hidden;
}
@@ -1437,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);
@@ -1792,6 +1951,10 @@ onUnmounted(() => {
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);
@@ -1810,6 +1973,13 @@ onUnmounted(() => {
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;
}

View File

@@ -8,7 +8,10 @@
"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"
"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": "",