Add sharp image processing library and update dependencies in package.json and package-lock.json; enhance Navigation component with new 'Galerie' link for improved user navigation.
This commit is contained in:
@@ -100,6 +100,11 @@
|
|||||||
active-class="text-white bg-primary-600">
|
active-class="text-white bg-primary-600">
|
||||||
Vereinsmeisterschaften
|
Vereinsmeisterschaften
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/verein/galerie"
|
||||||
|
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
|
||||||
|
active-class="text-white bg-primary-600">
|
||||||
|
Galerie
|
||||||
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Mannschaften Submenu -->
|
<!-- Mannschaften Submenu -->
|
||||||
@@ -314,6 +319,10 @@
|
|||||||
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
|
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
|
||||||
Vereinsmeisterschaften
|
Vereinsmeisterschaften
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/verein/galerie" @click="isMobileMenuOpen = false"
|
||||||
|
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
|
||||||
|
Galerie
|
||||||
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
525
package-lock.json
generated
525
package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"vue": "^3.5.22"
|
"vue": "^3.5.22"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -522,9 +523,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.6.0",
|
"version": "1.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
|
||||||
"integrity": "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==",
|
"integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -957,6 +958,471 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@img/colour": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
||||||
|
"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/@ioredis/commands": {
|
"node_modules/@ioredis/commands": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz",
|
||||||
@@ -10186,6 +10652,59 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/sharp": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@img/colour": "^1.0.0",
|
||||||
|
"detect-libc": "^2.1.2",
|
||||||
|
"semver": "^7.7.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-darwin-arm64": "0.34.5",
|
||||||
|
"@img/sharp-darwin-x64": "0.34.5",
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||||
|
"@img/sharp-linux-arm": "0.34.5",
|
||||||
|
"@img/sharp-linux-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linux-ppc64": "0.34.5",
|
||||||
|
"@img/sharp-linux-riscv64": "0.34.5",
|
||||||
|
"@img/sharp-linux-s390x": "0.34.5",
|
||||||
|
"@img/sharp-linux-x64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||||
|
"@img/sharp-wasm32": "0.34.5",
|
||||||
|
"@img/sharp-win32-arm64": "0.34.5",
|
||||||
|
"@img/sharp-win32-ia32": "0.34.5",
|
||||||
|
"@img/sharp-win32-x64": "0.34.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sharp/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/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",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"vue": "^3.5.22"
|
"vue": "^3.5.22"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -31,8 +32,8 @@
|
|||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
"lucide-vue-next": "^0.344.0",
|
"lucide-vue-next": "^0.344.0",
|
||||||
"postcss": "^8.4.0",
|
"postcss": "^8.4.0",
|
||||||
"tailwindcss": "^3.4.0",
|
|
||||||
"supertest": "^7.1.0",
|
"supertest": "^7.1.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
"vitest": "^2.1.4"
|
"vitest": "^2.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
430
pages/verein/galerie.vue
Normal file
430
pages/verein/galerie.vue
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen py-16 bg-white">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-8">
|
||||||
|
Bildergalerie
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Galerie-Grid -->
|
||||||
|
<div v-if="loading" class="text-center py-12">
|
||||||
|
<p class="text-gray-500">Bilder werden geladen...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="images.length === 0" class="text-center py-12">
|
||||||
|
<p class="text-gray-500">Noch keine Bilder in der Galerie.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
<div
|
||||||
|
v-for="image in images"
|
||||||
|
:key="image.id"
|
||||||
|
class="group relative bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition-shadow"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="overflow-hidden bg-gray-100 cursor-pointer flex items-center justify-center w-full"
|
||||||
|
style="min-height: 150px;"
|
||||||
|
@click="openModal(image)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="`/api/galerie/${image.id}?preview=true`"
|
||||||
|
:alt="image.title"
|
||||||
|
class="object-cover group-hover:scale-105 transition-transform duration-300 pointer-events-none"
|
||||||
|
style="max-width: 150px; max-height: 150px; width: auto; height: auto;"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="font-semibold text-gray-900 mb-1 cursor-pointer" @click="openModal(image)">{{ image.title }}</h3>
|
||||||
|
<div class="mt-2 flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
v-if="!image.isPublic"
|
||||||
|
class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-yellow-100 text-yellow-800"
|
||||||
|
>
|
||||||
|
Nur für Mitglieder
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
{{ formatDate(image.uploadedAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Lösch-Button für Admins -->
|
||||||
|
<button
|
||||||
|
v-if="isAdmin || isVorstand"
|
||||||
|
@click.stop="deleteImage(image.id)"
|
||||||
|
:disabled="deleting === image.id"
|
||||||
|
class="mt-2 w-full px-3 py-1.5 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{{ deleting === image.id ? 'Wird gelöscht...' : 'Löschen' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div v-if="pagination.totalPages > 1" class="flex justify-center items-center space-x-2 mb-8">
|
||||||
|
<button
|
||||||
|
@click="changePage(pagination.page - 1)"
|
||||||
|
:disabled="pagination.page === 1"
|
||||||
|
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
<span class="px-4 py-2 text-gray-700">
|
||||||
|
Seite {{ pagination.page }} von {{ pagination.totalPages }} ({{ pagination.total }} Bilder)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
@click="changePage(pagination.page + 1)"
|
||||||
|
:disabled="pagination.page >= pagination.totalPages"
|
||||||
|
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload-Bereich für Admins (Collapsible) -->
|
||||||
|
<div v-if="isAdmin || isVorstand" class="mt-12">
|
||||||
|
<button
|
||||||
|
@click="showUploadForm = !showUploadForm"
|
||||||
|
class="w-full flex items-center justify-between p-4 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">Bild hochladen</h2>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-gray-600 transition-transform"
|
||||||
|
:class="{ 'rotate-180': showUploadForm }"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div v-if="showUploadForm" class="mt-4 p-6 bg-gray-50 rounded-lg border border-gray-300">
|
||||||
|
<form @submit.prevent="uploadImage" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Bilddatei
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref="fileInput"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
|
||||||
|
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-primary-600 file:text-white hover:file:bg-primary-700"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Titel
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="uploadForm.title"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
placeholder="Titel des Bildes"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Beschreibung (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="uploadForm.description"
|
||||||
|
rows="3"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
placeholder="Beschreibung des Bildes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
v-model="uploadForm.isPublic"
|
||||||
|
type="checkbox"
|
||||||
|
id="isPublic"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label for="isPublic" class="ml-2 block text-sm text-gray-700">
|
||||||
|
Öffentlich sichtbar (für alle Besucher)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="uploading"
|
||||||
|
class="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{{ uploading ? 'Wird hochgeladen...' : 'Bild hochladen' }}
|
||||||
|
</button>
|
||||||
|
<p v-if="uploadError" class="text-red-600 text-sm">{{ uploadError }}</p>
|
||||||
|
<p v-if="uploadSuccess" class="text-green-600 text-sm">{{ uploadSuccess }}</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lightbox Modal -->
|
||||||
|
<div
|
||||||
|
v-if="selectedImage"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-90 p-4"
|
||||||
|
@click="closeModal"
|
||||||
|
>
|
||||||
|
<div class="relative max-w-5xl max-h-full w-full" @click.stop>
|
||||||
|
<!-- Schließen-Button -->
|
||||||
|
<button
|
||||||
|
@click="closeModal"
|
||||||
|
class="absolute top-4 right-4 text-white hover:text-gray-300 z-10 bg-black bg-opacity-50 rounded-full p-2"
|
||||||
|
>
|
||||||
|
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Vorheriges Bild Button -->
|
||||||
|
<button
|
||||||
|
v-if="hasPreviousImage"
|
||||||
|
@click.stop="showPreviousImage"
|
||||||
|
class="absolute left-4 top-1/2 -translate-y-1/2 text-white hover:text-gray-300 z-10 bg-black bg-opacity-50 rounded-full p-3"
|
||||||
|
aria-label="Vorheriges Bild"
|
||||||
|
>
|
||||||
|
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Nächstes Bild Button -->
|
||||||
|
<button
|
||||||
|
v-if="hasNextImage"
|
||||||
|
@click.stop="showNextImage"
|
||||||
|
class="absolute right-4 top-1/2 -translate-y-1/2 text-white hover:text-gray-300 z-10 bg-black bg-opacity-50 rounded-full p-3"
|
||||||
|
aria-label="Nächstes Bild"
|
||||||
|
>
|
||||||
|
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<img
|
||||||
|
:src="`/api/galerie/${selectedImage.id}`"
|
||||||
|
:alt="selectedImage.title"
|
||||||
|
class="max-w-[90%] max-h-[90vh] object-contain mx-auto"
|
||||||
|
/>
|
||||||
|
<div class="mt-4 text-white text-center">
|
||||||
|
<h3 class="text-xl font-semibold">{{ selectedImage.title }}</h3>
|
||||||
|
<p v-if="selectedImage.description" class="mt-2 text-gray-300">
|
||||||
|
{{ selectedImage.description }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-gray-400">
|
||||||
|
Bild {{ currentImageIndex + 1 }} von {{ images.length }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const images = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const selectedImage = ref(null)
|
||||||
|
const fileInput = ref(null)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const uploadError = ref('')
|
||||||
|
const uploadSuccess = ref('')
|
||||||
|
const showUploadForm = ref(false)
|
||||||
|
const deleting = ref(null)
|
||||||
|
const pagination = ref({
|
||||||
|
page: 1,
|
||||||
|
perPage: 10,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const uploadForm = ref({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
isPublic: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const isAdmin = computed(() => authStore.isAdmin)
|
||||||
|
const isVorstand = computed(() => authStore.user?.role === 'vorstand')
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Galerie - Harheimer TC',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadImages(page = 1) {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const response = await $fetch(`/api/galerie/list?page=${page}`)
|
||||||
|
images.value = response.images || []
|
||||||
|
if (response.pagination) {
|
||||||
|
pagination.value = response.pagination
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Bilder:', error)
|
||||||
|
images.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(newPage) {
|
||||||
|
if (newPage >= 1 && newPage <= pagination.value.totalPages) {
|
||||||
|
loadImages(newPage)
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteImage(imageId) {
|
||||||
|
if (!confirm('Möchten Sie dieses Bild wirklich löschen?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
deleting.value = imageId
|
||||||
|
await $fetch(`/api/galerie/${imageId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Galerie neu laden (bleibt auf aktueller Seite)
|
||||||
|
await loadImages(pagination.value.page)
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.data?.message || 'Fehler beim Löschen des Bildes')
|
||||||
|
} finally {
|
||||||
|
deleting.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelect(event) {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (file && !uploadForm.value.title) {
|
||||||
|
uploadForm.value.title = file.name.replace(/\.[^/.]+$/, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImage() {
|
||||||
|
if (!fileInput.value?.files[0]) {
|
||||||
|
uploadError.value = 'Bitte wählen Sie eine Bilddatei aus'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
uploading.value = true
|
||||||
|
uploadError.value = ''
|
||||||
|
uploadSuccess.value = ''
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('image', fileInput.value.files[0])
|
||||||
|
formData.append('title', uploadForm.value.title)
|
||||||
|
formData.append('description', uploadForm.value.description)
|
||||||
|
formData.append('isPublic', uploadForm.value.isPublic)
|
||||||
|
|
||||||
|
await $fetch('/api/galerie/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
uploadSuccess.value = 'Bild erfolgreich hochgeladen!'
|
||||||
|
uploadForm.value = {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
isPublic: false
|
||||||
|
}
|
||||||
|
fileInput.value.value = ''
|
||||||
|
|
||||||
|
// Galerie neu laden
|
||||||
|
await loadImages(pagination.value.page)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
uploadSuccess.value = ''
|
||||||
|
}, 3000)
|
||||||
|
} catch (error) {
|
||||||
|
uploadError.value = error.data?.message || 'Fehler beim Hochladen des Bildes'
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentImageIndex = computed(() => {
|
||||||
|
if (!selectedImage.value) return -1
|
||||||
|
return images.value.findIndex(img => img.id === selectedImage.value.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasPreviousImage = computed(() => {
|
||||||
|
return currentImageIndex.value > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasNextImage = computed(() => {
|
||||||
|
return currentImageIndex.value >= 0 && currentImageIndex.value < images.value.length - 1
|
||||||
|
})
|
||||||
|
|
||||||
|
function openModal(image) {
|
||||||
|
selectedImage.value = image
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
// Fokus auf Modal setzen für Keyboard-Navigation
|
||||||
|
setTimeout(() => {
|
||||||
|
const modal = document.querySelector('[tabindex="0"]')
|
||||||
|
if (modal) modal.focus()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
selectedImage.value = null
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPreviousImage() {
|
||||||
|
if (hasPreviousImage.value) {
|
||||||
|
const prevIndex = currentImageIndex.value - 1
|
||||||
|
selectedImage.value = images.value[prevIndex]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNextImage() {
|
||||||
|
if (hasNextImage.value) {
|
||||||
|
const nextIndex = currentImageIndex.value + 1
|
||||||
|
selectedImage.value = images.value[nextIndex]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLightboxKeydown(event) {
|
||||||
|
// Nur verarbeiten, wenn Lightbox offen ist
|
||||||
|
if (!selectedImage.value) return
|
||||||
|
|
||||||
|
if (event.key === 'ArrowLeft' && hasPreviousImage.value) {
|
||||||
|
event.preventDefault()
|
||||||
|
showPreviousImage()
|
||||||
|
} else if (event.key === 'ArrowRight' && hasNextImage.value) {
|
||||||
|
event.preventDefault()
|
||||||
|
showNextImage()
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
closeModal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleDateString('de-DE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
authStore.checkAuth()
|
||||||
|
loadImages()
|
||||||
|
// Keyboard-Event-Listener für Lightbox
|
||||||
|
document.addEventListener('keydown', handleLightboxKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleLightboxKeydown)
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
})
|
||||||
|
</script>
|
||||||
118
server/api/galerie/[id].delete.js
Normal file
118
server/api/galerie/[id].delete.js
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { getUserFromToken, verifyToken } from '../../utils/auth.js'
|
||||||
|
|
||||||
|
// Handle both dev and production paths
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const GALERIE_DIR = getDataPath('galerie')
|
||||||
|
const GALERIE_METADATA = getDataPath('galerie-metadata.json')
|
||||||
|
|
||||||
|
async function readGalerieMetadata() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(GALERIE_METADATA, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeGalerieMetadata(metadata) {
|
||||||
|
await fs.writeFile(GALERIE_METADATA, JSON.stringify(metadata, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Authentifizierung prüfen
|
||||||
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Nicht authentifiziert'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = verifyToken(token)
|
||||||
|
if (!decoded) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Ungültiges Token'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Keine Berechtigung zum Löschen von Bildern'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageId = getRouterParam(event, 'id')
|
||||||
|
|
||||||
|
if (!imageId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Bild-ID erforderlich'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = await readGalerieMetadata()
|
||||||
|
const image = metadata.find(img => img.id === imageId)
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Bild nicht gefunden'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lösche Dateien
|
||||||
|
const originalPath = path.join(GALERIE_DIR, 'originals', image.filename)
|
||||||
|
const previewPath = path.join(GALERIE_DIR, 'previews', image.previewFilename)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.unlink(originalPath)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Original-Datei konnte nicht gelöscht werden:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.unlink(previewPath)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Preview-Datei konnte nicht gelöscht werden:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entferne aus Metadaten
|
||||||
|
const updatedMetadata = metadata.filter(img => img.id !== imageId)
|
||||||
|
await writeGalerieMetadata(updatedMetadata)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Bild erfolgreich gelöscht'
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Löschen des Bildes:', error)
|
||||||
|
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler beim Löschen des Bildes'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
125
server/api/galerie/[id].get.js
Normal file
125
server/api/galerie/[id].get.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import sharp from 'sharp'
|
||||||
|
import { getUserFromToken, verifyToken } from '../../utils/auth.js'
|
||||||
|
|
||||||
|
// Handle both dev and production paths
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const GALERIE_DIR = getDataPath('galerie')
|
||||||
|
const GALERIE_METADATA = getDataPath('galerie-metadata.json')
|
||||||
|
|
||||||
|
async function readGalerieMetadata() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(GALERIE_METADATA, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const imageId = getRouterParam(event, 'id')
|
||||||
|
const query = getQuery(event)
|
||||||
|
const isPreview = query.preview === 'true'
|
||||||
|
|
||||||
|
if (!imageId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Bild-ID erforderlich'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob Benutzer eingeloggt ist
|
||||||
|
let isLoggedIn = false
|
||||||
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const decoded = verifyToken(token)
|
||||||
|
if (decoded) {
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
if (user && user.active) {
|
||||||
|
isLoggedIn = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = await readGalerieMetadata()
|
||||||
|
const image = metadata.find(img => img.id === imageId)
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Bild nicht gefunden'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe Zugriffsberechtigung
|
||||||
|
if (!image.isPublic && !isLoggedIn) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Keine Berechtigung zum Anzeigen dieses Bildes'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bestimme Dateipfad
|
||||||
|
const filename = isPreview ? image.previewFilename : image.filename
|
||||||
|
const subdir = isPreview ? 'previews' : 'originals'
|
||||||
|
const filePath = path.join(GALERIE_DIR, subdir, filename)
|
||||||
|
|
||||||
|
// Prüfe ob Datei existiert
|
||||||
|
try {
|
||||||
|
await fs.access(filePath)
|
||||||
|
} catch {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Bilddatei nicht gefunden'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bestimme MIME-Type
|
||||||
|
const ext = path.extname(filename).toLowerCase()
|
||||||
|
const mimeTypes = {
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp'
|
||||||
|
}
|
||||||
|
const contentType = mimeTypes[ext] || 'application/octet-stream'
|
||||||
|
|
||||||
|
// Datei lesen und EXIF-Orientierung korrigieren
|
||||||
|
const imageBuffer = await fs.readFile(filePath)
|
||||||
|
const correctedBuffer = await sharp(imageBuffer)
|
||||||
|
.rotate() // Korrigiert automatisch EXIF-Orientierung
|
||||||
|
.toBuffer()
|
||||||
|
|
||||||
|
setHeader(event, 'Content-Type', contentType)
|
||||||
|
setHeader(event, 'Cache-Control', 'public, max-age=31536000')
|
||||||
|
|
||||||
|
return correctedBuffer
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden des Bildes:', error)
|
||||||
|
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler beim Laden des Bildes'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
90
server/api/galerie/list.get.js
Normal file
90
server/api/galerie/list.get.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { getUserFromToken, verifyToken } from '../../utils/auth.js'
|
||||||
|
|
||||||
|
// Handle both dev and production paths
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const GALERIE_METADATA = getDataPath('galerie-metadata.json')
|
||||||
|
|
||||||
|
async function readGalerieMetadata() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(GALERIE_METADATA, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Prüfe ob Benutzer eingeloggt ist
|
||||||
|
let isLoggedIn = false
|
||||||
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const decoded = verifyToken(token)
|
||||||
|
if (decoded) {
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
if (user && user.active) {
|
||||||
|
isLoggedIn = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = await readGalerieMetadata()
|
||||||
|
|
||||||
|
// Filtere Bilder basierend auf Sichtbarkeit
|
||||||
|
const visibleImages = metadata.filter(image => {
|
||||||
|
// Öffentliche Bilder sind für alle sichtbar
|
||||||
|
if (image.isPublic) return true
|
||||||
|
// Private Bilder nur für eingeloggte Mitglieder
|
||||||
|
return isLoggedIn
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sortiere nach Upload-Datum (neueste zuerst)
|
||||||
|
visibleImages.sort((a, b) => new Date(b.uploadedAt) - new Date(a.uploadedAt))
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const page = parseInt(getQuery(event).page) || 1
|
||||||
|
const perPage = 10
|
||||||
|
const start = (page - 1) * perPage
|
||||||
|
const end = start + perPage
|
||||||
|
const paginatedImages = visibleImages.slice(start, end)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
images: paginatedImages.map(img => ({
|
||||||
|
id: img.id,
|
||||||
|
title: img.title,
|
||||||
|
description: img.description,
|
||||||
|
isPublic: img.isPublic,
|
||||||
|
uploadedAt: img.uploadedAt,
|
||||||
|
previewFilename: img.previewFilename
|
||||||
|
})),
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
total: visibleImages.length,
|
||||||
|
totalPages: Math.ceil(visibleImages.length / perPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Galerie:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler beim Laden der Galerie'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
208
server/api/galerie/upload.post.js
Normal file
208
server/api/galerie/upload.post.js
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import multer from 'multer'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import sharp from 'sharp'
|
||||||
|
import { getUserFromToken, verifyToken } from '../../utils/auth.js'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
|
// Handle both dev and production paths
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const GALERIE_DIR = getDataPath('galerie')
|
||||||
|
const GALERIE_METADATA = getDataPath('galerie-metadata.json')
|
||||||
|
|
||||||
|
// Multer-Konfiguration für Bild-Uploads
|
||||||
|
// Temporärer Dateiname, wird später basierend auf Titel umbenannt
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: async (req, file, cb) => {
|
||||||
|
try {
|
||||||
|
await fs.mkdir(GALERIE_DIR, { recursive: true })
|
||||||
|
await fs.mkdir(path.join(GALERIE_DIR, 'originals'), { recursive: true })
|
||||||
|
await fs.mkdir(path.join(GALERIE_DIR, 'previews'), { recursive: true })
|
||||||
|
cb(null, path.join(GALERIE_DIR, 'originals'))
|
||||||
|
} catch (error) {
|
||||||
|
cb(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
// Temporärer Dateiname, wird später umbenannt
|
||||||
|
const ext = path.extname(file.originalname)
|
||||||
|
const tempFilename = `temp_${randomUUID()}${ext}`
|
||||||
|
cb(null, tempFilename)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
const allowedMimes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']
|
||||||
|
if (allowedMimes.includes(file.mimetype)) {
|
||||||
|
cb(null, true)
|
||||||
|
} else {
|
||||||
|
cb(new Error('Nur Bilddateien sind erlaubt (JPEG, PNG, GIF, WebP)'), false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
fileSize: 10 * 1024 * 1024 // 10MB Limit
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function readGalerieMetadata() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(GALERIE_METADATA, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeGalerieMetadata(metadata) {
|
||||||
|
await fs.writeFile(GALERIE_METADATA, JSON.stringify(metadata, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Authentifizierung prüfen
|
||||||
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Nicht authentifiziert'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = verifyToken(token)
|
||||||
|
if (!decoded) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Ungültiges Token'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Keine Berechtigung zum Hochladen von Bildern'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multer-Middleware für multipart/form-data
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
upload.single('image')(event.node.req, event.node.res, (err) => {
|
||||||
|
if (err) reject(err)
|
||||||
|
else resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const file = event.node.req.file
|
||||||
|
const body = event.node.req.body || {}
|
||||||
|
const isPublic = body.isPublic === 'true' || body.isPublic === true
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Keine Bilddatei hochgeladen'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Titel ist Pflichtfeld
|
||||||
|
if (!body.title || !body.title.trim()) {
|
||||||
|
// Lösche die hochgeladene Datei
|
||||||
|
await fs.unlink(file.path).catch(() => {})
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Titel ist ein Pflichtfeld'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generiere Dateinamen basierend auf Titel
|
||||||
|
const titleSlug = body.title.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.substring(0, 100) // Max 100 Zeichen
|
||||||
|
const ext = path.extname(file.originalname)
|
||||||
|
const filename = `${titleSlug}_${randomUUID().substring(0, 8)}${ext}`
|
||||||
|
const previewFilename = `preview_${filename}`
|
||||||
|
|
||||||
|
// Verschiebe die Datei zum neuen Namen
|
||||||
|
const originalPath = path.join(GALERIE_DIR, 'originals', filename)
|
||||||
|
await fs.rename(file.path, originalPath)
|
||||||
|
|
||||||
|
const previewPath = path.join(GALERIE_DIR, 'previews', previewFilename)
|
||||||
|
|
||||||
|
// Thumbnail erstellen (150x150px) mit automatischer EXIF-Orientierungskorrektur
|
||||||
|
await sharp(originalPath)
|
||||||
|
.rotate() // Korrigiert automatisch EXIF-Orientierung
|
||||||
|
.resize(150, 150, {
|
||||||
|
fit: 'inside',
|
||||||
|
withoutEnlargement: true
|
||||||
|
})
|
||||||
|
.toFile(previewPath)
|
||||||
|
|
||||||
|
// Metadaten speichern
|
||||||
|
const metadata = await readGalerieMetadata()
|
||||||
|
const newImage = {
|
||||||
|
id: randomUUID(),
|
||||||
|
filename,
|
||||||
|
previewFilename,
|
||||||
|
title: body.title.trim(),
|
||||||
|
description: body.description || '',
|
||||||
|
isPublic,
|
||||||
|
uploadedBy: user.id,
|
||||||
|
uploadedAt: new Date().toISOString(),
|
||||||
|
originalName: file.originalname
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata.push(newImage)
|
||||||
|
await writeGalerieMetadata(metadata)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Bild erfolgreich hochgeladen',
|
||||||
|
image: {
|
||||||
|
id: newImage.id,
|
||||||
|
title: newImage.title,
|
||||||
|
isPublic: newImage.isPublic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Bild-Upload:', error)
|
||||||
|
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === 'LIMIT_FILE_SIZE') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 413,
|
||||||
|
statusMessage: 'Datei zu groß (max. 10MB)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message && error.message.includes('Nur Bilddateien')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler beim Hochladen des Bildes'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
2
server/data/galerie-metadata.json
Normal file
2
server/data/galerie-metadata.json
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[
|
||||||
|
]
|
||||||
@@ -1 +1 @@
|
|||||||
hvxN6KvvickWNnxyvIqXHN9VWFkXlbP9ivRinrShBA0a9Pq7y9QwL23SVGG/8Eu1OrbERAnSJjzyVKKfaR/5gn+a5Q+3Z8MXt+UfADTvNJB4jU+IISnRsEw8T9gXMQQNvkUygoF+qI19+vqdEjMSXTG3HvsWqvgp/0fcoT++i3v2L7UfReXm9hBJti2EiqrD03mH6t+PW31yPe8HxJTnSTCJaHzYhtjsanHE3IeYukBVhSflwtInm1hRecux1Hu1/gfnf+FqtG+9aJp81Ixdmuekk5WNckPblavBsrOHnKYHaT3GExpbK60rls2zgLqMkINg5GqePKRLIffLNU+CbOTTZkgYcLEWN4ICMGxAWAbgOxS8QLTg9iKsmhb7KkB1i4emqCFMSAbeLphbbxgEeA==
|
vt5myp1IVj2hMck3wi+hrAym+ZAIGNkg5zeSZcHwpt8NV9ZIj3KD1bPEbzTT7LhmlgspNL/HmTYwdUYN/yoxOxZ5d3usU+/q690XcuP4j4PzMtRc+xXVlA2oZT2lszkZtw0sm9auHI7NCAIViCqfpmnAtjsJPy9Pguni/9BH5hMJtNzR1zg0wIgigqA0eYLatRyMusk+hq0Bv2qodwOH0V6kQ9NHAj6lR6Dehs/nO8R+qjgtvWgYjxPR8RMtn62s8zFki3YcXi8Zweb/I0XUTS9VV4EukyZXpEGDs7ECiN6nesYNAHSB/PhC8rqrPjUPPna2s2sZjVgfY8WueuODw5oArRGfgzDhCz/eqpTS5pjMSrGJ8AygrC7R+l5KSSsMN2hHn/AwY6PAhUtbLe3mmQ==
|
||||||
163
tests/galerie-endpoints.spec.ts
Normal file
163
tests/galerie-endpoints.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createEvent, mockSuccessReadBody } from './setup'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import sharp from 'sharp'
|
||||||
|
|
||||||
|
vi.mock('../server/utils/auth.js', () => ({
|
||||||
|
getUserFromToken: vi.fn(),
|
||||||
|
verifyToken: vi.fn(),
|
||||||
|
readUsers: vi.fn(),
|
||||||
|
writeUsers: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('sharp', () => ({
|
||||||
|
default: vi.fn(() => ({
|
||||||
|
resize: vi.fn().mockReturnThis(),
|
||||||
|
toFile: vi.fn().mockResolvedValue({})
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('multer', () => {
|
||||||
|
const single = vi.fn((field) => (req, _res, cb) => {
|
||||||
|
if (req.__mockMulterError) {
|
||||||
|
cb(req.__mockMulterError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.file = req.__mockFile || null
|
||||||
|
req.body = req.body || {}
|
||||||
|
cb(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const multerFn = vi.fn(() => ({ single }))
|
||||||
|
const diskStorage = vi.fn(() => ({}))
|
||||||
|
multerFn.diskStorage = diskStorage
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: multerFn,
|
||||||
|
diskStorage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const authUtils = await import('../server/utils/auth.js')
|
||||||
|
|
||||||
|
import uploadHandler from '../server/api/galerie/upload.post.js'
|
||||||
|
import listHandler from '../server/api/galerie/list.get.js'
|
||||||
|
import imageHandler from '../server/api/galerie/[id].get.js'
|
||||||
|
|
||||||
|
describe('Galerie API Endpoints', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.spyOn(fs, 'readFile').mockResolvedValue('[]')
|
||||||
|
vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined)
|
||||||
|
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined)
|
||||||
|
vi.spyOn(fs, 'access').mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('POST /api/galerie/upload', () => {
|
||||||
|
it('erfordert Authentifizierung', async () => {
|
||||||
|
const event = createEvent({ method: 'POST' })
|
||||||
|
event.node.req.__mockFile = { filename: 'test.jpg', path: 'tmp/test.jpg', originalname: 'test.jpg', mimetype: 'image/jpeg' }
|
||||||
|
event.node.req.body = { title: 'Test', isPublic: 'true' }
|
||||||
|
|
||||||
|
await expect(uploadHandler(event)).rejects.toMatchObject({ statusCode: 401 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erfordert Admin- oder Vorstand-Rolle', async () => {
|
||||||
|
const event = createEvent({ method: 'POST', cookies: { auth_token: 'token' } })
|
||||||
|
event.node.req.__mockFile = { filename: 'test.jpg', path: 'tmp/test.jpg', originalname: 'test.jpg', mimetype: 'image/jpeg' }
|
||||||
|
event.node.req.body = { title: 'Test', isPublic: 'true' }
|
||||||
|
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
||||||
|
authUtils.getUserFromToken.mockResolvedValue({ id: '1', role: 'mitglied', active: true })
|
||||||
|
|
||||||
|
await expect(uploadHandler(event)).rejects.toMatchObject({ statusCode: 403 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lädt Bild hoch und erstellt Thumbnail', async () => {
|
||||||
|
const event = createEvent({ method: 'POST', cookies: { auth_token: 'token' } })
|
||||||
|
event.node.req.__mockFile = { filename: 'test.jpg', path: 'tmp/test.jpg', originalname: 'test.jpg', mimetype: 'image/jpeg' }
|
||||||
|
event.node.req.body = { title: 'Test Bild', description: 'Beschreibung', isPublic: 'true' }
|
||||||
|
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
||||||
|
authUtils.getUserFromToken.mockResolvedValue({ id: '1', role: 'admin', active: true })
|
||||||
|
|
||||||
|
const response = await uploadHandler(event)
|
||||||
|
|
||||||
|
expect(response.success).toBe(true)
|
||||||
|
expect(sharp).toHaveBeenCalled()
|
||||||
|
expect(fs.writeFile).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GET /api/galerie/list', () => {
|
||||||
|
it('zeigt öffentliche Bilder für alle', async () => {
|
||||||
|
const event = createEvent()
|
||||||
|
vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify([
|
||||||
|
{ id: '1', title: 'Öffentlich', isPublic: true, previewFilename: 'preview_1.jpg', uploadedAt: '2025-01-01' },
|
||||||
|
{ id: '2', title: 'Privat', isPublic: false, previewFilename: 'preview_2.jpg', uploadedAt: '2025-01-02' }
|
||||||
|
]))
|
||||||
|
|
||||||
|
const response = await listHandler(event)
|
||||||
|
|
||||||
|
expect(response.success).toBe(true)
|
||||||
|
expect(response.images).toHaveLength(1)
|
||||||
|
expect(response.images[0].id).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zeigt alle Bilder für eingeloggte Mitglieder', async () => {
|
||||||
|
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||||
|
vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify([
|
||||||
|
{ id: '1', title: 'Öffentlich', isPublic: true, previewFilename: 'preview_1.jpg', uploadedAt: '2025-01-01' },
|
||||||
|
{ id: '2', title: 'Privat', isPublic: false, previewFilename: 'preview_2.jpg', uploadedAt: '2025-01-02' }
|
||||||
|
]))
|
||||||
|
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
||||||
|
authUtils.getUserFromToken.mockResolvedValue({ id: '1', active: true })
|
||||||
|
|
||||||
|
const response = await listHandler(event)
|
||||||
|
|
||||||
|
expect(response.images).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GET /api/galerie/[id]', () => {
|
||||||
|
it('verweigert Zugriff auf private Bilder für nicht eingeloggte Benutzer', async () => {
|
||||||
|
const event = createEvent()
|
||||||
|
event.context.params = { id: '1' }
|
||||||
|
vi.spyOn(fs, 'readFile')
|
||||||
|
.mockResolvedValueOnce(JSON.stringify([
|
||||||
|
{ id: '1', filename: 'test.jpg', previewFilename: 'preview_test.jpg', isPublic: false }
|
||||||
|
]))
|
||||||
|
|
||||||
|
await expect(imageHandler(event)).rejects.toMatchObject({ statusCode: 403 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liefert Bild für eingeloggte Mitglieder', async () => {
|
||||||
|
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||||
|
event.context.params = { id: '1' }
|
||||||
|
vi.spyOn(fs, 'readFile')
|
||||||
|
.mockResolvedValueOnce(JSON.stringify([
|
||||||
|
{ id: '1', filename: 'test.jpg', previewFilename: 'preview_test.jpg', isPublic: false }
|
||||||
|
]))
|
||||||
|
.mockResolvedValueOnce(Buffer.from('image data'))
|
||||||
|
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
||||||
|
authUtils.getUserFromToken.mockResolvedValue({ id: '1', active: true })
|
||||||
|
|
||||||
|
const response = await imageHandler(event)
|
||||||
|
|
||||||
|
expect(response).toBeInstanceOf(Buffer)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liefert Preview-Bild bei preview=true', async () => {
|
||||||
|
const event = createEvent({ query: { preview: 'true' } })
|
||||||
|
event.context.params = { id: '1' }
|
||||||
|
vi.spyOn(fs, 'readFile')
|
||||||
|
.mockResolvedValueOnce(JSON.stringify([
|
||||||
|
{ id: '1', filename: 'test.jpg', previewFilename: 'preview_test.jpg', isPublic: true }
|
||||||
|
]))
|
||||||
|
.mockResolvedValueOnce(Buffer.from('preview data'))
|
||||||
|
|
||||||
|
const response = await imageHandler(event)
|
||||||
|
|
||||||
|
expect(response).toBeInstanceOf(Buffer)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
@@ -20,11 +20,20 @@ const getCookieStore = (event: any): CookieStore => {
|
|||||||
|
|
||||||
global.readBody = vi.fn(async (event: any) => event.__body ?? null)
|
global.readBody = vi.fn(async (event: any) => event.__body ?? null)
|
||||||
global.getQuery = (event: any) => event.__query ?? {}
|
global.getQuery = (event: any) => event.__query ?? {}
|
||||||
|
global.getRouterParam = (event: any, name: string) => {
|
||||||
|
return event.context?.params?.[name] ?? null
|
||||||
|
}
|
||||||
global.getHeader = (event: any, name: string) => {
|
global.getHeader = (event: any, name: string) => {
|
||||||
const headers = event.node?.req?.headers || {}
|
const headers = event.node?.req?.headers || {}
|
||||||
const key = Object.keys(headers).find(h => h.toLowerCase() === name.toLowerCase())
|
const key = Object.keys(headers).find(h => h.toLowerCase() === name.toLowerCase())
|
||||||
return key ? headers[key] : undefined
|
return key ? headers[key] : undefined
|
||||||
}
|
}
|
||||||
|
global.setHeader = vi.fn((event: any, name: string, value: string) => {
|
||||||
|
if (event.node?.res) {
|
||||||
|
event.node.res.headers = event.node.res.headers || {}
|
||||||
|
event.node.res.headers[name] = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
global.setCookie = (event: any, name: string, value: string, options = {}) => {
|
global.setCookie = (event: any, name: string, value: string, options = {}) => {
|
||||||
const store = getCookieStore(event)
|
const store = getCookieStore(event)
|
||||||
|
|||||||
Reference in New Issue
Block a user