Reorganise the views and prepare summary
All checks were successful
Build and Deploy / build-and-push (push) Successful in 23s
All checks were successful
Build and Deploy / build-and-push (push) Successful in 23s
This commit is contained in:
357
VIEWS_AND_SCENARIOS.md
Normal file
357
VIEWS_AND_SCENARIOS.md
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
# Widoki i scenariusze — Aplikacja Kuchenna
|
||||||
|
|
||||||
|
> **Cel dokumentu:** Opis wszystkich widoków aplikacji z obecnym stanem, scenariusze użytkownika i znane problemy. Odniesienie dla dalszego rozwoju.
|
||||||
|
|
||||||
|
> **Kontekst projektu:** To jest **prototyp / mockup** — celem jest wypracowanie UX, logiki widoków i przepływów użytkownika. Finalna aplikacja będzie pisana w innym języku z backendem. Wartość tego prototypu to przede wszystkim: struktura widoków, scenariusze, model danych i decyzje UX — nie kod sam w sobie.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Profil użytkownika
|
||||||
|
|
||||||
|
| Cecha | Opis |
|
||||||
|
|-------|------|
|
||||||
|
| Liczba osób | 1 (gotuje dla siebie) |
|
||||||
|
| Planowanie | Elastyczne, 1–7 dni do przodu |
|
||||||
|
| Posiłki | 3 główne (śniadanie, obiad, kolacja) + dodatkowe przy treningu |
|
||||||
|
| Powtarzalność | Duża (zwłaszcza śniadania) — kopiowanie dnia kluczowe |
|
||||||
|
| Styl gotowania | Hybrydowy: trochę meal-prep, trochę na bieżąco |
|
||||||
|
| Zakupy | Mieszane: duże zakupy + uzupełnienia w tygodniu |
|
||||||
|
| Cel dietetyczny | Utrzymanie wagi, śledzenie per posiłek |
|
||||||
|
| Pomijanie posiłków | Jawne "pomijam" (jedzenie na mieście) |
|
||||||
|
| Przepisy | Na razie katalog wbudowany (9 przepisów), bez edytora |
|
||||||
|
|
||||||
|
### Kluczowe pain-pointy (zgłoszone)
|
||||||
|
|
||||||
|
1. Brak możliwości pominięcia slotu w planerze
|
||||||
|
2. Brak "Dodaj do planera" z widoku przepisu
|
||||||
|
3. Ilości na liście zakupów nie do edycji
|
||||||
|
4. Brak kalorii per slot w widoku dnia
|
||||||
|
5. Za dużo kliknięć w kluczowych flow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Przegląd widoków
|
||||||
|
|
||||||
|
### 2.1 Przepisy (`RecipeList`)
|
||||||
|
|
||||||
|
**Lokalizacja:** `js/views/RecipeList.js`
|
||||||
|
|
||||||
|
Siatka 2-kolumnowa kart przepisów generowana dynamicznie z `RECIPES`. Każda karta zawiera miniaturę (placeholder), tytuł, czas przygotowania, kalorie. Kliknięcie otwiera detal.
|
||||||
|
|
||||||
|
**Elementy:**
|
||||||
|
- Wyszukiwarka (real-time, po tytule i tagach)
|
||||||
|
- Przycisk filtrów (overlay)
|
||||||
|
- Siatka kart
|
||||||
|
|
||||||
|
**Stan:** Kompletny.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Filtry (`Filter`)
|
||||||
|
|
||||||
|
**Lokalizacja:** `js/views/Filter.js`
|
||||||
|
|
||||||
|
Overlay z chipami pór posiłku, tagami dietetycznymi i suwakiem czasu. Dynamicznie generowane z `MEAL_SLOTS` i tagów z `RECIPES`.
|
||||||
|
|
||||||
|
**Elementy:**
|
||||||
|
- Chipy: pory posiłku (z `MEAL_SLOTS`)
|
||||||
|
- Chipy: tagi dietetyczne (zbierane z `RECIPES`)
|
||||||
|
- Suwak: maksymalny czas przygotowania
|
||||||
|
- Przycisk "Wyczyść" + "Pokaż X wyników"
|
||||||
|
|
||||||
|
**Stan:** Kompletny.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 Szczegóły przepisu (`RecipeDetail`)
|
||||||
|
|
||||||
|
**Lokalizacja:** `js/views/RecipeDetail.js`
|
||||||
|
|
||||||
|
Full-screen overlay z detalami przepisu. Trzy zakładki: Składniki, Kroki, Wartości.
|
||||||
|
|
||||||
|
**Elementy:**
|
||||||
|
- Hero (placeholder) + strzałka powrotu + **"Do planera"** (nowe)
|
||||||
|
- Tytuł, tagi, czas, kcal
|
||||||
|
- Selektor porcji (±) z przeliczaniem
|
||||||
|
- Zakładka Składniki: lista z checkboxami + badge stanu spiżarni + "Dodaj do listy zakupów"
|
||||||
|
- Zakładka Kroki: numerowane kroki
|
||||||
|
- Zakładka Wartości: kcal/białko/tłuszcze/węglowodany × porcje
|
||||||
|
- **Bottom sheet "Dodaj do planera"**: wybór dnia (7 dni) + slotu → zapis do planStore
|
||||||
|
|
||||||
|
**Stan:** Kompletny.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4 Planer posiłków (`MealPlanner`)
|
||||||
|
|
||||||
|
**Lokalizacja:** `js/views/MealPlanner.js`
|
||||||
|
|
||||||
|
Kalendarz (tydzień/miesiąc) + plan dnia z slotami posiłków.
|
||||||
|
|
||||||
|
**Elementy kalendarza:**
|
||||||
|
- Widok tygodnia ↔ miesiąca (swipe góra/dół)
|
||||||
|
- Nawigacja: ←/→ + "Dziś"
|
||||||
|
- Kropki pod dniem = zaplanowane posiłki
|
||||||
|
|
||||||
|
**Elementy planu dnia:**
|
||||||
|
- Nagłówek dnia + **"Kopiuj dzień"** (nowe)
|
||||||
|
- Karta podsumowania kalorycznego (kcal + makro, rozwijalne szczegóły)
|
||||||
|
- "Składniki na ten dzień" (badge z liczbą braków)
|
||||||
|
- Sloty posiłków (5 slotów z `MEAL_SLOTS`):
|
||||||
|
- **Kcal per slot** w nagłówku (nowe)
|
||||||
|
- Karty przepisów z porcjami, kcal, czasem, przyciskiem usuwania
|
||||||
|
- Kliknięcie nazwy przepisu → RecipeDetail
|
||||||
|
- "Dodaj przepis" / "Dodaj kolejny"
|
||||||
|
- **"Pomijam"** przy pustym slocie (nowe) → slot przygaszony z "Cofnij"
|
||||||
|
|
||||||
|
**Bottom sheety:**
|
||||||
|
1. **Picker przepisów**: wyszukiwarka + "Ostatnio używane" + lista filtrowana do `allowedSlots`
|
||||||
|
2. **Składniki i spiżarnia**: porównanie potrzeb vs zapasy + prognoza tygodnia + "Dodaj braki"
|
||||||
|
3. **Kopiuj plan dnia**: lista 13 dni (3 wstecz, 10 do przodu) → kopiuje cały dzień
|
||||||
|
|
||||||
|
**Stan:** Kompletny.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.5 Spiżarnia (`Pantry`)
|
||||||
|
|
||||||
|
**Lokalizacja:** `js/views/Pantry.js`
|
||||||
|
|
||||||
|
Przeglądanie i edycja stanów magazynowych składników.
|
||||||
|
|
||||||
|
**Elementy:**
|
||||||
|
- Wyszukiwarka
|
||||||
|
- Chipy filtrów kategorii
|
||||||
|
- Toggle "Tylko na stanie"
|
||||||
|
- Siatka chipów składników (kolorowana wg stanu)
|
||||||
|
- Bottom sheet edycji: ±, input, wartości odżywcze, "Dodaj na listę zakupów"
|
||||||
|
|
||||||
|
**Stan:** Kompletny.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.6 Zakupy (`Shopping`)
|
||||||
|
|
||||||
|
**Lokalizacja:** `js/views/Shopping.js`
|
||||||
|
|
||||||
|
Zarządzanie listami zakupów.
|
||||||
|
|
||||||
|
**Elementy:**
|
||||||
|
- Selektor aktywnej listy
|
||||||
|
- Przycisk "Nowa lista"
|
||||||
|
- **Pasek akcji listy kuchennej** (widoczny gdy są zaznaczone pozycje):
|
||||||
|
- "Kupione → spiżarnia" (z **potwierdzeniem** — nowe)
|
||||||
|
- "Wyczyść kupione"
|
||||||
|
- Lista kuchenna: pogrupowana po kategorii, checkbox, **edycja ilości** (klik → prompt, nowe)
|
||||||
|
- Lista freeform: tekst + notatka, checkbox
|
||||||
|
|
||||||
|
**Stan:** Kompletny.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Przepływy między widokami
|
||||||
|
|
||||||
|
```
|
||||||
|
Przepisy ──[klik kartę]──→ Szczegóły przepisu
|
||||||
|
├──[Dodaj do listy zakupów]──→ Zakupy (lista kuchenna)
|
||||||
|
└──[Do planera]──→ Planer (wybór dnia + slotu)
|
||||||
|
|
||||||
|
Planer ──[klik przepis w slocie]──→ Szczegóły przepisu
|
||||||
|
──[Składniki na ten dzień]──→ Sheet: porównanie z spiżarnią
|
||||||
|
──[Dodaj braki do listy]──→ Zakupy (lista kuchenna)
|
||||||
|
──[Kopiuj dzień]──→ Sheet: wybór dnia docelowego
|
||||||
|
|
||||||
|
Spiżarnia ──[Dodaj na listę zakupów]──→ Zakupy
|
||||||
|
|
||||||
|
Zakupy ──[Kupione → spiżarnia]──→ Spiżarnia (stany zaktualizowane)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Scenariusze użytkownika
|
||||||
|
|
||||||
|
### Scenariusz 1: Przeglądanie przepisów
|
||||||
|
|
||||||
|
**Cel:** Użytkownik otwiera apkę, chce zobaczyć co jest dostępne.
|
||||||
|
|
||||||
|
1. Otwiera aplikację → widzi zakładkę **Przepisy** z siatką 9 kart
|
||||||
|
2. Przewija listę, czyta opisy i kalorie na kartach
|
||||||
|
3. Klika kartę "Makaron z pomidorami i bazylią"
|
||||||
|
4. Widzi detal: składniki, kroki, wartości odżywcze
|
||||||
|
5. Zmienia liczbę porcji z 1 na 3 → składniki i kcal się przeliczają
|
||||||
|
6. Przełącza zakładkę na "Kroki" → widzi kroki
|
||||||
|
7. Przełącza na "Wartości" → widzi makroskładniki ×3
|
||||||
|
8. Wraca strzałką ← do listy
|
||||||
|
|
||||||
|
**Znane problemy:**
|
||||||
|
- Brak zdjęć (szare placeholdery) — OK dla prototypu
|
||||||
|
- Po powrocie z detalu filtr/szukajka powinny się utrzymać (utrzymują się)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenariusz 2: Szukanie przepisu na kolację
|
||||||
|
|
||||||
|
**Cel:** Użytkownik szuka czegoś konkretnego.
|
||||||
|
|
||||||
|
1. Wpisuje "łosoś" → lista filtruje się do 1 karty
|
||||||
|
2. Kasuje tekst → wracają wszystkie
|
||||||
|
3. Klika ikonę filtrów
|
||||||
|
4. Zaznacza "Kolacja" → lista się zawęża
|
||||||
|
5. Dodatkowo zaznacza tag "Wysokobiałkowe"
|
||||||
|
6. Ustawia suwak na max 25 min
|
||||||
|
7. Klika "Pokaż X wyników" → wraca do przefiltrowanej listy
|
||||||
|
8. Wybiera przepis i otwiera detal
|
||||||
|
|
||||||
|
**Znane problemy:**
|
||||||
|
- Brak wizualnego wskaźnika aktywnych filtrów na ikonce (badge/kropka)
|
||||||
|
- Po zamknięciu filtrów ← (zamiast "Pokaż") — zachowanie może być nieintuicyjne
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenariusz 3: Planowanie posiłków na tydzień
|
||||||
|
|
||||||
|
**Cel:** Użytkownik układa menu na kilka dni.
|
||||||
|
|
||||||
|
1. Przechodzi na zakładkę **Planer**
|
||||||
|
2. Widzi dzisiejszy dzień z demo-danymi
|
||||||
|
3. Klika na przepis w slocie → otwiera się detal
|
||||||
|
4. Wraca ← do planera
|
||||||
|
5. Klika następny dzień w kalendarzu
|
||||||
|
6. Widzi puste sloty, klika "Dodaj przepis" przy Śniadaniu
|
||||||
|
7. Picker: wpisuje fragment nazwy, widzi "Ostatnio używane"
|
||||||
|
8. Wybiera przepis → pojawia się w slocie z **kcal w nagłówku**
|
||||||
|
9. Przy obiedzie klika **"Pomijam"** (je na mieście) → slot przygaszony
|
||||||
|
10. Klika "Składniki na ten dzień" → widzi braki vs spiżarnia
|
||||||
|
11. Klika "Dodaj braki na dziś do listy" → toast
|
||||||
|
12. Następnego dnia: **"Kopiuj dzień"** → wybiera dzień docelowy → gotowe
|
||||||
|
|
||||||
|
**Znane problemy:**
|
||||||
|
- Po dodaniu braków brak informacji "już dodano" — ryzyko duplikacji
|
||||||
|
- "Kopiuj dzień" kopiuje też status "Pominięto"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenariusz 4: Zarządzanie spiżarnią
|
||||||
|
|
||||||
|
**Cel:** Użytkownik sprawdza co ma w domu.
|
||||||
|
|
||||||
|
1. Przechodzi na zakładkę **Spiżarnia**
|
||||||
|
2. Widzi chipy składników pogrupowane po kategorii
|
||||||
|
3. Włącza "Tylko na stanie" → widzi co ma
|
||||||
|
4. Klika "Płatki owsiane" → bottom sheet
|
||||||
|
5. Ustawia 500g
|
||||||
|
6. Zamyka → chip zmienił się na zielony z "500 g"
|
||||||
|
7. Chce dodać mleko na listę → klika "Dodaj na listę" w sheecie
|
||||||
|
|
||||||
|
**Znane problemy:**
|
||||||
|
- Po zamknięciu bottom sheet scroll wraca na górę (re-render)
|
||||||
|
- Brak możliwości ręcznego wpisania ilości szybko (ale jest input type=number)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenariusz 5: Zakupy w sklepie
|
||||||
|
|
||||||
|
**Cel:** Użytkownik jest w sklepie, odznacza kupione.
|
||||||
|
|
||||||
|
1. Otwiera zakładkę **Zakupy**
|
||||||
|
2. Widzi listę kuchenną pogrupowaną po kategorii
|
||||||
|
3. Bierze mleko z półki → klika checkbox → przekreślenie
|
||||||
|
4. Widzi `sourceNote`: "Braki z planu dnia"
|
||||||
|
5. Ilość się nie zgadza — klika na **ilość** → prompt → poprawia
|
||||||
|
6. Kupuje dalsze pozycje
|
||||||
|
|
||||||
|
**Znane problemy:**
|
||||||
|
- Kupione mieszają się z niekupionymi w grupie (brak separacji)
|
||||||
|
- Brak podsumowania: "Do kupienia: 5, kupione: 3"
|
||||||
|
- Na telefonie w sklepie — elementy mogłyby być większe
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenariusz 6: Po zakupach — przeniesienie do spiżarni
|
||||||
|
|
||||||
|
**Cel:** Użytkownik wrócił ze sklepu, aktualizuje spiżarnię.
|
||||||
|
|
||||||
|
1. Otwiera **Zakupy** → widzi zaznaczone pozycje
|
||||||
|
2. Pojawia się pasek: **"Kupione → spiżarnia"** i **"Wyczyść kupione"**
|
||||||
|
3. Klika "Kupione → spiżarnia" → **potwierdzenie z podglądem** pozycji
|
||||||
|
4. Potwierdza → toast "Przeniesiono X pozycji"
|
||||||
|
5. Kupione znikają z listy
|
||||||
|
6. Przechodzi do **Spiżarni** → stany zaktualizowane
|
||||||
|
7. Wraca do **Planera** → braki zmniejszone
|
||||||
|
|
||||||
|
**Znane problemy:**
|
||||||
|
- Brak undo (cofnięcia przeniesienia)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenariusz 7: Gotowanie z przepisu
|
||||||
|
|
||||||
|
**Cel:** Użytkownik gotuje, sprawdza przepis krok po kroku.
|
||||||
|
|
||||||
|
1. Otwiera **Planer** → dzisiejszy dzień
|
||||||
|
2. Klika przepis w slocie Kolacja
|
||||||
|
3. Otwiera się detal → przełącza na "Kroki"
|
||||||
|
4. Czyta krok po kroku
|
||||||
|
5. Sprawdza ilość składnika → przełącza na "Składniki"
|
||||||
|
6. Wraca na "Kroki"
|
||||||
|
|
||||||
|
**Znane problemy:**
|
||||||
|
- Brak trybu "krok po kroku" (full-screen, jeden krok, swipe next)
|
||||||
|
- Ekran wygasa po chwili (brak wake lock)
|
||||||
|
- Nie ma checkboxa przy krokach
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenariusz 8: "Co mogę ugotować?"
|
||||||
|
|
||||||
|
**Cel:** Użytkownik ma coś w spiżarni, chce wiedzieć co da się z tego zrobić.
|
||||||
|
|
||||||
|
1. Otwiera **Spiżarnię** → widzi co ma
|
||||||
|
2. Chciałby kliknąć "Co mogę ugotować?" → **takiej funkcji jeszcze nie ma**
|
||||||
|
3. Musi ręcznie sprawdzać przepisy
|
||||||
|
|
||||||
|
**Propozycja:** Filtr "Mam składniki" w widoku Przepisy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Tabela znanych problemów i luk
|
||||||
|
|
||||||
|
| # | Problem | Scenariusz | Status |
|
||||||
|
|---|---------|-----------|--------|
|
||||||
|
| 1 | Brak wskaźnika aktywnych filtrów na ikonce | 2 | TODO |
|
||||||
|
| 2 | Podwójne dodanie braków do listy zakupów | 3 | TODO |
|
||||||
|
| 3 | Kupione mieszają się z niekupionymi (brak separacji) | 5 | TODO |
|
||||||
|
| 4 | Brak podsumowania na liście zakupów (ile kupione/do kupienia) | 5 | TODO |
|
||||||
|
| 5 | Brak undo przy "Kupione → spiżarnia" | 6 | TODO |
|
||||||
|
| 6 | Brak trybu "krok po kroku" przy gotowaniu | 7 | PROPOZYCJA |
|
||||||
|
| 7 | Brak wake lock (ekran wygasa) | 7 | PROPOZYCJA |
|
||||||
|
| 8 | Brak filtra "Mam składniki" | 8 | PROPOZYCJA |
|
||||||
|
| 9 | Scroll spiżarni resetuje się po edycji | 4 | TODO |
|
||||||
|
| 10 | Zamknięcie filtrów ← vs "Pokaż" — niespójne zachowanie? | 2 | DO WERYFIKACJI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Zrealizowane w ostatniej iteracji
|
||||||
|
|
||||||
|
| # | Funkcja | Widok |
|
||||||
|
|---|---------|-------|
|
||||||
|
| P1 | "Dodaj do planera" z widoku przepisu (wybór dnia + slotu) | RecipeDetail |
|
||||||
|
| P2 | Kopiowanie planu dnia na inny dzień | MealPlanner |
|
||||||
|
| P3 | "Pomijam" w slocie planera | MealPlanner + planStore |
|
||||||
|
| P4 | Kalorie per slot w nagłówku slotu | MealPlanner |
|
||||||
|
| P5 | Edycja ilości na liście zakupów (klik → prompt) | Shopping |
|
||||||
|
| P6 | Potwierdzenie "Kupione → spiżarnia" z podglądem | Shopping |
|
||||||
|
| P7 | Lepszy picker przepisów (wyszukiwarka + ostatnio używane) | MealPlanner |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Stos technologiczny
|
||||||
|
|
||||||
|
| Warstwa | Technologia |
|
||||||
|
|---------|-------------|
|
||||||
|
| Frontend | Plain HTML + ES modules, imperatywna manipulacja DOM |
|
||||||
|
| Style | Tailwind CSS (CDN), Font Awesome 6 (CDN) |
|
||||||
|
| Dane | Statyczny katalog `js/data/catalog.js`, localStorage |
|
||||||
|
| PWA | manifest.webmanifest + sw.js |
|
||||||
|
| Deploy | Docker + nginx:alpine, Gitea CI/CD |
|
||||||
|
| Język UI | Polski |
|
||||||
@@ -24,9 +24,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Ingredient Active States */
|
/* Ingredient Active States */
|
||||||
.ingredient-active .check-box { background-color: #111827; border-color: #111827; }
|
.ingredient-active .check-box,
|
||||||
.ingredient-active .check-icon { display: block; }
|
.ingredient-active .rd-check-box { background-color: #111827; border-color: #111827; }
|
||||||
.ingredient-active .ingredient-text { text-decoration: line-through; color: #9ca3af; }
|
.ingredient-active .check-icon,
|
||||||
|
.ingredient-active .rd-check-icon { display: block; }
|
||||||
|
.ingredient-active .ingredient-text,
|
||||||
|
.ingredient-active .rd-ing-text { text-decoration: line-through; color: #9ca3af; }
|
||||||
|
|
||||||
/* Utilities */
|
/* Utilities */
|
||||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||||
|
|||||||
30
js/app.js
30
js/app.js
@@ -1,4 +1,4 @@
|
|||||||
import { getRecipeListHTML } from './views/RecipeList.js';
|
import { getRecipeListHTML, setupRecipeList } from './views/RecipeList.js';
|
||||||
import { getFilterHTML, setupFilter } from './views/Filter.js';
|
import { getFilterHTML, setupFilter } from './views/Filter.js';
|
||||||
import { getRecipeDetailHTML, setupRecipeDetail } from './views/RecipeDetail.js';
|
import { getRecipeDetailHTML, setupRecipeDetail } from './views/RecipeDetail.js';
|
||||||
import { getMealPlannerHTML, setupMealPlanner } from './views/MealPlanner.js';
|
import { getMealPlannerHTML, setupMealPlanner } from './views/MealPlanner.js';
|
||||||
@@ -95,36 +95,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
setupTabs();
|
setupTabs();
|
||||||
|
setupRecipeList();
|
||||||
setupMealPlanner();
|
setupMealPlanner();
|
||||||
setupPantry();
|
setupPantry();
|
||||||
setupShopping();
|
setupShopping();
|
||||||
setupFilter();
|
setupFilter();
|
||||||
setupRecipeDetail();
|
setupRecipeDetail();
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- GLOBAL NAVIGATION METHODS ---
|
|
||||||
window.openRecipeDetail = () => {
|
|
||||||
const view = document.getElementById('recipe-detail-view');
|
|
||||||
// Swap Tailwind classes to slide IN
|
|
||||||
view.classList.remove('translate-x-full', 'opacity-0', 'pointer-events-none');
|
|
||||||
view.classList.add('translate-x-0', 'opacity-100', 'pointer-events-auto');
|
|
||||||
};
|
|
||||||
|
|
||||||
window.closeRecipeDetail = () => {
|
|
||||||
const view = document.getElementById('recipe-detail-view');
|
|
||||||
// Swap Tailwind classes to slide OUT
|
|
||||||
view.classList.remove('translate-x-0', 'opacity-100', 'pointer-events-auto');
|
|
||||||
view.classList.add('translate-x-full', 'opacity-0', 'pointer-events-none');
|
|
||||||
};
|
|
||||||
|
|
||||||
window.openFilters = () => {
|
|
||||||
const fv = document.getElementById('filter-view');
|
|
||||||
fv.classList.remove('hidden');
|
|
||||||
fv.classList.add('flex');
|
|
||||||
};
|
|
||||||
|
|
||||||
window.closeFilters = () => {
|
|
||||||
const fv = document.getElementById('filter-view');
|
|
||||||
fv.classList.add('hidden');
|
|
||||||
fv.classList.remove('flex');
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -208,113 +208,177 @@ export const RECIPES = {
|
|||||||
placki: {
|
placki: {
|
||||||
id: 'placki',
|
id: 'placki',
|
||||||
title: 'Puszyste placki',
|
title: 'Puszyste placki',
|
||||||
|
description: 'Klasyczne placki na śniadanie — puszyste i złociste.',
|
||||||
minutes: 15,
|
minutes: 15,
|
||||||
thumbLabel: 'Placki',
|
thumbLabel: 'Placki',
|
||||||
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
||||||
|
tags: ['wegetariańskie', 'słodkie'],
|
||||||
nutritionPerServing: { kcal: 320, protein: 12, fat: 8, carbs: 48 },
|
nutritionPerServing: { kcal: 320, protein: 12, fat: 8, carbs: 48 },
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: 'maka_pszenna', amount: 200, unit: 'g' },
|
{ ingredientId: 'maka_pszenna', amount: 200, unit: 'g' },
|
||||||
{ ingredientId: 'mleko', amount: 250, unit: 'ml' },
|
{ ingredientId: 'mleko', amount: 250, unit: 'ml' },
|
||||||
{ ingredientId: 'jajko', amount: 2, unit: 'szt.' },
|
{ ingredientId: 'jajko', amount: 2, unit: 'szt.' },
|
||||||
],
|
],
|
||||||
|
steps: [
|
||||||
|
'Mąkę przesiej do miski, dodaj szczyptę soli.',
|
||||||
|
'Wbij jajka, wlej mleko i wymieszaj trzepaczką na gładkie ciasto.',
|
||||||
|
'Rozgrzej patelnię z odrobiną masła na średnim ogniu.',
|
||||||
|
'Nakładaj ciasto łyżką wazową i smaż placki po ok. 2 min z każdej strony.',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
salatka: {
|
salatka: {
|
||||||
id: 'salatka',
|
id: 'salatka',
|
||||||
title: 'Sałatka z kurczakiem',
|
title: 'Sałatka z kurczakiem',
|
||||||
|
description: 'Zielone warzywa z grillowanym kurczakiem.',
|
||||||
minutes: 20,
|
minutes: 20,
|
||||||
thumbLabel: 'Sałatka',
|
thumbLabel: 'Sałatka',
|
||||||
allowedSlots: ['obiad'],
|
allowedSlots: ['obiad'],
|
||||||
|
tags: ['wysokobiałkowe', 'niskokaloryczne'],
|
||||||
nutritionPerServing: { kcal: 250, protein: 35, fat: 9, carbs: 12 },
|
nutritionPerServing: { kcal: 250, protein: 35, fat: 9, carbs: 12 },
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: 'piers_kurczaka', amount: 150, unit: 'g' },
|
{ ingredientId: 'piers_kurczaka', amount: 150, unit: 'g' },
|
||||||
{ ingredientId: 'mix_salat', amount: 100, unit: 'g' },
|
{ ingredientId: 'mix_salat', amount: 100, unit: 'g' },
|
||||||
{ ingredientId: 'pomidor', amount: 1, unit: 'szt.' },
|
{ ingredientId: 'pomidor', amount: 1, unit: 'szt.' },
|
||||||
],
|
],
|
||||||
|
steps: [
|
||||||
|
'Pierś z kurczaka przypraw solą i pieprzem, griluj na patelni ok. 5 min z każdej strony.',
|
||||||
|
'Pokrój kurczaka w paski i odłóż do ostygnięcia.',
|
||||||
|
'Wymieszaj mix sałat z pokrojonym pomidorem.',
|
||||||
|
'Ułóż kurczaka na sałatce, polej ulubionym dressingiem.',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
makaron: {
|
makaron: {
|
||||||
id: 'makaron',
|
id: 'makaron',
|
||||||
title: 'Makaron z pomidorami i bazylią',
|
title: 'Makaron z pomidorami i bazylią',
|
||||||
|
description: 'Aromatyczny sos pomidorowy z czosnkiem i świeżą bazylią.',
|
||||||
minutes: 30,
|
minutes: 30,
|
||||||
thumbLabel: 'Makaron',
|
thumbLabel: 'Makaron',
|
||||||
allowedSlots: ['obiad', 'kolacja'],
|
allowedSlots: ['obiad', 'kolacja'],
|
||||||
|
tags: ['wegetariańskie', 'wegańskie'],
|
||||||
nutritionPerServing: { kcal: 450, protein: 14, fat: 12, carbs: 72 },
|
nutritionPerServing: { kcal: 450, protein: 14, fat: 12, carbs: 72 },
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: 'makaron_suchy', amount: 120, unit: 'g' },
|
{ ingredientId: 'makaron_suchy', amount: 120, unit: 'g' },
|
||||||
{ ingredientId: 'pomidory_krojone', amount: 400, unit: 'g' },
|
{ ingredientId: 'pomidory_krojone', amount: 400, unit: 'g' },
|
||||||
{ ingredientId: 'bazylia_swieza', amount: 10, unit: 'g' },
|
{ ingredientId: 'bazylia_swieza', amount: 10, unit: 'g' },
|
||||||
],
|
],
|
||||||
|
steps: [
|
||||||
|
'Ugotuj makaron al dente wg instrukcji na opakowaniu.',
|
||||||
|
'Na patelni rozgrzej oliwę, dodaj pomidory krojone i gotuj 10 min.',
|
||||||
|
'Dopraw solą, pieprzem i szczyptą cukru.',
|
||||||
|
'Wymieszaj makaron z sosem, udekoruj świeżą bazylią.',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
koktajl: {
|
koktajl: {
|
||||||
id: 'koktajl',
|
id: 'koktajl',
|
||||||
title: 'Koktajl owocowy',
|
title: 'Koktajl owocowy',
|
||||||
|
description: 'Mix jagód i jogurtu — szybka przekąska lub drugie śniadanie.',
|
||||||
minutes: 5,
|
minutes: 5,
|
||||||
thumbLabel: 'Koktajl',
|
thumbLabel: 'Koktajl',
|
||||||
allowedSlots: ['przekaska', 'drugie_sniadanie'],
|
allowedSlots: ['przekaska', 'drugie_sniadanie'],
|
||||||
|
tags: ['wegetariańskie', 'szybkie'],
|
||||||
nutritionPerServing: { kcal: 180, protein: 8, fat: 3, carbs: 32 },
|
nutritionPerServing: { kcal: 180, protein: 8, fat: 3, carbs: 32 },
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: 'jogurt_naturalny', amount: 200, unit: 'g' },
|
{ ingredientId: 'jogurt_naturalny', amount: 200, unit: 'g' },
|
||||||
{ ingredientId: 'mieszanka_jagod', amount: 150, unit: 'g' },
|
{ ingredientId: 'mieszanka_jagod', amount: 150, unit: 'g' },
|
||||||
{ ingredientId: 'miod', amount: 15, unit: 'g' },
|
{ ingredientId: 'miod', amount: 15, unit: 'g' },
|
||||||
],
|
],
|
||||||
|
steps: [
|
||||||
|
'Wrzuć jogurt, jagody i miód do blendera.',
|
||||||
|
'Zmiksuj na gładką masę (~30 sekund).',
|
||||||
|
'Przelej do szklanki. Gotowe!',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
tost_awokado: {
|
tost_awokado: {
|
||||||
id: 'tost_awokado',
|
id: 'tost_awokado',
|
||||||
title: 'Tost z awokado',
|
title: 'Tost z awokado',
|
||||||
|
description: 'Chleb na zakwasie z rozgniecionym awokado i cytryną.',
|
||||||
minutes: 10,
|
minutes: 10,
|
||||||
thumbLabel: 'Tost',
|
thumbLabel: 'Tost',
|
||||||
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
||||||
|
tags: ['wegetariańskie', 'wegańskie', 'szybkie'],
|
||||||
nutritionPerServing: { kcal: 220, protein: 6, fat: 14, carbs: 20 },
|
nutritionPerServing: { kcal: 220, protein: 6, fat: 14, carbs: 20 },
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: 'chleb_zakwas', amount: 2, unit: 'kromki' },
|
{ ingredientId: 'chleb_zakwas', amount: 2, unit: 'kromki' },
|
||||||
{ ingredientId: 'awokado', amount: 1, unit: 'szt.' },
|
{ ingredientId: 'awokado', amount: 1, unit: 'szt.' },
|
||||||
{ ingredientId: 'cytryna', amount: 0.5, unit: 'szt.' },
|
{ ingredientId: 'cytryna', amount: 0.5, unit: 'szt.' },
|
||||||
],
|
],
|
||||||
|
steps: [
|
||||||
|
'Opiecz kromki chleba w tosterze lub na suchej patelni.',
|
||||||
|
'Przekrój awokado, wyjmij pestkę i wyłóż miąższ do miseczki.',
|
||||||
|
'Rozgnieć widelcem, dodaj sok z cytryny, sól i pieprz.',
|
||||||
|
'Nałóż masę na tosty. Podawaj od razu.',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
losos: {
|
losos: {
|
||||||
id: 'losos',
|
id: 'losos',
|
||||||
title: 'Grillowany łosoś',
|
title: 'Grillowany łosoś',
|
||||||
|
description: 'Świeży łosoś z masłem cytrynowym i koperkiem.',
|
||||||
minutes: 25,
|
minutes: 25,
|
||||||
thumbLabel: 'Łosoś',
|
thumbLabel: 'Łosoś',
|
||||||
allowedSlots: ['kolacja', 'obiad'],
|
allowedSlots: ['kolacja', 'obiad'],
|
||||||
|
tags: ['wysokobiałkowe'],
|
||||||
nutritionPerServing: { kcal: 380, protein: 38, fat: 22, carbs: 4 },
|
nutritionPerServing: { kcal: 380, protein: 38, fat: 22, carbs: 4 },
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: 'losos_filet', amount: 180, unit: 'g' },
|
{ ingredientId: 'losos_filet', amount: 180, unit: 'g' },
|
||||||
{ ingredientId: 'cytryna', amount: 0.5, unit: 'szt.' },
|
{ ingredientId: 'cytryna', amount: 0.5, unit: 'szt.' },
|
||||||
{ ingredientId: 'koper_swiezy', amount: 5, unit: 'g' },
|
{ ingredientId: 'koper_swiezy', amount: 5, unit: 'g' },
|
||||||
],
|
],
|
||||||
|
steps: [
|
||||||
|
'Filety oprósz solą, pieprzem i skrop sokiem z cytryny.',
|
||||||
|
'Rozgrzej patelnię grillową na dość mocnym ogniu.',
|
||||||
|
'Smaż łososia 4–5 min z każdej strony (skórą do dołu na start).',
|
||||||
|
'Podawaj z posiekanym koperkiem i plasterkiem cytryny.',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
tacos: {
|
tacos: {
|
||||||
id: 'tacos',
|
id: 'tacos',
|
||||||
title: 'Tacos z wołowiną',
|
title: 'Tacos z wołowiną',
|
||||||
|
description: 'Pikantna mielona wołowina ze świeżą salsą w tortillach.',
|
||||||
minutes: 20,
|
minutes: 20,
|
||||||
thumbLabel: 'Tacos',
|
thumbLabel: 'Tacos',
|
||||||
allowedSlots: ['kolacja', 'obiad'],
|
allowedSlots: ['kolacja', 'obiad'],
|
||||||
|
tags: [],
|
||||||
nutritionPerServing: { kcal: 410, protein: 28, fat: 18, carbs: 38 },
|
nutritionPerServing: { kcal: 410, protein: 28, fat: 18, carbs: 38 },
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: 'mieso_wol_mielone', amount: 200, unit: 'g' },
|
{ ingredientId: 'mieso_wol_mielone', amount: 200, unit: 'g' },
|
||||||
{ ingredientId: 'tortilla_kukurydziana', amount: 4, unit: 'szt.' },
|
{ ingredientId: 'tortilla_kukurydziana', amount: 4, unit: 'szt.' },
|
||||||
{ ingredientId: 'salsa_pomidorowa', amount: 100, unit: 'g' },
|
{ ingredientId: 'salsa_pomidorowa', amount: 100, unit: 'g' },
|
||||||
],
|
],
|
||||||
|
steps: [
|
||||||
|
'Na rozgrzanej patelni podsmaż mielone, rozbijając widelcem, aż się zarumieni.',
|
||||||
|
'Dopraw kuminem, papryką, solą i pieprzem.',
|
||||||
|
'Podgrzej tortille na suchej patelni po 15 sek. z każdej strony.',
|
||||||
|
'Nałóż mięso na tortillę, polej salsą i zawiń.',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
owsianka: {
|
owsianka: {
|
||||||
id: 'owsianka',
|
id: 'owsianka',
|
||||||
title: 'Miska owsianki',
|
title: 'Miska owsianki',
|
||||||
|
description: 'Ciepła owsianka z miodem — szybki i sycący posiłek.',
|
||||||
minutes: 10,
|
minutes: 10,
|
||||||
thumbLabel: 'Owsianka',
|
thumbLabel: 'Owsianka',
|
||||||
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
||||||
|
tags: ['wegetariańskie', 'szybkie'],
|
||||||
nutritionPerServing: { kcal: 210, protein: 8, fat: 6, carbs: 34 },
|
nutritionPerServing: { kcal: 210, protein: 8, fat: 6, carbs: 34 },
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: 'platki_owsiane', amount: 60, unit: 'g' },
|
{ ingredientId: 'platki_owsiane', amount: 60, unit: 'g' },
|
||||||
{ ingredientId: 'mleko', amount: 200, unit: 'ml' },
|
{ ingredientId: 'mleko', amount: 200, unit: 'ml' },
|
||||||
{ ingredientId: 'miod', amount: 20, unit: 'g' },
|
{ ingredientId: 'miod', amount: 20, unit: 'g' },
|
||||||
],
|
],
|
||||||
|
steps: [
|
||||||
|
'W garnuszku zagotuj mleko.',
|
||||||
|
'Wsyp płatki owsiane, zmniejsz ogień i gotuj 3–4 min, mieszając.',
|
||||||
|
'Przełóż do miski, polej miodem. Opcjonalnie dodaj owoce lub orzechy.',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
serek_owoc: {
|
serek_owoc: {
|
||||||
id: 'serek_owoc',
|
id: 'serek_owoc',
|
||||||
title: 'Serek wiejski z orzechami i owocami',
|
title: 'Serek wiejski z orzechami i owocami',
|
||||||
|
description: 'Lekki, pożywny posiłek: serek z orzechami, truskawkami i borówkami.',
|
||||||
minutes: 5,
|
minutes: 5,
|
||||||
thumbLabel: 'Serek',
|
thumbLabel: 'Serek',
|
||||||
allowedSlots: ['sniadanie', 'drugie_sniadanie', 'przekaska'],
|
allowedSlots: ['sniadanie', 'drugie_sniadanie', 'przekaska'],
|
||||||
|
tags: ['wegetariańskie', 'wysokobiałkowe', 'szybkie'],
|
||||||
nutritionPerServing: { kcal: 642, protein: 32, fat: 43, carbs: 41 },
|
nutritionPerServing: { kcal: 642, protein: 32, fat: 43, carbs: 41 },
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: 'serek_wiejski', amount: 200, unit: 'g' },
|
{ ingredientId: 'serek_wiejski', amount: 200, unit: 'g' },
|
||||||
@@ -323,6 +387,12 @@ export const RECIPES = {
|
|||||||
{ ingredientId: 'truskawki', amount: 100, unit: 'g' },
|
{ ingredientId: 'truskawki', amount: 100, unit: 'g' },
|
||||||
{ ingredientId: 'borowki_amerykanskie', amount: 80, unit: 'g' },
|
{ ingredientId: 'borowki_amerykanskie', amount: 80, unit: 'g' },
|
||||||
],
|
],
|
||||||
|
steps: [
|
||||||
|
'Przełóż serek wiejski do miseczki.',
|
||||||
|
'Dodaj miód i delikatnie wymieszaj.',
|
||||||
|
'Orzechy posiekaj na mniejsze kawałki i posyp nimi serek z miodem.',
|
||||||
|
'Umyj owoce (ew. pokrój na połówki) i ułóż na wierzchu. Gotowe!',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -296,6 +296,20 @@ export function toggleItemInList(listId, itemId) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateKitchenItemAmount(listId, itemId, newAmount) {
|
||||||
|
const s = loadShoppingState();
|
||||||
|
const list = s.lists.find((l) => l.id === listId && l.type === 'kitchen');
|
||||||
|
if (!list) return s;
|
||||||
|
const it = /** @type {KitchenShoppingItem[]} */ (list.items).find((i) => i.id === itemId);
|
||||||
|
if (!it) return s;
|
||||||
|
it.amount = Math.max(0, Math.round(newAmount * 100) / 100);
|
||||||
|
if (it.amount <= 0) {
|
||||||
|
list.items = list.items.filter((i) => i.id !== itemId);
|
||||||
|
}
|
||||||
|
saveShoppingState(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
export function removeItemFromList(listId, itemId) {
|
export function removeItemFromList(listId, itemId) {
|
||||||
const s = loadShoppingState();
|
const s = loadShoppingState();
|
||||||
const list = s.lists.find((l) => l.id === listId);
|
const list = s.lists.find((l) => l.id === listId);
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { getDayPlan } from './planStore.js';
|
|||||||
|
|
||||||
export function dayHasAnyMeal(plans, d) {
|
export function dayHasAnyMeal(plans, d) {
|
||||||
const p = getDayPlan(plans, d);
|
const p = getDayPlan(plans, d);
|
||||||
|
const skipped = p._skipped || {};
|
||||||
return MEAL_SLOTS.some((s) => {
|
return MEAL_SLOTS.some((s) => {
|
||||||
|
if (skipped[s.id]) return false;
|
||||||
const arr = p[s.id];
|
const arr = p[s.id];
|
||||||
return Array.isArray(arr) && arr.length > 0;
|
return Array.isArray(arr) && arr.length > 0;
|
||||||
});
|
});
|
||||||
@@ -17,7 +19,9 @@ export function sumDayNutrition(dayPlan) {
|
|||||||
let fat = 0;
|
let fat = 0;
|
||||||
let carbs = 0;
|
let carbs = 0;
|
||||||
let mealCount = 0;
|
let mealCount = 0;
|
||||||
|
const skipped = dayPlan._skipped || {};
|
||||||
MEAL_SLOTS.forEach((slot) => {
|
MEAL_SLOTS.forEach((slot) => {
|
||||||
|
if (skipped[slot.id]) return;
|
||||||
const entries = dayPlan[slot.id];
|
const entries = dayPlan[slot.id];
|
||||||
if (!Array.isArray(entries)) return;
|
if (!Array.isArray(entries)) return;
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
@@ -56,7 +60,9 @@ function resolveLine(ing, scaledAmount) {
|
|||||||
export function flattenDayIngredientLines(dayPlan) {
|
export function flattenDayIngredientLines(dayPlan) {
|
||||||
/** @type {ReturnType<typeof resolveLine>[]} */
|
/** @type {ReturnType<typeof resolveLine>[]} */
|
||||||
const out = [];
|
const out = [];
|
||||||
|
const skipped = dayPlan._skipped || {};
|
||||||
MEAL_SLOTS.forEach((slot) => {
|
MEAL_SLOTS.forEach((slot) => {
|
||||||
|
if (skipped[slot.id]) return;
|
||||||
const entries = dayPlan[slot.id];
|
const entries = dayPlan[slot.id];
|
||||||
if (!Array.isArray(entries)) return;
|
if (!Array.isArray(entries)) return;
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
@@ -112,7 +118,9 @@ export function aggregateWeekIngredientNeed(plans, weekStart) {
|
|||||||
export function aggregateDayIngredientsBySlot(dayPlan) {
|
export function aggregateDayIngredientsBySlot(dayPlan) {
|
||||||
/** @type {{ mealLabel: string, recipes: { recipeTitle: string, items: { ingredientId: string, name: string, amount: number, unit: string, category: string }[] }[] }[]} */
|
/** @type {{ mealLabel: string, recipes: { recipeTitle: string, items: { ingredientId: string, name: string, amount: number, unit: string, category: string }[] }[] }[]} */
|
||||||
const blocks = [];
|
const blocks = [];
|
||||||
|
const skipped = dayPlan._skipped || {};
|
||||||
MEAL_SLOTS.forEach((slot) => {
|
MEAL_SLOTS.forEach((slot) => {
|
||||||
|
if (skipped[slot.id]) return;
|
||||||
const entries = dayPlan[slot.id];
|
const entries = dayPlan[slot.id];
|
||||||
if (!Array.isArray(entries) || entries.length === 0) return;
|
if (!Array.isArray(entries) || entries.length === 0) return;
|
||||||
const recipes = [];
|
const recipes = [];
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ export function normalizeDayPlan(day) {
|
|||||||
const arr = normalizeSlotValue(day[s.id]);
|
const arr = normalizeSlotValue(day[s.id]);
|
||||||
if (arr.length > 0) out[s.id] = arr;
|
if (arr.length > 0) out[s.id] = arr;
|
||||||
});
|
});
|
||||||
|
if (day._skipped && typeof day._skipped === 'object') {
|
||||||
|
const sk = {};
|
||||||
|
for (const k of Object.keys(day._skipped)) {
|
||||||
|
if (day._skipped[k] === true) sk[k] = true;
|
||||||
|
}
|
||||||
|
if (Object.keys(sk).length > 0) out._skipped = sk;
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,48 @@
|
|||||||
|
import { RECIPES } from '../data/catalog.js';
|
||||||
|
import { MEAL_SLOTS } from '../planner/mealSlots.js';
|
||||||
|
import { applyFilters, getFilterState, getFilteredCount, refreshRecipeList } from './RecipeList.js';
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAllTags() {
|
||||||
|
const tags = new Set();
|
||||||
|
for (const r of Object.values(RECIPES)) {
|
||||||
|
for (const t of (r.tags || [])) tags.add(t);
|
||||||
|
}
|
||||||
|
return [...tags].sort((a, b) => a.localeCompare(b, 'pl'));
|
||||||
|
}
|
||||||
|
|
||||||
export function getFilterHTML() {
|
export function getFilterHTML() {
|
||||||
return `
|
return `
|
||||||
<div id="filter-view" class="absolute inset-0 bg-white z-50 hidden flex-col">
|
<div id="filter-view" class="absolute inset-0 bg-white z-50 hidden flex-col">
|
||||||
<div class="p-4 border-b border-gray-200 flex items-center justify-between mt-4">
|
<div class="p-4 border-b border-gray-200 flex items-center justify-between mt-4">
|
||||||
<button onclick="closeFilters()" class="w-10 h-10 flex items-center justify-center text-gray-600 hover:bg-gray-100 rounded-full transition-colors"><i class="fas fa-arrow-left text-lg"></i></button>
|
<button id="filter-close-btn" class="w-10 h-10 flex items-center justify-center text-gray-600 hover:bg-gray-100 rounded-full transition-colors"><i class="fas fa-arrow-left text-lg"></i></button>
|
||||||
<h2 class="text-lg font-semibold text-black">Filtry</h2>
|
<h2 class="text-lg font-semibold text-black">Filtry</h2>
|
||||||
<button class="px-2 text-sm font-medium text-gray-500 hover:text-black transition-colors">Wyczyść</button>
|
<button id="filter-clear-btn" class="px-2 text-sm font-medium text-gray-500 hover:text-black transition-colors">Wyczyść</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-6 space-y-8">
|
<div class="flex-1 overflow-y-auto p-6 space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-base font-semibold text-black mb-4">Pora posiłku</h3>
|
<h3 class="text-base font-semibold text-black mb-4">Pora posiłku</h3>
|
||||||
<div class="flex flex-wrap gap-2.5">
|
<div id="filter-slot-chips" class="flex flex-wrap gap-2.5"></div>
|
||||||
<button class="px-4 py-2 bg-gray-900 text-white rounded-full text-sm font-medium transition-colors">Śniadanie</button>
|
|
||||||
<button class="px-4 py-2 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-full text-sm font-medium transition-colors">Drugie śniadanie</button>
|
|
||||||
<button class="px-4 py-2 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-full text-sm font-medium transition-colors">Obiad</button>
|
|
||||||
<button class="px-4 py-2 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-full text-sm font-medium transition-colors">Podwieczorek</button>
|
|
||||||
<button class="px-4 py-2 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-full text-sm font-medium transition-colors">Kolacja</button>
|
|
||||||
<button class="px-4 py-2 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-full text-sm font-medium transition-colors">Przekąska</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-base font-semibold text-black mb-4">Dieta i tagi</h3>
|
<h3 class="text-base font-semibold text-black mb-4">Dieta i tagi</h3>
|
||||||
<div class="flex flex-wrap gap-2.5">
|
<div id="filter-tag-chips" class="flex flex-wrap gap-2.5"></div>
|
||||||
<button class="px-4 py-2 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-full text-sm font-medium transition-colors">Wegetariańska</button>
|
|
||||||
<button class="px-4 py-2 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-full text-sm font-medium transition-colors">Wegańska</button>
|
|
||||||
<button class="px-4 py-2 bg-gray-900 text-white rounded-full text-sm font-medium transition-colors">Wysokobiałkowe</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h3 class="text-base font-semibold text-black">Maks. czas przygotowania</h3>
|
<h3 class="text-base font-semibold text-black">Maks. czas przygotowania</h3>
|
||||||
<span id="time-display" class="text-sm font-medium text-gray-600">30 min</span>
|
<span id="time-display" class="text-sm font-medium text-gray-600">120 min</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-1">
|
<div class="px-1">
|
||||||
<input type="range" id="prep-time-slider" min="5" max="120" step="5" value="30" class="w-full appearance-none bg-transparent">
|
<input type="range" id="prep-time-slider" min="5" max="120" step="5" value="120" class="w-full appearance-none bg-transparent">
|
||||||
<div class="flex justify-between text-xs text-gray-400 mt-3 font-medium">
|
<div class="flex justify-between text-xs text-gray-400 mt-3 font-medium">
|
||||||
<span>5 min</span><span>30 min</span><span>1 godz.</span><span>2 godz.+</span>
|
<span>5 min</span><span>30 min</span><span>1 godz.</span><span>2 godz.+</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,20 +51,128 @@ export function getFilterHTML() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4 border-t border-gray-200 bg-white mt-auto">
|
<div class="p-4 border-t border-gray-200 bg-white mt-auto">
|
||||||
<button onclick="closeFilters()" class="w-full bg-gray-900 hover:bg-black text-white py-3.5 rounded-xl font-semibold shadow-sm transition-colors text-sm">Pokaż 12 wyników</button>
|
<button id="filter-apply-btn" class="w-full bg-gray-900 hover:bg-black text-white py-3.5 rounded-xl font-semibold shadow-sm transition-colors text-sm"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let localSlots = [];
|
||||||
|
let localTags = [];
|
||||||
|
let localMaxMinutes = 120;
|
||||||
|
|
||||||
|
function renderSlotChips() {
|
||||||
|
const wrap = document.getElementById('filter-slot-chips');
|
||||||
|
if (!wrap) return;
|
||||||
|
|
||||||
|
wrap.innerHTML = MEAL_SLOTS.map((slot) => {
|
||||||
|
const active = localSlots.includes(slot.id);
|
||||||
|
const cls = active
|
||||||
|
? 'px-4 py-2 bg-gray-900 text-white rounded-full text-sm font-medium transition-colors'
|
||||||
|
: 'px-4 py-2 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-full text-sm font-medium transition-colors';
|
||||||
|
return `<button type="button" data-filter-slot="${escapeHtml(slot.id)}" class="${cls}">${escapeHtml(slot.label)}</button>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
wrap.querySelectorAll('[data-filter-slot]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const id = btn.dataset.filterSlot;
|
||||||
|
const idx = localSlots.indexOf(id);
|
||||||
|
if (idx >= 0) localSlots.splice(idx, 1);
|
||||||
|
else localSlots.push(id);
|
||||||
|
renderSlotChips();
|
||||||
|
updateResultCount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTagChips() {
|
||||||
|
const wrap = document.getElementById('filter-tag-chips');
|
||||||
|
if (!wrap) return;
|
||||||
|
|
||||||
|
const allTags = collectAllTags();
|
||||||
|
wrap.innerHTML = allTags.map((tag) => {
|
||||||
|
const active = localTags.includes(tag.toLowerCase());
|
||||||
|
const cls = active
|
||||||
|
? 'px-4 py-2 bg-gray-900 text-white rounded-full text-sm font-medium transition-colors'
|
||||||
|
: 'px-4 py-2 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-full text-sm font-medium transition-colors';
|
||||||
|
return `<button type="button" data-filter-tag="${escapeHtml(tag)}" class="${cls}">${escapeHtml(tag)}</button>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
wrap.querySelectorAll('[data-filter-tag]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const tag = btn.dataset.filterTag.toLowerCase();
|
||||||
|
const idx = localTags.indexOf(tag);
|
||||||
|
if (idx >= 0) localTags.splice(idx, 1);
|
||||||
|
else localTags.push(tag);
|
||||||
|
renderTagChips();
|
||||||
|
updateResultCount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateResultCount() {
|
||||||
|
applyFilters({ slots: localSlots, tags: localTags, maxMinutes: localMaxMinutes });
|
||||||
|
const count = getFilteredCount();
|
||||||
|
const applyBtn = document.getElementById('filter-apply-btn');
|
||||||
|
if (applyBtn) applyBtn.textContent = `Pokaż ${count} wyników`;
|
||||||
|
}
|
||||||
|
|
||||||
export function setupFilter() {
|
export function setupFilter() {
|
||||||
const timeSlider = document.getElementById('prep-time-slider');
|
const timeSlider = document.getElementById('prep-time-slider');
|
||||||
const timeDisplay = document.getElementById('time-display');
|
const timeDisplay = document.getElementById('time-display');
|
||||||
|
|
||||||
if(timeSlider) {
|
if (timeSlider) {
|
||||||
timeSlider.addEventListener('input', (e) => {
|
timeSlider.addEventListener('input', (e) => {
|
||||||
const val = e.target.value;
|
const val = Number(e.target.value);
|
||||||
|
localMaxMinutes = val;
|
||||||
timeDisplay.textContent = val >= 120 ? 'ponad 120 min' : `${val} min`;
|
timeDisplay.textContent = val >= 120 ? 'ponad 120 min' : `${val} min`;
|
||||||
|
updateResultCount();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.getElementById('filter-close-btn')?.addEventListener('click', () => {
|
||||||
|
window.closeFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('filter-apply-btn')?.addEventListener('click', () => {
|
||||||
|
applyFilters({ slots: localSlots, tags: localTags, maxMinutes: localMaxMinutes });
|
||||||
|
refreshRecipeList();
|
||||||
|
window.closeFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('filter-clear-btn')?.addEventListener('click', () => {
|
||||||
|
localSlots = [];
|
||||||
|
localTags = [];
|
||||||
|
localMaxMinutes = 120;
|
||||||
|
if (timeSlider) timeSlider.value = '120';
|
||||||
|
if (timeDisplay) timeDisplay.textContent = '120 min';
|
||||||
|
renderSlotChips();
|
||||||
|
renderTagChips();
|
||||||
|
applyFilters({ slots: [], tags: [], maxMinutes: 120 });
|
||||||
|
updateResultCount();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.openFilters = () => {
|
||||||
|
const state = getFilterState();
|
||||||
|
localSlots = [...state.slots];
|
||||||
|
localTags = [...state.tags];
|
||||||
|
localMaxMinutes = state.maxMinutes;
|
||||||
|
|
||||||
|
if (timeSlider) timeSlider.value = String(localMaxMinutes);
|
||||||
|
if (timeDisplay) timeDisplay.textContent = localMaxMinutes >= 120 ? 'ponad 120 min' : `${localMaxMinutes} min`;
|
||||||
|
|
||||||
|
renderSlotChips();
|
||||||
|
renderTagChips();
|
||||||
|
updateResultCount();
|
||||||
|
|
||||||
|
const fv = document.getElementById('filter-view');
|
||||||
|
fv.classList.remove('hidden');
|
||||||
|
fv.classList.add('flex');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.closeFilters = () => {
|
||||||
|
const fv = document.getElementById('filter-view');
|
||||||
|
fv.classList.add('hidden');
|
||||||
|
fv.classList.remove('flex');
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -35,8 +35,8 @@ const WEEKDAYS_LONG = [
|
|||||||
'Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota',
|
'Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Odstęp od dołu planera = miejsce na dolną nawigację. Ten sam w `bottom`, `max-height` i w `translateY(calc(100% + …))` przy zamknięciu — inaczej zostaje widoczny uchwyt. */
|
|
||||||
const PLANNER_SHEET_BOTTOM_INSET = '5.25rem';
|
const PLANNER_SHEET_BOTTOM_INSET = '5.25rem';
|
||||||
|
const PLANNER_SHEET_MAX_HEIGHT = '70vh';
|
||||||
const PLANNER_SHEET_OFF_TRANSFORM = `translateY(calc(100% + ${PLANNER_SHEET_BOTTOM_INSET}))`;
|
const PLANNER_SHEET_OFF_TRANSFORM = `translateY(calc(100% + ${PLANNER_SHEET_BOTTOM_INSET}))`;
|
||||||
|
|
||||||
function recipesForSlot(slotId) {
|
function recipesForSlot(slotId) {
|
||||||
@@ -100,7 +100,12 @@ export function getMealPlannerHTML() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="planner-scroll" class="flex-1 overflow-y-auto px-4 pt-3 pb-4">
|
<div id="planner-scroll" class="flex-1 overflow-y-auto px-4 pt-3 pb-4">
|
||||||
<p id="planner-day-heading" class="text-[13px] font-semibold text-gray-900 tabular-nums mb-2"></p>
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<p id="planner-day-heading" class="text-[13px] font-semibold text-gray-900 tabular-nums"></p>
|
||||||
|
<button type="button" id="planner-copy-day" class="shrink-0 text-[11px] font-semibold text-gray-500 hover:text-gray-900 px-2.5 py-1 rounded-lg hover:bg-gray-100 transition-colors flex items-center gap-1.5">
|
||||||
|
<i class="fas fa-copy text-[9px]"></i>Kopiuj dzień
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="planner-summary-card" class="rounded-xl border border-amber-200/80 bg-gradient-to-br from-amber-50 to-white p-2.5 shadow-sm mb-3">
|
<div id="planner-summary-card" class="rounded-xl border border-amber-200/80 bg-gradient-to-br from-amber-50 to-white p-2.5 shadow-sm mb-3">
|
||||||
<div class="flex items-start justify-between gap-2 mb-2">
|
<div class="flex items-start justify-between gap-2 mb-2">
|
||||||
<div>
|
<div>
|
||||||
@@ -144,17 +149,20 @@ export function getMealPlannerHTML() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="planner-picker-backdrop" class="absolute left-0 right-0 top-0 z-[45] bg-black/45 hidden opacity-0 transition-opacity duration-200" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}" aria-hidden="true"></div>
|
<div id="planner-picker-backdrop" class="absolute left-0 right-0 top-0 z-[45] bg-black/45 hidden opacity-0 transition-opacity duration-200" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}" aria-hidden="true"></div>
|
||||||
<div id="planner-picker-sheet" class="absolute left-0 right-0 z-[50] bg-white rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] flex flex-col min-h-0 will-change-transform" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}; max-height: calc(100% - ${PLANNER_SHEET_BOTTOM_INSET}); transform: ${PLANNER_SHEET_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1)" role="dialog" aria-labelledby="planner-picker-title" aria-modal="true">
|
<div id="planner-picker-sheet" class="absolute left-0 right-0 z-[50] bg-white rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] flex flex-col will-change-transform" style="visibility: hidden; bottom: ${PLANNER_SHEET_BOTTOM_INSET}; height: auto; max-height: ${PLANNER_SHEET_MAX_HEIGHT}; transform: ${PLANNER_SHEET_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1)" role="dialog" aria-labelledby="planner-picker-title" aria-modal="true">
|
||||||
<div class="shrink-0 px-4 pt-3 pb-2 border-b border-gray-100 touch-none cursor-grab active:cursor-grabbing select-none" data-planner-sheet-drag-zone aria-label="Przeciągnij w dół, by zamknąć">
|
<div class="shrink-0 px-4 pt-3 pb-2 border-b border-gray-100 touch-none cursor-grab active:cursor-grabbing select-none" data-planner-sheet-drag-zone aria-label="Przeciągnij w dół, by zamknąć">
|
||||||
<div class="w-10 h-1 bg-gray-200 rounded-full mx-auto mb-2.5" aria-hidden="true"></div>
|
<div class="w-10 h-1 bg-gray-200 rounded-full mx-auto mb-2.5" aria-hidden="true"></div>
|
||||||
<h2 id="planner-picker-title" class="text-[15px] font-bold text-gray-900 leading-tight pr-2">Wybierz przepis</h2>
|
<h2 id="planner-picker-title" class="text-[15px] font-bold text-gray-900 leading-tight pr-2">Wybierz przepis</h2>
|
||||||
<p id="planner-picker-sub" class="text-[11px] text-gray-500 mt-1"></p>
|
<p id="planner-picker-sub" class="text-[11px] text-gray-500 mt-1"></p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="shrink-0 px-4 pt-2 pb-2">
|
||||||
|
<input type="text" id="planner-picker-search" class="w-full rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm outline-none focus:border-gray-400 placeholder:text-gray-400" placeholder="Szukaj przepisu…" />
|
||||||
|
</div>
|
||||||
<div id="planner-picker-list" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-4 py-2.5 pb-8 space-y-2"></div>
|
<div id="planner-picker-list" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-4 py-2.5 pb-8 space-y-2"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="planner-ing-backdrop" class="absolute left-0 right-0 top-0 z-[45] bg-black/45 hidden opacity-0 transition-opacity duration-200" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}" aria-hidden="true"></div>
|
<div id="planner-ing-backdrop" class="absolute left-0 right-0 top-0 z-[45] bg-black/45 hidden opacity-0 transition-opacity duration-200" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}" aria-hidden="true"></div>
|
||||||
<div id="planner-ing-sheet" class="absolute left-0 right-0 z-[50] bg-white rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] flex flex-col min-h-0 will-change-transform" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}; max-height: calc(100% - ${PLANNER_SHEET_BOTTOM_INSET}); transform: ${PLANNER_SHEET_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1)" role="dialog" aria-labelledby="planner-ing-title" aria-modal="true">
|
<div id="planner-ing-sheet" class="absolute left-0 right-0 z-[50] bg-white rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] flex flex-col will-change-transform" style="visibility: hidden; bottom: ${PLANNER_SHEET_BOTTOM_INSET}; height: auto; max-height: ${PLANNER_SHEET_MAX_HEIGHT}; transform: ${PLANNER_SHEET_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1)" role="dialog" aria-labelledby="planner-ing-title" aria-modal="true">
|
||||||
<div class="shrink-0 px-4 pt-3 pb-2 border-b border-gray-100 touch-none cursor-grab active:cursor-grabbing select-none" data-planner-sheet-drag-zone aria-label="Przeciągnij w dół, by zamknąć">
|
<div class="shrink-0 px-4 pt-3 pb-2 border-b border-gray-100 touch-none cursor-grab active:cursor-grabbing select-none" data-planner-sheet-drag-zone aria-label="Przeciągnij w dół, by zamknąć">
|
||||||
<div class="w-10 h-1 bg-gray-200 rounded-full mx-auto mb-2.5" aria-hidden="true"></div>
|
<div class="w-10 h-1 bg-gray-200 rounded-full mx-auto mb-2.5" aria-hidden="true"></div>
|
||||||
<h2 id="planner-ing-title" class="text-[15px] font-bold text-gray-900 leading-tight pr-2">Składniki i spiżarnia</h2>
|
<h2 id="planner-ing-title" class="text-[15px] font-bold text-gray-900 leading-tight pr-2">Składniki i spiżarnia</h2>
|
||||||
@@ -173,6 +181,16 @@ export function getMealPlannerHTML() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="planner-copy-backdrop" class="absolute left-0 right-0 top-0 z-[45] bg-black/45 hidden opacity-0 transition-opacity duration-200" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}" aria-hidden="true"></div>
|
||||||
|
<div id="planner-copy-sheet" class="absolute left-0 right-0 z-[50] bg-white rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] flex flex-col will-change-transform" style="visibility: hidden; bottom: ${PLANNER_SHEET_BOTTOM_INSET}; height: auto; max-height: ${PLANNER_SHEET_MAX_HEIGHT}; transform: ${PLANNER_SHEET_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1)" role="dialog" aria-modal="true">
|
||||||
|
<div class="shrink-0 px-4 pt-3 pb-2 border-b border-gray-100 touch-none cursor-grab active:cursor-grabbing select-none" data-planner-sheet-drag-zone>
|
||||||
|
<div class="w-10 h-1 bg-gray-200 rounded-full mx-auto mb-2.5"></div>
|
||||||
|
<h2 class="text-[15px] font-bold text-gray-900 leading-tight">Kopiuj plan dnia</h2>
|
||||||
|
<p id="planner-copy-sub" class="text-[11px] text-gray-500 mt-1">Wybierz dzień docelowy.</p>
|
||||||
|
</div>
|
||||||
|
<div id="planner-copy-list" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-4 py-2.5 pb-8 space-y-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="planner-toast" class="pointer-events-none absolute left-4 right-4 bottom-28 z-[55] opacity-0 translate-y-2 transition-all duration-300" role="status">
|
<div id="planner-toast" class="pointer-events-none absolute left-4 right-4 bottom-28 z-[55] opacity-0 translate-y-2 transition-all duration-300" role="status">
|
||||||
<div class="rounded-xl bg-gray-900 text-white text-sm font-medium px-4 py-3 shadow-lg text-center" id="planner-toast-text"></div>
|
<div class="rounded-xl bg-gray-900 text-white text-sm font-medium px-4 py-3 shadow-lg text-center" id="planner-toast-text"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -351,6 +369,7 @@ function showPlannerToast(message) {
|
|||||||
|
|
||||||
function openSheet(backdrop, sheet) {
|
function openSheet(backdrop, sheet) {
|
||||||
if (!backdrop || !sheet) return;
|
if (!backdrop || !sheet) return;
|
||||||
|
sheet.style.visibility = 'visible';
|
||||||
sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)';
|
sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)';
|
||||||
sheet.style.transform = 'translateY(0)';
|
sheet.style.transform = 'translateY(0)';
|
||||||
backdrop.classList.remove('hidden');
|
backdrop.classList.remove('hidden');
|
||||||
@@ -364,7 +383,10 @@ function closeSheet(backdrop, sheet) {
|
|||||||
sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)';
|
sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)';
|
||||||
sheet.style.transform = PLANNER_SHEET_OFF_TRANSFORM;
|
sheet.style.transform = PLANNER_SHEET_OFF_TRANSFORM;
|
||||||
backdrop.classList.add('opacity-0');
|
backdrop.classList.add('opacity-0');
|
||||||
setTimeout(() => backdrop.classList.add('hidden'), 300);
|
setTimeout(() => {
|
||||||
|
backdrop.classList.add('hidden');
|
||||||
|
sheet.style.visibility = 'hidden';
|
||||||
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zamykanie panelu: przeciągnięcie nagłówka w dół (pointer). */
|
/** Zamykanie panelu: przeciągnięcie nagłówka w dół (pointer). */
|
||||||
@@ -472,10 +494,22 @@ function renderDayContent(state) {
|
|||||||
const slotsRoot = document.getElementById('planner-meal-slots');
|
const slotsRoot = document.getElementById('planner-meal-slots');
|
||||||
if (!slotsRoot) return;
|
if (!slotsRoot) return;
|
||||||
|
|
||||||
|
const skipped = dayPlan._skipped || {};
|
||||||
|
|
||||||
slotsRoot.innerHTML = MEAL_SLOTS.map((slot) => {
|
slotsRoot.innerHTML = MEAL_SLOTS.map((slot) => {
|
||||||
const entries = Array.isArray(dayPlan[slot.id]) ? dayPlan[slot.id] : [];
|
const isSkipped = skipped[slot.id] === true;
|
||||||
|
const entries = isSkipped ? [] : (Array.isArray(dayPlan[slot.id]) ? dayPlan[slot.id] : []);
|
||||||
|
|
||||||
|
let slotKcal = 0;
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const r = entry?.recipeId ? RECIPES[entry.recipeId] : null;
|
||||||
|
if (r) slotKcal += Math.round(r.nutritionPerServing.kcal * Math.max(1, Number(entry.servings) || 1));
|
||||||
|
});
|
||||||
|
const kcalBadge = slotKcal > 0
|
||||||
|
? `<span class="text-[10px] font-semibold text-amber-600 tabular-nums shrink-0 ml-auto">${slotKcal} kcal</span>`
|
||||||
|
: '';
|
||||||
const countLabel = entries.length > 1
|
const countLabel = entries.length > 1
|
||||||
? `<span class="text-[10px] font-semibold text-gray-400 tabular-nums shrink-0 ml-auto">${entries.length} dania</span>`
|
? `<span class="text-[10px] font-semibold text-gray-400 tabular-nums shrink-0">${entries.length} dania</span>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const entryCards = entries.map((entry) => {
|
const entryCards = entries.map((entry) => {
|
||||||
@@ -488,12 +522,12 @@ function renderDayContent(state) {
|
|||||||
return `
|
return `
|
||||||
<div class="rounded-lg border border-gray-200 bg-white p-2 shadow-sm" data-slot-id="${slot.id}" data-entry-id="${eid}">
|
<div class="rounded-lg border border-gray-200 bg-white p-2 shadow-sm" data-slot-id="${slot.id}" data-entry-id="${eid}">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0 cursor-pointer planner-open-recipe" data-recipe-id="${escapeHtml(recipe.id)}">
|
||||||
<div class="w-8 h-8 rounded-lg bg-[#d4d4d4] flex items-center justify-center shrink-0">
|
<div class="w-8 h-8 rounded-lg bg-[#d4d4d4] flex items-center justify-center shrink-0">
|
||||||
<span class="text-white text-[8px] font-medium">${escapeHtml(recipe.thumbLabel)}</span>
|
<span class="text-white text-[8px] font-medium">${escapeHtml(recipe.thumbLabel)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="text-[13px] font-bold text-gray-900 truncate">${escapeHtml(recipe.title)}</p>
|
<p class="text-[13px] font-bold text-gray-900 truncate underline decoration-1 underline-offset-2">${escapeHtml(recipe.title)}</p>
|
||||||
<p class="text-[11px] text-gray-500 mt-0.5 tabular-nums">
|
<p class="text-[11px] text-gray-500 mt-0.5 tabular-nums">
|
||||||
<i class="fas fa-clock text-gray-400 mr-0.5" aria-hidden="true"></i>${recipe.minutes} min
|
<i class="fas fa-clock text-gray-400 mr-0.5" aria-hidden="true"></i>${recipe.minutes} min
|
||||||
<span class="mx-1.5 text-gray-300">·</span>
|
<span class="mx-1.5 text-gray-300">·</span>
|
||||||
@@ -516,11 +550,35 @@ function renderDayContent(state) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
const addLabel = entries.length === 0 ? 'Dodaj przepis' : 'Dodaj kolejny przepis';
|
if (isSkipped) {
|
||||||
|
return `
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden opacity-60" data-slot-id="${slot.id}">
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2 border-b border-gray-100 bg-gray-50/90">
|
||||||
|
<span class="w-7 h-7 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 shrink-0">
|
||||||
|
<i class="fas ${slot.icon} text-[13px]" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
<span class="text-[13px] font-semibold text-gray-400 truncate min-w-0 flex-1">${slot.label}</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-2.5 flex items-center justify-between">
|
||||||
|
<span class="text-xs text-gray-400 italic"><i class="fas fa-forward text-[9px] mr-1.5"></i>Pominięto</span>
|
||||||
|
<button type="button" class="planner-unskip text-[11px] font-semibold text-gray-500 hover:text-gray-900 px-2 py-1 rounded-lg hover:bg-gray-100 transition-colors" data-slot-id="${slot.id}">Cofnij</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const addLabel = entries.length === 0 ? 'Dodaj przepis' : 'Dodaj kolejny';
|
||||||
const addClasses = entries.length === 0
|
const addClasses = entries.length === 0
|
||||||
? 'planner-add-meal w-full py-2 rounded-lg border border-dashed border-gray-200 text-[13px] font-semibold text-gray-700 hover:bg-gray-50 hover:border-gray-300 transition-colors'
|
? 'planner-add-meal flex-1 py-2 rounded-lg border border-dashed border-gray-200 text-[13px] font-semibold text-gray-700 hover:bg-gray-50 hover:border-gray-300 transition-colors'
|
||||||
: 'planner-add-meal w-full py-1.5 rounded-lg border border-dashed border-gray-200 text-xs font-semibold text-gray-600 hover:bg-gray-50 hover:border-gray-300 transition-colors';
|
: 'planner-add-meal w-full py-1.5 rounded-lg border border-dashed border-gray-200 text-xs font-semibold text-gray-600 hover:bg-gray-50 hover:border-gray-300 transition-colors';
|
||||||
|
|
||||||
|
const skipBtn = entries.length === 0
|
||||||
|
? `<button type="button" class="planner-skip-meal shrink-0 py-2 px-3 rounded-lg text-[11px] font-semibold text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors" data-slot-id="${slot.id}"><i class="fas fa-forward text-[9px] mr-1"></i>Pomijam</button>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const bottomRow = entries.length === 0
|
||||||
|
? `<div class="flex gap-2">${`<button type="button" class="${addClasses}" data-slot-id="${slot.id}"><i class="fas fa-plus text-[10px] mr-1 opacity-70" aria-hidden="true"></i>${addLabel}</button>`}${skipBtn}</div>`
|
||||||
|
: `<button type="button" class="${addClasses}" data-slot-id="${slot.id}"><i class="fas fa-plus text-[10px] mr-1 opacity-70" aria-hidden="true"></i>${addLabel}</button>`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden" data-slot-id="${slot.id}">
|
<div class="rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden" data-slot-id="${slot.id}">
|
||||||
<div class="flex items-center gap-2 px-3 py-2 border-b border-gray-100 bg-gray-50/90">
|
<div class="flex items-center gap-2 px-3 py-2 border-b border-gray-100 bg-gray-50/90">
|
||||||
@@ -529,13 +587,11 @@ function renderDayContent(state) {
|
|||||||
</span>
|
</span>
|
||||||
<span class="text-[13px] font-semibold text-gray-900 truncate min-w-0 flex-1">${slot.label}</span>
|
<span class="text-[13px] font-semibold text-gray-900 truncate min-w-0 flex-1">${slot.label}</span>
|
||||||
${countLabel}
|
${countLabel}
|
||||||
|
${kcalBadge}
|
||||||
</div>
|
</div>
|
||||||
<div class="p-2.5 space-y-2">
|
<div class="p-2.5 space-y-2">
|
||||||
${entryCards}
|
${entryCards}
|
||||||
<button type="button" class="${addClasses}" data-slot-id="${slot.id}">
|
${bottomRow}
|
||||||
<i class="fas fa-plus text-[10px] mr-1 opacity-70" aria-hidden="true"></i>
|
|
||||||
${addLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -549,23 +605,29 @@ function escapeHtml(s) {
|
|||||||
.replace(/"/g, '"');
|
.replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPickerList(slotId) {
|
function getRecentRecipeIds(plans, limit = 5) {
|
||||||
const slot = MEAL_SLOTS.find((s) => s.id === slotId);
|
const seen = new Map();
|
||||||
const list = document.getElementById('planner-picker-list');
|
const keys = Object.keys(plans).sort().reverse();
|
||||||
const title = document.getElementById('planner-picker-title');
|
for (const key of keys) {
|
||||||
const sub = document.getElementById('planner-picker-sub');
|
const day = plans[key];
|
||||||
if (!list || !title || !sub) return;
|
if (!day) continue;
|
||||||
|
for (const slotId of Object.keys(day)) {
|
||||||
title.textContent = 'Wybierz przepis';
|
if (slotId === '_skipped') continue;
|
||||||
sub.textContent = slot ? `Dla: ${slot.label}. Przeciągnij nagłówek w dół lub dotknij tła, by zamknąć.` : '';
|
const entries = day[slotId];
|
||||||
|
if (!Array.isArray(entries)) continue;
|
||||||
const recipes = recipesForSlot(slotId);
|
for (const e of entries) {
|
||||||
if (recipes.length === 0) {
|
if (e?.recipeId && RECIPES[e.recipeId] && !seen.has(e.recipeId)) {
|
||||||
list.innerHTML = '<p class="text-sm text-gray-500 text-center py-6">Brak dopasowanych przepisów.</p>';
|
seen.set(e.recipeId, true);
|
||||||
return;
|
if (seen.size >= limit) return [...seen.keys()];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...seen.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
list.innerHTML = recipes.map((r) => `
|
function recipeCardHtml(r) {
|
||||||
|
return `
|
||||||
<button type="button" class="planner-pick-recipe w-full flex gap-2.5 p-2.5 rounded-xl border border-gray-200 bg-gray-50/80 hover:border-gray-900 hover:bg-white text-left transition-all" data-recipe-id="${r.id}">
|
<button type="button" class="planner-pick-recipe w-full flex gap-2.5 p-2.5 rounded-xl border border-gray-200 bg-gray-50/80 hover:border-gray-900 hover:bg-white text-left transition-all" data-recipe-id="${r.id}">
|
||||||
<div class="w-11 h-11 rounded-lg bg-[#d4d4d4] flex items-center justify-center shrink-0">
|
<div class="w-11 h-11 rounded-lg bg-[#d4d4d4] flex items-center justify-center shrink-0">
|
||||||
<span class="text-white text-[9px] font-medium">${escapeHtml(r.thumbLabel)}</span>
|
<span class="text-white text-[9px] font-medium">${escapeHtml(r.thumbLabel)}</span>
|
||||||
@@ -578,8 +640,55 @@ function renderPickerList(slotId) {
|
|||||||
<i class="fas fa-clock text-gray-400 mr-0.5" aria-hidden="true"></i>${r.minutes} min
|
<i class="fas fa-clock text-gray-400 mr-0.5" aria-hidden="true"></i>${r.minutes} min
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>`;
|
||||||
`).join('');
|
}
|
||||||
|
|
||||||
|
let _pickerSlotRecipes = [];
|
||||||
|
let _pickerPlans = {};
|
||||||
|
|
||||||
|
function renderPickerList(slotId, plans, query = '') {
|
||||||
|
const slot = MEAL_SLOTS.find((s) => s.id === slotId);
|
||||||
|
const list = document.getElementById('planner-picker-list');
|
||||||
|
const title = document.getElementById('planner-picker-title');
|
||||||
|
const sub = document.getElementById('planner-picker-sub');
|
||||||
|
if (!list || !title || !sub) return;
|
||||||
|
|
||||||
|
title.textContent = 'Wybierz przepis';
|
||||||
|
sub.textContent = slot ? `Dla: ${slot.label}` : '';
|
||||||
|
|
||||||
|
const allRecipes = recipesForSlot(slotId);
|
||||||
|
_pickerSlotRecipes = allRecipes;
|
||||||
|
_pickerPlans = plans;
|
||||||
|
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const filtered = q
|
||||||
|
? allRecipes.filter((r) => r.title.toLowerCase().includes(q) || (r.tags || []).some((t) => t.toLowerCase().includes(q)))
|
||||||
|
: allRecipes;
|
||||||
|
|
||||||
|
if (filtered.length === 0 && q) {
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 text-center py-6">Brak wyników.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 text-center py-6">Brak dopasowanych przepisów.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
if (!q) {
|
||||||
|
const recentIds = getRecentRecipeIds(plans);
|
||||||
|
const recentInSlot = recentIds.map((id) => RECIPES[id]).filter((r) => r && r.allowedSlots.includes(slotId));
|
||||||
|
if (recentInSlot.length > 0) {
|
||||||
|
html += `<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider px-0.5 pt-1 pb-1"><i class="fas fa-history text-[9px] mr-1"></i>Ostatnio używane</p>`;
|
||||||
|
html += recentInSlot.map(recipeCardHtml).join('');
|
||||||
|
html += `<div class="border-t border-gray-100 my-2"></div>`;
|
||||||
|
html += `<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider px-0.5 pt-1 pb-1">Wszystkie</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html += filtered.map(recipeCardHtml).join('');
|
||||||
|
list.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function plIngredientWord(n) {
|
function plIngredientWord(n) {
|
||||||
@@ -792,6 +901,8 @@ export function setupMealPlanner() {
|
|||||||
const pickerSheet = document.getElementById('planner-picker-sheet');
|
const pickerSheet = document.getElementById('planner-picker-sheet');
|
||||||
const ingBackdrop = document.getElementById('planner-ing-backdrop');
|
const ingBackdrop = document.getElementById('planner-ing-backdrop');
|
||||||
const ingSheet = document.getElementById('planner-ing-sheet');
|
const ingSheet = document.getElementById('planner-ing-sheet');
|
||||||
|
const copyBackdrop = document.getElementById('planner-copy-backdrop');
|
||||||
|
const copySheet = document.getElementById('planner-copy-sheet');
|
||||||
|
|
||||||
const rerender = () => {
|
const rerender = () => {
|
||||||
syncModeToggle(state.mode);
|
syncModeToggle(state.mode);
|
||||||
@@ -859,11 +970,45 @@ export function setupMealPlanner() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('planner-meal-slots')?.addEventListener('click', (e) => {
|
document.getElementById('planner-meal-slots')?.addEventListener('click', (e) => {
|
||||||
|
const skipBtn = e.target.closest('.planner-skip-meal');
|
||||||
|
if (skipBtn) {
|
||||||
|
const slotId = skipBtn.getAttribute('data-slot-id');
|
||||||
|
if (!slotId) return;
|
||||||
|
const key = dateKey(state.selected);
|
||||||
|
if (!state.plans[key]) state.plans[key] = {};
|
||||||
|
if (!state.plans[key]._skipped) state.plans[key]._skipped = {};
|
||||||
|
state.plans[key]._skipped[slotId] = true;
|
||||||
|
persist();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const unskipBtn = e.target.closest('.planner-unskip');
|
||||||
|
if (unskipBtn) {
|
||||||
|
const slotId = unskipBtn.getAttribute('data-slot-id');
|
||||||
|
if (!slotId) return;
|
||||||
|
const key = dateKey(state.selected);
|
||||||
|
if (state.plans[key]?._skipped) {
|
||||||
|
delete state.plans[key]._skipped[slotId];
|
||||||
|
if (Object.keys(state.plans[key]._skipped).length === 0) delete state.plans[key]._skipped;
|
||||||
|
if (Object.keys(state.plans[key]).length === 0) delete state.plans[key];
|
||||||
|
}
|
||||||
|
persist();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const openRecipe = e.target.closest('.planner-open-recipe');
|
||||||
|
if (openRecipe) {
|
||||||
|
const recipeId = openRecipe.getAttribute('data-recipe-id');
|
||||||
|
if (recipeId && window.openRecipeDetail) {
|
||||||
|
window.openRecipeDetail(recipeId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const addBtn = e.target.closest('.planner-add-meal');
|
const addBtn = e.target.closest('.planner-add-meal');
|
||||||
if (addBtn) {
|
if (addBtn) {
|
||||||
const slotId = addBtn.getAttribute('data-slot-id');
|
const slotId = addBtn.getAttribute('data-slot-id');
|
||||||
state.pickerSlot = slotId;
|
state.pickerSlot = slotId;
|
||||||
renderPickerList(slotId);
|
const searchInput = document.getElementById('planner-picker-search');
|
||||||
|
if (searchInput) searchInput.value = '';
|
||||||
|
renderPickerList(slotId, state.plans);
|
||||||
openSheet(pickerBackdrop, pickerSheet);
|
openSheet(pickerBackdrop, pickerSheet);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -904,6 +1049,12 @@ export function setupMealPlanner() {
|
|||||||
closeSheet(pickerBackdrop, pickerSheet);
|
closeSheet(pickerBackdrop, pickerSheet);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
document.getElementById('planner-picker-search')?.addEventListener('input', (e) => {
|
||||||
|
if (state.pickerSlot) {
|
||||||
|
renderPickerList(state.pickerSlot, state.plans, e.target.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
bindPlannerSheetDragClose(pickerSheet, closePicker);
|
bindPlannerSheetDragClose(pickerSheet, closePicker);
|
||||||
bindPlannerSheetDragClose(ingSheet, () => closeSheet(ingBackdrop, ingSheet));
|
bindPlannerSheetDragClose(ingSheet, () => closeSheet(ingBackdrop, ingSheet));
|
||||||
|
|
||||||
@@ -933,6 +1084,63 @@ export function setupMealPlanner() {
|
|||||||
closeSheet(ingBackdrop, ingSheet);
|
closeSheet(ingBackdrop, ingSheet);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const closeCopy = () => closeSheet(copyBackdrop, copySheet);
|
||||||
|
bindPlannerSheetDragClose(copySheet, closeCopy);
|
||||||
|
copyBackdrop?.addEventListener('click', closeCopy);
|
||||||
|
|
||||||
|
document.getElementById('planner-copy-day')?.addEventListener('click', () => {
|
||||||
|
const srcKey = dateKey(state.selected);
|
||||||
|
const srcPlan = state.plans[srcKey];
|
||||||
|
if (!srcPlan || Object.keys(srcPlan).length === 0) {
|
||||||
|
showPlannerToast('Ten dzień jest pusty — nie ma co kopiować.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const copyList = document.getElementById('planner-copy-list');
|
||||||
|
if (!copyList) return;
|
||||||
|
const days = [];
|
||||||
|
for (let i = -3; i <= 10; i++) {
|
||||||
|
if (i === 0) continue;
|
||||||
|
const d = addDays(state.selected, i);
|
||||||
|
days.push(d);
|
||||||
|
}
|
||||||
|
copyList.innerHTML = days.map((d) => {
|
||||||
|
const wd = WEEKDAYS_LONG[d.getDay()];
|
||||||
|
const label = `${wd}, ${d.getDate()} ${MONTHS_SHORT[d.getMonth()]}`;
|
||||||
|
const hasMeals = dayHasAnyMeal(state.plans, d);
|
||||||
|
const badge = hasMeals ? '<span class="text-[10px] text-amber-600 font-semibold">ma posiłki</span>' : '';
|
||||||
|
return `<button type="button" class="planner-copy-target w-full flex items-center justify-between gap-2 p-3 rounded-xl border border-gray-200 bg-gray-50/80 hover:border-gray-900 hover:bg-white transition-all text-left" data-target-ts="${d.getTime()}">
|
||||||
|
<span class="text-[13px] font-semibold text-gray-900">${escapeHtml(label)}</span>
|
||||||
|
${badge}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
openSheet(copyBackdrop, copySheet);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('planner-copy-list')?.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.planner-copy-target');
|
||||||
|
if (!btn) return;
|
||||||
|
const targetDate = new Date(Number(btn.getAttribute('data-target-ts')));
|
||||||
|
const srcKey = dateKey(state.selected);
|
||||||
|
const tgtKey = dateKey(targetDate);
|
||||||
|
const srcPlan = state.plans[srcKey];
|
||||||
|
if (!srcPlan) return;
|
||||||
|
|
||||||
|
const copy = {};
|
||||||
|
for (const [slotId, entries] of Object.entries(srcPlan)) {
|
||||||
|
if (slotId === '_skipped') {
|
||||||
|
copy._skipped = { ...entries };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Array.isArray(entries)) {
|
||||||
|
copy[slotId] = entries.map((e) => ({ ...e, id: newPlanEntryId() }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.plans[tgtKey] = copy;
|
||||||
|
closeCopy();
|
||||||
|
persist();
|
||||||
|
showPlannerToast('Plan skopiowany!');
|
||||||
|
});
|
||||||
|
|
||||||
ingSheet?.addEventListener('click', (e) => {
|
ingSheet?.addEventListener('click', (e) => {
|
||||||
const row = e.target.closest('.planner-ing-row');
|
const row = e.target.closest('.planner-ing-row');
|
||||||
if (!row || !ingSheet.contains(row)) return;
|
if (!row || !ingSheet.contains(row)) return;
|
||||||
@@ -983,6 +1191,11 @@ export function setupMealPlanner() {
|
|||||||
|
|
||||||
rerender();
|
rerender();
|
||||||
|
|
||||||
|
window.refreshPlanner = () => {
|
||||||
|
state.plans = loadPlans();
|
||||||
|
rerender();
|
||||||
|
};
|
||||||
|
|
||||||
bindCalendarSwipeGesture(state, rerender);
|
bindCalendarSwipeGesture(state, rerender);
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|||||||
@@ -1,290 +1,418 @@
|
|||||||
|
import { RECIPES, INGREDIENTS } from '../data/catalog.js';
|
||||||
|
import { MEAL_SLOTS } from '../planner/mealSlots.js';
|
||||||
|
import { addDays, startOfDay } from '../services/dateUtils.js';
|
||||||
|
import { addOrMergeShoppingLines, loadPantry } from '../services/pantryShopping.js';
|
||||||
|
import { dateKey, loadPlans, newPlanEntryId, savePlans } from '../services/planStore.js';
|
||||||
|
import { showAppToast } from '../ui/toast.js';
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
const slotLabelMap = Object.fromEntries(MEAL_SLOTS.map((s) => [s.id, s.label]));
|
||||||
|
|
||||||
export function getRecipeDetailHTML() {
|
export function getRecipeDetailHTML() {
|
||||||
return `
|
return `
|
||||||
<div id="recipe-detail-view" class="absolute inset-0 bg-white z-30 transition-all duration-300 ease-in-out translate-x-full opacity-0 pointer-events-none flex flex-col overflow-hidden">
|
<div id="recipe-detail-view" class="absolute inset-0 bg-white z-30 transition-all duration-300 ease-in-out translate-x-full opacity-0 pointer-events-none flex flex-col overflow-hidden">
|
||||||
|
|
||||||
<div class="absolute top-0 w-full p-3.5 flex justify-between z-40 mt-3">
|
<div class="absolute top-0 w-full p-3.5 flex justify-between z-40 mt-3">
|
||||||
<button onclick="closeRecipeDetail()" class="w-9 h-9 bg-white/90 backdrop-blur rounded-full flex items-center justify-center shadow-sm text-gray-800 hover:bg-white transition-colors">
|
<button onclick="closeRecipeDetail()" class="w-9 h-9 bg-white/90 backdrop-blur rounded-full flex items-center justify-center shadow-sm text-gray-800 hover:bg-white transition-colors">
|
||||||
<i class="fas fa-arrow-left text-[13px]"></i>
|
<i class="fas fa-arrow-left text-[13px]"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="w-9 h-9 bg-white/90 backdrop-blur rounded-full flex items-center justify-center shadow-sm text-gray-400 hover:text-red-500 transition-colors">
|
<button id="rd-add-to-planner-btn" class="h-9 px-3 bg-white/90 backdrop-blur rounded-full flex items-center justify-center gap-1.5 shadow-sm text-gray-800 hover:bg-white transition-colors text-[12px] font-semibold">
|
||||||
<i class="far fa-heart text-[13px]"></i>
|
<i class="fas fa-calendar-plus text-[11px]"></i> Do planera
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-[220px] shrink-0 w-full bg-[#d4d4d4] flex items-center justify-center relative">
|
<div id="rd-planner-picker" class="absolute inset-0 z-50 bg-black/45 hidden flex items-end" style="pointer-events: none">
|
||||||
<span class="text-white font-medium text-[15px]">Zdjęcie: Serek z owocami</span>
|
<div id="rd-planner-sheet" class="w-full bg-white rounded-t-3xl shadow-lg p-5 pb-8 max-h-[70vh] overflow-y-auto" style="pointer-events: auto; transform: translateY(100%); transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1)">
|
||||||
|
<div class="w-10 h-1 bg-gray-200 rounded-full mx-auto mb-4"></div>
|
||||||
|
<h3 class="text-[15px] font-bold text-gray-900 mb-1">Dodaj do planera</h3>
|
||||||
|
<p id="rd-planner-recipe-name" class="text-[11px] text-gray-500 mb-4"></p>
|
||||||
|
<div id="rd-planner-days" class="space-y-2 mb-4"></div>
|
||||||
|
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-2">Pora posiłku</p>
|
||||||
|
<div id="rd-planner-slots" class="flex flex-wrap gap-1.5 mb-5"></div>
|
||||||
|
<button id="rd-planner-confirm" class="w-full bg-gray-900 hover:bg-black text-white py-3 rounded-xl font-semibold text-[13px] transition-colors flex items-center justify-center gap-2">
|
||||||
|
<i class="fas fa-check text-xs"></i> Dodaj
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="rd-hero" class="h-[220px] shrink-0 w-full bg-[#d4d4d4] flex items-center justify-center relative">
|
||||||
|
<span id="rd-hero-label" class="text-white font-medium text-[15px]"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white rounded-t-3xl -mt-6 relative z-30 pt-6 flex flex-col flex-1 overflow-hidden">
|
<div class="bg-white rounded-t-3xl -mt-6 relative z-30 pt-6 flex flex-col flex-1 overflow-hidden">
|
||||||
|
|
||||||
<div class="mb-3 px-5 shrink-0">
|
<div class="mb-3 px-5 shrink-0">
|
||||||
<div class="flex justify-between items-start mb-2.5">
|
<div class="flex justify-between items-start mb-2.5">
|
||||||
<h1 class="text-xl font-bold text-gray-900">Serek wiejski z orzechami i owocami</h1>
|
<h1 id="rd-title" class="text-xl font-bold text-gray-900"></h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="rd-tags" class="flex flex-wrap gap-1.5 mb-3"></div>
|
||||||
<div class="flex flex-wrap gap-1.5 mb-3">
|
|
||||||
<span class="px-2.5 py-0.5 bg-gray-100 text-gray-700 text-[11px] rounded-md font-medium">Śniadanie</span>
|
|
||||||
<span class="px-2.5 py-0.5 bg-gray-100 text-gray-700 text-[11px] rounded-md font-medium">Wegetariańskie</span>
|
|
||||||
<span class="px-2.5 py-0.5 bg-gray-100 text-gray-700 text-[11px] rounded-md font-medium">Słodkie</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-between items-center text-[13px] text-gray-600 font-medium">
|
<div class="flex justify-between items-center text-[13px] text-gray-600 font-medium">
|
||||||
<div class="flex gap-3.5">
|
<div class="flex gap-3.5">
|
||||||
<div class="flex items-center gap-1.5"><i class="fas fa-clock text-gray-400 text-xs"></i><span>5 min</span></div>
|
<div class="flex items-center gap-1.5"><i class="fas fa-clock text-gray-400 text-xs"></i><span id="rd-time"></span></div>
|
||||||
<div class="flex items-center gap-1.5"><i class="fas fa-fire text-gray-400 text-xs"></i><span>642 kcal</span></div>
|
<div class="flex items-center gap-1.5"><i class="fas fa-fire text-gray-400 text-xs"></i><span id="rd-kcal"></span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-0.5 bg-gray-100 p-0.5 rounded-lg">
|
<div class="flex items-center gap-0.5 bg-gray-100 p-0.5 rounded-lg">
|
||||||
<button onclick="changeServings(-1)" class="w-6 h-6 bg-white rounded-md shadow-sm flex items-center justify-center text-gray-600 hover:text-black hover:bg-gray-50"><i class="fas fa-minus text-[9px]"></i></button>
|
<button id="rd-serv-minus" class="w-6 h-6 bg-white rounded-md shadow-sm flex items-center justify-center text-gray-600 hover:text-black hover:bg-gray-50"><i class="fas fa-minus text-[9px]"></i></button>
|
||||||
<div class="flex items-center gap-1 px-1.5">
|
<div class="flex items-center gap-1 px-1.5">
|
||||||
<span id="servings-count" class="font-bold text-gray-900 text-[13px] w-3 text-center tabular-nums">1</span>
|
<span id="rd-servings" class="font-bold text-gray-900 text-[13px] w-3 text-center tabular-nums">1</span>
|
||||||
<span class="text-[11px] text-gray-500"><i class="fas fa-user-friends"></i></span>
|
<span class="text-[11px] text-gray-500"><i class="fas fa-user-friends"></i></span>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="changeServings(1)" class="w-6 h-6 bg-white rounded-md shadow-sm flex items-center justify-center text-gray-600 hover:text-black hover:bg-gray-50"><i class="fas fa-plus text-[9px]"></i></button>
|
<button id="rd-serv-plus" class="w-6 h-6 bg-white rounded-md shadow-sm flex items-center justify-center text-gray-600 hover:text-black hover:bg-gray-50"><i class="fas fa-plus text-[9px]"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex border-b border-gray-200 mb-2 px-5 shrink-0">
|
<div class="flex border-b border-gray-200 mb-2 px-5 shrink-0">
|
||||||
<button class="flex-1 pb-2.5 text-[13px] font-semibold text-gray-900 border-b-2 border-gray-900 tab-btn" onclick="switchTab('ingredients', this)">Składniki</button>
|
<button class="flex-1 pb-2.5 text-[13px] font-semibold text-gray-900 border-b-2 border-gray-900 rd-tab-btn" data-rd-tab="ingredients">Składniki</button>
|
||||||
<button class="flex-1 pb-2.5 text-[13px] font-medium text-gray-500 border-b-2 border-transparent hover:text-gray-700 tab-btn" onclick="switchTab('steps', this)">Kroki</button>
|
<button class="flex-1 pb-2.5 text-[13px] font-medium text-gray-500 border-b-2 border-transparent hover:text-gray-700 rd-tab-btn" data-rd-tab="steps">Kroki</button>
|
||||||
<button class="flex-1 pb-2.5 text-[13px] font-medium text-gray-500 border-b-2 border-transparent hover:text-gray-700 tab-btn" onclick="switchTab('nutrition', this)">Wartości</button>
|
<button class="flex-1 pb-2.5 text-[13px] font-medium text-gray-500 border-b-2 border-transparent hover:text-gray-700 rd-tab-btn" data-rd-tab="nutrition">Wartości</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto px-5 pt-2 pb-10 no-scrollbar relative">
|
<div class="flex-1 overflow-y-auto px-5 pt-2 pb-10 no-scrollbar relative">
|
||||||
|
<div id="rd-tab-ingredients" class="rd-tab-content block animate-fade-in"></div>
|
||||||
<div id="tab-ingredients" class="tab-content block animate-fade-in">
|
<div id="rd-tab-steps" class="rd-tab-content hidden animate-fade-in"></div>
|
||||||
<div class="flex justify-between items-end mb-3">
|
<div id="rd-tab-nutrition" class="rd-tab-content hidden animate-fade-in"></div>
|
||||||
<span class="text-[11px] text-gray-500 font-medium">Zaznacz, by dodać do listy zakupów</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="space-y-0 mb-5" id="ingredient-list">
|
|
||||||
<li class="flex items-center gap-2.5 py-2.5 border-b border-gray-100 cursor-pointer hover:bg-gray-50 px-1 -mx-1 transition-colors" onclick="toggleIngredient(this)">
|
|
||||||
<div class="w-5 h-5 rounded border border-gray-300 flex items-center justify-center text-white check-box transition-colors"><i class="fas fa-check text-[10px] hidden check-icon"></i></div>
|
|
||||||
<span class="text-gray-700 text-[13px] flex-1 ingredient-text transition-colors">Serek wiejski</span>
|
|
||||||
<span class="font-medium text-gray-900 text-[13px] ingredient-amount tabular-nums" data-base-amount="200" data-unit="g">200 g</span>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="flex items-center gap-2.5 py-2.5 border-b border-gray-100 cursor-pointer hover:bg-gray-50 px-1 -mx-1 transition-colors" onclick="toggleIngredient(this)">
|
|
||||||
<div class="w-5 h-5 rounded border border-gray-300 flex items-center justify-center text-white check-box transition-colors"><i class="fas fa-check text-[10px] hidden check-icon"></i></div>
|
|
||||||
<span class="text-gray-700 text-[13px] flex-1 ingredient-text transition-colors">Miód</span>
|
|
||||||
<span class="font-medium text-gray-900 text-[13px] ingredient-amount tabular-nums" data-base-amount="10" data-unit="g">10 g</span>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="flex items-center gap-2.5 py-2.5 border-b border-gray-100 cursor-pointer hover:bg-gray-50 px-1 -mx-1 transition-colors" onclick="toggleIngredient(this)">
|
|
||||||
<div class="w-5 h-5 rounded border border-gray-300 flex items-center justify-center text-white check-box transition-colors"><i class="fas fa-check text-[10px] hidden check-icon"></i></div>
|
|
||||||
<span class="text-gray-700 text-[13px] flex-1 ingredient-text transition-colors font-medium text-gray-900" id="ingredient-orzechy">Orzechy włoskie</span>
|
|
||||||
<div class="flex items-center gap-2.5">
|
|
||||||
<button onclick="event.stopPropagation(); openSwapModal('orzechy')" class="w-6 h-6 flex items-center justify-center rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-900 transition-colors shadow-sm" title="Zamień">
|
|
||||||
<i class="fas fa-exchange-alt text-[9px]"></i>
|
|
||||||
</button>
|
|
||||||
<span class="font-medium text-gray-900 text-[13px] ingredient-amount w-10 text-right tabular-nums" data-base-amount="50" data-unit="g">50 g</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="flex items-center gap-2.5 py-2.5 border-b border-gray-100 cursor-pointer hover:bg-gray-50 px-1 -mx-1 transition-colors" onclick="toggleIngredient(this)">
|
|
||||||
<div class="w-5 h-5 rounded border border-gray-300 flex items-center justify-center text-white check-box transition-colors"><i class="fas fa-check text-[10px] hidden check-icon"></i></div>
|
|
||||||
<span class="text-gray-700 text-[13px] flex-1 ingredient-text transition-colors font-medium text-gray-900" id="ingredient-owoce1">Truskawki</span>
|
|
||||||
<div class="flex items-center gap-2.5">
|
|
||||||
<button onclick="event.stopPropagation(); openSwapModal('owoce1')" class="w-6 h-6 flex items-center justify-center rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-900 transition-colors shadow-sm" title="Zamień">
|
|
||||||
<i class="fas fa-exchange-alt text-[9px]"></i>
|
|
||||||
</button>
|
|
||||||
<span class="font-medium text-gray-900 text-[13px] ingredient-amount w-10 text-right tabular-nums" data-base-amount="100" data-unit="g">100 g</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="flex items-center gap-2.5 py-2.5 border-b border-gray-100 cursor-pointer hover:bg-gray-50 px-1 -mx-1 transition-colors" onclick="toggleIngredient(this)">
|
|
||||||
<div class="w-5 h-5 rounded border border-gray-300 flex items-center justify-center text-white check-box transition-colors"><i class="fas fa-check text-[10px] hidden check-icon"></i></div>
|
|
||||||
<span class="text-gray-700 text-[13px] flex-1 ingredient-text transition-colors font-medium text-gray-900" id="ingredient-owoce2">Borówki ameryk.</span>
|
|
||||||
<div class="flex items-center gap-2.5">
|
|
||||||
<button onclick="event.stopPropagation(); openSwapModal('owoce2')" class="w-6 h-6 flex items-center justify-center rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-900 transition-colors shadow-sm" title="Zamień">
|
|
||||||
<i class="fas fa-exchange-alt text-[9px]"></i>
|
|
||||||
</button>
|
|
||||||
<span class="font-medium text-gray-900 text-[13px] ingredient-amount w-10 text-right tabular-nums" data-base-amount="100" data-unit="g">100 g</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<button class="w-full bg-gray-900 hover:bg-black text-white py-3 rounded-xl font-semibold shadow-sm transition-colors text-[13px] flex items-center justify-center gap-2 mb-5">
|
|
||||||
<i class="fas fa-plus text-xs"></i> Dodaj do listy zakupów
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="tab-steps" class="tab-content hidden animate-fade-in">
|
|
||||||
<div class="space-y-5 pb-5">
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<div class="w-6 h-6 rounded-full bg-gray-900 text-white flex items-center justify-center text-[11px] font-bold shrink-0 shadow-sm">1</div>
|
|
||||||
<div class="pt-0.5"><p class="text-[13px] text-gray-600 leading-relaxed">Przełóż serek wiejski do miseczki.</p></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<div class="w-6 h-6 rounded-full bg-gray-900 text-white flex items-center justify-center text-[11px] font-bold shrink-0 shadow-sm">2</div>
|
|
||||||
<div class="pt-0.5"><p class="text-[13px] text-gray-600 leading-relaxed">Dodaj miód i delikatnie wymieszaj.</p></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<div class="w-6 h-6 rounded-full bg-gray-900 text-white flex items-center justify-center text-[11px] font-bold shrink-0 shadow-sm">3</div>
|
|
||||||
<div class="pt-0.5"><p class="text-[13px] text-gray-600 leading-relaxed">Orzechy posiekaj na mniejsze kawałki i posyp nimi serek z miodem.</p></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<div class="w-6 h-6 rounded-full bg-gray-900 text-white flex items-center justify-center text-[11px] font-bold shrink-0 shadow-sm">4</div>
|
|
||||||
<div class="pt-0.5"><p class="text-[13px] text-gray-600 leading-relaxed">Umyj owoce (ew. pokrój na połówki) i ułóż na wierzchu. Gotowe!</p></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="tab-nutrition" class="tab-content hidden animate-fade-in">
|
|
||||||
<div class="bg-gray-50 rounded-xl p-4 border border-gray-100 mb-5">
|
|
||||||
<ul class="space-y-0 divide-y divide-gray-200">
|
|
||||||
<li class="flex justify-between py-2 font-bold"><span class="text-gray-900 text-[13px]">Kalorie</span><span class="text-gray-900 text-[13px] tabular-nums">642 kcal</span></li>
|
|
||||||
<li class="flex justify-between py-2"><span class="text-gray-800 text-[13px] font-medium">Białko</span><span class="font-medium text-gray-900 text-[13px] tabular-nums">32 g</span></li>
|
|
||||||
<li class="flex justify-between py-2"><span class="text-gray-800 text-[13px] font-medium">Tłuszcze</span><span class="font-medium text-gray-900 text-[13px] tabular-nums">43 g</span></li>
|
|
||||||
<li class="flex justify-between py-2"><span class="text-gray-800 text-[13px] font-medium">Węglowodany</span><span class="font-medium text-gray-900 text-[13px] tabular-nums">41 g</span></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="swap-backdrop" onclick="closeSwapModal()" class="absolute inset-0 bg-black/40 z-40 hidden opacity-0 transition-opacity duration-300"></div>
|
|
||||||
|
|
||||||
<div id="swap-modal" class="absolute inset-x-0 bottom-0 bg-white rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.1)] z-50 transform translate-y-full transition-transform duration-300 ease-in-out p-5 flex flex-col max-h-[60%]">
|
|
||||||
<div class="flex justify-between items-center mb-4 shrink-0">
|
|
||||||
<h3 class="text-[15px] font-bold text-gray-900">Zmień <span id="swap-title-target" class="text-blue-600">składnik</span></h3>
|
|
||||||
<button onclick="closeSwapModal()" class="w-7 h-7 flex items-center justify-center bg-gray-100 rounded-full text-gray-500 hover:bg-gray-200 hover:text-gray-900 transition-colors">
|
|
||||||
<i class="fas fa-times text-xs"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="swap-options-container" class="space-y-2 overflow-y-auto no-scrollbar pb-2">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentRecipeId = null;
|
||||||
|
let currentServings = 1;
|
||||||
|
|
||||||
|
function populateDetail(recipeId) {
|
||||||
|
const recipe = RECIPES[recipeId];
|
||||||
|
if (!recipe) return;
|
||||||
|
|
||||||
|
currentRecipeId = recipeId;
|
||||||
|
currentServings = 1;
|
||||||
|
|
||||||
|
document.getElementById('rd-hero-label').textContent = `Zdjęcie: ${recipe.title}`;
|
||||||
|
document.getElementById('rd-title').textContent = recipe.title;
|
||||||
|
document.getElementById('rd-time').textContent = `${recipe.minutes} min`;
|
||||||
|
updateKcalDisplay();
|
||||||
|
|
||||||
|
const tagsHtml = [];
|
||||||
|
for (const slotId of recipe.allowedSlots) {
|
||||||
|
const label = slotLabelMap[slotId];
|
||||||
|
if (label) tagsHtml.push(`<span class="px-2.5 py-0.5 bg-gray-100 text-gray-700 text-[11px] rounded-md font-medium">${escapeHtml(label)}</span>`);
|
||||||
|
}
|
||||||
|
for (const tag of (recipe.tags || [])) {
|
||||||
|
tagsHtml.push(`<span class="px-2.5 py-0.5 bg-gray-100 text-gray-700 text-[11px] rounded-md font-medium">${escapeHtml(tag)}</span>`);
|
||||||
|
}
|
||||||
|
document.getElementById('rd-tags').innerHTML = tagsHtml.join('');
|
||||||
|
|
||||||
|
document.getElementById('rd-servings').textContent = '1';
|
||||||
|
|
||||||
|
renderIngredients(recipe);
|
||||||
|
renderSteps(recipe);
|
||||||
|
renderNutrition(recipe);
|
||||||
|
|
||||||
|
const tabBtns = document.querySelectorAll('.rd-tab-btn');
|
||||||
|
const tabs = document.querySelectorAll('.rd-tab-content');
|
||||||
|
tabBtns.forEach((b) => {
|
||||||
|
b.classList.remove('text-gray-900', 'border-gray-900', 'font-semibold');
|
||||||
|
b.classList.add('text-gray-500', 'border-transparent', 'font-medium');
|
||||||
|
});
|
||||||
|
tabBtns[0]?.classList.remove('text-gray-500', 'border-transparent', 'font-medium');
|
||||||
|
tabBtns[0]?.classList.add('text-gray-900', 'border-gray-900', 'font-semibold');
|
||||||
|
tabs.forEach((t) => { t.classList.add('hidden'); t.classList.remove('block'); });
|
||||||
|
document.getElementById('rd-tab-ingredients')?.classList.remove('hidden');
|
||||||
|
document.getElementById('rd-tab-ingredients')?.classList.add('block');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateKcalDisplay() {
|
||||||
|
const recipe = RECIPES[currentRecipeId];
|
||||||
|
if (!recipe) return;
|
||||||
|
const kcal = Math.round(recipe.nutritionPerServing.kcal * currentServings);
|
||||||
|
document.getElementById('rd-kcal').textContent = `${kcal} kcal`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIngredients(recipe) {
|
||||||
|
const container = document.getElementById('rd-tab-ingredients');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const pantry = loadPantry();
|
||||||
|
|
||||||
|
const rows = recipe.ingredients.map((ing) => {
|
||||||
|
const def = INGREDIENTS[ing.ingredientId];
|
||||||
|
const name = def?.name || ing.ingredientId;
|
||||||
|
const scaledAmount = ing.amount * currentServings;
|
||||||
|
const displayAmount = Number.isInteger(scaledAmount) ? scaledAmount : parseFloat(scaledAmount.toFixed(1));
|
||||||
|
|
||||||
|
const pantryQty = Number(pantry[ing.ingredientId]) || 0;
|
||||||
|
let stockBadge = '';
|
||||||
|
if (def) {
|
||||||
|
const u = def.pantryUnit === 'szt' ? 'szt.' : def.pantryUnit;
|
||||||
|
if (pantryQty >= scaledAmount) {
|
||||||
|
stockBadge = `<span class="text-[9px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-600 font-semibold whitespace-nowrap">Masz</span>`;
|
||||||
|
} else if (pantryQty > 0) {
|
||||||
|
const miss = parseFloat((scaledAmount - pantryQty).toFixed(1));
|
||||||
|
stockBadge = `<span class="text-[9px] px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 font-semibold whitespace-nowrap">Brak ${miss} ${u}</span>`;
|
||||||
|
} else {
|
||||||
|
stockBadge = `<span class="text-[9px] px-1.5 py-0.5 rounded bg-red-50 text-red-500 font-semibold whitespace-nowrap">Brak</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<li class="flex items-center gap-2.5 py-2.5 border-b border-gray-100 cursor-pointer hover:bg-gray-50 px-1 -mx-1 transition-colors rd-ingredient-row" data-ingredient-id="${escapeHtml(ing.ingredientId)}" data-base-amount="${ing.amount}" data-unit="${escapeHtml(ing.unit)}">
|
||||||
|
<div class="w-5 h-5 rounded border border-gray-300 flex items-center justify-center text-white rd-check-box transition-colors"><i class="fas fa-check text-[10px] hidden rd-check-icon"></i></div>
|
||||||
|
<span class="text-gray-700 text-[13px] flex-1 rd-ing-text transition-colors">${escapeHtml(name)}</span>
|
||||||
|
${stockBadge}
|
||||||
|
<span class="font-medium text-gray-900 text-[13px] rd-ing-amount tabular-nums">${displayAmount} ${escapeHtml(ing.unit)}</span>
|
||||||
|
</li>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="flex justify-between items-end mb-3">
|
||||||
|
<span class="text-[11px] text-gray-500 font-medium">Zaznacz składniki do kupienia</span>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-0 mb-5" id="rd-ingredient-list">${rows}</ul>
|
||||||
|
<button id="rd-add-to-shopping" class="w-full bg-gray-900 hover:bg-black text-white py-3 rounded-xl font-semibold shadow-sm transition-colors text-[13px] flex items-center justify-center gap-2 mb-5">
|
||||||
|
<i class="fas fa-plus text-xs"></i> Dodaj do listy zakupów
|
||||||
|
</button>`;
|
||||||
|
|
||||||
|
container.querySelectorAll('.rd-ingredient-row').forEach((row) => {
|
||||||
|
row.addEventListener('click', () => row.classList.toggle('ingredient-active'));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('rd-add-to-shopping')?.addEventListener('click', () => {
|
||||||
|
const recipe = RECIPES[currentRecipeId];
|
||||||
|
if (!recipe) return;
|
||||||
|
|
||||||
|
const checkedRows = container.querySelectorAll('.rd-ingredient-row.ingredient-active');
|
||||||
|
if (checkedRows.length === 0) {
|
||||||
|
showAppToast('Zaznacz składniki, które chcesz dodać.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
checkedRows.forEach((row) => {
|
||||||
|
const ingredientId = row.dataset.ingredientId;
|
||||||
|
const baseAmount = parseFloat(row.dataset.baseAmount);
|
||||||
|
const unit = row.dataset.unit;
|
||||||
|
const def = INGREDIENTS[ingredientId];
|
||||||
|
lines.push({
|
||||||
|
ingredientId,
|
||||||
|
amount: Math.round(baseAmount * currentServings * 100) / 100,
|
||||||
|
unit,
|
||||||
|
name: def?.name || ingredientId,
|
||||||
|
category: def?.category || 'inne',
|
||||||
|
sourceNote: `Przepis: ${recipe.title}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
addOrMergeShoppingLines(lines);
|
||||||
|
showAppToast(`Dodano ${lines.length} składnik(ów) na listę zakupów.`);
|
||||||
|
window.refreshShopping?.();
|
||||||
|
|
||||||
|
checkedRows.forEach((row) => row.classList.remove('ingredient-active'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSteps(recipe) {
|
||||||
|
const container = document.getElementById('rd-tab-steps');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const steps = recipe.steps || [];
|
||||||
|
if (steps.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-sm text-gray-500 text-center py-8">Brak kroków przygotowania.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="space-y-5 pb-5">
|
||||||
|
${steps.map((step, i) => `
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="w-6 h-6 rounded-full bg-gray-900 text-white flex items-center justify-center text-[11px] font-bold shrink-0 shadow-sm">${i + 1}</div>
|
||||||
|
<div class="pt-0.5"><p class="text-[13px] text-gray-600 leading-relaxed">${escapeHtml(step)}</p></div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNutrition(recipe) {
|
||||||
|
const container = document.getElementById('rd-tab-nutrition');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const n = recipe.nutritionPerServing;
|
||||||
|
const s = currentServings;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="bg-gray-50 rounded-xl p-4 border border-gray-100 mb-5">
|
||||||
|
<p class="text-[11px] text-gray-500 mb-3 font-medium">${s > 1 ? `Wartości dla ${s} porcji` : 'Wartości na 1 porcję'}</p>
|
||||||
|
<ul class="space-y-0 divide-y divide-gray-200">
|
||||||
|
<li class="flex justify-between py-2 font-bold"><span class="text-gray-900 text-[13px]">Kalorie</span><span class="text-gray-900 text-[13px] tabular-nums">${Math.round(n.kcal * s)} kcal</span></li>
|
||||||
|
<li class="flex justify-between py-2"><span class="text-gray-800 text-[13px] font-medium">Białko</span><span class="font-medium text-gray-900 text-[13px] tabular-nums">${Math.round(n.protein * s * 10) / 10} g</span></li>
|
||||||
|
<li class="flex justify-between py-2"><span class="text-gray-800 text-[13px] font-medium">Tłuszcze</span><span class="font-medium text-gray-900 text-[13px] tabular-nums">${Math.round(n.fat * s * 10) / 10} g</span></li>
|
||||||
|
<li class="flex justify-between py-2"><span class="text-gray-800 text-[13px] font-medium">Węglowodany</span><span class="font-medium text-gray-900 text-[13px] tabular-nums">${Math.round(n.carbs * s * 10) / 10} g</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function setupRecipeDetail() {
|
export function setupRecipeDetail() {
|
||||||
let currentServings = 1; // Domślnie 1 porcja dla tego przepisu
|
document.querySelectorAll('.rd-tab-btn').forEach((btn) => {
|
||||||
const defaultServings = 1;
|
btn.addEventListener('click', () => {
|
||||||
let currentlySwapping = null;
|
const tabId = btn.dataset.rdTab;
|
||||||
|
document.querySelectorAll('.rd-tab-content').forEach((el) => {
|
||||||
// Dane do dynamicznego modala
|
|
||||||
const swapOptions = {
|
|
||||||
'orzechy': [
|
|
||||||
{ name: 'Orzechy włoskie', hint: 'Bazowe', color: 'gray' },
|
|
||||||
{ name: 'Migdały', hint: '+ Białko', color: 'blue' },
|
|
||||||
{ name: 'Orzechy laskowe', hint: 'Klasyk', color: 'gray' },
|
|
||||||
{ name: 'Orzechy nerkowca', hint: 'Słodsze', color: 'gray' },
|
|
||||||
{ name: 'Orzechy pekan', hint: '+ Tłuszcz', color: 'green' }
|
|
||||||
],
|
|
||||||
'owoce1': [
|
|
||||||
{ name: 'Truskawki', hint: 'Bazowe', color: 'gray' },
|
|
||||||
{ name: 'Gruszka konferencja', hint: '+ Węgle', color: 'blue' },
|
|
||||||
{ name: 'Banany', hint: '+ Kalorie', color: 'green' }
|
|
||||||
],
|
|
||||||
'owoce2': [
|
|
||||||
{ name: 'Borówki ameryk.', hint: 'Bazowe', color: 'gray' },
|
|
||||||
{ name: 'Jagody leśne', hint: 'Sezonowe', color: 'blue' },
|
|
||||||
{ name: 'Maliny', hint: '- Kalorie', color: 'green' }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
window.switchTab = (tabId, clickedBtn) => {
|
|
||||||
document.querySelectorAll('.tab-content').forEach(el => {
|
|
||||||
el.classList.remove('block');
|
|
||||||
el.classList.add('hidden');
|
el.classList.add('hidden');
|
||||||
|
el.classList.remove('block');
|
||||||
|
});
|
||||||
|
const target = document.getElementById(`rd-tab-${tabId}`);
|
||||||
|
if (target) {
|
||||||
|
target.classList.remove('hidden');
|
||||||
|
target.classList.add('block');
|
||||||
|
target.parentElement.scrollTop = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.rd-tab-btn').forEach((b) => {
|
||||||
|
b.classList.remove('text-gray-900', 'border-gray-900', 'font-semibold');
|
||||||
|
b.classList.add('text-gray-500', 'border-transparent', 'font-medium');
|
||||||
|
});
|
||||||
|
btn.classList.remove('text-gray-500', 'border-transparent', 'font-medium');
|
||||||
|
btn.classList.add('text-gray-900', 'border-gray-900', 'font-semibold');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const targetTab = document.getElementById(`tab-${tabId}`);
|
document.getElementById('rd-serv-minus')?.addEventListener('click', () => {
|
||||||
targetTab.classList.remove('hidden');
|
if (currentServings <= 1) return;
|
||||||
targetTab.classList.add('block');
|
currentServings--;
|
||||||
targetTab.parentElement.scrollTop = 0;
|
document.getElementById('rd-servings').textContent = currentServings;
|
||||||
|
const recipe = RECIPES[currentRecipeId];
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
if (recipe) {
|
||||||
btn.classList.remove('text-gray-900', 'border-gray-900', 'font-semibold');
|
renderIngredients(recipe);
|
||||||
btn.classList.add('text-gray-500', 'border-transparent', 'font-medium');
|
renderNutrition(recipe);
|
||||||
});
|
updateKcalDisplay();
|
||||||
|
|
||||||
clickedBtn.classList.remove('text-gray-500', 'border-transparent', 'font-medium');
|
|
||||||
clickedBtn.classList.add('text-gray-900', 'border-gray-900', 'font-semibold');
|
|
||||||
};
|
|
||||||
|
|
||||||
window.toggleIngredient = (element) => {
|
|
||||||
element.classList.toggle('ingredient-active');
|
|
||||||
};
|
|
||||||
|
|
||||||
window.changeServings = (delta) => {
|
|
||||||
const newServings = currentServings + delta;
|
|
||||||
if (newServings < 1) return;
|
|
||||||
|
|
||||||
currentServings = newServings;
|
|
||||||
document.getElementById('servings-count').innerText = currentServings;
|
|
||||||
|
|
||||||
const ratio = currentServings / defaultServings;
|
|
||||||
document.querySelectorAll('.ingredient-amount').forEach(el => {
|
|
||||||
const baseAmount = parseFloat(el.getAttribute('data-base-amount'));
|
|
||||||
const unit = el.getAttribute('data-unit');
|
|
||||||
|
|
||||||
if (!isNaN(baseAmount)) {
|
|
||||||
let newAmount = baseAmount * ratio;
|
|
||||||
newAmount = Number.isInteger(newAmount) ? newAmount : parseFloat(newAmount.toFixed(1));
|
|
||||||
el.innerText = `${newAmount} ${unit}`;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
window.openSwapModal = (type) => {
|
document.getElementById('rd-serv-plus')?.addEventListener('click', () => {
|
||||||
currentlySwapping = type;
|
if (currentServings >= 12) return;
|
||||||
|
currentServings++;
|
||||||
|
document.getElementById('rd-servings').textContent = currentServings;
|
||||||
|
const recipe = RECIPES[currentRecipeId];
|
||||||
|
if (recipe) {
|
||||||
|
renderIngredients(recipe);
|
||||||
|
renderNutrition(recipe);
|
||||||
|
updateKcalDisplay();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let title = '';
|
const WEEKDAYS_LONG = ['Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota'];
|
||||||
if(type === 'orzechy') title = 'Orzechy';
|
const MONTHS_SHORT = ['sty', 'lut', 'mar', 'kwi', 'maj', 'cze', 'lip', 'sie', 'wrz', 'paź', 'lis', 'gru'];
|
||||||
if(type === 'owoce1') title = 'Owoce bazy';
|
|
||||||
if(type === 'owoce2') title = 'Dodatki owocowe';
|
|
||||||
|
|
||||||
document.getElementById('swap-title-target').innerText = title;
|
let plannerPickerDay = null;
|
||||||
|
let plannerPickerSlot = null;
|
||||||
|
|
||||||
// Wygeneruj opcje na podstawie słownika
|
const plannerOverlay = document.getElementById('rd-planner-picker');
|
||||||
const container = document.getElementById('swap-options-container');
|
const plannerSheet = document.getElementById('rd-planner-sheet');
|
||||||
container.innerHTML = swapOptions[type].map(opt => {
|
|
||||||
let badgeClass = 'text-gray-600 bg-gray-200'; // Domyślny gray
|
|
||||||
if (opt.color === 'blue') badgeClass = 'text-blue-600 bg-blue-100';
|
|
||||||
if (opt.color === 'green') badgeClass = 'text-green-600 bg-green-100';
|
|
||||||
|
|
||||||
return `
|
function openPlannerPicker() {
|
||||||
<button onclick="confirmSwap('${opt.name}')" class="w-full flex justify-between items-center p-3 border border-gray-200 rounded-xl hover:border-gray-900 hover:shadow-sm transition-all bg-gray-50 hover:bg-white text-left">
|
const recipe = RECIPES[currentRecipeId];
|
||||||
<span class="font-medium text-[13px] text-gray-800">${opt.name}</span>
|
if (!recipe) return;
|
||||||
<span class="text-[10px] px-2 py-0.5 rounded-md font-semibold ${badgeClass}">${opt.hint}</span>
|
|
||||||
</button>
|
document.getElementById('rd-planner-recipe-name').textContent = recipe.title;
|
||||||
`;
|
|
||||||
|
const daysContainer = document.getElementById('rd-planner-days');
|
||||||
|
const today = startOfDay(new Date());
|
||||||
|
const days = [];
|
||||||
|
for (let i = 0; i < 7; i++) days.push(addDays(today, i));
|
||||||
|
|
||||||
|
plannerPickerDay = today;
|
||||||
|
plannerPickerSlot = recipe.allowedSlots[0] || MEAL_SLOTS[0]?.id;
|
||||||
|
|
||||||
|
daysContainer.innerHTML = days.map((d, idx) => {
|
||||||
|
const wd = WEEKDAYS_LONG[d.getDay()];
|
||||||
|
const label = idx === 0 ? `Dziś — ${wd}, ${d.getDate()} ${MONTHS_SHORT[d.getMonth()]}` : `${wd}, ${d.getDate()} ${MONTHS_SHORT[d.getMonth()]}`;
|
||||||
|
const sel = idx === 0;
|
||||||
|
return `<button type="button" class="rd-plan-day-btn w-full text-left px-3 py-2.5 rounded-xl border text-[13px] font-semibold transition-all ${sel ? 'border-gray-900 bg-gray-900 text-white' : 'border-gray-200 bg-gray-50 text-gray-900 hover:border-gray-400'}" data-day-ts="${d.getTime()}">${escapeHtml(label)}</button>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
const backdrop = document.getElementById('swap-backdrop');
|
const slotsContainer = document.getElementById('rd-planner-slots');
|
||||||
backdrop.classList.remove('hidden');
|
slotsContainer.innerHTML = MEAL_SLOTS.filter((s) => recipe.allowedSlots.includes(s.id)).map((s) => {
|
||||||
setTimeout(() => backdrop.classList.remove('opacity-0'), 10);
|
const sel = s.id === plannerPickerSlot;
|
||||||
|
return `<button type="button" class="rd-plan-slot-btn px-3 py-1.5 rounded-lg border text-[12px] font-semibold transition-all ${sel ? 'border-gray-900 bg-gray-900 text-white' : 'border-gray-200 bg-gray-50 text-gray-700 hover:border-gray-400'}" data-slot-id="${s.id}">${escapeHtml(s.label)}</button>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
const modal = document.getElementById('swap-modal');
|
plannerOverlay.classList.remove('hidden');
|
||||||
modal.classList.remove('translate-y-full');
|
plannerOverlay.style.pointerEvents = 'auto';
|
||||||
modal.classList.add('translate-y-0');
|
requestAnimationFrame(() => {
|
||||||
};
|
plannerSheet.style.transform = 'translateY(0)';
|
||||||
|
});
|
||||||
window.closeSwapModal = () => {
|
|
||||||
const backdrop = document.getElementById('swap-backdrop');
|
|
||||||
backdrop.classList.add('opacity-0');
|
|
||||||
setTimeout(() => backdrop.classList.add('hidden'), 300);
|
|
||||||
|
|
||||||
const modal = document.getElementById('swap-modal');
|
|
||||||
modal.classList.remove('translate-y-0');
|
|
||||||
modal.classList.add('translate-y-full');
|
|
||||||
};
|
|
||||||
|
|
||||||
window.confirmSwap = (newItemName) => {
|
|
||||||
if (currentlySwapping === 'orzechy') {
|
|
||||||
document.getElementById('ingredient-orzechy').innerText = newItemName;
|
|
||||||
} else if (currentlySwapping === 'owoce1') {
|
|
||||||
document.getElementById('ingredient-owoce1').innerText = newItemName;
|
|
||||||
} else if (currentlySwapping === 'owoce2') {
|
|
||||||
document.getElementById('ingredient-owoce2').innerText = newItemName;
|
|
||||||
}
|
}
|
||||||
closeSwapModal();
|
|
||||||
|
function closePlannerPicker() {
|
||||||
|
plannerSheet.style.transform = 'translateY(100%)';
|
||||||
|
setTimeout(() => {
|
||||||
|
plannerOverlay.classList.add('hidden');
|
||||||
|
plannerOverlay.style.pointerEvents = 'none';
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('rd-add-to-planner-btn')?.addEventListener('click', openPlannerPicker);
|
||||||
|
|
||||||
|
plannerOverlay?.addEventListener('click', (e) => {
|
||||||
|
if (e.target === plannerOverlay) closePlannerPicker();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('rd-planner-days')?.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.rd-plan-day-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
plannerPickerDay = new Date(Number(btn.getAttribute('data-day-ts')));
|
||||||
|
document.querySelectorAll('.rd-plan-day-btn').forEach((b) => {
|
||||||
|
b.classList.remove('border-gray-900', 'bg-gray-900', 'text-white');
|
||||||
|
b.classList.add('border-gray-200', 'bg-gray-50', 'text-gray-900');
|
||||||
|
});
|
||||||
|
btn.classList.remove('border-gray-200', 'bg-gray-50', 'text-gray-900');
|
||||||
|
btn.classList.add('border-gray-900', 'bg-gray-900', 'text-white');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('rd-planner-slots')?.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.rd-plan-slot-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
plannerPickerSlot = btn.getAttribute('data-slot-id');
|
||||||
|
document.querySelectorAll('.rd-plan-slot-btn').forEach((b) => {
|
||||||
|
b.classList.remove('border-gray-900', 'bg-gray-900', 'text-white');
|
||||||
|
b.classList.add('border-gray-200', 'bg-gray-50', 'text-gray-700');
|
||||||
|
});
|
||||||
|
btn.classList.remove('border-gray-200', 'bg-gray-50', 'text-gray-700');
|
||||||
|
btn.classList.add('border-gray-900', 'bg-gray-900', 'text-white');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('rd-planner-confirm')?.addEventListener('click', () => {
|
||||||
|
if (!currentRecipeId || !plannerPickerDay || !plannerPickerSlot) return;
|
||||||
|
const plans = loadPlans();
|
||||||
|
const key = dateKey(plannerPickerDay);
|
||||||
|
if (!plans[key]) plans[key] = {};
|
||||||
|
if (!plans[key][plannerPickerSlot]) plans[key][plannerPickerSlot] = [];
|
||||||
|
plans[key][plannerPickerSlot].push({
|
||||||
|
id: newPlanEntryId(),
|
||||||
|
recipeId: currentRecipeId,
|
||||||
|
servings: currentServings,
|
||||||
|
});
|
||||||
|
savePlans(plans);
|
||||||
|
closePlannerPicker();
|
||||||
|
showAppToast('Dodano do planera!');
|
||||||
|
window.refreshPlanner?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.openRecipeDetail = (recipeId) => {
|
||||||
|
if (!recipeId || !RECIPES[recipeId]) return;
|
||||||
|
populateDetail(recipeId);
|
||||||
|
const view = document.getElementById('recipe-detail-view');
|
||||||
|
view.classList.remove('translate-x-full', 'opacity-0', 'pointer-events-none');
|
||||||
|
view.classList.add('translate-x-0', 'opacity-100', 'pointer-events-auto');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.closeRecipeDetail = () => {
|
||||||
|
closePlannerPicker();
|
||||||
|
const view = document.getElementById('recipe-detail-view');
|
||||||
|
view.classList.remove('translate-x-0', 'opacity-100', 'pointer-events-auto');
|
||||||
|
view.classList.add('translate-x-full', 'opacity-0', 'pointer-events-none');
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,106 @@
|
|||||||
|
import { RECIPES } from '../data/catalog.js';
|
||||||
|
import { MEAL_SLOTS } from '../planner/mealSlots.js';
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
const slotLabelMap = Object.fromEntries(MEAL_SLOTS.map((s) => [s.id, s.label]));
|
||||||
|
|
||||||
|
function slotLabelsFor(recipe) {
|
||||||
|
return (recipe.allowedSlots || [])
|
||||||
|
.map((id) => slotLabelMap[id])
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
let filterState = {
|
||||||
|
query: '',
|
||||||
|
slots: [],
|
||||||
|
tags: [],
|
||||||
|
maxMinutes: 120,
|
||||||
|
};
|
||||||
|
|
||||||
|
function matchesFilters(recipe) {
|
||||||
|
const { query, slots, tags, maxMinutes } = filterState;
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const haystack = `${recipe.title} ${recipe.description || ''} ${(recipe.tags || []).join(' ')}`.toLowerCase();
|
||||||
|
if (!haystack.includes(q)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slots.length > 0) {
|
||||||
|
if (!recipe.allowedSlots.some((s) => slots.includes(s))) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags.length > 0) {
|
||||||
|
const recipeTags = (recipe.tags || []).map((t) => t.toLowerCase());
|
||||||
|
if (!tags.some((t) => recipeTags.includes(t.toLowerCase()))) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipe.minutes > maxMinutes) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilteredRecipes() {
|
||||||
|
return Object.values(RECIPES).filter(matchesFilters);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecipeCard(recipe) {
|
||||||
|
const labels = slotLabelsFor(recipe);
|
||||||
|
return `
|
||||||
|
<div onclick="openRecipeDetail('${escapeHtml(recipe.id)}')" class="border border-gray-200 rounded-xl overflow-hidden shadow-sm flex flex-col bg-white cursor-pointer hover:shadow-md transition-shadow">
|
||||||
|
<div class="h-32 bg-[#d4d4d4] relative flex items-center justify-center">
|
||||||
|
<span class="text-white font-medium text-xs">${escapeHtml(recipe.thumbLabel)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 flex flex-col flex-1">
|
||||||
|
<h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-black mb-1 line-clamp-1">${escapeHtml(recipe.title)}</h3>
|
||||||
|
<p class="text-gray-500 text-xs mb-3 line-clamp-2">${escapeHtml(recipe.description || '')}</p>
|
||||||
|
<div class="mt-auto">
|
||||||
|
<div class="flex items-center justify-between text-[11px] text-gray-600 font-medium mb-2">
|
||||||
|
<div class="flex items-center gap-1"><i class="fas fa-clock text-gray-400"></i><span>${recipe.minutes} min</span></div>
|
||||||
|
<div class="flex items-center gap-1"><i class="fas fa-fire text-gray-400"></i><span>${recipe.nutritionPerServing.kcal} kcal</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
${labels.map((l) => `<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-[10px] rounded-md font-medium">${escapeHtml(l)}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid() {
|
||||||
|
const grid = document.getElementById('recipe-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
const recipes = getFilteredRecipes();
|
||||||
|
if (recipes.length === 0) {
|
||||||
|
grid.innerHTML = `
|
||||||
|
<div class="col-span-2 flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<div class="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
||||||
|
<i class="fas fa-search text-2xl text-gray-300"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm font-semibold text-gray-700">Brak wyników</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-1 max-w-[220px] leading-relaxed">Zmień kryteria wyszukiwania lub filtry</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = recipes.map(renderRecipeCard).join('');
|
||||||
|
}
|
||||||
|
|
||||||
export function getRecipeListHTML() {
|
export function getRecipeListHTML() {
|
||||||
return `
|
return `
|
||||||
<div id="main-view" class="flex flex-col h-full absolute inset-0 bg-gray-50 z-10">
|
<div id="main-view" class="flex flex-col h-full absolute inset-0 bg-gray-50 z-10">
|
||||||
<div class="p-4 border-b border-gray-200 mt-4 bg-white">
|
<div class="p-4 border-b border-gray-200 mt-4 bg-white">
|
||||||
<div class="flex items-center w-full border border-gray-300 rounded-lg bg-white focus-within:border-gray-400 transition-colors">
|
<div class="flex items-center w-full border border-gray-300 rounded-lg bg-white focus-within:border-gray-400 transition-colors">
|
||||||
<div class="pl-3 pr-2 text-gray-400"><i class="fas fa-search"></i></div>
|
<div class="pl-3 pr-2 text-gray-400"><i class="fas fa-search"></i></div>
|
||||||
<input type="text" placeholder="Szukaj przepisów..." class="flex-1 py-2.5 bg-transparent outline-none text-gray-600 placeholder-gray-400 text-sm">
|
<input type="text" id="recipe-search-input" placeholder="Szukaj przepisów..." class="flex-1 py-2.5 bg-transparent outline-none text-gray-600 placeholder-gray-400 text-sm">
|
||||||
<div class="w-px h-6 bg-gray-200"></div>
|
<div class="w-px h-6 bg-gray-200"></div>
|
||||||
<button onclick="openFilters()" class="px-4 text-gray-700 hover:text-black flex items-center justify-center transition-colors">
|
<button onclick="openFilters()" class="px-4 text-gray-700 hover:text-black flex items-center justify-center transition-colors">
|
||||||
<i class="fas fa-sliders-h"></i>
|
<i class="fas fa-sliders-h"></i>
|
||||||
@@ -13,162 +109,34 @@ export function getRecipeListHTML() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto px-4 pt-4 pb-24 bg-gray-50">
|
<div class="flex-1 overflow-y-auto px-4 pt-4 pb-24 bg-gray-50">
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div id="recipe-grid" class="grid grid-cols-2 gap-3"></div>
|
||||||
|
|
||||||
<div onclick="openRecipeDetail()" class="border border-gray-200 rounded-xl overflow-hidden shadow-sm flex flex-col bg-white cursor-pointer hover:shadow-md transition-shadow">
|
|
||||||
<div class="h-32 bg-[#d4d4d4] relative flex items-center justify-center">
|
|
||||||
<span class="text-white font-medium text-xs">Placki</span>
|
|
||||||
</div>
|
|
||||||
<div class="p-3 flex flex-col flex-1">
|
|
||||||
<h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-black mb-1 line-clamp-1">Puszyste placki</h3>
|
|
||||||
<p class="text-gray-500 text-xs mb-3 line-clamp-2">Klasyczne placki na śniadanie</p>
|
|
||||||
<div class="mt-auto">
|
|
||||||
<div class="flex items-center justify-between text-[11px] text-gray-600 font-medium mb-2">
|
|
||||||
<div class="flex items-center gap-1"><i class="fas fa-clock text-gray-400"></i><span>15 min</span></div>
|
|
||||||
<div class="flex items-center gap-1"><i class="fas fa-fire text-gray-400"></i><span>320 kcal</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-[10px] rounded-md font-medium">Śniadanie</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div onclick="openRecipeDetail()" class="border border-gray-200 rounded-xl overflow-hidden shadow-sm flex flex-col bg-white cursor-pointer hover:shadow-md transition-shadow">
|
|
||||||
<div class="h-32 bg-[#d4d4d4] relative flex items-center justify-center">
|
|
||||||
<span class="text-white font-medium text-xs">Sałatka</span>
|
|
||||||
</div>
|
|
||||||
<div class="p-3 flex flex-col flex-1">
|
|
||||||
<h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-black mb-1 line-clamp-1">Sałatka z kurczakiem</h3>
|
|
||||||
<p class="text-gray-500 text-xs mb-3 line-clamp-2">Zielone warzywa z grillowanym kurczakiem</p>
|
|
||||||
<div class="mt-auto">
|
|
||||||
<div class="flex items-center justify-between text-[11px] text-gray-600 font-medium mb-2">
|
|
||||||
<div class="flex items-center gap-1"><i class="fas fa-clock text-gray-400"></i><span>20 min</span></div>
|
|
||||||
<div class="flex items-center gap-1"><i class="fas fa-fire text-gray-400"></i><span>250 kcal</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-[10px] rounded-md font-medium">Obiad</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div onclick="openRecipeDetail()" class="border border-gray-200 rounded-xl overflow-hidden shadow-sm flex flex-col bg-white cursor-pointer hover:shadow-md transition-shadow">
|
|
||||||
<div class="h-32 bg-[#d4d4d4] relative flex items-center justify-center">
|
|
||||||
<span class="text-white font-medium text-xs">Makaron</span>
|
|
||||||
</div>
|
|
||||||
<div class="p-3 flex flex-col flex-1">
|
|
||||||
<h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-black mb-1 line-clamp-1">Makaron z pomidorami i bazylią</h3>
|
|
||||||
<p class="text-gray-500 text-xs mb-3 line-clamp-2">Aromatyczny sos pomidorowy z czosnkiem</p>
|
|
||||||
<div class="mt-auto">
|
|
||||||
<div class="flex items-center justify-between text-[11px] text-gray-600 font-medium mb-2">
|
|
||||||
<div class="flex items-center gap-1"><i class="fas fa-clock text-gray-400"></i><span>30 min</span></div>
|
|
||||||
<div class="flex items-center gap-1"><i class="fas fa-fire text-gray-400"></i><span>450 kcal</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-[10px] rounded-md font-medium">Kolacja</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div onclick="openRecipeDetail()" class="border border-gray-200 rounded-xl overflow-hidden shadow-sm flex flex-col bg-white cursor-pointer hover:shadow-md transition-shadow">
|
|
||||||
<div class="h-32 bg-[#d4d4d4] relative flex items-center justify-center">
|
|
||||||
<span class="text-white font-medium text-xs">Koktajl</span>
|
|
||||||
</div>
|
|
||||||
<div class="p-3 flex flex-col flex-1">
|
|
||||||
<h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-black mb-1 line-clamp-1">Koktajl owocowy</h3>
|
|
||||||
<p class="text-gray-500 text-xs mb-3 line-clamp-2">Mix jagód i jogurtu</p>
|
|
||||||
<div class="mt-auto">
|
|
||||||
<div class="flex items-center justify-between text-[11px] text-gray-600 font-medium mb-2">
|
|
||||||
<div class="flex items-center gap-1"><i class="fas fa-clock text-gray-400"></i><span>5 min</span></div>
|
|
||||||
<div class="flex items-center gap-1"><i class="fas fa-fire text-gray-400"></i><span>180 kcal</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-[10px] rounded-md font-medium">Przekąska</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div onclick="openRecipeDetail()" class="border border-gray-200 rounded-xl overflow-hidden shadow-sm flex flex-col bg-white cursor-pointer hover:shadow-md transition-shadow">
|
|
||||||
<div class="h-32 bg-[#d4d4d4] relative flex items-center justify-center">
|
|
||||||
<span class="text-white font-medium text-xs">Tost z awokado</span>
|
|
||||||
</div>
|
|
||||||
<div class="p-3 flex flex-col flex-1">
|
|
||||||
<h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-black mb-1 line-clamp-1">Tost z awokado</h3>
|
|
||||||
<p class="text-gray-500 text-xs mb-3 line-clamp-2">Chleb na zakwasie z rozgniecionym awokado</p>
|
|
||||||
<div class="mt-auto">
|
|
||||||
<div class="flex items-center justify-between text-[11px] text-gray-600 font-medium mb-2">
|
|
||||||
<div class="flex items-center gap-1"><i class="fas fa-clock text-gray-400"></i><span>10 min</span></div>
|
|
||||||
<div class="flex items-center gap-1"><i class="fas fa-fire text-gray-400"></i><span>220 kcal</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-[10px] rounded-md font-medium">Śniadanie</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div onclick="openRecipeDetail()" class="border border-gray-200 rounded-xl overflow-hidden shadow-sm flex flex-col bg-white cursor-pointer hover:shadow-md transition-shadow">
|
|
||||||
<div class="h-32 bg-[#d4d4d4] relative flex items-center justify-center">
|
|
||||||
<span class="text-white font-medium text-xs">Łosoś</span>
|
|
||||||
</div>
|
|
||||||
<div class="p-3 flex flex-col flex-1">
|
|
||||||
<h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-black mb-1 line-clamp-1">Grillowany łosoś</h3>
|
|
||||||
<p class="text-gray-500 text-xs mb-3 line-clamp-2">Świeży łosoś z masłem cytrynowym</p>
|
|
||||||
<div class="mt-auto">
|
|
||||||
<div class="flex items-center justify-between text-[11px] text-gray-600 font-medium mb-2">
|
|
||||||
<div class="flex items-center gap-1"><i class="fas fa-clock text-gray-400"></i><span>25 min</span></div>
|
|
||||||
<div class="flex items-center gap-1"><i class="fas fa-fire text-gray-400"></i><span>380 kcal</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-[10px] rounded-md font-medium">Kolacja</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div onclick="openRecipeDetail()" class="border border-gray-200 rounded-xl overflow-hidden shadow-sm flex flex-col bg-white cursor-pointer hover:shadow-md transition-shadow">
|
|
||||||
<div class="h-32 bg-[#d4d4d4] relative flex items-center justify-center">
|
|
||||||
<span class="text-white font-medium text-xs">Tacos</span>
|
|
||||||
</div>
|
|
||||||
<div class="p-3 flex flex-col flex-1">
|
|
||||||
<h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-black mb-1 line-clamp-1">Tacos z wołowiną</h3>
|
|
||||||
<p class="text-gray-500 text-xs mb-3 line-clamp-2">Pikantna mielona wołowina ze świeżą salsą</p>
|
|
||||||
<div class="mt-auto">
|
|
||||||
<div class="flex items-center justify-between text-[11px] text-gray-600 font-medium mb-2">
|
|
||||||
<div class="flex items-center gap-1"><i class="fas fa-clock text-gray-400"></i><span>20 min</span></div>
|
|
||||||
<div class="flex items-center gap-1"><i class="fas fa-fire text-gray-400"></i><span>410 kcal</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-[10px] rounded-md font-medium">Kolacja</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div onclick="openRecipeDetail()" class="border border-gray-200 rounded-xl overflow-hidden shadow-sm flex flex-col bg-white cursor-pointer hover:shadow-md transition-shadow">
|
|
||||||
<div class="h-32 bg-[#d4d4d4] relative flex items-center justify-center">
|
|
||||||
<span class="text-white font-medium text-xs">Owsianka</span>
|
|
||||||
</div>
|
|
||||||
<div class="p-3 flex flex-col flex-1">
|
|
||||||
<h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-black mb-1 line-clamp-1">Miska owsianki</h3>
|
|
||||||
<p class="text-gray-500 text-xs mb-3 line-clamp-2">Ciepła owsianka z miodem i orzechami</p>
|
|
||||||
<div class="mt-auto">
|
|
||||||
<div class="flex items-center justify-between text-[11px] text-gray-600 font-medium mb-2">
|
|
||||||
<div class="flex items-center gap-1"><i class="fas fa-clock text-gray-400"></i><span>10 min</span></div>
|
|
||||||
<div class="flex items-center gap-1"><i class="fas fa-fire text-gray-400"></i><span>210 kcal</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-[10px] rounded-md font-medium">Śniadanie</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getFilterState() {
|
||||||
|
return filterState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyFilters(newState) {
|
||||||
|
Object.assign(filterState, newState);
|
||||||
|
renderGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFilteredCount() {
|
||||||
|
return getFilteredRecipes().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshRecipeList() {
|
||||||
|
renderGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupRecipeList() {
|
||||||
|
renderGrid();
|
||||||
|
|
||||||
|
document.getElementById('recipe-search-input')?.addEventListener('input', (e) => {
|
||||||
|
filterState.query = e.target.value.trim();
|
||||||
|
renderGrid();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
addFreeformLine,
|
addFreeformLine,
|
||||||
addFreeformList,
|
addFreeformList,
|
||||||
|
applyCheckedKitchenListToPantry,
|
||||||
categoryLabel,
|
categoryLabel,
|
||||||
|
clearCheckedInList,
|
||||||
deleteList,
|
deleteList,
|
||||||
getActiveList,
|
getActiveList,
|
||||||
getListSummaries,
|
getListSummaries,
|
||||||
@@ -9,6 +11,7 @@ import {
|
|||||||
removeItemFromList,
|
removeItemFromList,
|
||||||
setActiveListId,
|
setActiveListId,
|
||||||
toggleItemInList,
|
toggleItemInList,
|
||||||
|
updateKitchenItemAmount,
|
||||||
} from '../services/pantryShopping.js';
|
} from '../services/pantryShopping.js';
|
||||||
import { showAppToast } from '../ui/toast.js';
|
import { showAppToast } from '../ui/toast.js';
|
||||||
|
|
||||||
@@ -34,6 +37,14 @@ export function getShoppingHTML() {
|
|||||||
<button type="button" id="shopping-delete-list" class="hidden w-full py-2 rounded-lg text-xs font-medium text-red-600 hover:bg-red-50 transition-colors">
|
<button type="button" id="shopping-delete-list" class="hidden w-full py-2 rounded-lg text-xs font-medium text-red-600 hover:bg-red-50 transition-colors">
|
||||||
Usuń tę listę (nie dotyczy listy kuchennej)
|
Usuń tę listę (nie dotyczy listy kuchennej)
|
||||||
</button>
|
</button>
|
||||||
|
<div id="shopping-kitchen-actions" class="hidden flex gap-2">
|
||||||
|
<button type="button" id="shopping-to-pantry" class="flex-1 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold transition-colors flex items-center justify-center gap-1.5">
|
||||||
|
<i class="fas fa-warehouse text-[10px]"></i> Kupione → spiżarnia
|
||||||
|
</button>
|
||||||
|
<button type="button" id="shopping-clear-checked" class="flex-1 py-2.5 rounded-xl border border-gray-200 bg-white text-gray-700 hover:bg-gray-50 text-xs font-semibold transition-colors flex items-center justify-center gap-1.5">
|
||||||
|
<i class="fas fa-broom text-[10px]"></i> Wyczyść kupione
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="shopping-freeform-add" class="hidden shrink-0 px-4 pt-3 pb-2 space-y-2 border-b border-gray-100">
|
<div id="shopping-freeform-add" class="hidden shrink-0 px-4 pt-3 pb-2 space-y-2 border-b border-gray-100">
|
||||||
@@ -68,11 +79,14 @@ function syncChromeForList() {
|
|||||||
const isKitchen = list.type === 'kitchen';
|
const isKitchen = list.type === 'kitchen';
|
||||||
const delBtn = document.getElementById('shopping-delete-list');
|
const delBtn = document.getElementById('shopping-delete-list');
|
||||||
const ffAdd = document.getElementById('shopping-freeform-add');
|
const ffAdd = document.getElementById('shopping-freeform-add');
|
||||||
|
const kitchenActions = document.getElementById('shopping-kitchen-actions');
|
||||||
|
|
||||||
if (ffAdd) ffAdd.classList.toggle('hidden', isKitchen);
|
if (ffAdd) ffAdd.classList.toggle('hidden', isKitchen);
|
||||||
|
if (delBtn) delBtn.classList.toggle('hidden', isKitchen);
|
||||||
|
|
||||||
if (delBtn) {
|
if (kitchenActions) {
|
||||||
delBtn.classList.toggle('hidden', isKitchen);
|
const hasChecked = isKitchen && list.items.some((i) => i.checked);
|
||||||
|
kitchenActions.classList.toggle('hidden', !hasChecked);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +123,7 @@ function renderKitchenItems() {
|
|||||||
</button>
|
</button>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm font-medium text-gray-900 ${it.checked ? 'line-through text-gray-500' : ''}">${escapeHtml(it.name)}</p>
|
<p class="text-sm font-medium text-gray-900 ${it.checked ? 'line-through text-gray-500' : ''}">${escapeHtml(it.name)}</p>
|
||||||
<p class="text-xs text-gray-600 tabular-nums mt-0.5">${escapeHtml(String(it.amount))} ${escapeHtml(it.unit)}</p>
|
<button type="button" data-shop-edit-amount="${escapeHtml(it.id)}" data-current-amount="${it.amount}" data-unit="${escapeHtml(it.unit)}" class="text-xs text-gray-600 tabular-nums mt-0.5 hover:text-gray-900 underline decoration-dashed underline-offset-2 cursor-pointer">${escapeHtml(String(it.amount))} ${escapeHtml(it.unit)}</button>
|
||||||
${it.sourceNote ? `<p class="text-[10px] text-gray-400 mt-1">${escapeHtml(it.sourceNote)}</p>` : ''}
|
${it.sourceNote ? `<p class="text-[10px] text-gray-400 mt-1">${escapeHtml(it.sourceNote)}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" data-shop-remove="${escapeHtml(it.id)}" class="shrink-0 w-8 h-8 rounded-full text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors" aria-label="Usuń">
|
<button type="button" data-shop-remove="${escapeHtml(it.id)}" class="shrink-0 w-8 h-8 rounded-full text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors" aria-label="Usuń">
|
||||||
@@ -177,6 +191,23 @@ function bindItemButtons(listId) {
|
|||||||
refreshShopping();
|
refreshShopping();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
root.querySelectorAll('[data-shop-edit-amount]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const itemId = btn.getAttribute('data-shop-edit-amount');
|
||||||
|
const current = btn.getAttribute('data-current-amount');
|
||||||
|
const unit = btn.getAttribute('data-unit');
|
||||||
|
const newVal = window.prompt(`Nowa ilość (${unit}):`, current);
|
||||||
|
if (newVal === null) return;
|
||||||
|
const num = parseFloat(newVal.replace(',', '.'));
|
||||||
|
if (!Number.isFinite(num) || num < 0) {
|
||||||
|
showAppToast('Nieprawidłowa wartość.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateKitchenItemAmount(listId, itemId, num);
|
||||||
|
refreshShopping();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function refreshShopping() {
|
export function refreshShopping() {
|
||||||
@@ -232,6 +263,28 @@ export function setupShopping() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('shopping-to-pantry')?.addEventListener('click', () => {
|
||||||
|
const list = getActiveList();
|
||||||
|
if (list.type !== 'kitchen') return;
|
||||||
|
const checked = list.items.filter((i) => i.checked);
|
||||||
|
if (checked.length === 0) return;
|
||||||
|
const preview = checked.map((i) => ` • ${i.name}: ${i.amount} ${i.unit}`).join('\n');
|
||||||
|
if (!window.confirm(`Przenieść do spiżarni?\n\n${preview}`)) return;
|
||||||
|
applyCheckedKitchenListToPantry();
|
||||||
|
showAppToast(`Przeniesiono ${checked.length} pozycji do spiżarni.`);
|
||||||
|
window.refreshPantry?.();
|
||||||
|
refreshShopping();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('shopping-clear-checked')?.addEventListener('click', () => {
|
||||||
|
const list = getActiveList();
|
||||||
|
const checkedCount = list.items.filter((i) => i.checked).length;
|
||||||
|
if (checkedCount === 0) return;
|
||||||
|
clearCheckedInList(list.id);
|
||||||
|
showAppToast(`Usunięto ${checkedCount} kupionych pozycji.`);
|
||||||
|
refreshShopping();
|
||||||
|
});
|
||||||
|
|
||||||
refreshShopping();
|
refreshShopping();
|
||||||
window.refreshShopping = refreshShopping;
|
window.refreshShopping = refreshShopping;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user