Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 880c79bbc4 | |||
| 7967361cac | |||
| fde62dbb43 | |||
| a5c632d9ae | |||
| a19606c516 | |||
| 0581de6c17 | |||
| 4db1b21ad5 | |||
| d760c7331a | |||
| ebe7a4408f | |||
| 2fba826900 | |||
| 5b047b0458 | |||
| 6dce53db7a | |||
| 3227181c24 | |||
| fadfd0ba58 | |||
| f77ce2a580 | |||
| 50773f799a | |||
| 0b283276ce | |||
| 6105208aef | |||
| f6dc64dfc8 | |||
| 40a8dac7b6 | |||
| faa2a01f6c | |||
| 2cdd627658 | |||
| 34ddd1083d | |||
| b5ec579e5d | |||
| 25b893407c |
537
backend/package-lock.json
generated
537
backend/package-lock.json
generated
@@ -17,12 +17,488 @@
|
|||||||
"mysql2": "^3.20.0",
|
"mysql2": "^3.20.0",
|
||||||
"nanoid": "^5.1.7",
|
"nanoid": "^5.1.7",
|
||||||
"session-file-store": "^1.5.0",
|
"session-file-store": "^1.5.0",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.14"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.5.0",
|
"version": "25.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||||
@@ -368,6 +844,15 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -1381,7 +1866,6 @@
|
|||||||
"version": "7.7.4",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@@ -1458,6 +1942,50 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
@@ -1635,6 +2163,13 @@
|
|||||||
"nodetouch": "bin/nodetouch.js"
|
"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": {
|
"node_modules/type-is": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor nodemon --legacy-watch --watch index.js --watch src index.js",
|
"dev": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor nodemon --legacy-watch --watch index.js --watch src index.js",
|
||||||
"start": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js"
|
"start": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js",
|
||||||
|
"images:backfill": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/backfill-legacy-image-assets.js",
|
||||||
|
"images:migrate-legacy": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/migrate-legacy-uploads-to-assets.js"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
"mysql2": "^3.20.0",
|
"mysql2": "^3.20.0",
|
||||||
"nanoid": "^5.1.7",
|
"nanoid": "^5.1.7",
|
||||||
"session-file-store": "^1.5.0",
|
"session-file-store": "^1.5.0",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
106
backend/scripts/backfill-legacy-image-assets.js
Normal file
106
backend/scripts/backfill-legacy-image-assets.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
const fs = require('fs/promises')
|
||||||
|
const path = require('path')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const sharp = require('sharp')
|
||||||
|
const { nanoid } = require('nanoid')
|
||||||
|
const {
|
||||||
|
ensureData,
|
||||||
|
closePool,
|
||||||
|
listReferencedUploadSources,
|
||||||
|
findImageAssetBySrc,
|
||||||
|
createImageAsset,
|
||||||
|
} = require('../src/db')
|
||||||
|
|
||||||
|
const BACKEND_ROOT = path.join(__dirname, '..')
|
||||||
|
|
||||||
|
function inferMimeType(src, metadata) {
|
||||||
|
const format = String(metadata?.format || '').toLowerCase()
|
||||||
|
if (format === 'jpeg' || format === 'jpg') return 'image/jpeg'
|
||||||
|
if (format === 'png') return 'image/png'
|
||||||
|
if (format === 'gif') return 'image/gif'
|
||||||
|
if (format === 'webp') return 'image/webp'
|
||||||
|
if (format === 'svg' || format === 'svg+xml') return 'image/svg+xml'
|
||||||
|
if (format === 'avif') return 'image/avif'
|
||||||
|
|
||||||
|
const ext = path.extname(src || '').toLowerCase()
|
||||||
|
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
||||||
|
if (ext === '.png') return 'image/png'
|
||||||
|
if (ext === '.gif') return 'image/gif'
|
||||||
|
if (ext === '.webp') return 'image/webp'
|
||||||
|
if (ext === '.svg') return 'image/svg+xml'
|
||||||
|
if (ext === '.avif') return 'image/avif'
|
||||||
|
return 'application/octet-stream'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await ensureData()
|
||||||
|
|
||||||
|
const referencedSrcs = Array.from(new Set(await listReferencedUploadSources()))
|
||||||
|
.filter((src) => typeof src === 'string' && src.startsWith('/uploads/'))
|
||||||
|
.sort()
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
scanned: referencedSrcs.length,
|
||||||
|
skippedExisting: 0,
|
||||||
|
backfilled: 0,
|
||||||
|
missingFiles: 0,
|
||||||
|
failed: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const src of referencedSrcs) {
|
||||||
|
const existing = await findImageAssetBySrc(src)
|
||||||
|
if (existing) {
|
||||||
|
summary.skippedExisting += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = path.join(BACKEND_ROOT, src.replace(/^\//, ''))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [buffer, stat] = await Promise.all([fs.readFile(absolutePath), fs.stat(absolutePath)])
|
||||||
|
let metadata = {}
|
||||||
|
try {
|
||||||
|
metadata = await sharp(buffer, { failOn: 'none' }).metadata()
|
||||||
|
} catch (error) {
|
||||||
|
metadata = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawHash = crypto.createHash('sha256').update(buffer).digest('hex')
|
||||||
|
const contentHash = crypto.createHash('sha256').update(`${rawHash}|${src}`).digest('hex')
|
||||||
|
|
||||||
|
await createImageAsset({
|
||||||
|
id: nanoid(),
|
||||||
|
contentHash,
|
||||||
|
src,
|
||||||
|
mimeType: inferMimeType(src, metadata),
|
||||||
|
byteSize: Number(stat.size || 0),
|
||||||
|
originalByteSize: Number(stat.size || 0),
|
||||||
|
width: Number(metadata.width || 0),
|
||||||
|
height: Number(metadata.height || 0),
|
||||||
|
})
|
||||||
|
summary.backfilled += 1
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') {
|
||||||
|
summary.missingFiles += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (error?.code === 'ER_DUP_ENTRY') {
|
||||||
|
summary.skippedExisting += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
summary.failed += 1
|
||||||
|
console.error('[backfill-legacy-image-assets] failed:', src, error?.message || error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify(summary, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await closePool()
|
||||||
|
})
|
||||||
112
backend/scripts/migrate-legacy-uploads-to-assets.js
Normal file
112
backend/scripts/migrate-legacy-uploads-to-assets.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
const fs = require('fs/promises')
|
||||||
|
const path = require('path')
|
||||||
|
const sharp = require('sharp')
|
||||||
|
const {
|
||||||
|
ensureData,
|
||||||
|
closePool,
|
||||||
|
listReferencedUploadUsage,
|
||||||
|
replaceUploadSourceReferences,
|
||||||
|
} = require('../src/db')
|
||||||
|
const { writeOptimizedImage } = require('../src/lib/image-storage')
|
||||||
|
|
||||||
|
const BACKEND_ROOT = path.join(__dirname, '..')
|
||||||
|
|
||||||
|
function inferMimeType(src, metadata) {
|
||||||
|
const format = String(metadata?.format || '').toLowerCase()
|
||||||
|
if (format === 'jpeg' || format === 'jpg') return 'image/jpeg'
|
||||||
|
if (format === 'png') return 'image/png'
|
||||||
|
if (format === 'gif') return 'image/gif'
|
||||||
|
if (format === 'webp') return 'image/webp'
|
||||||
|
if (format === 'svg' || format === 'svg+xml') return 'image/svg+xml'
|
||||||
|
if (format === 'avif') return 'image/avif'
|
||||||
|
|
||||||
|
const ext = path.extname(src || '').toLowerCase()
|
||||||
|
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
||||||
|
if (ext === '.png') return 'image/png'
|
||||||
|
if (ext === '.gif') return 'image/gif'
|
||||||
|
if (ext === '.webp') return 'image/webp'
|
||||||
|
if (ext === '.svg') return 'image/svg+xml'
|
||||||
|
if (ext === '.avif') return 'image/avif'
|
||||||
|
return 'application/octet-stream'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptimizationConfig(roles) {
|
||||||
|
const roleSet = new Set(roles || [])
|
||||||
|
if (roleSet.has('avatar')) {
|
||||||
|
return { directory: 'avatars', width: 512, height: 512, fit: 'cover', quality: 82 }
|
||||||
|
}
|
||||||
|
if (roleSet.has('game-thumbnail') || roleSet.has('tierlist-thumbnail') || roleSet.has('template-thumbnail')) {
|
||||||
|
return { directory: 'legacy-thumbnails', width: 1280, height: 1280, fit: 'inside', quality: 84 }
|
||||||
|
}
|
||||||
|
return { directory: 'legacy-items', width: 512, height: 512, fit: 'inside', quality: 84 }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFileLike(src) {
|
||||||
|
const absolutePath = path.join(BACKEND_ROOT, src.replace(/^\//, ''))
|
||||||
|
const [buffer, stat] = await Promise.all([fs.readFile(absolutePath), fs.stat(absolutePath)])
|
||||||
|
let metadata = {}
|
||||||
|
try {
|
||||||
|
metadata = await sharp(buffer, { failOn: 'none' }).metadata()
|
||||||
|
} catch (error) {
|
||||||
|
metadata = {}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
file: {
|
||||||
|
originalname: path.basename(src),
|
||||||
|
mimetype: inferMimeType(src, metadata),
|
||||||
|
size: Number(stat.size || 0),
|
||||||
|
buffer,
|
||||||
|
},
|
||||||
|
absolutePath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await ensureData()
|
||||||
|
const usageEntries = await listReferencedUploadUsage()
|
||||||
|
const legacyEntries = usageEntries.filter((entry) => entry.src && entry.src.startsWith('/uploads/') && !entry.src.startsWith('/uploads/assets/'))
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
scanned: legacyEntries.length,
|
||||||
|
migrated: 0,
|
||||||
|
reusedAsset: 0,
|
||||||
|
unchanged: 0,
|
||||||
|
missingFiles: 0,
|
||||||
|
failed: 0,
|
||||||
|
updatedRows: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of legacyEntries) {
|
||||||
|
const config = getOptimizationConfig(entry.roles)
|
||||||
|
try {
|
||||||
|
const { file } = await createFileLike(entry.src)
|
||||||
|
const optimized = await writeOptimizedImage({ file, ...config })
|
||||||
|
if (optimized.src === entry.src) {
|
||||||
|
summary.unchanged += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const replaced = await replaceUploadSourceReferences({ fromSrc: entry.src, toSrc: optimized.src })
|
||||||
|
summary.updatedRows += Number(replaced.updatedRows || 0)
|
||||||
|
if (optimized.reused) summary.reusedAsset += 1
|
||||||
|
else summary.migrated += 1
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') {
|
||||||
|
summary.missingFiles += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
summary.failed += 1
|
||||||
|
console.error('[migrate-legacy-uploads-to-assets] failed:', entry.src, error?.message || error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify(summary, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await closePool()
|
||||||
|
})
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const fs = require('fs/promises')
|
||||||
|
const path = require('path')
|
||||||
const mysql = require('mysql2/promise')
|
const mysql = require('mysql2/promise')
|
||||||
|
|
||||||
const DB_HOST = process.env.DB_HOST || '127.0.0.1'
|
const DB_HOST = process.env.DB_HOST || '127.0.0.1'
|
||||||
@@ -28,6 +30,29 @@ function serializeJson(value) {
|
|||||||
return JSON.stringify(value || [])
|
return JSON.stringify(value || [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectUploadSrcsFromItems(items, bucket) {
|
||||||
|
for (const item of items || []) {
|
||||||
|
if (typeof item?.src === 'string' && item.src.startsWith('/uploads/')) {
|
||||||
|
bucket.add(item.src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMonthRange(month) {
|
||||||
|
if (typeof month !== 'string') return null
|
||||||
|
const match = month.trim().match(/^(\d{4})-(\d{2})$/)
|
||||||
|
if (!match) return null
|
||||||
|
|
||||||
|
const year = Number(match[1])
|
||||||
|
const monthIndex = Number(match[2]) - 1
|
||||||
|
if (!Number.isInteger(year) || monthIndex < 0 || monthIndex > 11) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: new Date(year, monthIndex, 1).getTime(),
|
||||||
|
end: new Date(year, monthIndex + 1, 1).getTime(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function mapUserRow(row) {
|
function mapUserRow(row) {
|
||||||
if (!row) return null
|
if (!row) return null
|
||||||
return {
|
return {
|
||||||
@@ -64,6 +89,37 @@ function mapGameItemRow(row) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapImageAssetRow(row) {
|
||||||
|
if (!row) return null
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
contentHash: row.content_hash,
|
||||||
|
src: row.src || '',
|
||||||
|
mimeType: row.mime_type || 'image/webp',
|
||||||
|
byteSize: Number(row.byte_size || 0),
|
||||||
|
originalByteSize: Number(row.original_byte_size || 0),
|
||||||
|
width: Number(row.width || 0),
|
||||||
|
height: Number(row.height || 0),
|
||||||
|
createdAt: Number(row.created_at || 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapImageOptimizationJobRow(row) {
|
||||||
|
if (!row) return null
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
status: row.status,
|
||||||
|
sourceCategory: row.source_category || '',
|
||||||
|
targetDirectory: row.target_directory || '',
|
||||||
|
originalByteSize: Number(row.original_byte_size || 0),
|
||||||
|
optimizedByteSize: Number(row.optimized_byte_size || 0),
|
||||||
|
reusedAsset: !!row.reused_asset,
|
||||||
|
errorMessage: row.error_message || '',
|
||||||
|
queuedAt: Number(row.queued_at || 0),
|
||||||
|
startedAt: Number(row.started_at || 0),
|
||||||
|
finishedAt: Number(row.finished_at || 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
function mapTierListRow(row) {
|
function mapTierListRow(row) {
|
||||||
if (!row) return null
|
if (!row) return null
|
||||||
return {
|
return {
|
||||||
@@ -79,6 +135,9 @@ function mapTierListRow(row) {
|
|||||||
description: row.description || '',
|
description: row.description || '',
|
||||||
isPublic: !!row.is_public,
|
isPublic: !!row.is_public,
|
||||||
showCharacterNames: !!row.show_character_names,
|
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, []),
|
groups: parseJson(row.groups_json, []),
|
||||||
pool: parseJson(row.pool_json, []),
|
pool: parseJson(row.pool_json, []),
|
||||||
createdAt: Number(row.created_at),
|
createdAt: Number(row.created_at),
|
||||||
@@ -164,6 +223,14 @@ async function query(sql, params = []) {
|
|||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function closePool() {
|
||||||
|
if (!poolPromise) return
|
||||||
|
const pool = await poolPromise
|
||||||
|
await pool.end()
|
||||||
|
poolPromise = null
|
||||||
|
initPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureSchema() {
|
async function ensureSchema() {
|
||||||
if (initPromise) return initPromise
|
if (initPromise) return initPromise
|
||||||
initPromise = (async () => {
|
initPromise = (async () => {
|
||||||
@@ -228,6 +295,9 @@ async function ensureSchema() {
|
|||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
is_public TINYINT(1) NOT NULL DEFAULT 0,
|
is_public TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
show_character_names 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,
|
groups_json LONGTEXT NOT NULL,
|
||||||
pool_json LONGTEXT NOT NULL,
|
pool_json LONGTEXT NOT NULL,
|
||||||
created_at BIGINT NOT NULL,
|
created_at BIGINT NOT NULL,
|
||||||
@@ -252,6 +322,50 @@ async function ensureSchema() {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) 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(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS template_requests (
|
CREATE TABLE IF NOT EXISTS template_requests (
|
||||||
id VARCHAR(64) PRIMARY KEY,
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
@@ -283,6 +397,18 @@ async function ensureSchema() {
|
|||||||
if (!tierListShowNamesColumns.length) {
|
if (!tierListShowNamesColumns.length) {
|
||||||
await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
|
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(
|
await query(
|
||||||
`
|
`
|
||||||
@@ -431,7 +557,7 @@ async function adminDeleteUser(id) {
|
|||||||
await query('DELETE FROM users WHERE id = ?', [id])
|
await query('DELETE FROM users WHERE id = ?', [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listGames() {
|
async function listGames(currentUserId = '') {
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`
|
`
|
||||||
SELECT id, name, thumbnail_src, display_rank, created_at
|
SELECT id, name, thumbnail_src, display_rank, created_at
|
||||||
@@ -445,7 +571,15 @@ async function listGames() {
|
|||||||
`,
|
`,
|
||||||
[FREEFORM_GAME_ID]
|
[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) {
|
async function findGameById(id) {
|
||||||
@@ -484,6 +618,367 @@ async function updateGameThumbnail(gameId, thumbnailSrc) {
|
|||||||
return findGameById(gameId)
|
return findGameById(gameId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findImageAssetByHash(contentHash) {
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE content_hash = ? LIMIT 1',
|
||||||
|
[contentHash]
|
||||||
|
)
|
||||||
|
return mapImageAssetRow(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findImageAssetBySrc(src) {
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE src = ? LIMIT 1',
|
||||||
|
[src]
|
||||||
|
)
|
||||||
|
return mapImageAssetRow(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createImageAsset({ id, contentHash, src, mimeType = "image/webp", byteSize, originalByteSize, width, height }) {
|
||||||
|
const createdAt = now()
|
||||||
|
await query(
|
||||||
|
'INSERT INTO image_assets (id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[id, contentHash, src, mimeType, byteSize, originalByteSize, width, height, createdAt]
|
||||||
|
)
|
||||||
|
return findImageAssetByHash(contentHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createImageOptimizationJob({ id, sourceCategory, targetDirectory, originalByteSize }) {
|
||||||
|
const queuedAt = now()
|
||||||
|
await query(
|
||||||
|
'INSERT INTO image_optimization_jobs (id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[id, 'queued', sourceCategory || '', targetDirectory || '', originalByteSize || 0, 0, 0, '', queuedAt, 0, 0]
|
||||||
|
)
|
||||||
|
return findImageOptimizationJobById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findImageOptimizationJobById(id) {
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at FROM image_optimization_jobs WHERE id = ? LIMIT 1',
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
return mapImageOptimizationJobRow(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateImageOptimizationJobStatus({ id, status, optimizedByteSize = 0, reusedAsset = false, errorMessage = '', startedAt, finishedAt }) {
|
||||||
|
const fields = ['status = ?', 'optimized_byte_size = ?', 'reused_asset = ?', 'error_message = ?']
|
||||||
|
const params = [status, optimizedByteSize, reusedAsset ? 1 : 0, errorMessage.slice(0, 255)]
|
||||||
|
|
||||||
|
if (typeof startedAt === 'number') {
|
||||||
|
fields.push('started_at = ?')
|
||||||
|
params.push(startedAt)
|
||||||
|
}
|
||||||
|
if (typeof finishedAt === 'number') {
|
||||||
|
fields.push('finished_at = ?')
|
||||||
|
params.push(finishedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(id)
|
||||||
|
await query(`UPDATE image_optimization_jobs SET ${fields.join(', ')} WHERE id = ?`, params)
|
||||||
|
return findImageOptimizationJobById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listRecentImageOptimizationJobs(limit = 20, { month } = {}) {
|
||||||
|
const safeLimit = Math.max(1, Math.min(100, Number(limit) || 20))
|
||||||
|
const range = resolveMonthRange(month)
|
||||||
|
const where = []
|
||||||
|
const params = []
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
where.push('queued_at >= ? AND queued_at < ?')
|
||||||
|
params.push(range.start, range.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at
|
||||||
|
FROM image_optimization_jobs
|
||||||
|
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||||
|
ORDER BY queued_at DESC
|
||||||
|
LIMIT ${safeLimit}`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
return rows.map(mapImageOptimizationJobRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
|
||||||
|
const safeLimit = Math.max(1, Math.min(500, Number(limit) || 100))
|
||||||
|
const safeMinAgeHours = Math.max(0, Number(minAgeHours) || 24)
|
||||||
|
const cutoff = now() - safeMinAgeHours * 60 * 60 * 1000
|
||||||
|
|
||||||
|
const assets = (await query(
|
||||||
|
`SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE created_at <= ? ORDER BY created_at ASC LIMIT ${safeLimit}`,
|
||||||
|
[cutoff]
|
||||||
|
)).map(mapImageAssetRow)
|
||||||
|
|
||||||
|
if (!assets.length) return []
|
||||||
|
|
||||||
|
const referencedSrcs = new Set()
|
||||||
|
|
||||||
|
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
|
||||||
|
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
|
||||||
|
query("SELECT thumbnail_src FROM games WHERE thumbnail_src <> ''"),
|
||||||
|
query("SELECT src FROM game_items WHERE src <> ''"),
|
||||||
|
query("SELECT src FROM custom_items WHERE src <> ''"),
|
||||||
|
query("SELECT thumbnail_src, pool_json FROM tierlists"),
|
||||||
|
query("SELECT thumbnail_src_snapshot, items_json FROM template_requests"),
|
||||||
|
])
|
||||||
|
|
||||||
|
for (const row of userRows) if (row.avatar_src) referencedSrcs.add(row.avatar_src)
|
||||||
|
for (const row of gameRows) if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
|
||||||
|
for (const row of gameItemRows) if (row.src) referencedSrcs.add(row.src)
|
||||||
|
for (const row of customItemRows) if (row.src) referencedSrcs.add(row.src)
|
||||||
|
|
||||||
|
for (const row of tierListRows) {
|
||||||
|
if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
|
||||||
|
collectUploadSrcsFromItems(parseJson(row.pool_json, []), referencedSrcs)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of templateRequestRows) {
|
||||||
|
if (row.thumbnail_src_snapshot) referencedSrcs.add(row.thumbnail_src_snapshot)
|
||||||
|
collectUploadSrcsFromItems(parseJson(row.items_json, []), referencedSrcs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return assets.filter((asset) => !referencedSrcs.has(asset.src))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteImageAssets(ids) {
|
||||||
|
const uniqueIds = Array.from(new Set((ids || []).filter(Boolean)))
|
||||||
|
if (!uniqueIds.length) return []
|
||||||
|
const placeholders = uniqueIds.map(() => '?').join(', ')
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id IN (${placeholders})`,
|
||||||
|
uniqueIds
|
||||||
|
)
|
||||||
|
await query(`DELETE FROM image_assets WHERE id IN (${placeholders})`, uniqueIds)
|
||||||
|
return rows.map(mapImageAssetRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listReferencedUploadSources() {
|
||||||
|
const usage = await listReferencedUploadUsage()
|
||||||
|
return usage.map((entry) => entry.src)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listReferencedUploadUsage() {
|
||||||
|
const usageMap = new Map()
|
||||||
|
const addUsage = (src, role) => {
|
||||||
|
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return
|
||||||
|
if (!usageMap.has(src)) usageMap.set(src, new Set())
|
||||||
|
usageMap.get(src).add(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
|
||||||
|
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
|
||||||
|
query("SELECT thumbnail_src FROM games WHERE thumbnail_src <> ''"),
|
||||||
|
query("SELECT src FROM game_items WHERE src <> ''"),
|
||||||
|
query("SELECT src FROM custom_items WHERE src <> ''"),
|
||||||
|
query("SELECT id, thumbnail_src, pool_json FROM tierlists"),
|
||||||
|
query("SELECT id, thumbnail_src_snapshot, items_json FROM template_requests"),
|
||||||
|
])
|
||||||
|
|
||||||
|
for (const row of userRows) addUsage(row.avatar_src, 'avatar')
|
||||||
|
for (const row of gameRows) addUsage(row.thumbnail_src, 'game-thumbnail')
|
||||||
|
for (const row of gameItemRows) addUsage(row.src, 'game-item')
|
||||||
|
for (const row of customItemRows) addUsage(row.src, 'custom-item')
|
||||||
|
|
||||||
|
for (const row of tierListRows) {
|
||||||
|
addUsage(row.thumbnail_src, 'tierlist-thumbnail')
|
||||||
|
for (const item of parseJson(row.pool_json, [])) addUsage(item?.src, 'tierlist-pool')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of templateRequestRows) {
|
||||||
|
addUsage(row.thumbnail_src_snapshot, 'template-thumbnail')
|
||||||
|
for (const item of parseJson(row.items_json, [])) addUsage(item?.src, 'template-item')
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(usageMap.entries())
|
||||||
|
.map(([src, roles]) => ({ src, roles: Array.from(roles).sort() }))
|
||||||
|
.sort((a, b) => a.src.localeCompare(b.src))
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceItemSrc(items, fromSrc, toSrc) {
|
||||||
|
let changed = false
|
||||||
|
const nextItems = (items || []).map((item) => {
|
||||||
|
if (item?.src !== fromSrc) return item
|
||||||
|
changed = true
|
||||||
|
return { ...item, src: toSrc }
|
||||||
|
})
|
||||||
|
return { changed, items: nextItems }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
|
||||||
|
if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 }
|
||||||
|
|
||||||
|
const [userResult, gameResult, gameItemResult, customItemResult] = await Promise.all([
|
||||||
|
query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]),
|
||||||
|
query('UPDATE games SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]),
|
||||||
|
query('UPDATE game_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
|
||||||
|
query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
|
||||||
|
])
|
||||||
|
|
||||||
|
let updatedRows = Number(userResult.affectedRows || 0) + Number(gameResult.affectedRows || 0) + Number(gameItemResult.affectedRows || 0) + Number(customItemResult.affectedRows || 0)
|
||||||
|
|
||||||
|
const tierListRows = await query('SELECT id, thumbnail_src, pool_json FROM tierlists')
|
||||||
|
for (const row of tierListRows) {
|
||||||
|
let nextThumbnail = row.thumbnail_src
|
||||||
|
let changed = false
|
||||||
|
if (row.thumbnail_src === fromSrc) {
|
||||||
|
nextThumbnail = toSrc
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const replacedPool = replaceItemSrc(parseJson(row.pool_json, []), fromSrc, toSrc)
|
||||||
|
if (replacedPool.changed) changed = true
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await query('UPDATE tierlists SET thumbnail_src = ?, pool_json = ?, updated_at = ? WHERE id = ?', [
|
||||||
|
nextThumbnail || '',
|
||||||
|
serializeJson(replacedPool.items),
|
||||||
|
now(),
|
||||||
|
row.id,
|
||||||
|
])
|
||||||
|
updatedRows += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestRows = await query('SELECT id, thumbnail_src_snapshot, items_json FROM template_requests')
|
||||||
|
for (const row of requestRows) {
|
||||||
|
let nextThumbnail = row.thumbnail_src_snapshot
|
||||||
|
let changed = false
|
||||||
|
if (row.thumbnail_src_snapshot === fromSrc) {
|
||||||
|
nextThumbnail = toSrc
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const replacedItems = replaceItemSrc(parseJson(row.items_json, []), fromSrc, toSrc)
|
||||||
|
if (replacedItems.changed) changed = true
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await query('UPDATE template_requests SET thumbnail_src_snapshot = ?, items_json = ?, updated_at = ? WHERE id = ?', [
|
||||||
|
nextThumbnail || '',
|
||||||
|
serializeJson(replacedItems.items),
|
||||||
|
now(),
|
||||||
|
row.id,
|
||||||
|
])
|
||||||
|
updatedRows += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { updatedRows }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listImageAssets() {
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets ORDER BY created_at DESC'
|
||||||
|
)
|
||||||
|
return rows.map(mapImageAssetRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getReferencedUploadFootprint() {
|
||||||
|
const [referencedSrcs, assets] = await Promise.all([listReferencedUploadSources(), listImageAssets()])
|
||||||
|
const assetMap = new Map(assets.map((asset) => [asset.src, asset]))
|
||||||
|
let totalReferencedByteSize = 0
|
||||||
|
let trackedReferencedByteSize = 0
|
||||||
|
let legacyReferencedByteSize = 0
|
||||||
|
let trackedReferencedCount = 0
|
||||||
|
let legacyReferencedCount = 0
|
||||||
|
let missingCount = 0
|
||||||
|
|
||||||
|
for (const src of referencedSrcs) {
|
||||||
|
if (typeof src !== 'string' || !src.startsWith('/uploads/')) continue
|
||||||
|
const absolutePath = path.join(__dirname, '..', src.replace(/^\//, ''))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(absolutePath)
|
||||||
|
const size = Number(stat.size || 0)
|
||||||
|
totalReferencedByteSize += size
|
||||||
|
if (assetMap.has(src)) {
|
||||||
|
trackedReferencedCount += 1
|
||||||
|
trackedReferencedByteSize += size
|
||||||
|
} else {
|
||||||
|
legacyReferencedCount += 1
|
||||||
|
legacyReferencedByteSize += size
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') missingCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
referencedCount: referencedSrcs.length,
|
||||||
|
totalReferencedByteSize,
|
||||||
|
trackedReferencedCount,
|
||||||
|
trackedReferencedByteSize,
|
||||||
|
legacyReferencedCount,
|
||||||
|
legacyReferencedByteSize,
|
||||||
|
missingCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getImageAssetStats({ month } = {}) {
|
||||||
|
const range = resolveMonthRange(month)
|
||||||
|
const jobWhere = []
|
||||||
|
const jobParams = []
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
jobWhere.push('queued_at >= ? AND queued_at < ?')
|
||||||
|
jobParams.push(range.start, range.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [assetRows, jobRows, footprint] = await Promise.all([
|
||||||
|
query(
|
||||||
|
`SELECT COUNT(*) AS asset_count, COALESCE(SUM(byte_size), 0) AS total_byte_size, COALESCE(SUM(original_byte_size), 0) AS total_original_byte_size FROM image_assets`
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'queued' THEN 1 ELSE 0 END), 0) AS queued_count,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END), 0) AS processing_count,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0) AS completed_count,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS failed_count,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'completed' AND reused_asset = 1 THEN 1 ELSE 0 END), 0) AS reused_count
|
||||||
|
FROM image_optimization_jobs
|
||||||
|
${jobWhere.length ? `WHERE ${jobWhere.join(' AND ')}` : ''}`,
|
||||||
|
jobParams
|
||||||
|
),
|
||||||
|
getReferencedUploadFootprint(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const asset = assetRows[0] || {}
|
||||||
|
const jobs = jobRows[0] || {}
|
||||||
|
const totalByteSize = Number(asset.total_byte_size || 0)
|
||||||
|
const totalOriginalByteSize = Number(asset.total_original_byte_size || 0)
|
||||||
|
const savedByteSize = Math.max(0, totalOriginalByteSize - totalByteSize)
|
||||||
|
|
||||||
|
return {
|
||||||
|
assetCount: Number(asset.asset_count || 0),
|
||||||
|
totalByteSize,
|
||||||
|
totalOriginalByteSize,
|
||||||
|
savedByteSize,
|
||||||
|
savingsRatio: totalOriginalByteSize > 0 ? savedByteSize / totalOriginalByteSize : 0,
|
||||||
|
referencedCount: Number(footprint.referencedCount || 0),
|
||||||
|
referencedByteSize: Number(footprint.totalReferencedByteSize || 0),
|
||||||
|
trackedReferencedCount: Number(footprint.trackedReferencedCount || 0),
|
||||||
|
trackedReferencedByteSize: Number(footprint.trackedReferencedByteSize || 0),
|
||||||
|
legacyReferencedCount: Number(footprint.legacyReferencedCount || 0),
|
||||||
|
legacyReferencedByteSize: Number(footprint.legacyReferencedByteSize || 0),
|
||||||
|
missingReferencedCount: Number(footprint.missingCount || 0),
|
||||||
|
queuedCount: Number(jobs.queued_count || 0),
|
||||||
|
processingCount: Number(jobs.processing_count || 0),
|
||||||
|
completedCount: Number(jobs.completed_count || 0),
|
||||||
|
failedCount: Number(jobs.failed_count || 0),
|
||||||
|
reusedCount: Number(jobs.reused_count || 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearImageOptimizationJobs({ month } = {}) {
|
||||||
|
const range = resolveMonthRange(month)
|
||||||
|
if (range) {
|
||||||
|
const result = await query('DELETE FROM image_optimization_jobs WHERE queued_at >= ? AND queued_at < ?', [range.start, range.end])
|
||||||
|
return Number(result.affectedRows || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query('DELETE FROM image_optimization_jobs')
|
||||||
|
return Number(result.affectedRows || 0)
|
||||||
|
}
|
||||||
async function createGameItem({ id, gameId, src, label }) {
|
async function createGameItem({ id, gameId, src, label }) {
|
||||||
const createdAt = now()
|
const createdAt = now()
|
||||||
await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
|
await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
|
||||||
@@ -605,28 +1100,51 @@ async function findCustomItemById(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCustomItemUsageMap() {
|
async function getCustomItemUsageMeta() {
|
||||||
const rows = await query('SELECT groups_json, pool_json FROM tierlists')
|
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 usageMap = new Map()
|
||||||
|
const linkedGamesMap = new Map()
|
||||||
|
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
const groups = parseJson(row.groups_json, [])
|
const groups = parseJson(row.groups_json, [])
|
||||||
const pool = parseJson(row.pool_json, [])
|
const pool = parseJson(row.pool_json, [])
|
||||||
|
const seenItemIds = new Set()
|
||||||
|
|
||||||
groups.forEach((group) => {
|
groups.forEach((group) => {
|
||||||
;(group?.itemIds || []).forEach((itemId) => {
|
;(group?.itemIds || []).forEach((itemId) => {
|
||||||
usageMap.set(itemId, (usageMap.get(itemId) || 0) + 1)
|
usageMap.set(itemId, (usageMap.get(itemId) || 0) + 1)
|
||||||
|
if (itemId) seenItemIds.add(itemId)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
pool.forEach((item) => {
|
pool.forEach((item) => {
|
||||||
if (item?.id) {
|
if (item?.id) {
|
||||||
usageMap.set(item.id, (usageMap.get(item.id) || 0) + 1)
|
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 } = {}) {
|
async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnly = false } = {}) {
|
||||||
@@ -655,7 +1173,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
|
|||||||
params
|
params
|
||||||
)
|
)
|
||||||
|
|
||||||
const usageMap = await getCustomItemUsageMap()
|
const { usageMap, linkedGamesMap } = await getCustomItemUsageMeta()
|
||||||
const allItems = rows
|
const allItems = rows
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -666,6 +1184,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
|
|||||||
ownerName: row.nickname || row.email,
|
ownerName: row.nickname || row.email,
|
||||||
ownerEmail: row.email,
|
ownerEmail: row.email,
|
||||||
usageCount: usageMap.get(row.id) || 0,
|
usageCount: usageMap.get(row.id) || 0,
|
||||||
|
linkedGames: linkedGamesMap.get(row.id) || [],
|
||||||
}))
|
}))
|
||||||
.filter((item) => (orphanOnly ? item.usageCount === 0 : true))
|
.filter((item) => (orphanOnly ? item.usageCount === 0 : true))
|
||||||
|
|
||||||
@@ -705,7 +1224,7 @@ async function findUnusedCustomItems({ queryText = '' } = {}) {
|
|||||||
params
|
params
|
||||||
)
|
)
|
||||||
|
|
||||||
const usageMap = await getCustomItemUsageMap()
|
const { usageMap } = await getCustomItemUsageMeta()
|
||||||
return rows
|
return rows
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -850,6 +1369,9 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
|
|||||||
t.description,
|
t.description,
|
||||||
t.is_public,
|
t.is_public,
|
||||||
t.show_character_names,
|
t.show_character_names,
|
||||||
|
t.source_tierlist_id,
|
||||||
|
t.source_snapshot_title,
|
||||||
|
t.source_snapshot_author,
|
||||||
t.groups_json,
|
t.groups_json,
|
||||||
t.pool_json,
|
t.pool_json,
|
||||||
t.created_at,
|
t.created_at,
|
||||||
@@ -980,6 +1502,9 @@ async function listAdminTierLists({ queryText = '', page = 1, limit = 50, curren
|
|||||||
t.description,
|
t.description,
|
||||||
t.is_public,
|
t.is_public,
|
||||||
t.show_character_names,
|
t.show_character_names,
|
||||||
|
t.source_tierlist_id,
|
||||||
|
t.source_snapshot_title,
|
||||||
|
t.source_snapshot_author,
|
||||||
t.groups_json,
|
t.groups_json,
|
||||||
t.pool_json,
|
t.pool_json,
|
||||||
t.created_at,
|
t.created_at,
|
||||||
@@ -1036,6 +1561,9 @@ async function findTierListById(id, currentUserId = '') {
|
|||||||
t.description,
|
t.description,
|
||||||
t.is_public,
|
t.is_public,
|
||||||
t.show_character_names,
|
t.show_character_names,
|
||||||
|
t.source_tierlist_id,
|
||||||
|
t.source_snapshot_title,
|
||||||
|
t.source_snapshot_author,
|
||||||
t.groups_json,
|
t.groups_json,
|
||||||
t.pool_json,
|
t.pool_json,
|
||||||
t.created_at,
|
t.created_at,
|
||||||
@@ -1233,7 +1761,21 @@ async function deleteCustomItems(ids) {
|
|||||||
await query(`DELETE FROM custom_items WHERE id IN (${placeholders})`, ids)
|
await query(`DELETE FROM custom_items WHERE id IN (${placeholders})`, ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', description, isPublic, showCharacterNames = false, groups, pool }) {
|
async function saveTierList({
|
||||||
|
id,
|
||||||
|
authorId,
|
||||||
|
gameId,
|
||||||
|
title,
|
||||||
|
thumbnailSrc = '',
|
||||||
|
description,
|
||||||
|
isPublic,
|
||||||
|
showCharacterNames = false,
|
||||||
|
sourceTierListId = '',
|
||||||
|
sourceSnapshotTitle = '',
|
||||||
|
sourceSnapshotAuthor = '',
|
||||||
|
groups,
|
||||||
|
pool,
|
||||||
|
}) {
|
||||||
const existing = id ? await findTierListById(id, authorId) : null
|
const existing = id ? await findTierListById(id, authorId) : null
|
||||||
await syncOwnedCustomItemLabels({ ownerId: authorId, items: pool })
|
await syncOwnedCustomItemLabels({ ownerId: authorId, items: pool })
|
||||||
const nextThumbnailSrc = (thumbnailSrc || '').trim() || getAutoThumbnailSrc(groups, pool)
|
const nextThumbnailSrc = (thumbnailSrc || '').trim() || getAutoThumbnailSrc(groups, pool)
|
||||||
@@ -1242,10 +1784,10 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de
|
|||||||
await query(
|
await query(
|
||||||
`
|
`
|
||||||
UPDATE tierlists
|
UPDATE tierlists
|
||||||
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, show_character_names = ?, groups_json = ?, pool_json = ?, updated_at = ?
|
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, show_character_names = ?, source_tierlist_id = ?, source_snapshot_title = ?, source_snapshot_author = ?, groups_json = ?, pool_json = ?, updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`,
|
`,
|
||||||
[title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id]
|
[title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), now(), existing.id]
|
||||||
)
|
)
|
||||||
return findTierListById(existing.id, authorId)
|
return findTierListById(existing.id, authorId)
|
||||||
}
|
}
|
||||||
@@ -1254,15 +1796,37 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de
|
|||||||
await query(
|
await query(
|
||||||
`
|
`
|
||||||
INSERT INTO tierlists (
|
INSERT INTO tierlists (
|
||||||
id, author_id, game_id, title, thumbnail_src, description, is_public, show_character_names, groups_json, pool_json, created_at, updated_at
|
id, author_id, game_id, title, thumbnail_src, description, is_public, show_character_names, source_tierlist_id, source_snapshot_title, source_snapshot_author, groups_json, pool_json, created_at, updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
[id, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
|
[id, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt]
|
||||||
)
|
)
|
||||||
return findTierListById(id, authorId)
|
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 }) {
|
async function favoriteTierList({ userId, tierListId }) {
|
||||||
await query('INSERT IGNORE INTO favorite_tierlists (user_id, tierlist_id, created_at) VALUES (?, ?, ?)', [userId, tierListId, now()])
|
await query('INSERT IGNORE INTO favorite_tierlists (user_id, tierlist_id, created_at) VALUES (?, ?, ?)', [userId, tierListId, now()])
|
||||||
}
|
}
|
||||||
@@ -1271,9 +1835,18 @@ async function unfavoriteTierList({ userId, tierListId }) {
|
|||||||
await query('DELETE FROM favorite_tierlists WHERE user_id = ? AND tierlist_id = ?', [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 = {
|
module.exports = {
|
||||||
DB_NAME,
|
DB_NAME,
|
||||||
ensureData,
|
ensureData,
|
||||||
|
closePool,
|
||||||
countUsers,
|
countUsers,
|
||||||
findUserByEmail,
|
findUserByEmail,
|
||||||
findUserById,
|
findUserById,
|
||||||
@@ -1289,6 +1862,20 @@ module.exports = {
|
|||||||
getGameDetail,
|
getGameDetail,
|
||||||
createGame,
|
createGame,
|
||||||
updateGameThumbnail,
|
updateGameThumbnail,
|
||||||
|
findImageAssetByHash,
|
||||||
|
findImageAssetBySrc,
|
||||||
|
createImageAsset,
|
||||||
|
createImageOptimizationJob,
|
||||||
|
findImageOptimizationJobById,
|
||||||
|
updateImageOptimizationJobStatus,
|
||||||
|
listRecentImageOptimizationJobs,
|
||||||
|
listUnusedImageAssets,
|
||||||
|
deleteImageAssets,
|
||||||
|
listReferencedUploadSources,
|
||||||
|
listReferencedUploadUsage,
|
||||||
|
replaceUploadSourceReferences,
|
||||||
|
clearImageOptimizationJobs,
|
||||||
|
getImageAssetStats,
|
||||||
createGameItem,
|
createGameItem,
|
||||||
updateGameItemLabel,
|
updateGameItemLabel,
|
||||||
deleteGameItem,
|
deleteGameItem,
|
||||||
@@ -1305,10 +1892,13 @@ module.exports = {
|
|||||||
findTierListById,
|
findTierListById,
|
||||||
favoriteTierList,
|
favoriteTierList,
|
||||||
unfavoriteTierList,
|
unfavoriteTierList,
|
||||||
|
favoriteGame,
|
||||||
|
unfavoriteGame,
|
||||||
deleteTierList,
|
deleteTierList,
|
||||||
findCustomItemsByIds,
|
findCustomItemsByIds,
|
||||||
deleteCustomItems,
|
deleteCustomItems,
|
||||||
saveTierList,
|
saveTierList,
|
||||||
|
duplicateTierListForUser,
|
||||||
createTemplateRequest,
|
createTemplateRequest,
|
||||||
findTemplateRequestById,
|
findTemplateRequestById,
|
||||||
listAdminTemplateRequests,
|
listAdminTemplateRequests,
|
||||||
|
|||||||
218
backend/src/lib/image-storage.js
Normal file
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,
|
||||||
|
}
|
||||||
@@ -30,8 +30,14 @@ const {
|
|||||||
adminUpdateUser,
|
adminUpdateUser,
|
||||||
adminUpdateUserPassword,
|
adminUpdateUserPassword,
|
||||||
adminDeleteUser,
|
adminDeleteUser,
|
||||||
|
listUnusedImageAssets,
|
||||||
|
deleteImageAssets,
|
||||||
|
getImageAssetStats,
|
||||||
|
listRecentImageOptimizationJobs,
|
||||||
|
clearImageOptimizationJobs,
|
||||||
} = require('../db')
|
} = require('../db')
|
||||||
const { requireAdmin } = require('../middleware/auth')
|
const { requireAdmin } = require('../middleware/auth')
|
||||||
|
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
@@ -53,21 +59,8 @@ function buildItemLabelFromFilename(file) {
|
|||||||
return normalized || 'item'
|
return normalized || 'item'
|
||||||
}
|
}
|
||||||
|
|
||||||
const upload = multer({
|
const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 })
|
||||||
storage: multer.diskStorage({
|
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
|
||||||
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'games')),
|
|
||||||
filename: (req, file, cb) => cb(null, buildUploadFilename(file)),
|
|
||||||
}),
|
|
||||||
limits: { fileSize: 6 * 1024 * 1024 },
|
|
||||||
})
|
|
||||||
|
|
||||||
const avatarUpload = multer({
|
|
||||||
storage: multer.diskStorage({
|
|
||||||
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'avatars')),
|
|
||||||
filename: (req, file, cb) => cb(null, buildUploadFilename(file)),
|
|
||||||
}),
|
|
||||||
limits: { fileSize: 3 * 1024 * 1024 },
|
|
||||||
})
|
|
||||||
|
|
||||||
router.post('/games', requireAdmin, async (req, res) => {
|
router.post('/games', requireAdmin, async (req, res) => {
|
||||||
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
|
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
|
||||||
@@ -97,7 +90,17 @@ router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail')
|
|||||||
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
||||||
const game = await findGameById(req.params.gameId)
|
const game = await findGameById(req.params.gameId)
|
||||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
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 })
|
res.json({ game: updated })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -107,18 +110,29 @@ router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), a
|
|||||||
const game = await findGameById(req.params.gameId)
|
const game = await findGameById(req.params.gameId)
|
||||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|
||||||
const manualLabel = typeof req.body?.label === 'string' ? req.body.label.trim() : ''
|
const labelsRaw = req.body?.labels
|
||||||
if (manualLabel && manualLabel.length > 60) return res.status(400).json({ error: 'bad_request' })
|
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(
|
const items = await Promise.all(
|
||||||
files.map((file, index) =>
|
files.map(async (file, index) => {
|
||||||
createGameItem({
|
const optimized = await writeOptimizedImage({
|
||||||
|
file,
|
||||||
|
directory: 'games',
|
||||||
|
width: 512,
|
||||||
|
height: 512,
|
||||||
|
fit: 'inside',
|
||||||
|
quality: 84,
|
||||||
|
})
|
||||||
|
|
||||||
|
return createGameItem({
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
gameId: game.id,
|
gameId: game.id,
|
||||||
src: `/uploads/games/${file.filename}`,
|
src: optimized.src,
|
||||||
label: index === 0 && manualLabel ? manualLabel : buildItemLabelFromFilename(file),
|
label: normalizedLabels[index] || buildItemLabelFromFilename(file),
|
||||||
})
|
})
|
||||||
)
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
res.json({ item: items[0], items })
|
res.json({ item: items[0], items })
|
||||||
@@ -197,6 +211,78 @@ router.get('/template-requests', requireAdmin, async (req, res) => {
|
|||||||
res.json({ requests })
|
res.json({ requests })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/image-assets/orphans', requireAdmin, async (req, res) => {
|
||||||
|
const schema = z.object({
|
||||||
|
limit: z.coerce.number().int().min(1).max(500).optional().default(100),
|
||||||
|
minAgeHours: z.coerce.number().min(0).max(24 * 365).optional().default(24),
|
||||||
|
})
|
||||||
|
const parsed = schema.safeParse(req.query)
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
|
const assets = await listUnusedImageAssets(parsed.data)
|
||||||
|
res.json({ assets })
|
||||||
|
})
|
||||||
|
|
||||||
|
async function removeImageAssetFiles(assets) {
|
||||||
|
await Promise.all(
|
||||||
|
(assets || []).map(async (asset) => {
|
||||||
|
if (!asset?.src || !asset.src.startsWith('/uploads/')) return
|
||||||
|
const absolutePath = path.join(__dirname, '..', '..', asset.src.replace(/^\//, ''))
|
||||||
|
try {
|
||||||
|
await fs.unlink(absolutePath)
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/image-assets/cleanup', requireAdmin, async (req, res) => {
|
||||||
|
const schema = z.object({
|
||||||
|
limit: z.coerce.number().int().min(1).max(500).optional().default(100),
|
||||||
|
minAgeHours: z.coerce.number().min(0).max(24 * 365).optional().default(24),
|
||||||
|
})
|
||||||
|
const parsed = schema.safeParse(req.body)
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
|
const assets = await listUnusedImageAssets(parsed.data)
|
||||||
|
const deleted = await deleteImageAssets(assets.map((asset) => asset.id))
|
||||||
|
await removeImageAssetFiles(deleted)
|
||||||
|
res.json({ deletedCount: deleted.length, assets: deleted })
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/image-assets/stats', requireAdmin, async (req, res) => {
|
||||||
|
const schema = z.object({
|
||||||
|
month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(24).optional().default(12),
|
||||||
|
})
|
||||||
|
const parsed = schema.safeParse(req.query)
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
|
const filters = { month: parsed.data.month }
|
||||||
|
const [stats, recentJobs] = await Promise.all([
|
||||||
|
getImageAssetStats(filters),
|
||||||
|
listRecentImageOptimizationJobs(parsed.data.limit, filters),
|
||||||
|
])
|
||||||
|
res.json({
|
||||||
|
stats,
|
||||||
|
filters,
|
||||||
|
queue: getImageOptimizationQueueState(),
|
||||||
|
recentJobs,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/image-assets/stats/reset', requireAdmin, async (req, res) => {
|
||||||
|
const schema = z.object({
|
||||||
|
month: z.string().regex(/^\d{4}-\d{2}$/).optional().nullable(),
|
||||||
|
})
|
||||||
|
const parsed = schema.safeParse(req.body || {})
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
|
const deletedCount = await clearImageOptimizationJobs({ month: parsed.data.month || undefined })
|
||||||
|
res.json({ deletedCount })
|
||||||
|
})
|
||||||
|
|
||||||
async function removeCustomItemFiles(items) {
|
async function removeCustomItemFiles(items) {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
items.map(async (item) => {
|
items.map(async (item) => {
|
||||||
@@ -212,33 +298,18 @@ async function removeCustomItemFiles(items) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function promoteCustomItemToGameItem({ customItem, gameId }) {
|
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({
|
return createGameItem({
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
gameId,
|
gameId,
|
||||||
src: `/${targetRelativePath.replace(/\\/g, '/')}`,
|
src: customItem.src || '',
|
||||||
label: customItem.label,
|
label: customItem.label,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyUploadIntoGameAsset(src) {
|
async function copyUploadIntoGameAsset(src) {
|
||||||
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return src || ''
|
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return src || ''
|
||||||
|
if (src.startsWith('/uploads/assets/')) return src
|
||||||
const originalName = path.basename(src)
|
return 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, '/')}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueTierListPoolItems(tierList) {
|
function uniqueTierListPoolItems(tierList) {
|
||||||
@@ -512,8 +583,18 @@ router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar')
|
|||||||
const user = await findUserById(req.params.userId)
|
const user = await findUserById(req.params.userId)
|
||||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
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 shouldRemoveAvatar = parsed.data.removeAvatar === '1'
|
||||||
const nextAvatarSrc = shouldRemoveAvatar ? '' : req.file ? `/uploads/avatars/${req.file.filename}` : user.avatarSrc || ''
|
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || ''
|
||||||
const updated = await adminUpdateUser({
|
const updated = await adminUpdateUser({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
const express = require('express')
|
const express = require('express')
|
||||||
const path = require('path')
|
|
||||||
const bcrypt = require('bcryptjs')
|
const bcrypt = require('bcryptjs')
|
||||||
const { z } = require('zod')
|
const { z } = require('zod')
|
||||||
const { nanoid } = require('nanoid')
|
const { nanoid } = require('nanoid')
|
||||||
@@ -12,15 +11,10 @@ const {
|
|||||||
updateUserProfile,
|
updateUserProfile,
|
||||||
} = require('../db')
|
} = require('../db')
|
||||||
const { requireAuth } = require('../middleware/auth')
|
const { requireAuth } = require('../middleware/auth')
|
||||||
|
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
|
||||||
|
|
||||||
const router = express.Router()
|
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({
|
const signupSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(6),
|
password: z.string().min(6),
|
||||||
@@ -45,7 +39,6 @@ router.post('/signup', async (req, res) => {
|
|||||||
|
|
||||||
req.session.userId = user.id
|
req.session.userId = user.id
|
||||||
req.session.isAdmin = !!user.isAdmin
|
req.session.isAdmin = !!user.isAdmin
|
||||||
// 세션을 응답 전에 명시적으로 저장해 Set-Cookie가 확실히 내려오도록 보강
|
|
||||||
req.session.save((err) => {
|
req.session.save((err) => {
|
||||||
if (err) return res.status(500).json({ error: 'session_save_failed' })
|
if (err) return res.status(500).json({ error: 'session_save_failed' })
|
||||||
res.json(user)
|
res.json(user)
|
||||||
@@ -94,13 +87,7 @@ router.get('/meta', async (req, res) => {
|
|||||||
res.json({ hasUsers: (await countUsers()) > 0 })
|
res.json({ hasUsers: (await countUsers()) > 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
const upload = multer({
|
const upload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
|
||||||
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 },
|
|
||||||
})
|
|
||||||
|
|
||||||
router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) => {
|
router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) => {
|
||||||
const parsed = profileSchema.safeParse(req.body)
|
const parsed = profileSchema.safeParse(req.body)
|
||||||
@@ -109,12 +96,19 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
|
|||||||
const user = await findUserById(req.session.userId)
|
const user = await findUserById(req.session.userId)
|
||||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
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 shouldRemoveAvatar = parsed.data.removeAvatar === '1'
|
||||||
const nextAvatarSrc = shouldRemoveAvatar
|
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || ''
|
||||||
? ''
|
|
||||||
: req.file
|
|
||||||
? `/uploads/avatars/${req.file.filename}`
|
|
||||||
: user.avatarSrc || ''
|
|
||||||
const updated = await updateUserProfile({
|
const updated = await updateUserProfile({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
nickname: parsed.data.nickname,
|
nickname: parsed.data.nickname,
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
const express = require('express')
|
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()
|
const router = express.Router()
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const games = await listGames()
|
const games = await listGames(req.session?.userId || '')
|
||||||
res.json({ games })
|
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) => {
|
router.get('/:gameId', async (req, res) => {
|
||||||
const detail = await getGameDetail(req.params.gameId)
|
const detail = await getGameDetail(req.params.gameId)
|
||||||
if (!detail) return res.status(404).json({ error: 'not_found' })
|
if (!detail) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
const express = require('express')
|
const express = require('express')
|
||||||
const path = require('path')
|
|
||||||
const multer = require('multer')
|
const multer = require('multer')
|
||||||
const { z } = require('zod')
|
const { z } = require('zod')
|
||||||
const { nanoid } = require('nanoid')
|
const { nanoid } = require('nanoid')
|
||||||
@@ -15,8 +14,10 @@ const {
|
|||||||
findUserById,
|
findUserById,
|
||||||
favoriteTierList,
|
favoriteTierList,
|
||||||
unfavoriteTierList,
|
unfavoriteTierList,
|
||||||
|
duplicateTierListForUser,
|
||||||
} = require('../db')
|
} = require('../db')
|
||||||
const { requireAuth } = require('../middleware/auth')
|
const { requireAuth } = require('../middleware/auth')
|
||||||
|
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
const FREEFORM_GAME_ID = 'freeform'
|
const FREEFORM_GAME_ID = 'freeform'
|
||||||
@@ -54,27 +55,8 @@ function getCustomTemplateItems(tierList) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUploadFilename(file) {
|
const upload = createMemoryUpload(multer, { fileSize: 6 * 1024 * 1024 })
|
||||||
const ext = path.extname(file.originalname || '').toLowerCase()
|
const thumbnailUpload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024 })
|
||||||
const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : ''
|
|
||||||
return `${Date.now()}-${nanoid()}${safeExt}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const upload = multer({
|
|
||||||
storage: multer.diskStorage({
|
|
||||||
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'custom')),
|
|
||||||
filename: (req, file, cb) => cb(null, buildUploadFilename(file)),
|
|
||||||
}),
|
|
||||||
limits: { fileSize: 6 * 1024 * 1024 },
|
|
||||||
})
|
|
||||||
|
|
||||||
const thumbnailUpload = multer({
|
|
||||||
storage: multer.diskStorage({
|
|
||||||
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'tierlists')),
|
|
||||||
filename: (req, file, cb) => cb(null, buildUploadFilename(file)),
|
|
||||||
}),
|
|
||||||
limits: { fileSize: 6 * 1024 * 1024 },
|
|
||||||
})
|
|
||||||
|
|
||||||
const tierListUpsertSchema = z.object({
|
const tierListUpsertSchema = z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
@@ -84,6 +66,9 @@ const tierListUpsertSchema = z.object({
|
|||||||
description: z.string().max(1000).optional().default(''),
|
description: z.string().max(1000).optional().default(''),
|
||||||
isPublic: z.boolean().default(false),
|
isPublic: z.boolean().default(false),
|
||||||
showCharacterNames: z.boolean().optional().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(
|
groups: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
@@ -131,6 +116,15 @@ router.get('/:id', async (req, res) => {
|
|||||||
res.json({ tierList: normalizeTierList(t) })
|
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) => {
|
router.delete('/:id', requireAuth, async (req, res) => {
|
||||||
const tierList = await findTierListById(req.params.id, req.session.userId)
|
const tierList = await findTierListById(req.params.id, req.session.userId)
|
||||||
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
||||||
@@ -166,10 +160,19 @@ router.post('/custom-items', requireAuth, upload.single('image'), async (req, re
|
|||||||
const parsed = schema.safeParse(req.body)
|
const parsed = schema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
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({
|
const item = await createCustomItem({
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
ownerId: req.session.userId,
|
ownerId: req.session.userId,
|
||||||
src: `/uploads/custom/${req.file.filename}`,
|
src: optimized.src,
|
||||||
label: parsed.data.label,
|
label: parsed.data.label,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -178,12 +181,24 @@ router.post('/custom-items', requireAuth, upload.single('image'), async (req, re
|
|||||||
|
|
||||||
router.post('/thumbnail', requireAuth, thumbnailUpload.single('thumbnail'), async (req, res) => {
|
router.post('/thumbnail', requireAuth, thumbnailUpload.single('thumbnail'), async (req, res) => {
|
||||||
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
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) => {
|
router.post('/:id/template-request', requireAuth, async (req, res) => {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
type: z.enum(['create', 'update']),
|
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)
|
const parsed = schema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
@@ -197,9 +212,6 @@ router.post('/:id/template-request', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
if (parsed.data.type === 'create') {
|
if (parsed.data.type === 'create') {
|
||||||
if (tierList.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
|
if (tierList.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
|
||||||
if (!(tierList.title || '').trim() || (tierList.title || '').trim() === FREEFORM_DEFAULT_TITLE) {
|
|
||||||
return res.status(400).json({ error: 'title_required' })
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (tierList.gameId === FREEFORM_GAME_ID) return res.status(400).json({ error: 'game_template_required' })
|
if (tierList.gameId === FREEFORM_GAME_ID) return res.status(400).json({ error: 'game_template_required' })
|
||||||
}
|
}
|
||||||
@@ -212,8 +224,8 @@ router.post('/:id/template-request', requireAuth, async (req, res) => {
|
|||||||
sourceTierListId: tierList.id,
|
sourceTierListId: tierList.id,
|
||||||
sourceGameId: tierList.gameId,
|
sourceGameId: tierList.gameId,
|
||||||
targetGameId: parsed.data.type === 'update' ? tierList.gameId : '',
|
targetGameId: parsed.data.type === 'update' ? tierList.gameId : '',
|
||||||
title: tierList.title,
|
title: parsed.data.requestTitle,
|
||||||
description: tierList.description || '',
|
description: parsed.data.requestDescription,
|
||||||
thumbnailSrc: tierList.thumbnailSrc || '',
|
thumbnailSrc: tierList.thumbnailSrc || '',
|
||||||
items: customItems,
|
items: customItems,
|
||||||
})
|
})
|
||||||
@@ -246,6 +258,9 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
description: payload.description || '',
|
description: payload.description || '',
|
||||||
isPublic: !!payload.isPublic,
|
isPublic: !!payload.isPublic,
|
||||||
showCharacterNames: !!payload.showCharacterNames,
|
showCharacterNames: !!payload.showCharacterNames,
|
||||||
|
sourceTierListId: payload.sourceTierListId || existing.sourceTierListId || '',
|
||||||
|
sourceSnapshotTitle: payload.sourceSnapshotTitle || existing.sourceSnapshotTitle || '',
|
||||||
|
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || existing.sourceSnapshotAuthor || '',
|
||||||
groups: payload.groups,
|
groups: payload.groups,
|
||||||
pool: normalizedPool,
|
pool: normalizedPool,
|
||||||
})
|
})
|
||||||
@@ -261,6 +276,9 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
description: payload.description || '',
|
description: payload.description || '',
|
||||||
isPublic: !!payload.isPublic,
|
isPublic: !!payload.isPublic,
|
||||||
showCharacterNames: !!payload.showCharacterNames,
|
showCharacterNames: !!payload.showCharacterNames,
|
||||||
|
sourceTierListId: payload.sourceTierListId || '',
|
||||||
|
sourceSnapshotTitle: payload.sourceSnapshotTitle || '',
|
||||||
|
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || '',
|
||||||
groups: payload.groups,
|
groups: payload.groups,
|
||||||
pool: normalizedPool,
|
pool: normalizedPool,
|
||||||
})
|
})
|
||||||
|
|||||||
35
docs/todo.md
35
docs/todo.md
@@ -1,40 +1,11 @@
|
|||||||
# 할 일 및 이슈
|
# 할 일 및 이슈
|
||||||
|
|
||||||
## 즉시 확인 필요
|
## 즉시 확인 필요
|
||||||
- 피그마 기반 리디자인은 현재 공통 셸과 카드 목록 화면, 포커스 화면 안정화까지만 반영된 상태이므로, 에디터/관리자 우측 옵션 패널을 시안 구조에 맞게 실제 기능 패널로 이관하는 작업이 남아 있다.
|
- 레거시 참조를 `/uploads/assets/`로 재정렬하는 마이그레이션 스크립트는 준비됐으므로, 운영 반영 후에는 더 이상 참조되지 않는 예전 업로드 파일을 안전하게 정리하는 후속 배치를 검토한다.
|
||||||
- 홈/게임 허브/내 티어표/즐겨찾기 카드 문법은 어느 정도 통일됐지만, 아직 실제 SVG 아이콘, 미세 간격, hover/selection 상태 같은 디테일은 더 다듬을 필요가 있다.
|
|
||||||
- 목록 화면 상단 도구 막대는 공통 카드 문법으로 거의 맞췄지만, 실제 피그마처럼 필터 토글/정렬 상태를 시각적으로 더 강하게 드러내는 디테일은 남아 있다.
|
|
||||||
- 현재 공통 셸에는 임시 선형 SVG 아이콘을 사용하므로, 최종 머티리얼 아이콘 에셋을 받으면 교체하고 아이콘 크기/정렬을 다시 미세 조정할 필요가 있다.
|
|
||||||
- 공통 셸과 에디터에는 일부 실제 SVG 아이콘을 연결했지만, 아직 즐겨찾기/설정/관리자 등 나머지 내비 아이콘은 임시 선형 SVG이므로 추가 에셋 교체가 남아 있다.
|
|
||||||
- 공통 우측 패널의 토글과 독립 컬럼 구조는 반영되었지만, 현재는 안내 카드 중심이므로 실제 화면별 기능 컨트롤을 이 패널로 옮기는 작업이 남아 있다.
|
|
||||||
- 티어표 편집 화면과 관리자 화면 모두 로컬 우측 패널 구조로 옮겼지만, 아직 세부 카드 밀도와 아이콘/모션 디테일은 피그마 시안 수준으로 더 다듬을 필요가 있다.
|
|
||||||
- 에디터/관리자 로컬 우측 패널은 셸 카드에서 분리됐지만, 아직 실제 피그마처럼 패널 토글 전환 모션과 상태 강조가 더 필요하다.
|
|
||||||
- 에디터 로컬 우측 패널은 공통 토글과 연결됐지만, 아직 완전한 피그마 수준의 패널 애니메이션과 내부 카드 재배치는 더 다듬을 필요가 있다.
|
|
||||||
- 에디터 우측 패널은 셸의 세 번째 컬럼으로 옮겼지만, 내부 카드 간격과 섹션 구분선은 아직 첨부 시안처럼 더 촘촘하게 정리할 필요가 있다.
|
|
||||||
- 에디터 우측 패널 외곽 래퍼는 제거했으므로, 다음 단계는 공통 오른쪽 컬럼 안에서 입력/버튼/구분선 간격을 시안처럼 더 정교하게 다듬는 작업이다.
|
|
||||||
- 공통 56px 셸 헤더는 반영했으므로, 다음 단계는 좌/중앙/우 헤더 안에 실제 아이콘/상태 요소를 시안 순서에 맞게 하나씩 채워 넣는 작업이다.
|
|
||||||
- 좌측 레일은 최근 즐겨찾기와 전역 검색까지 붙었으므로, 다음 단계는 검색 자동완성이나 즐겨찾기 썸네일 품질 같은 디테일을 더 다듬는 작업이다.
|
|
||||||
- 좌측 레일 축소형은 반영했으므로, 다음 단계는 축소 상태에서 관리자/로그인 진입점과 hover 툴팁 같은 보조 UX를 더 다듬는 작업이다.
|
|
||||||
- 좌우 하단 액션 영역은 분리했으므로, 다음 단계는 축소된 왼쪽 레일에서도 관리자/로그인 버튼을 아이콘형으로 어떻게 유지할지 검토할 수 있다.
|
|
||||||
- 홈 게임 카드 메타는 간소화했으므로, 이후 필요하면 게임 썸네일은 상세 허브나 우측 패널처럼 더 맥락이 분명한 위치에만 쓰는 방향을 검토할 수 있다.
|
|
||||||
- 좌우 하단 액션은 항상 보이도록 보정했으므로, 다음 단계는 축소된 레일 상태에서 액션 버튼의 아이콘화 여부를 추가 검토할 수 있다.
|
|
||||||
- 카드 목록은 4열 기준과 메타 줄 구성까지 통일했으므로, 다음 단계는 필터 상태 배지나 hover·selection 강조 같은 상호작용 디테일을 더 다듬는 작업이다.
|
|
||||||
- 검색 결과 화면은 좌측 전역 검색 입력만 쓰도록 정리됐으므로, 다음 단계는 결과 필터/정렬 여부를 검토하는 식으로 확장하면 된다.
|
|
||||||
- 공통 3단 셸 구조는 고정했지만, 관리자/에디터 우측 패널 내부에 아직 바디에 남아 있는 제어 요소를 더 옮겨야 한다.
|
|
||||||
- 홈 화면 우측 사이드는 CTA 하나만 남긴 상태이므로, 이후 필요할 때도 임시 정보 카드 다수를 다시 넣기보다 실제 필요한 기능만 선별해 추가해야 한다.
|
|
||||||
- 관리자 화면은 헤더 요약 통계와 카드 계층까지 정리됐지만, 아직 표준 SVG 아이콘 교체와 더 세밀한 상태 색상/선택 상태 표현은 남아 있다.
|
|
||||||
- 머티리얼 아이콘 SVG는 아직 임시 문자/배지 스타일로 대체된 부분이 있으므로, 최종 아이콘 에셋을 받아 반영하는 작업이 필요하다.
|
|
||||||
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
|
|
||||||
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
|
|
||||||
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
||||||
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
|
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
|
||||||
- 템플릿 등록/업데이트 요청은 현재 승인/반려만 지원하므로, 필요하면 관리자 코멘트나 사용자용 요청 상태 이력 화면을 추가 검토한다.
|
|
||||||
- 관리자 티어표 관리의 템플릿 생성은 현재 `freeform`만 직접 지원하므로, 필요하면 일반 게임 티어표의 전체 아이템을 복제한 파생 템플릿 생성 UX도 검토한다.
|
|
||||||
- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다.
|
- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다.
|
||||||
- 즐겨찾기는 현재 `내 즐겨찾기` 목록과 정렬까지 지원하므로, 필요하면 폴더 분류나 메모 같은 개인 정리 기능을 추가 검토한다.
|
- 이미지 최적화 기록은 월별 조회/비우기까지 지원하므로, 운영 단계에서는 보관 기간 정책과 자동 아카이브 기준을 정한다.
|
||||||
- 전역 토스트는 중복 합치기와 페이드아웃까지 지원하므로, 필요하면 액션 링크나 수동 고정(pin) 같은 상호작용 확장을 검토한다.
|
|
||||||
- 공개 티어표 검색은 현재 게임별 허브 안에서만 제공하므로, 필요하면 홈 전역 통합 검색도 검토한다.
|
|
||||||
- 즐겨찾기 토글은 현재 상세 화면 중심이므로, 필요하면 카드 목록에서도 안전한 보조 인터랙션(예: 길게 누르기, 별도 메뉴)을 검토한다.
|
|
||||||
|
|
||||||
## 배포 전 작업
|
## 배포 전 작업
|
||||||
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
|
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
|
||||||
@@ -44,8 +15,6 @@
|
|||||||
- 로컬 docker compose와 NAS MariaDB 사이의 버전 차이가 크지 않도록 유지한다.
|
- 로컬 docker compose와 NAS MariaDB 사이의 버전 차이가 크지 않도록 유지한다.
|
||||||
|
|
||||||
## 중기 개선
|
## 중기 개선
|
||||||
- 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다.
|
|
||||||
- 자동 테스트와 최소한의 배포 체크리스트를 만든다.
|
|
||||||
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
|
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
|
||||||
- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다.
|
- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다.
|
||||||
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
||||||
|
|||||||
399
docs/update.md
399
docs/update.md
@@ -1,5 +1,175 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-03-31 v1.3.7
|
||||||
|
- 현재 참조 중인 레거시 업로드를 다시 최적화 자산 경로로 편입하고 DB 참조를 일괄 교체하는 1회 마이그레이션 스크립트를 추가함.
|
||||||
|
- 아바타/썸네일/아이템 역할에 따라 기존 업로드를 512px 또는 1280px 규격으로 다시 정리해, 실제 참조 경로도 '/uploads/assets/' 체계에 점진적으로 수렴시킬 수 있게 함.
|
||||||
|
- 루트와 백엔드에 레거시 마이그레이션 실행 스크립트를 연결하고, todo 문서도 다음 단계 기준으로 갱신함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.3.6
|
||||||
|
- 현재 참조 중인 레거시 업로드 파일을 'image_assets' 메타에 안전하게 편입하는 1회 백필 스크립트를 추가해, 과거 이미지도 최적화 대시보드와 같은 통계 체계 안에서 집계할 수 있게 함.
|
||||||
|
- 루트와 백엔드에 백필 실행 스크립트를 연결해 운영 중 필요할 때 즉시 재실행할 수 있도록 정리함.
|
||||||
|
- todo 문서의 즉시 확인 항목도 백필 완료 상태에 맞춰 후속 마이그레이션 과제로 갱신함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.3.5
|
||||||
|
- 관리자 이미지 최적화 대시보드는 이제 'image_assets'만이 아니라 현재 실제로 참조 중인 업로드 파일 전체를 합산해, 기존 레거시 업로드까지 포함한 실사용 용량을 함께 보여주도록 확장함.
|
||||||
|
- 최근 최적화 작업은 기본 12건으로 늘리고 6/12/24건 선택과 월 단위 필터를 지원해, 특정 기간 사용량과 최적화 이력을 운영 관점에서 바로 확인할 수 있게 정리함.
|
||||||
|
- 관리자에서 월별 또는 전체 최적화 기록을 비우는 정리 액션을 추가하고, todo 문서도 현재 이미지 최적화 흐름에 맞게 갱신함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.3.4
|
||||||
|
- 관리자 API에 이미지 자산 통계 엔드포인트를 추가해 총 자산 수, 현재 용량, 원본 대비 절감 용량/절감률, 작업 누적 상태를 조회할 수 있게 확장함.
|
||||||
|
- 관리자 오른쪽 사이드 하단에 `Image Optimization` 패널을 추가해 큐 상태, 절감 통계, 최근 최적화 작업을 바로 확인할 수 있도록 대시보드를 구성함.
|
||||||
|
- 미사용 자산 정리 API와 작업 기록 큐를 기반으로, 운영 중 이미지 스토리지 상태를 관리자 화면에서 직접 점검할 수 있는 흐름을 완성함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.3.3
|
||||||
|
- `image_assets` 참조를 전수 점검해 아무 곳에서도 사용하지 않는 최적화 이미지 자산만 추려내는 정리 배치 로직을 추가함.
|
||||||
|
- 관리자용 미사용 자산 조회/정리 API를 추가해 오래된 고아 이미지 자산을 미리 확인하거나 실제로 삭제할 수 있도록 확장함.
|
||||||
|
- 관리자 승격/템플릿 생성 과정은 기존 `/uploads/assets/` 자산을 그대로 재사용하도록 바꿔, 불필요한 복제 파일이 다시 생기지 않게 정리함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.3.2
|
||||||
|
- 업로드 최적화는 이제 백엔드 내부 대기열을 통해 처리되어, 다수 이미지가 한 번에 들어와도 설정된 동시성 안에서 순차적으로 안정적으로 변환되도록 정리함.
|
||||||
|
- `image_optimization_jobs` 작업 기록 테이블을 추가해 queued/processing/completed/failed 상태와 원본·최적화 용량, 재사용 여부, 시작/종료 시각을 저장하도록 확장함.
|
||||||
|
- 현재 라우트 응답 방식은 유지하면서도 내부적으로는 큐를 타도록 구조를 바꿔, 이후 관리자 대시보드와 작업 통계 화면을 바로 얹을 수 있는 기반을 마련함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.3.1
|
||||||
|
- 최적화된 WebP 결과물 기준으로 SHA-256 해시를 계산해, 같은 이미지가 다시 업로드되면 새 파일을 저장하지 않고 기존 자산을 재사용하도록 중복 이미지 해시 검사를 추가함.
|
||||||
|
- 이미지 자산 메타데이터를 저장하는 `image_assets` 테이블을 도입해 파일 경로, 해시, 원본 대비 최적화 용량, 해상도를 함께 기록하도록 확장함.
|
||||||
|
- 중복 업로드 경쟁 상황에서도 고유 해시 충돌을 안전하게 처리하고, 새 파일 저장에 실패하면 즉시 정리하도록 업로드 헬퍼를 보강함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.3.0
|
||||||
|
- 백엔드 업로드 파이프라인을 메모리 기반으로 전환하고, 대표 썸네일·게임 썸네일·커스텀 아이템·게임 기본 아이템·아바타를 서버에서 즉시 WebP로 변환해 저장하도록 정리함.
|
||||||
|
- 아이템 이미지는 최대 512px 규격으로 리사이즈하고, 티어표/게임 썸네일은 긴 변 기준 1280px 안쪽으로 최적화해 원본 이미지를 별도로 보관하지 않는 흐름으로 전환함.
|
||||||
|
- 업로드 최적화 공통 헬퍼를 추가해 앞으로 중복 해시 검사, 비동기 최적화 큐, 용량 통계 대시보드를 같은 경로 위에 확장할 수 있는 기반을 마련함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.73
|
||||||
|
- 게임 허브 리스트형 보기의 썸네일을 48px 밀도로 축소해 한 줄이 과하게 커 보이던 인상을 줄이고, 더 많은 티어표를 한눈에 볼 수 있게 조정함.
|
||||||
|
- 깨진 대표 썸네일은 `img` alt 텍스트가 길게 노출되지 않도록 에러 시 즉시 플레이스홀더로 대체하고, 제목/메타 말줄임을 더 보강해 레이아웃 붕괴를 막음.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.72
|
||||||
|
- 게임 허브 공개 티어표 목록은 카드 폭과 제목/메타 줄 계산을 다시 조정해, 브라우저 폭에 따라 썸네일과 정보가 카드 밖으로 넘치던 레이아웃 깨짐을 보정함.
|
||||||
|
- 상단 워크스페이스 헤더에 grid/list 보기 토글을 추가하고, 게임 허브는 그리드 카드형과 가로 리스트형을 즉시 전환해 볼 수 있도록 확장함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.71
|
||||||
|
- 게임 허브 공개 티어표 카드는 자동 폭 그리드와 2줄 제목/유연한 메타 배치로 보정해, 브라우저 폭이 줄어들어도 썸네일과 텍스트가 카드 밖으로 넘치지 않도록 정리함.
|
||||||
|
- 공개 티어표 상세에서는 다른 사용자의 티어표를 복사해 내 작업본으로 가져오는 기능을 추가하고, 복사본에는 원본 제목/작성자 정보를 작은 출처 메모로 남기도록 확장함.
|
||||||
|
- 보기 전용 티어표의 미배치 아이템은 더 어둡고 흐리게 표시하고 `미배치` 상태를 붙여, 내 보드처럼 조작 가능한 인상을 줄이도록 보정함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.70
|
||||||
|
- 관리자 게임 관리의 썸네일 드롭존을 카드 안 카드 구조 대신, 썸네일 전체 위에 하단 오버레이 문구를 얹는 단일 미디어 영역으로 정리함.
|
||||||
|
- 게임 관리 본문 상단 안내 패널과 과한 설명 문구를 제거하고, 비선택 상태는 `게임을 선택해 주세요.` 한 줄 중심의 empty 상태로 단순화함.
|
||||||
|
- 새 게임 생성 버튼은 게임 선택과 함께 오른쪽 사이드로 옮겨, 게임 관리 흐름을 선택·생성·썸네일 지정까지 한쪽 패널에서 처리하도록 정리함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.69
|
||||||
|
- 좌우 사이드 축소/확대 시 텍스트를 즉시 `display:none` 처리하던 방식을 줄이고, 폭·투명도 기반 전환으로 바꿔 아이콘이 떨리는 듯한 느낌을 완화함.
|
||||||
|
- 관리자 게임 관리는 오른쪽 사이드에서 게임 선택과 썸네일 지정을 담당하도록 재배치하고, 본문은 기본 아이템 추가/이름 입력/목록 관리에 집중하도록 정리함.
|
||||||
|
- 게임 기본 아이템 추가는 업로드 직후 각 파일 이름을 바로 수정할 수 있는 draft 입력 행을 넣고, 선택한 이름이 서버에 함께 저장되도록 관리자 업로드 API를 확장함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.68
|
||||||
|
- 내 티어표 카드 그리드는 각 카드가 화면 전체 너비를 과도하게 먹지 않도록 최대 폭을 제한해, 1~2개만 있을 때도 적당한 카드 크기를 유지하도록 조정함.
|
||||||
|
- 새 티어표 기본 그룹은 기존 S/A/B/C/D 5줄 대신 S/A/B/C 4줄로 시작하게 바꾸고, 좌우 사이드 토글 아이콘 버튼은 외곽선과 배경을 제거해 더 가볍게 정리함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.67
|
||||||
|
- 홈 화면 게임 템플릿 즐겨찾기 버튼 위치 변경은 유지하면서, 즐겨찾기 on/off 시 카드가 즉시 튀지 않고 부드럽게 재정렬되도록 이동/페이드 전환을 추가함.
|
||||||
|
- 별 아이콘을 눌렀을 때 카드가 즐겨찾기 우선순위 위치로 자연스럽게 이동해 전체 라이브러리 전환감이 덜 거칠게 보이도록 보정함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.66
|
||||||
|
- 내 티어표 카드 하단의 큰 삭제 버튼은 제거하고, 삭제는 상세 편집 화면에서만 하도록 흐름을 단순화함.
|
||||||
|
- 내 티어표 카드 그리드를 고정 4/3/2열에서 `auto-fit` 기반 최소 폭 카드로 바꾸고, 제목/메타가 좁은 화면에서도 말줄임과 유연한 폭 계산을 유지하도록 보정함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.65
|
||||||
|
- 에디터 옵션 토글의 라벨과 스위치 순서를 바꾼 뒤 체크 상태 셀렉터가 끊긴 문제를 보정해, 왼쪽 라벨·오른쪽 스위치 배치에서도 정상 동작하도록 수정함.
|
||||||
|
- 왼쪽 사이드 축소 상태 검색은 전용 모달의 기본 스타일이 빠져 있던 문제를 복구해, 다시 중앙 상단 검색 오버레이로 열리도록 정리함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.64
|
||||||
|
- 메인 콘텐츠가 길어질 때 스크롤 끝이 화면 바닥에 붙지 않도록 중앙 워크스페이스 하단 여백을 추가하고, 긴 작업 화면에서도 마감선이 답답하지 않게 보정함.
|
||||||
|
- 템플릿 요청 모달 입력창을 Settings 화면과 같은 어두운 언더라인 입력 문법으로 통일하고, 에디터의 공개/이름 표시 옵션은 체크박스 대신 스위치형 토글로 재구성함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.63
|
||||||
|
- 앱 셸과 워크스페이스에 걸려 있던 고정 `100dvh` 높이를 풀어, 본문이 길어질 때 중앙 `main` 영역이 잘리거나 접히는 현상을 보정함.
|
||||||
|
- 좌우 레일은 그대로 화면 기준 높이를 유지하되, 중앙 작업 영역은 내용만큼 자연스럽게 늘어나도록 높이 계산을 다시 정리함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.62
|
||||||
|
- 템플릿 요청 모달의 제목/설명 입력을 Settings 화면과 같은 어두운 입력 문법으로 맞춰 흰 배경/흰 글자처럼 보이던 문제를 정리함.
|
||||||
|
- 앱 셸은 사이드 기본 바탕색을 중심으로 재정리하고, 중앙 바디에 배경과 좌우 보더를 줘 긴 스크롤에서도 사이드가 잘리는 듯한 인상을 줄이도록 조정함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.61
|
||||||
|
- Game Library 왼쪽 검색을 전체 티어표 검색이 아니라 게임 템플릿 검색으로 바꾸고, 홈 화면에서 검색어에 맞는 게임만 필터링하도록 조정함.
|
||||||
|
- 게임 템플릿에 사용자별 즐겨찾기 별 아이콘을 추가하고, 즐겨찾기한 게임이 관리자 고정 순서보다 우선 노출되도록 백엔드와 홈 화면을 함께 확장함.
|
||||||
|
- 앱 셸의 100vh 높이 계산을 100dvh와 고정 행 구조로 정리해, 콘텐츠가 없어도 생기던 불필요한 세로 스크롤을 줄임.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.60
|
||||||
|
- 관리자 티어표 관리 카드에서 사용자가 입력한 설명을 제목 아래에 함께 노출해 요청 의도를 더 빨리 파악할 수 있게 함.
|
||||||
|
- 템플릿 등록/업데이트 요청은 이제 에디터 모달에서 제목과 설명을 별도로 입력받고, 예시 문구와 함께 전송하도록 정리함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.59
|
||||||
|
- 관리자 아이템 상세 모달의 게임 선택을 전용 상태로 분리해 기본 선택값이 비어 있도록 바꾸고, 썸네일 아래에 배치해 정보/액션과 시각적으로 분리함.
|
||||||
|
- 커스텀 아이템이 실제로 사용 중인 게임 목록을 백엔드에서 함께 내려주고, 템플릿 요청 생성 폼에는 게임 ID와 게임 이름 라벨을 추가해 구분을 명확히 함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.58
|
||||||
|
- 관리자 아이템 관리 카드를 썸네일과 제목만 보이는 compact 카드로 줄여, 대량 업로드된 이미지도 훨씬 높은 밀도로 탐색할 수 있게 정리함.
|
||||||
|
- 카드 클릭 시 상세 정보를 모달로 열고 이미지 다운로드, 기본 템플릿 추가, 삭제를 모달 안에서 결정하는 흐름으로 바꿈.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.57
|
||||||
|
- 관리자 오른쪽 사이드에서 Featured, Game Summary, Users 패널을 완전히 제거하고, 티어표 요청 모드에는 모드 전환 탭만 남기도록 정리함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.56
|
||||||
|
- 관리자 아이템 관리 카드 그리드에 최대 폭을 줘서 결과가 1~2개일 때 카드가 과하게 늘어나지 않도록 조정함.
|
||||||
|
- 관리자 오른쪽 사이드에서 Featured, Game Summary, Users 요약 패널과 티어표 요청 새로고침/대기 개수 영역을 제거해 중복 정보를 정리함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.55
|
||||||
|
- 관리자 게임 관리 썸네일 입력을 파일 버튼 대신 클릭/드래그형 드롭존으로 바꿔 에디터 쪽 업로드 경험과 맞춤.
|
||||||
|
- 관리자 아이템 관리 카드를 세로 카드 구조로 재정리해 긴 파일명과 버튼 문구에도 레이아웃이 무너지지 않도록 보정함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.54
|
||||||
|
- 관리자 게임 상세 로딩 전에 호출되던 preview reset helper를 복구해, 게임 선택 시 런타임 오류로 상세 패널이 비어 있던 문제를 보정함.
|
||||||
|
- 선택 실패 시 원인을 더 쉽게 확인할 수 있도록 로딩 실패 안내와 콘솔 에러 로그를 추가함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.53
|
||||||
|
- 관리자 게임 관리에서 새 게임 만들기 카드를 제거하고, 헤더 버튼으로 여는 모달 기반 생성 흐름으로 정리함.
|
||||||
|
- 게임 선택은 명시적인 변경 핸들러로 다시 묶어 선택 즉시 상세 정보를 불러오도록 보강함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.52
|
||||||
|
- 관리자 게임 관리에서 선택 이벤트를 놓치지 않도록 `selectedGameId`와 탭 진입 시점을 감시해 상세 정보를 자동으로 다시 불러오도록 보정함.
|
||||||
|
- 선택 후 잠시 비어 보이던 구간을 줄이기 위해 로딩 상태와 선택된 게임 ID 안내를 추가함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.51
|
||||||
|
- 운영 비밀값이 들어 있는 `.env.production`과 로컬 에디터 설정 `.vscode/`를 `.gitignore`에 추가해 푸시 대상에서 제외함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.50
|
||||||
|
- 관리자 회원 아바타 삭제 버튼 조건을 명확히 하고 hover 표시를 visibility까지 포함해 보정해 다른 사용자 카드에서도 안정적으로 노출되도록 조정함.
|
||||||
|
- 삭제 배지 아이콘을 흰색으로 보정하고 어두운 배경 위에서 더 잘 보이도록 스타일을 다듬음.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.49
|
||||||
|
- 관리자 회원 저장 후 통계 정보가 흔들리던 문제를 줄이기 위해 저장/아바타 변경 뒤 회원 목록을 다시 동기화하도록 보정함.
|
||||||
|
- 회원 아바타 액션을 hover 기반으로 재배치해 평소에는 숨기고, 마우스 오버 시에만 수정 오버레이와 삭제 버튼이 나타나도록 조정함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.48
|
||||||
|
- 관리자 회원 관리 배지를 Settings 화면의 Administrator 스타일로 통일하고, 카드 우측 상단에 걸치는 형태로 재배치함.
|
||||||
|
- 관리자 권한 체크박스를 제거하고 작은 텍스트 액션과 확인 모달을 거쳐 draft 상태만 바꾸는 흐름으로 정리함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.47
|
||||||
|
- 관리자 회원 관리에서 비밀번호 초기화와 삭제를 실제 모달 플로우로 연결하고, 저장 버튼은 회원 정보 변경 시에만 활성화되도록 정리함.
|
||||||
|
- 상단 휴지통 아이콘과 불필요 문구를 제거하고, 관리자도 회원 썸네일을 카드 안에서 바로 수정/삭제할 수 있게 보완함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.46
|
||||||
|
- **회원 액션 플로우 수정**: 회원 카드의 불필요한 안내 문구와 상단 삭제 아이콘을 제거하고, 비밀번호 초기화/회원 삭제를 각각 전용 확인 모달로 재구성
|
||||||
|
- **저장 버튼 활성 조건 정리**: 회원정보 저장은 필드가 실제로 바뀐 경우에만 활성화되고, 비밀번호 초기화와 삭제 아이콘은 즉시 사용할 수 있도록 조정
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.45
|
||||||
|
- **회원 카드 액션 재구성**: 비밀번호 초기화와 회원 삭제를 아이콘 액션으로 축소하고, `회원정보 저장` 버튼은 실제 변경이 있을 때만 활성화되도록 조정
|
||||||
|
- **관리자 아바타 편집 지원**: 관리자도 회원 아바타를 클릭해 변경하거나 삭제할 수 있도록 전용 업로드 API와 카드 UI를 추가
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.44
|
||||||
|
- **관리자 탭 구조 재정리**: `목록 관리`와 `게임 관리`를 분리하고, 게임 생성/선택 흐름을 우측 사이드가 아닌 본문 전용 작업 화면으로 이동
|
||||||
|
- **회원/액션 레이아웃 정리**: 회원 카드의 작성 수/최근 활동을 텍스트형 정보로 단순화하고, 관리 버튼의 줄바꿈이 어색하지 않도록 액션 그리드를 보정
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.43
|
||||||
|
- **이름 표시 옵션 추가**: 티어 에디터 우측 옵션에 `캐릭터 이름 표시` 토글을 추가하고, 보드 안에서는 이미지 하단 오버레이 라벨로 표시되도록 개선
|
||||||
|
- **저장/불러오기 연동**: 이름 표시 옵션이 저장된 티어표와 다운로드 이미지에도 그대로 반영되도록 프런트/백엔드 저장 구조를 확장
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.42
|
||||||
|
- **에디터 보드 폭 기준 정리**: 티어표 보드 영역을 저장 이미지 기준에 맞춰 최대 약 `960px` 폭으로 묶고, 넓은 화면에서는 아이템 풀이 남는 공간을 더 가져가도록 조정
|
||||||
|
- **아이템 풀 카드형 통일**: 넓은 화면에서도 우측 아이템 목록을 카드형 그리드로 바꿔 한 번에 더 많은 아이템을 보고 드래그할 수 있도록 개선
|
||||||
|
|
||||||
## 2026-03-31 v1.2.41
|
## 2026-03-31 v1.2.41
|
||||||
- **에디터 하단 아이템 풀 카드형 전환**: 브라우저 폭이 `980px` 이하로 줄어 아이템 풀이 티어표 아래로 내려오면, 세로 리스트 대신 `이미지 위 / 이름 아래` 카드형 그리드로 전환되도록 조정
|
- **에디터 하단 아이템 풀 카드형 전환**: 브라우저 폭이 `980px` 이하로 줄어 아이템 풀이 티어표 아래로 내려오면, 세로 리스트 대신 `이미지 위 / 이름 아래` 카드형 그리드로 전환되도록 조정
|
||||||
- **소형 폭 열 수 최적화**: 약 `800px` 전후에서는 6열 그리드가 유지되고, 더 작은 폭에서는 4열/3열로 자연스럽게 줄어들며 긴 이름은 가운데 정렬된 말줄임 형태로 보이도록 정리
|
- **소형 폭 열 수 최적화**: 약 `800px` 전후에서는 6열 그리드가 유지되고, 더 작은 폭에서는 4열/3열로 자연스럽게 줄어들며 긴 이름은 가운데 정렬된 말줄임 형태로 보이도록 정리
|
||||||
@@ -345,6 +515,101 @@
|
|||||||
- **미사용 아이콘 필터 수정**: 관리자 아이템 관리의 `미사용 아이콘 보기` 체크 상태가 실제 API 요청의 `orphanOnly` 파라미터로 전달되도록 수정
|
- **미사용 아이콘 필터 수정**: 관리자 아이템 관리의 `미사용 아이콘 보기` 체크 상태가 실제 API 요청의 `orphanOnly` 파라미터로 전달되도록 수정
|
||||||
- **삭제 활성화 흐름 정상화**: 미사용 아이콘만 조회했을 때 `usageCount = 0` 항목의 개별 삭제 버튼이 의도대로 활성화되도록 정리
|
- **삭제 활성화 흐름 정상화**: 미사용 아이콘만 조회했을 때 `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
|
## 2026-03-19 v0.1.0
|
||||||
- **초기 스캐폴딩**: `frontend/`에 Vue3(Vite, JavaScript) 프로젝트 생성
|
- **초기 스캐폴딩**: `frontend/`에 Vue3(Vite, JavaScript) 프로젝트 생성
|
||||||
- **라우팅/화면 골격**: 게임 선택(`/`), 게임 허브(`/games/:gameId`), 에디터(`/editor/:gameId/...`), 로그인(`/login`), 내 티어표(`/me`), 관리자(`/admin`) 라우트 추가
|
- **라우팅/화면 골격**: 게임 선택(`/`), 게임 허브(`/games/:gameId`), 에디터(`/editor/:gameId/...`), 로그인(`/login`), 내 티어표(`/me`), 관리자(`/admin`) 라우트 추가
|
||||||
@@ -357,137 +622,3 @@
|
|||||||
- **네비/권한 UX**: 관리자 메뉴는 admin 로그인 시에만 노출, 로그인 대신 아바타 버튼/메뉴 노출
|
- **네비/권한 UX**: 관리자 메뉴는 admin 로그인 시에만 노출, 로그인 대신 아바타 버튼/메뉴 노출
|
||||||
- **프로필**: `/profile` 페이지 추가, 아바타 업로드 API(`/api/auth/avatar`) 및 표시 지원
|
- **프로필**: `/profile` 페이지 추가, 아바타 업로드 API(`/api/auth/avatar`) 및 표시 지원
|
||||||
- **에디터 버그 수정**: 드래그 시 아이템들이 “묶음”으로 같이 움직이던 문제 해결(드롭 영역 DOM 구조/Sortable 옵션 수정), 드롭 영역 overflow/배치 레이아웃 개선
|
- **에디터 버그 수정**: 드래그 시 아이템들이 “묶음”으로 같이 움직이던 문제 해결(드롭 영역 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를 추가
|
|
||||||
- **미사용 커스텀 이미지 관리 추가**: 관리자 아이템 탭에서 커스텀 이미지의 사용 횟수를 표시하고, 미사용 항목만 따로 필터링해 개별/일괄 삭제할 수 있도록 보강
|
|
||||||
|
|
||||||
## 2026-03-31 v0.1.18
|
|
||||||
- **에디터 보드 폭 기준 정리**: 티어표 보드 영역을 저장 이미지 기준에 맞춰 최대 약 `960px` 폭으로 묶고, 넓은 화면에서는 아이템 풀이 남는 공간을 더 가져가도록 조정
|
|
||||||
- **아이템 풀 카드형 통일**: 넓은 화면에서도 우측 아이템 목록을 카드형 그리드로 바꿔 한 번에 더 많은 아이템을 보고 드래그할 수 있도록 개선
|
|
||||||
|
|
||||||
## 2026-03-31 v0.1.19
|
|
||||||
- **이름 표시 옵션 추가**: 티어 에디터 우측 옵션에 `캐릭터 이름 표시` 토글을 추가하고, 보드 안에서는 이미지 하단 오버레이 라벨로 표시되도록 개선
|
|
||||||
- **저장/불러오기 연동**: 이름 표시 옵션이 저장된 티어표와 다운로드 이미지에도 그대로 반영되도록 프런트/백엔드 저장 구조를 확장
|
|
||||||
|
|
||||||
## 2026-03-31 v0.1.20
|
|
||||||
- **관리자 탭 구조 재정리**: `목록 관리`와 `게임 관리`를 분리하고, 게임 생성/선택 흐름을 우측 사이드가 아닌 본문 전용 작업 화면으로 이동
|
|
||||||
- **회원/액션 레이아웃 정리**: 회원 카드의 작성 수/최근 활동을 텍스트형 정보로 단순화하고, 관리 버튼의 줄바꿈이 어색하지 않도록 액션 그리드를 보정
|
|
||||||
|
|
||||||
## 2026-03-31 v0.1.21
|
|
||||||
- **회원 카드 액션 재구성**: 비밀번호 초기화와 회원 삭제를 아이콘 액션으로 축소하고, `회원정보 저장` 버튼은 실제 변경이 있을 때만 활성화되도록 조정
|
|
||||||
- **관리자 아바타 편집 지원**: 관리자도 회원 아바타를 클릭해 변경하거나 삭제할 수 있도록 전용 업로드 API와 카드 UI를 추가
|
|
||||||
|
|
||||||
## 2026-03-31 v0.1.22
|
|
||||||
- **회원 액션 플로우 수정**: 회원 카드의 불필요한 안내 문구와 상단 삭제 아이콘을 제거하고, 비밀번호 초기화/회원 삭제를 각각 전용 확인 모달로 재구성
|
|
||||||
- **저장 버튼 활성 조건 정리**: 회원정보 저장은 필드가 실제로 바뀐 경우에만 활성화되고, 비밀번호 초기화와 삭제 아이콘은 즉시 사용할 수 있도록 조정
|
|
||||||
|
|
||||||
## v0.1.23
|
|
||||||
- 관리자 회원 관리에서 비밀번호 초기화와 삭제를 실제 모달 플로우로 연결하고, 저장 버튼은 회원 정보 변경 시에만 활성화되도록 정리함.
|
|
||||||
- 상단 휴지통 아이콘과 불필요 문구를 제거하고, 관리자도 회원 썸네일을 카드 안에서 바로 수정/삭제할 수 있게 보완함.
|
|
||||||
|
|
||||||
## v0.1.24
|
|
||||||
- 관리자 회원 관리 배지를 Settings 화면의 Administrator 스타일로 통일하고, 카드 우측 상단에 걸치는 형태로 재배치함.
|
|
||||||
- 관리자 권한 체크박스를 제거하고 작은 텍스트 액션과 확인 모달을 거쳐 draft 상태만 바꾸는 흐름으로 정리함.
|
|
||||||
|
|
||||||
## v0.1.25
|
|
||||||
- 관리자 회원 저장 후 통계 정보가 흔들리던 문제를 줄이기 위해 저장/아바타 변경 뒤 회원 목록을 다시 동기화하도록 보정함.
|
|
||||||
- 회원 아바타 액션을 hover 기반으로 재배치해 평소에는 숨기고, 마우스 오버 시에만 수정 오버레이와 삭제 버튼이 나타나도록 조정함.
|
|
||||||
|
|
||||||
## v0.1.26
|
|
||||||
- 관리자 회원 아바타 삭제 버튼 조건을 명확히 하고 hover 표시를 visibility까지 포함해 보정해 다른 사용자 카드에서도 안정적으로 노출되도록 조정함.
|
|
||||||
- 삭제 배지 아이콘을 흰색으로 보정하고 어두운 배경 위에서 더 잘 보이도록 스타일을 다듬음.
|
|
||||||
|
|
||||||
## v0.1.27
|
|
||||||
- 운영 비밀값이 들어 있는 `.env.production`과 로컬 에디터 설정 `.vscode/`를 `.gitignore`에 추가해 푸시 대상에서 제외함.
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const { toasts, dismissToast } = useToast()
|
|||||||
const leftRailCollapsed = ref(false)
|
const leftRailCollapsed = ref(false)
|
||||||
const rightRailOpen = ref(true)
|
const rightRailOpen = ref(true)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
const searchPlaceholder = computed(() => (route.name === 'home' ? '게임 템플릿 검색' : '전체 티어표 검색'))
|
||||||
const isCollapsedSearchOpen = ref(false)
|
const isCollapsedSearchOpen = ref(false)
|
||||||
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
|
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
|
||||||
provide('rightRailOpen', rightRailOpen)
|
provide('rightRailOpen', rightRailOpen)
|
||||||
@@ -54,6 +55,8 @@ const leftNavItems = computed(() => {
|
|||||||
return items.filter((item) => !item.requiresAuth || auth.user)
|
return items.filter((item) => !item.requiresAuth || auth.user)
|
||||||
})
|
})
|
||||||
const showRightRailAction = computed(() => false)
|
const showRightRailAction = computed(() => false)
|
||||||
|
const showGameHubViewToggle = computed(() => route.name === 'gameHub')
|
||||||
|
const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||||
const leftBottomPrimaryAction = computed(() => {
|
const leftBottomPrimaryAction = computed(() => {
|
||||||
if (route.name === 'home' && auth.user) {
|
if (route.name === 'home' && auth.user) {
|
||||||
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new' }
|
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new' }
|
||||||
@@ -241,6 +244,14 @@ function toggleRightRail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setGameHubViewMode(mode) {
|
||||||
|
if (route.name !== 'gameHub') return
|
||||||
|
const nextQuery = { ...route.query }
|
||||||
|
if (mode === 'list') nextQuery.view = 'list'
|
||||||
|
else delete nextQuery.view
|
||||||
|
router.replace({ path: route.path, query: nextQuery })
|
||||||
|
}
|
||||||
|
|
||||||
function openCollapsedSearch() {
|
function openCollapsedSearch() {
|
||||||
if (!leftRailCollapsed.value || isMobileLayout.value) return
|
if (!leftRailCollapsed.value || isMobileLayout.value) return
|
||||||
isCollapsedSearchOpen.value = true
|
isCollapsedSearchOpen.value = true
|
||||||
@@ -261,6 +272,10 @@ function handleLeftRailSearch() {
|
|||||||
function submitGlobalSearch() {
|
function submitGlobalSearch() {
|
||||||
const query = (searchQuery.value || '').trim()
|
const query = (searchQuery.value || '').trim()
|
||||||
isCollapsedSearchOpen.value = false
|
isCollapsedSearchOpen.value = false
|
||||||
|
if (route.name === 'home') {
|
||||||
|
router.push(query ? `/?q=${encodeURIComponent(query)}` : '/')
|
||||||
|
return
|
||||||
|
}
|
||||||
router.push(query ? `/search?q=${encodeURIComponent(query)}` : '/search')
|
router.push(query ? `/search?q=${encodeURIComponent(query)}` : '/search')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +325,7 @@ function submitGlobalSearch() {
|
|||||||
<img :src="iconSearch" alt="" />
|
<img :src="iconSearch" alt="" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : '전체 티어표 검색'" />
|
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : searchPlaceholder" />
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<nav class="leftNav">
|
<nav class="leftNav">
|
||||||
@@ -348,6 +363,14 @@ function submitGlobalSearch() {
|
|||||||
<span class="workspaceHead__brandSub">by zenn</span>
|
<span class="workspaceHead__brandSub">by zenn</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="workspaceHead__actions">
|
<div class="workspaceHead__actions">
|
||||||
|
<div v-if="showGameHubViewToggle" class="viewToggle" role="group" aria-label="티어표 보기 방식">
|
||||||
|
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': gameHubViewMode === 'grid' }" type="button" aria-label="그리드 보기" @click="setGameHubViewMode('grid')">
|
||||||
|
<img :src="iconGridView" alt="" />
|
||||||
|
</button>
|
||||||
|
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': gameHubViewMode === 'list' }" type="button" aria-label="리스트 보기" @click="setGameHubViewMode('list')">
|
||||||
|
<img :src="iconLists" alt="" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button v-if="!rightRailOpen" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="패널 열기" @click="toggleRightRail">
|
<button v-if="!rightRailOpen" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="패널 열기" @click="toggleRightRail">
|
||||||
<img :src="iconDockToLeft" alt="" />
|
<img :src="iconDockToLeft" alt="" />
|
||||||
</button>
|
</button>
|
||||||
@@ -359,12 +382,12 @@ function submitGlobalSearch() {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" aria-label="전체 티어표 검색" @click.self="closeCollapsedSearch">
|
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" :aria-label="searchPlaceholder" @click.self="closeCollapsedSearch">
|
||||||
<form class="collapsedSearchBar" @submit.prevent="submitGlobalSearch">
|
<form class="collapsedSearchBar" @submit.prevent="submitGlobalSearch">
|
||||||
<span class="collapsedSearchBar__icon">
|
<span class="collapsedSearchBar__icon">
|
||||||
<img :src="iconSearch" alt="" />
|
<img :src="iconSearch" alt="" />
|
||||||
</span>
|
</span>
|
||||||
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" placeholder="전체 티어표 검색" autofocus />
|
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="searchPlaceholder" autofocus />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -410,12 +433,10 @@ function submitGlobalSearch() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.appShell {
|
.appShell {
|
||||||
min-height: 100vh;
|
min-height: 100dvh;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr) var(--right-rail-width, 320px);
|
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr) var(--right-rail-width, 320px);
|
||||||
background:
|
background: rgba(14, 14, 14, 0.96);
|
||||||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.04), transparent 28%),
|
|
||||||
linear-gradient(180deg, #1a1a1a 0%, #121212 100%);
|
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
transition: grid-template-columns 220ms ease;
|
transition: grid-template-columns 220ms ease;
|
||||||
}
|
}
|
||||||
@@ -426,7 +447,7 @@ function submitGlobalSearch() {
|
|||||||
|
|
||||||
.leftRail,
|
.leftRail,
|
||||||
.rightRail {
|
.rightRail {
|
||||||
min-height: 100vh;
|
min-height: 100dvh;
|
||||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
background: rgba(14, 14, 14, 0.92);
|
background: rgba(14, 14, 14, 0.92);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -492,7 +513,7 @@ function submitGlobalSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.leftRail__body {
|
.leftRail__body {
|
||||||
max-height: calc(100vh - 56px);
|
max-height: calc(100dvh - 56px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rightRail__body {
|
.rightRail__body {
|
||||||
@@ -557,6 +578,9 @@ function submitGlobalSearch() {
|
|||||||
min-width: 32px;
|
min-width: 32px;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appUserCard {
|
.appUserCard {
|
||||||
@@ -602,7 +626,9 @@ function submitGlobalSearch() {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
transition: opacity 180ms ease;
|
max-width: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: opacity 180ms ease, max-width 220ms ease, transform 220ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appUserCard__name {
|
.appUserCard__name {
|
||||||
@@ -636,12 +662,14 @@ function submitGlobalSearch() {
|
|||||||
.searchStub__input {
|
.searchStub__input {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
max-width: 100%;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
outline: none;
|
outline: none;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
transition: opacity 180ms ease, width 180ms ease;
|
overflow: hidden;
|
||||||
|
transition: opacity 180ms ease, max-width 220ms ease, transform 220ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchStub__input::placeholder {
|
.searchStub__input::placeholder {
|
||||||
@@ -685,7 +713,10 @@ function submitGlobalSearch() {
|
|||||||
|
|
||||||
.leftNav__label {
|
.leftNav__label {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 140px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: opacity 180ms ease, max-width 220ms ease, transform 220ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leftNav__item--active,
|
.leftNav__item--active,
|
||||||
@@ -720,13 +751,15 @@ function submitGlobalSearch() {
|
|||||||
.appShell--leftCollapsed .appUserCard__button,
|
.appShell--leftCollapsed .appUserCard__button,
|
||||||
.appShell--leftCollapsed .appUserCard__guest {
|
.appShell--leftCollapsed .appUserCard__guest {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 6px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .appUserCard__meta,
|
.appShell--leftCollapsed .appUserCard__meta,
|
||||||
.appShell--leftCollapsed .leftNav__label,
|
.appShell--leftCollapsed .leftNav__label,
|
||||||
.appShell--leftCollapsed .searchStub__input {
|
.appShell--leftCollapsed .searchStub__input {
|
||||||
display: none;
|
opacity: 0;
|
||||||
|
max-width: 0;
|
||||||
|
transform: translateX(-4px);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .appUserCard__avatar {
|
.appShell--leftCollapsed .appUserCard__avatar {
|
||||||
@@ -736,11 +769,10 @@ function submitGlobalSearch() {
|
|||||||
|
|
||||||
.appShell--leftCollapsed .searchStub {
|
.appShell--leftCollapsed .searchStub {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 10px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .searchStub__iconButton {
|
.appShell--leftCollapsed .searchStub__iconButton {
|
||||||
width: 100%;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .leftNav {
|
.appShell--leftCollapsed .leftNav {
|
||||||
@@ -749,7 +781,6 @@ function submitGlobalSearch() {
|
|||||||
|
|
||||||
.appShell--leftCollapsed .leftNav__item {
|
.appShell--leftCollapsed .leftNav__item {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 11px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .leftRail__bottom {
|
.appShell--leftCollapsed .leftRail__bottom {
|
||||||
@@ -785,7 +816,11 @@ function submitGlobalSearch() {
|
|||||||
|
|
||||||
.appMain {
|
.appMain {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
background: rgba(18, 18, 18, 0.98);
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.appMain--preview {
|
.appMain--preview {
|
||||||
@@ -794,8 +829,9 @@ function submitGlobalSearch() {
|
|||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-rows: 56px minmax(0, 1fr);
|
||||||
gap: 0;
|
gap: 0;
|
||||||
min-height: 100vh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace--localRail {
|
.workspace--localRail {
|
||||||
@@ -835,24 +871,44 @@ function submitGlobalSearch() {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.viewToggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewToggle .ghostIcon--iconOnly {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghostIcon--active {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.workspaceBody {
|
.workspaceBody {
|
||||||
min-height: calc(100vh - 56px);
|
min-height: 0;
|
||||||
padding: 0;
|
padding: 18px 18px 32px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: rgba(24, 24, 24, 0.92);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
margin: 18px 18px 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspaceBody--localRail {
|
.workspaceBody--localRail {
|
||||||
min-height: calc(100vh - 56px);
|
min-height: 0;
|
||||||
padding: 0;
|
padding: 18px 18px 32px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: rgba(24, 24, 24, 0.92);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
margin: 18px 18px 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rightRail {
|
.rightRail {
|
||||||
@@ -885,6 +941,60 @@ function submitGlobalSearch() {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.collapsedSearchModal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 35;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 88px 20px 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.44);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsedSearchBar {
|
||||||
|
width: min(520px, calc(100vw - 32px));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px 22px;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(26, 26, 26, 0.96);
|
||||||
|
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsedSearchBar__icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsedSearchBar__icon img {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: block;
|
||||||
|
filter: brightness(0) saturate(100%) invert(94%) sepia(6%) saturate(207%) hue-rotate(186deg) brightness(96%) contrast(92%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsedSearchBar__input {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsedSearchBar__input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.46);
|
||||||
|
}
|
||||||
|
|
||||||
.localRightRailRoot {
|
.localRightRailRoot {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
@@ -978,7 +1088,7 @@ function submitGlobalSearch() {
|
|||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: min(360px, calc(100vw - 20px));
|
width: min(360px, calc(100vw - 20px));
|
||||||
height: 100vh;
|
height: 100dvh;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
background: rgba(14, 14, 14, 0.96);
|
background: rgba(14, 14, 14, 0.96);
|
||||||
@@ -996,10 +1106,12 @@ function submitGlobalSearch() {
|
|||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.appShell {
|
.appShell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leftRail {
|
.leftRail {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
|
height: auto;
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
@@ -1013,6 +1125,19 @@ function submitGlobalSearch() {
|
|||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.appMain {
|
||||||
|
min-height: auto;
|
||||||
|
border-left: 0;
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace,
|
||||||
|
.workspaceBody,
|
||||||
|
.workspaceBody--localRail {
|
||||||
|
min-height: 0;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.leftRail__content {
|
.leftRail__content {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
@@ -1064,10 +1189,11 @@ function submitGlobalSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.collapsedSearchModal {
|
.collapsedSearchModal {
|
||||||
padding-top: 72px;
|
padding: 72px 16px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapsedSearchBar {
|
.collapsedSearchBar {
|
||||||
|
width: min(100%, calc(100vw - 24px));
|
||||||
padding: 16px 18px;
|
padding: 16px 18px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export const api = {
|
|||||||
|
|
||||||
listGames: () => request('/api/games'),
|
listGames: () => request('/api/games'),
|
||||||
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
|
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 }),
|
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
|
||||||
updateAdminGameItem: (gameId, itemId, payload) =>
|
updateAdminGameItem: (gameId, itemId, payload) =>
|
||||||
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||||
@@ -42,6 +44,15 @@ export const api = {
|
|||||||
listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) =>
|
listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) =>
|
||||||
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
|
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
|
||||||
listAdminTemplateRequests: () => request('/api/admin/template-requests'),
|
listAdminTemplateRequests: () => request('/api/admin/template-requests'),
|
||||||
|
getAdminImageAssetStats: ({ month = '', limit = 12 } = {}) => {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (month) query.set('month', month)
|
||||||
|
query.set('limit', String(limit))
|
||||||
|
return request(`/api/admin/image-assets/stats?${query.toString()}`)
|
||||||
|
},
|
||||||
|
resetAdminImageAssetStats: (payload) => request('/api/admin/image-assets/stats/reset', { method: 'POST', body: payload || {} }),
|
||||||
|
listAdminUnusedImageAssets: ({ limit = 100, minAgeHours = 24 } = {}) => request(`/api/admin/image-assets/orphans?limit=${encodeURIComponent(limit)}&minAgeHours=${encodeURIComponent(minAgeHours)}`),
|
||||||
|
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
|
||||||
promoteAdminCustomItem: (itemId, payload) =>
|
promoteAdminCustomItem: (itemId, payload) =>
|
||||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||||
promoteAdminTierListItems: (tierListId, payload) =>
|
promoteAdminTierListItems: (tierListId, payload) =>
|
||||||
@@ -88,6 +99,7 @@ export const api = {
|
|||||||
favoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'POST' }),
|
favoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'POST' }),
|
||||||
unfavoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'DELETE' }),
|
unfavoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'DELETE' }),
|
||||||
deleteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`, { 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 }),
|
requestTierListTemplate: (id, payload) => request(`/api/tierlists/${encodeURIComponent(id)}/template-request`, { method: 'POST', body: payload }),
|
||||||
saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }),
|
saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }),
|
||||||
uploadTierListThumbnail: async (file) => {
|
uploadTierListThumbnail: async (file) => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,8 @@ const gameName = ref('')
|
|||||||
const tierLists = ref([])
|
const tierLists = ref([])
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
|
const brokenThumbnailIds = ref({})
|
||||||
|
const isListView = computed(() => route.query.view === 'list')
|
||||||
|
|
||||||
function fmt(ts) {
|
function fmt(ts) {
|
||||||
return new Date(ts).toLocaleDateString(undefined, {
|
return new Date(ts).toLocaleDateString(undefined, {
|
||||||
@@ -37,9 +39,15 @@ function avatarFallbackOf(tierList) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tierListThumbnailUrl(tierList) {
|
function tierListThumbnailUrl(tierList) {
|
||||||
|
if (!tierList?.id || brokenThumbnailIds.value[tierList.id]) return ''
|
||||||
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
|
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleThumbnailError(tierListId) {
|
||||||
|
if (!tierListId || brokenThumbnailIds.value[tierListId]) return
|
||||||
|
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadTierLists()
|
await loadTierLists()
|
||||||
})
|
})
|
||||||
@@ -51,6 +59,7 @@ async function loadTierLists() {
|
|||||||
api.searchPublicTierLists(gameId.value, query.value),
|
api.searchPublicTierLists(gameId.value, query.value),
|
||||||
])
|
])
|
||||||
gameName.value = gameRes.game?.name || gameId.value
|
gameName.value = gameRes.game?.name || gameId.value
|
||||||
|
brokenThumbnailIds.value = {}
|
||||||
tierLists.value = listRes.tierLists || []
|
tierLists.value = listRes.tierLists || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '게임 정보를 불러오지 못했어요.'
|
error.value = '게임 정보를 불러오지 못했어요.'
|
||||||
@@ -96,11 +105,11 @@ function submitSearch() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
|
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
|
||||||
<div v-else class="list">
|
<div v-else class="list" :class="{ 'list--table': isListView }">
|
||||||
<article v-for="t in tierLists" :key="t.id" class="boardCard">
|
<article v-for="t in tierLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
||||||
<button class="boardCard__body" @click="openTierList(t.id)">
|
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openTierList(t.id)">
|
||||||
<div class="boardCard__thumbWrap">
|
<div class="boardCard__thumbWrap">
|
||||||
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
|
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" alt="" @error="handleThumbnailError(t.id)" />
|
||||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="boardCard__head">
|
<div class="boardCard__head">
|
||||||
@@ -213,10 +222,15 @@ function submitSearch() {
|
|||||||
}
|
}
|
||||||
.list {
|
.list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list--table {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.boardCard {
|
.boardCard {
|
||||||
|
min-width: 0;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
background: rgba(62, 62, 62, 0.82);
|
background: rgba(62, 62, 62, 0.82);
|
||||||
@@ -233,6 +247,7 @@ function submitSearch() {
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
.boardCard__body {
|
.boardCard__body {
|
||||||
|
min-width: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -242,12 +257,24 @@ function submitSearch() {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boardCard__body--list {
|
||||||
|
grid-template-columns: 76px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
.boardCard__thumbWrap {
|
.boardCard__thumbWrap {
|
||||||
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
padding: 14px 14px 0;
|
padding: 14px 14px 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boardCard--list .boardCard__thumbWrap {
|
||||||
|
aspect-ratio: auto;
|
||||||
|
height: 100%;
|
||||||
|
padding: 14px 0 14px 14px;
|
||||||
|
}
|
||||||
.boardCard__thumb {
|
.boardCard__thumb {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -255,6 +282,14 @@ function submitSearch() {
|
|||||||
display: block;
|
display: block;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boardCard--list .boardCard__thumb,
|
||||||
|
.boardCard--list .boardCard__thumbPlaceholder {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
.boardCard__thumbPlaceholder {
|
.boardCard__thumbPlaceholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -271,21 +306,35 @@ function submitSearch() {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
white-space: nowrap;
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
.boardCard__head {
|
.boardCard__head {
|
||||||
|
min-width: 0;
|
||||||
padding: 16px 18px 18px;
|
padding: 16px 18px 18px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boardCard--list .boardCard__head {
|
||||||
|
height: 100%;
|
||||||
|
padding: 14px 16px 14px 0;
|
||||||
|
align-content: center;
|
||||||
}
|
}
|
||||||
.boardCard__titleRow,
|
.boardCard__titleRow,
|
||||||
.boardCard__metaRow {
|
.boardCard__metaRow {
|
||||||
display: flex;
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
}
|
||||||
justify-content: space-between;
|
|
||||||
|
.boardCard__titleRow {
|
||||||
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.boardCard__metaRow {
|
.boardCard__metaRow {
|
||||||
@@ -293,11 +342,13 @@ function submitSearch() {
|
|||||||
}
|
}
|
||||||
.boardCard__author {
|
.boardCard__author {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
opacity: 0.86;
|
opacity: 0.86;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.boardCard__authorName {
|
.boardCard__authorName {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -323,28 +374,37 @@ function submitSearch() {
|
|||||||
.boardCard__date,
|
.boardCard__date,
|
||||||
.favoriteStat {
|
.favoriteStat {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: rgba(255, 255, 255, 0.64);
|
color: rgba(255, 255, 255, 0.64);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.boardCard__date {
|
.boardCard__date {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
@media (max-width: 1400px) {
|
@media (max-width: 900px) {
|
||||||
.list {
|
.boardCard__body--list {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
@media (max-width: 1024px) {
|
.boardCard--list .boardCard__head {
|
||||||
.list {
|
padding: 0 18px 18px;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
}
|
||||||
|
|
||||||
|
.boardCard--list .boardCard__thumbWrap {
|
||||||
|
padding: 14px 14px 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.list {
|
.list {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchBar__input {
|
.searchBar__input {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -1,28 +1,71 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { toApiUrl } from '../lib/runtime'
|
import { toApiUrl } from '../lib/runtime'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
const error = 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 {
|
try {
|
||||||
const data = await api.listGames()
|
const data = await api.listGames()
|
||||||
items.value = data.games || []
|
items.value = data.games || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
|
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
onMounted(loadGames)
|
||||||
|
watch(() => auth.user?.id, loadGames)
|
||||||
|
|
||||||
function goGame(gameId) {
|
function goGame(gameId) {
|
||||||
router.push(`/games/${gameId}`)
|
router.push(`/games/${gameId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleFavorite(game, event) {
|
||||||
|
event?.stopPropagation()
|
||||||
|
if (!auth.user) {
|
||||||
|
router.push(`/login?redirect=${encodeURIComponent(route.fullPath || '/')}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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) {
|
function thumbUrl(g) {
|
||||||
return g.thumbnailSrc ? toApiUrl(g.thumbnailSrc) : ''
|
return g.thumbnailSrc ? toApiUrl(g.thumbnailSrc) : ''
|
||||||
}
|
}
|
||||||
@@ -34,22 +77,35 @@ function thumbUrl(g) {
|
|||||||
<div class="pageHead__eyebrow">Workspace</div>
|
<div class="pageHead__eyebrow">Workspace</div>
|
||||||
<h1 class="pageHead__title">Game Library</h1>
|
<h1 class="pageHead__title">Game Library</h1>
|
||||||
<p class="pageHead__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
<p class="pageHead__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
||||||
|
<p v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 게임 템플릿만 보고 있어요.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
<section class="libraryGrid">
|
<TransitionGroup v-if="games.length" name="libraryCard" tag="section" class="libraryGrid">
|
||||||
<button v-for="g in games" :key="g.id" class="libraryCard" @click="goGame(g.id)">
|
<article v-for="g in games" :key="g.id" class="libraryCard">
|
||||||
<div class="libraryCard__thumbWrap">
|
<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" />
|
<img v-if="thumbUrl(g)" class="libraryCard__thumb" :src="thumbUrl(g)" :alt="g.name" />
|
||||||
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="libraryCard__body">
|
<div class="libraryCard__body">
|
||||||
<div class="libraryCard__title">{{ g.name }}</div>
|
<div class="libraryCard__title">{{ g.name }}</div>
|
||||||
<div class="libraryCard__meta">{{ g.id }}</div>
|
<div class="libraryCard__meta">{{ g.id }}</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</article>
|
||||||
|
</TransitionGroup>
|
||||||
|
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 게임 템플릿이 없어요.' : '표시할 게임 템플릿이 없어요.' }}</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -66,7 +122,12 @@ function thumbUrl(g) {
|
|||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
}
|
}
|
||||||
|
.pageHead__searchState {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.62);
|
||||||
|
}
|
||||||
.libraryCard {
|
.libraryCard {
|
||||||
|
position: relative;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
@@ -77,14 +138,41 @@ function thumbUrl(g) {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
transition:
|
transition: transform 0.16s ease, background 0.16s ease;
|
||||||
transform 0.16s ease,
|
will-change: transform, opacity;
|
||||||
background 0.16s ease;
|
|
||||||
}
|
}
|
||||||
.libraryCard:hover {
|
.libraryCard:hover {
|
||||||
background: rgba(70, 70, 70, 0.96);
|
background: rgba(70, 70, 70, 0.96);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
.libraryCard__main {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.libraryCard__favorite {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 14px;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
background: rgba(15, 15, 15, 0.72);
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.libraryCard__favorite--active {
|
||||||
|
color: #ffd86b;
|
||||||
|
}
|
||||||
.libraryCard__thumbWrap {
|
.libraryCard__thumbWrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
@@ -119,6 +207,28 @@ function thumbUrl(g) {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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) {
|
@media (max-width: 1400px) {
|
||||||
.libraryGrid {
|
.libraryGrid {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -54,18 +54,6 @@ function openList(t) {
|
|||||||
router.push(`/editor/${t.gameId}/${t.id}`)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -101,7 +89,6 @@ async function removeList(t) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button class="link link--danger" @click="removeList(t)">삭제</button>
|
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,25 +102,18 @@ async function removeList(t) {
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.link {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 14px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
color: rgba(255, 255, 255, 0.92);
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
.empty {
|
.empty {
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
.list {
|
.list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(260px, 320px));
|
||||||
|
justify-content: start;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
.boardCard {
|
.boardCard {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
background: rgba(62, 62, 62, 0.82);
|
background: rgba(62, 62, 62, 0.82);
|
||||||
@@ -184,30 +164,40 @@ async function removeList(t) {
|
|||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
}
|
}
|
||||||
.boardCard__title {
|
.boardCard__title {
|
||||||
font-weight: 900;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
font-weight: 900;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
white-space: nowrap;
|
line-height: 1.3;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
.boardCard__head {
|
.boardCard__head {
|
||||||
padding: 16px 18px 18px;
|
padding: 16px 18px 18px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.boardCard__titleRow,
|
.boardCard__titleRow,
|
||||||
.boardCard__metaRow {
|
.boardCard__metaRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boardCard__titleRow {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.boardCard__metaRow {
|
.boardCard__metaRow {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
.boardCard__author {
|
.boardCard__author {
|
||||||
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
@@ -246,21 +236,6 @@ async function removeList(t) {
|
|||||||
.boardCard__date {
|
.boardCard__date {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
.link--danger {
|
|
||||||
background: rgba(239, 68, 68, 0.14);
|
|
||||||
border-color: rgba(239, 68, 68, 0.28);
|
|
||||||
margin: 0 18px 18px;
|
|
||||||
}
|
|
||||||
@media (max-width: 1400px) {
|
|
||||||
.list {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.list {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.list {
|
.list {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ const groups = ref([
|
|||||||
{ id: 'gA', name: 'A', itemIds: [] },
|
{ id: 'gA', name: 'A', itemIds: [] },
|
||||||
{ id: 'gB', name: 'B', itemIds: [] },
|
{ id: 'gB', name: 'B', itemIds: [] },
|
||||||
{ id: 'gC', name: 'C', itemIds: [] },
|
{ id: 'gC', name: 'C', itemIds: [] },
|
||||||
{ id: 'gD', name: 'D', itemIds: [] },
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const pool = ref([])
|
const pool = ref([])
|
||||||
@@ -43,11 +42,16 @@ const isExporting = ref(false)
|
|||||||
const isSaveModalOpen = ref(false)
|
const isSaveModalOpen = ref(false)
|
||||||
const isTemplateRequestModalOpen = ref(false)
|
const isTemplateRequestModalOpen = ref(false)
|
||||||
const isTemplateUpdateModalOpen = ref(false)
|
const isTemplateUpdateModalOpen = ref(false)
|
||||||
|
const templateRequestDraftTitle = ref('')
|
||||||
|
const templateRequestDraftDescription = ref('')
|
||||||
const isDeleteModalOpen = ref(false)
|
const isDeleteModalOpen = ref(false)
|
||||||
const ownerId = ref('')
|
const ownerId = ref('')
|
||||||
const authorName = ref('')
|
const authorName = ref('')
|
||||||
const authorAccountName = ref('')
|
const authorAccountName = ref('')
|
||||||
const updatedAt = ref(0)
|
const updatedAt = ref(0)
|
||||||
|
const sourceTierListId = ref('')
|
||||||
|
const sourceSnapshotTitle = ref('')
|
||||||
|
const sourceSnapshotAuthor = ref('')
|
||||||
const isDragActive = ref(false)
|
const isDragActive = ref(false)
|
||||||
const isThumbnailDragActive = ref(false)
|
const isThumbnailDragActive = ref(false)
|
||||||
const iconSize = ref(80)
|
const iconSize = ref(80)
|
||||||
@@ -94,6 +98,14 @@ const untitledWarning = computed(
|
|||||||
'제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.'
|
'제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.'
|
||||||
)
|
)
|
||||||
const canFavorite = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
|
const canFavorite = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
|
||||||
|
const canDuplicate = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
|
||||||
|
const copiedFromLabel = computed(() => {
|
||||||
|
if (!sourceTierListId.value) return ''
|
||||||
|
const parts = []
|
||||||
|
if (sourceSnapshotTitle.value) parts.push(`원본 ${sourceSnapshotTitle.value}`)
|
||||||
|
if (sourceSnapshotAuthor.value) parts.push(sourceSnapshotAuthor.value)
|
||||||
|
return parts.join(' · ') || '복사해 온 티어표'
|
||||||
|
})
|
||||||
const customItems = computed(() =>
|
const customItems = computed(() =>
|
||||||
Object.values(itemsById.value)
|
Object.values(itemsById.value)
|
||||||
.filter((item) => item?.origin === 'custom')
|
.filter((item) => item?.origin === 'custom')
|
||||||
@@ -112,7 +124,8 @@ const templateRequestChecks = computed(() => [
|
|||||||
passed: !!(title.value || '').trim() && (title.value || '').trim() !== (gameName.value || '').trim(),
|
passed: !!(title.value || '').trim() && (title.value || '').trim() !== (gameName.value || '').trim(),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
const canSubmitTemplateCreateRequest = computed(() => templateRequestChecks.value.every((item) => item.passed))
|
const canSubmitTemplateCreateRequest = computed(() => templateRequestChecks.value.every((item) => item.passed) && !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||||
|
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||||
const templateRequestTargetLabel = computed(() => (gameId.value === 'freeform' ? '새로운 템플릿' : (gameName.value || gameId.value || '선택한 게임')))
|
const templateRequestTargetLabel = computed(() => (gameId.value === 'freeform' ? '새로운 템플릿' : (gameName.value || gameId.value || '선택한 게임')))
|
||||||
|
|
||||||
watch(error, (message) => {
|
watch(error, (message) => {
|
||||||
@@ -471,6 +484,9 @@ function buildPayload(existingId) {
|
|||||||
description: (description.value || '').trim(),
|
description: (description.value || '').trim(),
|
||||||
isPublic: !!isPublic.value,
|
isPublic: !!isPublic.value,
|
||||||
showCharacterNames: !!showCharacterNames.value,
|
showCharacterNames: !!showCharacterNames.value,
|
||||||
|
sourceTierListId: sourceTierListId.value || '',
|
||||||
|
sourceSnapshotTitle: sourceSnapshotTitle.value || '',
|
||||||
|
sourceSnapshotAuthor: sourceSnapshotAuthor.value || '',
|
||||||
groups: groups.value.map((g) => ({ id: g.id, name: g.name, itemIds: g.itemIds })),
|
groups: groups.value.map((g) => ({ id: g.id, name: g.name, itemIds: g.itemIds })),
|
||||||
pool: Object.values(itemsById.value),
|
pool: Object.values(itemsById.value),
|
||||||
}
|
}
|
||||||
@@ -510,20 +526,29 @@ function closeSaveModal() {
|
|||||||
isSaveModalOpen.value = false
|
isSaveModalOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetTemplateRequestDrafts() {
|
||||||
|
templateRequestDraftTitle.value = ''
|
||||||
|
templateRequestDraftDescription.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
function openTemplateRequestModal() {
|
function openTemplateRequestModal() {
|
||||||
|
resetTemplateRequestDrafts()
|
||||||
isTemplateRequestModalOpen.value = true
|
isTemplateRequestModalOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeTemplateRequestModal() {
|
function closeTemplateRequestModal() {
|
||||||
isTemplateRequestModalOpen.value = false
|
isTemplateRequestModalOpen.value = false
|
||||||
|
resetTemplateRequestDrafts()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTemplateUpdateModal() {
|
function openTemplateUpdateModal() {
|
||||||
|
resetTemplateRequestDrafts()
|
||||||
isTemplateUpdateModalOpen.value = true
|
isTemplateUpdateModalOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeTemplateUpdateModal() {
|
function closeTemplateUpdateModal() {
|
||||||
isTemplateUpdateModalOpen.value = false
|
isTemplateUpdateModalOpen.value = false
|
||||||
|
resetTemplateRequestDrafts()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDeleteModal() {
|
function openDeleteModal() {
|
||||||
@@ -550,6 +575,19 @@ async function confirmDeleteTierList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function duplicateCurrentTierList() {
|
||||||
|
if (!canDuplicate.value) return
|
||||||
|
try {
|
||||||
|
const data = await api.duplicateTierList(tierListId.value)
|
||||||
|
const duplicatedId = data.tierList?.id
|
||||||
|
if (!duplicatedId) throw new Error('duplicate_failed')
|
||||||
|
toast.success('티어표를 복사해 내 작업으로 가져왔어요.')
|
||||||
|
router.push(`/editor/${gameId.value}/${duplicatedId}`)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '티어표 복사에 실패했어요.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleFavorite() {
|
async function toggleFavorite() {
|
||||||
if (!canFavorite.value || isFavoriteBusy.value) return
|
if (!canFavorite.value || isFavoriteBusy.value) return
|
||||||
try {
|
try {
|
||||||
@@ -574,7 +612,11 @@ async function requestTemplate(type) {
|
|||||||
try {
|
try {
|
||||||
isRequestingTemplate.value = true
|
isRequestingTemplate.value = true
|
||||||
const persisted = await persistTierList({ showModal: false })
|
const persisted = await persistTierList({ showModal: false })
|
||||||
await api.requestTierListTemplate(persisted.savedTierListId, { type })
|
await api.requestTierListTemplate(persisted.savedTierListId, {
|
||||||
|
type,
|
||||||
|
requestTitle: templateRequestDraftTitle.value.trim(),
|
||||||
|
requestDescription: templateRequestDraftDescription.value.trim(),
|
||||||
|
})
|
||||||
if (type === 'create') closeTemplateRequestModal()
|
if (type === 'create') closeTemplateRequestModal()
|
||||||
if (type === 'update') closeTemplateUpdateModal()
|
if (type === 'update') closeTemplateUpdateModal()
|
||||||
toast.success(type === 'create' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
|
toast.success(type === 'create' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
|
||||||
@@ -638,6 +680,9 @@ onMounted(() => {
|
|||||||
authorName.value = t.authorName || ''
|
authorName.value = t.authorName || ''
|
||||||
authorAccountName.value = t.authorAccountName || ''
|
authorAccountName.value = t.authorAccountName || ''
|
||||||
updatedAt.value = Number(t.updatedAt || 0)
|
updatedAt.value = Number(t.updatedAt || 0)
|
||||||
|
sourceTierListId.value = t.sourceTierListId || ''
|
||||||
|
sourceSnapshotTitle.value = t.sourceSnapshotTitle || ''
|
||||||
|
sourceSnapshotAuthor.value = t.sourceSnapshotAuthor || ''
|
||||||
favoriteCount.value = Number(t.favoriteCount || 0)
|
favoriteCount.value = Number(t.favoriteCount || 0)
|
||||||
isFavorited.value = !!t.isFavorited
|
isFavorited.value = !!t.isFavorited
|
||||||
groups.value = t.groups
|
groups.value = t.groups
|
||||||
@@ -684,7 +729,7 @@ onUnmounted(() => {
|
|||||||
<div v-if="pool.length" class="previewOnly__pool">
|
<div v-if="pool.length" class="previewOnly__pool">
|
||||||
<div class="previewOnly__poolTitle">남은 아이템</div>
|
<div class="previewOnly__poolTitle">남은 아이템</div>
|
||||||
<div class="previewOnly__poolGrid">
|
<div class="previewOnly__poolGrid">
|
||||||
<div v-for="id in pool" :key="id" class="previewOnly__poolItem">
|
<div v-for="id in pool" :key="id" class="previewOnly__poolItem previewOnly__poolItem--inactive">
|
||||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -722,7 +767,18 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="requestChecklist__hint">
|
<div class="requestChecklist__hint">
|
||||||
제목만 명확하게 적어두면 관리자가 어떤 게임 템플릿 요청인지 빠르게 파악할 수 있어요. 여러 사용자가 비슷한 주제로 요청할 수 있으니 게임 이름을 구체적으로 적어주세요.
|
제목과 설명을 함께 적어두면 관리자가 어떤 신규 템플릿인지 훨씬 빠르게 파악할 수 있어요.
|
||||||
|
예시: 제목 `템플릿 등록 요청`, 설명 `여름 이벤트 한정 캐릭터 중심으로 새 게임 템플릿이 필요합니다.`
|
||||||
|
</div>
|
||||||
|
<div class="templateRequestDraft">
|
||||||
|
<label class="templateRequestDraft__field">
|
||||||
|
<span class="templateRequestDraft__label">요청 제목</span>
|
||||||
|
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 등록 요청" />
|
||||||
|
</label>
|
||||||
|
<label class="templateRequestDraft__field">
|
||||||
|
<span class="templateRequestDraft__label">요청 설명</span>
|
||||||
|
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="modalCard__actions">
|
<div class="modalCard__actions">
|
||||||
<button class="btn btn--ghost" @click="closeTemplateRequestModal">취소</button>
|
<button class="btn btn--ghost" @click="closeTemplateRequestModal">취소</button>
|
||||||
@@ -741,10 +797,21 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="modalCard__note">
|
<div class="modalCard__note">
|
||||||
모두가 사용하는 기본 템플릿이니 개인적인 항목이 아닌 공통된 항목만 추가한 뒤 신청해주세요.
|
모두가 사용하는 기본 템플릿이니 개인적인 항목이 아닌 공통된 항목만 추가한 뒤 신청해주세요.
|
||||||
|
예시: 제목 `템플릿 업데이트 요청`, 설명 `여름 이벤트 한정 캐릭터 추가`
|
||||||
|
</div>
|
||||||
|
<div class="templateRequestDraft">
|
||||||
|
<label class="templateRequestDraft__field">
|
||||||
|
<span class="templateRequestDraft__label">요청 제목</span>
|
||||||
|
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 업데이트 요청" />
|
||||||
|
</label>
|
||||||
|
<label class="templateRequestDraft__field">
|
||||||
|
<span class="templateRequestDraft__label">요청 설명</span>
|
||||||
|
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가" />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="modalCard__actions">
|
<div class="modalCard__actions">
|
||||||
<button class="btn btn--ghost" @click="closeTemplateUpdateModal">요청 취소</button>
|
<button class="btn btn--ghost" @click="closeTemplateUpdateModal">요청 취소</button>
|
||||||
<button class="btn btn--save" :disabled="isRequestingTemplate" @click="requestTemplate('update')">
|
<button class="btn btn--save" :disabled="!canSubmitTemplateUpdateRequest || isRequestingTemplate" @click="requestTemplate('update')">
|
||||||
{{ isRequestingTemplate ? '요청중...' : '예, 요청할게요' }}
|
{{ isRequestingTemplate ? '요청중...' : '예, 요청할게요' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -779,6 +846,10 @@ onUnmounted(() => {
|
|||||||
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 수 있어요.
|
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 수 있어요.
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="sourceTierListId" class="editorMain__sourceNote">
|
||||||
|
<span>복사본</span>
|
||||||
|
<button class="editorMain__sourceLink" type="button" @click="router.push(`/editor/${gameId}/${sourceTierListId}`)">{{ copiedFromLabel }}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -874,9 +945,10 @@ onUnmounted(() => {
|
|||||||
{{ canEdit ? '등록된 아이템 리스트입니다. 드래그해서 표에 넣을 수 있습니다.' : '공개 티어표는 보기 전용입니다.' }}
|
{{ canEdit ? '등록된 아이템 리스트입니다. 드래그해서 표에 넣을 수 있습니다.' : '공개 티어표는 보기 전용입니다.' }}
|
||||||
</div>
|
</div>
|
||||||
<div ref="poolEl" class="pool" data-list-type="pool">
|
<div ref="poolEl" class="pool" data-list-type="pool">
|
||||||
<div v-for="id in pool" :key="id" class="poolItem" :data-item-id="id">
|
<div v-for="id in pool" :key="id" class="poolItem" :class="{ 'poolItem--readonly': !canEdit }" :data-item-id="id">
|
||||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||||
|
<div v-if="!canEdit" class="poolItem__state">미배치</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -952,13 +1024,15 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editorSidebar__section editorSidebar__section--footer">
|
<div class="editorSidebar__section editorSidebar__section--footer">
|
||||||
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
|
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': !canEdit }">
|
||||||
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
|
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
|
||||||
<span>공개</span>
|
<span class="toggleSwitch__label">공개</span>
|
||||||
|
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
||||||
</label>
|
</label>
|
||||||
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
|
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': !canEdit }">
|
||||||
<input v-model="showCharacterNames" type="checkbox" :disabled="!canEdit" />
|
<input v-model="showCharacterNames" type="checkbox" :disabled="!canEdit" />
|
||||||
<span>캐릭터 이름 표시</span>
|
<span class="toggleSwitch__label">캐릭터 이름 표시</span>
|
||||||
|
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
||||||
</label>
|
</label>
|
||||||
<div class="editorSidebar__actionGrid">
|
<div class="editorSidebar__actionGrid">
|
||||||
<button class="btn btn--ghost editorSidebar__button" @click="downloadImage">이미지 다운로드</button>
|
<button class="btn btn--ghost editorSidebar__button" @click="downloadImage">이미지 다운로드</button>
|
||||||
@@ -966,6 +1040,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="editorSidebar__utilityLinks">
|
<div class="editorSidebar__utilityLinks">
|
||||||
<button v-if="canEdit && !isNewTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--danger" @click="openDeleteModal">삭제하기</button>
|
<button v-if="canEdit && !isNewTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--danger" @click="openDeleteModal">삭제하기</button>
|
||||||
|
<button v-if="canDuplicate" class="editorSidebar__utilityLink" @click="duplicateCurrentTierList">복사해서 내 티어표로 가져오기</button>
|
||||||
<button
|
<button
|
||||||
v-if="canRequestTemplateCreate"
|
v-if="canRequestTemplateCreate"
|
||||||
class="editorSidebar__utilityLink"
|
class="editorSidebar__utilityLink"
|
||||||
@@ -1016,6 +1091,23 @@ onUnmounted(() => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
.editorMain__sourceNote {
|
||||||
|
margin-top: 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.62);
|
||||||
|
}
|
||||||
|
.editorMain__sourceLink {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(191, 219, 254, 0.94);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.previewOnly {
|
.previewOnly {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@@ -1094,23 +1186,60 @@ onUnmounted(() => {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.toggle {
|
.previewOnly__poolItem--inactive {
|
||||||
|
opacity: 0.52;
|
||||||
|
filter: grayscale(0.22) brightness(0.78);
|
||||||
|
}
|
||||||
|
.toggleSwitch {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 10px;
|
justify-content: space-between;
|
||||||
border-radius: 12px;
|
gap: 12px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
padding: 10px 12px;
|
||||||
background: rgba(0, 0, 0, 0.12);
|
border-radius: 14px;
|
||||||
font-weight: 800;
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.toggle input {
|
.toggleSwitch input {
|
||||||
width: 16px;
|
position: absolute;
|
||||||
height: 16px;
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.toggle--disabled {
|
.toggleSwitch__track {
|
||||||
|
position: relative;
|
||||||
|
width: 42px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
transition: background 180ms ease, border-color 180ms ease;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.toggleSwitch__thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.24);
|
||||||
|
transition: transform 180ms ease;
|
||||||
|
}
|
||||||
|
.toggleSwitch__label {
|
||||||
|
font-weight: 800;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
.toggleSwitch input:checked ~ .toggleSwitch__track {
|
||||||
|
background: rgba(96, 165, 250, 0.34);
|
||||||
|
border-color: rgba(96, 165, 250, 0.42);
|
||||||
|
}
|
||||||
|
.toggleSwitch input:checked ~ .toggleSwitch__track .toggleSwitch__thumb {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
.toggleSwitch--disabled {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -1253,6 +1382,42 @@ onUnmounted(() => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
opacity: 0.78;
|
opacity: 0.78;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
.templateRequestDraft {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.templateRequestDraft__field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.templateRequestDraft__label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.64);
|
||||||
|
}
|
||||||
|
.templateRequestDraft__input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 0;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.94);
|
||||||
|
outline: none;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.5;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
.templateRequestDraft__input:focus {
|
||||||
|
border-bottom-color: rgba(96, 165, 250, 0.9);
|
||||||
|
}
|
||||||
|
.templateRequestDraft__input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.34);
|
||||||
|
}
|
||||||
|
.templateRequestDraft__textarea {
|
||||||
|
min-height: 92px;
|
||||||
|
resize: vertical;
|
||||||
}
|
}
|
||||||
.boardTools {
|
.boardTools {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1737,6 +1902,10 @@ onUnmounted(() => {
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||||
background: rgba(0, 0, 0, 0.18);
|
background: rgba(0, 0, 0, 0.18);
|
||||||
}
|
}
|
||||||
|
.poolItem--readonly {
|
||||||
|
opacity: 0.58;
|
||||||
|
filter: grayscale(0.25) brightness(0.78);
|
||||||
|
}
|
||||||
.poolItem .thumb {
|
.poolItem .thumb {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: var(--thumb-size, 80px);
|
max-width: var(--thumb-size, 80px);
|
||||||
@@ -1755,6 +1924,13 @@ onUnmounted(() => {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.poolItem__state {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255, 255, 255, 0.58);
|
||||||
|
}
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
"dev:backend": "npm --prefix backend run dev",
|
"dev:backend": "npm --prefix backend run dev",
|
||||||
"build": "npm --prefix frontend run build",
|
"build": "npm --prefix frontend run build",
|
||||||
"start": "npm --prefix backend run start",
|
"start": "npm --prefix backend run start",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"images:backfill": "npm --prefix backend run images:backfill",
|
||||||
|
"images:migrate-legacy": "npm --prefix backend run images:migrate-legacy"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user