feat(deps): add sharp image processing library
This commit is contained in:
Generated
+537
-2
@@ -12,7 +12,8 @@
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"kysely": "^0.29.2",
|
||||
"pg": "^8.21.0",
|
||||
"postgres": "^3.4.5"
|
||||
"postgres": "^3.4.5",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.15.0",
|
||||
@@ -175,6 +176,16 @@
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild-kit/core-utils": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz",
|
||||
@@ -1276,6 +1287,471 @@
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"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/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -2674,6 +3150,15 @@
|
||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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/devalue": {
|
||||
"version": "5.8.1",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
|
||||
@@ -4248,7 +4733,6 @@
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
|
||||
"integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -4263,6 +4747,50 @@
|
||||
"integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -4559,6 +5087,13 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"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/tsx": {
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||
|
||||
+2
-1
@@ -43,6 +43,7 @@
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"kysely": "^0.29.2",
|
||||
"pg": "^8.21.0",
|
||||
"postgres": "^3.4.5"
|
||||
"postgres": "^3.4.5",
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import sharp from 'sharp';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
// ─── Bunny CDN Configuration ─────────────────────────────────────────────────
|
||||
|
||||
function getConfig() {
|
||||
const baseUrl = env.CDN_BASE_URL;
|
||||
const storageEndpoint = env.CDN_STORAGE_ENDPOINT;
|
||||
const accessKey = env.CDN_ACCESS_KEY;
|
||||
const bucket = env.CDN_BUCKET;
|
||||
|
||||
if (!baseUrl || !storageEndpoint || !accessKey || !bucket) {
|
||||
throw new Error(
|
||||
'CDN is not configured. Set CDN_BASE_URL, CDN_STORAGE_ENDPOINT, CDN_ACCESS_KEY, and CDN_BUCKET in your .env file.'
|
||||
);
|
||||
}
|
||||
|
||||
return { baseUrl, storageEndpoint, accessKey, bucket };
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CdnUploadResult {
|
||||
cdnKey: string;
|
||||
cdnUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
// ─── Key Generation ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a CDN storage key for a new upload.
|
||||
* Format: sites/{siteSlug}/{type}/{uuid}.webp
|
||||
*/
|
||||
export function generateCdnKey(siteSlug: string, type: string): string {
|
||||
const id = randomUUID();
|
||||
return `sites/${siteSlug}/${type}/${id}.webp`;
|
||||
}
|
||||
|
||||
// ─── Public URL ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the public CDN URL for a stored file.
|
||||
*/
|
||||
export function getCdnUrl(cdnKey: string): string {
|
||||
const { baseUrl } = getConfig();
|
||||
const normalizedBase = baseUrl.replace(/\/+$/, '');
|
||||
const normalizedKey = cdnKey.replace(/^\/+/, '');
|
||||
return `${normalizedBase}/${normalizedKey}`;
|
||||
}
|
||||
|
||||
// ─── WebP Conversion ──────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
|
||||
|
||||
/**
|
||||
* Validate and convert an uploaded image buffer to webp.
|
||||
* Returns the webp buffer and image metadata.
|
||||
*/
|
||||
export async function convertToWebP(
|
||||
buffer: Buffer,
|
||||
originalMimeType: string
|
||||
): Promise<{ webpBuffer: Buffer; width: number; height: number }> {
|
||||
if (!ALLOWED_TYPES.includes(originalMimeType)) {
|
||||
throw new Error(
|
||||
`Unsupported file type: ${originalMimeType}. Allowed: PNG, JPEG, WebP.`
|
||||
);
|
||||
}
|
||||
|
||||
if (buffer.length > MAX_SIZE) {
|
||||
const sizeMB = (buffer.length / (1024 * 1024)).toFixed(1);
|
||||
throw new Error(`File too large: ${sizeMB}MB. Maximum size is 5MB.`);
|
||||
}
|
||||
|
||||
const image = sharp(buffer);
|
||||
const metadata = await image.metadata();
|
||||
|
||||
if (!metadata.width || !metadata.height) {
|
||||
throw new Error('Could not read image dimensions.');
|
||||
}
|
||||
|
||||
const webpBuffer = await image.webp({ quality: 82 }).toBuffer();
|
||||
|
||||
return {
|
||||
webpBuffer,
|
||||
width: metadata.width,
|
||||
height: metadata.height
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Upload ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upload a buffer to Bunny CDN storage.
|
||||
* Returns the CDN key and public URL.
|
||||
*/
|
||||
export async function uploadToCdn(
|
||||
buffer: Buffer,
|
||||
siteSlug: string,
|
||||
type: string,
|
||||
originalMimeType: string
|
||||
): Promise<CdnUploadResult> {
|
||||
const { storageEndpoint, accessKey, bucket } = getConfig();
|
||||
|
||||
// Convert to webp
|
||||
const { webpBuffer, width, height } = await convertToWebP(buffer, originalMimeType);
|
||||
|
||||
// Generate key and upload
|
||||
const cdnKey = generateCdnKey(siteSlug, type);
|
||||
const normalizedEndpoint = storageEndpoint.replace(/\/+$/, '');
|
||||
const normalizedBucket = bucket.replace(/^\/+/, '');
|
||||
const uploadUrl = `${normalizedEndpoint}/${normalizedBucket}/${cdnKey}`;
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
AccessKey: accessKey,
|
||||
'Content-Type': 'image/webp'
|
||||
},
|
||||
body: new Uint8Array(webpBuffer)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`CDN upload failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const cdnUrl = getCdnUrl(cdnKey);
|
||||
|
||||
return {
|
||||
cdnKey,
|
||||
cdnUrl,
|
||||
width,
|
||||
height,
|
||||
size: webpBuffer.length,
|
||||
mimeType: 'image/webp'
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Delete ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Delete a file from Bunny CDN storage.
|
||||
*/
|
||||
export async function deleteFromCdn(cdnKey: string): Promise<void> {
|
||||
const { storageEndpoint, accessKey, bucket } = getConfig();
|
||||
|
||||
const normalizedEndpoint = storageEndpoint.replace(/\/+$/, '');
|
||||
const normalizedBucket = bucket.replace(/^\/+/, '');
|
||||
const deleteUrl = `${normalizedEndpoint}/${normalizedBucket}/${cdnKey}`;
|
||||
|
||||
const response = await fetch(deleteUrl, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
AccessKey: accessKey
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`CDN delete failed (${response.status}): ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sanity Check ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify CDN connectivity at startup (optional).
|
||||
* Returns true if the CDN endpoint is reachable.
|
||||
*/
|
||||
export async function checkCdnConnectivity(): Promise<boolean> {
|
||||
try {
|
||||
const { storageEndpoint, accessKey } = getConfig();
|
||||
// Simple HEAD or GET to the storage root to verify credentials
|
||||
const response = await fetch(storageEndpoint.replace(/\/+$/, ''), {
|
||||
method: 'GET',
|
||||
headers: { AccessKey: accessKey }
|
||||
});
|
||||
return response.ok || response.status === 404; // 404 means endpoint reachable, just no file
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,58 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
let { data, children }: { data: LayoutData; children: Snippet } = $props();
|
||||
|
||||
/**
|
||||
* Compute the relative luminance of a hex color (WCAG).
|
||||
* Used to determine whether the background is dark or light
|
||||
* so we can pick an appropriate secondary text color.
|
||||
*/
|
||||
function hexLuminance(hex: string): number {
|
||||
const h = hex.replace('#', '');
|
||||
const r = parseInt(h.substring(0, 2), 16) / 255;
|
||||
const g = parseInt(h.substring(2, 4), 16) / 255;
|
||||
const b = parseInt(h.substring(4, 6), 16) / 255;
|
||||
const toLinear = (c: number) =>
|
||||
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
||||
}
|
||||
|
||||
// ── Theme values (reactive via $derived — updates if data.siteSettings changes) ──
|
||||
let accentColor = $derived(data.siteSettings?.theme?.accentColor || '#e63946');
|
||||
let backgroundColor = $derived(data.siteSettings?.theme?.backgroundColor || '#1a1a2e');
|
||||
let textColor = $derived(data.siteSettings?.theme?.textColor || '#eaeaea');
|
||||
|
||||
// Secondary text: muted version based on background luminance
|
||||
let textSecondary = $derived(
|
||||
hexLuminance(backgroundColor) < 0.5 ? '#b0b0b0' : '#555555'
|
||||
);
|
||||
|
||||
let cssVars = $derived(
|
||||
[
|
||||
`--color-accent: ${accentColor}`,
|
||||
`--color-background: ${backgroundColor}`,
|
||||
`--color-text: ${textColor}`,
|
||||
`--color-text-secondary: ${textSecondary}`,
|
||||
`--font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif`
|
||||
].join('; ')
|
||||
);
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
<div class="site-root" style={cssVars}>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.site-root {
|
||||
min-height: 100vh;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
/**
|
||||
* Homepage server load — flattens site settings into simple props.
|
||||
*
|
||||
* All data originates from the root layout server (+layout.server.ts).
|
||||
* This loader calls event.parent() to access that cached data so we
|
||||
* make zero additional database queries here.
|
||||
*
|
||||
* Fallback chain for each prop ensures the page always has sensible
|
||||
* values, even when settings have never been configured by the owner.
|
||||
*/
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const parent = await event.parent();
|
||||
const { site, siteSettings, user, membership } = parent;
|
||||
|
||||
const homepage = siteSettings?.homepage;
|
||||
const branding = siteSettings?.branding;
|
||||
|
||||
// heroTitle: homepage.heroTitle → branding.siteName → site.name → 'My Site'
|
||||
const heroTitle = homepage?.heroTitle || branding?.siteName || site?.name || 'My Site';
|
||||
|
||||
// heroSubtitle: homepage.heroSubtitle → branding.tagline → ''
|
||||
const heroSubtitle = homepage?.heroSubtitle || branding?.tagline || '';
|
||||
|
||||
// aboutText: homepage.aboutText → ''
|
||||
const aboutText = homepage?.aboutText || '';
|
||||
|
||||
// CTA
|
||||
const ctaText = homepage?.primaryButtonText || '';
|
||||
const ctaLink = homepage?.primaryButtonLink || '';
|
||||
|
||||
// Show flags (use ?? so explicit false is respected)
|
||||
const showNextEvent = homepage?.showNextEvent ?? false;
|
||||
const showSchedule = homepage?.showSchedule ?? false;
|
||||
|
||||
return {
|
||||
site,
|
||||
heroTitle,
|
||||
heroSubtitle,
|
||||
aboutText,
|
||||
ctaText,
|
||||
ctaLink,
|
||||
showNextEvent,
|
||||
showSchedule,
|
||||
user,
|
||||
membership
|
||||
};
|
||||
};
|
||||
+250
-17
@@ -2,25 +2,258 @@
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
/**
|
||||
* Determine if a link is external (starts with http:// or https://).
|
||||
* Internal links (starting with `/`) navigate normally.
|
||||
*/
|
||||
function isExternal(href: string): boolean {
|
||||
return /^https?:\/\//.test(href);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if data.site}
|
||||
<h1>{data.site.name}</h1>
|
||||
{/if}
|
||||
<svelte:head>
|
||||
<title>{data.heroTitle}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if data.siteSettings?.branding?.tagline}
|
||||
<p class="tagline">{data.siteSettings.branding.tagline}</p>
|
||||
{/if}
|
||||
|
||||
{#if data.user}
|
||||
<p>Welcome, {data.user.discordUsername}!</p>
|
||||
{#if data.membership}
|
||||
<p>Role: {data.membership.role}</p>
|
||||
<a href="/admin">Admin Panel</a>
|
||||
{:else}
|
||||
<p>You are logged in but not a member of this site.</p>
|
||||
{/if}
|
||||
<a href="/api/auth/sign-out">Sign Out</a>
|
||||
<!-- Error state: no site configured -->
|
||||
{#if !data.site}
|
||||
<main class="error-state">
|
||||
<h1>Site not configured</h1>
|
||||
<p>Check the <code>SITE_SLUG</code> environment variable and ensure a matching row exists in the sites table.</p>
|
||||
<p>Run <code>npm run db:seed</code> to create the default "local-dev" site.</p>
|
||||
</main>
|
||||
{:else}
|
||||
<p>Your site is running. <a href="/login">Login with Discord</a> to manage it.</p>
|
||||
<!-- ═══════════════════════════════════════════ -->
|
||||
<!-- HERO SECTION -->
|
||||
<!-- ═══════════════════════════════════════════ -->
|
||||
<section class="hero">
|
||||
<div class="hero-content">
|
||||
<h1>{data.heroTitle}</h1>
|
||||
|
||||
{#if data.heroSubtitle}
|
||||
<p class="hero-subtitle">{data.heroSubtitle}</p>
|
||||
{/if}
|
||||
|
||||
{#if data.ctaText && data.ctaLink}
|
||||
{@const external = isExternal(data.ctaLink)}
|
||||
<a
|
||||
class="cta-button"
|
||||
href={data.ctaLink}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{data.ctaText}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════════ -->
|
||||
<!-- ABOUT SECTION (only if aboutText is set) -->
|
||||
<!-- ═══════════════════════════════════════════ -->
|
||||
{#if data.aboutText}
|
||||
<section class="about">
|
||||
<div class="about-content">
|
||||
<h2>About</h2>
|
||||
<p>{data.aboutText}</p>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ═══════════════════════════════════════════ -->
|
||||
<!-- FOOTER -->
|
||||
<!-- ═══════════════════════════════════════════ -->
|
||||
<footer class="footer">
|
||||
<div class="footer-content">
|
||||
<span class="footer-site-name">{data.site.name}</span>
|
||||
<span class="footer-powered">Powered by The Collective Hub</span>
|
||||
{#if data.user && data.membership}
|
||||
<a class="footer-admin-link" href="/admin">Manage Site</a>
|
||||
{/if}
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* ── Error State ───────────────────────────── */
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.error-state h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.error-state p {
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 60ch;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-state code {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* ── Hero Section ──────────────────────────── */
|
||||
.hero {
|
||||
min-height: 60vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-background) 0%,
|
||||
color-mix(in srgb, var(--color-background) 85%, black) 100%
|
||||
);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
text-align: center;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.hero-content h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 0.75rem 0;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 2rem 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
padding: 0.85rem 2rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.cta-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ── About Section ─────────────────────────── */
|
||||
.about {
|
||||
padding: 4rem 2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.about-content {
|
||||
max-width: 65ch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.about-content h2 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.about-content p {
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
/* ── Footer ────────────────────────────────── */
|
||||
.footer {
|
||||
border-top: 1px solid rgba(176, 176, 176, 0.2);
|
||||
padding: 1.5rem 2rem;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.footer-site-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footer-powered {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.footer-admin-link {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.15s ease;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.footer-admin-link:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Responsive ────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.hero-content h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
font-size: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
.about {
|
||||
padding: 2.5rem 1.25rem;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.footer-admin-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { assets } from '$lib/server/db/schema';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { getCdnUrl, deleteFromCdn } from '$lib/server/cdn';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
/**
|
||||
* Load all assets for the current site, newest first.
|
||||
* Computes the public CDN URL for each asset.
|
||||
*/
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const { site } = event.locals;
|
||||
|
||||
if (!site) {
|
||||
return { assetList: [] };
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(assets)
|
||||
.where(eq(assets.siteId, site.id))
|
||||
.orderBy(desc(assets.createdAt))
|
||||
.limit(100);
|
||||
|
||||
const assetList = rows.map((a) => ({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
cdnKey: a.cdnKey,
|
||||
cdnUrl: getCdnUrl(a.cdnKey),
|
||||
createdAt: a.createdAt
|
||||
}));
|
||||
|
||||
return { assetList };
|
||||
};
|
||||
|
||||
/**
|
||||
* Form action: delete an asset by id.
|
||||
*/
|
||||
export const actions: Actions = {
|
||||
delete: async (event) => {
|
||||
const { site } = event.locals;
|
||||
|
||||
if (!site) {
|
||||
return { success: false, error: 'No site context.' };
|
||||
}
|
||||
|
||||
const formData = await event.request.formData();
|
||||
const assetId = formData.get('assetId')?.toString();
|
||||
|
||||
if (!assetId) {
|
||||
return { success: false, error: 'Missing asset ID.' };
|
||||
}
|
||||
|
||||
// Find the asset (scoped to site)
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(assets)
|
||||
.where(and(eq(assets.id, assetId), eq(assets.siteId, site.id)))
|
||||
.limit(1);
|
||||
|
||||
if (!record) {
|
||||
return { success: false, error: 'Asset not found.' };
|
||||
}
|
||||
|
||||
// Delete from CDN (soft-fail: log but don't block)
|
||||
try {
|
||||
await deleteFromCdn(record.cdnKey);
|
||||
} catch (err) {
|
||||
console.error('CDN delete failed for', record.cdnKey, err);
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
await db.delete(assets).where(eq(assets.id, assetId));
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,439 @@
|
||||
<script lang="ts">
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
/** Currently uploading? */
|
||||
let uploading = $state(false);
|
||||
/** Feedback message */
|
||||
let feedback = $state<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||
/** ID of the asset being deleted (for confirmation) */
|
||||
let deletingId = $state<string | null>(null);
|
||||
/** Which asset's URL was just copied */
|
||||
let copiedId = $state<string | null>(null);
|
||||
|
||||
// Handle form action feedback
|
||||
$effect(() => {
|
||||
if (form) {
|
||||
if (form.success) {
|
||||
feedback = { type: 'success', message: 'Asset deleted.' };
|
||||
} else if (form.error) {
|
||||
feedback = { type: 'error', message: form.error };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** Handle file upload via the API endpoint */
|
||||
async function handleUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
uploading = true;
|
||||
feedback = null;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const res = await fetch('/api/assets', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: 'Upload failed.' }));
|
||||
feedback = { type: 'error', message: err.message ?? 'Upload failed.' };
|
||||
} else {
|
||||
feedback = { type: 'success', message: `"${file.name}" uploaded.` };
|
||||
// Reload to show the new asset
|
||||
window.location.reload();
|
||||
}
|
||||
} catch {
|
||||
feedback = { type: 'error', message: 'Network error during upload.' };
|
||||
} finally {
|
||||
uploading = false;
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy CDN URL to clipboard */
|
||||
async function copyUrl(url: string, id: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
copiedId = id;
|
||||
setTimeout(() => (copiedId = null), 2000);
|
||||
} catch {
|
||||
feedback = { type: 'error', message: 'Failed to copy URL.' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Format file size for display */
|
||||
function formatSize(bytes: number | null): string {
|
||||
if (bytes === null || bytes === undefined) return '—';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Format date for display */
|
||||
function formatDate(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Assets — {data.site?.name ?? 'Admin'}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="assets-page">
|
||||
<h1 class="page-title">Assets</h1>
|
||||
<p class="page-desc">Upload and manage images for your site. All uploads are converted to WebP.</p>
|
||||
|
||||
<!-- Feedback -->
|
||||
{#if feedback}
|
||||
<div
|
||||
class="feedback"
|
||||
class:feedback--success={feedback.type === 'success'}
|
||||
class:feedback--error={feedback.type === 'error'}
|
||||
role="alert"
|
||||
>
|
||||
{feedback.message}
|
||||
<button class="feedback-close" onclick={() => (feedback = null)}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Upload Zone -->
|
||||
<div class="upload-zone" class:upload-zone--active={uploading}>
|
||||
<label class="upload-label">
|
||||
{#if uploading}
|
||||
<span class="spinner"></span>
|
||||
Uploading…
|
||||
{:else}
|
||||
<span class="upload-icon">+</span>
|
||||
<span>Click to upload an image</span>
|
||||
<span class="upload-hint">PNG, JPEG, or WebP — max 5MB</span>
|
||||
{/if}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
class="upload-input"
|
||||
onchange={handleUpload}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Asset Grid -->
|
||||
{#if data.assetList.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>No assets uploaded yet. Click above to add your first image.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="asset-grid">
|
||||
{#each data.assetList as asset}
|
||||
<div class="asset-card">
|
||||
<div class="asset-preview">
|
||||
<img
|
||||
src={asset.cdnUrl}
|
||||
alt={asset.filename}
|
||||
loading="lazy"
|
||||
width="200"
|
||||
height="150"
|
||||
/>
|
||||
</div>
|
||||
<div class="asset-info">
|
||||
<span class="asset-name" title={asset.filename}>{asset.filename}</span>
|
||||
<span class="asset-meta">{formatSize(asset.size)} · {formatDate(asset.createdAt)}</span>
|
||||
</div>
|
||||
<div class="asset-actions">
|
||||
<button
|
||||
class="action-btn action-btn--copy"
|
||||
onclick={() => copyUrl(asset.cdnUrl, asset.id)}
|
||||
title="Copy CDN URL"
|
||||
>
|
||||
{copiedId === asset.id ? '✓ Copied' : 'Copy URL'}
|
||||
</button>
|
||||
|
||||
{#if deletingId === asset.id}
|
||||
<span class="confirm-delete">
|
||||
Sure?
|
||||
<button
|
||||
class="action-btn action-btn--confirm"
|
||||
form="delete-form"
|
||||
name="assetId"
|
||||
value={asset.id}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
class="action-btn action-btn--cancel"
|
||||
onclick={() => (deletingId = null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</span>
|
||||
{:else}
|
||||
<button
|
||||
class="action-btn action-btn--delete"
|
||||
onclick={() => (deletingId = asset.id)}
|
||||
title="Delete asset"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Hidden form for delete action (so it works without JS too) -->
|
||||
<form method="POST" action="?/delete" use:enhance id="delete-form" class="hidden-form">
|
||||
<input type="hidden" name="assetId" value="" />
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.assets-page {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.25rem;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
color: #666;
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 0.925rem;
|
||||
}
|
||||
|
||||
/* ── Feedback ─────────────────────────────── */
|
||||
.feedback {
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.feedback--success {
|
||||
background: #daf5e0;
|
||||
color: #1a6b30;
|
||||
border: 1px solid #a3d9b1;
|
||||
}
|
||||
|
||||
.feedback--error {
|
||||
background: #fde8e8;
|
||||
color: #9b1c1c;
|
||||
border: 1px solid #f4b2b2;
|
||||
}
|
||||
|
||||
.feedback-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.feedback-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Upload Zone ──────────────────────────── */
|
||||
.upload-zone {
|
||||
border: 2px dashed #d0d0d6;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: #58a6ff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.upload-zone--active {
|
||||
border-color: #58a6ff;
|
||||
background: #f0f7ff;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.upload-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
color: #555;
|
||||
font-size: 0.925rem;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 2rem;
|
||||
color: #58a6ff;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.upload-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Spinner ───────────────────────────────── */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(88, 166, 255, 0.3);
|
||||
border-top-color: #58a6ff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Empty State ───────────────────────────── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* ── Asset Grid ────────────────────────────── */
|
||||
.asset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.asset-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e6;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.asset-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.asset-preview {
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
background: #f5f5f7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.asset-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.asset-info {
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.asset-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: #1a1a2e;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-meta {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.asset-actions {
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #d0d0d6;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-btn--copy {
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.action-btn--copy:hover {
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.action-btn--delete {
|
||||
color: #e5534b;
|
||||
border-color: #e5534b;
|
||||
}
|
||||
|
||||
.action-btn--delete:hover {
|
||||
background: #fde8e8;
|
||||
}
|
||||
|
||||
.action-btn--confirm {
|
||||
color: #fff;
|
||||
background: #e5534b;
|
||||
border-color: #e5534b;
|
||||
}
|
||||
|
||||
.action-btn--cancel {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.confirm-delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
color: #e5534b;
|
||||
}
|
||||
|
||||
/* Hidden form for progressive enhancement */
|
||||
.hidden-form {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { assets } from '$lib/server/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { uploadToCdn, deleteFromCdn } from '$lib/server/cdn';
|
||||
|
||||
/**
|
||||
* POST /api/assets — Upload a new image asset.
|
||||
*
|
||||
* Expects multipart/form-data with a "file" field.
|
||||
* Validates type (PNG/JPEG/WebP), max size (5MB), converts to webp,
|
||||
* uploads to Bunny CDN, and creates an asset record in the database.
|
||||
*
|
||||
* Returns JSON: { id, cdnKey, cdnUrl, filename, mimeType, width, height, size }
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { site, user, membership } = event.locals;
|
||||
|
||||
// Auth check: must be logged in with a membership
|
||||
if (!user || !membership) {
|
||||
error(401, 'You must be logged in as a site member to upload assets.');
|
||||
}
|
||||
|
||||
if (!site) {
|
||||
error(400, 'No site context. Check SITE_SLUG.');
|
||||
}
|
||||
|
||||
const formData = await event.request.formData();
|
||||
const file = formData.get('file');
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
error(400, 'No file provided. Send a file in the "file" field.');
|
||||
}
|
||||
|
||||
// Read file buffer
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const originalMimeType = file.type || 'application/octet-stream';
|
||||
|
||||
// Upload to CDN (validates, converts to webp)
|
||||
const result = await uploadToCdn(buffer, site.slug, 'uploads', originalMimeType);
|
||||
|
||||
// Create asset record in the database
|
||||
const [record] = await db
|
||||
.insert(assets)
|
||||
.values({
|
||||
siteId: site.id,
|
||||
uploadedByUserId: user.id,
|
||||
type: 'image',
|
||||
filename: file.name,
|
||||
mimeType: result.mimeType,
|
||||
size: result.size,
|
||||
cdnKey: result.cdnKey
|
||||
})
|
||||
.returning();
|
||||
|
||||
return json({
|
||||
id: record.id,
|
||||
cdnKey: record.cdnKey,
|
||||
cdnUrl: result.cdnUrl,
|
||||
filename: record.filename,
|
||||
mimeType: record.mimeType,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
size: record.size
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/assets?id=... — Delete an asset.
|
||||
*
|
||||
* Removes the file from CDN storage and deletes the database record.
|
||||
* Scoped to the current site for security.
|
||||
*/
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
const { site, user, membership } = event.locals;
|
||||
|
||||
if (!user || !membership) {
|
||||
error(401, 'You must be logged in as a site member to delete assets.');
|
||||
}
|
||||
|
||||
if (!site) {
|
||||
error(400, 'No site context.');
|
||||
}
|
||||
|
||||
const url = new URL(event.request.url);
|
||||
const assetId = url.searchParams.get('id');
|
||||
|
||||
if (!assetId) {
|
||||
error(400, 'Missing asset "id" query parameter.');
|
||||
}
|
||||
|
||||
// Find the asset record (scoped to the current site)
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(assets)
|
||||
.where(and(eq(assets.id, assetId), eq(assets.siteId, site.id)))
|
||||
.limit(1);
|
||||
|
||||
if (!record) {
|
||||
error(404, 'Asset not found.');
|
||||
}
|
||||
|
||||
// Delete from CDN
|
||||
await deleteFromCdn(record.cdnKey);
|
||||
|
||||
// Delete from database
|
||||
await db.delete(assets).where(eq(assets.id, assetId));
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
Reference in New Issue
Block a user