feat: persönliche Gedenkseite – Tribute, Autoplay-Musik, Next.js 16 Fixes

- TributeSection: zwei Perspektiven (Familie + Dennis), emotional und persönlich
- MusicPlayer: Autoplay mit stummem Start + Fade-In bei Interaktion, nahtloser
  Crossfade-Loop (überspringt stille letzten 10s), kompakter Mute-Button
- Alle API-Routes: export const runtime = 'nodejs' für node:sqlite in Next.js 16
- Admin: defensive res.ok Checks vor .json() Parsing
- DB: mkdirSync erst zur Laufzeit, path.resolve für DATA_DIR
- page.tsx: plain() Helper für null-prototype SQLite-Rows
- erinnerungen.md: alle Familieninfos und Erinnerungen dokumentiert
- Nav: Musik-Tab entfernt, "Über Oma" hinzugefügt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
denshooter
2026-02-16 03:48:46 +01:00
parent 313b5ff7fd
commit 4d56d4904a
17 changed files with 1121 additions and 600 deletions
+62
View File
@@ -0,0 +1,62 @@
# Maria Malejka — Erinnerungen
## Lebensdaten
- **Geboren:** 29. November 1944
- **Gestorben:** 10. Februar 2026 (Lungenembolie, im Krankenhaus)
- **Beerdigung:** 20. Februar 2026 (Donnerstag)
- **Urnenbestattung** (Verbrennung am 16. Februar 2026)
## Familie
- **Ehemann:** Josef Malejka (diamantene Hochzeit, über 60 Jahre verheiratet)
- **Töchter:** Beate, Renate, Margret
- **Enkel:** Dennis (Ersteller dieser Seite), Jacky (Dennis' Schwester)
- **Jackys Ehemann:** Niklas
- **Jacky** ist schwanger, hat den Ultraschall mit in den Sarg gelegt
- **Herkunft:** Kam aus Polen nach Deutschland mit ihren drei Töchtern
## Omas Haus
- Jacky und Niklas haben Omas Haus 2025 gekauft und renovieren es
- Neues Leben in alten Wänden, dort wird bald ein Kind aufwachsen
## Charakter & Wesen
- Einfach gestrickt im besten Sinne, kein Schickimicki, kein großes Trara
- Hat nichts weggeworfen, alles wiederverwendet
- Tierlieb: hatte Vögel und ihren Hund **Ronny**
- Liebte den Familienhund **Pico** (Hund von Beate/Dennis' Familie). Pico ist 2025 ebenfalls verstorben
- Stur auf eine liebevolle Art, hat Dinge auf ihre Weise gemacht
- Direkt und persönlich, aber warmherzig und ruhig
- Hat die Familie zusammengehalten
- Hat es geliebt, zu geben
- Roch nach "Oma": dezent, warm, vertraut, nicht nach Parfum
## Weihnachten bei Oma und Opa
- Immer heiß, der Kamin lief immer
- Karpfen in der Badewanne (polnische Tradition)
- Berge von Essen
- Geschenke im Wohnzimmer, aber die Tür blieb zu bis das Essen fertig war
- Kein Schummeln
## Erinnerungen
- **Rahmsauce** nach der Schule, immer
- Omas Kochen war legendär, mit Liebe gemacht
- Hat mehr mit Pico geredet als mit den Menschen wenn er zu Besuch kam
## Die letzten Tage
- Oberschenkelhalsbruch (Sturheit, wollte alles alleine machen)
- Nach der Hüft-OP ging es ihr gut, sie lief schon wieder
- Im Aufenthaltsraum der Geriatrie: Blumenvasen gesehen, "die könnte man ja mitnehmen"
- Sollte in einer Woche in die Reha
- Ihr Zimmer lag gegenüber dem Zimmer, in dem Beate (Dennis' Mutter) wochenlang lag als sie mit Dennis schwanger war
- Im selben Flur, in dem Dennis auf die Welt kam, ist Oma gegangen
- Lungenembolie, Kollaps im Zimmer, Reanimation, Intensivstation
- 10. Februar 2026, 13:13 Uhr: Nachricht von Jacky über Telegram: "Sie möchten die Reanimation abbrechen."
## Persönliches (Dennis)
- "Ich habe es dir viel zu selten gesagt, Oma. Aber ich habe dich geliebt. Sehr."
- Es kam unerwartet, kein langer Abschied, kein letztes Gespräch
- Pico und Oma: vielleicht sind sie jetzt wieder zusammen
## Tonalität der Gedenkseite
- Direkt und persönlich
- Liebevoll und ruhig
- So wie Oma war
+604 -107
View File
@@ -10,7 +10,7 @@
"dependencies": {
"framer-motion": "^11.2.0",
"lucide-react": "^0.400.0",
"next": "14.2.5",
"next": "^16.1.6",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
@@ -37,6 +37,482 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"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",
"optional": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -77,15 +553,15 @@
}
},
"node_modules/@next/env": {
"version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz",
"integrity": "sha512-/zZGkrTOsraVfYjGP8uM0p6r0BDT6xWpkjdVbcz66PJVSpwXX3yNiRycxAuDfBKGWBrZBXRuK/YVlkNgxHGwmA==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
"integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.5.tgz",
"integrity": "sha512-/9zVxJ+K9lrzSGli1///ujyRfon/ZneeZ+v4ptpiPoOU+GKZnm8Wj8ELWU1Pm7GHltYRBklmXMTUqM/DqQ99FQ==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
"integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
"cpu": [
"arm64"
],
@@ -99,9 +575,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.5.tgz",
"integrity": "sha512-vXHOPCwfDe9qLDuq7U1OYM2wUY+KQ4Ex6ozwsKxp26BlJ6XXbHleOUldenM67JRyBfVjv371oneEvYd3H2gNSA==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
"integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
"cpu": [
"x64"
],
@@ -115,9 +591,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.5.tgz",
"integrity": "sha512-vlhB8wI+lj8q1ExFW8lbWutA4M2ZazQNvMWuEDqZcuJJc78iUnLdPPunBPX8rC4IgT6lIx/adB+Cwrl99MzNaA==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
"integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
"cpu": [
"arm64"
],
@@ -131,9 +607,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.5.tgz",
"integrity": "sha512-NpDB9NUR2t0hXzJJwQSGu1IAOYybsfeB+LxpGsXrRIb7QOrYmidJz3shzY8cM6+rO4Aojuef0N/PEaX18pi9OA==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
"integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
"cpu": [
"arm64"
],
@@ -147,9 +623,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.5.tgz",
"integrity": "sha512-8XFikMSxWleYNryWIjiCX+gU201YS+erTUidKdyOVYi5qUQo/gRxv/3N1oZFCgqpesN6FPeqGM72Zve+nReVXQ==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
"integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
"cpu": [
"x64"
],
@@ -163,9 +639,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.5.tgz",
"integrity": "sha512-6QLwi7RaYiQDcRDSU/os40r5o06b5ue7Jsk5JgdRBGGp8l37RZEh9JsLSM8QF0YDsgcosSeHjglgqi25+m04IQ==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
"integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
"cpu": [
"x64"
],
@@ -179,9 +655,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.5.tgz",
"integrity": "sha512-1GpG2VhbspO+aYoMOQPQiqc/tG3LzmsdBH0LhnDS3JrtDx2QmzXe0B6mSZZiN3Bq7IOMXxv1nlsjzoS1+9mzZw==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
"integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
"cpu": [
"arm64"
],
@@ -194,26 +670,10 @@
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.5.tgz",
"integrity": "sha512-Igh9ZlxwvCDsu6438FXlQTHlRno4gFpJzqPjSIBZooD22tKeI4fE/YMRoHVJHmrQ2P5YL1DoZ0qaOKkbeFWeMg==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.5.tgz",
"integrity": "sha512-tEQ7oinq1/CjSG9uSTerca3v4AZ+dFa+4Yu6ihaG8Ud8ddqLQgFGcnwYls13H5X5CPDPZJdYxyeMui6muOLd4g==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
"integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
"cpu": [
"x64"
],
@@ -264,20 +724,13 @@
"node": ">= 8"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz",
"integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"tslib": "^2.4.0"
"tslib": "^2.8.0"
}
},
"node_modules/@types/node": {
@@ -387,7 +840,6 @@
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
@@ -453,17 +905,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -568,6 +1009,16 @@
"dev": true,
"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",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -731,12 +1182,6 @@
"node": ">=10.13.0"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -939,42 +1384,41 @@
}
},
"node_modules/next": {
"version": "14.2.5",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.5.tgz",
"integrity": "sha512-0f8aRfBVL+mpzfBjYfQuLWh2WyAwtJXCRfkPF4UJ5qd2YwrHczsrSzXU4tRMV0OAxR8ZJZWPFn6uhSC56UTsLA==",
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
"integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
"license": "MIT",
"dependencies": {
"@next/env": "14.2.5",
"@swc/helpers": "0.5.5",
"busboy": "1.6.0",
"@next/env": "16.1.6",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001579",
"graceful-fs": "^4.2.11",
"postcss": "8.4.31",
"styled-jsx": "5.1.1"
"styled-jsx": "5.1.6"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": ">=18.17.0"
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.5",
"@next/swc-darwin-x64": "14.2.5",
"@next/swc-linux-arm64-gnu": "14.2.5",
"@next/swc-linux-arm64-musl": "14.2.5",
"@next/swc-linux-x64-gnu": "14.2.5",
"@next/swc-linux-x64-musl": "14.2.5",
"@next/swc-win32-arm64-msvc": "14.2.5",
"@next/swc-win32-ia32-msvc": "14.2.5",
"@next/swc-win32-x64-msvc": "14.2.5"
"@next/swc-darwin-arm64": "16.1.6",
"@next/swc-darwin-x64": "16.1.6",
"@next/swc-linux-arm64-gnu": "16.1.6",
"@next/swc-linux-arm64-musl": "16.1.6",
"@next/swc-linux-x64-gnu": "16.1.6",
"@next/swc-linux-x64-musl": "16.1.6",
"@next/swc-win32-arm64-msvc": "16.1.6",
"@next/swc-win32-x64-msvc": "16.1.6",
"sharp": "^0.34.4"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.41.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@playwright/test": "^1.51.1",
"babel-plugin-react-compiler": "*",
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
@@ -984,6 +1428,9 @@
"@playwright/test": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"sass": {
"optional": true
}
@@ -1397,6 +1844,64 @@
"loose-envify": "^1.1.0"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"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",
"optional": true,
"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/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1406,18 +1911,10 @@
"node": ">=0.10.0"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/styled-jsx": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
"integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"license": "MIT",
"dependencies": {
"client-only": "0.0.1"
@@ -1426,7 +1923,7 @@
"node": ">= 12.0.0"
},
"peerDependencies": {
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
+1 -1
View File
@@ -10,7 +10,7 @@
"dependencies": {
"framer-motion": "^11.2.0",
"lucide-react": "^0.400.0",
"next": "14.2.5",
"next": "^16.1.6",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
+2 -4
View File
@@ -64,10 +64,8 @@ export default function AdminPage() {
fetch('/api/memories'),
fetch('/api/media'),
])
const [memoriesData, mediaData] = await Promise.all([
memoriesRes.json(),
mediaRes.json(),
])
const memoriesData = memoriesRes.ok ? await memoriesRes.json() : []
const mediaData = mediaRes.ok ? await mediaRes.json() : []
setMemories(Array.isArray(memoriesData) ? memoriesData : [])
const items: MediaItem[] = Array.isArray(mediaData) ? mediaData : []
setPhotos(items.filter((m) => m.type === 'photo'))
+2
View File
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server'
import { createHash } from 'crypto'
import { cookies } from 'next/headers'
export const runtime = 'nodejs'
function getExpectedToken() {
return createHash('sha256')
.update(process.env.ADMIN_PASSWORD || 'change-me')
+1 -1
View File
@@ -4,7 +4,7 @@ import path from 'path'
export const runtime = 'nodejs'
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
const DATA_DIR = path.resolve(process.cwd(), process.env.DATA_DIR || 'data')
const MIME: Record<string, string> = {
'.jpg': 'image/jpeg',
+3 -1
View File
@@ -6,6 +6,8 @@ import { unlink } from 'fs/promises'
import { existsSync } from 'fs'
import path from 'path'
export const runtime = 'nodejs'
async function isAdmin() {
const cookieStore = await cookies()
const token = cookieStore.get('admin_auth')?.value
@@ -15,7 +17,7 @@ async function isAdmin() {
return token === expected
}
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
const DATA_DIR = path.resolve(process.cwd(), process.env.DATA_DIR || 'data')
export async function DELETE(
_req: NextRequest,
+2
View File
@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
export const runtime = 'nodejs'
export async function GET(req: NextRequest) {
const type = req.nextUrl.searchParams.get('type')
const db = getDb()
+2
View File
@@ -3,6 +3,8 @@ import { getDb } from '@/lib/db'
import { cookies } from 'next/headers'
import { createHash } from 'crypto'
export const runtime = 'nodejs'
async function isAdmin() {
const cookieStore = await cookies()
const token = cookieStore.get('admin_auth')?.value
+2
View File
@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
export const runtime = 'nodejs'
export async function GET() {
const db = getDb()
const memories = db
+1 -1
View File
@@ -17,7 +17,7 @@ async function isAdmin() {
return token === expected
}
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
const DATA_DIR = path.resolve(process.cwd(), process.env.DATA_DIR || 'data')
const MIME_TO_FOLDER: Record<string, string> = {
'image/jpeg': 'photos',
+2 -2
View File
@@ -37,7 +37,7 @@ export default function ImpressumPage() {
Angaben gemäß § 5 TMG
</h2>
<address className="not-italic space-y-0.5">
<p className="font-medium text-warm-brown/90">Dennis Malejka</p>
<p className="font-medium text-warm-brown/90">Dennis Konkol</p>
<p className="text-warm-brown-light text-xs italic">
(Kontaktdaten auf Anfrage)
</p>
@@ -99,7 +99,7 @@ export default function ImpressumPage() {
</h2>
<p>
Alle auf dieser Seite veröffentlichten Fotos und Medien sind privates
Eigentum der Familie Malejka. Eine Weitergabe oder Veröffentlichung ohne
Eigentum der Familie. Eine Weitergabe oder Veröffentlichung ohne
ausdrückliche Genehmigung ist nicht gestattet.
</p>
</section>
+25 -16
View File
@@ -7,24 +7,30 @@ import MemorySection from '@/components/MemorySection'
import WriteSection from '@/components/WriteSection'
import MusicPlayer from '@/components/MusicPlayer'
import VideoGallery from '@/components/VideoGallery'
import TributeSection from '@/components/TributeSection'
export const dynamic = 'force-dynamic'
// node:sqlite returns null-prototype objects; convert to plain objects for Client Components
function plain<T>(rows: unknown[]): T[] {
return JSON.parse(JSON.stringify(rows))
}
export default async function HomePage() {
const db = getDb()
const photos = db
.prepare("SELECT * FROM media WHERE type = 'photo' ORDER BY sort_order, created_at")
.all() as MediaItem[]
const videos = db
.prepare("SELECT * FROM media WHERE type = 'video' ORDER BY sort_order, created_at")
.all() as MediaItem[]
const music = db
.prepare("SELECT * FROM media WHERE type = 'music' ORDER BY sort_order, created_at")
.all() as MediaItem[]
const memories = db
.prepare('SELECT * FROM memories ORDER BY created_at DESC')
.all() as Memory[]
const photos = plain<MediaItem>(
db.prepare("SELECT * FROM media WHERE type = 'photo' ORDER BY sort_order, created_at").all()
)
const videos = plain<MediaItem>(
db.prepare("SELECT * FROM media WHERE type = 'video' ORDER BY sort_order, created_at").all()
)
const music = plain<MediaItem>(
db.prepare("SELECT * FROM media WHERE type = 'music' ORDER BY sort_order, created_at").all()
)
const memories = plain<Memory>(
db.prepare('SELECT * FROM memories ORDER BY created_at DESC').all()
)
return (
<main className="min-h-screen bg-cream">
@@ -34,6 +40,9 @@ export default async function HomePage() {
{/* Navigation */}
<nav className="sticky top-0 z-20 bg-cream/80 backdrop-blur-sm border-b border-warm-border">
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-center gap-6 sm:gap-10">
<a href="#ueber-oma" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
Über Oma
</a>
{photos.length > 0 && (
<a href="#bilder" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
Bilder
@@ -47,12 +56,12 @@ export default async function HomePage() {
Videos
</a>
)}
<a href="#musik" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
Musik
</a>
</div>
</nav>
{/* Personal tribute */}
<TributeSection />
{/* Photos */}
{photos.length > 0 && (
<section id="bilder" className="py-16 sm:py-20">
@@ -84,7 +93,7 @@ export default async function HomePage() {
{/* Videos */}
<VideoGallery videos={videos} />
{/* Music always rendered (ambient fallback when no tracks) */}
{/* Floating music player */}
<MusicPlayer tracks={music} />
{/* Footer */}
+132 -452
View File
@@ -1,481 +1,161 @@
'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
Play,
Pause,
SkipForward,
SkipBack,
Volume2,
VolumeX,
X,
} from 'lucide-react'
import { Volume2, VolumeX } from 'lucide-react'
import type { MediaItem } from '@/lib/types'
// ─── helpers ────────────────────────────────────────────────────────────────
function formatTime(s: number) {
if (!s || isNaN(s) || !isFinite(s)) return '--:--'
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m}:${sec.toString().padStart(2, '0')}`
}
function WaveformBars({ playing }: { playing: boolean }) {
return (
<div className="flex items-end gap-px h-4">
{[0.55, 1, 0.7, 0.9, 0.5].map((h, i) => (
<motion.div
key={i}
className="w-[3px] bg-amber-400/70 rounded-full"
animate={
playing
? { height: ['30%', `${h * 100}%`, '45%', `${h * 75}%`, '30%'] }
: { height: '30%' }
}
transition={{ duration: 0.75 + i * 0.13, repeat: Infinity, ease: 'easeInOut', delay: i * 0.11 }}
style={{ height: '30%' }}
/>
))}
</div>
)
}
// ─── ambient audio via Web Audio API ────────────────────────────────────────
//
// "Singing bowls" synthesis: each tone is a struck resonance with
// natural exponential decay sounds like real crystal/tibetan bowls,
// not oscillator drones. Appropriate and peaceful for a memorial.
//
// Scale: A-minor pentatonic (A3 C4 D4 E4 G4 A4 C5 E5)
// Timing: random gaps of 38 s between strikes, so it breathes naturally.
// Reverb: two long delay tails for a large, warm hall.
function useAmbient() {
const ctxRef = useRef<AudioContext | null>(null)
const timerRef = useRef<ReturnType<typeof setTimeout>[]>([])
const [playing, setPlaying] = useState(false)
const start = useCallback(() => {
if (ctxRef.current) return
const ctx = new AudioContext()
ctxRef.current = ctx
// ── Signal chain ────────────────────────────────────────────────────
const master = ctx.createGain()
master.gain.value = 0.55
master.connect(ctx.destination)
// Long hall reverb (two delay tails, no feedback)
const mkTail = (t: number, wet: number) => {
const d = ctx.createDelay(6.0)
d.delayTime.value = t
const g = ctx.createGain()
g.gain.value = wet
d.connect(g)
g.connect(ctx.destination)
return d
}
const tail1 = mkTail(2.4, 0.30)
const tail2 = mkTail(4.8, 0.18)
// ── Bowl strike ──────────────────────────────────────────────────────
// Two slightly detuned sine waves → natural "shimmer beat" of a real bowl.
// Gain follows exponential decay → sounds exactly like a struck bowl.
const strike = (freq: number, vol: number) => {
if (!ctxRef.current) return
const now = ctx.currentTime
const decay = 7 + Math.random() * 5 // 712 s natural ring
const detune = freq * 0.0025 // 0.25 % → slow shimmer beat
;[freq, freq + detune].forEach((f, i) => {
const osc = ctx.createOscillator()
osc.type = 'sine'
osc.frequency.value = f
const g = ctx.createGain()
// Instant attack (bowl is struck), then pure exponential decay
g.gain.setValueAtTime(vol * (i === 0 ? 1 : 0.6), now)
g.gain.exponentialRampToValueAtTime(0.0001, now + decay)
osc.connect(g)
g.connect(master)
g.connect(tail1)
g.connect(tail2)
osc.start(now)
osc.stop(now + decay + 0.05)
})
}
// ── Scale ───────────────────────────────────────────────────────────
// A-minor pentatonic every note here sounds consonant with every other.
// Weighted toward lower, warmer tones (more prominent) vs. higher (softer).
const bowls: { freq: number; vol: number }[] = [
{ freq: 220.00, vol: 0.38 }, // A3 deep, warm anchor
{ freq: 261.63, vol: 0.32 }, // C4
{ freq: 293.66, vol: 0.28 }, // D4
{ freq: 329.63, vol: 0.30 }, // E4
{ freq: 392.00, vol: 0.26 }, // G4
{ freq: 440.00, vol: 0.22 }, // A4
{ freq: 523.25, vol: 0.18 }, // C5 bright, light
{ freq: 659.25, vol: 0.14 }, // E5 delicate shimmer
]
const pickBowl = () => bowls[Math.floor(Math.random() * bowls.length)]
// ── Scheduler ───────────────────────────────────────────────────────
// Occasional harmonic pairs (two bowls together) feel more musical.
const scheduleNext = () => {
if (!ctxRef.current) return
const gap = 3000 + Math.random() * 5000 // 38 s between strikes
// ~30 % chance of a soft harmonic pair (two bowls a fifth apart)
const b = pickBowl()
strike(b.freq, b.vol)
if (Math.random() < 0.30) {
const fifth = b.freq * 1.5 // perfect fifth
strike(fifth, b.vol * 0.55) // softer companion tone
}
timerRef.current.push(setTimeout(scheduleNext, gap))
}
// Open with three spaced strikes to fill the silence gently
strike(220.00, 0.38) // A3 immediately
timerRef.current.push(setTimeout(() => { if (ctxRef.current) strike(329.63, 0.30) }, 2800)) // E4
timerRef.current.push(setTimeout(() => { if (ctxRef.current) strike(261.63, 0.28) }, 5200)) // C4
timerRef.current.push(setTimeout(scheduleNext, 7500)) // then random
setPlaying(true)
}, [])
const stop = useCallback(() => {
timerRef.current.forEach(clearTimeout)
timerRef.current = []
const ctx = ctxRef.current
if (ctx) {
// Brief master fade so the currently ringing bowls die out naturally
const fade = ctx.createGain()
fade.gain.setValueAtTime(1, ctx.currentTime)
fade.gain.linearRampToValueAtTime(0, ctx.currentTime + 2.5)
fade.connect(ctx.destination)
setTimeout(() => { ctx.close(); ctxRef.current = null }, 2700)
}
setPlaying(false)
}, [])
const toggle = useCallback(() => {
if (playing) stop()
else start()
}, [playing, start, stop])
return { playing, toggle, start, stop }
}
// ─── component ──────────────────────────────────────────────────────────────
const TAIL_SKIP = 10
const CROSSFADE_DURATION = 3
const VOLUME = 0.4
const FADE_IN_DURATION = 2000 // ms to fade in after first interaction
export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
const [current, setCurrent] = useState(0)
const track = tracks[0] ?? null
const src = track ? `/api/files/${track.filename}` : null
const audioA = useRef<HTMLAudioElement>(null)
const audioB = useRef<HTMLAudioElement>(null)
const activeRef = useRef<'A' | 'B'>('A')
const fadingRef = useRef(false)
const unmutedRef = useRef(false)
const [userMuted, setUserMuted] = useState(false)
const [playing, setPlaying] = useState(false)
const [volume, setVolume] = useState(0.4)
const [muted, setMuted] = useState(false)
const [progress, setProgress] = useState(0)
const [duration, setDuration] = useState(0)
const [elapsed, setElapsed] = useState(0)
const [miniVisible, setMiniVisible] = useState(false)
const audioRef = useRef<HTMLAudioElement>(null)
const ambient = useAmbient()
const hasTrack = tracks.length > 0
const track = tracks[current] ?? null
const getActive = useCallback(
() => (activeRef.current === 'A' ? audioA.current : audioB.current),
[],
)
const getInactive = useCallback(
() => (activeRef.current === 'A' ? audioB.current : audioA.current),
[],
)
const trackName = (i: number) =>
tracks[i]?.original_name?.replace(/\.[^/.]+$/, '') ||
tracks[i]?.caption ||
`Titel ${i + 1}`
const getVolume = useCallback(
() => (userMuted ? 0 : VOLUME),
[userMuted],
)
// Crossfade to loop back
const crossfade = useCallback(() => {
if (fadingRef.current) return
fadingRef.current = true
const out = getActive()!
const inp = getInactive()!
activeRef.current = activeRef.current === 'A' ? 'B' : 'A'
inp.currentTime = 0
inp.volume = 0
inp.play().catch(() => {})
const startTime = performance.now()
const outStartVol = out.volume
const targetVol = getVolume()
const step = () => {
const t = Math.min((performance.now() - startTime) / (CROSSFADE_DURATION * 1000), 1)
inp.volume = targetVol * t
out.volume = outStartVol * (1 - t)
if (t < 1) {
requestAnimationFrame(step)
} else {
out.pause()
out.currentTime = 0
fadingRef.current = false
}
}
requestAnimationFrame(step)
}, [getVolume, getActive, getInactive])
// Monitor playback for crossfade trigger
useEffect(() => {
const a = audioRef.current
if (!a) return
a.volume = muted ? 0 : volume
}, [volume, muted])
if (!playing || !src) return
let id: number
const tick = () => {
const a = getActive()
if (a && a.duration) {
const remaining = a.duration - a.currentTime
if (remaining <= TAIL_SKIP && !fadingRef.current) {
crossfade()
}
}
id = requestAnimationFrame(tick)
}
id = requestAnimationFrame(tick)
return () => cancelAnimationFrame(id)
}, [playing, src, getActive, crossfade])
// Fallback: if audio ends without crossfade, restart
const handleEnded = useCallback(() => {
const a = getActive()
if (a) {
a.currentTime = 0
a.play().catch(() => {})
}
}, [getActive])
// Sync volume when user toggles mute
useEffect(() => {
const a = audioRef.current
if (!a || !track) return
a.src = `/api/files/${track.filename}`
a.volume = muted ? 0 : volume
setDuration(0); setElapsed(0); setProgress(0)
if (playing) a.play().catch(() => setPlaying(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [current])
if (!unmutedRef.current) return
const vol = getVolume()
const a = getActive()
const b = getInactive()
if (a && !fadingRef.current) a.volume = vol
if (b && !fadingRef.current && !b.paused) b.volume = vol
}, [userMuted, getVolume, getActive, getInactive])
const togglePlay = () => {
const a = audioRef.current
// Autoplay strategy:
// 1. Start muted (browsers allow this)
// 2. On first user interaction (scroll/click/touch/key), fade volume in
useEffect(() => {
if (!src) return
const a = audioA.current
if (!a) return
if (playing) a.pause()
else a.play().catch(() => setPlaying(false))
}
const playTrack = (i: number) => {
if (i === current) togglePlay()
else { setCurrent(i); setPlaying(true) }
setMiniVisible(true)
}
// Start muted playback immediately
a.volume = 0
a.play().then(() => setPlaying(true)).catch(() => {})
const prev = () => setCurrent((c) => (c - 1 + tracks.length) % tracks.length)
const next = () => setCurrent((c) => (c + 1) % tracks.length)
// Fade in on first interaction
const fadeIn = () => {
if (unmutedRef.current) return
unmutedRef.current = true
const handleTimeUpdate = () => {
const a = audioRef.current
if (!a || !a.duration) return
setProgress((a.currentTime / a.duration) * 100)
setElapsed(a.currentTime)
}
const active = activeRef.current === 'A' ? audioA.current : audioB.current
if (!active) return
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const a = audioRef.current
if (!a || !a.duration) return
const pct = parseFloat(e.target.value)
a.currentTime = (pct / 100) * a.duration
setProgress(pct)
}
const startTime = performance.now()
const step = () => {
const t = Math.min((performance.now() - startTime) / FADE_IN_DURATION, 1)
active.volume = VOLUME * t
if (t < 1) requestAnimationFrame(step)
}
requestAnimationFrame(step)
}
// Decide what the mini-player shows
const miniLabel = hasTrack ? trackName(current) : 'Stille Begleitung'
const miniPlaying = hasTrack ? playing : ambient.playing
const events = ['scroll', 'click', 'touchstart', 'keydown'] as const
events.forEach((e) => window.addEventListener(e, fadeIn, { once: true, passive: true }))
return () => {
events.forEach((e) => window.removeEventListener(e, fadeIn))
}
}, [src])
if (!track || !src) return null
return (
<>
{hasTrack && (
<audio
ref={audioRef}
src={`/api/files/${track!.filename}`}
onEnded={next}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={() => { const a = audioRef.current; if (a) setDuration(a.duration) }}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
/>
)}
<audio ref={audioA} src={src} preload="auto" onEnded={handleEnded} />
<audio ref={audioB} src={src} preload="auto" onEnded={handleEnded} />
{/* ── Inline section ─────────────────────────────────────── */}
<section
id="musik"
className="py-20 px-4"
style={{ background: 'linear-gradient(to bottom, #0a0706, #060304)' }}
<button
onClick={() => setUserMuted((m) => !m)}
className="fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full bg-stone-950/85 backdrop-blur-sm border border-amber-900/20 shadow-lg flex items-center justify-center text-amber-400/60 hover:text-amber-300 transition-colors"
title={userMuted ? 'Ton an' : 'Ton aus'}
>
<div className="max-w-xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-center mb-12"
>
<p className="text-amber-200/30 text-xs tracking-[0.5em] uppercase font-lora mb-3">
Begleitung in Tönen
</p>
<h2 className="font-cormorant italic text-4xl sm:text-5xl text-amber-100/70 mb-3">
Musik
</h2>
<div className="flex items-center justify-center gap-4">
<div className="h-px w-12 bg-amber-400/15" />
<span className="text-amber-400/25"></span>
<div className="h-px w-12 bg-amber-400/15" />
</div>
</motion.div>
{/* ── No uploads: ambient player ── */}
{!hasTrack && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1 }}
className="text-center"
>
<p className="font-lora text-amber-100/30 text-sm mb-8 leading-relaxed max-w-xs mx-auto">
Sanfte Klangschalen begleiten dich
lade eigene Musik hoch, um sie hier abzuspielen.
</p>
<div className="flex flex-col items-center gap-5">
<motion.button
onClick={() => { ambient.toggle(); setMiniVisible(true) }}
whileTap={{ scale: 0.94 }}
className="w-16 h-16 rounded-full bg-amber-400/10 hover:bg-amber-400/18 border border-amber-400/20 hover:border-amber-400/40 text-amber-300/70 flex items-center justify-center transition-all duration-200"
style={{ boxShadow: ambient.playing ? '0 0 28px rgba(196,160,74,0.14)' : undefined }}
>
{ambient.playing
? <Pause size={22} />
: <Play size={22} className="ml-1" />
}
</motion.button>
{ambient.playing && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-end gap-1"
>
<WaveformBars playing={ambient.playing} />
</motion.div>
)}
<p className="font-cormorant italic text-amber-200/35 text-lg">
Klangschalen
</p>
</div>
</motion.div>
)}
{/* ── With uploads: track list + controls ── */}
{hasTrack && (
<>
<div className="space-y-0.5 mb-8">
{tracks.map((t, i) => (
<motion.button
key={t.id}
initial={{ opacity: 0, x: -8 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.05 }}
onClick={() => playTrack(i)}
className={`w-full flex items-center gap-4 px-4 py-3.5 rounded-xl transition-all duration-200 text-left group ${
i === current
? 'bg-amber-400/[0.07] border border-amber-400/15'
: 'hover:bg-white/[0.03] border border-transparent'
}`}
>
<div className="w-7 flex-shrink-0 flex items-center justify-center">
{i === current && playing
? <WaveformBars playing={playing} />
: <span className={`font-lora text-xs tabular-nums ${i === current ? 'text-amber-400/60' : 'text-amber-100/20'}`}>
{String(i + 1).padStart(2, '0')}
</span>
}
</div>
<span className={`font-cormorant italic text-lg flex-1 min-w-0 truncate transition-colors ${
i === current ? 'text-amber-200/80' : 'text-amber-100/35 group-hover:text-amber-100/60'
}`}>
{trackName(i)}
</span>
<span className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
{i === current && playing
? <Pause size={13} className="text-amber-400/50" />
: <Play size={13} className="text-amber-400/40" />
}
</span>
</motion.button>
))}
</div>
{/* Controls */}
<div className="border-t border-amber-400/10 pt-6">
<p className="text-center font-cormorant italic text-amber-200/50 text-xl mb-5 truncate px-4">
{trackName(current)}
</p>
<div className="flex items-center gap-3 mb-6">
<span className="font-lora text-xs text-amber-100/20 w-9 text-right tabular-nums">{formatTime(elapsed)}</span>
<input
type="range" min="0" max="100" step="0.1" value={progress}
onChange={handleSeek}
className="flex-1 cursor-pointer accent-amber-500"
style={{ height: '2px' }}
/>
<span className="font-lora text-xs text-amber-100/20 w-9 tabular-nums">{formatTime(duration)}</span>
</div>
<div className="flex items-center justify-center gap-8 mb-5">
<button onClick={prev} className="text-amber-400/30 hover:text-amber-400/70 transition-colors">
<SkipBack size={20} />
</button>
<motion.button
onClick={() => { togglePlay(); setMiniVisible(true) }}
whileTap={{ scale: 0.93 }}
className="w-14 h-14 rounded-full bg-amber-400/10 hover:bg-amber-400/18 border border-amber-400/20 hover:border-amber-400/40 text-amber-300/80 flex items-center justify-center transition-all duration-200"
style={{ boxShadow: playing ? '0 0 24px rgba(196,160,74,0.15)' : undefined }}
>
{playing ? <Pause size={22} /> : <Play size={22} className="ml-0.5" />}
</motion.button>
<button onClick={next} className="text-amber-400/30 hover:text-amber-400/70 transition-colors">
<SkipForward size={20} />
</button>
</div>
<div className="flex items-center justify-center gap-3">
<button onClick={() => setMuted((m) => !m)} className="text-amber-600/40 hover:text-amber-400/60 transition-colors">
{muted ? <VolumeX size={15} /> : <Volume2 size={15} />}
</button>
<input
type="range" min="0" max="1" step="0.01"
value={muted ? 0 : volume}
onChange={(e) => { setVolume(parseFloat(e.target.value)); setMuted(false) }}
className="w-28 accent-amber-600 cursor-pointer"
style={{ height: '2px' }}
/>
</div>
</div>
</>
)}
</div>
</section>
{/* ── Floating mini-player ─────────────────────────────────── */}
<AnimatePresence>
{miniVisible && (
<motion.div
initial={{ opacity: 0, y: 16, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 16, scale: 0.95 }}
transition={{ duration: 0.25 }}
className="fixed bottom-6 right-6 z-50 flex items-center gap-3 bg-stone-950/96 backdrop-blur-md px-4 py-3 rounded-2xl border border-amber-900/25 shadow-2xl"
>
<div className="relative flex-shrink-0">
<div className={`w-8 h-8 rounded-full bg-amber-800/40 flex items-center justify-center ${miniPlaying ? 'ring-1 ring-amber-400/30' : ''}`}>
{miniPlaying
? <WaveformBars playing />
: <Play size={12} className="text-amber-300/70 ml-0.5" />
}
</div>
{miniPlaying && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
)}
</div>
<button
onClick={() => document.getElementById('musik')?.scrollIntoView({ behavior: 'smooth' })}
className="max-w-[130px] text-left"
>
<p className="text-amber-200/70 font-cormorant italic text-sm truncate leading-tight">
{miniLabel}
</p>
<p className="text-amber-600/40 text-xs font-lora mt-0.5">
{hasTrack
? (playing ? `${formatTime(elapsed)} / ${formatTime(duration)}` : 'pausiert')
: (ambient.playing ? 'läuft …' : 'pausiert')
}
</p>
</button>
<button
onClick={hasTrack ? togglePlay : () => { ambient.toggle() }}
className="text-amber-400/60 hover:text-amber-300 transition-colors"
>
{miniPlaying ? <Pause size={16} /> : <Play size={16} />}
</button>
<button
onClick={() => setMiniVisible(false)}
className="text-amber-800/60 hover:text-amber-500/80 transition-colors ml-1"
>
<X size={13} />
</button>
</motion.div>
)}
</AnimatePresence>
{userMuted ? <VolumeX size={22} /> : <Volume2 size={22} />}
</button>
</>
)
}
+248
View File
@@ -0,0 +1,248 @@
'use client'
import { motion } from 'framer-motion'
const fade = {
initial: { opacity: 0, y: 20 },
whileInView: { opacity: 1, y: 0 },
viewport: { once: true },
transition: { duration: 0.8 },
}
export default function TributeSection() {
return (
<section id="ueber-oma" className="py-20 sm:py-28">
<div className="max-w-2xl mx-auto px-6">
{/* ── Family perspective ─────────────────────────────────── */}
<motion.div {...fade} className="text-center mb-16">
<p className="text-warm-gold/50 text-xs tracking-[0.5em] uppercase font-lora mb-4">
Für immer in unseren Herzen
</p>
<h2 className="font-cormorant italic text-5xl sm:text-6xl text-warm-brown mb-4">
Unsere Oma
</h2>
<div className="flex items-center justify-center gap-4">
<div className="h-px w-16 bg-warm-gold/30" />
<span className="text-warm-gold/40 text-lg">&#10045;</span>
<div className="h-px w-16 bg-warm-gold/30" />
</div>
</motion.div>
<div className="space-y-8 font-lora text-warm-brown/80 text-base sm:text-lg leading-relaxed">
<motion.p {...fade}>
Maria Malejka war keine Frau der großen Worte. Sie hat nicht viel
geredet über das, was sie fühlt. Sie hat es gezeigt. Mit Essen, das
stundenlang auf dem Herd stand. Mit einem Haus, in dem es immer warm
war. Mit einer Tür, die für jeden offen stand.
</motion.p>
<motion.p {...fade}>
Zusammen mit Josef, ihrem Mann, ist sie aus Polen nach Deutschland
gekommen. Über 60 Jahre waren sie verheiratet. Diamantene Hochzeit.
Drei Töchter hat sie großgezogen: Beate, Renate und Margret. Sie
hat hier ein Zuhause aufgebaut. Nicht nur vier Wände, sondern einen
Ort, an den man immer zurückkommen konnte.
</motion.p>
<motion.p {...fade}>
Sie hat nichts weggeworfen. Alles hatte noch einen Zweck, alles
wurde wiederverwendet. Kein Schickimicki, kein Trara. Einfach
und ehrlich, so war sie. Und genau so hat sie auch geliebt.
Nicht laut, aber immer da.
</motion.p>
<motion.p {...fade}>
Weihnachten bei Oma und Opa war jedes Jahr das Gleiche, und genau
das hat es so besonders gemacht. Der Kamin lief. Der Karpfen
schwamm in der Badewanne. Die Geschenke lagen im Wohnzimmer,
aber die Tür blieb zu, bis alle fertig gegessen hatten. Berge
von Essen. Und sie mittendrin, glücklich wenn alle satt waren.
Sie hat es geliebt, zu geben.
</motion.p>
<motion.p {...fade}>
Sie war stur. Das wissen alle, die sie kannten. Sie hat die Dinge
auf ihre Weise gemacht und sich nichts sagen lassen. Aber genau
diese Sturheit hat auch die Familie zusammengehalten. Sie war der
Anker. Der feste Punkt, um den sich alles gedreht hat.
</motion.p>
<motion.p {...fade}>
Ihre Tiere hat sie geliebt. Ihre Vögel, ihren Hund Ronny. Und
Pico, den Familienhund, als wäre er ihrer gewesen. Pico ist
letztes Jahr auch von uns gegangen. Vielleicht hat er jetzt
wieder seinen Platz bei ihr gefunden.
</motion.p>
<motion.p {...fade}>
Ihr Haus, in dem so viel passiert ist, wird jetzt von Jacky und
Niklas weitergeführt. Bald wird dort ein neues Leben beginnen.
Ein Kind, das seine Uroma nie kennenlernen wird. Aber dessen
Ultraschallbild bei ihr im Sarg liegt. So nah, wie es nur geht.
</motion.p>
<motion.div
{...fade}
className="border-l-2 border-warm-gold/30 pl-6 py-2 my-12"
>
<p className="font-cormorant italic text-2xl sm:text-3xl text-warm-brown/70 leading-snug">
Du hast uns ein Zuhause gegeben, Oma.
<br />
Das bleibt. Für immer.
</p>
</motion.div>
<motion.p {...fade}>
Nach einem Oberschenkelhalsbruch wurde sie operiert. Es ging ihr
gut. Sie lief schon wieder. Im Aufenthaltsraum hat sie Blumenvasen
gesehen und gemeint, die könnte man doch mitnehmen. So war sie.
In einer Woche hätte die Reha angefangen.
</motion.p>
<motion.p {...fade}>
Dann eine Lungenembolie. Plötzlich. Ohne Vorwarnung. Am 10. Februar
2026 mussten wir sie gehen lassen.
</motion.p>
<motion.p {...fade}>
Diese Seite ist für dich, Oma. Und für alle, die dich kannten.
Damit wir nicht vergessen. Damit deine Geschichten weitererzählt
werden. Damit noch einmal jemand sagt, wie gut deine Rahmsauce
war.
</motion.p>
</div>
{/* Divider between family and personal */}
<motion.div {...fade} className="my-24 flex items-center justify-center gap-4">
<div className="h-px w-20 bg-warm-gold/15" />
<span className="text-warm-gold/25 text-sm">&#10045;&#10045;&#10045;</span>
<div className="h-px w-20 bg-warm-gold/15" />
</motion.div>
{/* ── Dennis's perspective ───────────────────────────────── */}
<motion.div {...fade} className="text-center mb-16">
<p className="text-warm-gold/50 text-xs tracking-[0.5em] uppercase font-lora mb-4">
Von Dennis
</p>
<h2 className="font-cormorant italic text-5xl sm:text-6xl text-warm-brown mb-4">
Meine Oma
</h2>
<div className="flex items-center justify-center gap-4">
<div className="h-px w-16 bg-warm-gold/30" />
<span className="text-warm-gold/40 text-lg">&#10045;</span>
<div className="h-px w-16 bg-warm-gold/30" />
</div>
</motion.div>
<div className="space-y-8 font-lora text-warm-brown/80 text-base sm:text-lg leading-relaxed">
<motion.p {...fade}>
Ich kam aus der Schule und das Essen stand schon da. Jedes Mal.
Rahmsauce. Ich weiß nicht, wie oft ich die gegessen habe, aber
es war nie genug. Ich würde alles dafür geben, noch einmal an
ihrem Küchentisch zu sitzen.
</motion.p>
<motion.p {...fade}>
Oma roch nach Oma. Ich weiß nicht, wie ich das anders beschreiben
soll. Nicht nach Parfum. Nicht nach irgendwas, das man kaufen kann.
Einfach nach ihr. Wenn ich an sie denke, ist das Erste, was
kommt, dieses Gefühl. Diese Wärme. Der Geruch von ihrem Haus.
</motion.p>
<motion.p {...fade}>
Bei ihr war es immer heiß. Immer. Der Kamin lief, die Heizung
lief, man hat geschwitzt und es war trotzdem schön. An Weihnachten
war der Karpfen in der Badewanne und die Geschenke im Wohnzimmer.
Aber die Tür blieb zu. Erst essen, dann Geschenke. Das war Gesetz.
Und wir haben uns jedes Mal gefreut, als wären wir fünf.
</motion.p>
<motion.p {...fade}>
Wenn Pico bei ihr war, hab ich nicht mehr existiert. Sie hat mit
ihm geredet, ihn gefüttert, ihn verwöhnt. Ich saß daneben und
war Luft. Aber das war okay. Weil sie so glücklich war dabei.
</motion.p>
<motion.p {...fade}>
Pico ist letztes Jahr gestorben. Und jetzt Oma. Ich stelle mir
vor, wie sie irgendwo sitzt und er neben ihr liegt und sie ihm
wieder irgendwas erzählt, was er nicht versteht. Und er hört
trotzdem zu.
</motion.p>
<motion.p {...fade}>
Sie war stur. Richtig stur. Deswegen hatte sie auch den
Oberschenkelhalsbruch. Weil sie alles alleine machen wollte.
Weil sie sich nichts sagen lassen hat. Man konnte sich aufregen.
Aber im Nachhinein war das auch das, was sie so stark gemacht hat.
</motion.p>
<motion.div
{...fade}
className="border-l-2 border-warm-gold/30 pl-6 py-2 my-12"
>
<p className="font-cormorant italic text-2xl sm:text-3xl text-warm-brown/70 leading-snug">
Ich habe es dir viel zu selten gesagt, Oma.
<br />
Aber ich habe dich geliebt. Sehr.
</p>
</motion.div>
<motion.p {...fade}>
Das letzte Mal hab ich sie im Krankenhaus besucht. In der
Geriatrie. Sie lief schon wieder, es ging ihr gut. Sie hat
im Aufenthaltsraum Blumenvasen gesehen und meinte, die könnte
man ja mitnehmen. Ich hab gelacht. So war sie halt.
</motion.p>
<motion.p {...fade}>
Ihr Zimmer war gegenüber von dem Zimmer, in dem meine Mutter
wochenlang gelegen hatte, als sie mit mir schwanger war. Mama
durfte sich kaum bewegen damals. Im selben Flur, in dem ich auf
die Welt kam, ist Oma gegangen. Das lässt mich nicht los.
</motion.p>
<motion.p {...fade}>
Am 10. Februar, um 13:13 Uhr, schrieb mir meine Schwester
auf Telegram: <span className="italic text-warm-brown">Sie
möchten die Reanimation abbrechen."</span>
</motion.p>
<motion.p {...fade}>
Jacky ist schwanger. Sie hat den Ultraschall mit in den Sarg
gelegt. Ein Kind, das Oma nie treffen wird, aber das in ihrem
Haus aufwachsen wird. In den Wänden, die nach Oma riechen. In
der Küche, in der die Rahmsauce stand.
</motion.p>
<motion.p {...fade}>
Diese Seite ist für dich. Damit ich nicht vergesse. Damit
niemand vergisst.
</motion.p>
</div>
{/* Closing details */}
<motion.div
{...fade}
className="mt-20 text-center"
>
<div className="flex items-center justify-center gap-4 mb-8">
<div className="h-px w-12 bg-warm-gold/20" />
<span className="text-warm-gold/30">&#10045;</span>
<div className="h-px w-12 bg-warm-gold/20" />
</div>
<p className="font-cormorant italic text-warm-brown/50 text-lg">
Maria Malejka
</p>
<p className="font-lora text-warm-brown-light/40 text-sm mt-1">
29. November 1944 · 10. Februar 2026
</p>
<p className="font-lora text-warm-brown-light/30 text-xs mt-3 tracking-wide">
Beerdigung am 20. Februar 2026
</p>
</motion.div>
</div>
</section>
)
}
+6 -8
View File
@@ -4,19 +4,17 @@ import fs from 'fs'
export type { Memory, MediaItem } from './types'
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
// Ensure upload directories exist
for (const dir of ['uploads/photos', 'uploads/videos', 'uploads/music']) {
fs.mkdirSync(path.join(DATA_DIR, dir), { recursive: true })
}
const dbPath = path.join(DATA_DIR, 'oma.db')
const DATA_DIR = path.resolve(process.cwd(), process.env.DATA_DIR || 'data')
let _db: DatabaseSync | null = null
export function getDb(): DatabaseSync {
if (!_db) {
// Ensure upload directories exist (only at runtime, not build time)
for (const dir of ['uploads/photos', 'uploads/videos', 'uploads/music']) {
fs.mkdirSync(path.join(DATA_DIR, dir), { recursive: true })
}
const dbPath = path.join(DATA_DIR, 'oma.db')
_db = new DatabaseSync(dbPath)
initDb(_db)
}
+26 -7
View File
@@ -1,6 +1,10 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -10,13 +14,28 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
"@/*": [
"./src/*"
]
},
"target": "ES2017"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}