feat(deps): add sharp image processing library
This commit is contained in:
Generated
+537
-2
@@ -12,7 +12,8 @@
|
|||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"kysely": "^0.29.2",
|
"kysely": "^0.29.2",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
"postgres": "^3.4.5"
|
"postgres": "^3.4.5",
|
||||||
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.15.0",
|
"@eslint/js": "^9.15.0",
|
||||||
@@ -175,6 +176,16 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/@esbuild-kit/core-utils": {
|
||||||
"version": "3.3.2",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz",
|
"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"
|
"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": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@@ -2674,6 +3150,15 @@
|
|||||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/devalue": {
|
||||||
"version": "5.8.1",
|
"version": "5.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
|
||||||
@@ -4248,7 +4733,6 @@
|
|||||||
"version": "7.8.2",
|
"version": "7.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
|
||||||
"integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
|
"integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@@ -4263,6 +4747,50 @@
|
|||||||
"integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
|
"integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -4559,6 +5087,13 @@
|
|||||||
"typescript": ">=4.8.4"
|
"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": {
|
"node_modules/tsx": {
|
||||||
"version": "4.22.4",
|
"version": "4.22.4",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||||
|
|||||||
+2
-1
@@ -43,6 +43,7 @@
|
|||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"kysely": "^0.29.2",
|
"kysely": "^0.29.2",
|
||||||
"pg": "^8.21.0",
|
"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">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
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>
|
</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';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
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>
|
</script>
|
||||||
|
|
||||||
{#if data.site}
|
<svelte:head>
|
||||||
<h1>{data.site.name}</h1>
|
<title>{data.heroTitle}</title>
|
||||||
{/if}
|
</svelte:head>
|
||||||
|
|
||||||
{#if data.siteSettings?.branding?.tagline}
|
<!-- Error state: no site configured -->
|
||||||
<p class="tagline">{data.siteSettings.branding.tagline}</p>
|
{#if !data.site}
|
||||||
{/if}
|
<main class="error-state">
|
||||||
|
<h1>Site not configured</h1>
|
||||||
{#if data.user}
|
<p>Check the <code>SITE_SLUG</code> environment variable and ensure a matching row exists in the sites table.</p>
|
||||||
<p>Welcome, {data.user.discordUsername}!</p>
|
<p>Run <code>npm run db:seed</code> to create the default "local-dev" site.</p>
|
||||||
{#if data.membership}
|
</main>
|
||||||
<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>
|
|
||||||
{:else}
|
{: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}
|
{/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