Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a19606c516 | |||
| 0581de6c17 | |||
| 4db1b21ad5 | |||
| d760c7331a | |||
| ebe7a4408f | |||
| 2fba826900 | |||
| 5b047b0458 | |||
| 6dce53db7a | |||
| 3227181c24 | |||
| fadfd0ba58 | |||
| f77ce2a580 | |||
| 50773f799a | |||
| 0b283276ce | |||
| 6105208aef | |||
| f6dc64dfc8 | |||
| 40a8dac7b6 | |||
| faa2a01f6c | |||
| 2cdd627658 | |||
| 34ddd1083d | |||
| b5ec579e5d | |||
| 25b893407c | |||
| ba6ad0593a | |||
| df46e43da5 | |||
| 26d7e4c4a8 | |||
| ed68b609bc | |||
| 876c13d99b | |||
| 1a7ec50a93 | |||
| c1f0471f1f | |||
| 0812640ec1 | |||
| 285644bdde | |||
| ed4023d1bd | |||
| 14a6823c3e | |||
| adc697eb13 | |||
| 5d7797925e | |||
| 69a8cd3600 | |||
| 26a77bd3e1 | |||
| d36502fe51 | |||
| a6e78b29f1 | |||
| 2346b5fbe3 | |||
| d724a64451 | |||
| 781a131ade | |||
| 6fceeaf15b | |||
| 7886b98380 | |||
| b5d5f4b079 | |||
| fbd596bdd0 | |||
| 14607fbbbb | |||
| 28e23d6c26 | |||
| b15398761b | |||
| 2bee78ba5e | |||
| 7b4a80f47d | |||
| 9644eabf00 | |||
| 676b952982 | |||
| 4fe6b90d08 | |||
| 3b314381a0 | |||
| e0eeaa01cd | |||
| 282e89b738 |
@@ -1 +1 @@
|
||||
모든 작업 시 프로젝트 루트의 .ai-rules.md 지침을 엄격히 준수하고, 작업 종료 시마다 docs/ 폴더 내 문서를 업데이트할 것.
|
||||
모든 작업 시 프로젝트 루트의 /ai-rules.md 지침을 엄격히 준수하고, 작업 종료 시마다 docs/ 폴더 내 문서를 업데이트할 것.
|
||||
2
.gitignore
vendored
@@ -10,3 +10,5 @@ backend/uploads/games/
|
||||
backend/uploads/custom/
|
||||
|
||||
.DS_Store
|
||||
.env.production
|
||||
.vscode/
|
||||
|
||||
537
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"mysql2": "^3.20.0",
|
||||
"nanoid": "^5.1.7",
|
||||
"session-file-store": "^1.5.0",
|
||||
"sharp": "^0.34.5",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -28,6 +28,14 @@ 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 mapUserRow(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
@@ -37,6 +45,8 @@ function mapUserRow(row) {
|
||||
isAdmin: !!row.is_admin,
|
||||
avatarSrc: row.avatar_src || '',
|
||||
createdAt: Number(row.created_at),
|
||||
tierListCount: Number(row.tierlist_count || 0),
|
||||
recentActivityAt: Number(row.recent_activity_at || row.created_at || 0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +72,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 {
|
||||
@@ -76,6 +117,10 @@ function mapTierListRow(row) {
|
||||
thumbnailSrc: row.thumbnail_src || '',
|
||||
description: row.description || '',
|
||||
isPublic: !!row.is_public,
|
||||
showCharacterNames: !!row.show_character_names,
|
||||
sourceTierListId: row.source_tierlist_id || '',
|
||||
sourceSnapshotTitle: row.source_snapshot_title || '',
|
||||
sourceSnapshotAuthor: row.source_snapshot_author || '',
|
||||
groups: parseJson(row.groups_json, []),
|
||||
pool: parseJson(row.pool_json, []),
|
||||
createdAt: Number(row.created_at),
|
||||
@@ -83,6 +128,30 @@ function mapTierListRow(row) {
|
||||
}
|
||||
}
|
||||
|
||||
function mapTemplateRequestRow(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.request_type,
|
||||
requesterId: row.requester_id,
|
||||
requesterName: getUserDisplayName(row),
|
||||
requesterAccountName: getUserAccountName(row),
|
||||
requesterAvatarSrc: row.requester_avatar_src || '',
|
||||
sourceTierListId: row.source_tierlist_id,
|
||||
sourceGameId: row.source_game_id,
|
||||
sourceGameName: row.source_game_name || '',
|
||||
sourceTierListTitle: row.title_snapshot || '',
|
||||
sourceDescription: row.description_snapshot || '',
|
||||
thumbnailSrc: row.thumbnail_src_snapshot || '',
|
||||
targetGameId: row.target_game_id || '',
|
||||
targetGameName: row.target_game_name || '',
|
||||
status: row.status,
|
||||
items: parseJson(row.items_json, []),
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
}
|
||||
}
|
||||
|
||||
function getUserDisplayName(row) {
|
||||
if (!row) return ''
|
||||
const nickname = (row.nickname || '').trim()
|
||||
@@ -200,6 +269,10 @@ async function ensureSchema() {
|
||||
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL,
|
||||
is_public TINYINT(1) NOT NULL DEFAULT 0,
|
||||
show_character_names TINYINT(1) NOT NULL DEFAULT 0,
|
||||
source_tierlist_id VARCHAR(64) NULL DEFAULT NULL,
|
||||
source_snapshot_title VARCHAR(120) NOT NULL DEFAULT '',
|
||||
source_snapshot_author VARCHAR(120) NOT NULL DEFAULT '',
|
||||
groups_json LONGTEXT NOT NULL,
|
||||
pool_json LONGTEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
@@ -224,10 +297,93 @@ async function ensureSchema() {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS favorite_games (
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
game_id VARCHAR(120) NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (user_id, game_id),
|
||||
INDEX idx_favorite_games_game_id (game_id),
|
||||
CONSTRAINT fk_favorite_games_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_favorite_games_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS image_assets (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
content_hash CHAR(64) NOT NULL UNIQUE,
|
||||
src VARCHAR(255) NOT NULL UNIQUE,
|
||||
mime_type VARCHAR(32) NOT NULL DEFAULT 'image/webp',
|
||||
byte_size INT UNSIGNED NOT NULL,
|
||||
original_byte_size INT UNSIGNED NOT NULL,
|
||||
width INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
height INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
created_at BIGINT NOT NULL,
|
||||
INDEX idx_image_assets_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS image_optimization_jobs (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'queued',
|
||||
source_category VARCHAR(40) NOT NULL DEFAULT '',
|
||||
target_directory VARCHAR(40) NOT NULL DEFAULT '',
|
||||
original_byte_size INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
optimized_byte_size INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
reused_asset TINYINT(1) NOT NULL DEFAULT 0,
|
||||
error_message VARCHAR(255) NOT NULL DEFAULT '',
|
||||
queued_at BIGINT NOT NULL,
|
||||
started_at BIGINT NOT NULL DEFAULT 0,
|
||||
finished_at BIGINT NOT NULL DEFAULT 0,
|
||||
INDEX idx_image_optimization_jobs_status_queued (status, queued_at),
|
||||
INDEX idx_image_optimization_jobs_finished_at (finished_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS template_requests (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
request_type VARCHAR(20) NOT NULL,
|
||||
requester_id VARCHAR(64) NOT NULL,
|
||||
source_tierlist_id VARCHAR(64) NOT NULL,
|
||||
source_game_id VARCHAR(120) NOT NULL,
|
||||
target_game_id VARCHAR(120) NOT NULL DEFAULT '',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
title_snapshot VARCHAR(120) NOT NULL,
|
||||
description_snapshot TEXT NOT NULL,
|
||||
thumbnail_src_snapshot VARCHAR(255) NOT NULL DEFAULT '',
|
||||
items_json LONGTEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
INDEX idx_template_requests_status_created (status, created_at),
|
||||
INDEX idx_template_requests_source_tierlist (source_tierlist_id),
|
||||
INDEX idx_template_requests_requester (requester_id),
|
||||
CONSTRAINT fk_template_requests_requester FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_template_requests_source_tierlist FOREIGN KEY (source_tierlist_id) REFERENCES tierlists(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
const tierListThumbnailColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'thumbnail_src'")
|
||||
if (!tierListThumbnailColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
|
||||
}
|
||||
const tierListShowNamesColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'show_character_names'")
|
||||
if (!tierListShowNamesColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
|
||||
}
|
||||
const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'")
|
||||
if (!tierListSourceIdColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER show_character_names")
|
||||
}
|
||||
const tierListSourceTitleColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_title'")
|
||||
if (!tierListSourceTitleColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN source_snapshot_title VARCHAR(120) NOT NULL DEFAULT '' AFTER source_tierlist_id")
|
||||
}
|
||||
const tierListSourceAuthorColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_author'")
|
||||
if (!tierListSourceAuthorColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN source_snapshot_author VARCHAR(120) NOT NULL DEFAULT '' AFTER source_snapshot_title")
|
||||
}
|
||||
|
||||
await query(
|
||||
`
|
||||
@@ -326,19 +482,44 @@ async function updateUserProfile({ id, nickname, avatarSrc }) {
|
||||
}
|
||||
|
||||
async function listUsers() {
|
||||
const rows = await query(
|
||||
'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users ORDER BY created_at ASC, email ASC'
|
||||
)
|
||||
const rows = await query(`
|
||||
SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
u.nickname,
|
||||
u.is_admin,
|
||||
u.avatar_src,
|
||||
u.created_at,
|
||||
COUNT(t.id) AS tierlist_count,
|
||||
GREATEST(
|
||||
u.created_at,
|
||||
COALESCE(MAX(t.updated_at), 0)
|
||||
) AS recent_activity_at
|
||||
FROM users u
|
||||
LEFT JOIN tierlists t ON t.author_id = u.id
|
||||
GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at
|
||||
ORDER BY recent_activity_at DESC, u.created_at ASC, u.email ASC
|
||||
`)
|
||||
return rows.map(mapUserRow)
|
||||
}
|
||||
|
||||
async function adminUpdateUser({ id, email, nickname, isAdmin }) {
|
||||
await query('UPDATE users SET email = ?, nickname = ?, is_admin = ? WHERE id = ?', [
|
||||
email,
|
||||
nickname || '',
|
||||
isAdmin ? 1 : 0,
|
||||
id,
|
||||
])
|
||||
async function adminUpdateUser({ id, email, nickname, isAdmin, avatarSrc }) {
|
||||
if (typeof avatarSrc === 'string') {
|
||||
await query('UPDATE users SET email = ?, nickname = ?, is_admin = ?, avatar_src = ? WHERE id = ?', [
|
||||
email,
|
||||
nickname || '',
|
||||
isAdmin ? 1 : 0,
|
||||
avatarSrc,
|
||||
id,
|
||||
])
|
||||
} else {
|
||||
await query('UPDATE users SET email = ?, nickname = ?, is_admin = ? WHERE id = ?', [
|
||||
email,
|
||||
nickname || '',
|
||||
isAdmin ? 1 : 0,
|
||||
id,
|
||||
])
|
||||
}
|
||||
return findUserById(id)
|
||||
}
|
||||
|
||||
@@ -351,7 +532,7 @@ async function adminDeleteUser(id) {
|
||||
await query('DELETE FROM users WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
async function listGames() {
|
||||
async function listGames(currentUserId = '') {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT id, name, thumbnail_src, display_rank, created_at
|
||||
@@ -365,7 +546,15 @@ async function listGames() {
|
||||
`,
|
||||
[FREEFORM_GAME_ID]
|
||||
)
|
||||
return rows.map(mapGameRow)
|
||||
const games = rows.map(mapGameRow)
|
||||
if (!currentUserId) return games.map((game) => ({ ...game, isFavorited: false }))
|
||||
|
||||
const favoriteRows = await query('SELECT game_id FROM favorite_games WHERE user_id = ?', [currentUserId])
|
||||
const favoriteSet = new Set(favoriteRows.map((row) => row.game_id))
|
||||
return games.map((game) => ({
|
||||
...game,
|
||||
isFavorited: favoriteSet.has(game.id),
|
||||
}))
|
||||
}
|
||||
|
||||
async function findGameById(id) {
|
||||
@@ -404,6 +593,118 @@ 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 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) {
|
||||
const safeLimit = Math.max(1, Math.min(100, Number(limit) || 20))
|
||||
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 ORDER BY queued_at DESC LIMIT ${safeLimit}`
|
||||
)
|
||||
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 createGameItem({ id, gameId, src, label }) {
|
||||
const createdAt = now()
|
||||
await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
|
||||
@@ -485,6 +786,24 @@ async function createCustomItem({ id, ownerId, src, label }) {
|
||||
return { id, ownerId, src, label, origin: 'custom', createdAt }
|
||||
}
|
||||
|
||||
async function syncOwnedCustomItemLabels({ ownerId, items }) {
|
||||
const customItems = Array.from(
|
||||
new Map(
|
||||
(items || [])
|
||||
.filter((item) => item?.origin === 'custom' && item?.id && typeof item.label === 'string')
|
||||
.map((item) => [item.id, item])
|
||||
).values()
|
||||
)
|
||||
|
||||
if (!customItems.length) return
|
||||
|
||||
await Promise.all(
|
||||
customItems.map((item) =>
|
||||
query('UPDATE custom_items SET label = ? WHERE id = ? AND owner_id = ?', [item.label.trim().slice(0, 60), item.id, ownerId])
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async function findCustomItemById(id) {
|
||||
const rows = await query(
|
||||
`
|
||||
@@ -507,28 +826,51 @@ async function findCustomItemById(id) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getCustomItemUsageMap() {
|
||||
const rows = await query('SELECT groups_json, pool_json FROM tierlists')
|
||||
async function getCustomItemUsageMeta() {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT t.game_id, g.name AS game_name, t.groups_json, t.pool_json
|
||||
FROM tierlists t
|
||||
LEFT JOIN games g ON g.id = t.game_id
|
||||
`
|
||||
)
|
||||
const usageMap = new Map()
|
||||
const linkedGamesMap = new Map()
|
||||
|
||||
rows.forEach((row) => {
|
||||
const groups = parseJson(row.groups_json, [])
|
||||
const pool = parseJson(row.pool_json, [])
|
||||
const seenItemIds = new Set()
|
||||
|
||||
groups.forEach((group) => {
|
||||
;(group?.itemIds || []).forEach((itemId) => {
|
||||
usageMap.set(itemId, (usageMap.get(itemId) || 0) + 1)
|
||||
if (itemId) seenItemIds.add(itemId)
|
||||
})
|
||||
})
|
||||
|
||||
pool.forEach((item) => {
|
||||
if (item?.id) {
|
||||
usageMap.set(item.id, (usageMap.get(item.id) || 0) + 1)
|
||||
seenItemIds.add(item.id)
|
||||
}
|
||||
})
|
||||
|
||||
if (!row.game_id) return
|
||||
|
||||
seenItemIds.forEach((itemId) => {
|
||||
if (!linkedGamesMap.has(itemId)) linkedGamesMap.set(itemId, new Map())
|
||||
linkedGamesMap.get(itemId).set(row.game_id, {
|
||||
id: row.game_id,
|
||||
name: row.game_name || row.game_id,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return usageMap
|
||||
return {
|
||||
usageMap,
|
||||
linkedGamesMap: new Map(Array.from(linkedGamesMap.entries()).map(([itemId, gameMap]) => [itemId, Array.from(gameMap.values())])),
|
||||
}
|
||||
}
|
||||
|
||||
async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnly = false } = {}) {
|
||||
@@ -557,7 +899,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
|
||||
params
|
||||
)
|
||||
|
||||
const usageMap = await getCustomItemUsageMap()
|
||||
const { usageMap, linkedGamesMap } = await getCustomItemUsageMeta()
|
||||
const allItems = rows
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
@@ -568,6 +910,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
|
||||
ownerName: row.nickname || row.email,
|
||||
ownerEmail: row.email,
|
||||
usageCount: usageMap.get(row.id) || 0,
|
||||
linkedGames: linkedGamesMap.get(row.id) || [],
|
||||
}))
|
||||
.filter((item) => (orphanOnly ? item.usageCount === 0 : true))
|
||||
|
||||
@@ -607,7 +950,7 @@ async function findUnusedCustomItems({ queryText = '' } = {}) {
|
||||
params
|
||||
)
|
||||
|
||||
const usageMap = await getCustomItemUsageMap()
|
||||
const { usageMap } = await getCustomItemUsageMeta()
|
||||
return rows
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
@@ -751,6 +1094,10 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
|
||||
t.thumbnail_src,
|
||||
t.description,
|
||||
t.is_public,
|
||||
t.show_character_names,
|
||||
t.source_tierlist_id,
|
||||
t.source_snapshot_title,
|
||||
t.source_snapshot_author,
|
||||
t.groups_json,
|
||||
t.pool_json,
|
||||
t.created_at,
|
||||
@@ -838,6 +1185,20 @@ function uniqueTierListItems(poolItems) {
|
||||
return Array.from(map.values())
|
||||
}
|
||||
|
||||
function getAutoThumbnailSrc(groups = [], pool = []) {
|
||||
const itemMap = new Map((pool || []).filter((item) => item?.id && item?.src).map((item) => [item.id, item]))
|
||||
|
||||
for (const group of groups || []) {
|
||||
for (const itemId of group?.itemIds || []) {
|
||||
const item = itemMap.get(itemId)
|
||||
if (item?.src) return item.src
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackItem = (pool || []).find((item) => item?.src)
|
||||
return fallbackItem?.src || ''
|
||||
}
|
||||
|
||||
async function listAdminTierLists({ queryText = '', page = 1, limit = 50, currentUserId = '' } = {}) {
|
||||
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||
const normalizedPage = Math.max(Number(page) || 1, 1)
|
||||
@@ -866,6 +1227,10 @@ async function listAdminTierLists({ queryText = '', page = 1, limit = 50, curren
|
||||
t.thumbnail_src,
|
||||
t.description,
|
||||
t.is_public,
|
||||
t.show_character_names,
|
||||
t.source_tierlist_id,
|
||||
t.source_snapshot_title,
|
||||
t.source_snapshot_author,
|
||||
t.groups_json,
|
||||
t.pool_json,
|
||||
t.created_at,
|
||||
@@ -921,6 +1286,10 @@ async function findTierListById(id, currentUserId = '') {
|
||||
t.thumbnail_src,
|
||||
t.description,
|
||||
t.is_public,
|
||||
t.show_character_names,
|
||||
t.source_tierlist_id,
|
||||
t.source_snapshot_title,
|
||||
t.source_snapshot_author,
|
||||
t.groups_json,
|
||||
t.pool_json,
|
||||
t.created_at,
|
||||
@@ -942,6 +1311,151 @@ async function findTierListById(id, currentUserId = '') {
|
||||
return applyFavoriteMetaToTierLists([tierList], favoriteStats)[0]
|
||||
}
|
||||
|
||||
async function findPendingTemplateRequestByTierList({ sourceTierListId, type }) {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT id, request_type, status
|
||||
FROM template_requests
|
||||
WHERE source_tierlist_id = ? AND request_type = ? AND status = 'pending'
|
||||
LIMIT 1
|
||||
`,
|
||||
[sourceTierListId, type]
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function createTemplateRequest({
|
||||
id,
|
||||
type,
|
||||
requesterId,
|
||||
sourceTierListId,
|
||||
sourceGameId,
|
||||
targetGameId = '',
|
||||
title,
|
||||
description = '',
|
||||
thumbnailSrc = '',
|
||||
items = [],
|
||||
}) {
|
||||
const existing = await findPendingTemplateRequestByTierList({ sourceTierListId, type })
|
||||
if (existing) {
|
||||
const err = new Error('template_request_exists')
|
||||
err.code = 'TEMPLATE_REQUEST_EXISTS'
|
||||
throw err
|
||||
}
|
||||
|
||||
const createdAt = now()
|
||||
await query(
|
||||
`
|
||||
INSERT INTO template_requests (
|
||||
id,
|
||||
request_type,
|
||||
requester_id,
|
||||
source_tierlist_id,
|
||||
source_game_id,
|
||||
target_game_id,
|
||||
status,
|
||||
title_snapshot,
|
||||
description_snapshot,
|
||||
thumbnail_src_snapshot,
|
||||
items_json,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
id,
|
||||
type,
|
||||
requesterId,
|
||||
sourceTierListId,
|
||||
sourceGameId,
|
||||
targetGameId,
|
||||
title,
|
||||
description,
|
||||
thumbnailSrc,
|
||||
serializeJson(items),
|
||||
createdAt,
|
||||
createdAt,
|
||||
]
|
||||
)
|
||||
return findTemplateRequestById(id)
|
||||
}
|
||||
|
||||
async function findTemplateRequestById(id) {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT
|
||||
tr.id,
|
||||
tr.request_type,
|
||||
tr.requester_id,
|
||||
tr.source_tierlist_id,
|
||||
tr.source_game_id,
|
||||
tr.target_game_id,
|
||||
tr.status,
|
||||
tr.title_snapshot,
|
||||
tr.description_snapshot,
|
||||
tr.thumbnail_src_snapshot,
|
||||
tr.items_json,
|
||||
tr.created_at,
|
||||
tr.updated_at,
|
||||
u.nickname,
|
||||
u.email,
|
||||
u.avatar_src AS requester_avatar_src,
|
||||
sg.name AS source_game_name,
|
||||
tg.name AS target_game_name
|
||||
FROM template_requests tr
|
||||
INNER JOIN users u ON u.id = tr.requester_id
|
||||
LEFT JOIN games sg ON sg.id = tr.source_game_id
|
||||
LEFT JOIN games tg ON tg.id = tr.target_game_id
|
||||
WHERE tr.id = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
[id]
|
||||
)
|
||||
|
||||
return mapTemplateRequestRow(rows[0])
|
||||
}
|
||||
|
||||
async function listAdminTemplateRequests({ status = 'pending' } = {}) {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT
|
||||
tr.id,
|
||||
tr.request_type,
|
||||
tr.requester_id,
|
||||
tr.source_tierlist_id,
|
||||
tr.source_game_id,
|
||||
tr.target_game_id,
|
||||
tr.status,
|
||||
tr.title_snapshot,
|
||||
tr.description_snapshot,
|
||||
tr.thumbnail_src_snapshot,
|
||||
tr.items_json,
|
||||
tr.created_at,
|
||||
tr.updated_at,
|
||||
u.nickname,
|
||||
u.email,
|
||||
u.avatar_src AS requester_avatar_src,
|
||||
sg.name AS source_game_name,
|
||||
tg.name AS target_game_name
|
||||
FROM template_requests tr
|
||||
INNER JOIN users u ON u.id = tr.requester_id
|
||||
LEFT JOIN games sg ON sg.id = tr.source_game_id
|
||||
LEFT JOIN games tg ON tg.id = tr.target_game_id
|
||||
WHERE tr.status = ?
|
||||
ORDER BY tr.created_at DESC
|
||||
`,
|
||||
[status]
|
||||
)
|
||||
|
||||
return rows.map(mapTemplateRequestRow)
|
||||
}
|
||||
|
||||
async function updateTemplateRequestStatus({ id, status }) {
|
||||
await query('UPDATE template_requests SET status = ?, updated_at = ? WHERE id = ?', [status, now(), id])
|
||||
return findTemplateRequestById(id)
|
||||
}
|
||||
|
||||
async function deleteTierList(id) {
|
||||
await query('DELETE FROM tierlists WHERE id = ?', [id])
|
||||
}
|
||||
@@ -973,17 +1487,33 @@ async function deleteCustomItems(ids) {
|
||||
await query(`DELETE FROM custom_items WHERE id IN (${placeholders})`, ids)
|
||||
}
|
||||
|
||||
async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', description, isPublic, groups, pool }) {
|
||||
async function saveTierList({
|
||||
id,
|
||||
authorId,
|
||||
gameId,
|
||||
title,
|
||||
thumbnailSrc = '',
|
||||
description,
|
||||
isPublic,
|
||||
showCharacterNames = false,
|
||||
sourceTierListId = '',
|
||||
sourceSnapshotTitle = '',
|
||||
sourceSnapshotAuthor = '',
|
||||
groups,
|
||||
pool,
|
||||
}) {
|
||||
const existing = id ? await findTierListById(id, authorId) : null
|
||||
await syncOwnedCustomItemLabels({ ownerId: authorId, items: pool })
|
||||
const nextThumbnailSrc = (thumbnailSrc || '').trim() || getAutoThumbnailSrc(groups, pool)
|
||||
|
||||
if (existing) {
|
||||
await query(
|
||||
`
|
||||
UPDATE tierlists
|
||||
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, groups_json = ?, pool_json = ?, updated_at = ?
|
||||
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, show_character_names = ?, source_tierlist_id = ?, source_snapshot_title = ?, source_snapshot_author = ?, groups_json = ?, pool_json = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
[title, thumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id]
|
||||
[title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), now(), existing.id]
|
||||
)
|
||||
return findTierListById(existing.id, authorId)
|
||||
}
|
||||
@@ -992,15 +1522,37 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de
|
||||
await query(
|
||||
`
|
||||
INSERT INTO tierlists (
|
||||
id, author_id, game_id, title, thumbnail_src, description, is_public, groups_json, pool_json, created_at, updated_at
|
||||
id, author_id, game_id, title, thumbnail_src, description, is_public, show_character_names, source_tierlist_id, source_snapshot_title, source_snapshot_author, groups_json, pool_json, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[id, authorId, gameId, title, thumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
|
||||
[id, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt]
|
||||
)
|
||||
return findTierListById(id, authorId)
|
||||
}
|
||||
|
||||
async function duplicateTierListForUser({ tierList, targetUserId }) {
|
||||
const { nanoid } = require('nanoid')
|
||||
const duplicateId = nanoid()
|
||||
const baseTitle = (tierList.title || '티어표').trim() || '티어표'
|
||||
const copyTitle = baseTitle.endsWith(' 복사본') ? baseTitle : `${baseTitle} 복사본`
|
||||
return saveTierList({
|
||||
id: duplicateId,
|
||||
authorId: targetUserId,
|
||||
gameId: tierList.gameId,
|
||||
title: copyTitle,
|
||||
thumbnailSrc: tierList.thumbnailSrc || '',
|
||||
description: tierList.description || '',
|
||||
isPublic: false,
|
||||
showCharacterNames: !!tierList.showCharacterNames,
|
||||
sourceTierListId: tierList.id,
|
||||
sourceSnapshotTitle: tierList.title || '',
|
||||
sourceSnapshotAuthor: tierList.authorName || tierList.authorAccountName || '',
|
||||
groups: JSON.parse(JSON.stringify(tierList.groups || [])),
|
||||
pool: JSON.parse(JSON.stringify(tierList.pool || [])),
|
||||
})
|
||||
}
|
||||
|
||||
async function favoriteTierList({ userId, tierListId }) {
|
||||
await query('INSERT IGNORE INTO favorite_tierlists (user_id, tierlist_id, created_at) VALUES (?, ?, ?)', [userId, tierListId, now()])
|
||||
}
|
||||
@@ -1009,6 +1561,14 @@ async function unfavoriteTierList({ userId, tierListId }) {
|
||||
await query('DELETE FROM favorite_tierlists WHERE user_id = ? AND tierlist_id = ?', [userId, tierListId])
|
||||
}
|
||||
|
||||
async function favoriteGame({ userId, gameId }) {
|
||||
await query('INSERT IGNORE INTO favorite_games (user_id, game_id, created_at) VALUES (?, ?, ?)', [userId, gameId, now()])
|
||||
}
|
||||
|
||||
async function unfavoriteGame({ userId, gameId }) {
|
||||
await query('DELETE FROM favorite_games WHERE user_id = ? AND game_id = ?', [userId, gameId])
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DB_NAME,
|
||||
ensureData,
|
||||
@@ -1027,6 +1587,14 @@ module.exports = {
|
||||
getGameDetail,
|
||||
createGame,
|
||||
updateGameThumbnail,
|
||||
findImageAssetByHash,
|
||||
createImageAsset,
|
||||
createImageOptimizationJob,
|
||||
findImageOptimizationJobById,
|
||||
updateImageOptimizationJobStatus,
|
||||
listRecentImageOptimizationJobs,
|
||||
listUnusedImageAssets,
|
||||
deleteImageAssets,
|
||||
createGameItem,
|
||||
updateGameItemLabel,
|
||||
deleteGameItem,
|
||||
@@ -1043,8 +1611,15 @@ module.exports = {
|
||||
findTierListById,
|
||||
favoriteTierList,
|
||||
unfavoriteTierList,
|
||||
favoriteGame,
|
||||
unfavoriteGame,
|
||||
deleteTierList,
|
||||
findCustomItemsByIds,
|
||||
deleteCustomItems,
|
||||
saveTierList,
|
||||
duplicateTierListForUser,
|
||||
createTemplateRequest,
|
||||
findTemplateRequestById,
|
||||
listAdminTemplateRequests,
|
||||
updateTemplateRequestStatus,
|
||||
}
|
||||
|
||||
218
backend/src/lib/image-storage.js
Normal 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,
|
||||
}
|
||||
@@ -24,11 +24,17 @@ const {
|
||||
listUsers,
|
||||
listAdminTierLists,
|
||||
findTierListById,
|
||||
listAdminTemplateRequests,
|
||||
findTemplateRequestById,
|
||||
updateTemplateRequestStatus,
|
||||
adminUpdateUser,
|
||||
adminUpdateUserPassword,
|
||||
adminDeleteUser,
|
||||
listUnusedImageAssets,
|
||||
deleteImageAssets,
|
||||
} = require('../db')
|
||||
const { requireAdmin } = require('../middleware/auth')
|
||||
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
@@ -50,13 +56,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 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) })
|
||||
@@ -86,7 +87,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 })
|
||||
})
|
||||
|
||||
@@ -96,18 +107,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 })
|
||||
@@ -181,6 +203,51 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.get('/template-requests', requireAdmin, async (req, res) => {
|
||||
const requests = await listAdminTemplateRequests({ status: 'pending' })
|
||||
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 })
|
||||
})
|
||||
|
||||
async function removeCustomItemFiles(items) {
|
||||
await Promise.all(
|
||||
items.map(async (item) => {
|
||||
@@ -196,33 +263,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) {
|
||||
@@ -255,6 +307,24 @@ async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) {
|
||||
return createdItems
|
||||
}
|
||||
|
||||
async function promoteSnapshotItemsToGame({ items, gameId }) {
|
||||
const createdItems = []
|
||||
|
||||
for (const item of items || []) {
|
||||
const copiedSrc = await copyUploadIntoGameAsset(item.src)
|
||||
createdItems.push(
|
||||
await createGameItem({
|
||||
id: nanoid(),
|
||||
gameId,
|
||||
src: copiedSrc,
|
||||
label: item.label,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return createdItems
|
||||
}
|
||||
|
||||
async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
|
||||
await createGame({ id: gameId, name: gameName })
|
||||
if (tierList.thumbnailSrc) {
|
||||
@@ -278,6 +348,22 @@ async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
|
||||
return { game: await findGameById(gameId), items: createdItems }
|
||||
}
|
||||
|
||||
async function createGameTemplateFromRequest({ templateRequest, gameId, gameName }) {
|
||||
await createGame({ id: gameId, name: gameName })
|
||||
|
||||
if (templateRequest.thumbnailSrc) {
|
||||
const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc)
|
||||
await updateGameThumbnail(gameId, copiedThumb)
|
||||
}
|
||||
|
||||
const items = await promoteSnapshotItemsToGame({
|
||||
items: templateRequest.items || [],
|
||||
gameId,
|
||||
})
|
||||
|
||||
return { game: await findGameById(gameId), items }
|
||||
}
|
||||
|
||||
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||
const result = await listCustomItems({ page: 1, limit: 200, orphanOnly: false })
|
||||
const target = result.items.find((item) => item.id === req.params.itemId)
|
||||
@@ -355,6 +441,52 @@ router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.post('/template-requests/:requestId/approve', requireAdmin, async (req, res) => {
|
||||
const templateRequest = await findTemplateRequestById(req.params.requestId)
|
||||
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
|
||||
if (templateRequest.status !== 'pending') return res.status(409).json({ error: 'request_already_handled' })
|
||||
|
||||
if (templateRequest.type === 'update') {
|
||||
const targetGameId = templateRequest.targetGameId || templateRequest.sourceGameId
|
||||
const game = await findGameById(targetGameId)
|
||||
if (!game) return res.status(404).json({ error: 'game_not_found' })
|
||||
|
||||
const items = await promoteSnapshotItemsToGame({
|
||||
items: templateRequest.items || [],
|
||||
gameId: game.id,
|
||||
})
|
||||
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
|
||||
return res.json({ request, items })
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
gameId: z.string().trim().min(1).max(120),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const exists = await findGameById(parsed.data.gameId)
|
||||
if (exists) return res.status(409).json({ error: 'game_id_taken' })
|
||||
|
||||
const result = await createGameTemplateFromRequest({
|
||||
templateRequest,
|
||||
gameId: parsed.data.gameId,
|
||||
gameName: parsed.data.name,
|
||||
})
|
||||
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
|
||||
res.json({ request, ...result })
|
||||
})
|
||||
|
||||
router.post('/template-requests/:requestId/reject', requireAdmin, async (req, res) => {
|
||||
const templateRequest = await findTemplateRequestById(req.params.requestId)
|
||||
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
|
||||
if (templateRequest.status !== 'pending') return res.status(409).json({ error: 'request_already_handled' })
|
||||
|
||||
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'rejected' })
|
||||
res.json({ request })
|
||||
})
|
||||
|
||||
router.delete('/custom-items', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
@@ -406,6 +538,39 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar'), async (req, res) => {
|
||||
const schema = z.object({
|
||||
removeAvatar: z.union([z.literal('1'), z.undefined()]).optional(),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const 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 ? '' : optimized?.src || user.avatarSrc || ''
|
||||
const updated = await adminUpdateUser({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nickname: user.nickname || '',
|
||||
isAdmin: !!user.isAdmin,
|
||||
avatarSrc: nextAvatarSrc,
|
||||
})
|
||||
|
||||
res.json({ user: updated })
|
||||
})
|
||||
|
||||
router.delete('/users/:userId', requireAdmin, async (req, res) => {
|
||||
if (req.params.userId === req.session.userId) {
|
||||
return res.status(400).json({ error: 'cannot_delete_self' })
|
||||
|
||||
@@ -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),
|
||||
@@ -28,6 +22,7 @@ const signupSchema = z.object({
|
||||
|
||||
const profileSchema = z.object({
|
||||
nickname: z.string().trim().min(1).max(40),
|
||||
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
|
||||
})
|
||||
|
||||
router.post('/signup', async (req, res) => {
|
||||
@@ -44,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)
|
||||
@@ -93,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)
|
||||
@@ -108,7 +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 nextAvatarSrc = req.file ? `/uploads/avatars/${req.file.filename}` : user.avatarSrc || ''
|
||||
const optimized = req.file
|
||||
? await writeOptimizedImage({
|
||||
file: req.file,
|
||||
directory: 'avatars',
|
||||
width: 512,
|
||||
height: 512,
|
||||
fit: 'cover',
|
||||
quality: 82,
|
||||
})
|
||||
: null
|
||||
|
||||
const shouldRemoveAvatar = parsed.data.removeAvatar === '1'
|
||||
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || ''
|
||||
const updated = await updateUserProfile({
|
||||
id: user.id,
|
||||
nickname: parsed.data.nickname,
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
const express = require('express')
|
||||
const { listGames, getGameDetail } = require('../db')
|
||||
const { listGames, getGameDetail, findGameById, favoriteGame, unfavoriteGame } = require('../db')
|
||||
const { requireAuth } = require('../middleware/auth')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const games = await listGames()
|
||||
const games = await listGames(req.session?.userId || '')
|
||||
res.json({ games })
|
||||
})
|
||||
|
||||
router.post('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game || game.id === 'freeform') return res.status(404).json({ error: 'not_found' })
|
||||
await favoriteGame({ userId: req.session.userId, gameId: game.id })
|
||||
const games = await listGames(req.session.userId)
|
||||
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: true }
|
||||
res.json({ game: updated })
|
||||
})
|
||||
|
||||
router.delete('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game || game.id === 'freeform') return res.status(404).json({ error: 'not_found' })
|
||||
await unfavoriteGame({ userId: req.session.userId, gameId: game.id })
|
||||
const games = await listGames(req.session.userId)
|
||||
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: false }
|
||||
res.json({ game: updated })
|
||||
})
|
||||
|
||||
router.get('/:gameId', async (req, res) => {
|
||||
const detail = await getGameDetail(req.params.gameId)
|
||||
if (!detail) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const express = require('express')
|
||||
const path = require('path')
|
||||
const multer = require('multer')
|
||||
const { z } = require('zod')
|
||||
const { nanoid } = require('nanoid')
|
||||
@@ -11,13 +10,18 @@ const {
|
||||
deleteTierList,
|
||||
saveTierList,
|
||||
createCustomItem,
|
||||
createTemplateRequest,
|
||||
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'
|
||||
const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기'
|
||||
|
||||
function normalizePoolItem(item) {
|
||||
if (!item || item.origin !== 'game' || typeof item.src !== 'string') return item
|
||||
@@ -42,27 +46,17 @@ function normalizeTierList(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}`
|
||||
function getCustomTemplateItems(tierList) {
|
||||
const seen = new Set()
|
||||
return (tierList?.pool || []).filter((item) => {
|
||||
if (!item?.id || item.origin !== 'custom' || seen.has(item.id)) return false
|
||||
seen.add(item.id)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
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(),
|
||||
@@ -71,6 +65,10 @@ const tierListUpsertSchema = z.object({
|
||||
thumbnailSrc: z.string().max(255).optional().default(''),
|
||||
description: z.string().max(1000).optional().default(''),
|
||||
isPublic: z.boolean().default(false),
|
||||
showCharacterNames: z.boolean().optional().default(false),
|
||||
sourceTierListId: z.string().max(64).optional().default(''),
|
||||
sourceSnapshotTitle: z.string().max(120).optional().default(''),
|
||||
sourceSnapshotAuthor: z.string().max(120).optional().default(''),
|
||||
groups: z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
@@ -118,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' })
|
||||
@@ -153,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,
|
||||
})
|
||||
|
||||
@@ -165,7 +181,61 @@ router.post('/custom-items', requireAuth, upload.single('image'), async (req, re
|
||||
|
||||
router.post('/thumbnail', requireAuth, thumbnailUpload.single('thumbnail'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
||||
res.json({ thumbnailSrc: `/uploads/tierlists/${req.file.filename}` })
|
||||
|
||||
const optimized = await writeOptimizedImage({
|
||||
file: req.file,
|
||||
directory: 'tierlists',
|
||||
width: 1280,
|
||||
height: 1280,
|
||||
fit: 'inside',
|
||||
quality: 84,
|
||||
})
|
||||
|
||||
res.json({ thumbnailSrc: optimized.src })
|
||||
})
|
||||
|
||||
router.post('/:id/template-request', requireAuth, async (req, res) => {
|
||||
const schema = z.object({
|
||||
type: z.enum(['create', 'update']),
|
||||
requestTitle: z.string().trim().min(1).max(80),
|
||||
requestDescription: z.string().trim().min(1).max(240),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const tierList = await findTierListById(req.params.id, req.session.userId)
|
||||
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
||||
if (tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
|
||||
|
||||
const customItems = getCustomTemplateItems(tierList)
|
||||
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
|
||||
|
||||
if (parsed.data.type === 'create') {
|
||||
if (tierList.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
|
||||
} else {
|
||||
if (tierList.gameId === FREEFORM_GAME_ID) return res.status(400).json({ error: 'game_template_required' })
|
||||
}
|
||||
|
||||
try {
|
||||
const request = await createTemplateRequest({
|
||||
id: nanoid(),
|
||||
type: parsed.data.type,
|
||||
requesterId: req.session.userId,
|
||||
sourceTierListId: tierList.id,
|
||||
sourceGameId: tierList.gameId,
|
||||
targetGameId: parsed.data.type === 'update' ? tierList.gameId : '',
|
||||
title: parsed.data.requestTitle,
|
||||
description: parsed.data.requestDescription,
|
||||
thumbnailSrc: tierList.thumbnailSrc || '',
|
||||
items: customItems,
|
||||
})
|
||||
return res.json({ request })
|
||||
} catch (e) {
|
||||
if (e?.code === 'TEMPLATE_REQUEST_EXISTS') {
|
||||
return res.status(409).json({ error: 'template_request_exists' })
|
||||
}
|
||||
throw e
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', requireAuth, async (req, res) => {
|
||||
@@ -187,6 +257,10 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
thumbnailSrc: payload.thumbnailSrc || '',
|
||||
description: payload.description || '',
|
||||
isPublic: !!payload.isPublic,
|
||||
showCharacterNames: !!payload.showCharacterNames,
|
||||
sourceTierListId: payload.sourceTierListId || existing.sourceTierListId || '',
|
||||
sourceSnapshotTitle: payload.sourceSnapshotTitle || existing.sourceSnapshotTitle || '',
|
||||
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || existing.sourceSnapshotAuthor || '',
|
||||
groups: payload.groups,
|
||||
pool: normalizedPool,
|
||||
})
|
||||
@@ -201,6 +275,10 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
thumbnailSrc: payload.thumbnailSrc || '',
|
||||
description: payload.description || '',
|
||||
isPublic: !!payload.isPublic,
|
||||
showCharacterNames: !!payload.showCharacterNames,
|
||||
sourceTierListId: payload.sourceTierListId || '',
|
||||
sourceSnapshotTitle: payload.sourceSnapshotTitle || '',
|
||||
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || '',
|
||||
groups: payload.groups,
|
||||
pool: normalizedPool,
|
||||
})
|
||||
|
||||
297
docs/history.md
@@ -1,5 +1,156 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-03-30 v1.2.25
|
||||
- 홈 게임 카드는 메인 썸네일까지 없애는 것보다, 큰 썸네일은 유지하고 ID 옆의 작은 보조 표시만 제거하는 편이 원래 의도와 맞다고 정리했다.
|
||||
- 좌우 하단 액션 여백은 `margin`으로 푸터 전체를 밀기보다, 푸터 내부 `padding-bottom`으로 확보해야 버튼 자체는 항상 보이고 여백만 남는다고 판단했다.
|
||||
|
||||
## 2026-03-30 v1.2.24
|
||||
- `내 티어표` 헤더의 저장 개수 stat은 정보 가치보다 시각 잡음이 더 크다고 보고, 제목/설명 중심 헤더로 단순화하는 편이 낫다고 정리했다.
|
||||
- 게임 선택 카드는 티어표 카드와 달리 템플릿 선택 진입점이므로, 썸네일까지 반복하기보다 제목과 ID만 간결하게 보여주는 편이 더 적합하다고 판단했다.
|
||||
- 좌우 하단 액션 버튼은 푸터 블록 안에 있더라도 화면 바닥에 너무 붙으면 무거워 보이므로, 추가 하단 여백을 두어 숨을 쉬게 하는 편이 낫다고 정리했다.
|
||||
|
||||
## 2026-03-30 v1.2.23
|
||||
- 홈 화면의 게임 카드도 다른 목록 카드와 같은 밀도를 따라가야 하므로, 메인 라이브러리 역시 데스크톱 기본 4열을 기준으로 두는 편이 더 일관되다고 정리했다.
|
||||
- 게임 허브에서 새 티어표 만들기 버튼이 본문과 우측 패널에 동시에 있으면 역할이 겹치므로, 생성 CTA는 우측 사이드 하나만 남기는 편이 맞다고 판단했다.
|
||||
- 좌우 레일 액션 버튼은 스크롤되는 본문 안보다 독립된 하단 `56px` 푸터 영역에 놓는 편이 위치 인지가 더 안정적이라고 정리했다.
|
||||
|
||||
## 2026-03-30 v1.2.22
|
||||
- 왼쪽 레일은 홈/목록/에디터 어디서든 “사라지는 패널”보다 “축소된 내비 레일”로 읽히는 편이 구조적으로 더 일관되므로, 완전 숨김 대신 아이콘 중심 축소 상태를 유지하기로 했다.
|
||||
- 좌우 패널 토글은 상태마다 다른 아이콘이 바뀌기보다 방향만 고정하는 편이 덜 혼란스러우므로, 우측은 `dock_to_left`, 좌측은 `dock_to_right` 하나로 통일하기로 정리했다.
|
||||
- 좌측 검색도 임시 선형 SVG보다 실제 에셋을 쓰는 편이 전체 레일 완성도가 높으므로, 사용자가 추가한 `search.svg`를 우선 적용하기로 했다.
|
||||
|
||||
## 2026-03-30 v1.2.21
|
||||
- 티어표 목록 카드는 페이지마다 다른 메타 구성을 두기보다, `제목+좋아요 / 작성자+최종 수정일` 두 줄 문법으로 통일하는 편이 시안과 사용성 모두에 더 맞다고 정리했다.
|
||||
- `내 즐겨찾기` 화면에서 “즐겨찾기한 날짜”는 컬렉션 내부 정보일 뿐 카드 핵심 정보는 아니므로, 정렬은 유지하되 카드에는 마지막 수정일만 보여주는 편이 더 읽기 쉽다고 판단했다.
|
||||
- 좌측 `Favorites`는 메인 내비보다 보조 영역이어야 하므로, 같은 공간 안에서도 더 작은 썸네일·더 작은 텍스트·더 약한 대비로 눌러두는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-03-30 v1.2.20
|
||||
- 전역 검색 입력이 이미 좌측 레일에 고정되어 있으므로, 검색 결과 화면 안에 검색 폼을 또 두는 것은 중복이라고 판단해 `/search` 화면은 결과 표시 자체에만 집중시키기로 했다.
|
||||
- 중앙 워크스페이스는 셸 여백 위에 다시 큰 카드 테두리와 배경을 씌우면 시안보다 한 겹 더 두꺼워 보이므로, `workspaceBody`는 외곽 카드 없이 단일 여백 레이어만 유지하는 편이 더 맞다고 정리했다.
|
||||
- 우측 패널 토글은 같은 위치에 서로 다른 상태의 버튼이 번갈아 보여야 인지가 쉬우므로, 패널이 닫혀 있을 때는 중앙 헤더에 열기 버튼을 두고 열려 있을 때는 우측 헤더에 닫기 버튼을 두는 방식으로 정리했다.
|
||||
|
||||
## 2026-03-30 v1.2.19
|
||||
- 사용자 카드에서 프로필/로그아웃 팝업을 또 띄우는 구조는 좌측 `Settings` 메뉴와 역할이 겹치므로, 설정 진입은 메뉴 하나로만 통일하고 로그아웃은 설정 화면 안쪽에서 마무리하는 편이 더 명확하다고 정리했다.
|
||||
- 좌측 `Favorites`는 단순 링크보다 “내가 최근 좋아요한 실제 티어표 바로가기”를 보여주는 쪽이 시안과 사용성 모두에 더 가깝다고 판단해, 최근 10개만 노출하고 나머지는 `즐겨찾기 더 보기`로 보내기로 했다.
|
||||
- 좌측 검색은 페이지 내부 국소 검색보다 서비스 전체 공개 티어표 검색 진입점으로 쓰는 편이 더 자연스럽다고 판단해, 별도 `/search` 결과 화면을 두는 방향으로 정리했다.
|
||||
|
||||
## 2026-03-30 v1.2.18
|
||||
- 피그마 기준 상단 구조는 페이지마다 다르게 보이면 안 되므로, 좌/중앙/우 컬럼 모두 `56px` 헤더를 고정으로 두고 내용이 없을 때도 빈 헤더 공간을 유지하는 편이 맞다고 정리했다.
|
||||
- 사이트 브랜드는 좌측 레일 안쪽 카드가 아니라 중앙 워크스페이스 상단의 고정 헤더에 두는 쪽이 시안과 더 가깝고, 페이지 이동 시에도 더 일관되게 읽힌다고 판단했다.
|
||||
- 에디터 화면 안에서 `.layout`이 다시 좌우 컬럼을 만들면 공통 3단 셸과 충돌하므로, 에디터 본문은 셸이 제공한 중앙 컬럼 안에서만 레이아웃을 잡아야 한다고 정리했다.
|
||||
|
||||
## 2026-03-30 v1.2.17
|
||||
- 공통 오른쪽 레일을 쓰는 화면에서는 로컬 패널이 다시 외곽 래퍼 카드로 감싸지면 “오른쪽 레일 안의 또 다른 사이드”처럼 읽히므로, 에디터 우측 패널은 섹션들만 공통 레일 루트에 직접 배치하는 쪽이 더 일관적이라고 정리했다.
|
||||
- 에디터/관리자 공통 오른쪽 컬럼은 컨테이너를 화면별로 따로 꾸미기보다, 셸의 `localRightRailRoot`가 기본 스택 문법을 제공하고 각 화면은 내부 section만 채우는 방식으로 맞추기로 했다.
|
||||
|
||||
## 2026-03-30 v1.2.16
|
||||
- 홈 화면은 이동 경로가 이미 좌측/우측 사이드에 충분히 있으므로, 중앙 바디 상단에 상태 카드와 중복 버튼을 다시 두기보다 본문은 게임 카드에만 집중시키는 편이 더 낫다고 정리했다.
|
||||
- 오른쪽 사이드도 정보가 막막하다고 해서 임시 카드를 많이 넣기보다, 우선 핵심 CTA 하나만 남기고 나중에 필요한 항목만 추가하는 편이 시안과 운영 흐름 모두에 더 적합하다고 판단했다.
|
||||
|
||||
## 2026-03-30 v1.2.15
|
||||
- 리디자인 기준 구조는 화면마다 달라지면 안 되므로, 홈에서 보이는 `좌측 레일 / 중앙 / 우측 레일` 3단 셸을 일반 페이지 공통 뼈대로 고정하고 안쪽 콘텐츠만 바꾸는 방식으로 정리하기로 했다.
|
||||
- 에디터와 관리자의 우측 패널도 예외적인 바디 내부 aside가 아니라 공통 셸의 세 번째 컬럼을 공유해야 전체 제품 구조가 일관된다고 판단했다.
|
||||
|
||||
## 2026-03-30 v1.2.14
|
||||
- 에디터 우측 패널은 본문 내부 그리드의 일부가 아니라 공통 셸의 세 번째 컬럼이어야 메인 화면과 같은 구조로 읽히므로, Teleport로 셸 aside에 직접 붙이는 편이 맞다고 정리했다.
|
||||
- 로컬 우측 패널 화면에서 “메인 안쪽 2단 레이아웃”과 “셸 3단 레이아웃”을 섞으면 계속 혼선이 생기므로, 에디터는 셸 레벨 3단 구조를 우선 기준으로 삼기로 결정했다.
|
||||
|
||||
## 2026-03-30 v1.2.13
|
||||
- 공통 상태를 로컬 우측 패널에 연결할 때는 템플릿의 ref 자동 언래핑을 고려해야 하므로, 템플릿에서는 `.value` 없이 직접 참조하는 편이 안전하다고 다시 정리했다.
|
||||
- 이번 회귀처럼 편집 화면이 통째로 무너질 수 있는 연결점은 작은 레이아웃 수정이어도 바로 복구 릴리스로 끊는 편이 낫다고 판단했다.
|
||||
|
||||
## 2026-03-30 v1.2.12
|
||||
- 공통 상단의 패널 토글은 로컬 우측 패널 화면에서도 같은 의미로 동작해야 하므로, 에디터의 `editorSidebar`도 같은 상태를 공유해 접고 펴는 편이 일관된다고 판단했다.
|
||||
- 로컬 우측 패널 화면에 공통 `rightClosed` 그리드 계산이 다시 들어오면 컬럼 수가 꼬일 수 있으므로, 에디터/관리자 화면은 셸 차원에서 별도 예외 컬럼 규칙을 유지하기로 결정했다.
|
||||
|
||||
## 2026-03-30 v1.2.11
|
||||
- 에디터와 관리자처럼 자체 우측 패널이 있는 화면은 공통 `workspaceBody` 카드 배경 안에 다시 넣기보다, 셸 레벨에서 중앙 본문을 투명하게 풀어주는 편이 우측 사이드바 독립성이 더 잘 살아난다고 판단했다.
|
||||
- 로컬 우측 패널의 핵심은 “본문 안쪽 보조 박스”가 아니라 “진짜 오른쪽 컬럼”처럼 읽히는 것이므로, 에디터에서는 본문 카드보다 패널 분리감을 먼저 확보하기로 결정했다.
|
||||
|
||||
## 2026-03-30 v1.2.10
|
||||
- 목록 화면도 결국 같은 제품의 라이브러리 레이어이므로, 상단 통계 카드와 버튼의 높이·반경·배경을 공통 셸과 같은 문법으로 맞추는 편이 일관성이 높다고 정리했다.
|
||||
- 홈 화면의 빠른 액션은 중복 의미 버튼보다 `즐겨찾기 / 내 리스트 / 커스텀 시작`처럼 실제 이동 동선이 분명한 버튼 구성이 더 적합하다고 판단했다.
|
||||
- 카드 hover 반응은 화면마다 조금씩 다르게 두기보다, 모두 얕은 위로 이동과 배경 변화로 통일하는 편이 대시보드 감도를 유지하기 쉽다고 결정했다.
|
||||
|
||||
## 2026-03-30 v1.2.9
|
||||
- 관리자 화면은 기능보다 먼저 정보 계층이 읽혀야 하므로, 현재 탭에 맞는 요약 통계를 헤더에서 먼저 보여주는 편이 운영 판단에 더 유리하다고 정리했다.
|
||||
- 게임/아이템/티어표/회원 카드는 기능이 다른 대신 같은 제품 안에 있으므로, 배경층·반경·패딩은 하나의 대시보드 문법으로 맞춰 시안 톤을 더 강하게 유지하기로 결정했다.
|
||||
- 우측 운영 패널은 단순 필터 모음보다 “현재 상태를 짧게 읽고 바로 액션하는 패널”에 가까워야 하므로, 입력과 통계 카드를 더 단단한 카드형 레이어로 정리하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-03-30 v1.2.8
|
||||
- 에디터는 “보드 편집”과 “옵션 편집”의 역할이 다르므로, 보드 옆에는 드래그용 아이템 풀을 두고 제목/설명/썸네일/저장 같은 설정은 최우측 사이드바에만 남기는 편이 맞다고 판단했다.
|
||||
- 커스텀 아이템 이름 정리는 배치 중에 계속 보는 정보보다 저장 전 정리용 정보에 가까우므로, 아이템 풀 아래보다 우측 편집 패널 내부가 더 적합하다고 정리했다.
|
||||
- 실제 SVG 에셋이 들어오기 시작한 만큼, 공통 셸은 새 아이콘을 우선 적용하고 나머지는 점진적으로 교체하는 방식이 안전하다고 판단했다.
|
||||
|
||||
## 2026-03-30 v1.2.7
|
||||
- 피그마 감도는 개별 화면보다 공통 셸의 밀도와 아이콘 체계가 먼저 맞아야 하므로, 좌측/우측 레일을 먼저 아이콘형 카드 문법으로 정리하기로 했다.
|
||||
- 실제 머티리얼 SVG 자산을 받기 전까지는 간단한 선형 SVG 아이콘으로 정보 구조를 먼저 맞추고, 이후 에셋 교체만으로 다듬을 수 있게 하는 편이 안전하다고 판단했다.
|
||||
- 에디터는 기능은 이미 많은 상태이므로 구조를 더 바꾸기보다 보드, 툴바, 우측 편집 패널의 카드 톤을 공통 셸과 맞추는 방식으로 단계적으로 다듬기로 했다.
|
||||
|
||||
## 2026-03-30 v1.2.6
|
||||
- 홈, 게임 허브, 내 티어표, 즐겨찾기처럼 카드 중심 화면은 한 번에 같은 카드 문법으로 맞춰야 전체 앱이 하나의 제품처럼 보이므로, 목록 화면을 우선 통일하기로 했다.
|
||||
- 홈 화면은 단순 게임 버튼 모음보다 상태 카드와 CTA가 있는 라이브러리 대시보드 쪽이 피그마 톤에 더 가깝다고 판단했다.
|
||||
- 게임 허브와 개인 목록도 썸네일/작성자/메타의 비중이 비슷하므로, 화면마다 다른 카드 구조를 유지하기보다 동일한 정보 계층을 반복하는 편이 더 읽기 쉽다고 정리했다.
|
||||
|
||||
## 2026-03-30 v1.2.5
|
||||
- 관리자 화면도 에디터와 마찬가지로 공통 우측 패널보다 전용 로컬 운영 패널이 더 중요하므로, `/admin` 역시 화면 내부 `320px` 패널을 사용하는 포커스 화면으로 정리하기로 결정했다.
|
||||
- 관리자 기능은 탭, 검색, 필터, 빠른 액션이 본문에 섞이면 밀도가 너무 높아지므로, 우측 패널에는 제어 요소를 모으고 중앙에는 실제 관리 대상 목록과 상세만 남기는 편이 낫다고 판단했다.
|
||||
- 새 셸 단계에서는 기능을 줄이기보다 위치를 재배치하는 것이 안전하므로, 기존 게임/아이템/티어표/회원 관리 로직은 유지한 채 정보 구조만 피그마 방향으로 옮기기로 했다.
|
||||
|
||||
## 2026-03-30 v1.2.4
|
||||
- 로그인 유도는 좌측 하단의 단일 버튼이면 충분하므로, 비로그인 상태에서 사이드 상단에 별도 안내 카드를 또 보여주는 구조는 제거하는 편이 더 깔끔하다고 판단했다.
|
||||
- 티어표 편집 화면은 공통 우측 패널의 generic 문맥 카드보다 실제 편집 필드가 우측에 있는 편이 훨씬 중요하므로, 이 화면은 전용 로컬 우측 패널을 두는 쪽으로 정리했다.
|
||||
- 좌측 내비가 이미 라우팅 역할을 하므로, 에디터 우측 패널에서는 “게임 목록으로” 같은 중복 이동 CTA보다 저장과 편집 자체에 집중하는 것이 맞다고 판단했다.
|
||||
|
||||
## 2026-03-30 v1.2.2
|
||||
- 우측 패널은 본문 내부 보조 박스가 아니라 별도 컬럼으로 보이는 것이 핵심이므로, 폭을 `320px`로 고정하고 접힘/펼침도 레이아웃 레벨에서 처리하는 편이 맞다고 판단했다.
|
||||
- 좌측 패널도 시안 기준 인지 폭이 중요하므로 `248px`로 고정하고, 중앙 콘텐츠는 나머지 공간을 유동적으로 쓰게 하는 구조로 정리했다.
|
||||
- 우측 패널 토글은 라우트별 개별 구현보다 공통 셸의 상단 컨트롤로 두는 편이 모든 화면에서 일관된 사용성을 제공한다고 판단했다.
|
||||
|
||||
## 2026-03-30 v1.2.1
|
||||
- 공통 셸을 먼저 올린 직후에는 에디터와 관리자처럼 자체 패널이 많은 화면이 가장 크게 깨지므로, 이 화면들은 우선 공통 우측 패널을 숨기고 중앙 폭을 회복시키는 편이 안정적이라고 판단했다.
|
||||
- 목록형 카드 화면은 셸 안쪽 폭이 줄어든 상태에서 이전보다 더 많은 컬럼을 유지하면 즉시 사용성이 무너지므로, 기본 컬럼 수를 줄여 먼저 읽히는 상태를 만드는 쪽을 우선하기로 했다.
|
||||
- 리디자인 초기 단계에서는 “완벽한 시안 재현”보다 먼저 실제 조작 가능한 상태를 되찾는 것이 중요하므로, 이번 단계는 안정화 릴리스로 짧게 끊어 가기로 정리했다.
|
||||
|
||||
## 2026-03-30 v1.2.0
|
||||
- 피그마 시안은 단순 컴포넌트 교체보다 앱 전체의 정보 구조를 바꾸는 성격이 강하므로, 우선 공통 앱 셸부터 `좌측 내비 / 중앙 워크스페이스 / 우측 컨텍스트 패널`로 올리는 단계적 리디자인이 더 안전하다고 판단했다.
|
||||
- 홈, 게임 허브, 내 티어표, 즐겨찾기처럼 카드 목록 중심 화면은 시안 톤을 먼저 맞추고, 에디터와 관리자처럼 상호작용이 무거운 화면은 같은 셸 안에서 후속 이관하는 방식이 리스크가 적다고 정리했다.
|
||||
- 이번 리디자인은 사용자 체감 변화가 큰 편이므로, 버전도 기존 `0.1.x`가 아니라 `v1.2.0`으로 점프해 기록하는 편이 더 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-03-27 v0.1.52
|
||||
- 관리자 확인용 완성본은 사이트 전체가 아니라 보드만 보여주는 preview 전용 모드가 더 적합하다고 판단했다.
|
||||
- 티어표 썸네일은 비어 있는 것보다 자동 기본값이 있는 편이 낫다고 보고, 사용자가 직접 지정하지 않으면 티어표 아이템 중 대표 이미지를 자동 썸네일로 채우기로 결정했다.
|
||||
- 이력 문서는 날짜 역순이 깨지면 추적이 어렵기 때문에, 오래된 2026-03-19 항목을 최신 2026-03-26/27 항목 뒤로 다시 정렬해 흐름을 복구했다.
|
||||
|
||||
## 2026-03-27 v0.1.51
|
||||
- 관리자 확인은 편집 화면으로 이동하는 것보다 관리 페이지 안에서 닫고 돌아올 수 있는 미리보기 모달이 더 적합하다고 판단했다.
|
||||
- 템플릿 등록 요청은 실제로는 배치 상태보다 제목 식별성이 더 중요하므로, `보드 비움` 조건은 제거하고 제목 직접 입력 중심으로 단순화하기로 결정했다.
|
||||
|
||||
## 2026-03-27 v0.1.50
|
||||
- 신규 티어표 저장 직후 요청 실패는 별도 요청용 티어표를 또 만드는 것보다, 방금 저장된 실제 티어표 ID를 그대로 이어받아 요청하는 편이 구조가 단순하고 안전하다고 판단했다.
|
||||
|
||||
## 2026-03-27 v0.1.49
|
||||
- 템플릿 등록 요청 모달은 체크리스트 설명이 먼저 읽히고 상태가 우측에서 한눈에 보여야 하므로, 라벨 좌측·상태 우측 구조로 정리하기로 했다.
|
||||
- 관리자 입장에서는 `요청 목록`과 `저장된 전체 티어표 목록`이 서로 다른 성격이므로, 같은 화면 안에서도 서브 탭으로 분리해 맥락을 명확히 하는 편이 더 적합하다고 판단했다.
|
||||
|
||||
## 2026-03-27 v0.1.48
|
||||
- 템플릿 등록 요청은 실패 원인이 불명확하면 혼란이 크므로, 요청 전에 체크리스트 모달로 조건을 먼저 확인시키고 조건이 맞을 때만 전송하게 하는 편이 낫다고 정리했다.
|
||||
- freeform 템플릿 등록 요청은 제목이 곧 게임 이름 후보가 되므로, 기본값이 아닌 사용자가 직접 입력한 제목을 요구하기로 했다.
|
||||
- 관리자 입장에서는 처리하지 않을 요청을 대기 목록에서 바로 치울 수 있어야 하므로, 반려는 단순 상태 변경이 아니라 “대기 목록에서 숨김”으로 인지되게 문구를 맞추기로 했다.
|
||||
|
||||
## 2026-03-27 v0.1.47
|
||||
- 새 게임 템플릿 등록과 기존 템플릿 업데이트는 운영자가 직접 일일이 훑기보다, 사용자가 명시적으로 요청을 보내고 관리자가 승인하는 흐름이 더 빠르고 명확하다고 정리했다.
|
||||
- 템플릿 요청에 포함되는 커스텀 아이템 이름은 관리자 판단의 핵심 정보이므로, 티어표 편집 화면 안에서도 직접 이름을 정리하고 저장 시 원본 커스텀 아이템 라벨까지 함께 동기화하기로 결정했다.
|
||||
- 새 템플릿 등록 요청은 실제 템플릿처럼 비어 있는 상태가 더 활용도가 높으므로, `freeform + 빈 보드 + 커스텀 아이템 존재` 조건에서만 보낼 수 있게 제한하기로 했다.
|
||||
|
||||
## 2026-03-27 v0.1.46
|
||||
- 티어표 편집 중 등급 행에 넣은 아이템도 다시 제외할 수 있어야 배치 실험이 쉬우므로, 별도 제거 버튼으로 아이템 풀로 되돌리는 흐름을 제공하기로 결정했다.
|
||||
- 관리자 회원 관리는 수정 기능만으로는 부족하므로, 운영 판단에 바로 필요한 아바타, 작성 티어표 수, 최근 활동 시각을 함께 보여주기로 했다.
|
||||
|
||||
## 2026-03-27 v0.1.45
|
||||
- 카드형 목록의 별표는 개수 표시로 읽히기 쉬우므로, 목록에서는 상태/개수만 보여주고 실제 즐겨찾기 토글은 상세 화면에서 처리하는 편이 오해가 적다고 정리했다.
|
||||
- 토스트는 같은 메시지가 짧게 반복될 때 누적 표시가 더 낫고, 종료도 급격히 끊기지 않도록 페이드아웃을 넣는 편이 사용자 경험상 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-03-27 v0.1.44
|
||||
- 전역 토스트는 공통 composable을 템플릿에 넘길 때 top-level ref로 풀어줘야 하므로, 렌더링 구조를 단순하게 유지하는 편이 안전하다고 정리했다.
|
||||
- 공개 티어표가 많아질수록 게임별 목록 안에서 바로 제목/작성자 검색이 가능해야 하므로, 검색은 별도 페이지보다 각 게임 허브 안에서 먼저 제공하기로 결정했다.
|
||||
@@ -30,79 +181,6 @@
|
||||
- 관리자 기본 아이템은 업로드 시점에만 이름을 정할 수 있으면 운영 중 수정이 어려우므로, 목록에서 직접 이름을 바꾸고 저장할 수 있게 하기로 결정했다.
|
||||
- 게임별 티어표 목록도 식별성이 중요하므로, 사용자가 편집 시 별도 썸네일을 지정할 수 있게 하고 목록 카드에서는 게임 카드와 비슷한 상단 썸네일 구조를 사용하기로 결정했다.
|
||||
|
||||
## 2026-03-19
|
||||
- 초기 저장소는 빠른 구현을 위해 `lowdb(JSON 파일)`를 채택했다.
|
||||
- 인증은 JWT 대신 서버 세션(`express-session` + `session-file-store`)을 사용했다.
|
||||
- 업로드 파일은 외부 스토리지 없이 로컬 디스크(`backend/uploads/`)에 저장하기로 했다.
|
||||
|
||||
## 2026-03-19 v0.1.3
|
||||
- 배포 환경 호환성을 위해 프런트엔드의 API 기준 주소를 환경변수(`VITE_API_ORIGIN`)로 통합했다.
|
||||
- NAS/리버스 프록시 환경을 고려해 CORS 및 세션 쿠키 옵션을 환경변수 기반으로 전환했다.
|
||||
- 파일명 깨짐과 URL 이식성 문제를 줄이기 위해 업로드 파일명을 ASCII 기반으로 생성하도록 변경했다.
|
||||
- 게임 이미지 경로는 저장 시 상대 경로(`/uploads/...`)를 유지하는 방향으로 정리했다.
|
||||
- 현재 단계에서는 구조 변경 비용을 고려해 DB를 유지하되, 운영/확장성 요구가 커지기 전 RDB 이관 판단이 필요하다고 기록했다.
|
||||
|
||||
## 2026-03-19 v0.1.4
|
||||
- 운영 편의성과 NAS 환경에서의 데이터 조회 필요성 때문에 저장소를 MariaDB(MySQL 호환) 기준으로 전환했다.
|
||||
- 관리자 지정 아이템과 사용자 커스텀 이미지는 책임과 수명 주기가 다르므로 별도 테이블(`game_items`, `custom_items`)로 분리했다.
|
||||
- 작성자 식별성을 위해 공개 티어표에 닉네임을 표시하고, 프로필에서 닉네임을 수정할 수 있게 했다.
|
||||
- 아바타 업로드는 즉시 반영보다 “선택 후 저장” 흐름이 맞다고 판단해 미리보기와 실제 저장을 분리했다.
|
||||
- 관리자 페이지는 게임 선택 후 상세 관리가 열리는 단계형 흐름으로 바꾸는 것이 실사용에 더 안전하다고 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.5
|
||||
- 로컬 개발과 운영 환경의 차이를 줄이기 위해 기본 로컬 개발 DB도 MariaDB로 고정했다.
|
||||
- 로컬 실행 편의를 위해 `docker-compose.yml`에 `mariadb`와 `phpMyAdmin` 서비스를 추가했다.
|
||||
- 백엔드 기본 `dev/start/migrate` 스크립트는 로컬 MariaDB 기준 값으로 정리하고, lowdb는 예외용 fallback 스크립트로만 남겼다.
|
||||
|
||||
## 2026-03-19 v0.1.6
|
||||
- 저장소 운영 규칙을 정리하면서 Git 작성자 정보는 프로젝트 기준 계정으로 통일하고, 커밋 메시지는 한국어로 남기기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.7
|
||||
- 관리자 페이지는 여러 작업을 동시에 나열하는 구조보다 “하나의 작업 모드를 선택하고 그 작업에 집중하는 구조”가 더 적합하다고 판단해 단계형 UI로 전환했다.
|
||||
- 관리자에게는 생성뿐 아니라 삭제 책임도 필요하므로 게임 삭제와 아이템 삭제 기능을 추가하기로 결정했다.
|
||||
- 아이템 삭제는 단순 파일/레코드 삭제만으로 끝내면 안 되고, 기존 티어표 데이터의 참조까지 함께 정리해야 한다고 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.8
|
||||
- 관리자 업로드 작업은 선택 즉시 결과를 예측할 수 있어야 하므로, 썸네일과 아이템 모두 “파일 선택 → 미리보기 → 실제 업로드” 흐름으로 보강했다.
|
||||
- 게임 썸네일은 대표 이미지 성격이 강하므로 16:9 비율로, 아이템은 캐릭터/오브젝트 단위 식별이 중요하므로 1:1 비율로 보는 방향을 채택했다.
|
||||
- 현재 `db.json`과 lowdb 관련 코드는 기본 운영 런타임이 아니라 마이그레이션/예외 fallback 성격임을 분명히 정리했다.
|
||||
|
||||
## 2026-03-19 v0.1.9
|
||||
- 로컬과 운영 환경을 완전히 같은 DB 계층으로 맞추기 위해 lowdb fallback을 제거하고 MariaDB만 지원하는 코드베이스로 정리했다.
|
||||
- 마이그레이션 종료 이후에는 레거시 JSON 저장소와 예외 실행 스크립트를 남겨두는 비용이 더 크다고 판단해 삭제하기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.10
|
||||
- 관리자 업로드 작업은 "파일 선택 후 적용"이 더 정확하므로, 썸네일 버튼 문구와 활성화 조건을 그 흐름에 맞추기로 결정했다.
|
||||
- 작은 화면에서 미리보기가 실제 작업 영역을 압박하지 않도록, 아이템 미리보기는 정사각형을 유지하되 최대 크기를 제한하는 방향을 채택했다.
|
||||
- 파일 입력은 업로드 성공 후와 게임 전환 시 초기화해 같은 파일 재선택이 막히지 않도록 정리했다.
|
||||
|
||||
## 2026-03-19 v0.1.11
|
||||
- 관리자 화면은 좌우 여백이 크게 남는 구조보다, 상단 2열 작업 카드와 하단 목록 영역으로 나누는 편이 더 안정적이라고 판단해 레이아웃을 재정리했다.
|
||||
- 게임 목록에 없는 주제로도 바로 작업할 수 있도록, 시스템 전용 `freeform` 게임을 내부적으로 유지하고 홈 화면에서는 `직접 티어표 만들기` 카드로 노출하기로 결정했다.
|
||||
- 게임 제안은 현재 운영 흐름과 맞지 않아 사용자 진입점과 프런트 API에서 제거하고, 대신 관리자에게는 사용자 커스텀 아이템 검토 기능을 제공하기로 했다.
|
||||
|
||||
## 2026-03-19 v0.1.12
|
||||
- 앱 전체 배경은 화면 폭 전체를 사용하고, 개별 콘텐츠만 필요한 만큼 정렬하는 방향이 더 자연스럽다고 판단해 전역 최대 폭 제한을 제거했다.
|
||||
- 비로그인 사용자가 새 티어표를 편집하다 저장 단계에서 막히는 경험은 손실 위험이 크므로, 작성 시작 자체를 로그인 사용자로 제한하고 공개 티어표는 읽기 전용으로 보여주기로 결정했다.
|
||||
- 커스텀 이미지 업로드는 단일 파일 선택만으로는 불편하므로, 다중 선택과 드래그 앤 드롭을 기본 흐름으로 보강했다.
|
||||
- 관리자에게는 게임 관리뿐 아니라 회원 관리 책임도 필요하므로, 회원 목록 조회/수정/삭제 기능을 추가하기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.13
|
||||
- 관리자 페이지는 기능 수가 늘어난 만큼 게임, 아이템, 회원 관리 탭으로 나누는 편이 더 안전하다고 판단했다.
|
||||
- 사용자 커스텀 아이템은 수량이 빠르게 커질 수 있으므로, 검색과 페이지네이션을 기본 제공하는 관리 화면으로 보는 방향을 채택했다.
|
||||
- 메일 기반 복구가 없으므로, 관리자가 회원 비밀번호를 직접 초기화할 수 있는 기능을 제공하기로 결정했다.
|
||||
- 티어표 구조는 게임마다 다르므로, 기본 5단은 시작 템플릿일 뿐 사용자가 행을 직접 추가/삭제할 수 있어야 한다고 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.17
|
||||
- 작성자가 자기 티어표를 직접 삭제할 수 있어야 관리 흐름이 완결되므로, `내 티어표` 화면에 삭제 기능을 추가하기로 결정했다.
|
||||
- 사용자 커스텀 이미지는 서버 용량을 계속 차지하므로, 참조가 하나도 남지 않은 이미지(미사용 항목)를 식별하고 관리자 화면에서 개별/일괄 정리할 수 있도록 하기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.19
|
||||
- 티어표 공개 여부는 운영 기준상 대부분 공개 공유가 목적이므로, 신규 작성 시 기본값을 `공개 ON`으로 두기로 결정했다.
|
||||
- 에디터에서 저장 관련 행동은 좌우로 역할을 나눠 `다운로드/삭제`와 `공개/저장`으로 묶는 편이 더 빠르게 인지된다고 판단했다.
|
||||
- 공개 목록과 내 목록에서 제목만으로는 식별이 어려우므로, 제목 옆에 작성자 아바타와 표시명을 함께 노출하기로 결정했다.
|
||||
- 티어표 카드 메타 정보는 저장 시각과 업데이트 시각을 동시에 노출하는 대신, 최종 업데이트 시각만 남겨 단순화하기로 결정했다.
|
||||
|
||||
## 2026-03-26 v0.1.21
|
||||
- 목록 썸네일 fallback 문자는 닉네임보다 계정 기준이 더 일관되므로, 아바타 이미지가 없을 때는 계정명 첫 글자를 사용하기로 결정했다.
|
||||
- 저장 성공은 화면 이동보다 현 위치 유지가 더 중요하므로, 편집을 계속할 수 있는 확인형 모달로 피드백을 제공하기로 결정했다.
|
||||
@@ -167,3 +245,76 @@
|
||||
## 2026-03-26 v0.1.37
|
||||
- 운영 포트 충돌을 피하기 위해 프로덕션 외부 포트는 `frontend=18080`, `phpMyAdmin=18081`로 고정하고, 리버스 프록시 문서도 그 기준으로 맞추기로 했다.
|
||||
- 인증 장애 원인을 찾기 위한 디버그 로그는 문제 해결 후 제거하고, 실제 운영에는 세션 저장 보강과 프록시 헤더 설정만 유지하는 편이 낫다고 판단했다.
|
||||
|
||||
## 2026-03-19 v0.1.19
|
||||
- 티어표 공개 여부는 운영 기준상 대부분 공개 공유가 목적이므로, 신규 작성 시 기본값을 `공개 ON`으로 두기로 결정했다.
|
||||
- 에디터에서 저장 관련 행동은 좌우로 역할을 나눠 `다운로드/삭제`와 `공개/저장`으로 묶는 편이 더 빠르게 인지된다고 판단했다.
|
||||
- 공개 목록과 내 목록에서 제목만으로는 식별이 어려우므로, 제목 옆에 작성자 아바타와 표시명을 함께 노출하기로 결정했다.
|
||||
- 티어표 카드 메타 정보는 저장 시각과 업데이트 시각을 동시에 노출하는 대신, 최종 업데이트 시각만 남겨 단순화하기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.17
|
||||
- 작성자가 자기 티어표를 직접 삭제할 수 있어야 관리 흐름이 완결되므로, `내 티어표` 화면에 삭제 기능을 추가하기로 결정했다.
|
||||
- 사용자 커스텀 이미지는 서버 용량을 계속 차지하므로, 참조가 하나도 남지 않은 이미지(미사용 항목)를 식별하고 관리자 화면에서 개별/일괄 정리할 수 있도록 하기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.13
|
||||
- 관리자 페이지는 기능 수가 늘어난 만큼 게임, 아이템, 회원 관리 탭으로 나누는 편이 더 안전하다고 판단했다.
|
||||
- 사용자 커스텀 아이템은 수량이 빠르게 커질 수 있으므로, 검색과 페이지네이션을 기본 제공하는 관리 화면으로 보는 방향을 채택했다.
|
||||
- 메일 기반 복구가 없으므로, 관리자가 회원 비밀번호를 직접 초기화할 수 있는 기능을 제공하기로 결정했다.
|
||||
- 티어표 구조는 게임마다 다르므로, 기본 5단은 시작 템플릿일 뿐 사용자가 행을 직접 추가/삭제할 수 있어야 한다고 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.12
|
||||
- 앱 전체 배경은 화면 폭 전체를 사용하고, 개별 콘텐츠만 필요한 만큼 정렬하는 방향이 더 자연스럽다고 판단해 전역 최대 폭 제한을 제거했다.
|
||||
- 비로그인 사용자가 새 티어표를 편집하다 저장 단계에서 막히는 경험은 손실 위험이 크므로, 작성 시작 자체를 로그인 사용자로 제한하고 공개 티어표는 읽기 전용으로 보여주기로 결정했다.
|
||||
- 커스텀 이미지 업로드는 단일 파일 선택만으로는 불편하므로, 다중 선택과 드래그 앤 드롭을 기본 흐름으로 보강했다.
|
||||
- 관리자에게는 게임 관리뿐 아니라 회원 관리 책임도 필요하므로, 회원 목록 조회/수정/삭제 기능을 추가하기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.11
|
||||
- 관리자 화면은 좌우 여백이 크게 남는 구조보다, 상단 2열 작업 카드와 하단 목록 영역으로 나누는 편이 더 안정적이라고 판단해 레이아웃을 재정리했다.
|
||||
- 게임 목록에 없는 주제로도 바로 작업할 수 있도록, 시스템 전용 `freeform` 게임을 내부적으로 유지하고 홈 화면에서는 `직접 티어표 만들기` 카드로 노출하기로 결정했다.
|
||||
- 게임 제안은 현재 운영 흐름과 맞지 않아 사용자 진입점과 프런트 API에서 제거하고, 대신 관리자에게는 사용자 커스텀 아이템 검토 기능을 제공하기로 했다.
|
||||
|
||||
## 2026-03-19 v0.1.10
|
||||
- 관리자 업로드 작업은 "파일 선택 후 적용"이 더 정확하므로, 썸네일 버튼 문구와 활성화 조건을 그 흐름에 맞추기로 결정했다.
|
||||
- 작은 화면에서 미리보기가 실제 작업 영역을 압박하지 않도록, 아이템 미리보기는 정사각형을 유지하되 최대 크기를 제한하는 방향을 채택했다.
|
||||
- 파일 입력은 업로드 성공 후와 게임 전환 시 초기화해 같은 파일 재선택이 막히지 않도록 정리했다.
|
||||
|
||||
## 2026-03-19 v0.1.9
|
||||
- 로컬과 운영 환경을 완전히 같은 DB 계층으로 맞추기 위해 lowdb fallback을 제거하고 MariaDB만 지원하는 코드베이스로 정리했다.
|
||||
- 마이그레이션 종료 이후에는 레거시 JSON 저장소와 예외 실행 스크립트를 남겨두는 비용이 더 크다고 판단해 삭제하기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.8
|
||||
- 관리자 업로드 작업은 선택 즉시 결과를 예측할 수 있어야 하므로, 썸네일과 아이템 모두 “파일 선택 → 미리보기 → 실제 업로드” 흐름으로 보강했다.
|
||||
- 게임 썸네일은 대표 이미지 성격이 강하므로 16:9 비율로, 아이템은 캐릭터/오브젝트 단위 식별이 중요하므로 1:1 비율로 보는 방향을 채택했다.
|
||||
- 현재 `db.json`과 lowdb 관련 코드는 기본 운영 런타임이 아니라 마이그레이션/예외 fallback 성격임을 분명히 정리했다.
|
||||
|
||||
## 2026-03-19 v0.1.7
|
||||
- 관리자 페이지는 여러 작업을 동시에 나열하는 구조보다 “하나의 작업 모드를 선택하고 그 작업에 집중하는 구조”가 더 적합하다고 판단해 단계형 UI로 전환했다.
|
||||
- 관리자에게는 생성뿐 아니라 삭제 책임도 필요하므로 게임 삭제와 아이템 삭제 기능을 추가하기로 결정했다.
|
||||
- 아이템 삭제는 단순 파일/레코드 삭제만으로 끝내면 안 되고, 기존 티어표 데이터의 참조까지 함께 정리해야 한다고 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.6
|
||||
- 저장소 운영 규칙을 정리하면서 Git 작성자 정보는 프로젝트 기준 계정으로 통일하고, 커밋 메시지는 한국어로 남기기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.5
|
||||
- 로컬 개발과 운영 환경의 차이를 줄이기 위해 기본 로컬 개발 DB도 MariaDB로 고정했다.
|
||||
- 로컬 실행 편의를 위해 `docker-compose.yml`에 `mariadb`와 `phpMyAdmin` 서비스를 추가했다.
|
||||
- 백엔드 기본 `dev/start/migrate` 스크립트는 로컬 MariaDB 기준 값으로 정리하고, lowdb는 예외용 fallback 스크립트로만 남겼다.
|
||||
|
||||
## 2026-03-19 v0.1.4
|
||||
- 운영 편의성과 NAS 환경에서의 데이터 조회 필요성 때문에 저장소를 MariaDB(MySQL 호환) 기준으로 전환했다.
|
||||
- 관리자 지정 아이템과 사용자 커스텀 이미지는 책임과 수명 주기가 다르므로 별도 테이블(`game_items`, `custom_items`)로 분리했다.
|
||||
- 작성자 식별성을 위해 공개 티어표에 닉네임을 표시하고, 프로필에서 닉네임을 수정할 수 있게 했다.
|
||||
- 아바타 업로드는 즉시 반영보다 “선택 후 저장” 흐름이 맞다고 판단해 미리보기와 실제 저장을 분리했다.
|
||||
- 관리자 페이지는 게임 선택 후 상세 관리가 열리는 단계형 흐름으로 바꾸는 것이 실사용에 더 안전하다고 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.3
|
||||
- 배포 환경 호환성을 위해 프런트엔드의 API 기준 주소를 환경변수(`VITE_API_ORIGIN`)로 통합했다.
|
||||
- NAS/리버스 프록시 환경을 고려해 CORS 및 세션 쿠키 옵션을 환경변수 기반으로 전환했다.
|
||||
- 파일명 깨짐과 URL 이식성 문제를 줄이기 위해 업로드 파일명을 ASCII 기반으로 생성하도록 변경했다.
|
||||
- 게임 이미지 경로는 저장 시 상대 경로(`/uploads/...`)를 유지하는 방향으로 정리했다.
|
||||
- 현재 단계에서는 구조 변경 비용을 고려해 DB를 유지하되, 운영/확장성 요구가 커지기 전 RDB 이관 판단이 필요하다고 기록했다.
|
||||
|
||||
## 2026-03-19
|
||||
- 초기 저장소는 빠른 구현을 위해 `lowdb(JSON 파일)`를 채택했다.
|
||||
- 인증은 JWT 대신 서버 세션(`express-session` + `session-file-store`)을 사용했다.
|
||||
- 업로드 파일은 외부 스토리지 없이 로컬 디스크(`backend/uploads/`)에 저장하기로 했다.
|
||||
|
||||
24
docs/map.md
@@ -2,17 +2,17 @@
|
||||
|
||||
## `/`
|
||||
- 화면 파일: `frontend/src/views/HomeView.vue`
|
||||
- 역할: 게임 목록 표시, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
|
||||
- 역할: 데스크톱 기본 4열 게임 카드 라이브러리 대시보드, 상단 메인 썸네일과 `게임명 + 작은 ID` 메타를 가진 템플릿 선택 카드, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
|
||||
- 연동 API: `GET /api/games`
|
||||
|
||||
## `/games/:gameId`
|
||||
- 화면 파일: `frontend/src/views/GameHubView.vue`
|
||||
- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 상단 썸네일/작성자 표시, 즐겨찾기 토글, 새 티어표 작성 진입
|
||||
- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
|
||||
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
|
||||
|
||||
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
|
||||
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
||||
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 티어표 썸네일 선택, 작성 권한 제어, 저장, 공개 여부 설정, 즐겨찾기 토글, PNG 다운로드
|
||||
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드
|
||||
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`
|
||||
|
||||
## `/login`
|
||||
@@ -22,27 +22,33 @@
|
||||
|
||||
## `/me`
|
||||
- 화면 파일: `frontend/src/views/MyTierListsView.vue`
|
||||
- 역할: 내 티어표 목록 조회, 상단 썸네일 카드 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제
|
||||
- 역할: 내 티어표 목록 조회, 4열 라이브러리 카드형 썸네일 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제
|
||||
- 연동 API: `GET /api/tierlists/me`, `DELETE /api/tierlists/:id`
|
||||
|
||||
## `/favorites`
|
||||
- 화면 파일: `frontend/src/views/FavoriteTierListsView.vue`
|
||||
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 편집 화면 이동, 즐겨찾기 해제
|
||||
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 라이브러리 카드형 표시, 편집 화면 이동, 즐겨찾기 상태 확인
|
||||
- 연동 API: `GET /api/tierlists/favorites/me`, `DELETE /api/tierlists/:id/favorite`
|
||||
|
||||
## `/search`
|
||||
- 화면 파일: `frontend/src/views/SearchResultsView.vue`
|
||||
- 역할: 좌측 전역 검색 입력에서 넘긴 키워드로 공개 티어표 전체를 검색하고, 자체 검색 툴바 없이 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 목록으로 표시
|
||||
- 연동 API: `GET /api/tierlists/public?q=...`
|
||||
|
||||
## `/admin`
|
||||
- 화면 파일: `frontend/src/views/AdminView.vue`
|
||||
- 역할: `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
|
||||
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-game-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
|
||||
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
|
||||
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-game-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
|
||||
|
||||
## `/profile`
|
||||
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
||||
- 역할: 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장
|
||||
- 역할: 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장, 설정 화면 하단 로그아웃 처리
|
||||
- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`
|
||||
|
||||
## 공통 레이아웃
|
||||
- 앱 셸 파일: `frontend/src/App.vue`
|
||||
- 역할: 상단 내비게이션, 로그인 상태 반영, 아바타 메뉴, 관리자 메뉴 노출 제어, 전역 우측 상단 토스트 렌더링
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
|
||||
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker by zenn`이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
|
||||
|
||||
## 백엔드 진입점
|
||||
- 서버 엔트리: `backend/index.js`
|
||||
|
||||
60
docs/spec.md
@@ -9,6 +9,12 @@
|
||||
- 운영 배포: `frontend(Nginx 정적 서빙 + /api,/uploads 프록시) + backend + mariadb` Docker Compose 구조
|
||||
- NAS HTTPS 리버스 프록시 운영 시 프런트 Nginx는 백엔드로 `X-Forwarded-Proto: https`를 전달하고, Express 세션은 프록시 환경에서 `secure` 쿠키를 허용하도록 설정한다.
|
||||
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
|
||||
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, preview 모드에서는 이 셸을 숨기고 콘텐츠만 렌더링한다.
|
||||
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
|
||||
- 좌측 패널은 필요 시 축소형 레일로 접을 수 있으며, 접힌 상태에서는 아이콘 중심 내비게이션과 축약된 바로가기만 유지한다.
|
||||
- 이 3단 셸 구조는 홈, 게임 허브, 에디터, 관리자 등 일반 페이지 전반의 공통 뼈대로 유지하고, 페이지별 차이는 중앙/우측에 어떤 콘텐츠를 넣는지만 달라지도록 관리한다.
|
||||
- 비로그인 상태의 로그인 유도는 좌측 하단 버튼으로만 노출하고, 좌측 상단 사용자 카드 영역에는 별도 게스트 안내 카드를 렌더링하지 않는다.
|
||||
- 공통 셸의 좌측 내비, 우측 패널, 빠른 점프 버튼은 간단한 선형 SVG 아이콘과 두꺼운 카드형 버튼 문법을 공유한다.
|
||||
|
||||
## 데이터 저장 구조
|
||||
- 메인 데이터베이스: MariaDB `tier_cursor` (기본값)
|
||||
@@ -19,6 +25,38 @@
|
||||
- 커스텀 아이템: `backend/uploads/custom/`
|
||||
- 시드 이미지: `backend/uploads/seeds/`
|
||||
|
||||
## 화면 구조
|
||||
- 좌측 패널
|
||||
- 사용자 요약, 전체 공개 티어표 검색 입력, 주요 라우트 내비게이션, 최근 즐겨찾기 티어표 바로가기, 관리자 진입 버튼을 배치한다.
|
||||
- 상단 토글 버튼은 항상 고정되어 있고, 패널을 축소하면 텍스트를 숨기고 아이콘 중심 레일로 전환한다.
|
||||
- `Settings`는 별도 메뉴 항목으로만 진입하며, 사용자 카드 자체는 정보 표시 용도로만 사용한다.
|
||||
- 사용자 아바타는 원형 보더 스타일을 유지하고, `Favorites` 영역은 최근 즐겨찾기 티어표 최대 10개를 메인 메뉴보다 작은 밀도의 바로가기 목록으로 보여준 뒤 하단 `즐겨찾기 더 보기` 링크로 전체 즐겨찾기 화면에 연결한다.
|
||||
- 중앙 워크스페이스
|
||||
- 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다.
|
||||
- 공통 `workspaceBody`는 별도 외곽 카드 테두리 없이 셸 여백만 제공하고, 실제 카드/패널 레이어는 각 화면 내부에서만 구성한다.
|
||||
- 홈, 게임 허브, 내 티어표, 즐겨찾기, 검색 결과 화면은 같은 카드 문법(상단 16:9 썸네일, `제목+좋아요` 1행, `작성자+최종 수정일` 1행)을 공유하며, 데스크톱 기준 기본 4열 카드 그리드를 사용한다.
|
||||
- 단, 홈 게임 선택 카드는 템플릿 선택용이므로 상단 메인 썸네일은 유지하되, 하단 메타는 `게임명 + 작은 ID`만 간결하게 표시한다.
|
||||
- 목록 계열 화면의 상단 도구 영역은 통계 카드와 액션 버튼을 공통 높이/반경으로 맞춰, 같은 라이브러리 대시보드로 읽히도록 정리한다.
|
||||
- 우측 패널
|
||||
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
|
||||
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
|
||||
- 공통 토글 버튼은 패널이 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 각각 아이콘만 표시하는 방식으로 동작한다.
|
||||
- 오른쪽 패널 토글은 열기/닫기 모두 `dock_to_left`, 왼쪽 패널 토글은 `dock_to_right` 아이콘으로 통일한다.
|
||||
- 좌우 레일의 주요 CTA는 스크롤되는 본문과 분리된 하단 `56px` 액션 영역에 배치한다.
|
||||
- 하단 액션은 화면 바닥에 바로 붙지 않도록 푸터 내부에 추가 하단 여백을 둔다.
|
||||
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
|
||||
- 티어표 편집 화면
|
||||
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
|
||||
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
|
||||
- 공통 상단 토글 버튼은 Teleport로 이동한 로컬 편집 패널의 접힘/펼침 상태와도 연결되어, 우측 패널을 숨기면 중앙 보드 영역이 확장된다.
|
||||
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다.
|
||||
- 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다.
|
||||
- 관리자 화면
|
||||
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
|
||||
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록은 메인 컬럼에, 우측 운영 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
|
||||
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다.
|
||||
- 상단 헤더에는 현재 탭 기준 요약 통계 카드를 배치해 운영 상태를 먼저 읽고, 각 관리 카드는 공통 대시보드 카드 문법(두꺼운 반경, 얕은 레이어 배경, 강조된 액션 버튼)을 공유한다.
|
||||
|
||||
## DB 스키마
|
||||
- `users`
|
||||
- `id`: string
|
||||
@@ -52,6 +90,7 @@
|
||||
- `gameId`: string
|
||||
- `title`: string
|
||||
- `thumbnailSrc`: string
|
||||
- 사용자가 직접 지정하지 않으면 저장 시 티어표 대표 아이템 이미지로 자동 채운다.
|
||||
- `description`: string
|
||||
- `isPublic`: boolean
|
||||
- `groups`: `{ id, name, itemIds[] }[]`
|
||||
@@ -80,9 +119,11 @@
|
||||
- `GET /api/games/:gameId`
|
||||
- 티어표
|
||||
- `GET /api/tierlists/public`
|
||||
- `gameId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
|
||||
- `GET /api/tierlists/me`
|
||||
- `GET /api/tierlists/favorites/me`
|
||||
- `GET /api/tierlists/:id`
|
||||
- `POST /api/tierlists/:id/template-request`
|
||||
- `POST /api/tierlists/:id/favorite`
|
||||
- `DELETE /api/tierlists/:id/favorite`
|
||||
- `DELETE /api/tierlists/:id`
|
||||
@@ -96,6 +137,9 @@
|
||||
- 여러 이미지를 한 번에 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
|
||||
- `PATCH /api/admin/games/:gameId/items/:itemId`
|
||||
- `GET /api/admin/tierlists`
|
||||
- `GET /api/admin/template-requests`
|
||||
- `POST /api/admin/template-requests/:requestId/approve`
|
||||
- `POST /api/admin/template-requests/:requestId/reject`
|
||||
- `POST /api/admin/tierlists/:tierListId/promote-items`
|
||||
- `POST /api/admin/tierlists/:tierListId/create-game-template`
|
||||
- `GET /api/admin/custom-items`
|
||||
@@ -120,32 +164,46 @@
|
||||
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
||||
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
|
||||
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
|
||||
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 최근 티어표 전체를 제목/게임/작성자 기준으로 검색하고 공개 여부를 함께 확인할 수 있다.
|
||||
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
|
||||
- `티어표 관리` 탭의 추가 아이템은 작은 그리드 카드로 표시하고, 클릭 시 `기존 템플릿에 추가 / 새 템플릿 만들기` 모달을 통해 목적지를 선택한다.
|
||||
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
|
||||
- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다.
|
||||
- 관리자 티어표 관리 상단에는 사용자가 보낸 템플릿 등록/업데이트 요청 목록이 별도로 표시되며, 여기서 승인/반려를 바로 처리할 수 있다.
|
||||
- 관리자 템플릿 요청 목록에서 `반려 후 숨김`을 누르면 해당 요청은 pending 목록에서 즉시 제외된다.
|
||||
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
|
||||
- 회원 관리 카드에는 아바타, 작성 티어표 수, 최근 활동 시각을 함께 표시한다.
|
||||
|
||||
## 티어표 접근 메모
|
||||
- `new` 작성 경로는 로그인한 사용자만 진입할 수 있다.
|
||||
- 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다.
|
||||
- 비공개 티어표라도 관리자는 편집 화면에서 완성본을 열람할 수 있다.
|
||||
- 공개 티어표는 목록과 상세 화면에서 즐겨찾기 토글 및 개수를 함께 표시한다.
|
||||
- 카드형 목록에서는 즐겨찾기 수/상태만 표시하고, 실제 토글은 상세 화면에서 처리한다.
|
||||
- 공개 티어표 목록은 현재 게임 기준으로 제목/작성자 검색을 지원한다.
|
||||
- `내 즐겨찾기` 화면에서는 즐겨찾기한 순, 최신 업데이트순, 인기순 정렬을 제공한다.
|
||||
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
|
||||
- 사용자가 직접 추가한 커스텀 아이템 이름은 편집 화면 우측 목록에서 정리할 수 있고, 저장 시 원본 커스텀 아이템 라벨과 함께 동기화된다.
|
||||
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
|
||||
- 티어 행에 넣은 아이템은 작은 제거 버튼으로 다시 우측 아이템 풀로 되돌릴 수 있다.
|
||||
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
||||
- 제목이 비어 있는 상태로 저장하면 내부 제목은 현재 게임명을 기본값으로 사용한다.
|
||||
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
|
||||
- `freeform` 티어표는 커스텀 아이템이 준비된 상태에서 `템플릿 등록 요청`을 보낼 수 있다.
|
||||
- `템플릿 등록 요청` 전에는 체크리스트 모달로 `제목 직접 입력` 여부를 확인하고, 관리자가 식별하기 쉬운 게임 이름을 입력하도록 안내한다.
|
||||
- 신규 티어표를 막 저장한 직후에도, 템플릿 요청은 새로 발급된 실제 티어표 ID를 기준으로 이어서 처리한다.
|
||||
- 기존 게임 티어표는 커스텀 아이템이 포함되어 있으면 `템플릿 업데이트 요청`을 보낼 수 있다.
|
||||
- 티어표는 편집 화면에서 16:9 썸네일 이미지를 별도로 선택해 저장할 수 있고, 목록 카드에서는 그 이미지를 상단 대표 썸네일로 사용한다.
|
||||
- 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다.
|
||||
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
|
||||
- 티어표 편집 화면의 우측 패널은 공통 `rightRail`의 `localRightRailRoot`에 직접 section들을 쌓는 구조이며, 별도 외곽 래퍼 카드 없이 공통 오른쪽 컬럼 문법을 그대로 따른다.
|
||||
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 고정 사이트 타이틀 `Tier Maker by zenn`을 표시한다.
|
||||
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
||||
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
||||
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.
|
||||
- 티어표 목록 메타 정보는 최종 업데이트 시각만 간략하게 표시한다.
|
||||
- 저장 성공 시에는 에디터 안에서 반투명 오버레이 기반 확인 모달을 띄우고, PNG export 이미지는 약 `960px` 보드 폭과 `pixelRatio 1.5`, 외곽 여백, 작성자/날짜 하단 메타를 포함해 생성한다.
|
||||
- 저장/삭제/가져오기 같은 사용자 행동 피드백은 전역 우측 상단 토스트로 표시한다.
|
||||
- 전역 토스트는 동일 메시지/타입이 연속 발생하면 하나로 합쳐 카운트를 올리고, 종료 시 짧은 페이드아웃 애니메이션을 사용한다.
|
||||
- 홈 게임 목록은 관리자 상단 고정 순서가 있으면 그 순서를 먼저 적용하고, 그 외 게임은 최근 생성순으로 뒤에 이어진다.
|
||||
- `커스텀 티어표 만들기`는 카드가 아니라 홈 우측 상단 버튼으로 진입한다.
|
||||
|
||||
|
||||
29
docs/todo.md
@@ -1,17 +1,40 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 즉시 확인 필요
|
||||
- 회원 관리에서 아바타/작성 티어표 수/최근 활동 같은 보조 정보는 아직 표시하지 않는다.
|
||||
- 피그마 기반 리디자인은 현재 공통 셸과 카드 목록 화면, 포커스 화면 안정화까지만 반영된 상태이므로, 에디터/관리자 우측 옵션 패널을 시안 구조에 맞게 실제 기능 패널로 이관하는 작업이 남아 있다.
|
||||
- 홈/게임 허브/내 티어표/즐겨찾기 카드 문법은 어느 정도 통일됐지만, 아직 실제 SVG 아이콘, 미세 간격, hover/selection 상태 같은 디테일은 더 다듬을 필요가 있다.
|
||||
- 목록 화면 상단 도구 막대는 공통 카드 문법으로 거의 맞췄지만, 실제 피그마처럼 필터 토글/정렬 상태를 시각적으로 더 강하게 드러내는 디테일은 남아 있다.
|
||||
- 현재 공통 셸에는 임시 선형 SVG 아이콘을 사용하므로, 최종 머티리얼 아이콘 에셋을 받으면 교체하고 아이콘 크기/정렬을 다시 미세 조정할 필요가 있다.
|
||||
- 공통 셸과 에디터에는 일부 실제 SVG 아이콘을 연결했지만, 아직 즐겨찾기/설정/관리자 등 나머지 내비 아이콘은 임시 선형 SVG이므로 추가 에셋 교체가 남아 있다.
|
||||
- 공통 우측 패널의 토글과 독립 컬럼 구조는 반영되었지만, 현재는 안내 카드 중심이므로 실제 화면별 기능 컨트롤을 이 패널로 옮기는 작업이 남아 있다.
|
||||
- 티어표 편집 화면과 관리자 화면 모두 로컬 우측 패널 구조로 옮겼지만, 아직 세부 카드 밀도와 아이콘/모션 디테일은 피그마 시안 수준으로 더 다듬을 필요가 있다.
|
||||
- 에디터/관리자 로컬 우측 패널은 셸 카드에서 분리됐지만, 아직 실제 피그마처럼 패널 토글 전환 모션과 상태 강조가 더 필요하다.
|
||||
- 에디터 로컬 우측 패널은 공통 토글과 연결됐지만, 아직 완전한 피그마 수준의 패널 애니메이션과 내부 카드 재배치는 더 다듬을 필요가 있다.
|
||||
- 에디터 우측 패널은 셸의 세 번째 컬럼으로 옮겼지만, 내부 카드 간격과 섹션 구분선은 아직 첨부 시안처럼 더 촘촘하게 정리할 필요가 있다.
|
||||
- 에디터 우측 패널 외곽 래퍼는 제거했으므로, 다음 단계는 공통 오른쪽 컬럼 안에서 입력/버튼/구분선 간격을 시안처럼 더 정교하게 다듬는 작업이다.
|
||||
- 공통 56px 셸 헤더는 반영했으므로, 다음 단계는 좌/중앙/우 헤더 안에 실제 아이콘/상태 요소를 시안 순서에 맞게 하나씩 채워 넣는 작업이다.
|
||||
- 좌측 레일은 최근 즐겨찾기와 전역 검색까지 붙었으므로, 다음 단계는 검색 자동완성이나 즐겨찾기 썸네일 품질 같은 디테일을 더 다듬는 작업이다.
|
||||
- 좌측 레일 축소형은 반영했으므로, 다음 단계는 축소 상태에서 관리자/로그인 진입점과 hover 툴팁 같은 보조 UX를 더 다듬는 작업이다.
|
||||
- 좌우 하단 액션 영역은 분리했으므로, 다음 단계는 축소된 왼쪽 레일에서도 관리자/로그인 버튼을 아이콘형으로 어떻게 유지할지 검토할 수 있다.
|
||||
- 홈 게임 카드 메타는 간소화했으므로, 이후 필요하면 게임 썸네일은 상세 허브나 우측 패널처럼 더 맥락이 분명한 위치에만 쓰는 방향을 검토할 수 있다.
|
||||
- 좌우 하단 액션은 항상 보이도록 보정했으므로, 다음 단계는 축소된 레일 상태에서 액션 버튼의 아이콘화 여부를 추가 검토할 수 있다.
|
||||
- 카드 목록은 4열 기준과 메타 줄 구성까지 통일했으므로, 다음 단계는 필터 상태 배지나 hover·selection 강조 같은 상호작용 디테일을 더 다듬는 작업이다.
|
||||
- 검색 결과 화면은 좌측 전역 검색 입력만 쓰도록 정리됐으므로, 다음 단계는 결과 필터/정렬 여부를 검토하는 식으로 확장하면 된다.
|
||||
- 공통 3단 셸 구조는 고정했지만, 관리자/에디터 우측 패널 내부에 아직 바디에 남아 있는 제어 요소를 더 옮겨야 한다.
|
||||
- 홈 화면 우측 사이드는 CTA 하나만 남긴 상태이므로, 이후 필요할 때도 임시 정보 카드 다수를 다시 넣기보다 실제 필요한 기능만 선별해 추가해야 한다.
|
||||
- 관리자 화면은 헤더 요약 통계와 카드 계층까지 정리됐지만, 아직 표준 SVG 아이콘 교체와 더 세밀한 상태 색상/선택 상태 표현은 남아 있다.
|
||||
- 머티리얼 아이콘 SVG는 아직 임시 문자/배지 스타일로 대체된 부분이 있으므로, 최종 아이콘 에셋을 받아 반영하는 작업이 필요하다.
|
||||
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
|
||||
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
|
||||
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
||||
- 티어표 썸네일은 현재 업로드/교체만 지원하므로, 필요하면 자동 썸네일 추출이나 업로드 이미지 크롭 UX를 추가 검토한다.
|
||||
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
|
||||
- 템플릿 등록/업데이트 요청은 현재 승인/반려만 지원하므로, 필요하면 관리자 코멘트나 사용자용 요청 상태 이력 화면을 추가 검토한다.
|
||||
- 관리자 티어표 관리의 템플릿 생성은 현재 `freeform`만 직접 지원하므로, 필요하면 일반 게임 티어표의 전체 아이템을 복제한 파생 템플릿 생성 UX도 검토한다.
|
||||
- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다.
|
||||
- 즐겨찾기는 현재 `내 즐겨찾기` 목록과 정렬까지 지원하므로, 필요하면 폴더 분류나 메모 같은 개인 정리 기능을 추가 검토한다.
|
||||
- 전역 토스트는 기본 시간 기반 자동 종료만 지원하므로, 필요하면 중복 합치기나 액션 링크 포함 형태로 확장할 수 있다.
|
||||
- 전역 토스트는 중복 합치기와 페이드아웃까지 지원하므로, 필요하면 액션 링크나 수동 고정(pin) 같은 상호작용 확장을 검토한다.
|
||||
- 공개 티어표 검색은 현재 게임별 허브 안에서만 제공하므로, 필요하면 홈 전역 통합 검색도 검토한다.
|
||||
- 즐겨찾기 토글은 현재 상세 화면 중심이므로, 필요하면 카드 목록에서도 안전한 보조 인터랙션(예: 길게 누르기, 별도 메뉴)을 검토한다.
|
||||
|
||||
## 배포 전 작업
|
||||
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
|
||||
|
||||
570
docs/update.md
@@ -1,5 +1,385 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-03-31 v1.3.3
|
||||
- `image_assets` 참조를 전수 점검해 아무 곳에서도 사용하지 않는 최적화 이미지 자산만 추려내는 정리 배치 로직을 추가함.
|
||||
- 관리자용 미사용 자산 조회/정리 API를 추가해 오래된 고아 이미지 자산을 미리 확인하거나 실제로 삭제할 수 있도록 확장함.
|
||||
- 관리자 승격/템플릿 생성 과정은 기존 `/uploads/assets/` 자산을 그대로 재사용하도록 바꿔, 불필요한 복제 파일이 다시 생기지 않게 정리함.
|
||||
|
||||
## 2026-03-31 v1.3.2
|
||||
- 업로드 최적화는 이제 백엔드 내부 대기열을 통해 처리되어, 다수 이미지가 한 번에 들어와도 설정된 동시성 안에서 순차적으로 안정적으로 변환되도록 정리함.
|
||||
- `image_optimization_jobs` 작업 기록 테이블을 추가해 queued/processing/completed/failed 상태와 원본·최적화 용량, 재사용 여부, 시작/종료 시각을 저장하도록 확장함.
|
||||
- 현재 라우트 응답 방식은 유지하면서도 내부적으로는 큐를 타도록 구조를 바꿔, 이후 관리자 대시보드와 작업 통계 화면을 바로 얹을 수 있는 기반을 마련함.
|
||||
|
||||
## 2026-03-31 v1.3.1
|
||||
- 최적화된 WebP 결과물 기준으로 SHA-256 해시를 계산해, 같은 이미지가 다시 업로드되면 새 파일을 저장하지 않고 기존 자산을 재사용하도록 중복 이미지 해시 검사를 추가함.
|
||||
- 이미지 자산 메타데이터를 저장하는 `image_assets` 테이블을 도입해 파일 경로, 해시, 원본 대비 최적화 용량, 해상도를 함께 기록하도록 확장함.
|
||||
- 중복 업로드 경쟁 상황에서도 고유 해시 충돌을 안전하게 처리하고, 새 파일 저장에 실패하면 즉시 정리하도록 업로드 헬퍼를 보강함.
|
||||
|
||||
## 2026-03-31 v1.3.0
|
||||
- 백엔드 업로드 파이프라인을 메모리 기반으로 전환하고, 대표 썸네일·게임 썸네일·커스텀 아이템·게임 기본 아이템·아바타를 서버에서 즉시 WebP로 변환해 저장하도록 정리함.
|
||||
- 아이템 이미지는 최대 512px 규격으로 리사이즈하고, 티어표/게임 썸네일은 긴 변 기준 1280px 안쪽으로 최적화해 원본 이미지를 별도로 보관하지 않는 흐름으로 전환함.
|
||||
- 업로드 최적화 공통 헬퍼를 추가해 앞으로 중복 해시 검사, 비동기 최적화 큐, 용량 통계 대시보드를 같은 경로 위에 확장할 수 있는 기반을 마련함.
|
||||
|
||||
## 2026-03-31 v1.2.73
|
||||
- 게임 허브 리스트형 보기의 썸네일을 48px 밀도로 축소해 한 줄이 과하게 커 보이던 인상을 줄이고, 더 많은 티어표를 한눈에 볼 수 있게 조정함.
|
||||
- 깨진 대표 썸네일은 `img` alt 텍스트가 길게 노출되지 않도록 에러 시 즉시 플레이스홀더로 대체하고, 제목/메타 말줄임을 더 보강해 레이아웃 붕괴를 막음.
|
||||
|
||||
## 2026-03-31 v1.2.72
|
||||
- 게임 허브 공개 티어표 목록은 카드 폭과 제목/메타 줄 계산을 다시 조정해, 브라우저 폭에 따라 썸네일과 정보가 카드 밖으로 넘치던 레이아웃 깨짐을 보정함.
|
||||
- 상단 워크스페이스 헤더에 grid/list 보기 토글을 추가하고, 게임 허브는 그리드 카드형과 가로 리스트형을 즉시 전환해 볼 수 있도록 확장함.
|
||||
|
||||
## 2026-03-31 v1.2.71
|
||||
- 게임 허브 공개 티어표 카드는 자동 폭 그리드와 2줄 제목/유연한 메타 배치로 보정해, 브라우저 폭이 줄어들어도 썸네일과 텍스트가 카드 밖으로 넘치지 않도록 정리함.
|
||||
- 공개 티어표 상세에서는 다른 사용자의 티어표를 복사해 내 작업본으로 가져오는 기능을 추가하고, 복사본에는 원본 제목/작성자 정보를 작은 출처 메모로 남기도록 확장함.
|
||||
- 보기 전용 티어표의 미배치 아이템은 더 어둡고 흐리게 표시하고 `미배치` 상태를 붙여, 내 보드처럼 조작 가능한 인상을 줄이도록 보정함.
|
||||
|
||||
## 2026-03-31 v1.2.70
|
||||
- 관리자 게임 관리의 썸네일 드롭존을 카드 안 카드 구조 대신, 썸네일 전체 위에 하단 오버레이 문구를 얹는 단일 미디어 영역으로 정리함.
|
||||
- 게임 관리 본문 상단 안내 패널과 과한 설명 문구를 제거하고, 비선택 상태는 `게임을 선택해 주세요.` 한 줄 중심의 empty 상태로 단순화함.
|
||||
- 새 게임 생성 버튼은 게임 선택과 함께 오른쪽 사이드로 옮겨, 게임 관리 흐름을 선택·생성·썸네일 지정까지 한쪽 패널에서 처리하도록 정리함.
|
||||
|
||||
## 2026-03-31 v1.2.69
|
||||
- 좌우 사이드 축소/확대 시 텍스트를 즉시 `display:none` 처리하던 방식을 줄이고, 폭·투명도 기반 전환으로 바꿔 아이콘이 떨리는 듯한 느낌을 완화함.
|
||||
- 관리자 게임 관리는 오른쪽 사이드에서 게임 선택과 썸네일 지정을 담당하도록 재배치하고, 본문은 기본 아이템 추가/이름 입력/목록 관리에 집중하도록 정리함.
|
||||
- 게임 기본 아이템 추가는 업로드 직후 각 파일 이름을 바로 수정할 수 있는 draft 입력 행을 넣고, 선택한 이름이 서버에 함께 저장되도록 관리자 업로드 API를 확장함.
|
||||
|
||||
## 2026-03-31 v1.2.68
|
||||
- 내 티어표 카드 그리드는 각 카드가 화면 전체 너비를 과도하게 먹지 않도록 최대 폭을 제한해, 1~2개만 있을 때도 적당한 카드 크기를 유지하도록 조정함.
|
||||
- 새 티어표 기본 그룹은 기존 S/A/B/C/D 5줄 대신 S/A/B/C 4줄로 시작하게 바꾸고, 좌우 사이드 토글 아이콘 버튼은 외곽선과 배경을 제거해 더 가볍게 정리함.
|
||||
|
||||
## 2026-03-31 v1.2.67
|
||||
- 홈 화면 게임 템플릿 즐겨찾기 버튼 위치 변경은 유지하면서, 즐겨찾기 on/off 시 카드가 즉시 튀지 않고 부드럽게 재정렬되도록 이동/페이드 전환을 추가함.
|
||||
- 별 아이콘을 눌렀을 때 카드가 즐겨찾기 우선순위 위치로 자연스럽게 이동해 전체 라이브러리 전환감이 덜 거칠게 보이도록 보정함.
|
||||
|
||||
## 2026-03-31 v1.2.66
|
||||
- 내 티어표 카드 하단의 큰 삭제 버튼은 제거하고, 삭제는 상세 편집 화면에서만 하도록 흐름을 단순화함.
|
||||
- 내 티어표 카드 그리드를 고정 4/3/2열에서 `auto-fit` 기반 최소 폭 카드로 바꾸고, 제목/메타가 좁은 화면에서도 말줄임과 유연한 폭 계산을 유지하도록 보정함.
|
||||
|
||||
## 2026-03-31 v1.2.65
|
||||
- 에디터 옵션 토글의 라벨과 스위치 순서를 바꾼 뒤 체크 상태 셀렉터가 끊긴 문제를 보정해, 왼쪽 라벨·오른쪽 스위치 배치에서도 정상 동작하도록 수정함.
|
||||
- 왼쪽 사이드 축소 상태 검색은 전용 모달의 기본 스타일이 빠져 있던 문제를 복구해, 다시 중앙 상단 검색 오버레이로 열리도록 정리함.
|
||||
|
||||
## 2026-03-31 v1.2.64
|
||||
- 메인 콘텐츠가 길어질 때 스크롤 끝이 화면 바닥에 붙지 않도록 중앙 워크스페이스 하단 여백을 추가하고, 긴 작업 화면에서도 마감선이 답답하지 않게 보정함.
|
||||
- 템플릿 요청 모달 입력창을 Settings 화면과 같은 어두운 언더라인 입력 문법으로 통일하고, 에디터의 공개/이름 표시 옵션은 체크박스 대신 스위치형 토글로 재구성함.
|
||||
|
||||
## 2026-03-31 v1.2.63
|
||||
- 앱 셸과 워크스페이스에 걸려 있던 고정 `100dvh` 높이를 풀어, 본문이 길어질 때 중앙 `main` 영역이 잘리거나 접히는 현상을 보정함.
|
||||
- 좌우 레일은 그대로 화면 기준 높이를 유지하되, 중앙 작업 영역은 내용만큼 자연스럽게 늘어나도록 높이 계산을 다시 정리함.
|
||||
|
||||
## 2026-03-31 v1.2.62
|
||||
- 템플릿 요청 모달의 제목/설명 입력을 Settings 화면과 같은 어두운 입력 문법으로 맞춰 흰 배경/흰 글자처럼 보이던 문제를 정리함.
|
||||
- 앱 셸은 사이드 기본 바탕색을 중심으로 재정리하고, 중앙 바디에 배경과 좌우 보더를 줘 긴 스크롤에서도 사이드가 잘리는 듯한 인상을 줄이도록 조정함.
|
||||
|
||||
## 2026-03-31 v1.2.61
|
||||
- Game Library 왼쪽 검색을 전체 티어표 검색이 아니라 게임 템플릿 검색으로 바꾸고, 홈 화면에서 검색어에 맞는 게임만 필터링하도록 조정함.
|
||||
- 게임 템플릿에 사용자별 즐겨찾기 별 아이콘을 추가하고, 즐겨찾기한 게임이 관리자 고정 순서보다 우선 노출되도록 백엔드와 홈 화면을 함께 확장함.
|
||||
- 앱 셸의 100vh 높이 계산을 100dvh와 고정 행 구조로 정리해, 콘텐츠가 없어도 생기던 불필요한 세로 스크롤을 줄임.
|
||||
|
||||
## 2026-03-31 v1.2.60
|
||||
- 관리자 티어표 관리 카드에서 사용자가 입력한 설명을 제목 아래에 함께 노출해 요청 의도를 더 빨리 파악할 수 있게 함.
|
||||
- 템플릿 등록/업데이트 요청은 이제 에디터 모달에서 제목과 설명을 별도로 입력받고, 예시 문구와 함께 전송하도록 정리함.
|
||||
|
||||
## 2026-03-31 v1.2.59
|
||||
- 관리자 아이템 상세 모달의 게임 선택을 전용 상태로 분리해 기본 선택값이 비어 있도록 바꾸고, 썸네일 아래에 배치해 정보/액션과 시각적으로 분리함.
|
||||
- 커스텀 아이템이 실제로 사용 중인 게임 목록을 백엔드에서 함께 내려주고, 템플릿 요청 생성 폼에는 게임 ID와 게임 이름 라벨을 추가해 구분을 명확히 함.
|
||||
|
||||
## 2026-03-31 v1.2.58
|
||||
- 관리자 아이템 관리 카드를 썸네일과 제목만 보이는 compact 카드로 줄여, 대량 업로드된 이미지도 훨씬 높은 밀도로 탐색할 수 있게 정리함.
|
||||
- 카드 클릭 시 상세 정보를 모달로 열고 이미지 다운로드, 기본 템플릿 추가, 삭제를 모달 안에서 결정하는 흐름으로 바꿈.
|
||||
|
||||
## 2026-03-31 v1.2.57
|
||||
- 관리자 오른쪽 사이드에서 Featured, Game Summary, Users 패널을 완전히 제거하고, 티어표 요청 모드에는 모드 전환 탭만 남기도록 정리함.
|
||||
|
||||
## 2026-03-31 v1.2.56
|
||||
- 관리자 아이템 관리 카드 그리드에 최대 폭을 줘서 결과가 1~2개일 때 카드가 과하게 늘어나지 않도록 조정함.
|
||||
- 관리자 오른쪽 사이드에서 Featured, Game Summary, Users 요약 패널과 티어표 요청 새로고침/대기 개수 영역을 제거해 중복 정보를 정리함.
|
||||
|
||||
## 2026-03-31 v1.2.55
|
||||
- 관리자 게임 관리 썸네일 입력을 파일 버튼 대신 클릭/드래그형 드롭존으로 바꿔 에디터 쪽 업로드 경험과 맞춤.
|
||||
- 관리자 아이템 관리 카드를 세로 카드 구조로 재정리해 긴 파일명과 버튼 문구에도 레이아웃이 무너지지 않도록 보정함.
|
||||
|
||||
## 2026-03-31 v1.2.54
|
||||
- 관리자 게임 상세 로딩 전에 호출되던 preview reset helper를 복구해, 게임 선택 시 런타임 오류로 상세 패널이 비어 있던 문제를 보정함.
|
||||
- 선택 실패 시 원인을 더 쉽게 확인할 수 있도록 로딩 실패 안내와 콘솔 에러 로그를 추가함.
|
||||
|
||||
## 2026-03-31 v1.2.53
|
||||
- 관리자 게임 관리에서 새 게임 만들기 카드를 제거하고, 헤더 버튼으로 여는 모달 기반 생성 흐름으로 정리함.
|
||||
- 게임 선택은 명시적인 변경 핸들러로 다시 묶어 선택 즉시 상세 정보를 불러오도록 보강함.
|
||||
|
||||
## 2026-03-31 v1.2.52
|
||||
- 관리자 게임 관리에서 선택 이벤트를 놓치지 않도록 `selectedGameId`와 탭 진입 시점을 감시해 상세 정보를 자동으로 다시 불러오도록 보정함.
|
||||
- 선택 후 잠시 비어 보이던 구간을 줄이기 위해 로딩 상태와 선택된 게임 ID 안내를 추가함.
|
||||
|
||||
## 2026-03-31 v1.2.51
|
||||
- 운영 비밀값이 들어 있는 `.env.production`과 로컬 에디터 설정 `.vscode/`를 `.gitignore`에 추가해 푸시 대상에서 제외함.
|
||||
|
||||
## 2026-03-31 v1.2.50
|
||||
- 관리자 회원 아바타 삭제 버튼 조건을 명확히 하고 hover 표시를 visibility까지 포함해 보정해 다른 사용자 카드에서도 안정적으로 노출되도록 조정함.
|
||||
- 삭제 배지 아이콘을 흰색으로 보정하고 어두운 배경 위에서 더 잘 보이도록 스타일을 다듬음.
|
||||
|
||||
## 2026-03-31 v1.2.49
|
||||
- 관리자 회원 저장 후 통계 정보가 흔들리던 문제를 줄이기 위해 저장/아바타 변경 뒤 회원 목록을 다시 동기화하도록 보정함.
|
||||
- 회원 아바타 액션을 hover 기반으로 재배치해 평소에는 숨기고, 마우스 오버 시에만 수정 오버레이와 삭제 버튼이 나타나도록 조정함.
|
||||
|
||||
## 2026-03-31 v1.2.48
|
||||
- 관리자 회원 관리 배지를 Settings 화면의 Administrator 스타일로 통일하고, 카드 우측 상단에 걸치는 형태로 재배치함.
|
||||
- 관리자 권한 체크박스를 제거하고 작은 텍스트 액션과 확인 모달을 거쳐 draft 상태만 바꾸는 흐름으로 정리함.
|
||||
|
||||
## 2026-03-31 v1.2.47
|
||||
- 관리자 회원 관리에서 비밀번호 초기화와 삭제를 실제 모달 플로우로 연결하고, 저장 버튼은 회원 정보 변경 시에만 활성화되도록 정리함.
|
||||
- 상단 휴지통 아이콘과 불필요 문구를 제거하고, 관리자도 회원 썸네일을 카드 안에서 바로 수정/삭제할 수 있게 보완함.
|
||||
|
||||
## 2026-03-31 v1.2.46
|
||||
- **회원 액션 플로우 수정**: 회원 카드의 불필요한 안내 문구와 상단 삭제 아이콘을 제거하고, 비밀번호 초기화/회원 삭제를 각각 전용 확인 모달로 재구성
|
||||
- **저장 버튼 활성 조건 정리**: 회원정보 저장은 필드가 실제로 바뀐 경우에만 활성화되고, 비밀번호 초기화와 삭제 아이콘은 즉시 사용할 수 있도록 조정
|
||||
|
||||
## 2026-03-31 v1.2.45
|
||||
- **회원 카드 액션 재구성**: 비밀번호 초기화와 회원 삭제를 아이콘 액션으로 축소하고, `회원정보 저장` 버튼은 실제 변경이 있을 때만 활성화되도록 조정
|
||||
- **관리자 아바타 편집 지원**: 관리자도 회원 아바타를 클릭해 변경하거나 삭제할 수 있도록 전용 업로드 API와 카드 UI를 추가
|
||||
|
||||
## 2026-03-31 v1.2.44
|
||||
- **관리자 탭 구조 재정리**: `목록 관리`와 `게임 관리`를 분리하고, 게임 생성/선택 흐름을 우측 사이드가 아닌 본문 전용 작업 화면으로 이동
|
||||
- **회원/액션 레이아웃 정리**: 회원 카드의 작성 수/최근 활동을 텍스트형 정보로 단순화하고, 관리 버튼의 줄바꿈이 어색하지 않도록 액션 그리드를 보정
|
||||
|
||||
## 2026-03-31 v1.2.43
|
||||
- **이름 표시 옵션 추가**: 티어 에디터 우측 옵션에 `캐릭터 이름 표시` 토글을 추가하고, 보드 안에서는 이미지 하단 오버레이 라벨로 표시되도록 개선
|
||||
- **저장/불러오기 연동**: 이름 표시 옵션이 저장된 티어표와 다운로드 이미지에도 그대로 반영되도록 프런트/백엔드 저장 구조를 확장
|
||||
|
||||
## 2026-03-31 v1.2.42
|
||||
- **에디터 보드 폭 기준 정리**: 티어표 보드 영역을 저장 이미지 기준에 맞춰 최대 약 `960px` 폭으로 묶고, 넓은 화면에서는 아이템 풀이 남는 공간을 더 가져가도록 조정
|
||||
- **아이템 풀 카드형 통일**: 넓은 화면에서도 우측 아이템 목록을 카드형 그리드로 바꿔 한 번에 더 많은 아이템을 보고 드래그할 수 있도록 개선
|
||||
|
||||
## 2026-03-31 v1.2.41
|
||||
- **에디터 하단 아이템 풀 카드형 전환**: 브라우저 폭이 `980px` 이하로 줄어 아이템 풀이 티어표 아래로 내려오면, 세로 리스트 대신 `이미지 위 / 이름 아래` 카드형 그리드로 전환되도록 조정
|
||||
- **소형 폭 열 수 최적화**: 약 `800px` 전후에서는 6열 그리드가 유지되고, 더 작은 폭에서는 4열/3열로 자연스럽게 줄어들며 긴 이름은 가운데 정렬된 말줄임 형태로 보이도록 정리
|
||||
|
||||
## 2026-03-31 v1.2.40
|
||||
- **목록 카드 메타 정리**: `내 티어표`, `즐겨찾기`, `검색 결과`, `게임 목록` 카드의 작성자 썸네일을 원형으로 통일하고, 메타 행 간격과 날짜 크기(`10px`)를 조정했으며 날짜 정렬을 위해 `boardCard__metaRow`를 `align-items: flex-end`로 보정
|
||||
- **게임 허브 CTA 좌측 하단 이동**: 게임 목록 화면의 `새 티어표 만들기` 버튼을 오른쪽 사이드에서 제거하고, 왼쪽 하단 액션 영역으로 옮겨 관리자 메뉴와 같은 버튼 문법으로 정리
|
||||
- **필수 우측 패널 자동 열기**: 티어 메이커/관리자처럼 오른쪽 사이드 사용이 필요한 페이지는 패널이 닫혀 있더라도 진입 시 자동으로 열리게 해, 도구 접근성과 이후 광고 노출 흐름을 함께 보정
|
||||
|
||||
## 2026-03-31 v1.2.39
|
||||
- **홈 하단 액션 재배치**: 홈 오른쪽 사이드의 `커스텀 티어표 만들기` CTA를 제거하고, 로그인/관리자 메뉴가 있던 왼쪽 하단 액션 영역으로 옮겨 같은 버튼 문법으로 정리
|
||||
- **우측 중복 액션 축소**: 일반 화면에서 중복되던 `로그인 하러가기` 계열 우측 CTA는 제거하고, 오른쪽 레일은 광고/도구 용도로만 유지하도록 단순화
|
||||
- **회원가입 확인 입력 추가**: 로그인 화면 회원가입 모드에 비밀번호 확인 필드를 추가하고, 버튼 문구를 `로그인 / 가입하기 / 취소` 같은 한글 흐름으로 정리
|
||||
|
||||
## 2026-03-31 v1.2.38
|
||||
- **로그인 화면 문법 통일**: 로그인/회원가입 화면을 기존 카드형에서 Settings와 같은 단일 컬럼 계정 설정 스타일로 재구성해 두 화면의 톤을 통일
|
||||
- **일반 우측 레일 광고 슬롯 전환**: 에디터/관리자처럼 실제 도구가 필요한 화면을 제외하면 오른쪽 레일은 중복 액션 버튼 대신 AdSense 수직형 반응형 슬롯을 기본으로 표시하도록 정리
|
||||
|
||||
## 2026-03-31 v1.2.37
|
||||
- **대표 썸네일 드래그 업로드 추가**: 우측 대표 썸네일 영역도 드래그앤드롭으로 이미지를 받을 수 있게 하고, 여러 파일을 드롭하면 첫 번째만 사용된다는 안내 토스트를 표시하도록 수정
|
||||
- **삭제/업데이트 요청 액션 경량화**: 우측 하단의 삭제와 템플릿 업데이트 요청을 무거운 정식 버튼 대신 작은 보조 링크형 액션으로 정리해 실제 주 행동과 시각적으로 분리
|
||||
- **확인 모달 보강**: 템플릿 업데이트 요청과 티어표 삭제는 이제 브라우저 기본 얼럿 대신 전용 확인 모달을 통해 안내 후 진행되도록 변경
|
||||
|
||||
## 2026-03-31 v1.2.36
|
||||
- **축소 검색 모달 재정의**: 좌측 레일 축소 상태에서는 검색 아이콘 클릭 시 카드형 다이얼로그 대신, 화면 중앙보다 약간 위에 뜨는 단일 검색 바와 은은한 암전 오버레이로 재구성하고 `ESC`/바깥 클릭으로 닫을 수 있게 보정
|
||||
- **드롭 영역 위치 재조정**: 커스텀 이미지 추가 영역을 전체 `editorCanvas` 하단이 아니라 왼쪽 티어표 컬럼 내부의 보드 바로 아래로 옮겨, 오른쪽 아이템 목록 길이와 무관하게 가까운 위치에서 추가할 수 있도록 수정
|
||||
|
||||
## 2026-03-31 v1.2.35
|
||||
- **축소 좌측 검색 동작 수정**: 접힌 상태의 검색 아이콘은 이제 즉시 모달을 열고, 일반 상태에서만 폼 제출이 되도록 분기해 실제 팝업이 보이도록 수정
|
||||
- **우측 레일 높이 제한 해제**: 공통 `max-height: calc(100vh - 56px)` 규칙은 왼쪽 레일에만 남기고, 오버레이 상태를 포함한 오른쪽 레일은 별도 높이 제한 없이 내용 전체가 자연스럽게 흐르도록 조정
|
||||
- **커스텀 업로드 영역 하단 이동**: 커스텀 이미지 드래그 영역과 파일 선택 버튼을 아이템 풀 아래가 아니라 티어표 섹션 하단으로 옮겨, 긴 아이템 목록과 충돌하지 않도록 정리
|
||||
|
||||
## 2026-03-31 v1.2.34
|
||||
- **축소 좌측 검색 팝업 추가**: 왼쪽 레일이 접힌 상태에서 검색 아이콘을 누르면 즉시 검색 입력이 가능한 모달 팝업이 뜨도록 바꾸고, 셸 톤에 맞는 블러/글래스 스타일로 정리
|
||||
- **에디터 빈 우측 섹션 제거**: 티어 메이커 우측 패널의 네 번째 빈 박스는 `즐겨찾기` 버튼 래퍼였고, 조건이 맞지 않을 때 박스만 남지 않도록 섹션 자체를 조건부 렌더링으로 수정
|
||||
- **우측 레일 스크롤 구조 완화**: 오른쪽 패널은 이제 본문 전체가 자연스럽게 세로 스크롤되고, 로컬 패널 루트의 불필요한 최소 높이를 제거해 내용이 늘어나도 잘려 보이는 느낌을 줄임
|
||||
|
||||
## 2026-03-31 v1.2.33
|
||||
- **우측 패널 토글 위치 보정**: 소형 해상도에서도 오른쪽 패널 열기 버튼이 본문 아래로 내려가지 않도록 워크스페이스 헤더 최상단 액션 영역으로 이동
|
||||
- **모바일 좌측 레일 단순화**: 모바일에서는 좌측 레일 접기 버튼을 숨기고, 축소 상태가 남아 있더라도 텍스트와 사용자 메타를 다시 보여주도록 보정해 아이콘만 덩그러니 남는 상황을 제거
|
||||
- **모바일 축소 상태 자동 해제**: 화면 폭이 모바일 범위로 들어오면 좌측 레일 축소 상태를 자동으로 풀어, 작은 화면에서는 항상 읽을 수 있는 메뉴 형태를 유지
|
||||
|
||||
## 2026-03-31 v1.2.32
|
||||
- **왼쪽 레일 축소 상태 재정의**: 축소 시 사용자 정보는 아바타만 남기고, 메뉴는 아이콘만 보이도록 숨김 처리해 중앙 정렬이 자연스럽게 되도록 정리
|
||||
- **축소 레일 검색/관리자 처리 보정**: 접힌 상태에서는 검색 입력을 숨기고 아이콘 중심으로 단순화했으며, 아이콘이 없는 하단 관리자 버튼은 축소 모드에서 숨김 유지
|
||||
- **우측 패널 소형 해상도 오버레이 전환**: `1200px` 이하에서는 오른쪽 패널을 고정 컬럼 대신 오버레이 패널로 띄우고, 본문 상단 쪽에 다시 열기 버튼을 배치해 패널을 잃어버리지 않도록 수정
|
||||
|
||||
## 2026-03-31 v1.2.31
|
||||
- **사이드 아이콘 에셋 정리**: 좌측 `Favorites` 메뉴도 제공된 `favorite.svg`를 사용하도록 바꿔, 다른 사이드 아이콘 및 패널 토글 SVG와 같은 자산 흐름으로 통일
|
||||
- **프로필 아바타 삭제 UX 개선**: `Settings`에서 텍스트형 `이미지 제거` 버튼을 없애고, 아바타 썸네일 우측 상단의 고정 아이콘 버튼으로 삭제하도록 변경해 레이아웃 흔들림을 제거
|
||||
- **셸 코드 정리**: `App.vue`의 비어 있던 감시 코드를 제거해 현재 사용자 수정 위에 불필요한 잔여 로직이 남지 않도록 정리
|
||||
|
||||
## 2026-03-31 v1.2.30
|
||||
- **왼쪽 즐겨찾기 섹션 제거**: 좌측 레일의 `즐겨찾기 보기` 섹션을 삭제하고, 상단 내비의 즐겨찾기 메뉴만 진입점으로 유지
|
||||
- **Settings 화면 리디자인**: 프로필 설정 화면을 카드형 대신 단일 컬럼의 미니멀한 계정 설정 레이아웃으로 재구성
|
||||
- **아바타 클릭 업로드/삭제 UX**: 파일 input 노출을 없애고, 아바타를 클릭해 이미지 업로드와 제거를 처리하는 최근 앱 스타일 인터랙션으로 변경
|
||||
- **백엔드 아바타 제거 지원**: 프로필 저장 API가 아바타 삭제 요청도 함께 처리하도록 확장
|
||||
|
||||
## 2026-03-30 v1.2.29
|
||||
- **왼쪽 즐겨찾기 목록 제거**: 좌측 레일의 최근 즐겨찾기 목록과 관련 데이터 로딩 로직을 제거하고, `즐겨찾기 보기` 링크만 유지하도록 단순화
|
||||
- **불필요한 즐겨찾기 API 호출 제거**: 사이드바 표시만을 위해 수행되던 즐겨찾기 목록 요청을 없애 초기 렌더 비용을 줄임
|
||||
|
||||
## 2026-03-30 v1.2.28
|
||||
- **사이드 스크롤 영역 재분리**: 좌우 레일에서 스크롤되는 콘텐츠 영역과 하단 액션 영역을 분리해, 상단 헤더 높이와 무관하게 버튼이 항상 최초 화면 안에 보이도록 수정
|
||||
- **레일 바디 overflow 구조 수정**: 레일 전체가 아니라 내부 콘텐츠만 스크롤되게 바꿔, 하단 버튼이 다시 스크롤 아래로 밀리는 문제를 해소
|
||||
|
||||
## 2026-03-30 v1.2.27
|
||||
- **사이드 하단 버튼 즉시 노출**: 좌우 하단 액션 버튼을 별도 푸터가 아니라 각 레일의 스크롤 바디 안으로 옮기고, 남는 공간을 밀어내는 spacer 구조로 바꿔 스크롤 없이도 처음부터 하단에 보이도록 수정
|
||||
- **56px 하단 여백 제거**: 기존 고정 푸터 높이와 추가 하단 패딩을 제거해, 하단 액션이 자연스럽게 레일 마지막 줄에 붙도록 정리
|
||||
|
||||
## 2026-03-30 v1.2.26
|
||||
- **페이지 헤더 정렬 통일**: `Games`, `내 리스트`, `즐겨찾기`, `Settings` 화면이 모두 같은 전역 헤더 문법과 높이를 사용하도록 정리해, 페이지 이동 시 상단 블록 위치가 미묘하게 흔들리던 문제를 완화
|
||||
- **헤더 내부 패딩 제거**: 워크스페이스 본문에 이미 좌우 여백이 있는 점을 반영해, 각 페이지 헤더 내부의 작은 추가 패딩을 제거하고 동일한 배치 규칙으로 맞춤
|
||||
- **Settings 헤더 문법 통일**: 프로필 화면도 다른 목록 화면과 동일한 eyebrow/title/description 구조를 갖도록 보강해 전체 화면 톤을 통일
|
||||
|
||||
## 2026-03-30 v1.2.25
|
||||
- **홈 게임 카드 썸네일 복구**: 메인 게임 선택 카드는 상단 메인 썸네일을 다시 표시하고, 하단 ID 라인 옆의 작은 보조 표시만 제거하도록 보정
|
||||
- **사이드 하단 버튼 고정 가시성 보정**: 좌우 하단 액션 버튼이 스크롤을 해야 보이지 않던 문제를 수정하고, 버튼 자체는 항상 보이면서 아래쪽 여백만 확보되도록 조정
|
||||
|
||||
## 2026-03-30 v1.2.24
|
||||
- **내 티어표 상단 stat 제거**: `내 티어표` 화면 헤더 오른쪽의 저장 개수 stat 카드를 제거해 제목/설명만 남도록 단순화
|
||||
- **홈 게임 카드 메타 단순화**: 게임 선택 카드에서 썸네일과 점형 메타를 제거하고, 한글 게임 제목과 아래 작은 ID만 보이는 형태로 정리
|
||||
- **좌우 하단 액션 여백 보정**: 왼쪽 로그인/관리자 버튼과 오른쪽 빠른 액션 버튼은 바닥에 바로 붙지 않도록 하단에 추가 여백을 확보
|
||||
|
||||
## 2026-03-30 v1.2.23
|
||||
- **홈 게임 카드 4열 정리**: 메인 게임 목록 화면도 카드형 레이아웃에서 데스크톱 기준 기본 4열로 보이도록 그리드를 조정
|
||||
- **게임 허브 중복 생성 CTA 제거**: 게임 선택 화면 본문 상단의 `새로운 티어표 만들기` 버튼을 제거하고, 우측 사이드 하단 CTA만 유지하도록 정리
|
||||
- **좌우 하단 액션 영역 분리**: 왼쪽 `관리자 메뉴/로그인`과 오른쪽 빠른 액션 버튼을 각각 독립된 하단 `56px` 영역에 배치해, 본문/스크롤 영역과 분리된 고정 액션 위치로 통일
|
||||
|
||||
## 2026-03-30 v1.2.22
|
||||
- **왼쪽 사이드 축소/확대 추가**: 좌측 레일을 완전히 숨기지 않고 축소형 내비로 접었다 펼 수 있게 바꾸고, 접힌 상태에서는 아이콘 중심으로만 보이도록 레이아웃을 정리
|
||||
- **좌우 패널 토글 아이콘 통일**: 오른쪽 패널 열기/닫기는 모두 `dock_to_left`, 왼쪽 패널 토글은 `dock_to_right` 아이콘만 사용하도록 통일
|
||||
- **전역 검색 아이콘 교체**: 좌측 전역 검색 입력에 사용자가 추가한 `search.svg`를 실제 아이콘으로 연결
|
||||
|
||||
## 2026-03-30 v1.2.21
|
||||
- **티어표 카드 문법 통일**: 게임 허브, 검색 결과, 내 티어표, 즐겨찾기 목록의 카드 레이아웃을 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 2줄 메타 구조로 통일하고, 데스크톱 기준 한 줄 4개 카드가 보이도록 재배치
|
||||
- **즐겨찾기 화면 날짜 기준 단순화**: `내 즐겨찾기` 화면은 더 이상 즐겨찾기한 시각을 표시하지 않고, 정렬 기준과 무관하게 덱의 마지막 수정일만 카드에 노출하도록 정리
|
||||
- **좌측 사용자 카드/즐겨찾기 밀도 보정**: 좌측 사용자 아바타를 원형 보더 스타일로 통일하고, `Favorites` 바로가기 섹션은 메인 메뉴보다 덜 강조되도록 썸네일·텍스트·간격을 한 단계 축소
|
||||
|
||||
## 2026-03-30 v1.2.20
|
||||
- **검색 결과 상단 툴바 제거**: `/search` 화면의 중복 검색 폼을 제거하고, 좌측 전역 검색 입력만 검색 진입점으로 사용하도록 단순화
|
||||
- **왼쪽 즐겨찾기 더보기 아이콘 교체**: 사용자가 추가한 `more.svg`를 좌측 `즐겨찾기 더 보기` 링크 아이콘에 연결
|
||||
- **중앙 본문 외곽 레이어 제거**: `workspaceBody`의 추가 패딩, 테두리, 둥근 카드 배경을 제거해 중앙 콘텐츠가 한 겹만 안쪽으로 들어온 것처럼 보이도록 셸 여백을 단순화
|
||||
- **게임 허브 상단 통계 제거**: 게임별 티어표 목록 화면의 `dashboardStat` 카드를 제거해 상단 헤더를 CTA 중심으로 정리
|
||||
- **우측 패널 토글 동작 정리**: 중앙 헤더에는 패널이 닫혀 있을 때만 열기 아이콘 버튼을, 우측 헤더에는 패널이 열려 있을 때만 닫기 아이콘 버튼을 표시하도록 토글 흐름을 재구성
|
||||
|
||||
## 2026-03-30 v1.2.19
|
||||
- **왼쪽 레일 설정 흐름 단순화**: 사용자 카드 클릭 팝업을 제거하고, 설정은 좌측 `Settings` 메뉴에서만 진입하도록 정리했으며 프로필 화면 하단에 로그아웃 버튼을 추가
|
||||
- **좌측 즐겨찾기 바로가기 추가**: 좌측 `Favorites` 영역에 최근 즐겨찾기 티어표 최대 10개를 바로가기 형태로 표시하고, 하단 `즐겨찾기 더 보기` 링크로 전체 즐겨찾기 화면에 연결
|
||||
- **전역 공개 티어표 검색 추가**: 좌측 검색 입력은 이제 전체 공개 티어표를 대상으로 검색하며, 새 `/search` 결과 화면에서 제목/작성자 기준 검색 결과를 카드 목록으로 표시
|
||||
- **설정 아이콘 반영 및 중복 관리자 버튼 제거**: 사용자가 추가한 `settings.svg`를 좌측 `Settings` 메뉴에 연결하고, 상단 내비에 중복되던 관리자 메뉴 항목은 제거
|
||||
|
||||
## 2026-03-30 v1.2.18
|
||||
- **공통 56px 셸 헤더 도입**: 좌측 사이드, 중앙 워크스페이스, 우측 사이드 상단에 각각 높이 `56px`의 고정 헤더 블록을 두고, 사이트 타이틀 `Tier Maker by zenn`은 중앙 상단 헤더에만 표시되도록 셸 구조를 재정리
|
||||
- **에디터 메인 래퍼 단순화**: 티어표 편집 화면의 `.layout` 2열 그리드를 제거해 공통 3단 셸 바깥에 중복 컬럼이 생기지 않도록 정리
|
||||
- **아이템 라벨 overflow 수정**: 편집 화면 우측 아이템 풀에서 긴 아이템 이름이 화면 밖으로 밀려나지 않도록 `minmax(0, 1fr)`와 말줄임 처리 기준을 추가
|
||||
|
||||
## 2026-03-30 v1.2.17
|
||||
- **에디터 우측 패널 래퍼 제거**: 티어표 편집 화면의 `editorSidebar` 외곽 래퍼를 제거하고, 공통 오른쪽 레일 루트에 편집 섹션들이 직접 쌓이도록 구조를 단순화
|
||||
- **공통 우측 레일 정렬 통일**: `App.vue`의 `localRightRailRoot`에 섹션 스택 정렬을 부여해, 에디터/관리자 같은 로컬 패널 화면도 공통 레일 안에서 같은 방식으로 콘텐츠가 배치되도록 정리
|
||||
|
||||
## 2026-03-30 v1.2.16
|
||||
- **메인 오른쪽 사이드 단순화**: 홈 화면 기준 오른쪽 컬럼의 컨텍스트/계정/점프 카드 3종을 제거하고, 시안에 맞춰 핵심 CTA 버튼만 남기는 구조로 단순화
|
||||
- **홈 상단 중복 도구 제거**: 중앙 바디 상단에 추가돼 있던 `Visible Games`, `Account`, `즐겨찾기 보기`, `내 리스트 보기`, `커스텀 티어표 만들기` 도구 막대를 제거해, 왼쪽/오른쪽 사이드와 중복되는 이동 요소를 정리
|
||||
|
||||
## 2026-03-30 v1.2.15
|
||||
- **3단 셸 구조 고정**: 홈 화면처럼 `왼쪽 사이드 | 중앙 컨텐츠 | 오른쪽 사이드` 3단 레이아웃을 모든 일반 페이지의 공통 구조로 고정하고, 페이지 이동 시 오른쪽 컬럼이 사라졌다 나타나는 구조를 제거
|
||||
- **에디터/관리자 우측 패널 공통 컬럼 통합**: 티어표 편집과 관리자 화면의 로컬 우측 패널을 Teleport로 공통 오른쪽 컬럼에 배치해, 바디 내부 2단 레이아웃 대신 셸의 세 번째 컬럼을 공유하도록 재정리
|
||||
|
||||
## 2026-03-30 v1.2.14
|
||||
- **에디터 우측 패널 셸 컬럼 이관**: 티어표 편집 화면의 `editorSidebar`를 `workspaceBody` 내부 보조 칼럼이 아니라 공통 셸의 세 번째 컬럼으로 옮겨, 메인 화면과 같은 `왼쪽 사이드 | 메인 | 오른쪽 사이드` 구조를 사용하도록 재배치
|
||||
- **공통 토글과 실제 aside 연결**: 상단 패널 토글 버튼은 이제 Teleport로 이동한 에디터 우측 aside를 직접 접고 펴며, 본문 내부 2단 레이아웃처럼 보이던 구조를 제거
|
||||
|
||||
## 2026-03-30 v1.2.13
|
||||
- **에디터 우측 패널 회귀 수정**: 공통 패널 상태를 템플릿에서 잘못 참조해 `editorSidebar`가 항상 닫힌 상태로 계산되던 문제를 수정해, 제목/설명/썸네일/저장 패널이 다시 정상 표시되도록 복구
|
||||
|
||||
## 2026-03-30 v1.2.12
|
||||
- **에디터 우측 패널 토글 연결**: 공통 상단의 패널 토글 버튼이 이제 티어표 편집 화면의 `editorSidebar`에도 직접 연결되어, 숨기면 우측 패널이 접히고 중앙 보드 영역이 넓어지도록 수정
|
||||
- **로컬 우측 패널 컬럼 충돌 방지**: 에디터/관리자처럼 로컬 우측 패널을 쓰는 화면에서는 공통 `rightClosed` 셸 컬럼 계산이 다시 끼어들지 않도록 예외 처리를 추가해 레이아웃이 다시 틀어지지 않게 보정
|
||||
|
||||
## 2026-03-30 v1.2.11
|
||||
- **에디터 로컬 우측 패널 분리 보정**: 에디터/관리자처럼 로컬 우측 패널을 쓰는 화면은 공통 `workspaceBody` 카드 컨테이너를 벗기고, 로컬 패널이 중앙 본문 안쪽이 아니라 독립 컬럼처럼 보이도록 셸 구조를 조정
|
||||
- **에디터 우측 컬럼 간격 보정**: 티어표 편집 화면의 `editorSidebar`가 본문 내부 보조 박스처럼 눌리지 않도록 간격과 최소 폭을 정리해 우측 사이드바 역할이 더 분명하게 보이도록 수정
|
||||
|
||||
## 2026-03-30 v1.2.10
|
||||
- **목록 화면 상단 툴바 밀도 통일**: 홈, 게임 허브, 내 티어표, 즐겨찾기 상단 영역의 통계 카드와 액션 버튼 높이/반경/배경을 맞춰 공통 셸과 같은 도구 막대 문법으로 정리
|
||||
- **홈 빠른 진입 흐름 보정**: 홈 화면 툴바에서 중복되던 버튼 흐름을 `즐겨찾기 / 내 리스트 / 커스텀 티어표 만들기` 중심으로 재구성해 실제 사용 동선에 맞게 정리
|
||||
- **목록 카드 인터랙션 보강**: 주요 카드 목록에 일관된 hover 이동과 배경 전환을 넣어, 대시보드 카드가 더 또렷하게 반응하도록 조정
|
||||
|
||||
## 2026-03-30 v1.2.9
|
||||
- **관리자 대시보드 헤더 보강**: 관리자 화면 상단에 현재 탭 기준 요약 통계 카드를 추가해, 게임/아이템/티어표/회원 상태를 즉시 읽을 수 있게 정리
|
||||
- **운영 패널 질감 정리**: 우측 `320px` 운영 패널의 탭, 입력, 통계 카드, 버튼 라운드/배경/호버 상태를 공통 셸 톤에 맞춰 더 두꺼운 대시보드 카드 문법으로 통일
|
||||
- **관리 카드 밀도 개선**: 게임 상세, 커스텀 아이템, 템플릿 요청, 전체 티어표, 회원 카드의 배경층·패딩·반경을 함께 다듬어 시안에 가까운 평평한 관리용 레이아웃으로 보정
|
||||
|
||||
## 2026-03-30 v1.2.8
|
||||
- **실제 SVG 아이콘 연결 시작**: 사용자가 추가한 `grid_view`, `lists`, `dock_to_left`, `dock_to_right` 아이콘을 공통 셸 내비와 우측 패널 토글에 연결해 문자 기반 아이콘을 일부 실제 에셋으로 교체
|
||||
- **에디터 3열 구조 복구**: 티어표 편집 화면을 `보드 / 아이템 풀 / 우측 편집 사이드바` 구조로 재배치해, 아이템 풀은 보드 옆에서 바로 드래그 가능하고 편집 옵션은 최우측 패널에만 남도록 수정
|
||||
- **커스텀 아이템 이름 정리 위치 조정**: 커스텀 아이템 이름 수정 목록은 드래그용 아이템 풀 아래가 아니라 우측 편집 사이드바 안으로 옮겨, 보드 배치 흐름과 옵션 정리 흐름을 분리
|
||||
|
||||
## 2026-03-30 v1.2.7
|
||||
- **공통 셸 아이콘형 정리**: 좌측 내비와 우측 보조 패널의 임시 문자 배지를 간단한 SVG 아이콘형으로 바꾸고, 버튼/카드 라운드와 밀도를 통일
|
||||
- **좌측 레일 정보 밀도 개선**: 사용자 카드, 빠른 검색, 내비 버튼, 하단 로그인/관리자 버튼을 더 두꺼운 카드 문법으로 맞춰 피그마 톤에 가까운 레일 형태로 재정리
|
||||
- **에디터 패널 감도 보정**: 티어표 편집 화면의 보드, 보드 툴바, 우측 편집 패널, 아이템 풀/드롭존 카드의 배경·경계·라운드를 함께 정리해 공통 셸과 시각 언어를 맞춤
|
||||
|
||||
## 2026-03-30 v1.2.6
|
||||
- **목록형 화면 카드 문법 통일**: 홈, 게임 허브, 내 티어표, 즐겨찾기 화면의 카드형 목록을 동일한 썸네일/제목/작성자/메타 구조로 정리해 대시보드 톤을 맞춤
|
||||
- **홈 화면 대시보드 재정렬**: 메인 게임 라이브러리 화면에 상단 상태 카드와 CTA를 추가하고, 게임 카드는 `16:9` 썸네일 + ID 메타를 갖는 라이브러리 카드 형태로 재배치
|
||||
- **게임 허브 헤더/검색 정리**: 게임 허브는 상단 통계와 생성 버튼, 보조 설명을 포함한 헤더로 재구성하고, 공개 티어표 카드도 같은 카드 밀도로 재정리
|
||||
|
||||
## 2026-03-30 v1.2.5
|
||||
- **관리자 로컬 우측 패널 이관**: 관리자 화면도 공통 우측 패널 대신 화면 내부의 `320px` 전용 운영 패널을 사용하도록 정리하고, 탭·검색·필터·빠른 액션을 우측으로 이동
|
||||
- **관리 화면 본문 집중도 개선**: 중앙 영역은 상단 고정 게임 순서, 선택된 게임 상세, 커스텀 아이템 카드, 템플릿 요청/전체 티어표, 회원 카드 같은 실제 관리 대상만 남기고 빈 상태 안내도 별도 패널로 정리
|
||||
- **관리자 셸 예외 확장**: 공통 앱 셸에서 `/admin`도 전용 로컬 우측 패널을 사용하는 포커스 화면으로 분류해 generic 우측 문맥 카드가 중복 표시되지 않게 조정
|
||||
|
||||
## 2026-03-30 v1.2.4
|
||||
- **비로그인 중복 안내 제거**: 좌측 사이드 상단의 별도 로그인 안내 카드를 제거하고, 비로그인 상태에서는 좌측 하단 버튼만 `로그인` 진입점으로 사용하도록 단순화
|
||||
- **에디터 우측 편집 패널 이관**: 티어표 편집 화면의 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 중앙 상단이 아니라 독립 우측 편집 패널로 이동
|
||||
- **공통 우측 패널 예외 처리**: 티어표 편집 화면은 공통 우측 패널 대신 화면 내부 전용 편집 패널을 사용하도록 조정해, generic 안내 카드가 중복 표시되지 않게 정리
|
||||
|
||||
## 2026-03-30 v1.2.2
|
||||
- **사이드 패널 폭 고정**: 공통 앱 셸의 좌측 패널 폭을 `248px`, 우측 패널 폭을 `320px` 기준으로 재정의해 피그마 시안과 더 가깝게 맞춤
|
||||
- **우측 패널 토글 추가**: 상단 우측 토글 버튼으로 우측 패널을 접고 펼칠 수 있게 하고, 접힐 때는 중앙 작업 영역이 자연스럽게 확장되도록 전환 애니메이션을 추가
|
||||
- **우측 패널 독립성 강화**: 우측 패널은 본문과 별도 컬럼으로 유지하고, 닫힐 때도 본문 레이아웃과 분리된 독립 패널처럼 동작하도록 셸 구조를 조정
|
||||
|
||||
## 2026-03-30 v1.2.1
|
||||
- **포커스 화면 폭 복구**: 에디터·관리자·프로필·로그인 화면은 공통 우측 패널을 잠시 숨기고 중앙 작업 폭을 넓혀, 기존 기능 UI가 3단 셸과 충돌하며 깨지던 문제를 완화
|
||||
- **목록 카드 밀도 재조정**: 홈, 게임 허브, 내 티어표, 즐겨찾기 화면의 기본 컬럼 수를 줄여 현재 셸 폭 안에서도 카드가 과도하게 눌리지 않도록 정리
|
||||
- **에디터/관리자 패널 안정화**: 내부 작업 패널 색상과 폭을 새 셸 톤에 맞춰 다시 정리해, 중첩 패널 때문에 사용성이 무너지던 부분을 우선 복구
|
||||
|
||||
## 2026-03-30 v1.2.0
|
||||
- **피그마 기반 공통 앱 셸 1차 적용**: 상단 헤더 중심 구조를 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 앱 셸로 재구성하고, 데스크톱 기준의 어두운 대시보드형 톤으로 전환
|
||||
- **홈/목록 화면 카드 UI 리디자인**: 홈, 게임 허브, 내 티어표, 즐겨찾기 화면의 카드 그리드와 툴바를 시안에 맞춰 더 조밀한 대시보드 형태로 재배치
|
||||
- **전역 스타일 리셋 정리**: 기존 Vite 기본 스타일 흔적을 제거하고, 서비스 전용 다크 테마와 입력/셀렉트/버튼 기본값을 새 레이아웃 기준으로 통일
|
||||
|
||||
## 2026-03-27 v0.1.52
|
||||
- **관리자 완성본 프리뷰 전용화**: 관리자 모달의 완성본 확인은 이제 전용 preview 모드로 열려 전역 헤더와 편집/탐색 UI 없이 보드만 깔끔하게 확인할 수 있도록 정리
|
||||
- **티어표 기본 썸네일 자동 생성**: 사용자가 별도 썸네일을 지정하지 않아도 저장 시 티어표에 포함된 아이템 중 대표 이미지를 골라 기본 썸네일을 자동으로 채우도록 보강
|
||||
- **이력 문서 날짜순 재정리**: `docs/history.md`를 날짜 역순 기준으로 다시 정렬해 오래된 2026-03-19 항목이 중간에 끼어 보이던 흐름을 바로잡음
|
||||
|
||||
## 2026-03-27 v0.1.51
|
||||
- **관리자 티어표 미리보기 모달 추가**: 템플릿 요청 관리와 전체 티어표 관리에서 `원본 보기 / 완성본 보기`를 눌러도 관리자 화면을 벗어나지 않도록, 확인용 미리보기를 모달 iframe으로 열도록 변경
|
||||
- **템플릿 등록 요청 조건 단순화**: freeform 템플릿 등록 요청은 더 이상 `보드 비움`을 요구하지 않고, `제목 직접 입력 + 커스텀 아이템 존재` 조건 중심으로 단순화
|
||||
- **등록 요청 안내 문구 조정**: 요청 모달 안내를 “게임 이름을 구체적으로 적어 달라”는 방향으로 정리해, 관리자 식별성을 높이는 쪽으로 보강
|
||||
|
||||
## 2026-03-27 v0.1.50
|
||||
- **신규 티어표 등록 요청 타이밍 수정**: 막 저장한 티어표에서 곧바로 템플릿 등록 요청을 보낼 때도 `new`가 아닌 실제 저장된 티어표 ID로 이어서 요청하도록 수정해, 신규 작성 직후 요청 실패 문제를 해결
|
||||
|
||||
## 2026-03-27 v0.1.49
|
||||
- **템플릿 등록 요청 모달 레이아웃 보정**: 체크리스트 문구 줄바꿈과 버튼 겹침 문제를 수정하고, 설명은 좌측·상태 배지는 우측에 배치되도록 요청 모달 레이아웃을 다시 정리
|
||||
- **관리자 티어표 화면 분리**: `티어표 관리` 탭 안에서 `템플릿 요청 관리 / 전체 티어표 관리`를 서브 탭으로 분리해, 요청 목록과 저장된 전체 티어표 목록이 섞여 보이지 않도록 개선
|
||||
- **관리자 안내 문구 보강**: 전체 티어표 목록은 요청과 별개로 저장된 티어표 전체를 보는 영역이라는 설명을 추가해 혼선을 줄이도록 보강
|
||||
|
||||
## 2026-03-27 v0.1.48
|
||||
- **템플릿 등록 요청 체크리스트 모달 추가**: freeform 템플릿 등록 요청 전 `제목 직접 입력 여부`, `보드 비움 상태`를 확인하는 모달과 안내 문구를 추가하고, 조건이 맞을 때만 요청 버튼이 활성화되도록 조정
|
||||
- **등록 요청 실패 원인 구체화**: 템플릿 등록 요청 실패 시 제목 미입력, 보드 비우지 않음, 커스텀 아이템 없음, 중복 대기 요청 같은 주요 원인을 토스트로 구체적으로 안내하도록 보강
|
||||
- **관리자 요청 목록 정리 문구 추가**: 관리자 템플릿 요청 탭에서 반려 시 대기 목록에서 바로 제외된다는 안내와 `반려 후 숨김` 버튼 문구를 추가해 운영 관점의 흐름을 더 명확히 정리
|
||||
|
||||
## 2026-03-27 v0.1.47
|
||||
- **템플릿 등록/업데이트 요청 추가**: 사용자가 저장된 티어표를 기준으로 관리자에게 `새 템플릿 등록` 또는 `기존 템플릿 업데이트` 요청을 보낼 수 있도록 요청 API와 관리자 승인 흐름을 추가
|
||||
- **커스텀 아이템 이름 편집 확장**: 티어표 편집 화면에서 사용자가 직접 추가한 커스텀 아이템 이름을 정리할 수 있는 전용 입력 목록을 추가하고, 저장 시 MariaDB의 커스텀 아이템 라벨도 함께 동기화
|
||||
- **관리자 요청 목록 추가**: 관리자 티어표 관리 탭 상단에 처리 대기 중인 템플릿 요청 목록을 추가하고, 새 게임 템플릿 생성 승인과 기존 게임 템플릿 업데이트 승인을 바로 처리할 수 있게 개선
|
||||
|
||||
## 2026-03-27 v0.1.46
|
||||
- **티어 행 아이템 제거 추가**: 티어표 편집 화면에서 이미 등급 행에 넣은 아이템도 작은 제거 버튼으로 다시 아이템 풀로 빼낼 수 있도록 보강
|
||||
- **회원 관리 보조 정보 확장**: 관리자 회원 관리 카드에 아바타, 작성 티어표 수, 최근 활동 시각을 함께 표시해 운영 판단에 필요한 정보를 바로 확인할 수 있도록 개선
|
||||
|
||||
## 2026-03-27 v0.1.45
|
||||
- **즐겨찾기 카드 액션 보정**: 카드형 목록에서는 별표를 클릭 액션이 아닌 상태/개수 표시로만 보여주고, 실제 즐겨찾기 토글은 상세 화면에서 처리하도록 조정
|
||||
- **토스트 중복/페이드아웃 개선**: 같은 메시지 토스트는 하나로 합치고 카운트를 올리도록 변경했으며, 사라질 때는 짧은 페이드아웃 애니메이션을 적용
|
||||
|
||||
## 2026-03-27 v0.1.44
|
||||
- **토스트 렌더링 버그 수정**: 전역 토스트가 빈 카드 여러 개로 보이던 ref 참조 문제를 수정해 실제 메시지만 표시되도록 정리
|
||||
- **공개 티어표 검색 추가**: 게임별 공개 티어표 목록에서 제목/작성자 기준 검색이 가능하도록 검색창과 API 쿼리 지원 추가
|
||||
@@ -115,6 +495,101 @@
|
||||
- **미사용 아이콘 필터 수정**: 관리자 아이템 관리의 `미사용 아이콘 보기` 체크 상태가 실제 API 요청의 `orphanOnly` 파라미터로 전달되도록 수정
|
||||
- **삭제 활성화 흐름 정상화**: 미사용 아이콘만 조회했을 때 `usageCount = 0` 항목의 개별 삭제 버튼이 의도대로 활성화되도록 정리
|
||||
|
||||
## 2026-03-19 v0.1.17
|
||||
- **내 티어표 삭제 추가**: `내 티어표` 목록에서 작성자가 자신의 티어표를 직접 삭제할 수 있도록 삭제 버튼과 API를 추가
|
||||
- **미사용 커스텀 이미지 관리 추가**: 관리자 아이템 탭에서 커스텀 이미지의 사용 횟수를 표시하고, 미사용 항목만 따로 필터링해 개별/일괄 삭제할 수 있도록 보강
|
||||
|
||||
## 2026-03-19 v0.1.16
|
||||
- **티어표 헤더 마감 정리**: 제목/설명 입력을 각각 한 줄 폭으로 정리하고, 액션 영역과 분리해 헤더 가독성을 개선
|
||||
- **export 정보 보강**: 이미지 저장 시 제목 아래에 설명이 함께 표시되도록 보강
|
||||
- **보드 여백/정렬 정리**: 보드 내부 패딩을 늘리고, 티어 그룹 제목을 중앙 정렬로 조정해 완성본 느낌을 개선
|
||||
|
||||
## 2026-03-19 v0.1.15
|
||||
- **셀렉트 화살표 여백 정리**: 전역 `select` 스타일에 커스텀 화살표 위치와 오른쪽 여백을 추가해 텍스트와 화살표가 지나치게 붙지 않도록 조정
|
||||
- **티어표 다운로드 결과 개선**: `TierEditorView`의 이미지 저장을 Blob 다운로드 방식으로 바꾸고, 캡처 대상을 보드 영역만 포함하는 전용 export 뷰로 분리해 우측 아이템 영역과 편집용 버튼/입력 UI가 저장 이미지에 섞이지 않도록 수정
|
||||
|
||||
## 2026-03-19 v0.1.14
|
||||
- **커스텀 아이템 카드 반응형 수정**: 관리자 아이템 관리 탭의 커스텀 아이템 카드에서 이미지 폭을 유동값으로 조정하고, 텍스트 영역에 `min-width: 0`과 강제 줄바꿈 기준을 추가해 카드 바깥 overflow를 방지
|
||||
|
||||
## 2026-03-19 v0.1.13
|
||||
- **관리자 탭 구조 정리**: 관리자 페이지를 `게임 관리 / 아이템 관리 / 회원 관리` 탭으로 분리하고 기능별 작업 영역을 명확히 분리
|
||||
- **커스텀 아이템 조회 강화**: 사용자 커스텀 아이템 목록에 파일명 검색, `50/200` 단위 페이지네이션, 다운로드 흐름 추가
|
||||
- **회원 비밀번호 초기화 추가**: 관리자 페이지와 API에서 회원 비밀번호를 직접 재설정할 수 있도록 기능 추가
|
||||
- **가변 티어 행 지원**: 티어표 에디터에서 `S~D` 고정 5단이 아니라 티어 행을 직접 추가/삭제할 수 있도록 보강
|
||||
|
||||
## 2026-03-19 v0.1.12
|
||||
- **전역 레이아웃 폭 정리**: 앱 메인 영역의 고정 최대 너비를 제거해 배경과 페이지 폭이 잘린 듯 보이지 않도록 조정
|
||||
- **작성 권한 제한**: 비로그인 사용자는 새 티어표 작성 화면으로 직접 진입할 수 없도록 하고, 공개된 티어표는 읽기 전용으로만 보이게 조정
|
||||
- **커스텀 이미지 업로드 개선**: 에디터의 커스텀 이미지 추가 영역에 다중 파일 선택과 드래그 앤 드롭 업로드를 추가
|
||||
- **회원 관리 추가**: 관리자 페이지에서 가입 회원 목록 조회, 이메일/닉네임/권한 수정, 계정 삭제가 가능한 관리 영역과 API를 추가
|
||||
|
||||
## 2026-03-19 v0.1.11
|
||||
- **관리자 레이아웃 재구성**: 인라인 스타일을 제거하고, 썸네일 적용과 아이템 추가를 상단 2열 카드로 재배치한 뒤 아이템 목록은 하단 리스트로 분리
|
||||
- **직접 티어표 만들기 추가**: 홈 화면에 게임 카드와 동일한 형태의 `직접 티어표 만들기` 진입점을 추가하고, 내부 전용 `freeform` 게임 레코드로 1회성 빈 티어표 저장 흐름을 지원
|
||||
- **게임 제안 흐름 제거**: 홈 화면의 `새로운 게임 제안` 버튼/모달과 관련 프런트 API를 제거해 현재 운영 흐름에 맞게 단순화
|
||||
- **커스텀 아이템 검토 영역 추가**: 관리자 페이지에서 사용자 업로드 커스텀 아이템을 목록으로 보고 다운로드할 수 있는 검토 영역과 조회 API를 추가
|
||||
|
||||
## 2026-03-19 v0.1.10
|
||||
- **관리자 썸네일 액션 정리**: 썸네일 버튼 문구를 `썸네일 적용`으로 바꾸고, 파일 선택 전에는 비활성화되도록 조정
|
||||
- **아이템 추가 폼 정리**: 아이템 이름 입력 너비를 줄이고, 과한 미리보기 안내 문구를 제거해 작업 집중도를 높임
|
||||
- **반응형 미리보기 보정**: 태블릿 이하 화면에서도 아이템 1:1 미리보기가 최대 `192px` 범위 안에서 보이도록 조정
|
||||
- **파일 재선택 버그 수정**: 아이템 추가나 게임 전환 뒤 파일 입력 값을 초기화해 같은 이미지를 다시 선택해도 정상 인식되도록 수정
|
||||
|
||||
## 2026-03-19 v0.1.9
|
||||
- **MariaDB 전용 전환 완료**: `backend/src/db.js`에서 lowdb 분기와 `DB_CLIENT` 기반 fallback을 제거하고 MariaDB 전용 저장 계층으로 정리
|
||||
- **레거시 파일 제거**: `backend/data/db.json`, `backend/scripts/migrate-lowdb-to-mariadb.js`, `dev:lowdb/start:lowdb/migrate:lowdb` 스크립트 및 `lowdb` 의존성 제거
|
||||
- **실행 문서 정리**: `README.md`, `docs/local-mariadb.md`, `docs/spec.md`, `docs/todo.md`, `docs/history.md`를 현재 MariaDB 전용 개발/배포 흐름 기준으로 갱신
|
||||
|
||||
## 2026-03-19 v0.1.8
|
||||
- **관리자 업로드 UX 개선**: 썸네일과 아이템 추가 시 파일 선택 직후 미리보기 표시
|
||||
- **썸네일 비율 정리**: 관리자 썸네일 미리보기와 대표 썸네일 표시를 16:9, 약 256px 폭 기준으로 조정
|
||||
- **아이템 카드 레이아웃 개선**: 아이템 목록과 추가 미리보기를 1:1 비율 기준으로 재구성하고 더 촘촘한 카드 그리드로 조정
|
||||
- **레거시 파일 역할 정리**: `db.json`과 lowdb 관련 코드는 현재 MariaDB 기본 런타임에는 필수가 아니며, 마이그레이션/예외 fallback 용도임을 문서에 명시
|
||||
|
||||
## 2026-03-19 v0.1.7
|
||||
- **AI 작업 규칙 보강**: `ai-rules.md`에 Git 작성자 정보, 한국어 커밋 메시지, 버전/태그 동기화, 민감 정보 확인 규칙 추가
|
||||
- **관리자 화면 재구성**: `/admin`을 좌우 병렬 구조에서 `모드 선택 → 게임 선택/생성 → 선택된 게임 상세 관리` 흐름으로 재구성
|
||||
- **관리자 삭제 기능 추가**: 등록된 게임 자체 삭제 및 등록된 아이템 개별 삭제 기능 추가
|
||||
- **데이터 정합성 보강**: 관리자 아이템 삭제 시 관련 티어표의 `groups/pool` 참조를 함께 정리하도록 백엔드 로직 보강
|
||||
|
||||
## 2026-03-19 v0.1.6
|
||||
- **저장소 메타데이터 정리**: Git 작성자 정보를 프로젝트 계정 기준으로 통일하고, 초기 릴리스 커밋 메시지를 한국어 기준으로 재작성
|
||||
- **버전 관리 규칙 보강**: 커밋 메시지 한국어 작성 및 문서 버전과 Git 태그를 함께 맞추는 규칙을 문서에 반영
|
||||
|
||||
## 2026-03-19 v0.1.5
|
||||
- **로컬 개발 환경 정렬**: 기본 백엔드 실행 기준을 lowdb가 아닌 로컬 MariaDB로 전환
|
||||
- **개발용 인프라 추가**: 루트 `docker-compose.yml`에 `MariaDB + phpMyAdmin` 추가
|
||||
- **실행 문서 정리**: `README.md`, `docs/local-mariadb.md`, `docs/spec.md`에 로컬 MariaDB 실행 절차 반영
|
||||
- **Fallback 분리**: `backend/package.json`에 `dev:lowdb`, `start:lowdb` 예외 스크립트 추가
|
||||
|
||||
## 2026-03-19 v0.1.4
|
||||
- **DB 마이그레이션 준비**: 런타임 저장소를 `MariaDB(MySQL 호환)` 기준으로 재구성하고 `backend/scripts/migrate-lowdb-to-mariadb.js` 마이그레이션 스크립트 추가
|
||||
- **데이터 구조 분리**: 관리자 지정 아이템은 `game_items`, 유저 커스텀 이미지는 `custom_items`로 분리
|
||||
- **프로필 개선**: 작성자 닉네임 저장 지원, 아바타는 파일 선택 시 미리보기만 변경되고 저장 버튼 클릭 시 실제 반영되도록 수정
|
||||
- **공개 티어표 목록 개선**: 공개 티어표 목록에 작성자 닉네임(없으면 이메일) 표시
|
||||
- **관리자 UI 개편**: 게임 선택 전에는 우측 관리 패널을 숨기고, 선택 후에만 썸네일/아이템 관리가 보이도록 단계형 흐름으로 수정
|
||||
- **관리자 레이아웃 수정**: 새 게임 입력 필드와 카드 셀 overflow 문제를 줄이도록 `box-sizing`, 썸네일/아이템 카드 레이아웃 정리
|
||||
- **커스텀 아이템 저장 흐름 수정**: 에디터의 커스텀 이미지는 저장 시 서버 업로드 후 티어표에 반영되도록 변경
|
||||
|
||||
## 2026-03-19 v0.1.3
|
||||
- **배포 설정 개선**: 프런트엔드의 API/정적 파일 주소 하드코딩(`http://localhost:5179`)을 `VITE_API_ORIGIN` 기반으로 통합
|
||||
- **백엔드 운영 설정 추가**: `CORS_ORIGINS`, `TRUST_PROXY`, `SESSION_COOKIE_SECURE`, `SESSION_COOKIE_SAME_SITE`, `SESSION_SECRET` 환경변수 기반으로 NAS/리버스 프록시 배포 대응
|
||||
- **업로드 파일명 안정화**: 한글 원본 파일명 기반 저장을 제거하고 ASCII 안전 파일명으로 저장하도록 변경
|
||||
- **티어표 데이터 정규화**: 게임 이미지 경로가 절대 로컬 URL로 저장되지 않도록 저장/조회 시 `/uploads/...` 상대 경로로 정규화
|
||||
- **프로젝트 점검 결과 문서화**: DB 구조, 화면-파일 매핑, 코딩 규칙, 기술 명세, 남은 위험 요소를 `docs/`에 신규 정리
|
||||
|
||||
## 2026-03-19 v0.1.2
|
||||
- **로그인 UI 개선**: 로그인 카드 중앙 배치, 중복 타이틀 제거, 입력 overflow 수정, 엔터로 로그인/회원가입 제출
|
||||
- **안내문 조건화**: “첫 회원가입 계정은 admin” 문구는 유저가 0명일 때만 표시(`/api/auth/meta`)
|
||||
- **게임 목록 UI 개선**: 게임 카드에 썸네일 표시, 중복 텍스트 제거, “새로운 게임 제안” 모달 추가
|
||||
- **관리자 기능 추가**: 게임 썸네일 업로드 API(`/api/admin/games/:gameId/thumbnail`) 및 UI 추가
|
||||
- **에디터 레이아웃 개선**: 등급(그룹) 라벨 칼럼 확장으로 텍스트 잘림 방지, 설명 입력 1줄, 정렬을 좌측 기준으로 조정
|
||||
|
||||
## 2026-03-19 v0.1.1
|
||||
- **티어표 메타데이터 개선**: 제목 미입력 시 저장 시점에 게임 이름 기반 자동 제목 적용, 설명(선택) 필드 추가
|
||||
- **시간 정보 표시**: 내 티어표/공개 목록에서 저장 시간(createdAt)과 업데이트 시간(updatedAt)을 시:분:초까지 표시
|
||||
- **에디터 UX 수정**: 빈 티어 칸 안내 문구가 첫 드래그 배치를 가리던 문제 수정(오버레이 처리), 제목 상단에 게임 이름 표시
|
||||
|
||||
## 2026-03-19 v0.1.0
|
||||
- **초기 스캐폴딩**: `frontend/`에 Vue3(Vite, JavaScript) 프로젝트 생성
|
||||
- **라우팅/화면 골격**: 게임 선택(`/`), 게임 허브(`/games/:gameId`), 에디터(`/editor/:gameId/...`), 로그인(`/login`), 내 티어표(`/me`), 관리자(`/admin`) 라우트 추가
|
||||
@@ -127,98 +602,3 @@
|
||||
- **네비/권한 UX**: 관리자 메뉴는 admin 로그인 시에만 노출, 로그인 대신 아바타 버튼/메뉴 노출
|
||||
- **프로필**: `/profile` 페이지 추가, 아바타 업로드 API(`/api/auth/avatar`) 및 표시 지원
|
||||
- **에디터 버그 수정**: 드래그 시 아이템들이 “묶음”으로 같이 움직이던 문제 해결(드롭 영역 DOM 구조/Sortable 옵션 수정), 드롭 영역 overflow/배치 레이아웃 개선
|
||||
|
||||
## 2026-03-19 v0.1.1
|
||||
- **티어표 메타데이터 개선**: 제목 미입력 시 저장 시점에 게임 이름 기반 자동 제목 적용, 설명(선택) 필드 추가
|
||||
- **시간 정보 표시**: 내 티어표/공개 목록에서 저장 시간(createdAt)과 업데이트 시간(updatedAt)을 시:분:초까지 표시
|
||||
- **에디터 UX 수정**: 빈 티어 칸 안내 문구가 첫 드래그 배치를 가리던 문제 수정(오버레이 처리), 제목 상단에 게임 이름 표시
|
||||
|
||||
## 2026-03-19 v0.1.2
|
||||
- **로그인 UI 개선**: 로그인 카드 중앙 배치, 중복 타이틀 제거, 입력 overflow 수정, 엔터로 로그인/회원가입 제출
|
||||
- **안내문 조건화**: “첫 회원가입 계정은 admin” 문구는 유저가 0명일 때만 표시(`/api/auth/meta`)
|
||||
- **게임 목록 UI 개선**: 게임 카드에 썸네일 표시, 중복 텍스트 제거, “새로운 게임 제안” 모달 추가
|
||||
- **관리자 기능 추가**: 게임 썸네일 업로드 API(`/api/admin/games/:gameId/thumbnail`) 및 UI 추가
|
||||
- **에디터 레이아웃 개선**: 등급(그룹) 라벨 칼럼 확장으로 텍스트 잘림 방지, 설명 입력 1줄, 정렬을 좌측 기준으로 조정
|
||||
|
||||
## 2026-03-19 v0.1.3
|
||||
- **배포 설정 개선**: 프런트엔드의 API/정적 파일 주소 하드코딩(`http://localhost:5179`)을 `VITE_API_ORIGIN` 기반으로 통합
|
||||
- **백엔드 운영 설정 추가**: `CORS_ORIGINS`, `TRUST_PROXY`, `SESSION_COOKIE_SECURE`, `SESSION_COOKIE_SAME_SITE`, `SESSION_SECRET` 환경변수 기반으로 NAS/리버스 프록시 배포 대응
|
||||
- **업로드 파일명 안정화**: 한글 원본 파일명 기반 저장을 제거하고 ASCII 안전 파일명으로 저장하도록 변경
|
||||
- **티어표 데이터 정규화**: 게임 이미지 경로가 절대 로컬 URL로 저장되지 않도록 저장/조회 시 `/uploads/...` 상대 경로로 정규화
|
||||
- **프로젝트 점검 결과 문서화**: DB 구조, 화면-파일 매핑, 코딩 규칙, 기술 명세, 남은 위험 요소를 `docs/`에 신규 정리
|
||||
|
||||
## 2026-03-19 v0.1.4
|
||||
- **DB 마이그레이션 준비**: 런타임 저장소를 `MariaDB(MySQL 호환)` 기준으로 재구성하고 `backend/scripts/migrate-lowdb-to-mariadb.js` 마이그레이션 스크립트 추가
|
||||
- **데이터 구조 분리**: 관리자 지정 아이템은 `game_items`, 유저 커스텀 이미지는 `custom_items`로 분리
|
||||
- **프로필 개선**: 작성자 닉네임 저장 지원, 아바타는 파일 선택 시 미리보기만 변경되고 저장 버튼 클릭 시 실제 반영되도록 수정
|
||||
- **공개 티어표 목록 개선**: 공개 티어표 목록에 작성자 닉네임(없으면 이메일) 표시
|
||||
- **관리자 UI 개편**: 게임 선택 전에는 우측 관리 패널을 숨기고, 선택 후에만 썸네일/아이템 관리가 보이도록 단계형 흐름으로 수정
|
||||
- **관리자 레이아웃 수정**: 새 게임 입력 필드와 카드 셀 overflow 문제를 줄이도록 `box-sizing`, 썸네일/아이템 카드 레이아웃 정리
|
||||
- **커스텀 아이템 저장 흐름 수정**: 에디터의 커스텀 이미지는 저장 시 서버 업로드 후 티어표에 반영되도록 변경
|
||||
|
||||
## 2026-03-19 v0.1.5
|
||||
- **로컬 개발 환경 정렬**: 기본 백엔드 실행 기준을 lowdb가 아닌 로컬 MariaDB로 전환
|
||||
- **개발용 인프라 추가**: 루트 `docker-compose.yml`에 `MariaDB + phpMyAdmin` 추가
|
||||
- **실행 문서 정리**: `README.md`, `docs/local-mariadb.md`, `docs/spec.md`에 로컬 MariaDB 실행 절차 반영
|
||||
- **Fallback 분리**: `backend/package.json`에 `dev:lowdb`, `start:lowdb` 예외 스크립트 추가
|
||||
|
||||
## 2026-03-19 v0.1.6
|
||||
- **저장소 메타데이터 정리**: Git 작성자 정보를 프로젝트 계정 기준으로 통일하고, 초기 릴리스 커밋 메시지를 한국어 기준으로 재작성
|
||||
- **버전 관리 규칙 보강**: 커밋 메시지 한국어 작성 및 문서 버전과 Git 태그를 함께 맞추는 규칙을 문서에 반영
|
||||
|
||||
## 2026-03-19 v0.1.7
|
||||
- **AI 작업 규칙 보강**: `ai-rules.md`에 Git 작성자 정보, 한국어 커밋 메시지, 버전/태그 동기화, 민감 정보 확인 규칙 추가
|
||||
- **관리자 화면 재구성**: `/admin`을 좌우 병렬 구조에서 `모드 선택 → 게임 선택/생성 → 선택된 게임 상세 관리` 흐름으로 재구성
|
||||
- **관리자 삭제 기능 추가**: 등록된 게임 자체 삭제 및 등록된 아이템 개별 삭제 기능 추가
|
||||
- **데이터 정합성 보강**: 관리자 아이템 삭제 시 관련 티어표의 `groups/pool` 참조를 함께 정리하도록 백엔드 로직 보강
|
||||
|
||||
## 2026-03-19 v0.1.8
|
||||
- **관리자 업로드 UX 개선**: 썸네일과 아이템 추가 시 파일 선택 직후 미리보기 표시
|
||||
- **썸네일 비율 정리**: 관리자 썸네일 미리보기와 대표 썸네일 표시를 16:9, 약 256px 폭 기준으로 조정
|
||||
- **아이템 카드 레이아웃 개선**: 아이템 목록과 추가 미리보기를 1:1 비율 기준으로 재구성하고 더 촘촘한 카드 그리드로 조정
|
||||
- **레거시 파일 역할 정리**: `db.json`과 lowdb 관련 코드는 현재 MariaDB 기본 런타임에는 필수가 아니며, 마이그레이션/예외 fallback 용도임을 문서에 명시
|
||||
|
||||
## 2026-03-19 v0.1.9
|
||||
- **MariaDB 전용 전환 완료**: `backend/src/db.js`에서 lowdb 분기와 `DB_CLIENT` 기반 fallback을 제거하고 MariaDB 전용 저장 계층으로 정리
|
||||
- **레거시 파일 제거**: `backend/data/db.json`, `backend/scripts/migrate-lowdb-to-mariadb.js`, `dev:lowdb/start:lowdb/migrate:lowdb` 스크립트 및 `lowdb` 의존성 제거
|
||||
- **실행 문서 정리**: `README.md`, `docs/local-mariadb.md`, `docs/spec.md`, `docs/todo.md`, `docs/history.md`를 현재 MariaDB 전용 개발/배포 흐름 기준으로 갱신
|
||||
|
||||
## 2026-03-19 v0.1.10
|
||||
- **관리자 썸네일 액션 정리**: 썸네일 버튼 문구를 `썸네일 적용`으로 바꾸고, 파일 선택 전에는 비활성화되도록 조정
|
||||
- **아이템 추가 폼 정리**: 아이템 이름 입력 너비를 줄이고, 과한 미리보기 안내 문구를 제거해 작업 집중도를 높임
|
||||
- **반응형 미리보기 보정**: 태블릿 이하 화면에서도 아이템 1:1 미리보기가 최대 `192px` 범위 안에서 보이도록 조정
|
||||
- **파일 재선택 버그 수정**: 아이템 추가나 게임 전환 뒤 파일 입력 값을 초기화해 같은 이미지를 다시 선택해도 정상 인식되도록 수정
|
||||
|
||||
## 2026-03-19 v0.1.11
|
||||
- **관리자 레이아웃 재구성**: 인라인 스타일을 제거하고, 썸네일 적용과 아이템 추가를 상단 2열 카드로 재배치한 뒤 아이템 목록은 하단 리스트로 분리
|
||||
- **직접 티어표 만들기 추가**: 홈 화면에 게임 카드와 동일한 형태의 `직접 티어표 만들기` 진입점을 추가하고, 내부 전용 `freeform` 게임 레코드로 1회성 빈 티어표 저장 흐름을 지원
|
||||
- **게임 제안 흐름 제거**: 홈 화면의 `새로운 게임 제안` 버튼/모달과 관련 프런트 API를 제거해 현재 운영 흐름에 맞게 단순화
|
||||
- **커스텀 아이템 검토 영역 추가**: 관리자 페이지에서 사용자 업로드 커스텀 아이템을 목록으로 보고 다운로드할 수 있는 검토 영역과 조회 API를 추가
|
||||
|
||||
## 2026-03-19 v0.1.12
|
||||
- **전역 레이아웃 폭 정리**: 앱 메인 영역의 고정 최대 너비를 제거해 배경과 페이지 폭이 잘린 듯 보이지 않도록 조정
|
||||
- **작성 권한 제한**: 비로그인 사용자는 새 티어표 작성 화면으로 직접 진입할 수 없도록 하고, 공개된 티어표는 읽기 전용으로만 보이게 조정
|
||||
- **커스텀 이미지 업로드 개선**: 에디터의 커스텀 이미지 추가 영역에 다중 파일 선택과 드래그 앤 드롭 업로드를 추가
|
||||
- **회원 관리 추가**: 관리자 페이지에서 가입 회원 목록 조회, 이메일/닉네임/권한 수정, 계정 삭제가 가능한 관리 영역과 API를 추가
|
||||
|
||||
## 2026-03-19 v0.1.13
|
||||
- **관리자 탭 구조 정리**: 관리자 페이지를 `게임 관리 / 아이템 관리 / 회원 관리` 탭으로 분리하고 기능별 작업 영역을 명확히 분리
|
||||
- **커스텀 아이템 조회 강화**: 사용자 커스텀 아이템 목록에 파일명 검색, `50/200` 단위 페이지네이션, 다운로드 흐름 추가
|
||||
- **회원 비밀번호 초기화 추가**: 관리자 페이지와 API에서 회원 비밀번호를 직접 재설정할 수 있도록 기능 추가
|
||||
- **가변 티어 행 지원**: 티어표 에디터에서 `S~D` 고정 5단이 아니라 티어 행을 직접 추가/삭제할 수 있도록 보강
|
||||
|
||||
## 2026-03-19 v0.1.14
|
||||
- **커스텀 아이템 카드 반응형 수정**: 관리자 아이템 관리 탭의 커스텀 아이템 카드에서 이미지 폭을 유동값으로 조정하고, 텍스트 영역에 `min-width: 0`과 강제 줄바꿈 기준을 추가해 카드 바깥 overflow를 방지
|
||||
|
||||
## 2026-03-19 v0.1.15
|
||||
- **셀렉트 화살표 여백 정리**: 전역 `select` 스타일에 커스텀 화살표 위치와 오른쪽 여백을 추가해 텍스트와 화살표가 지나치게 붙지 않도록 조정
|
||||
- **티어표 다운로드 결과 개선**: `TierEditorView`의 이미지 저장을 Blob 다운로드 방식으로 바꾸고, 캡처 대상을 보드 영역만 포함하는 전용 export 뷰로 분리해 우측 아이템 영역과 편집용 버튼/입력 UI가 저장 이미지에 섞이지 않도록 수정
|
||||
|
||||
## 2026-03-19 v0.1.16
|
||||
- **티어표 헤더 마감 정리**: 제목/설명 입력을 각각 한 줄 폭으로 정리하고, 액션 영역과 분리해 헤더 가독성을 개선
|
||||
- **export 정보 보강**: 이미지 저장 시 제목 아래에 설명이 함께 표시되도록 보강
|
||||
- **보드 여백/정렬 정리**: 보드 내부 패딩을 늘리고, 티어 그룹 제목을 중앙 정렬로 조정해 완성본 느낌을 개선
|
||||
|
||||
## 2026-03-19 v0.1.17
|
||||
- **내 티어표 삭제 추가**: `내 티어표` 목록에서 작성자가 자신의 티어표를 직접 삭제할 수 있도록 삭제 버튼과 API를 추가
|
||||
- **미사용 커스텀 이미지 관리 추가**: 관리자 아이템 탭에서 커스텀 이미지의 사용 횟수를 표시하고, 미사용 항목만 따로 필터링해 개별/일괄 삭제할 수 있도록 보강
|
||||
|
||||
1251
frontend/src/App.vue
1
frontend/src/assets/icons/delete.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z"/></svg>
|
||||
|
After Width: | Height: | Size: 318 B |
1
frontend/src/assets/icons/dock_to_left.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm440-80h120v-560H640v560Zm-80 0v-560H200v560h360Zm80 0h120-120Z"/></svg>
|
||||
|
After Width: | Height: | Size: 306 B |
1
frontend/src/assets/icons/dock_to_right.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm120-80v-560H200v560h120Zm80 0h360v-560H400v560Zm-80 0H200h120Z"/></svg>
|
||||
|
After Width: | Height: | Size: 306 B |
1
frontend/src/assets/icons/favorite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="m480-120-58-52q-101-91-167-157T150-447.5Q111-500 95.5-544T80-634q0-94 63-157t157-63q52 0 99 22t81 62q34-40 81-62t99-22q94 0 157 63t63 157q0 46-15.5 90T810-447.5Q771-395 705-329T538-172l-58 52Zm0-108q96-86 158-147.5t98-107q36-45.5 50-81t14-70.5q0-60-40-100t-100-40q-47 0-87 26.5T518-680h-76q-15-41-55-67.5T300-774q-60 0-100 40t-40 100q0 35 14 70.5t50 81q36 45.5 98 107T480-228Zm0-273Z"/></svg>
|
||||
|
After Width: | Height: | Size: 507 B |
1
frontend/src/assets/icons/grid_view.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M120-520v-320h320v320H120Zm0 400v-320h320v320H120Zm400-400v-320h320v320H520Zm0 400v-320h320v320H520ZM200-600h160v-160H200v160Zm400 0h160v-160H600v160Zm0 400h160v-160H600v160Zm-400 0h160v-160H200v160Zm400-400Zm0 240Zm-240 0Zm0-240Z"/></svg>
|
||||
|
After Width: | Height: | Size: 354 B |
1
frontend/src/assets/icons/lists.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M80-160v-160h160v160H80Zm240 0v-160h560v160H320ZM80-400v-160h160v160H80Zm240 0v-160h560v160H320ZM80-640v-160h160v160H80Zm240 0v-160h560v160H320Z"/></svg>
|
||||
|
After Width: | Height: | Size: 268 B |
1
frontend/src/assets/icons/lock_reset.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480h80q0 66 25 124.5t68.5 102q43.5 43.5 102 69T480-159q134 0 227-93t93-227q0-134-93-227t-227-93q-89 0-161.5 43.5T204-640h116v80H80v-240h80v80q55-73 138-116.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm-80-240q-17 0-28.5-11.5T360-360v-120q0-17 11.5-28.5T400-520v-40q0-33 23.5-56.5T480-640q33 0 56.5 23.5T560-560v40q17 0 28.5 11.5T600-480v120q0 17-11.5 28.5T560-320H400Zm40-200h80v-40q0-17-11.5-28.5T480-600q-17 0-28.5 11.5T440-560v40Z"/></svg>
|
||||
|
After Width: | Height: | Size: 663 B |
1
frontend/src/assets/icons/more.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M360-160q-19 0-36-8.5T296-192L80-480l216-288q11-15 28-23.5t36-8.5h440q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H360ZM180-480l180 240h440v-480H360L180-480Zm248.5 28.5Q440-463 440-480t-11.5-28.5Q417-520 400-520t-28.5 11.5Q360-497 360-480t11.5 28.5Q383-440 400-440t28.5-11.5Zm140 0Q580-463 580-480t-11.5-28.5Q557-520 540-520t-28.5 11.5Q500-497 500-480t11.5 28.5Q523-440 540-440t28.5-11.5Zm140 0Q720-463 720-480t-11.5-28.5Q697-520 680-520t-28.5 11.5Q640-497 640-480t11.5 28.5Q663-440 680-440t28.5-11.5ZM580-480Z"/></svg>
|
||||
|
After Width: | Height: | Size: 639 B |
1
frontend/src/assets/icons/search.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z"/></svg>
|
||||
|
After Width: | Height: | Size: 375 B |
1
frontend/src/assets/icons/settings.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z"/></svg>
|
||||
|
After Width: | Height: | Size: 770 B |
91
frontend/src/components/RightRailAd.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
className: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const adEl = ref(null)
|
||||
const client = 'ca-pub-4516420168710424'
|
||||
const slot = '1236919061'
|
||||
const panelClass = computed(() => ['rightRailAd', props.className].filter(Boolean).join(' '))
|
||||
|
||||
function ensureAdScript() {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return Promise.resolve()
|
||||
const existing = document.querySelector(`script[data-ad-client="${client}"]`)
|
||||
if (existing) return Promise.resolve()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.async = true
|
||||
script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${client}`
|
||||
script.crossOrigin = 'anonymous'
|
||||
script.dataset.adClient = client
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => reject(new Error('adsense_load_failed'))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
await ensureAdScript()
|
||||
await nextTick()
|
||||
if (!adEl.value) return
|
||||
window.adsbygoogle = window.adsbygoogle || []
|
||||
if (!adEl.value.dataset.adsbygoogleStatus) {
|
||||
window.adsbygoogle.push({})
|
||||
}
|
||||
} catch (e) {
|
||||
// Keep the slot quiet when ad blockers or network policies block the script.
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="panelClass">
|
||||
<div class="rightRailAd__eyebrow">Sponsored</div>
|
||||
<div class="rightRailAd__frame">
|
||||
<ins
|
||||
ref="adEl"
|
||||
class="adsbygoogle rightRailAd__slot"
|
||||
style="display:block"
|
||||
:data-ad-client="client"
|
||||
:data-ad-slot="slot"
|
||||
data-ad-format="auto"
|
||||
data-full-width-responsive="true"
|
||||
></ins>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rightRailAd {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rightRailAd__eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.34);
|
||||
}
|
||||
|
||||
.rightRailAd__frame {
|
||||
min-height: 520px;
|
||||
padding: 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.rightRailAd__slot {
|
||||
width: 100%;
|
||||
min-height: 490px;
|
||||
}
|
||||
</style>
|
||||
@@ -2,18 +2,48 @@ import { readonly, ref } from 'vue'
|
||||
|
||||
const toasts = ref([])
|
||||
let toastSeq = 0
|
||||
const TOAST_EXIT_MS = 220
|
||||
|
||||
function clearToastTimer(toast) {
|
||||
if (toast?.timerId) {
|
||||
window.clearTimeout(toast.timerId)
|
||||
toast.timerId = 0
|
||||
}
|
||||
}
|
||||
|
||||
function removeToast(id) {
|
||||
toasts.value = toasts.value.filter((toast) => toast.id !== id)
|
||||
}
|
||||
|
||||
function dismissToast(id) {
|
||||
toasts.value = toasts.value.filter((toast) => toast.id !== id)
|
||||
const target = toasts.value.find((toast) => toast.id === id)
|
||||
if (!target || target.isClosing) return
|
||||
|
||||
clearToastTimer(target)
|
||||
target.isClosing = true
|
||||
target.timerId = window.setTimeout(() => removeToast(id), TOAST_EXIT_MS)
|
||||
}
|
||||
|
||||
function showToast(message, { type = 'info', duration = 2600 } = {}) {
|
||||
if (!message) return ''
|
||||
const duplicated = toasts.value.find((toast) => toast.message === message && toast.type === type && !toast.isClosing)
|
||||
|
||||
if (duplicated) {
|
||||
duplicated.count = (duplicated.count || 1) + 1
|
||||
clearToastTimer(duplicated)
|
||||
if (duration > 0) {
|
||||
duplicated.timerId = window.setTimeout(() => dismissToast(duplicated.id), duration)
|
||||
}
|
||||
toasts.value = [...toasts.value]
|
||||
return duplicated.id
|
||||
}
|
||||
|
||||
const id = `toast-${++toastSeq}`
|
||||
toasts.value = [...toasts.value, { id, message, type }]
|
||||
const nextToast = { id, message, type, count: 1, isClosing: false, timerId: 0 }
|
||||
toasts.value = [...toasts.value, nextToast]
|
||||
|
||||
if (duration > 0) {
|
||||
window.setTimeout(() => dismissToast(id), duration)
|
||||
nextToast.timerId = window.setTimeout(() => dismissToast(id), duration)
|
||||
}
|
||||
|
||||
return id
|
||||
|
||||
@@ -32,6 +32,8 @@ export const api = {
|
||||
|
||||
listGames: () => request('/api/games'),
|
||||
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
|
||||
favoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'POST' }),
|
||||
unfavoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'DELETE' }),
|
||||
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
|
||||
updateAdminGameItem: (gameId, itemId, payload) =>
|
||||
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||
@@ -41,23 +43,46 @@ 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'),
|
||||
promoteAdminCustomItem: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||
promoteAdminTierListItems: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
|
||||
createAdminGameTemplateFromTierList: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
|
||||
approveAdminTemplateRequest: (requestId, payload) =>
|
||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/approve`, { method: 'POST', body: payload || {} }),
|
||||
rejectAdminTemplateRequest: (requestId) => request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/reject`, { method: 'POST', body: {} }),
|
||||
listAdminUsers: () => request('/api/admin/users'),
|
||||
updateAdminUser: (userId, payload) =>
|
||||
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
|
||||
updateAdminUserPassword: (userId, payload) =>
|
||||
request(`/api/admin/users/${encodeURIComponent(userId)}/password`, { method: 'PATCH', body: payload }),
|
||||
updateAdminUserAvatar: async (userId, { file, removeAvatar = false } = {}) => {
|
||||
const fd = new FormData()
|
||||
if (file) fd.append('avatar', file)
|
||||
if (removeAvatar) fd.append('removeAvatar', '1')
|
||||
const res = await fetch(toApiUrl(`/api/admin/users/${encodeURIComponent(userId)}/avatar`), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: fd,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
const err = new Error('request_failed')
|
||||
err.status = res.status
|
||||
err.data = data
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
},
|
||||
deleteAdminUser: (userId) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
|
||||
|
||||
listPublicTierLists: (gameId) =>
|
||||
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`),
|
||||
searchPublicTierLists: (gameId, q = '') =>
|
||||
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`),
|
||||
searchAllPublicTierLists: (q = '') => request(`/api/tierlists/public?q=${encodeURIComponent(q || '')}`),
|
||||
listMyTierLists: () => request('/api/tierlists/me'),
|
||||
listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) =>
|
||||
request(`/api/tierlists/favorites/me?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}`),
|
||||
@@ -65,6 +90,8 @@ export const api = {
|
||||
favoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'POST' }),
|
||||
unfavoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'DELETE' }),
|
||||
deleteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
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) => {
|
||||
const fd = new FormData()
|
||||
|
||||
@@ -8,6 +8,7 @@ import MyTierListsView from '../views/MyTierListsView.vue'
|
||||
import FavoriteTierListsView from '../views/FavoriteTierListsView.vue'
|
||||
import AdminView from '../views/AdminView.vue'
|
||||
import ProfileView from '../views/ProfileView.vue'
|
||||
import SearchResultsView from '../views/SearchResultsView.vue'
|
||||
|
||||
export function createRouter() {
|
||||
return _createRouter({
|
||||
@@ -20,6 +21,7 @@ export function createRouter() {
|
||||
{ path: '/login', name: 'login', component: LoginView },
|
||||
{ path: '/me', name: 'me', component: MyTierListsView },
|
||||
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView },
|
||||
{ path: '/search', name: 'search', component: SearchResultsView },
|
||||
{ path: '/admin', name: 'admin', component: AdminView },
|
||||
{ path: '/profile', name: 'profile', component: ProfileView },
|
||||
],
|
||||
|
||||
@@ -1,57 +1,49 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-family: 'Pretendard', 'Inter', 'Segoe UI', sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
background: #121212;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #121212;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
select {
|
||||
@@ -59,253 +51,73 @@ select {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, rgba(255, 255, 255, 0.78) 50%),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.78) 50%, transparent 50%);
|
||||
linear-gradient(45deg, transparent 50%, rgba(255, 255, 255, 0.72) 50%),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.72) 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 18px) calc(50% - 2px),
|
||||
calc(100% - 12px) calc(50% - 2px);
|
||||
calc(100% - 20px) calc(50% - 2px),
|
||||
calc(100% - 14px) calc(50% - 2px);
|
||||
background-size: 6px 6px, 6px 6px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 36px;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
/* width: 1126px; */
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
.pageWrap {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.pageHead {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
min-height: 96px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pageHead__main {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pageHead__eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
|
||||
.pageHead__title {
|
||||
font-size: 32px;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.pageHead__desc {
|
||||
max-width: 720px;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.pageHead__aside {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
@@ -13,12 +13,10 @@ const query = ref('')
|
||||
const sort = ref('favorited')
|
||||
|
||||
function fmt(ts) {
|
||||
return new Date(ts).toLocaleString(undefined, {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,27 +50,18 @@ function openTierList(tierList) {
|
||||
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
|
||||
}
|
||||
|
||||
async function unfavorite(tierList) {
|
||||
try {
|
||||
await api.unfavoriteTierList(tierList.id)
|
||||
favorites.value = favorites.value.filter((entry) => entry.id !== tierList.id)
|
||||
toast.success('즐겨찾기를 해제했어요.')
|
||||
} catch (e) {
|
||||
toast.error('즐겨찾기 해제에 실패했어요.')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadFavorites)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="wrap">
|
||||
<div class="head">
|
||||
<div>
|
||||
<h2 class="title">내 즐겨찾기</h2>
|
||||
<div class="desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 수 있어요.</div>
|
||||
<section class="pageWrap">
|
||||
<div class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Collection</div>
|
||||
<h2 class="pageHead__title">내 즐겨찾기</h2>
|
||||
<div class="pageHead__desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 수 있어요.</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<div class="pageHead__aside toolbar">
|
||||
<input v-model="query" class="input" placeholder="제목, 게임, 작성자 검색" @keydown.enter.prevent="loadFavorites" />
|
||||
<select v-model="sort" class="select" @change="loadFavorites">
|
||||
<option value="favorited">즐겨찾기한 순</option>
|
||||
@@ -85,53 +74,33 @@ onMounted(loadFavorites)
|
||||
|
||||
<div v-if="favorites.length === 0" class="empty">즐겨찾기한 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="tierList in favorites" :key="tierList.id" class="row">
|
||||
<button class="row__body" @click="openTierList(tierList)">
|
||||
<div class="row__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="row__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
|
||||
<div v-else class="row__thumbPlaceholder"></div>
|
||||
<article v-for="tierList in favorites" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="row__head">
|
||||
<div class="row__title">{{ tierList.title }}</div>
|
||||
<div class="row__author">
|
||||
<img v-if="avatarSrcOf(tierList)" class="row__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
|
||||
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
|
||||
<span>by {{ displayNameOf(tierList) }}</span>
|
||||
<div class="boardCard__head">
|
||||
<div class="boardCard__titleRow">
|
||||
<div class="boardCard__title">{{ tierList.title }}</div>
|
||||
<div class="favoriteStat">♡ {{ tierList.favoriteCount || 0 }}</div>
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
|
||||
</div>
|
||||
<div class="boardCard__date">{{ fmt(tierList.updatedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="row__foot">
|
||||
<div class="row__meta">
|
||||
<div>{{ tierList.gameName || tierList.gameId }}</div>
|
||||
<div>{{ fmt(sort === 'favorited' ? tierList.favoritedAt : tierList.updatedAt) }}</div>
|
||||
</div>
|
||||
<button class="favoriteBtn" @click="unfavorite(tierList)">★ {{ tierList.favoriteCount || 0 }}</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
.desc {
|
||||
margin-top: 6px;
|
||||
opacity: 0.78;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -139,16 +108,16 @@ onMounted(loadFavorites)
|
||||
}
|
||||
.input,
|
||||
.select {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.btn {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-weight: 800;
|
||||
@@ -159,18 +128,25 @@ onMounted(loadFavorites)
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.row {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
.boardCard {
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(62, 62, 62, 0.82);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background 0.16s ease;
|
||||
}
|
||||
.row__body {
|
||||
.boardCard:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(70, 70, 70, 0.96);
|
||||
}
|
||||
.boardCard__body {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
@@ -178,77 +154,100 @@ onMounted(loadFavorites)
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.row__thumbWrap {
|
||||
.boardCard__thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 14px 14px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.row__thumb,
|
||||
.row__thumbPlaceholder {
|
||||
.boardCard__thumb,
|
||||
.boardCard__thumbPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.row__thumb {
|
||||
.boardCard__thumb {
|
||||
object-fit: cover;
|
||||
}
|
||||
.row__thumbPlaceholder {
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
|
||||
}
|
||||
.row__head {
|
||||
padding: 14px 14px 0;
|
||||
.boardCard__thumbPlaceholder {
|
||||
background: #555;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
place-items: center;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.row__title {
|
||||
.boardCard__head {
|
||||
padding: 16px 18px 18px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.boardCard__titleRow,
|
||||
.boardCard__metaRow {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.boardCard__metaRow {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.boardCard__title {
|
||||
min-width: 0;
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.row__author {
|
||||
.boardCard__author {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
opacity: 0.86;
|
||||
}
|
||||
.row__avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
.boardCard__authorName {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.boardCard__avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.row__avatar--fallback {
|
||||
.boardCard__avatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.row__foot {
|
||||
padding: 0 14px 14px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.row__meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
opacity: 0.78;
|
||||
.boardCard__date,
|
||||
.favoriteStat {
|
||||
flex: 0 0 auto;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.favoriteBtn {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 999px;
|
||||
padding: 7px 10px;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
|
||||
.boardCard__date {
|
||||
font-size: 10px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
@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));
|
||||
}
|
||||
|
||||
@@ -4,12 +4,10 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const gameId = computed(() => route.params.gameId)
|
||||
|
||||
@@ -17,14 +15,14 @@ const gameName = ref('')
|
||||
const tierLists = ref([])
|
||||
const error = ref('')
|
||||
const query = ref('')
|
||||
const brokenThumbnailIds = ref({})
|
||||
const isListView = computed(() => route.query.view === 'list')
|
||||
|
||||
function fmt(ts) {
|
||||
return new Date(ts).toLocaleString(undefined, {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -41,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()
|
||||
})
|
||||
@@ -55,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 = '게임 정보를 불러오지 못했어요.'
|
||||
@@ -73,114 +78,97 @@ function openTierList(id) {
|
||||
router.push(`/editor/${gameId.value}/${id}`)
|
||||
}
|
||||
|
||||
async function toggleFavorite(tierList) {
|
||||
if (!auth.user) {
|
||||
router.push(`/login?redirect=/games/${gameId.value}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = tierList.isFavorited ? await api.unfavoriteTierList(tierList.id) : await api.favoriteTierList(tierList.id)
|
||||
tierLists.value = tierLists.value.map((entry) => (entry.id === tierList.id ? { ...entry, ...data.tierList } : entry))
|
||||
toast.success(tierList.isFavorited ? '즐겨찾기를 해제했어요.' : '즐겨찾기에 추가했어요.')
|
||||
} catch (e) {
|
||||
toast.error('즐겨찾기 처리에 실패했어요.')
|
||||
}
|
||||
}
|
||||
|
||||
function submitSearch() {
|
||||
loadTierLists()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="head">
|
||||
<div class="head__left">
|
||||
<div class="kicker">게임</div>
|
||||
<h2 class="title">{{ gameName || gameId }}</h2>
|
||||
<p class="desc">새 티어표를 만들거나, 다른 사람들이 올린 티어표를 확인하세요.</p>
|
||||
</div>
|
||||
<div class="head__right">
|
||||
<button class="primary" @click="createNew">{{ auth.user ? '새로운 티어표 만들기' : '로그인 후 새 티어표 만들기' }}</button>
|
||||
<section class="dashboardHero">
|
||||
<div class="dashboardHero__left">
|
||||
<div class="dashboardHero__eyebrow">Collection</div>
|
||||
<h2 class="dashboardHero__title">{{ gameName || gameId }}</h2>
|
||||
<p class="dashboardHero__desc">이 게임의 공개 티어표를 탐색하고, 바로 새 보드를 만들어 같은 흐름으로 이어갈 수 있어요.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<section class="panel">
|
||||
<div class="panel__head">
|
||||
<div class="panel__title">공개 티어표</div>
|
||||
<div>
|
||||
<div class="panel__title">공개 티어표</div>
|
||||
<div class="panel__sub">제목이나 작성자로 빠르게 좁혀볼 수 있어요.</div>
|
||||
</div>
|
||||
<div class="searchBar">
|
||||
<input v-model="query" class="searchBar__input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
|
||||
<button class="searchBar__button" @click="submitSearch">검색</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="t in tierLists" :key="t.id" class="row">
|
||||
<button class="row__body" @click="openTierList(t.id)">
|
||||
<div class="row__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(t)" class="row__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
|
||||
<div v-else class="row__thumbPlaceholder"></div>
|
||||
<div v-else class="list" :class="{ 'list--table': isListView }">
|
||||
<article v-for="t in tierLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
||||
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openTierList(t.id)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" alt="" @error="handleThumbnailError(t.id)" />
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="row__head">
|
||||
<div class="row__title">{{ t.title }}</div>
|
||||
<div class="row__author">
|
||||
<img v-if="avatarSrcOf(t)" class="row__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
|
||||
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
|
||||
<span>by {{ displayNameOf(t) }}</span>
|
||||
<div class="boardCard__head">
|
||||
<div class="boardCard__titleRow">
|
||||
<div class="boardCard__title">{{ t.title }}</div>
|
||||
<div class="favoriteStat" :title="t.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
|
||||
{{ t.isFavorited ? '♥' : '♡' }} {{ t.favoriteCount || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
|
||||
</div>
|
||||
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="row__foot">
|
||||
<div class="row__meta">{{ fmt(t.updatedAt) }}</div>
|
||||
<button class="favoriteBtn" @click="toggleFavorite(t)">
|
||||
{{ t.isFavorited ? '★' : '☆' }} {{ t.favoriteCount || 0 }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
.dashboardHero {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-end;
|
||||
gap: 18px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 2px 14px;
|
||||
padding: 6px 2px 18px;
|
||||
}
|
||||
.kicker {
|
||||
.dashboardHero__left {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.dashboardHero__eyebrow {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.title {
|
||||
.dashboardHero__title {
|
||||
margin: 4px 0 6px;
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.02em;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.04em;
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
.desc {
|
||||
.dashboardHero__desc {
|
||||
margin: 0;
|
||||
opacity: 0.84;
|
||||
}
|
||||
.primary {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
.primary:hover {
|
||||
background: rgba(96, 165, 250, 0.26);
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
max-width: 720px;
|
||||
}
|
||||
.panel {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
/* border: 1px solid rgba(255, 255, 255, 0.08); */
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.error {
|
||||
margin: 10px 0 14px;
|
||||
@@ -191,6 +179,12 @@ function submitSearch() {
|
||||
}
|
||||
.panel__title {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
}
|
||||
.panel__sub {
|
||||
margin-top: 6px;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
font-size: 13px;
|
||||
}
|
||||
.panel__head {
|
||||
display: flex;
|
||||
@@ -198,7 +192,7 @@ function submitSearch() {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.searchBar {
|
||||
display: flex;
|
||||
@@ -208,16 +202,16 @@ function submitSearch() {
|
||||
}
|
||||
.searchBar__input {
|
||||
min-width: 240px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.searchBar__button {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
padding: 11px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-weight: 800;
|
||||
@@ -228,24 +222,32 @@ function submitSearch() {
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.row {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
|
||||
.list--table {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.boardCard {
|
||||
min-width: 0;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(62, 62, 62, 0.82);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
min-height: 168px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background 0.16s ease;
|
||||
}
|
||||
.row:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
.boardCard:hover {
|
||||
background: rgba(70, 70, 70, 0.96);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.row__body {
|
||||
.boardCard__body {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -254,92 +256,155 @@ function submitSearch() {
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.row__thumbWrap {
|
||||
|
||||
.boardCard__body--list {
|
||||
grid-template-columns: 76px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
.boardCard__thumbWrap {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 14px 14px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.row__thumb {
|
||||
|
||||
.boardCard--list .boardCard__thumbWrap {
|
||||
aspect-ratio: auto;
|
||||
height: 100%;
|
||||
padding: 14px 0 14px 14px;
|
||||
}
|
||||
.boardCard__thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.row__thumbPlaceholder {
|
||||
|
||||
.boardCard--list .boardCard__thumb,
|
||||
.boardCard--list .boardCard__thumbPlaceholder {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.boardCard__thumbPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
|
||||
background: #555;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.row__title {
|
||||
.boardCard__title {
|
||||
font-weight: 800;
|
||||
min-width: 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
}
|
||||
.row__head {
|
||||
padding: 14px 14px 0;
|
||||
.boardCard__head {
|
||||
min-width: 0;
|
||||
padding: 16px 18px 18px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}
|
||||
.row__author {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.boardCard--list .boardCard__head {
|
||||
height: 100%;
|
||||
padding: 14px 16px 14px 0;
|
||||
align-content: center;
|
||||
}
|
||||
.boardCard__titleRow,
|
||||
.boardCard__metaRow {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.boardCard__titleRow {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.boardCard__metaRow {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.boardCard__author {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
opacity: 0.86;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
.row__avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
.boardCard__authorName {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.boardCard__avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.row__avatar--fallback {
|
||||
.boardCard__avatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.row__meta {
|
||||
opacity: 0.78;
|
||||
.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;
|
||||
}
|
||||
.row__foot {
|
||||
padding: 0 14px 14px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: auto;
|
||||
|
||||
.boardCard__date {
|
||||
font-size: 10px;
|
||||
}
|
||||
.favoriteBtn {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 999px;
|
||||
padding: 7px 10px;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
.favoriteBtn:hover {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.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%;
|
||||
|
||||
@@ -1,165 +1,252 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const items = ref([])
|
||||
const error = ref('')
|
||||
const games = computed(() => items.value.filter((item) => item.id !== 'freeform'))
|
||||
const loadingFavoriteId = ref('')
|
||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
|
||||
const games = computed(() => {
|
||||
const filtered = items.value
|
||||
.filter((item) => item.id !== 'freeform')
|
||||
.filter((item) => {
|
||||
if (!query.value) return true
|
||||
const haystack = `${item.name || ''} ${item.id || ''}`.toLowerCase()
|
||||
return haystack.includes(query.value)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
return filtered.slice().sort((a, b) => {
|
||||
if (!!a.isFavorited !== !!b.isFavorited) return a.isFavorited ? -1 : 1
|
||||
const rankA = a.displayRank == null ? Number.MAX_SAFE_INTEGER : a.displayRank
|
||||
const rankB = b.displayRank == null ? Number.MAX_SAFE_INTEGER : b.displayRank
|
||||
if (rankA !== rankB) return rankA - rankB
|
||||
return (a.name || '').localeCompare(b.name || '', 'ko')
|
||||
})
|
||||
})
|
||||
|
||||
async function loadGames() {
|
||||
try {
|
||||
const data = await api.listGames()
|
||||
items.value = data.games || []
|
||||
} catch (e) {
|
||||
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadGames)
|
||||
watch(() => auth.user?.id, loadGames)
|
||||
|
||||
function goGame(gameId) {
|
||||
router.push(`/games/${gameId}`)
|
||||
}
|
||||
|
||||
function goFreeform() {
|
||||
async function toggleFavorite(game, event) {
|
||||
event?.stopPropagation()
|
||||
if (!auth.user) {
|
||||
router.push('/login?redirect=/editor/freeform/new')
|
||||
router.push(`/login?redirect=${encodeURIComponent(route.fullPath || '/')}`)
|
||||
return
|
||||
}
|
||||
router.push('/editor/freeform/new')
|
||||
if (!game?.id || loadingFavoriteId.value === game.id) return
|
||||
|
||||
try {
|
||||
loadingFavoriteId.value = game.id
|
||||
const res = game.isFavorited ? await api.unfavoriteGame(game.id) : await api.favoriteGame(game.id)
|
||||
items.value = items.value.map((entry) => (entry.id === game.id ? { ...entry, ...res.game } : entry))
|
||||
} catch (e) {
|
||||
error.value = '즐겨찾기 변경에 실패했어요.'
|
||||
} finally {
|
||||
loadingFavoriteId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function thumbUrl(g) {
|
||||
if (!g.thumbnailSrc) return ''
|
||||
return toApiUrl(g.thumbnailSrc)
|
||||
return g.thumbnailSrc ? toApiUrl(g.thumbnailSrc) : ''
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="topBar">
|
||||
<div class="topBar__copy">
|
||||
<h1 class="topBar__title">게임 선택</h1>
|
||||
<p class="topBar__desc">관리자 고정 순서가 있으면 먼저 보여주고, 그 외 게임은 최근 생성순으로 정렬됩니다.</p>
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Workspace</div>
|
||||
<h1 class="pageHead__title">Game Library</h1>
|
||||
<p class="pageHead__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
||||
<p v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 게임 템플릿만 보고 있어요.</p>
|
||||
</div>
|
||||
<button class="customTierBtn" @click="goFreeform">{{ auth.user ? '커스텀 티어표 만들기' : '로그인 후 커스텀 티어표 만들기' }}</button>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<section class="grid">
|
||||
<button v-for="g in games" :key="g.id" class="card" @click="goGame(g.id)">
|
||||
<div class="thumbWrap">
|
||||
<img v-if="thumbUrl(g)" class="thumb" :src="thumbUrl(g)" :alt="g.name" />
|
||||
<div v-else class="thumbFallback">{{ g.name[0] }}</div>
|
||||
<TransitionGroup v-if="games.length" name="libraryCard" tag="section" class="libraryGrid">
|
||||
<article v-for="g in games" :key="g.id" class="libraryCard">
|
||||
<button
|
||||
class="libraryCard__favorite"
|
||||
type="button"
|
||||
:class="{ 'libraryCard__favorite--active': g.isFavorited }"
|
||||
:disabled="loadingFavoriteId === g.id"
|
||||
@click.stop="toggleFavorite(g, $event)"
|
||||
>
|
||||
{{ g.isFavorited ? '★' : '☆' }}
|
||||
</button>
|
||||
<button class="libraryCard__main" type="button" @click="goGame(g.id)">
|
||||
<div class="libraryCard__thumbWrap">
|
||||
<img v-if="thumbUrl(g)" class="libraryCard__thumb" :src="thumbUrl(g)" :alt="g.name" />
|
||||
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="card__title">{{ g.name }}</div>
|
||||
</button>
|
||||
</section>
|
||||
<div class="libraryCard__body">
|
||||
<div class="libraryCard__title">{{ g.name }}</div>
|
||||
<div class="libraryCard__meta">{{ g.id }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</TransitionGroup>
|
||||
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 게임 템플릿이 없어요.' : '표시할 게임 템플릿이 없어요.' }}</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topBar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.topBar__copy {
|
||||
.libraryGrid {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.topBar__title {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.topBar__desc {
|
||||
margin: 0;
|
||||
opacity: 0.78;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.customTierBtn {
|
||||
padding: 12px 16px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(96, 165, 250, 0.28);
|
||||
background: linear-gradient(135deg, rgba(96, 165, 250, 0.2), rgba(16, 185, 129, 0.16));
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.error {
|
||||
margin-top: 12px;
|
||||
margin: 0 0 16px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border: 1px solid rgba(239, 68, 68, 0.18);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.card {
|
||||
.pageHead__searchState {
|
||||
margin-top: 8px;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
.libraryCard {
|
||||
position: relative;
|
||||
text-align: left;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(62, 62, 62, 0.82);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
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;
|
||||
}
|
||||
.card:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
.libraryCard:hover {
|
||||
background: rgba(70, 70, 70, 0.96);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.thumbWrap {
|
||||
.libraryCard__main {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.libraryCard__favorite {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
right: 14px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(15, 15, 15, 0.72);
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
}
|
||||
.libraryCard__favorite--active {
|
||||
color: #ffd86b;
|
||||
}
|
||||
.libraryCard__thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: #555;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.thumb {
|
||||
.libraryCard__thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.thumbFallback {
|
||||
font-weight: 900;
|
||||
font-size: 28px;
|
||||
opacity: 0.85;
|
||||
.libraryCard__thumbFallback {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
.card__title {
|
||||
.libraryCard__body {
|
||||
display: grid;
|
||||
}
|
||||
.libraryCard__title {
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
font-size: 18px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.topBar {
|
||||
align-items: stretch;
|
||||
}
|
||||
.customTierBtn {
|
||||
width: 100%;
|
||||
}
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
.libraryCard__meta {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.libraryCard-move,
|
||||
.libraryCard-enter-active,
|
||||
.libraryCard-leave-active {
|
||||
transition: transform 280ms ease, opacity 220ms ease;
|
||||
}
|
||||
|
||||
.libraryCard-enter-from,
|
||||
.libraryCard-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.985);
|
||||
}
|
||||
|
||||
.libraryCard-leave-active {
|
||||
position: absolute;
|
||||
width: calc(100% - 0px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.libraryEmpty {
|
||||
padding: 20px 0;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.libraryGrid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (min-width: 721px) and (max-width: 1100px) {
|
||||
.grid {
|
||||
@media (max-width: 1200px) {
|
||||
.libraryGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.libraryGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.libraryGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { api } from '../lib/api'
|
||||
@@ -12,6 +12,7 @@ const toast = useToast()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const passwordConfirm = ref('')
|
||||
const mode = ref('login')
|
||||
const error = ref('')
|
||||
const hasUsers = ref(true)
|
||||
@@ -22,6 +23,14 @@ watch(error, (message) => {
|
||||
error.value = ''
|
||||
})
|
||||
|
||||
const title = computed(() => (mode.value === 'signup' ? '회원가입' : '로그인'))
|
||||
const description = computed(() =>
|
||||
mode.value === 'signup'
|
||||
? '티어표를 저장하고 즐겨찾기, 개인 설정을 관리할 수 있도록 계정을 만들어요.'
|
||||
: '저장한 티어표와 즐겨찾기, 프로필 설정을 이어서 관리할 수 있어요.'
|
||||
)
|
||||
const submitLabel = computed(() => (mode.value === 'signup' ? '가입하기' : '로그인'))
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const meta = await api.authMeta()
|
||||
@@ -33,6 +42,10 @@ onMounted(async () => {
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
if (mode.value === 'signup' && password.value !== passwordConfirm.value) {
|
||||
error.value = '비밀번호 확인이 일치하지 않아요.'
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (mode.value === 'signup') await auth.signup(email.value, password.value)
|
||||
else await auth.login(email.value, password.value)
|
||||
@@ -44,104 +57,184 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="wrap">
|
||||
<form class="card" @submit.prevent="submit">
|
||||
<div class="tabs">
|
||||
<button type="button" class="tab" :class="{ 'tab--active': mode === 'login' }" @click="mode = 'login'">
|
||||
<section class="pageWrap">
|
||||
<header class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Account</div>
|
||||
<h2 class="pageHead__title">{{ title }}</h2>
|
||||
<div class="pageHead__desc">{{ description }}</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="authScreen">
|
||||
<div class="authTabs" role="tablist" aria-label="로그인 또는 회원가입">
|
||||
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'login' }" @click="mode = 'login'">
|
||||
로그인
|
||||
</button>
|
||||
<button type="button" class="tab" :class="{ 'tab--active': mode === 'signup' }" @click="mode = 'signup'">
|
||||
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'signup' }" @click="mode = 'signup'">
|
||||
회원가입
|
||||
</button>
|
||||
</div>
|
||||
<label class="label">이메일</label>
|
||||
<input v-model="email" class="input" placeholder="you@example.com" autocomplete="email" />
|
||||
|
||||
<label class="label">비밀번호</label>
|
||||
<input
|
||||
v-model="password"
|
||||
class="input"
|
||||
type="password"
|
||||
placeholder="********"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<form class="authFields" @submit.prevent="submit">
|
||||
<label class="field">
|
||||
<span class="field__label">이메일</span>
|
||||
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" />
|
||||
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다.</span>
|
||||
</label>
|
||||
|
||||
<button class="btn" type="submit">{{ mode === 'signup' ? '회원가입' : '로그인' }}</button>
|
||||
<label class="field">
|
||||
<span class="field__label">비밀번호</span>
|
||||
<input
|
||||
v-model="password"
|
||||
class="field__input"
|
||||
type="password"
|
||||
placeholder="********"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<span class="field__hint">8자 이상으로 설정하면 더 안전하게 사용할 수 있어요.</span>
|
||||
</label>
|
||||
|
||||
<div v-if="!hasUsers" class="hint">첫 회원가입 계정은 자동으로 admin 권한이 부여됩니다(개발용).</div>
|
||||
</form>
|
||||
<label v-if="mode === 'signup'" class="field">
|
||||
<span class="field__label">비밀번호 확인</span>
|
||||
<input
|
||||
v-model="passwordConfirm"
|
||||
class="field__input"
|
||||
type="password"
|
||||
placeholder="********"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<span class="field__hint">같은 비밀번호를 한 번 더 입력해주세요.</span>
|
||||
</label>
|
||||
|
||||
<div v-if="!hasUsers" class="roleBadge">첫 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
|
||||
|
||||
<div class="authActions">
|
||||
<button class="secondaryAction" type="button" @click="router.push('/')">취소</button>
|
||||
<button class="primaryAction" type="submit">{{ submitLabel }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
min-height: calc(100vh - 74px);
|
||||
.authScreen {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 14px 2px;
|
||||
gap: 28px;
|
||||
max-width: 620px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.card {
|
||||
max-width: 420px;
|
||||
width: min(420px, 92vw);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
.authTabs {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
width: fit-content;
|
||||
padding: 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.tab {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 800;
|
||||
|
||||
.authTabs__button {
|
||||
min-width: 112px;
|
||||
padding: 10px 16px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tab--active {
|
||||
background: rgba(96, 165, 250, 0.18);
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
|
||||
.authTabs__button--active {
|
||||
background: rgba(76, 133, 245, 0.22);
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
|
||||
.authFields {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field__label {
|
||||
font-size: 13px;
|
||||
opacity: 0.78;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 6px;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
.input {
|
||||
|
||||
.field__input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
padding: 14px 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.btn {
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
|
||||
.field__input:focus {
|
||||
border-bottom-color: rgba(96, 165, 250, 0.9);
|
||||
}
|
||||
|
||||
.field__hint {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
|
||||
.roleBadge {
|
||||
width: fit-content;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(96, 165, 250, 0.28);
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
color: rgba(191, 219, 254, 0.92);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.authActions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction {
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
.btn:hover {
|
||||
background: rgba(96, 165, 250, 0.26);
|
||||
|
||||
.primaryAction {
|
||||
border: 1px solid rgba(76, 133, 245, 0.96);
|
||||
background: rgba(76, 133, 245, 0.92);
|
||||
color: #fff;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 10px;
|
||||
opacity: 0.72;
|
||||
font-size: 13px;
|
||||
|
||||
.secondaryAction {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.authTabs {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.authTabs__button {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,12 +17,10 @@ watch(error, (message) => {
|
||||
})
|
||||
|
||||
function fmt(ts) {
|
||||
return new Date(ts).toLocaleString(undefined, {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,43 +54,41 @@ function openList(t) {
|
||||
router.push(`/editor/${t.gameId}/${t.id}`)
|
||||
}
|
||||
|
||||
async function removeList(t) {
|
||||
error.value = ''
|
||||
try {
|
||||
const ok = window.confirm(`"${t.title}" 티어표를 삭제할까요?`)
|
||||
if (!ok) return
|
||||
await api.deleteTierList(t.id)
|
||||
myLists.value = myLists.value.filter((entry) => entry.id !== t.id)
|
||||
toast.success('티어표를 삭제했어요.')
|
||||
} catch (e) {
|
||||
error.value = '티어표 삭제에 실패했어요.'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="wrap">
|
||||
<h2 class="title">내 티어표</h2>
|
||||
<section class="pageWrap">
|
||||
<header class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Library</div>
|
||||
<h2 class="pageHead__title">내 티어표</h2>
|
||||
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="card">
|
||||
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="t in myLists" :key="t.id" class="row">
|
||||
<button class="row__body" @click="openList(t)">
|
||||
<div class="row__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(t)" class="row__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
|
||||
<div v-else class="row__thumbPlaceholder"></div>
|
||||
<article v-for="t in myLists" :key="t.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openList(t)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="row__head">
|
||||
<div class="row__title">{{ t.title }}</div>
|
||||
<div class="row__author">
|
||||
<img v-if="avatarSrcOf(t)" class="row__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
|
||||
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
|
||||
<span>by {{ displayNameOf(t) }}</span>
|
||||
<div class="boardCard__head">
|
||||
<div class="boardCard__titleRow">
|
||||
<div class="boardCard__title">{{ t.title }}</div>
|
||||
<div class="favoriteStat">♡ {{ t.favoriteCount || 0 }}</div>
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
|
||||
</div>
|
||||
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row__meta">{{ fmt(t.updatedAt) }}</div>
|
||||
</button>
|
||||
<button class="link link--danger" @click="removeList(t)">삭제</button>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,47 +96,39 @@ async function removeList(t) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
padding: 10px 2px;
|
||||
}
|
||||
.title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
}
|
||||
.link {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.empty {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 320px));
|
||||
justify-content: start;
|
||||
gap: 18px;
|
||||
}
|
||||
.row {
|
||||
.boardCard {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
min-width: 0;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(62, 62, 62, 0.82);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background 0.16s ease;
|
||||
}
|
||||
.row__body {
|
||||
.boardCard:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(70, 70, 70, 0.96);
|
||||
}
|
||||
.boardCard__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
@@ -150,73 +138,103 @@ async function removeList(t) {
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.row__thumbWrap {
|
||||
.boardCard__thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 14px 14px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.row__thumb {
|
||||
.boardCard__thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.row__thumbPlaceholder {
|
||||
.boardCard__thumbPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
|
||||
background: #555;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.row__title {
|
||||
.boardCard__title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-weight: 900;
|
||||
font-size: 18px;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boardCard__head {
|
||||
padding: 16px 18px 18px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.row__head {
|
||||
padding: 0 14px;
|
||||
.boardCard__titleRow,
|
||||
.boardCard__metaRow {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.row__author {
|
||||
|
||||
.boardCard__titleRow {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.boardCard__metaRow {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.boardCard__author {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
opacity: 0.84;
|
||||
}
|
||||
.row__avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
.boardCard__authorName {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.boardCard__avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.row__avatar--fallback {
|
||||
.boardCard__avatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.row__meta {
|
||||
padding: 0 14px;
|
||||
margin-top: 6px;
|
||||
opacity: 0.76;
|
||||
.boardCard__date,
|
||||
.favoriteStat {
|
||||
flex: 0 0 auto;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.link--danger {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
border-color: rgba(239, 68, 68, 0.28);
|
||||
margin: 0 14px 14px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.boardCard__date {
|
||||
font-size: 10px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.list {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
@@ -14,6 +14,8 @@ const saving = ref(false)
|
||||
const nickname = ref('')
|
||||
const previewUrl = ref('')
|
||||
const avatarFile = ref(null)
|
||||
const removeAvatar = ref(false)
|
||||
const fileInput = ref(null)
|
||||
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
@@ -23,26 +25,53 @@ watch(error, (message) => {
|
||||
|
||||
const avatarUrl = computed(() => {
|
||||
if (previewUrl.value) return previewUrl.value
|
||||
if (removeAvatar.value) return ''
|
||||
if (!auth.user?.avatarSrc) return ''
|
||||
return toApiUrl(auth.user.avatarSrc)
|
||||
})
|
||||
|
||||
const displayInitial = computed(() => {
|
||||
const email = auth.user?.email || 'U'
|
||||
return email[0].toUpperCase()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await auth.refresh()
|
||||
if (!auth.user) router.push('/login')
|
||||
nickname.value = auth.user?.nickname || ''
|
||||
removeAvatar.value = false
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
})
|
||||
|
||||
function openAvatarPicker() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function onAvatarChange(e) {
|
||||
const file = e.target.files && e.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
error.value = ''
|
||||
removeAvatar.value = false
|
||||
avatarFile.value = file
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = URL.createObjectURL(file)
|
||||
}
|
||||
|
||||
function clearAvatar() {
|
||||
error.value = ''
|
||||
avatarFile.value = null
|
||||
removeAvatar.value = true
|
||||
if (previewUrl.value) {
|
||||
URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
}
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
@@ -50,6 +79,8 @@ async function saveProfile() {
|
||||
const fd = new FormData()
|
||||
fd.append('nickname', nickname.value)
|
||||
if (avatarFile.value) fd.append('avatar', avatarFile.value)
|
||||
if (removeAvatar.value) fd.append('removeAvatar', '1')
|
||||
|
||||
const res = await fetch(toApiUrl('/api/auth/profile'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
@@ -59,10 +90,12 @@ async function saveProfile() {
|
||||
const data = await res.json()
|
||||
auth.user = data.user
|
||||
avatarFile.value = null
|
||||
removeAvatar.value = false
|
||||
if (previewUrl.value) {
|
||||
URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
}
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
toast.success('프로필을 저장했어요.')
|
||||
} catch (e2) {
|
||||
error.value = '프로필 저장에 실패했어요.'
|
||||
@@ -70,131 +103,289 @@ async function saveProfile() {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
toast.success('로그아웃했어요.')
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="wrap">
|
||||
<h2 class="title">프로필</h2>
|
||||
<section class="pageWrap">
|
||||
<header class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Account</div>
|
||||
<h2 class="pageHead__title">Settings</h2>
|
||||
<div class="pageHead__desc">계정 정보를 간결하게 정리하고, 프로필 이미지를 클릭해서 바로 변경할 수 있어요.</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="card" v-if="auth.user">
|
||||
<div class="row">
|
||||
<div class="avatar">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="avatarImg" alt="avatar" />
|
||||
<div v-else class="avatarFallback">{{ (auth.user.email || 'U')[0].toUpperCase() }}</div>
|
||||
<section v-if="auth.user" class="settingsScreen">
|
||||
<div class="settingsIdentity">
|
||||
<div class="avatarButtonWrap">
|
||||
<button class="avatarButton" type="button" @click="openAvatarPicker">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" />
|
||||
<div v-else class="avatarButton__fallback">{{ displayInitial }}</div>
|
||||
<div class="avatarButton__overlay">
|
||||
<span>{{ avatarUrl ? '이미지 변경' : '이미지 추가' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
v-if="avatarUrl || previewUrl"
|
||||
class="avatarButton__remove"
|
||||
type="button"
|
||||
aria-label="프로필 이미지 삭제"
|
||||
@click="clearAvatar"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div class="email">{{ auth.user.email }}</div>
|
||||
<input v-model="nickname" class="nicknameInput" placeholder="작성자 닉네임" />
|
||||
<div class="badge" v-if="auth.user.isAdmin">admin</div>
|
||||
|
||||
<div class="identityMeta">
|
||||
<div class="identityMeta__eyebrow">Profile Photo</div>
|
||||
<div class="identityMeta__title">프로필 이미지</div>
|
||||
<div class="identityMeta__desc">아바타를 클릭해서 이미지를 추가하거나 교체할 수 있습니다.</div>
|
||||
</div>
|
||||
|
||||
<input ref="fileInput" class="hiddenInput" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
|
||||
</div>
|
||||
|
||||
<div class="upload">
|
||||
<label class="label">아바타 업로드</label>
|
||||
<input class="file" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
|
||||
<div class="hint">파일 선택은 미리보기만 바뀌고, 실제 반영은 저장 버튼을 눌렀을 때 진행됩니다.</div>
|
||||
<button class="saveBtn" :disabled="saving" @click="saveProfile">
|
||||
{{ saving ? '저장중...' : '프로필 저장' }}
|
||||
</button>
|
||||
<div class="settingsFields">
|
||||
<label class="field">
|
||||
<span class="field__label">닉네임</span>
|
||||
<input v-model="nickname" class="field__input" placeholder="작성자 닉네임" />
|
||||
<span class="field__hint">티어표 작성자 이름으로 표시됩니다.</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field__label">이메일</span>
|
||||
<input :value="auth.user.email" class="field__input field__input--readonly" readonly />
|
||||
<span class="field__hint">현재 로그인에 사용 중인 계정입니다.</span>
|
||||
</label>
|
||||
|
||||
<div v-if="auth.user.isAdmin" class="roleBadge">Administrator</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settingsActions">
|
||||
<button class="primaryAction" :disabled="saving" @click="saveProfile">{{ saving ? '저장 중...' : '변경사항 저장' }}</button>
|
||||
<button class="secondaryAction" type="button" @click="logout">로그아웃</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
padding: 10px 2px;
|
||||
.settingsScreen {
|
||||
display: grid;
|
||||
gap: 32px;
|
||||
max-width: 620px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.card {
|
||||
max-width: 520px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.settingsIdentity {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
}
|
||||
.avatar {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
|
||||
.avatarButtonWrap {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.avatarButton {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.avatarImg {
|
||||
|
||||
.avatarButton__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.avatarFallback {
|
||||
|
||||
.avatarButton__fallback {
|
||||
font-size: 34px;
|
||||
font-weight: 900;
|
||||
font-size: 20px;
|
||||
opacity: 0.9;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
.meta {
|
||||
|
||||
.avatarButton__overlay {
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
padding: 12px 10px;
|
||||
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.72));
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.avatarButton__remove {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(10, 10, 10, 0.72);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.28);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.avatarButton__remove svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2.1;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.avatarButton__remove:hover {
|
||||
background: rgba(190, 24, 24, 0.88);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.identityMeta {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
.email {
|
||||
font-weight: 900;
|
||||
|
||||
.identityMeta__eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.36);
|
||||
}
|
||||
.nicknameInput {
|
||||
|
||||
.identityMeta__title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.identityMeta__desc {
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
|
||||
.hiddenInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settingsFields {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field__label {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
.field__input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
padding: 14px 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.badge {
|
||||
|
||||
.field__input:focus {
|
||||
border-bottom-color: rgba(96, 165, 250, 0.9);
|
||||
}
|
||||
|
||||
.field__input--readonly {
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.field__hint {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 999px;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
|
||||
.roleBadge {
|
||||
width: fit-content;
|
||||
opacity: 0.9;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(96, 165, 250, 0.28);
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
color: rgba(191, 219, 254, 0.92);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.upload {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
|
||||
.settingsActions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
opacity: 0.78;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.file {
|
||||
width: 100%;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 8px;
|
||||
opacity: 0.72;
|
||||
font-size: 13px;
|
||||
}
|
||||
.saveBtn {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction {
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.primaryAction {
|
||||
border: 1px solid rgba(76, 133, 245, 0.96);
|
||||
background: rgba(76, 133, 245, 0.92);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.secondaryAction {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.settingsIdentity {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.avatarButtonWrap {
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
}
|
||||
|
||||
.avatarButton {
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
279
frontend/src/views/SearchResultsView.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const tierLists = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const query = ref('')
|
||||
|
||||
function fmt(ts) {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function displayNameOf(tierList) {
|
||||
return tierList.authorName || '알 수 없음'
|
||||
}
|
||||
|
||||
function avatarSrcOf(tierList) {
|
||||
return tierList.authorAvatarSrc ? toApiUrl(tierList.authorAvatarSrc) : ''
|
||||
}
|
||||
|
||||
function avatarFallbackOf(tierList) {
|
||||
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
||||
}
|
||||
|
||||
function tierListThumbnailUrl(tierList) {
|
||||
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
|
||||
}
|
||||
|
||||
function openTierList(tierList) {
|
||||
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
|
||||
}
|
||||
|
||||
async function loadResults() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await api.searchAllPublicTierLists(query.value)
|
||||
tierLists.value = data.tierLists || []
|
||||
} catch (e) {
|
||||
error.value = '검색 결과를 불러오지 못했어요.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.q,
|
||||
async (nextQuery) => {
|
||||
query.value = typeof nextQuery === 'string' ? nextQuery : ''
|
||||
await loadResults()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="wrap">
|
||||
<div class="head">
|
||||
<div>
|
||||
<div class="head__eyebrow">Search</div>
|
||||
<h2 class="title">전체 티어표 검색</h2>
|
||||
<div class="desc">공개된 모든 티어표를 제목과 작성자 기준으로 찾아볼 수 있어요.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-else-if="loading" class="empty">검색 중이에요.</div>
|
||||
<div v-else-if="tierLists.length === 0" class="empty">검색 결과가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="boardCard__head">
|
||||
<div class="boardCard__titleRow">
|
||||
<div class="boardCard__title">{{ tierList.title }}</div>
|
||||
<div class="favoriteStat" :title="tierList.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
|
||||
{{ tierList.isFavorited ? '♥' : '♡' }} {{ tierList.favoriteCount || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
|
||||
</div>
|
||||
<div class="boardCard__date">{{ fmt(tierList.updatedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 2px 8px;
|
||||
}
|
||||
.head__eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
.title {
|
||||
margin: 4px 0 0;
|
||||
font-size: 32px;
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.desc {
|
||||
margin-top: 6px;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
.error {
|
||||
margin: 0 0 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.18);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
.empty {
|
||||
opacity: 0.76;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.boardCard {
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(62, 62, 62, 0.82);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
transition: transform 0.16s ease, background 0.16s ease;
|
||||
}
|
||||
.boardCard:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(70, 70, 70, 0.96);
|
||||
}
|
||||
.boardCard__body {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
}
|
||||
.boardCard__thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
padding: 14px 14px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.boardCard__thumb,
|
||||
.boardCard__thumbPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.boardCard__thumb {
|
||||
object-fit: cover;
|
||||
}
|
||||
.boardCard__thumbPlaceholder {
|
||||
background: #555;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.boardCard__head {
|
||||
padding: 16px 18px 18px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.boardCard__titleRow,
|
||||
.boardCard__metaRow {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.boardCard__metaRow {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.boardCard__title {
|
||||
min-width: 0;
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.boardCard__author {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
opacity: 0.86;
|
||||
}
|
||||
.boardCard__authorName {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.boardCard__avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.boardCard__avatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.boardCard__date,
|
||||
.favoriteStat {
|
||||
flex: 0 0 auto;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.boardCard__date {
|
||||
font-size: 10px;
|
||||
}
|
||||
.boardCard__date {
|
||||
font-size: 10px;
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,10 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:frontend": "npm --prefix frontend run dev",
|
||||
"dev:backend": "npm --prefix backend run dev",
|
||||
"build": "npm --prefix frontend run build",
|
||||
"start": "npm --prefix backend run start",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
9
update.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Update Log Entry Point
|
||||
|
||||
이 프로젝트의 상세 업데이트 로그는 [docs/update.md](/Users/bicute/Desktop/zenn.dev/tier-cursor/docs/update.md)에 계속 누적됩니다.
|
||||
|
||||
## 2026-03-30
|
||||
|
||||
- 루트 `package.json`에 공용 실행 스크립트(`dev:frontend`, `dev:backend`, `build`, `start`)를 추가했습니다.
|
||||
- 루트에서도 바로 `npm run build` 같은 공용 명령을 사용할 수 있게 정리했습니다.
|
||||
- 업데이트 로그 진입점을 루트 `update.md`로 추가해, 이후 작업 시 파일 위치를 바로 찾을 수 있게 했습니다.
|
||||