feat(ui): Favoriten-Liste, Dismiss-from-Recent, Inline-Rename, Lucide-Icons
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m31s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m31s
Homepage:
- Neue Sektion "Deine Favoriten" über "Zuletzt hinzugefügt" (alphabetisch
sortiert, lädt wenn Profil aktiv ist; versteckt sonst)
- Jede Karte in "Zuletzt hinzugefügt" hat jetzt oben-rechts ein X-Icon
zum Ausblenden. Das Rezept selbst bleibt in der DB — nur die
Anzeige in der Recent-Liste wird per recipe.hidden_from_recent = 1
unterdrückt. Section versteckt sich, wenn die Liste leer wird.
DB:
- Neue Migration 004_recipe_hidden_from_recent.sql (+Index)
- listFavoritesForProfile in search-local.ts (ORDER BY title NOCASE)
- setRecipeHiddenFromRecent in actions.ts
API:
- GET /api/recipes/favorites?profile_id=X
- PATCH /api/recipes/[id] akzeptiert jetzt title und/oder
hidden_from_recent (Zod-Schema mit refine)
Rezept-Detail:
- Titel ist jetzt inline editierbar: kleines Stift-Icon rechts neben
H1. Click öffnet Input, Enter speichert (PATCH), Escape bricht ab.
Kein location.reload() mehr.
- RecipeView bekommt neuen Snippet-Prop titleSlot für Title-Override.
- Neue Aktionsreihenfolge:
Zeile 1: Favorit | Wunschliste | Drucken
Zeile 2: Heute gekocht | Löschen
(Umbenennen ist jetzt am Titel statt in der Leiste.)
Icons (lucide-svelte, neues Dep):
- Emoji-Icons durch Lucide-SVGs ersetzt auf Startseite, Header,
Rezept-Detail, Wunschliste, Header-Dropdown:
🍽️→Heart/Utensils, ⚙️→Settings, 🥘→CookingPot, 🌐→Globe,
♥/♡→Heart(filled), 🖨→Printer, ✎→Pencil, 🗑→Trash2, ✓→Check,
🍳→ChefHat, X→X
- Header-Brand-Badge auf Mobile behält sein 🍳 (ist im ::after-Pseudo,
Lucide käme da nicht sauber rein).
- SearchLoader-Emojis bleiben — die sind Teil der Animations-Charme.
Tests: 99/99 grün (bestehend), Typecheck 0 Fehler.
This commit is contained in:
30
package-lock.json
generated
30
package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
"linkedom": "^0.18.5",
|
"linkedom": "^0.18.5",
|
||||||
|
"lucide-svelte": "^1.0.1",
|
||||||
"yauzl": "^3.3.0",
|
"yauzl": "^3.3.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
@@ -443,7 +444,6 @@
|
|||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
@@ -454,7 +454,6 @@
|
|||||||
"version": "2.3.5",
|
"version": "2.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/gen-mapping": "^0.3.5",
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
@@ -465,7 +464,6 @@
|
|||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
@@ -475,14 +473,12 @@
|
|||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/trace-mapping": {
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
"version": "0.3.31",
|
"version": "0.3.31",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/resolve-uri": "^3.1.0",
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
@@ -963,7 +959,6 @@
|
|||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
|
||||||
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
|
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"acorn": "^8.9.0"
|
"acorn": "^8.9.0"
|
||||||
@@ -1097,7 +1092,6 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
@@ -1129,7 +1123,6 @@
|
|||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/yauzl": {
|
"node_modules/@types/yauzl": {
|
||||||
@@ -1280,7 +1273,6 @@
|
|||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
@@ -1445,7 +1437,6 @@
|
|||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||||
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
|
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -1471,7 +1462,6 @@
|
|||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -1750,7 +1740,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -2041,7 +2030,6 @@
|
|||||||
"version": "5.7.1",
|
"version": "5.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz",
|
||||||
"integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==",
|
"integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/dom-serializer": {
|
"node_modules/dom-serializer": {
|
||||||
@@ -2192,14 +2180,12 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||||
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/esrap": {
|
"node_modules/esrap": {
|
||||||
"version": "2.2.5",
|
"version": "2.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz",
|
||||||
"integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==",
|
"integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||||
@@ -2619,7 +2605,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
@@ -2641,11 +2626,19 @@
|
|||||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-svelte": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"svelte": "^3 || ^4 || ^5.0.0-next.42"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
@@ -3434,7 +3427,6 @@
|
|||||||
"version": "5.55.4",
|
"version": "5.55.4",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz",
|
||||||
"integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==",
|
"integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.4",
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
@@ -3486,7 +3478,6 @@
|
|||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "^1.0.6"
|
"@types/estree": "^1.0.6"
|
||||||
@@ -3960,7 +3951,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/zip-stream": {
|
"node_modules/zip-stream": {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
"linkedom": "^0.18.5",
|
"linkedom": "^0.18.5",
|
||||||
|
"lucide-svelte": "^1.0.1",
|
||||||
"yauzl": "^3.3.0",
|
"yauzl": "^3.3.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
recipe: Recipe;
|
recipe: Recipe;
|
||||||
showActions?: import('svelte').Snippet;
|
showActions?: import('svelte').Snippet;
|
||||||
banner?: import('svelte').Snippet;
|
banner?: import('svelte').Snippet;
|
||||||
|
titleSlot?: import('svelte').Snippet;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { recipe, showActions, banner }: Props = $props();
|
let { recipe, showActions, banner, titleSlot }: Props = $props();
|
||||||
|
|
||||||
const defaultServings = $derived(recipe.servings_default ?? 4);
|
const defaultServings = $derived(recipe.servings_default ?? 4);
|
||||||
let servingsOverride = $state<number | null>(null);
|
let servingsOverride = $state<number | null>(null);
|
||||||
@@ -61,7 +62,11 @@
|
|||||||
<img src={imageSrc} alt="" class="cover" loading="eager" referrerpolicy="no-referrer" />
|
<img src={imageSrc} alt="" class="cover" loading="eager" referrerpolicy="no-referrer" />
|
||||||
{/if}
|
{/if}
|
||||||
<div class="hdr-body">
|
<div class="hdr-body">
|
||||||
<h1>{recipe.title}</h1>
|
{#if titleSlot}
|
||||||
|
{@render titleSlot()}
|
||||||
|
{:else}
|
||||||
|
<h1>{recipe.title}</h1>
|
||||||
|
{/if}
|
||||||
{#if recipe.description}
|
{#if recipe.description}
|
||||||
<p class="desc">{recipe.description}</p>
|
<p class="desc">{recipe.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Let the user dismiss individual recipes from the "Zuletzt hinzugefügt"
|
||||||
|
-- list on the homepage. The recipe itself stays searchable and fully
|
||||||
|
-- functional — only its appearance in the "recent" list is suppressed.
|
||||||
|
ALTER TABLE recipe ADD COLUMN hidden_from_recent INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE INDEX idx_recipe_hidden_from_recent ON recipe(hidden_from_recent, created_at);
|
||||||
@@ -150,3 +150,14 @@ export function renameRecipe(
|
|||||||
recipeId
|
recipeId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setRecipeHiddenFromRecent(
|
||||||
|
db: Database.Database,
|
||||||
|
recipeId: number,
|
||||||
|
hidden: boolean
|
||||||
|
): void {
|
||||||
|
db.prepare('UPDATE recipe SET hidden_from_recent = ? WHERE id = ?').run(
|
||||||
|
hidden ? 1 : 0,
|
||||||
|
recipeId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,8 +67,30 @@ export function listRecentRecipes(
|
|||||||
(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars,
|
(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars,
|
||||||
(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at
|
(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at
|
||||||
FROM recipe r
|
FROM recipe r
|
||||||
|
WHERE r.hidden_from_recent = 0
|
||||||
ORDER BY r.created_at DESC
|
ORDER BY r.created_at DESC
|
||||||
LIMIT ?`
|
LIMIT ?`
|
||||||
)
|
)
|
||||||
.all(limit) as SearchHit[];
|
.all(limit) as SearchHit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listFavoritesForProfile(
|
||||||
|
db: Database.Database,
|
||||||
|
profileId: number
|
||||||
|
): SearchHit[] {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT r.id,
|
||||||
|
r.title,
|
||||||
|
r.description,
|
||||||
|
r.image_path,
|
||||||
|
r.source_domain,
|
||||||
|
(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars,
|
||||||
|
(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at
|
||||||
|
FROM recipe r
|
||||||
|
JOIN favorite f ON f.recipe_id = r.id
|
||||||
|
WHERE f.profile_id = ?
|
||||||
|
ORDER BY r.title COLLATE NOCASE`
|
||||||
|
)
|
||||||
|
.all(profileId) as SearchHit[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto, afterNavigate } from '$app/navigation';
|
import { goto, afterNavigate } from '$app/navigation';
|
||||||
|
import { Heart, Settings, CookingPot, Globe, Utensils } from 'lucide-svelte';
|
||||||
import { profileStore } from '$lib/client/profile.svelte';
|
import { profileStore } from '$lib/client/profile.svelte';
|
||||||
import ProfileSwitcher from '$lib/components/ProfileSwitcher.svelte';
|
import ProfileSwitcher from '$lib/components/ProfileSwitcher.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
@@ -151,7 +152,7 @@
|
|||||||
{#if r.image_path}
|
{#if r.image_path}
|
||||||
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
|
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="dd-placeholder">🥘</div>
|
<div class="dd-placeholder"><CookingPot size={22} /></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="dd-body">
|
<div class="dd-body">
|
||||||
<div class="dd-title">{r.title}</div>
|
<div class="dd-title">{r.title}</div>
|
||||||
@@ -168,7 +169,8 @@
|
|||||||
href={`/search/web?q=${encodeURIComponent(navQuery.trim())}`}
|
href={`/search/web?q=${encodeURIComponent(navQuery.trim())}`}
|
||||||
onclick={pickHit}
|
onclick={pickHit}
|
||||||
>
|
>
|
||||||
🌐 Im Internet weitersuchen
|
<Globe size={16} strokeWidth={2} />
|
||||||
|
<span>Im Internet weitersuchen</span>
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="dd-section">Keine lokalen Rezepte – aus dem Internet:</p>
|
<p class="dd-section">Keine lokalen Rezepte – aus dem Internet:</p>
|
||||||
@@ -190,7 +192,7 @@
|
|||||||
{#if w.thumbnail}
|
{#if w.thumbnail}
|
||||||
<img src={w.thumbnail} alt="" loading="lazy" />
|
<img src={w.thumbnail} alt="" loading="lazy" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="dd-placeholder">🍽️</div>
|
<div class="dd-placeholder"><Utensils size={22} /></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="dd-body">
|
<div class="dd-body">
|
||||||
<div class="dd-title">{w.title}</div>
|
<div class="dd-title">{w.title}</div>
|
||||||
@@ -209,8 +211,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="bar-right">
|
<div class="bar-right">
|
||||||
<a href="/wishlist" class="nav-link" aria-label="Wunschliste">🍽️</a>
|
<a href="/wishlist" class="nav-link" aria-label="Wunschliste">
|
||||||
<a href="/admin" class="nav-link" aria-label="Einstellungen">⚙️</a>
|
<Heart size={20} strokeWidth={2} />
|
||||||
|
</a>
|
||||||
|
<a href="/admin" class="nav-link" aria-label="Einstellungen">
|
||||||
|
<Settings size={20} strokeWidth={2} />
|
||||||
|
</a>
|
||||||
<ProfileSwitcher />
|
<ProfileSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -363,9 +369,11 @@
|
|||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
.dd-web {
|
.dd-web {
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
padding: 0.75rem 0.85rem;
|
padding: 0.75rem 0.85rem;
|
||||||
text-align: center;
|
|
||||||
border-top: 1px solid #e4eae7;
|
border-top: 1px solid #e4eae7;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #2b6a3d;
|
color: #2b6a3d;
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { CookingPot, Globe, X } from 'lucide-svelte';
|
||||||
import type { SearchHit } from '$lib/server/recipes/search-local';
|
import type { SearchHit } from '$lib/server/recipes/search-local';
|
||||||
import type { WebHit } from '$lib/server/search/searxng';
|
import type { WebHit } from '$lib/server/search/searxng';
|
||||||
import { randomQuote } from '$lib/quotes';
|
import { randomQuote } from '$lib/quotes';
|
||||||
import SearchLoader from '$lib/components/SearchLoader.svelte';
|
import SearchLoader from '$lib/components/SearchLoader.svelte';
|
||||||
|
import { profileStore } from '$lib/client/profile.svelte';
|
||||||
|
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
let quote = $state('');
|
let quote = $state('');
|
||||||
let recent = $state<SearchHit[]>([]);
|
let recent = $state<SearchHit[]>([]);
|
||||||
|
let favorites = $state<SearchHit[]>([]);
|
||||||
let hits = $state<SearchHit[]>([]);
|
let hits = $state<SearchHit[]>([]);
|
||||||
let webHits = $state<WebHit[]>([]);
|
let webHits = $state<WebHit[]>([]);
|
||||||
let searching = $state(false);
|
let searching = $state(false);
|
||||||
@@ -15,11 +18,34 @@
|
|||||||
let webError = $state<string | null>(null);
|
let webError = $state<string | null>(null);
|
||||||
let searchedFor = $state<string | null>(null);
|
let searchedFor = $state<string | null>(null);
|
||||||
|
|
||||||
onMount(async () => {
|
async function loadRecent() {
|
||||||
quote = randomQuote();
|
|
||||||
const res = await fetch('/api/recipes/search');
|
const res = await fetch('/api/recipes/search');
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
recent = body.hits;
|
recent = body.hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFavorites(profileId: number) {
|
||||||
|
const res = await fetch(`/api/recipes/favorites?profile_id=${profileId}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
favorites = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = await res.json();
|
||||||
|
favorites = body.hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
quote = randomQuote();
|
||||||
|
void loadRecent();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const active = profileStore.active;
|
||||||
|
if (!active) {
|
||||||
|
favorites = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void loadFavorites(active.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -82,6 +108,17 @@
|
|||||||
void runSearch(q);
|
void runSearch(q);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function dismissFromRecent(recipeId: number, e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
recent = recent.filter((r) => r.id !== recipeId);
|
||||||
|
await fetch(`/api/recipes/${recipeId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ hidden_from_recent: true })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const activeSearch = $derived(query.trim().length > 3);
|
const activeSearch = $derived(query.trim().length > 3);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -112,7 +149,7 @@
|
|||||||
{#if r.image_path}
|
{#if r.image_path}
|
||||||
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
|
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="placeholder">🥘</div>
|
<div class="placeholder"><CookingPot size={36} /></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="title">{r.title}</div>
|
<div class="title">{r.title}</div>
|
||||||
@@ -125,10 +162,12 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
<a class="web-more" href={`/search/web?q=${encodeURIComponent(query.trim())}`}>
|
<a class="web-more" href={`/search/web?q=${encodeURIComponent(query.trim())}`}>
|
||||||
🌐 Im Internet weitersuchen
|
<Globe size={18} strokeWidth={2} /> Im Internet weitersuchen
|
||||||
</a>
|
</a>
|
||||||
{:else if searchedFor === query.trim()}
|
{:else if searchedFor === query.trim()}
|
||||||
<p class="muted no-local-msg">Keine lokalen Rezepte für „{searchedFor}" — Ergebnisse aus dem Internet:</p>
|
<p class="muted no-local-msg">
|
||||||
|
Keine lokalen Rezepte für „{searchedFor}" — Ergebnisse aus dem Internet:
|
||||||
|
</p>
|
||||||
{#if webSearching}
|
{#if webSearching}
|
||||||
<SearchLoader scope="web" />
|
<SearchLoader scope="web" />
|
||||||
{:else if webError}
|
{:else if webError}
|
||||||
@@ -141,7 +180,7 @@
|
|||||||
{#if w.thumbnail}
|
{#if w.thumbnail}
|
||||||
<img src={w.thumbnail} alt="" loading="lazy" />
|
<img src={w.thumbnail} alt="" loading="lazy" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="placeholder">🍽️</div>
|
<div class="placeholder"><CookingPot size={36} /></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="title">{w.title}</div>
|
<div class="title">{w.title}</div>
|
||||||
@@ -156,29 +195,62 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{:else if recent.length > 0}
|
{:else}
|
||||||
<section class="recent">
|
{#if profileStore.active && favorites.length > 0}
|
||||||
<h2>Zuletzt hinzugefügt</h2>
|
<section class="listing">
|
||||||
<ul class="cards">
|
<h2>Deine Favoriten</h2>
|
||||||
{#each recent as r (r.id)}
|
<ul class="cards">
|
||||||
<li>
|
{#each favorites as r (r.id)}
|
||||||
<a href={`/recipes/${r.id}`} class="card">
|
<li class="card-wrap">
|
||||||
{#if r.image_path}
|
<a href={`/recipes/${r.id}`} class="card">
|
||||||
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
|
{#if r.image_path}
|
||||||
{:else}
|
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
|
||||||
<div class="placeholder">🥘</div>
|
{:else}
|
||||||
{/if}
|
<div class="placeholder"><CookingPot size={36} /></div>
|
||||||
<div class="card-body">
|
|
||||||
<div class="title">{r.title}</div>
|
|
||||||
{#if r.source_domain}
|
|
||||||
<div class="domain">{r.source_domain}</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
<div class="card-body">
|
||||||
</a>
|
<div class="title">{r.title}</div>
|
||||||
</li>
|
{#if r.source_domain}
|
||||||
{/each}
|
<div class="domain">{r.source_domain}</div>
|
||||||
</ul>
|
{/if}
|
||||||
</section>
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{#if recent.length > 0}
|
||||||
|
<section class="listing">
|
||||||
|
<h2>Zuletzt hinzugefügt</h2>
|
||||||
|
<ul class="cards">
|
||||||
|
{#each recent as r (r.id)}
|
||||||
|
<li class="card-wrap">
|
||||||
|
<a href={`/recipes/${r.id}`} class="card">
|
||||||
|
{#if r.image_path}
|
||||||
|
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<div class="placeholder"><CookingPot size={36} /></div>
|
||||||
|
{/if}
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="title">{r.title}</div>
|
||||||
|
{#if r.source_domain}
|
||||||
|
<div class="domain">{r.source_domain}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
class="dismiss"
|
||||||
|
aria-label="Aus Zuletzt-hinzugefügt entfernen"
|
||||||
|
onclick={(e) => dismissFromRecent(r.id, e)}
|
||||||
|
>
|
||||||
|
<X size={16} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -219,10 +291,10 @@
|
|||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
.results,
|
.results,
|
||||||
.recent {
|
.listing {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
.recent h2 {
|
.listing h2 {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
color: #444;
|
color: #444;
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.75rem;
|
||||||
@@ -249,6 +321,9 @@
|
|||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
.card-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
.card {
|
.card {
|
||||||
display: block;
|
display: block;
|
||||||
background: white;
|
background: white;
|
||||||
@@ -270,7 +345,7 @@
|
|||||||
background: #eef3ef;
|
background: #eef3ef;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
font-size: 2rem;
|
color: #8fb097;
|
||||||
}
|
}
|
||||||
.card-body {
|
.card-body {
|
||||||
padding: 0.6rem 0.75rem 0.75rem;
|
padding: 0.6rem 0.75rem 0.75rem;
|
||||||
@@ -285,8 +360,41 @@
|
|||||||
color: #888;
|
color: #888;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
.dismiss {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.4rem;
|
||||||
|
right: 0.4rem;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
color: #444;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
}
|
||||||
|
.card-wrap:hover .dismiss,
|
||||||
|
.dismiss:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.dismiss {
|
||||||
|
opacity: 1; /* always visible on touch devices */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dismiss:hover {
|
||||||
|
background: #fff;
|
||||||
|
color: #c53030;
|
||||||
|
}
|
||||||
.web-more {
|
.web-more {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding: 0.7rem 1.1rem;
|
padding: 0.7rem 1.1rem;
|
||||||
border: 1px solid #b7d6c2;
|
border: 1px solid #b7d6c2;
|
||||||
|
|||||||
@@ -7,10 +7,18 @@ import {
|
|||||||
listComments,
|
listComments,
|
||||||
listCookingLog,
|
listCookingLog,
|
||||||
listRatings,
|
listRatings,
|
||||||
renameRecipe
|
renameRecipe,
|
||||||
|
setRecipeHiddenFromRecent
|
||||||
} from '$lib/server/recipes/actions';
|
} from '$lib/server/recipes/actions';
|
||||||
|
|
||||||
const RenameSchema = z.object({ title: z.string().min(1).max(200) });
|
const PatchSchema = z
|
||||||
|
.object({
|
||||||
|
title: z.string().min(1).max(200).optional(),
|
||||||
|
hidden_from_recent: z.boolean().optional()
|
||||||
|
})
|
||||||
|
.refine((v) => v.title !== undefined || v.hidden_from_recent !== undefined, {
|
||||||
|
message: 'Need title or hidden_from_recent'
|
||||||
|
});
|
||||||
|
|
||||||
function parseId(raw: string): number {
|
function parseId(raw: string): number {
|
||||||
const id = Number(raw);
|
const id = Number(raw);
|
||||||
@@ -34,9 +42,15 @@ export const GET: RequestHandler = async ({ params }) => {
|
|||||||
export const PATCH: RequestHandler = async ({ params, request }) => {
|
export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||||
const id = parseId(params.id!);
|
const id = parseId(params.id!);
|
||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null);
|
||||||
const parsed = RenameSchema.safeParse(body);
|
const parsed = PatchSchema.safeParse(body);
|
||||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||||
renameRecipe(getDb(), id, parsed.data.title);
|
const db = getDb();
|
||||||
|
if (parsed.data.title !== undefined) {
|
||||||
|
renameRecipe(db, id, parsed.data.title);
|
||||||
|
}
|
||||||
|
if (parsed.data.hidden_from_recent !== undefined) {
|
||||||
|
setRecipeHiddenFromRecent(db, id, parsed.data.hidden_from_recent);
|
||||||
|
}
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
14
src/routes/api/recipes/favorites/+server.ts
Normal file
14
src/routes/api/recipes/favorites/+server.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { getDb } from '$lib/server/db';
|
||||||
|
import { listFavoritesForProfile } from '$lib/server/recipes/search-local';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const raw = url.searchParams.get('profile_id');
|
||||||
|
const profileId = raw === null ? NaN : Number(raw);
|
||||||
|
if (!Number.isInteger(profileId) || profileId <= 0) {
|
||||||
|
error(400, { message: 'profile_id required' });
|
||||||
|
}
|
||||||
|
const hits = listFavoritesForProfile(getDb(), profileId);
|
||||||
|
return json({ hits });
|
||||||
|
};
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import {
|
||||||
|
Heart,
|
||||||
|
Utensils,
|
||||||
|
Printer,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
ChefHat,
|
||||||
|
Check,
|
||||||
|
X
|
||||||
|
} from 'lucide-svelte';
|
||||||
import RecipeView from '$lib/components/RecipeView.svelte';
|
import RecipeView from '$lib/components/RecipeView.svelte';
|
||||||
import StarRating from '$lib/components/StarRating.svelte';
|
import StarRating from '$lib/components/StarRating.svelte';
|
||||||
import { profileStore } from '$lib/client/profile.svelte';
|
import { profileStore } from '$lib/client/profile.svelte';
|
||||||
@@ -17,11 +27,17 @@
|
|||||||
let onWishlist = $state(false);
|
let onWishlist = $state(false);
|
||||||
let newComment = $state('');
|
let newComment = $state('');
|
||||||
|
|
||||||
|
let title = $state('');
|
||||||
|
let editingTitle = $state(false);
|
||||||
|
let titleDraft = $state('');
|
||||||
|
let titleInput: HTMLInputElement | null = $state(null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
ratings = [...data.ratings];
|
ratings = [...data.ratings];
|
||||||
comments = [...data.comments];
|
comments = [...data.comments];
|
||||||
cookingLog = [...data.cooking_log];
|
cookingLog = [...data.cooking_log];
|
||||||
favoriteProfileIds = [...data.favorite_profile_ids];
|
favoriteProfileIds = [...data.favorite_profile_ids];
|
||||||
|
title = data.recipe.title;
|
||||||
});
|
});
|
||||||
|
|
||||||
const myRating = $derived(
|
const myRating = $derived(
|
||||||
@@ -123,7 +139,7 @@
|
|||||||
async function deleteRecipe() {
|
async function deleteRecipe() {
|
||||||
const ok = await confirmAction({
|
const ok = await confirmAction({
|
||||||
title: 'Rezept löschen?',
|
title: 'Rezept löschen?',
|
||||||
message: `„${data.recipe.title}" wird endgültig entfernt — mit Bewertungen, Kommentaren und Kochjournal-Einträgen.`,
|
message: `„${title}" wird endgültig entfernt — mit Bewertungen, Kommentaren und Kochjournal-Einträgen.`,
|
||||||
confirmLabel: 'Löschen',
|
confirmLabel: 'Löschen',
|
||||||
destructive: true
|
destructive: true
|
||||||
});
|
});
|
||||||
@@ -132,15 +148,50 @@
|
|||||||
goto('/');
|
goto('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renameRecipe() {
|
async function startEditTitle() {
|
||||||
const newTitle = prompt('Neuer Titel:', data.recipe.title);
|
titleDraft = title;
|
||||||
if (!newTitle || newTitle === data.recipe.title) return;
|
editingTitle = true;
|
||||||
await fetch(`/api/recipes/${data.recipe.id}`, {
|
await tick();
|
||||||
|
titleInput?.focus();
|
||||||
|
titleInput?.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditTitle() {
|
||||||
|
editingTitle = false;
|
||||||
|
titleDraft = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTitle() {
|
||||||
|
const next = titleDraft.trim();
|
||||||
|
if (!next || next === title) {
|
||||||
|
editingTitle = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch(`/api/recipes/${data.recipe.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ title: newTitle })
|
body: JSON.stringify({ title: next })
|
||||||
});
|
});
|
||||||
location.reload();
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
await alertAction({
|
||||||
|
title: 'Umbenennen fehlgeschlagen',
|
||||||
|
message: body.message ?? `HTTP ${res.status}`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
title = next;
|
||||||
|
editingTitle = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTitleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
void saveTitle();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelEditTitle();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleWishlist() {
|
async function toggleWishlist() {
|
||||||
@@ -161,7 +212,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshWishlistState() {
|
async function refreshWishlistState() {
|
||||||
// No dedicated GET for a single entry; scan the list and check.
|
|
||||||
const res = await fetch('/api/wishlist?sort=newest');
|
const res = await fetch('/api/wishlist?sort=newest');
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
@@ -196,6 +246,32 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<RecipeView recipe={data.recipe}>
|
<RecipeView recipe={data.recipe}>
|
||||||
|
{#snippet titleSlot()}
|
||||||
|
<div class="title-row">
|
||||||
|
{#if editingTitle}
|
||||||
|
<input
|
||||||
|
bind:this={titleInput}
|
||||||
|
bind:value={titleDraft}
|
||||||
|
class="title-input"
|
||||||
|
onkeydown={onTitleKey}
|
||||||
|
aria-label="Rezept-Titel"
|
||||||
|
maxlength="200"
|
||||||
|
/>
|
||||||
|
<button class="icon-btn save" aria-label="Titel speichern" onclick={saveTitle}>
|
||||||
|
<Check size={20} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn cancel" aria-label="Abbrechen" onclick={cancelEditTitle}>
|
||||||
|
<X size={20} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<h1 class="title-heading">{title}</h1>
|
||||||
|
<button class="icon-btn edit" aria-label="Titel umbenennen" onclick={startEditTitle}>
|
||||||
|
<Pencil size={18} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet showActions()}
|
{#snippet showActions()}
|
||||||
<div class="action-bar">
|
<div class="action-bar">
|
||||||
<div class="rating-row">
|
<div class="rating-row">
|
||||||
@@ -205,22 +281,37 @@
|
|||||||
<span class="avg">⌀ {data.avg_stars.toFixed(1)} ({ratings.length})</span>
|
<span class="avg">⌀ {data.avg_stars.toFixed(1)} ({ratings.length})</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn" class:heart={isFav} onclick={toggleFavorite}>
|
||||||
|
<Heart size={18} strokeWidth={2} fill={isFav ? 'currentColor' : 'none'} />
|
||||||
|
<span>Favorit</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn" class:wish={onWishlist} onclick={toggleWishlist}>
|
||||||
|
{#if onWishlist}
|
||||||
|
<Check size={18} strokeWidth={2.5} />
|
||||||
|
<span>Auf Wunschliste</span>
|
||||||
|
{:else}
|
||||||
|
<Utensils size={18} strokeWidth={2} />
|
||||||
|
<span>Auf Wunschliste setzen</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button class="btn" onclick={() => window.print()}>
|
||||||
|
<Printer size={18} strokeWidth={2} />
|
||||||
|
<span>Drucken</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn" onclick={logCooked}>
|
<button class="btn" onclick={logCooked}>
|
||||||
🍳 Heute gekocht
|
<ChefHat size={18} strokeWidth={2} />
|
||||||
|
<span>Heute gekocht</span>
|
||||||
{#if cookingLog.length > 0}
|
{#if cookingLog.length > 0}
|
||||||
<span class="count">({cookingLog.length})</span>
|
<span class="count">({cookingLog.length})</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn" class:heart={isFav} onclick={toggleFavorite}>
|
<button class="btn danger" onclick={deleteRecipe}>
|
||||||
{isFav ? '♥' : '♡'} Favorit
|
<Trash2 size={18} strokeWidth={2} />
|
||||||
|
<span>Löschen</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn" class:wish={onWishlist} onclick={toggleWishlist}>
|
|
||||||
{onWishlist ? '✓' : '🍽️'} {onWishlist ? 'Auf Wunschliste' : 'Auf Wunschliste setzen'}
|
|
||||||
</button>
|
|
||||||
<button class="btn" onclick={() => window.print()}>🖨 Drucken</button>
|
|
||||||
<button class="btn" onclick={renameRecipe}>✎ Umbenennen</button>
|
|
||||||
<button class="btn danger" onclick={deleteRecipe}>🗑 Löschen</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -269,6 +360,62 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.title-heading {
|
||||||
|
font-size: clamp(1.5rem, 5.5vw, 2rem);
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.title-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: clamp(1.3rem, 5vw, 1.8rem);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border: 2px solid #2b6a3d;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.title-input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.icon-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #444;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.icon-btn.save {
|
||||||
|
background: #2b6a3d;
|
||||||
|
color: white;
|
||||||
|
border-color: #2b6a3d;
|
||||||
|
}
|
||||||
|
.icon-btn.save:hover {
|
||||||
|
background: #235532;
|
||||||
|
}
|
||||||
|
.icon-btn.cancel {
|
||||||
|
color: #c53030;
|
||||||
|
border-color: #f1b4b4;
|
||||||
|
}
|
||||||
.action-bar {
|
.action-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -298,6 +445,9 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
.btn {
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
padding: 0.6rem 0.85rem;
|
padding: 0.6rem 0.85rem;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
border: 1px solid #cfd9d1;
|
border: 1px solid #cfd9d1;
|
||||||
@@ -305,6 +455,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
color: #1a1a1a;
|
||||||
}
|
}
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
background: #f4f8f5;
|
background: #f4f8f5;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { Heart, Trash2, CookingPot } from 'lucide-svelte';
|
||||||
import { profileStore } from '$lib/client/profile.svelte';
|
import { profileStore } from '$lib/client/profile.svelte';
|
||||||
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
|
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
|
||||||
import type { WishlistEntry, SortKey } from '$lib/server/wishlist/repository';
|
import type { WishlistEntry, SortKey } from '$lib/server/wishlist/repository';
|
||||||
@@ -82,7 +83,7 @@
|
|||||||
<p class="muted">Lädt …</p>
|
<p class="muted">Lädt …</p>
|
||||||
{:else if entries.length === 0}
|
{:else if entries.length === 0}
|
||||||
<section class="empty">
|
<section class="empty">
|
||||||
<p class="big">🥘</p>
|
<div class="big"><CookingPot size={48} strokeWidth={1.5} /></div>
|
||||||
<p>Noch nichts gewünscht.</p>
|
<p>Noch nichts gewünscht.</p>
|
||||||
<p class="hint">Öffne ein Rezept und klick dort auf „Auf Wunschliste".</p>
|
<p class="hint">Öffne ein Rezept und klick dort auf „Auf Wunschliste".</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -94,7 +95,7 @@
|
|||||||
{#if resolveImage(e.image_path)}
|
{#if resolveImage(e.image_path)}
|
||||||
<img src={resolveImage(e.image_path)} alt="" loading="lazy" />
|
<img src={resolveImage(e.image_path)} alt="" loading="lazy" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="placeholder">🥘</div>
|
<div class="placeholder"><CookingPot size={32} /></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="text">
|
<div class="text">
|
||||||
<div class="title">{e.title}</div>
|
<div class="title">{e.title}</div>
|
||||||
@@ -118,12 +119,14 @@
|
|||||||
aria-label={e.liked_by_me ? 'Unlike' : 'Like'}
|
aria-label={e.liked_by_me ? 'Unlike' : 'Like'}
|
||||||
onclick={() => toggleLike(e)}
|
onclick={() => toggleLike(e)}
|
||||||
>
|
>
|
||||||
{e.liked_by_me ? '♥' : '♡'}
|
<Heart size={18} strokeWidth={2} fill={e.liked_by_me ? 'currentColor' : 'none'} />
|
||||||
{#if e.like_count > 0}
|
{#if e.like_count > 0}
|
||||||
<span class="count">{e.like_count}</span>
|
<span class="count">{e.like_count}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="del" aria-label="Entfernen" onclick={() => remove(e)}>🗑</button>
|
<button class="del" aria-label="Entfernen" onclick={() => remove(e)}>
|
||||||
|
<Trash2 size={18} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
Reference in New Issue
Block a user