Compare commits

...

38 Commits

Author SHA1 Message Date
6f902098a8 Adjust filter button
All checks were successful
Build and Deploy / build-and-push (push) Successful in 31s
2026-05-07 19:36:30 +02:00
6d6194df37 Adjust calendar controller 2026-05-07 19:12:38 +02:00
68e5227db1 Adjust tabbar 2026-05-07 18:42:54 +02:00
544df5175d Adjust height of the tab bar 2026-05-07 18:07:56 +02:00
53a7212dfe Redesign menu
All checks were successful
Build and Deploy / build-and-push (push) Successful in 27s
2026-04-22 22:53:24 +02:00
ded24b53b4 Design changes to pantry
All checks were successful
Build and Deploy / build-and-push (push) Successful in 27s
2026-04-22 21:57:23 +02:00
3d62d88d48 Apply liquid glass to pantry items 2026-04-22 19:55:59 +02:00
b9538a35b6 Apply liquid glass to pantry view
All checks were successful
Build and Deploy / build-and-push (push) Successful in 30s
2026-04-22 19:49:29 +02:00
120959365e Apply liquid glass to recipe cards 2026-04-22 18:47:05 +02:00
7049cb1d48 Apply liquid glass to filter panel in recipe list 2026-04-22 18:42:29 +02:00
bc505d6b4c Redesign controls in recipe list 2026-04-22 18:08:20 +02:00
7328b6ec4c Redesign controls in recipe list 2026-04-22 17:58:20 +02:00
2f362a7e56 Add titles to menu
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m15s
2026-04-21 22:44:08 +02:00
e914f93781 Liquid glass - continuation 2026-04-21 22:28:19 +02:00
5499476a17 Liquid glass - first try 2026-04-21 22:01:07 +02:00
8702830f68 Discover dark mode automatically
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m14s
2026-04-20 23:54:04 +02:00
08a275093c Unify calendar code
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m16s
2026-04-20 23:44:18 +02:00
c43b3766cd Fix calendar styling 2026-04-20 23:04:28 +02:00
63937ed7d1 Block calendar swiping outside possible range 2026-04-20 22:22:51 +02:00
570e44257f Swipeable calendar
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m15s
2026-04-20 11:33:43 +02:00
070a0a61db Redesign bought items on shopping list
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m13s
2026-04-19 18:41:10 +02:00
d2618a5b45 UI fixes
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m17s
2026-04-19 10:45:01 +02:00
1bca99a6bb Light mode fixes 2026-04-19 10:21:49 +02:00
3055ce53c1 Light mode
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m17s
2026-04-18 12:15:51 +02:00
5c21fb1e64 Extract colors
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m10s
2026-04-18 11:12:05 +02:00
59340e8afd Fix calendar color
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m17s
2026-04-18 09:16:12 +02:00
8e48ebdd95 Redesign shopping list
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m19s
2026-04-17 23:34:53 +02:00
a90e8ba9d2 Use SVGs in pantry list
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m17s
2026-04-17 20:52:49 +02:00
35b8babd0c Replace ingredients images with SVGs 2026-04-17 20:38:57 +02:00
9bd6627fe2 Design work on pantry
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m16s
2026-04-17 19:38:15 +02:00
0429b0b945 Add new recipes
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m14s
2026-04-17 18:15:39 +02:00
92cc779dbf Fix scrolling areas
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m13s
2026-04-17 17:51:32 +02:00
f533db1743 Make ingredient cards flexible
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m21s
2026-04-17 00:05:02 +02:00
8ed15bbe36 Merge branch 'claude/stoic-swartz'
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m18s
2026-04-16 00:19:04 +02:00
4d7a1a12ae Reorganizacja górnych paneli i ujednolicenie stylu filtrów
- Kalendarz: data między strzałkami zamiast napisu "Dziś", nawigacja po prawej, mniejszy komponent dopasowany do wysokości dnia
- MealPlanner/Pantry/RecipeList: spójne nagłówki z tytułem po lewej i kontrolkami po prawej
- RecipeList: nowy top bar z przyciskami filtrów i wyszukiwania wzorowany na spiżarni
- Filter popup: ujednolicony styl z popoverem spiżarni (ciemniejsze tło, jaśniejsze obramowanie, spójne chipy)
- Usunięcie przyciemnienia otoczenia przy otwieraniu filtrów
- Badge z liczbą aktywnych filtrów na przycisku, zachowujący stan po zamknięciu popupu
- Usunięcie ikon kalendarza z pigułek w spiżarni

Made-with: Cursor
2026-04-16 00:17:41 +02:00
230100b63f Merge branch 'claude/stoic-swartz'
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m12s
2026-04-14 23:14:47 +02:00
d3a68a80eb Redesign pantry horizon controls 2026-04-14 23:14:28 +02:00
d642fbd687 Adjust recipe detail view
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m18s
2026-04-14 22:41:41 +02:00
104 changed files with 5105 additions and 1245 deletions

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M14 20 Q12 26 18 32 Q28 44 42 46 Q50 46 52 42 Q50 40 48 42 Q38 42 28 34 Q20 28 18 22 L16 18 Z" fill="#f4d04a" stroke="#b89020" stroke-width="1.3"/><path d="M16 22 Q18 28 24 34 Q34 42 44 44 Q38 42 30 36 Q22 30 18 24 Z" fill="#f9dc7a" opacity="0.6"/><path d="M14 18 L16 14 Q18 14 18 18 Z" fill="#3d2a18" stroke="#2d1a0f" stroke-width="0.8"/><path d="M50 42 Q52 40 54 42 Q52 44 50 44 Z" fill="#8b6a3f"/><path d="M20 24 Q26 30 32 36" stroke="#fff" stroke-width="1" opacity="0.5" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 564 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M32 54 L32 22" stroke="#3d6825" stroke-width="1.5" stroke-linecap="round"/><g fill="#5fa347" stroke="#3d6825" stroke-width="0.8"><path d="M32 18 Q24 14 20 20 Q22 26 28 28 Q32 26 32 20 Z"/><path d="M32 18 Q40 14 44 20 Q42 26 36 28 Q32 26 32 20 Z"/><path d="M32 30 Q20 28 16 34 Q20 40 28 40 Q32 38 32 32 Z"/><path d="M32 30 Q44 28 48 34 Q44 40 36 40 Q32 38 32 32 Z"/><path d="M32 42 Q24 42 22 48 Q26 52 30 50 Q32 48 32 44 Z"/><path d="M32 42 Q40 42 42 48 Q38 52 34 50 Q32 48 32 44 Z"/></g><g stroke="#3d6825" stroke-width="0.5" fill="none" opacity="0.6"><path d="M32 18 L24 22"/><path d="M32 18 L40 22"/><path d="M32 30 L20 34"/><path d="M32 30 L44 34"/><path d="M32 42 L26 46"/><path d="M32 42 L38 46"/></g><g fill="#7ac253" opacity="0.6"><ellipse cx="24" cy="22" rx="2" ry="1"/><ellipse cx="40" cy="22" rx="2" ry="1"/><ellipse cx="22" cy="34" rx="3" ry="1.5"/><ellipse cx="42" cy="34" rx="3" ry="1.5"/></g></svg>

After

Width:  |  Height:  |  Size: 982 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><circle cx="22" cy="40" r="10" fill="#4a6fa8" stroke="#2d4a7a" stroke-width="1.2"/><circle cx="40" cy="38" r="11" fill="#5a7fb8" stroke="#2d4a7a" stroke-width="1.2"/><circle cx="30" cy="24" r="9" fill="#6a8fc8" stroke="#2d4a7a" stroke-width="1.2"/><circle cx="44" cy="22" r="7" fill="#4a6fa8" stroke="#2d4a7a" stroke-width="1.2"/><g fill="#8aafd8" opacity="0.6"><ellipse cx="20" cy="37" rx="3" ry="2"/><ellipse cx="37" cy="35" rx="3" ry="2"/><ellipse cx="28" cy="21" rx="3" ry="2"/><ellipse cx="42" cy="20" rx="2" ry="1.5"/></g><g fill="#2d4a7a"><path d="M22 34 L20 32 L24 32 Z"/><path d="M40 32 L38 30 L42 30 Z"/><path d="M30 18 L28 16 L32 16 Z"/><path d="M44 17 L43 15 L46 15 Z"/></g></svg>

After

Width:  |  Height:  |  Size: 753 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M12 26 Q10 22 14 22 L50 22 Q54 22 52 26 L48 52 Q48 56 44 56 L20 56 Q16 56 16 52 Z" fill="#a6743a" stroke="#6b4a28" stroke-width="1.3"/><ellipse cx="32" cy="24" rx="20" ry="4" fill="#c99866" stroke="#6b4a28" stroke-width="1.3"/><ellipse cx="32" cy="24" rx="17" ry="2.5" fill="#e3c18a"/><g fill="#5fa347" opacity="0.8"><ellipse cx="24" cy="32" rx="1.5" ry="1"/><ellipse cx="38" cy="34" rx="1.5" ry="1"/><ellipse cx="28" cy="40" rx="1.2" ry="0.8"/><ellipse cx="42" cy="42" rx="1.2" ry="0.8"/></g><g fill="#ef8a3d" opacity="0.8"><ellipse cx="30" cy="30" rx="1.3" ry="1"/><ellipse cx="36" cy="40" rx="1.3" ry="1"/><ellipse cx="22" cy="42" rx="1" ry="0.8"/></g><g fill="#b89020" opacity="0.7"><circle cx="26" cy="36" r="0.8"/><circle cx="34" cy="38" r="0.8"/><circle cx="40" cy="32" rd="0.8"/></g><path d="M14 10 Q16 14 14 18" stroke="#c9c3b3" stroke-width="0.8" fill="none" opacity="0.6"/><path d="M22 8 Q24 14 22 18" stroke="#c9c3b3" stroke-width="0.8" fill="none" opacity="0.6"/><path d="M32 8 Q34 14 32 18" stroke="#c9c3b3" stroke-width="0.8" fill="none" opacity="0.6"/><path d="M42 8 Q44 14 42 18" stroke="#c9c3b3" stroke-width="0.8" fill="none" opacity="0.6"/><path d="M50 10 Q52 14 50 18" stroke="#c9c3b3" stroke-width="0.8" fill="none" opacity="0.6"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><ellipse cx="32" cy="36" rx="22" ry="16" fill="#c79968" stroke="#8b6a3f" stroke-width="1.3"/><ellipse cx="32" cy="32" rx="20" ry="12" fill="#d8ad7a"/><path d="M14 36 Q32 32 50 36" stroke="#8b6a3f" stroke-width="1" fill="none" opacity="0.5"/><g fill="#6b4a28"><circle cx="22" cy="30" r="0.8"/><circle cx="28" cy="26" r="0.8"/><circle cx="35" cy="28" r="0.8"/><circle cx="40" cy="24" r="0.8"/><circle cx="43" cy="30" r="0.8"/><circle cx="30" cy="32" r="0.8"/><circle cx="38" cy="34" r="0.8"/><circle cx="24" cy="34" r="0.8"/><circle cx="17" cy="32" r="0.7"/><circle cx="47" cy="32" r="0.7"/></g><ellipse cx="25" cy="26" rx="8" ry="2" fill="#e8c497" opacity="0.5"/></svg>

After

Width:  |  Height:  |  Size: 729 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><ellipse cx="32" cy="54" rx="18" ry="3" fill="#e8e2d2" opacity="0.5"/><circle cx="32" cy="38" r="18" fill="#fefcf4" stroke="#c9c3b3" stroke-width="1.3"/><path d="M26 22 Q32 14 38 22 Q36 18 34 18 Q33 14 32 18 Q31 14 30 18 Q28 18 26 22 Z" fill="#fefcf4" stroke="#c9c3b3" stroke-width="1.2"/><path d="M28 20 Q32 18 36 20" stroke="#c9c3b3" stroke-width="0.8" fill="none"/><ellipse cx="26" cy="32" rx="6" ry="3" fill="#fff" opacity="0.7"/><circle cx="40" cy="42" r="2" fill="#fff" opacity="0.5"/></svg>

After

Width:  |  Height:  |  Size: 558 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M24 14 Q28 12 32 14 Q36 16 38 20 L44 30 L46 50 Q46 54 42 56 L22 56 Q18 54 18 50 L20 32 L24 22 Z" fill="#fefcf4" stroke="#b0a080" stroke-width="1.3"/><rect x="20" y="34" width="24" height="16" rx="1" fill="#f0ebdd" stroke="#c9c3b3" stroke-width="0.8"/><text x="32" y="42" text-anchor="middle" font-family="sans-serif" font-size="5" fill="#6b4a28" font-weight="bold">CHRZAN</text><circle cx="32" cy="46" r="2" fill="none" stroke="#6b4a28" stroke-width="0.6"/><path d="M24 14 L32 6 L40 14" fill="#5fa347" stroke="#3d6825" stroke-width="1"/><path d="M28 10 L32 8 L36 10" fill="#7ac253"/><ellipse cx="24" cy="24" rx="2" ry="4" fill="#fff" opacity="0.4"/></svg>

After

Width:  |  Height:  |  Size: 725 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g transform="rotate(-30 32 32)"><rect x="12" y="22" width="38" height="20" rx="10" fill="#5f8c3d" stroke="#3d6825" stroke-width="1.3"/><rect x="14" y="24" width="34" height="10" rx="5" fill="#7aa855" opacity="0.7"/><g stroke="#3d6825" stroke-width="0.6" fill="none" opacity="0.6"><path d="M18 30 Q22 32 26 30"/><path d="M30 32 Q34 34 38 32"/><path d="M42 30 Q46 32 48 30"/><path d="M18 38 Q22 40 26 38"/><path d="M30 36 Q34 38 38 36"/></g><path d="M13 24 Q11 22 13 20 L15 20 Q17 22 15 24" fill="#3d6825"/><path d="M14 20 L14 18" stroke="#3d6825" stroke-width="1" stroke-linecap="round"/><ellipse cx="20" cy="27" rx="6" ry="1.5" fill="#aed37a" opacity="0.7"/></g></svg>

After

Width:  |  Height:  |  Size: 730 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g transform="rotate(-20 32 32)"><rect x="16" y="24" width="32" height="16" rx="3" fill="#8b4a20" stroke="#5a2a10" stroke-width="1.3"/><ellipse cx="16" cy="32" rx="3" ry="8" fill="#6b3618"/><ellipse cx="18" cy="32" rx="2.5" ry="7" fill="#a6632a" opacity="0.7"/><ellipse cx="48" cy="32" rx="3" ry="8" fill="#6b3618"/><ellipse cx="46" cy="32" rx="2.5" ry="7" fill="#a6632a" opacity="0.7"/><g stroke="#5a2a10" stroke-width="0.5" fill="none" opacity="0.7"><path d="M22 28 L22 36"/><path d="M28 27 L28 37"/><path d="M34 28 L34 36"/><path d="M40 27 L40 37"/></g><ellipse cx="20" cy="28" rx="10" ry="1.5" fill="#fff" opacity="0.15"/></g><g transform="rotate(40 32 42)"><rect x="22" y="40" width="20" height="10" rx="2" fill="#8b4a20" stroke="#5a2a10" stroke-width="1.2"/><ellipse cx="22" cy="45" rx="2" ry="5" fill="#6b3618"/><ellipse cx="42" cy="45" rx="2" ry="5" fill="#6b3618"/></g></svg>

After

Width:  |  Height:  |  Size: 945 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect x="12" y="16" width="40" height="36" rx="2" fill="#8b4a20" stroke="#4a2c10" stroke-width="1.3"/><path d="M12 16 L16 12 L52 12 L52 16" fill="#a6632a" stroke="#4a2c10" stroke-width="1.3"/><g fill="#6b3618" stroke="#4a2c10" stroke-width="0.8"><rect x="14" y="18" width="11" height="10" rx="1"/><rect x="26" y="18" width="11" height="10" rx="1"/><rect x="38" y="18" width="12" height="10" rx="1"/><rect x="14" y="29" width="11" height="10" rx="1"/><rect x="26" y="29" width="11" height="10" rx="1"/><rect x="38" y="29" width="12" height="10" rx="1"/><rect x="14" y="40" width="11" height="10" rx="1"/><rect x="26" y="40" width="11" height="10" rx="1"/><rect x="38" y="40" width="12" height="10" rx="1"/></g><g fill="#a6632a" opacity="0.3"><rect x="15" y="19" width="9" height="3" rx="0.5"/><rect x="27" y="19" width="9" height="3" rx="0.5"/><rect x="39" y="19" width="10" height="3" rx="0.5"/><rect x="15" y="30" width="9" height="3" rx="0.5"/><rect x="27" y="30" width="9" height="3" rx="0.5"/><rect x="39" y="30" width="10" height="3" rx="0.5"/></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M32 12 Q30 18 28 20" stroke="#8a7a5a" stroke-width="2" fill="none" stroke-linecap="round"/><path d="M32 12 Q34 18 36 20" stroke="#8a7a5a" stroke-width="2" fill="none" stroke-linecap="round"/><path d="M14 32 Q14 20 24 18 Q32 16 40 18 Q50 20 50 32 Q50 44 42 52 Q32 56 22 52 Q14 44 14 32 Z" fill="#fefcf4" stroke="#c9c3b3" stroke-width="1.3"/><path d="M24 20 Q24 34 22 50" stroke="#c9c3b3" stroke-width="0.8" fill="none"/><path d="M32 18 Q32 34 32 54" stroke="#c9c3b3" stroke-width="0.8" fill="none"/><path d="M40 20 Q40 34 42 50" stroke="#c9c3b3" stroke-width="0.8" fill="none"/><path d="M18 28 Q24 24 32 24 Q40 24 46 28" stroke="#c9c3b3" stroke-width="0.6" fill="none" opacity="0.6"/><ellipse cx="22" cy="30" rx="2" ry="6" fill="#fff" opacity="0.5"/></svg>

After

Width:  |  Height:  |  Size: 825 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect x="28" y="10" width="8" height="4" rx="1" fill="#3d2a18"/><rect x="29" y="14" width="6" height="4" fill="#5a3a20"/><path d="M24 20 Q24 18 26 18 L38 18 Q40 18 40 20 L42 26 L42 52 Q42 56 38 56 L26 56 Q22 56 22 52 L22 26 Z" fill="#4a2c1a" stroke="#2d1a0f" stroke-width="1.3" stroke-linejoin="round"/><rect x="25" y="30" width="14" height="14" rx="1" fill="#f5f1e5" stroke="#c9c3b3" stroke-width="0.6"/><text x="32" y="36" text-anchor="middle" font-family="sans-serif" font-size="3" fill="#3d2a18" font-weight="bold">VANILLA</text><text x="32" y="40" text-anchor="middle" font-family="sans-serif" font-size="2.5" fill="#6b4a28">EXTRACT</text><circle cx="32" cy="42" r="1.5" fill="#b89020" opacity="0.7"/><rect x="24" y="22" width="2" height="28" fill="#fff" opacity="0.2"/></svg>

After

Width:  |  Height:  |  Size: 842 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M16 16 Q16 12 20 12 L44 12 Q48 12 48 16 L50 54 Q50 56 48 56 L16 56 Q14 56 14 54 Z" fill="#fefcf4" stroke="#a89778" stroke-width="1.3"/><path d="M16 16 Q32 18 48 16" stroke="#a89778" stroke-width="1" fill="none"/><rect x="20" y="24" width="24" height="20" rx="1" fill="#fff" stroke="#c9c3b3" stroke-width="0.6"/><text x="32" y="32" text-anchor="middle" font-family="sans-serif" font-size="4.5" fill="#3a7ca5" font-weight="bold">ERYTROL</text><path d="M24 36 L40 36" stroke="#a89778" stroke-width="0.6"/><g fill="#fff" stroke="#c9c3b3" stroke-width="0.3"><rect x="24" y="38" width="2" height="2"/><rect x="27" y="39" width="2" height="2"/><rect x="30" y="38" width="2" height="2"/><rect x="33" y="39" width="2" height="2"/><rect x="36" y="38" width="2" height="2"/><rect x="39" y="39" width="2" height="2"/></g><ellipse cx="22" cy="22" rx="3" ry="1.5" fill="#fff" opacity="0.6"/></svg>

After

Width:  |  Height:  |  Size: 953 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><ellipse cx="32" cy="52" rx="22" ry="4" fill="#c9c3b3" opacity="0.5"/><path d="M10 34 Q10 30 14 30 L50 30 Q54 30 54 34 Q54 48 50 52 Q32 56 14 52 Q10 48 10 34 Z" fill="#e3d098" stroke="#a67830" stroke-width="1.3"/><ellipse cx="32" cy="32" rx="22" ry="4" fill="#f0d8a8" stroke="#a67830" stroke-width="1.3"/><ellipse cx="32" cy="32" rx="18" ry="2.5" fill="#f7e2b8"/><g fill="#a67830" opacity="0.7"><circle cx="24" cy="34" r="1"/><circle cx="32" cy="35" r="1.2"/><circle cx="40" cy="34" r="1"/></g><path d="M26 40 L30 46 L34 40 L38 46 L42 40" stroke="#5fa347" stroke-width="1" fill="none" opacity="0.8"/><ellipse cx="30" cy="44" rx="1" ry="0.8" fill="#5fa347"/><ellipse cx="38" cy="44" rx="1" ry="0.8" fill="#5fa347"/><ellipse cx="20" cy="36" rx="5" ry="1" fill="#fff" opacity="0.5"/></svg>

After

Width:  |  Height:  |  Size: 847 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M12 36 Q10 28 18 24 Q22 20 28 24 Q32 22 38 26 Q42 22 48 26 Q54 30 52 38 Q54 44 48 46 Q44 50 38 46 Q32 50 26 46 Q20 50 14 44 Q10 40 12 36 Z" fill="#e3c18a" stroke="#9e7840" stroke-width="1.3"/><path d="M20 28 Q24 32 28 30 Q32 28 36 32 Q40 28 44 32" stroke="#9e7840" stroke-width="0.8" fill="none" opacity="0.7"/><g fill="#fff" opacity="0.4"><ellipse cx="22" cy="30" rx="3" ry="2"/><ellipse cx="38" cy="30" rx="3" ry="1.5"/></g><g stroke="#9e7840" stroke-width="0.5" fill="none" opacity="0.6"><path d="M16 36 Q20 38 18 42"/><path d="M30 38 Q32 42 34 40"/><path d="M44 38 Q46 42 42 44"/></g><circle cx="24" cy="34" r="0.6" fill="#6b4a28"/><circle cx="40" cy="36" r="0.6" fill="#6b4a28"/></svg>

After

Width:  |  Height:  |  Size: 760 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><circle cx="20" cy="42" r="8" fill="#2d2448" stroke="#1a1428" stroke-width="1.2"/><circle cx="34" cy="40" r="9" fill="#3a2f5a" stroke="#1a1428" stroke-width="1.2"/><circle cx="46" cy="42" r="7" fill="#2d2448" stroke="#1a1428" stroke-width="1.2"/><circle cx="26" cy="26" r="8" fill="#3a2f5a" stroke="#1a1428" stroke-width="1.2"/><circle cx="40" cy="24" r="7" fill="#2d2448" stroke="#1a1428" stroke-width="1.2"/><circle cx="50" cy="28" r="5" fill="#3a2f5a" stroke="#1a1428" stroke-width="1.2"/><g fill="#5a4f7a" opacity="0.6"><ellipse cx="18" cy="40" rx="2" ry="1.5"/><ellipse cx="32" cy="37" rx="2.5" ry="1.5"/><ellipse cx="44" cy="40" rx="2" ry="1.5"/><ellipse cx="24" cy="24" rx="2.5" ry="1.5"/><ellipse cx="38" cy="22" rx="2" ry="1.5"/></g><g fill="#1a1428"><circle cx="20" cy="38" r="1"/><circle cx="34" cy="36" r="1"/><circle cx="46" cy="38" r="0.8"/><circle cx="26" cy="22" r="1"/><circle cx="40" cy="20" r="0.8"/></g></svg>

After

Width:  |  Height:  |  Size: 990 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><ellipse cx="24" cy="40" rx="14" ry="18" fill="#fdf6e3" stroke="#e8dcc0" stroke-width="1.5"/><ellipse cx="40" cy="36" rx="15" ry="19" fill="#fffaf0" stroke="#e8dcc0" stroke-width="1.5"/><circle cx="40" cy="36" r="6" fill="#f4c542"/><circle cx="38" cy="34" r="2" fill="#f9dc7a" opacity="0.7"/></svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><circle cx="32" cy="34" r="22" fill="#d4e8b8" stroke="#7a9a5b" stroke-width="1.3"/><path d="M32 12 Q18 18 12 32 Q14 34 18 32 Q24 20 32 16 Q40 20 46 32 Q50 34 52 32 Q46 18 32 12 Z" fill="#aed37a"/><path d="M14 40 Q20 46 32 48 Q44 46 50 40 Q44 48 32 52 Q20 48 14 40 Z" fill="#aed37a" opacity="0.8"/><path d="M20 28 Q26 32 32 32 Q38 32 44 28" stroke="#7a9a5b" stroke-width="0.8" fill="none" opacity="0.7"/><path d="M18 38 Q26 42 32 42 Q38 42 46 38" stroke="#7a9a5b" stroke-width="0.8" fill="none" opacity="0.6"/><path d="M32 16 Q32 48 32 50" stroke="#7a9a5b" stroke-width="0.8" fill="none" opacity="0.5"/><ellipse cx="22" cy="28" rx="4" ry="6" fill="#fff" opacity="0.25"/></svg>

After

Width:  |  Height:  |  Size: 736 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g stroke="#fefcf4" stroke-width="1.5" fill="none" stroke-linecap="round"><path d="M20 54 Q22 44 24 32"/><path d="M28 54 Q30 42 30 28"/><path d="M36 54 Q36 42 38 28"/><path d="M44 54 Q44 44 42 32"/><path d="M24 54 Q24 46 20 36"/><path d="M32 54 Q34 46 36 36"/><path d="M40 54 Q38 46 40 34"/></g><g fill="#8bbf4f"><ellipse cx="24" cy="30" rx="2.5" ry="3"/><ellipse cx="30" cy="26" rx="2.5" ry="3"/><ellipse cx="38" cy="26" rx="2.5" ry="3"/><ellipse cx="42" cy="30" rx="2.5" ry="3"/><ellipse cx="20" cy="34" rx="2" ry="2.5"/><ellipse cx="36" cy="34" rx="2" ry="2.5"/><ellipse cx="40" cy="32" rx="2" ry="2.5"/></g><g fill="#c8253a" opacity="0.7"><circle cx="24" cy="30" r="0.8"/><circle cx="30" cy="26" r="0.8"/><circle cx="38" cy="26" r="0.8"/></g></svg>

After

Width:  |  Height:  |  Size: 813 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M32 54 L32 32" stroke="#3d6825" stroke-width="1.5" stroke-linecap="round"/><path d="M32 44 L22 40" stroke="#3d6825" stroke-width="1" stroke-linecap="round"/><path d="M32 44 L42 40" stroke="#3d6825" stroke-width="1" stroke-linecap="round"/><g fill="#5fa347" stroke="#3d6825" stroke-width="0.6"><path d="M22 40 Q16 34 14 38 Q14 44 20 42 Q16 36 22 40 Z"/><path d="M22 40 Q22 32 18 30 Q14 34 18 38 Q20 34 22 40 Z"/><path d="M42 40 Q48 34 50 38 Q50 44 44 42 Q48 36 42 40 Z"/><path d="M42 40 Q42 32 46 30 Q50 34 46 38 Q44 34 42 40 Z"/><path d="M32 32 Q26 28 24 22 Q30 22 32 28 Q34 22 40 22 Q38 28 32 32 Z"/><path d="M32 32 Q28 26 30 18 Q34 18 34 24 Q34 28 32 32 Z"/></g><g fill="#7ac253" opacity="0.6"><ellipse cx="18" cy="36" rx="2" ry="1.5"/><ellipse cx="46" cy="36" rx="2" ry="1.5"/><ellipse cx="32" cy="26" rx="2" ry="2"/></g></svg>

After

Width:  |  Height:  |  Size: 900 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M32 56 L32 20" stroke="#6b8e3d" stroke-width="1.5" stroke-linecap="round"/><g fill="#8bbf4f"><ellipse cx="32" cy="18" rx="2" ry="3"/><ellipse cx="24" cy="22" rx="2" ry="3" transform="rotate(-30 24 22)"/><ellipse cx="40" cy="22" rx="2" ry="3" transform="rotate(30 40 22)"/><ellipse cx="18" cy="28" rx="2" ry="3" transform="rotate(-45 18 28)"/><ellipse cx="46" cy="28" rx="2" ry="3" transform="rotate(45 46 28)"/><ellipse cx="14" cy="36" rx="2" ry="3" transform="rotate(-55 14 36)"/><ellipse cx="50" cy="36" rx="2" ry="3" transform="rotate(55 50 36)"/><ellipse cx="22" cy="32" rx="1.8" ry="2.5" transform="rotate(-40 22 32)"/><ellipse cx="42" cy="32" rx="1.8" ry="2.5" transform="rotate(40 42 32)"/><ellipse cx="28" cy="26" rx="1.8" ry="2.5" transform="rotate(-20 28 26)"/><ellipse cx="36" cy="26" rx="1.8" ry="2.5" transform="rotate(20 36 26)"/></g><g stroke="#6b8e3d" stroke-width="1" stroke-linecap="round" fill="none"><path d="M32 24 L24 22"/><path d="M32 24 L40 22"/><path d="M32 30 L18 28"/><path d="M32 30 L46 28"/><path d="M32 36 L14 36"/><path d="M32 36 L50 36"/></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><circle cx="32" cy="36" r="22" fill="#a8c85c" stroke="#5f8c3d" stroke-width="1.3"/><circle cx="32" cy="36" r="18" fill="#c2dc7e" opacity="0.6"/><g stroke="#7a9a5b" stroke-width="0.7" fill="none" opacity="0.6"><path d="M32 18 L32 54"/><path d="M14 36 L50 36"/><path d="M19 22 L45 50"/><path d="M45 22 L19 50"/></g><circle cx="32" cy="36" r="2" fill="#d8e8b0"/><path d="M32 14 Q30 12 28 14 Q30 14 32 16 Q34 14 36 14 Q34 12 32 14 Z" fill="#5fa347"/><ellipse cx="22" cy="30" rx="3" ry="6" fill="#fff" opacity="0.3"/></svg>

After

Width:  |  Height:  |  Size: 579 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M10 32 Q16 20 32 18 Q48 20 54 32 Q48 44 32 46 Q16 44 10 32 Z" fill="#e89676" stroke="#b85a3a" stroke-width="1.3"/><path d="M14 32 Q20 24 32 22 Q44 24 50 32 Q44 40 32 42 Q20 40 14 32 Z" fill="#f0a886" opacity="0.7"/><g stroke="#fffaf0" stroke-width="1" fill="none" opacity="0.85"><path d="M14 28 Q24 24 36 26 Q44 28 50 30"/><path d="M12 34 Q22 32 34 34 Q44 36 52 34"/><path d="M16 40 Q26 40 38 40 Q46 40 50 38"/></g><path d="M16 42 Q28 48 40 44 Q36 50 28 50 Q22 50 16 42 Z" fill="#d4715a" opacity="0.7"/></svg>

After

Width:  |  Height:  |  Size: 579 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M20 54 Q24 36 28 20 Q30 12 32 10" stroke="#8a7a4a" stroke-width="1.5" fill="none" stroke-linecap="round"/><path d="M44 54 Q40 36 36 20 Q34 12 32 10" stroke="#8a7a4a" stroke-width="1.5" fill="none" stroke-linecap="round"/><g fill="#a89d5a" stroke="#6b5e30" stroke-width="0.6"><ellipse cx="22" cy="46" rx="2" ry="2.5" transform="rotate(-30 22 46)"/><ellipse cx="24" cy="40" rx="2" ry="2.5" transform="rotate(20 24 40)"/><ellipse cx="26" cy="32" rx="2" ry="2.5" transform="rotate(-20 26 32)"/><ellipse cx="28" cy="24" rx="2" ry="2.5" transform="rotate(15 28 24)"/><ellipse cx="30" cy="16" rx="2" ry="2.5" transform="rotate(-10 30 16)"/><ellipse cx="42" cy="46" rx="2" ry="2.5" transform="rotate(30 42 46)"/><ellipse cx="40" cy="40" rx="2" ry="2.5" transform="rotate(-20 40 40)"/><ellipse cx="38" cy="32" rx="2" ry="2.5" transform="rotate(20 38 32)"/><ellipse cx="36" cy="24" rx="2" ry="2.5" transform="rotate(-15 36 24)"/><ellipse cx="34" cy="16" rx="2" ry="2.5" transform="rotate(10 34 16)"/><ellipse cx="28" cy="44" rx="1.5" ry="2" transform="rotate(20 28 44)"/><ellipse cx="30" cy="36" rx="1.5" ry="2" transform="rotate(-15 30 36)"/><ellipse cx="34" cy="36" rx="1.5" ry="2" transform="rotate(20 34 36)"/><ellipse cx="36" cy="44" rx="1.5" ry="2" transform="rotate(-25 36 44)"/></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M16 20 Q14 16 18 14 Q32 12 46 14 Q50 16 48 20 L50 54 Q50 56 48 56 L16 56 Q14 56 14 54 Z" fill="#f5f1e5" stroke="#a89778" stroke-width="1.3"/><path d="M18 14 Q32 16 46 14" stroke="#a89778" stroke-width="1" fill="none"/><rect x="22" y="28" width="20" height="18" rx="1" fill="#fff" stroke="#c9c3b3" stroke-width="0.6"/><text x="32" y="36" text-anchor="middle" font-family="sans-serif" font-size="4.5" fill="#8b6a3f" font-weight="bold">MĄKA</text><g fill="#b89058" opacity="0.7"><path d="M28 40 Q30 38 32 40 Q30 42 32 44 Q30 46 28 44 Z"/><path d="M34 40 Q36 38 38 40 Q36 42 38 44 Q36 46 34 44 Z"/></g><ellipse cx="20" cy="22" rx="3" ry="1" fill="#fff" opacity="0.6"/></svg>

After

Width:  |  Height:  |  Size: 741 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g transform="rotate(-15 32 32)"><rect x="16" y="18" width="10" height="28" rx="3" fill="#f0c878" stroke="#b89040" stroke-width="1.2"/><rect x="16" y="18" width="10" height="28" rx="3" fill="url(#g1)" opacity="0.3"/><line x1="18" y1="20" x2="18" y2="44" stroke="#b89040" stroke-width="0.5"/><line x1="21" y1="20" x2="21" y2="44" stroke="#b89040" stroke-width="0.5"/><line x1="24" y1="20" x2="24" y2="44" stroke="#b89040" stroke-width="0.5"/></g><g transform="rotate(20 32 32)"><rect x="32" y="22" width="10" height="26" rx="3" fill="#f4d898" stroke="#b89040" stroke-width="1.2"/><line x1="34" y1="24" x2="34" y2="46" stroke="#b89040" stroke-width="0.5"/><line x1="37" y1="24" x2="37" y2="46" stroke="#b89040" stroke-width="0.5"/><line x1="40" y1="24" x2="40" y2="46" stroke="#b89040" stroke-width="0.5"/></g><g transform="rotate(50 32 32)"><rect x="28" y="30" width="10" height="24" rx="3" fill="#f0c878" stroke="#b89040" stroke-width="1.2"/><line x1="30" y1="32" x2="30" y2="52" stroke="#b89040" stroke-width="0.5"/><line x1="33" y1="32" x2="33" y2="52" stroke="#b89040" stroke-width="0.5"/><line x1="36" y1="32" x2="36" y2="52" stroke="#b89040" stroke-width="0.5"/></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g transform="rotate(-15 32 32)"><path d="M32 18 Q28 18 26 22 L22 52 Q22 56 26 56 Q32 58 38 56 Q42 56 42 52 L38 22 Q36 18 32 18 Z" fill="#ef8a3d" stroke="#b55a1e" stroke-width="1.3"/><path d="M26 22 Q28 22 32 22 Q36 22 38 22 L36 26 L28 26 Z" fill="#f7a866" opacity="0.7"/><g stroke="#b55a1e" stroke-width="0.6" fill="none" opacity="0.7"><path d="M27 30 L37 30"/><path d="M27 38 L37 38"/><path d="M28 46 L36 46"/></g><g fill="#5fa347"><path d="M28 18 L24 8 L30 14 L30 6 L34 14 L38 8 L34 18 Z"/><path d="M32 14 L28 4 L32 10 L34 4 L34 14 Z" opacity="0.8"/></g></g></svg>

After

Width:  |  Height:  |  Size: 628 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect x="22" y="8" width="20" height="6" rx="1" fill="#6b4a28"/><path d="M14 16 L50 16 L48 54 Q48 56 46 56 L18 56 Q16 56 16 54 Z" fill="#c79968" stroke="#6b4a28" stroke-width="1.3"/><rect x="14" y="16" width="36" height="4" fill="#8b6a3f" opacity="0.7"/><rect x="20" y="24" width="24" height="22" rx="1" fill="#f5f1e5" stroke="#c9c3b3" stroke-width="0.8"/><path d="M22 28 Q26 26 32 30 Q38 26 42 28 L42 42 Q38 44 32 40 Q26 44 22 42 Z" fill="#a6743a"/><path d="M24 32 Q28 30 32 34 Q36 30 40 32 L40 38 Q36 40 32 36 Q28 40 24 38 Z" fill="#c99866" opacity="0.7"/><text x="32" y="44" text-anchor="middle" font-family="sans-serif" font-size="3" fill="#4a2c10" font-weight="bold">PEANUT</text><ellipse cx="18" cy="22" rx="2" ry="16" fill="#fff" opacity="0.2"/></svg>

After

Width:  |  Height:  |  Size: 819 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g transform="rotate(-15 22 32)"><path d="M22 18 Q28 18 28 32 Q28 46 22 46 Q16 46 16 32 Q16 18 22 18 Z" fill="#d8ad7a" stroke="#8b6a3f" stroke-width="1.2"/><path d="M22 20 Q26 24 26 32 Q26 42 22 44" fill="#e8c497" opacity="0.7"/><path d="M22 22 L22 42" stroke="#8b6a3f" stroke-width="0.5" opacity="0.6"/></g><g transform="rotate(20 42 32)"><path d="M42 18 Q48 18 48 32 Q48 46 42 46 Q36 46 36 32 Q36 18 42 18 Z" fill="#c99866" stroke="#8b6a3f" stroke-width="1.2"/><path d="M42 20 Q46 24 46 32 Q46 42 42 44" fill="#d8ad7a" opacity="0.7"/><path d="M42 22 L42 42" stroke="#8b6a3f" stroke-width="0.5" opacity="0.6"/></g><g transform="rotate(60 32 44)"><path d="M32 36 Q38 36 38 44 Q38 52 32 52 Q26 52 26 44 Q26 36 32 36 Z" fill="#d8ad7a" stroke="#8b6a3f" stroke-width="1.2"/><path d="M32 38 L32 50" stroke="#8b6a3f" stroke-width="0.5" opacity="0.6"/></g></svg>

After

Width:  |  Height:  |  Size: 916 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect x="22" y="8" width="20" height="6" rx="1" fill="#8b6f47"/><path d="M14 16 L50 16 L48 54 Q48 56 46 56 L18 56 Q16 56 16 54 Z" fill="#f4c542" stroke="#b89020" stroke-width="1.3"/><rect x="14" y="16" width="36" height="4" fill="#d8a828" opacity="0.7"/><rect x="20" y="24" width="24" height="20" rx="1" fill="#f5f1e5" stroke="#c9c3b3" stroke-width="0.8"/><g fill="#b89020"><path d="M26 30 Q28 28 30 30 Q28 32 30 34 Q28 36 26 34 Z"/><path d="M34 30 Q36 28 38 30 Q36 32 38 34 Q36 36 34 34 Z"/><path d="M30 36 Q32 34 34 36 Q32 38 34 40 Q32 42 30 40 Z"/></g><text x="32" y="44" text-anchor="middle" font-family="sans-serif" font-size="3.5" fill="#b89020" font-weight="bold">MIÓD</text><ellipse cx="18" cy="22" rx="2" ry="14" fill="#fff" opacity="0.2"/></svg>

After

Width:  |  Height:  |  Size: 817 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M22 14 L42 14 L42 20 L46 26 L46 56 Q46 58 44 58 L20 58 Q18 58 18 56 L18 26 L22 20 Z" fill="#f0ebdd" stroke="#9e9888" stroke-width="1.3" stroke-linejoin="round"/><path d="M22 14 L22 20 L18 26" fill="none" stroke="#9e9888" stroke-width="1"/><path d="M42 14 L42 20 L46 26" fill="none" stroke="#9e9888" stroke-width="1"/><rect x="24" y="32" width="16" height="18" rx="1" fill="#fff" stroke="#c9c3b3" stroke-width="0.8"/><text x="32" y="42" text-anchor="middle" font-family="sans-serif" font-size="5" fill="#3a7ca5" font-weight="bold">MLEKO</text><circle cx="32" cy="46" r="2.5" fill="#fefcf4" stroke="#3a7ca5" stroke-width="0.6"/><rect x="20" y="18" width="3" height="30" fill="#fff" opacity="0.2"/></svg>

After

Width:  |  Height:  |  Size: 771 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><ellipse cx="32" cy="52" rx="20" ry="4" fill="#e8e2d2" opacity="0.6"/><circle cx="32" cy="34" r="18" fill="#fefcf4" stroke="#d4cfc0" stroke-width="1.3"/><ellipse cx="26" cy="28" rx="7" ry="4" fill="#fff" opacity="0.9"/><ellipse cx="38" cy="38" rx="3" ry="2" fill="#fff" opacity="0.6"/><circle cx="24" cy="40" r="1" fill="#e8e2d2" opacity="0.6"/></svg>

After

Width:  |  Height:  |  Size: 412 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g fill="#3d2a18" stroke="#1a1008" stroke-width="0.8"><ellipse cx="22" cy="24" rx="3" ry="5" transform="rotate(20 22 24)"/><ellipse cx="32" cy="22" rx="3" ry="5" transform="rotate(-10 32 22)"/><ellipse cx="42" cy="26" rx="3" ry="5" transform="rotate(30 42 26)"/><ellipse cx="18" cy="34" rx="3" ry="5" transform="rotate(-20 18 34)"/><ellipse cx="28" cy="34" rx="3" ry="5" transform="rotate(15 28 34)"/><ellipse cx="38" cy="36" rx="3" ry="5" transform="rotate(-30 38 36)"/><ellipse cx="48" cy="34" rx="3" ry="5" transform="rotate(10 48 34)"/><ellipse cx="22" cy="44" rx="3" ry="5" transform="rotate(40 22 44)"/><ellipse cx="32" cy="46" rx="3" ry="5" transform="rotate(-15 32 46)"/><ellipse cx="42" cy="46" rx="3" ry="5" transform="rotate(25 42 46)"/></g><g stroke="#8a7a4a" stroke-width="0.5" fill="none"><path d="M22 22 L22 26"/><path d="M32 20 L32 24"/><path d="M42 24 L42 28"/><path d="M18 32 L18 36"/><path d="M28 32 L28 36"/><path d="M38 34 L38 38"/><path d="M48 32 L48 36"/><path d="M22 42 L22 46"/><path d="M32 44 L32 48"/><path d="M42 44 L42 48"/></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M32 54 L32 30" stroke="#3d6825" stroke-width="1.5" stroke-linecap="round"/><path d="M32 40 L24 36" stroke="#3d6825" stroke-width="1" stroke-linecap="round"/><path d="M32 40 L40 36" stroke="#3d6825" stroke-width="1" stroke-linecap="round"/><g fill="#3d7a2d" stroke="#2d5a20" stroke-width="0.8"><path d="M20 20 Q14 14 12 20 Q12 26 18 26 Q18 20 20 20 Z"/><path d="M20 20 Q18 12 24 10 Q28 14 24 20 Q22 22 20 20 Z"/><path d="M24 26 Q20 32 14 30 Q14 24 20 24 Q22 24 24 26 Z"/><path d="M44 20 Q50 14 52 20 Q52 26 46 26 Q46 20 44 20 Z"/><path d="M44 20 Q46 12 40 10 Q36 14 40 20 Q42 22 44 20 Z"/><path d="M40 26 Q44 32 50 30 Q50 24 44 24 Q42 24 40 26 Z"/><path d="M32 18 Q28 10 32 6 Q36 10 32 18 Z"/><path d="M32 18 Q36 22 40 20 Q38 14 32 18 Z"/><path d="M32 18 Q28 22 24 20 Q26 14 32 18 Z"/></g><g fill="#5fa347" opacity="0.7"><ellipse cx="16" cy="20" rx="2" ry="2"/><ellipse cx="48" cy="20" rx="2" ry="2"/><ellipse cx="32" cy="14" rx="2" ry="2"/></g></svg>

After

Width:  |  Height:  |  Size: 1020 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g transform="rotate(-30 32 32)"><rect x="18" y="22" width="28" height="20" rx="10" fill="#8cb85c" stroke="#5f8c3d" stroke-width="1.5"/><circle cx="24" cy="32" r="1.2" fill="#5f8c3d"/><circle cx="30" cy="30" r="1.2" fill="#5f8c3d"/><circle cx="30" cy="34" r="1.2" fill="#5f8c3d"/><circle cx="36" cy="32" r="1.2" fill="#5f8c3d"/><circle cx="40" cy="30" r="1.2" fill="#5f8c3d"/><circle cx="40" cy="34" r="1.2" fill="#5f8c3d"/><path d="M19 26 Q22 28 19 30" stroke="#5f8c3d" stroke-width="1" fill="none" stroke-linecap="round"/><ellipse cx="24" cy="26" rx="6" ry="1" fill="#aed37a" opacity="0.6"/></g></svg>

After

Width:  |  Height:  |  Size: 664 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect x="28" y="6" width="8" height="4" rx="1" fill="#8b6f47"/><rect x="29" y="10" width="6" height="6" fill="#d4c4a0" stroke="#b0a080" stroke-width="0.8"/><path d="M22 18 Q22 16 24 16 L40 16 Q42 16 42 18 L42 22 L46 28 L46 54 Q46 56 44 56 L20 56 Q18 56 18 54 L18 28 L22 22 Z" fill="#f4c542" stroke="#b89020" stroke-width="1.3" stroke-linejoin="round"/><path d="M22 18 L22 22 L18 28" fill="none" stroke="#b89020" stroke-width="1"/><path d="M42 18 L42 22 L46 28" fill="none" stroke="#b89020" stroke-width="1"/><rect x="24" y="34" width="16" height="16" rx="1" fill="#fffaf0" stroke="#c9c3b3" stroke-width="0.6"/><text x="32" y="42" text-anchor="middle" font-family="sans-serif" font-size="4" fill="#b55a1e" font-weight="bold">OLEJ</text><path d="M28 46 Q30 44 32 46 Q34 44 36 46" stroke="#b89020" stroke-width="0.8" fill="none"/><rect x="20" y="20" width="3" height="32" fill="#fff" opacity="0.25"/></svg>

After

Width:  |  Height:  |  Size: 964 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect x="28" y="6" width="8" height="4" rx="1" fill="#2d4a2a"/><rect x="29" y="10" width="6" height="6" fill="#5a7a45" stroke="#3d6825" stroke-width="0.8"/><path d="M22 18 Q22 16 24 16 L40 16 Q42 16 42 18 L42 22 L46 28 L46 54 Q46 56 44 56 L20 56 Q18 56 18 54 L18 28 L22 22 Z" fill="#5fa347" stroke="#3d6825" stroke-width="1.3" stroke-linejoin="round"/><path d="M22 18 L22 22 L18 28" fill="none" stroke="#3d6825" stroke-width="1"/><path d="M42 18 L42 22 L46 28" fill="none" stroke="#3d6825" stroke-width="1"/><rect x="24" y="34" width="16" height="16" rx="1" fill="#f5f1e5" stroke="#c9c3b3" stroke-width="0.6"/><ellipse cx="28" cy="42" rx="2" ry="3" fill="#5fa347"/><ellipse cx="33" cy="40" rx="1.5" ry="2" fill="#3d6825"/><ellipse cx="36" cy="44" rx="1.8" ry="2.5" fill="#5fa347"/><rect x="20" y="20" width="3" height="32" fill="#fff" opacity="0.2"/></svg>

After

Width:  |  Height:  |  Size: 917 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><circle cx="32" cy="36" r="18" fill="#a6743a" stroke="#6b4a28" stroke-width="1.3"/><circle cx="32" cy="36" r="14" fill="#c7a074" opacity="0.7"/><path d="M32 16 L30 14 L32 12 L34 14 Z" fill="#6b4a28"/><path d="M32 18 Q28 20 32 24 Q36 20 32 18 Z" fill="#8b6a3f"/><g stroke="#6b4a28" stroke-width="0.6" fill="none" opacity="0.6"><path d="M22 32 Q24 36 22 40"/><path d="M42 32 Q40 36 42 40"/><path d="M28 44 Q32 46 36 44"/></g><circle cx="32" cy="42" r="1" fill="#6b4a28"/><ellipse cx="26" cy="32" rx="3" ry="5" fill="#fff" opacity="0.25"/></svg>

After

Width:  |  Height:  |  Size: 603 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M14 28 Q14 18 24 18 Q32 18 36 24 Q40 18 48 20 Q56 24 54 34 Q52 44 44 46 Q34 48 30 42 Q26 48 18 46 Q12 40 14 28 Z" fill="#f0c878" stroke="#a67830" stroke-width="1.3"/><path d="M16 30 Q18 22 24 22 Q30 22 32 26 Q34 24 38 24 Q44 22 48 26 Q52 32 48 38 Q40 42 34 38 Q32 42 28 42 Q18 40 16 30 Z" fill="#f7d898" opacity="0.6"/><g stroke="#a67830" stroke-width="0.5" fill="none" opacity="0.6"><path d="M20 30 Q22 34 20 38"/><path d="M30 32 Q28 34 30 38"/><path d="M40 30 Q42 34 40 38"/></g><ellipse cx="22" cy="26" rx="4" ry="2" fill="#fff" opacity="0.3"/></svg>

After

Width:  |  Height:  |  Size: 623 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g transform="rotate(-10 32 32)"><ellipse cx="32" cy="32" rx="16" ry="12" fill="#8b5e28" stroke="#4a2c10" stroke-width="1.3"/><ellipse cx="32" cy="32" rx="13" ry="9" fill="#a67038" opacity="0.7"/><path d="M32 20 L32 44" stroke="#4a2c10" stroke-width="1"/><g stroke="#4a2c10" stroke-width="0.6" fill="none" opacity="0.8"><path d="M20 26 Q22 30 20 34 Q22 38 20 42"/><path d="M44 26 Q42 30 44 34 Q42 38 44 42"/><path d="M28 22 Q26 28 28 32 Q26 38 28 42"/><path d="M36 22 Q38 28 36 32 Q38 38 36 42"/></g><ellipse cx="26" cy="28" rx="3" ry="2" fill="#fff" opacity="0.3"/></g></svg>

After

Width:  |  Height:  |  Size: 637 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M14 32 Q14 18 32 16 Q50 18 50 32 Q50 46 32 48 Q14 46 14 32 Z" fill="#c7a074" stroke="#6b4a28" stroke-width="1.3"/><path d="M32 14 L32 50" stroke="#6b4a28" stroke-width="1"/><g stroke="#6b4a28" stroke-width="0.8" fill="none" opacity="0.7"><path d="M22 22 Q26 24 22 28 Q26 30 22 34 Q26 36 22 40"/><path d="M42 22 Q38 24 42 28 Q38 30 42 34 Q38 36 42 40"/><path d="M18 26 Q22 28 18 32 Q22 34 18 38"/><path d="M46 26 Q42 28 46 32 Q42 34 46 38"/><path d="M28 20 Q26 24 28 28 Q26 32 28 36 Q26 40 28 44"/><path d="M36 20 Q38 24 36 28 Q38 32 36 36 Q38 40 36 44"/></g><path d="M20 28 Q24 30 20 34" stroke="#6b4a28" stroke-width="0.6" fill="none"/><ellipse cx="24" cy="24" rx="4" ry="2" fill="#fff" opacity="0.2"/></svg>

After

Width:  |  Height:  |  Size: 779 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g transform="rotate(25 32 32)"><path d="M28 12 L28 16 Q28 18 30 18 L34 18 Q36 18 36 16 L36 12" fill="#5fa347" stroke="#3d7a2d" stroke-width="1.2"/><path d="M30 10 L34 10" stroke="#3d7a2d" stroke-width="1" stroke-linecap="round"/><path d="M24 18 Q26 20 32 22 Q38 26 36 38 Q32 48 30 52 Q28 54 26 52 Q20 42 22 30 Q22 22 24 18 Z" fill="#c8253a" stroke="#8a1a28" stroke-width="1.3"/><path d="M26 22 Q30 24 32 30 Q32 40 30 46" fill="#e63946" opacity="0.6"/><ellipse cx="26" cy="30" rx="2" ry="8" fill="#fff" opacity="0.3"/></g></svg>

After

Width:  |  Height:  |  Size: 589 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M18 26 Q18 20 24 18 Q32 16 40 18 Q46 20 48 26 Q50 38 46 46 Q42 54 32 54 Q20 54 16 44 Q14 34 18 26 Z" fill="#e63946" stroke="#a82838" stroke-width="1.3"/><path d="M24 28 Q32 24 40 28 Q42 36 40 42 Q36 48 32 48 Q26 46 24 40 Q22 32 24 28 Z" fill="#ef5666" opacity="0.6"/><path d="M28 18 L28 14 Q28 12 30 12 L34 12 Q36 12 36 14 L36 18" fill="#5fa347" stroke="#3d7a2d" stroke-width="1.2"/><path d="M30 14 L34 14" stroke="#3d7a2d" stroke-width="0.8"/><ellipse cx="24" cy="32" rx="2" ry="8" fill="#fff" opacity="0.25"/></svg>

After

Width:  |  Height:  |  Size: 587 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M18 24 Q28 14 44 18 Q54 24 52 38 Q48 50 32 52 Q16 48 14 36 Q14 28 18 24 Z" fill="#f8d4bc" stroke="#c58a6a" stroke-width="1.3"/><path d="M22 26 Q30 20 42 24 Q48 30 46 38 Q42 46 32 46 Q22 42 20 34 Q20 28 22 26 Z" fill="#fce2cf" opacity="0.8"/><g stroke="#d19878" stroke-width="0.8" fill="none" opacity="0.6"><path d="M24 30 Q32 28 40 32"/><path d="M22 36 Q30 34 42 36"/><path d="M26 42 Q32 42 38 40"/></g><ellipse cx="26" cy="28" rx="5" ry="2" fill="#fff" opacity="0.4"/></svg>

After

Width:  |  Height:  |  Size: 545 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g transform="rotate(-10 32 32)"><path d="M32 18 Q28 18 26 22 L22 52 Q22 56 26 56 Q32 58 38 56 Q42 56 42 52 L38 22 Q36 18 32 18 Z" fill="#f0ebdd" stroke="#a89778" stroke-width="1.3"/><path d="M26 22 Q28 22 32 22 Q36 22 38 22 L36 26 L28 26 Z" fill="#fff" opacity="0.6"/><g stroke="#a89778" stroke-width="0.6" fill="none" opacity="0.7"><path d="M27 30 L37 30"/><path d="M27 38 L37 38"/><path d="M28 46 L36 46"/></g><g fill="#5fa347"><path d="M28 18 L24 6 L28 12 L28 4 L32 12 L36 6 L34 16 Z"/><path d="M36 18 L34 10 L38 14 L38 6 L40 14 L42 10 L40 18 Z" opacity="0.85"/></g></g></svg>

After

Width:  |  Height:  |  Size: 641 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g fill="#e8c497" stroke="#8b6a3f" stroke-width="0.8"><ellipse cx="20" cy="22" rx="5" ry="2.5" transform="rotate(-20 20 22)"/><ellipse cx="32" cy="20" rx="5" ry="2.5" transform="rotate(15 32 20)"/><ellipse cx="44" cy="22" rx="5" ry="2.5" transform="rotate(-10 44 22)"/><ellipse cx="18" cy="32" rx="5" ry="2.5" transform="rotate(25 18 32)"/><ellipse cx="30" cy="30" rx="5" ry="2.5" transform="rotate(-30 30 30)"/><ellipse cx="42" cy="32" rx="5" ry="2.5" transform="rotate(10 42 32)"/><ellipse cx="22" cy="42" rx="5" ry="2.5" transform="rotate(-15 22 42)"/><ellipse cx="34" cy="40" rx="5" ry="2.5" transform="rotate(20 34 40)"/><ellipse cx="46" cy="42" rx="5" ry="2.5" transform="rotate(-25 46 42)"/><ellipse cx="20" cy="50" rx="5" ry="2.5" transform="rotate(30 20 50)"/><ellipse cx="32" cy="50" rx="5" ry="2.5" transform="rotate(-10 32 50)"/><ellipse cx="44" cy="50" rx="5" ry="2.5" transform="rotate(15 44 50)"/></g><g fill="#c7a074" opacity="0.6"><ellipse cx="20" cy="22" rx="2.5" ry="1" transform="rotate(-20 20 22)"/><ellipse cx="32" cy="30" rx="2.5" ry="1" transform="rotate(-30 32 30)"/><ellipse cx="46" cy="42" rx="2.5" ry="1" transform="rotate(-25 46 42)"/></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><circle cx="32" cy="36" r="22" fill="#e63946" stroke="#a82838" stroke-width="1.3"/><ellipse cx="26" cy="28" rx="7" ry="4" fill="#ef5666" opacity="0.7"/><path d="M32 14 Q30 10 26 12 Q28 14 28 16 Q30 14 32 18 Q34 14 36 16 Q36 14 38 12 Q34 10 32 14 Z" fill="#5fa347" stroke="#3d7a2d" stroke-width="1"/><path d="M32 18 L32 20" stroke="#3d7a2d" stroke-width="1" stroke-linecap="round"/><ellipse cx="22" cy="32" rx="3" ry="6" fill="#fff" opacity="0.2"/></svg>

After

Width:  |  Height:  |  Size: 514 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><circle cx="22" cy="42" r="11" fill="#e63946" stroke="#a82838" stroke-width="1.2"/><circle cx="42" cy="40" r="11" fill="#e63946" stroke="#a82838" stroke-width="1.2"/><circle cx="32" cy="28" r="10" fill="#ef5666" stroke="#a82838" stroke-width="1.2"/><ellipse cx="19" cy="38" rx="3" ry="2" fill="#ef5666" opacity="0.6"/><ellipse cx="39" cy="36" rx="3" ry="2" fill="#fff" opacity="0.3"/><ellipse cx="29" cy="24" rx="3" ry="2" fill="#fff" opacity="0.4"/><path d="M32 18 Q28 14 24 16 Q30 18 32 22 Q34 18 40 16 Q36 14 32 18 Z" fill="#5fa347"/><path d="M22 32 Q18 28 14 30 Q20 32 22 36 Q24 32 30 30 Q26 28 22 32 Z" fill="#5fa347" opacity="0.85"/><path d="M42 30 Q38 26 34 28 Q40 30 42 34 Q44 30 50 28 Q46 26 42 30 Z" fill="#5fa347" opacity="0.85"/></svg>

After

Width:  |  Height:  |  Size: 808 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M14 20 L50 20 L46 54 Q46 56 44 56 L20 56 Q18 56 18 54 Z" fill="#fefcf4" stroke="#c9c3b3" stroke-width="1.3"/><ellipse cx="32" cy="20" rx="18" ry="4" fill="#f5f1e5" stroke="#c9c3b3" stroke-width="1.3"/><ellipse cx="32" cy="20" rx="15" ry="2.5" fill="#fff"/><ellipse cx="24" cy="30" rx="5" ry="2" fill="#fff" opacity="0.7"/><path d="M22 38 Q28 40 32 38" stroke="#c9c3b3" stroke-width="0.8" fill="none" opacity="0.5"/></svg>

After

Width:  |  Height:  |  Size: 491 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M32 54 L32 10" stroke="#8b6a3f" stroke-width="1.5" stroke-linecap="round"/><path d="M32 20 L22 14" stroke="#8b6a3f" stroke-width="1" stroke-linecap="round"/><path d="M32 28 L42 22" stroke="#8b6a3f" stroke-width="1" stroke-linecap="round"/><path d="M32 38 L20 32" stroke="#8b6a3f" stroke-width="1" stroke-linecap="round"/><path d="M32 46 L44 42" stroke="#8b6a3f" stroke-width="1" stroke-linecap="round"/><g fill="#ef8a3d" stroke="#b55a1e" stroke-width="0.6"><circle cx="22" cy="14" r="3"/><circle cx="26" cy="18" r="2.5"/><circle cx="42" cy="22" r="3"/><circle cx="38" cy="26" r="2.5"/><circle cx="20" cy="32" r="3"/><circle cx="24" cy="36" r="2.5"/><circle cx="44" cy="42" r="3"/><circle cx="40" cy="40" r="2.5"/><circle cx="32" cy="20" r="2.5"/><circle cx="32" cy="30" r="2.5"/><circle cx="32" cy="40" r="2.5"/><circle cx="32" cy="48" r="2.5"/></g><g fill="#5fa347" opacity="0.85"><path d="M28 12 L24 8 L30 10 Z"/><path d="M38 20 L42 16 L40 22 Z"/><path d="M26 30 L22 26 L28 28 Z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><circle cx="32" cy="36" r="22" fill="#e8dcc0" stroke="#9e8868" stroke-width="1.3"/><circle cx="32" cy="36" r="18" fill="#f0e4c8" opacity="0.6"/><g stroke="#9e8868" stroke-width="0.7" fill="none" opacity="0.7"><path d="M14 28 Q20 26 24 30"/><path d="M38 26 Q46 28 50 34"/><path d="M14 44 Q22 48 28 44"/><path d="M38 46 Q46 44 50 40"/><path d="M18 36 Q24 38 26 34"/><path d="M42 36 Q46 32 48 36"/></g><path d="M32 14 Q28 10 26 12 Q28 14 28 16 Q30 14 32 18 Q34 14 36 16 Q36 14 38 12 Q34 10 32 14 Z" fill="#5fa347"/><path d="M32 18 L32 20" stroke="#3d7a2d" stroke-width="1" stroke-linecap="round"/><ellipse cx="22" cy="30" rx="3" ry="6" fill="#fff" opacity="0.3"/></svg>

After

Width:  |  Height:  |  Size: 727 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M14 22 L50 22 L47 52 Q47 54 45 54 L19 54 Q17 54 17 52 Z" fill="#fefcf4" stroke="#c9c3b3" stroke-width="1.3"/><ellipse cx="32" cy="22" rx="18" ry="4" fill="#f5f1e5" stroke="#c9c3b3" stroke-width="1.3"/><path d="M18 28 Q32 22 46 28 L46 34 Q32 30 18 34 Z" fill="#8bbf4f" opacity="0.3"/><rect x="24" y="36" width="16" height="10" rx="1" fill="#fff" stroke="#d4cfc0" stroke-width="0.6"/><text x="32" y="43" text-anchor="middle" font-family="sans-serif" font-size="4" fill="#7a9a5b" font-weight="bold">SEREK</text><ellipse cx="22" cy="26" rx="8" ry="1.5" fill="#fff" opacity="0.6"/></svg>

After

Width:  |  Height:  |  Size: 652 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M12 22 L52 22 L48 54 Q48 56 46 56 L18 56 Q16 56 16 54 Z" fill="#fefcf4" stroke="#b8b2a2" stroke-width="1.3"/><ellipse cx="32" cy="22" rx="20" ry="4" fill="#f0ebdd" stroke="#b8b2a2" stroke-width="1.3"/><g fill="#fff"><circle cx="22" cy="30" r="2"/><circle cx="28" cy="28" r="1.5"/><circle cx="34" cy="30" r="2.2"/><circle cx="40" cy="29" r="1.8"/><circle cx="25" cy="35" r="1.8"/><circle cx="32" cy="36" r="2"/><circle cx="38" cy="35" r="1.5"/><circle cx="43" cy="36" r="1.2"/><circle cx="22" cy="42" r="1.5"/><circle cx="28" cy="43" r="2"/><circle cx="35" cy="42" r="1.5"/><circle cx="40" cy="43" r="1.8"/><circle cx="30" cy="48" r="1.3"/><circle cx="36" cy="48" r="1.2"/></g><g fill="#e8e2d2" opacity="0.6"><circle cx="26" cy="33" r="0.6"/><circle cx="36" cy="39" r="0.6"/><circle cx="22" cy="46" r="0.6"/></g></svg>

After

Width:  |  Height:  |  Size: 887 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g fill="#f0e4c8" stroke="#a89778" stroke-width="0.6"><ellipse cx="18" cy="22" rx="2" ry="3.5" transform="rotate(20 18 22)"/><ellipse cx="26" cy="20" rx="2" ry="3.5" transform="rotate(-15 26 20)"/><ellipse cx="34" cy="22" rx="2" ry="3.5" transform="rotate(30 34 22)"/><ellipse cx="42" cy="20" rx="2" ry="3.5" transform="rotate(-25 42 20)"/><ellipse cx="48" cy="24" rx="2" ry="3.5" transform="rotate(15 48 24)"/><ellipse cx="16" cy="30" rx="2" ry="3.5" transform="rotate(-30 16 30)"/><ellipse cx="22" cy="30" rx="2" ry="3.5" transform="rotate(10 22 30)"/><ellipse cx="30" cy="30" rx="2" ry="3.5" transform="rotate(-20 30 30)"/><ellipse cx="38" cy="30" rx="2" ry="3.5" transform="rotate(25 38 30)"/><ellipse cx="46" cy="30" rx="2" ry="3.5" transform="rotate(-10 46 30)"/><ellipse cx="20" cy="38" rx="2" ry="3.5" transform="rotate(35 20 38)"/><ellipse cx="28" cy="40" rx="2" ry="3.5" transform="rotate(-15 28 40)"/><ellipse cx="36" cy="38" rx="2" ry="3.5" transform="rotate(20 36 38)"/><ellipse cx="44" cy="40" rx="2" ry="3.5" transform="rotate(-25 44 40)"/><ellipse cx="18" cy="46" rx="2" ry="3.5" transform="rotate(15 18 46)"/><ellipse cx="26" cy="48" rx="2" ry="3.5" transform="rotate(-20 26 48)"/><ellipse cx="34" cy="46" rx="2" ry="3.5" transform="rotate(30 34 46)"/><ellipse cx="42" cy="48" rx="2" ry="3.5" transform="rotate(-10 42 48)"/><ellipse cx="48" cy="44" rx="2" ry="3.5" transform="rotate(25 48 44)"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M14 20 L50 20 L47 54 Q47 56 45 56 L19 56 Q17 56 17 54 Z" fill="#fefcf4" stroke="#c9c3b3" stroke-width="1.3"/><ellipse cx="32" cy="20" rx="18" ry="4" fill="#f5f1e5" stroke="#c9c3b3" stroke-width="1.3"/><ellipse cx="32" cy="20" rx="15" ry="2.5" fill="#fff"/><rect x="22" y="28" width="20" height="14" rx="1" fill="#fff" stroke="#c9c3b3" stroke-width="0.8"/><text x="32" y="35" text-anchor="middle" font-family="sans-serif" font-size="4.5" fill="#2d1a0f" font-weight="bold">SKYR</text><rect x="24" y="37" width="16" height="3" fill="#4a2c1a" opacity="0.8"/><ellipse cx="24" cy="46" rx="4" ry="1" fill="#fff" opacity="0.6"/></svg>

After

Width:  |  Height:  |  Size: 696 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect x="26" y="8" width="12" height="6" rx="1" fill="#8b6f47"/><rect x="28" y="13" width="8" height="6" fill="#d4c4a0" stroke="#b0a080" stroke-width="1"/><path d="M20 20 Q20 18 22 18 L42 18 Q44 18 44 20 L44 52 Q44 56 40 56 L24 56 Q20 56 20 52 Z" fill="#4a2c1a" stroke="#2d1a0f" stroke-width="1.5"/><rect x="23" y="32" width="18" height="14" rx="1" fill="#f5f1e8"/><line x1="26" y1="36" x2="38" y2="36" stroke="#4a2c1a" stroke-width="1" stroke-linecap="round"/><line x1="26" y1="40" x2="35" y2="40" stroke="#8b6f47" stroke-width="0.8" stroke-linecap="round"/><line x1="26" y1="43" x2="36" y2="43" stroke="#8b6f47" stroke-width="0.8" stroke-linecap="round"/><rect x="22" y="22" width="2" height="26" rx="1" fill="#fff" opacity="0.2"/></svg>

After

Width:  |  Height:  |  Size: 800 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect x="28" y="8" width="8" height="4" rx="1" fill="#8b6f47"/><rect x="29" y="12" width="6" height="5" fill="#d4c4a0" stroke="#b0a080" stroke-width="0.8"/><path d="M22 18 Q22 16 24 16 L40 16 Q42 16 42 18 L44 26 L44 54 Q44 56 42 56 L22 56 Q20 56 20 54 L20 26 Z" fill="#c79968" stroke="#8b6a3f" stroke-width="1.3" stroke-linejoin="round"/><rect x="23" y="30" width="18" height="18" rx="1" fill="#f5f1e5" stroke="#c9c3b3" stroke-width="0.6"/><text x="32" y="37" text-anchor="middle" font-family="sans-serif" font-size="3.5" fill="#5fa347" font-weight="bold">AGAVE</text><g fill="#5fa347"><path d="M26 40 L28 38 L28 42 Z"/><path d="M30 40 L32 38 L32 42 Z"/><path d="M34 40 L36 38 L36 42 Z"/><path d="M38 40 L40 38 L40 42 Z"/></g><text x="32" y="46" text-anchor="middle" font-family="sans-serif" font-size="2.5" fill="#8b6a3f">SYRUP</text><rect x="22" y="20" width="2" height="32" fill="#fff" opacity="0.25"/></svg>

After

Width:  |  Height:  |  Size: 972 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g stroke="#5fa347" stroke-width="2" fill="none" stroke-linecap="round"><path d="M20 54 Q18 40 22 14"/><path d="M26 54 Q24 38 28 12"/><path d="M32 54 Q32 38 34 10"/><path d="M38 54 Q38 38 40 14"/><path d="M44 54 Q44 40 42 16"/></g><g stroke="#3d6825" stroke-width="0.8" fill="none" stroke-linecap="round" opacity="0.7"><path d="M20 54 Q18 40 22 14"/><path d="M32 54 Q32 38 34 10"/><path d="M44 54 Q44 40 42 16"/></g><g fill="#5fa347"><circle cx="22" cy="14" r="1.5"/><circle cx="28" cy="12" r="1.5"/><circle cx="34" cy="10" r="1.5"/><circle cx="40" cy="14" r="1.5"/><circle cx="42" cy="16" r="1.5"/></g></svg>

After

Width:  |  Height:  |  Size: 670 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M12 28 Q16 20 28 18 Q42 18 52 28 Q54 36 48 44 Q40 52 28 52 Q14 48 10 38 Q10 32 12 28 Z" fill="#d46670" stroke="#9e3a47" stroke-width="1.3"/><path d="M16 32 Q24 26 36 28 Q46 30 48 38 Q44 46 32 46 Q20 44 16 36 Z" fill="#e08891" opacity="0.7"/><g fill="#f5f1e5" opacity="0.85"><path d="M18 34 Q26 32 32 36 Q38 38 42 40" stroke="#f5f1e5" stroke-width="1.5" fill="none"/><path d="M16 40 Q24 38 30 42 Q36 44 40 44" stroke="#f5f1e5" stroke-width="1.2" fill="none"/><path d="M22 28 Q30 28 36 32" stroke="#f5f1e5" stroke-width="1" fill="none"/><path d="M26 46 Q32 46 38 44" stroke="#f5f1e5" stroke-width="0.8" fill="none"/></g></svg>

After

Width:  |  Height:  |  Size: 694 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><ellipse cx="34" cy="34" rx="20" ry="15" fill="#f2c4a8" stroke="#d19878" stroke-width="1.2"/><ellipse cx="30" cy="30" rx="20" ry="15" fill="#f8d4bc" stroke="#d19878" stroke-width="1.2"/><g fill="#e3a889" opacity="0.7"><ellipse cx="22" cy="26" rx="3" ry="1.5" transform="rotate(-20 22 26)"/><ellipse cx="36" cy="28" rx="2.5" ry="1.2" transform="rotate(15 36 28)"/><ellipse cx="28" cy="34" rx="4" ry="1.3" transform="rotate(-10 28 34)"/><ellipse cx="38" cy="34" rx="2" ry="1" transform="rotate(20 38 34)"/><ellipse cx="24" cy="38" rx="3" ry="1.2" transform="rotate(5 24 38)"/></g><ellipse cx="24" cy="24" rx="5" ry="2" fill="#fff" opacity="0.3"/></svg>

After

Width:  |  Height:  |  Size: 711 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M38 22 Q48 22 48 34 Q48 48 38 52 Q32 50 32 42 Q32 30 38 22 Z" fill="#c8253a"/><path d="M22 24 Q14 24 14 36 Q14 50 24 54 Q34 50 34 38 Q34 28 22 24 Z" fill="#e63946"/><g fill="#fff3b0"><ellipse cx="20" cy="32" rx="0.8" ry="1.3" transform="rotate(20 20 32)"/><ellipse cx="26" cy="34" rx="0.8" ry="1.3" transform="rotate(-10 26 34)"/><ellipse cx="18" cy="38" rx="0.8" ry="1.3" transform="rotate(25 18 38)"/><ellipse cx="24" cy="42" rx="0.8" ry="1.3" transform="rotate(-15 24 42)"/><ellipse cx="29" cy="40" rx="0.8" ry="1.3" transform="rotate(5 29 40)"/><ellipse cx="20" cy="46" rx="0.8" ry="1.3" transform="rotate(30 20 46)"/><ellipse cx="26" cy="48" rx="0.8" ry="1.3" transform="rotate(-5 26 48)"/></g><g fill="#fff3b0" opacity="0.9"><ellipse cx="40" cy="32" rx="0.7" ry="1.1"/><ellipse cx="44" cy="38" rx="0.7" ry="1.1"/><ellipse cx="41" cy="44" rx="0.7" ry="1.1"/></g><g fill="#5fa347"><path d="M22 24 L18 16 L22 20 L22 14 L26 20 L30 16 L26 24 Z"/><path d="M38 22 L34 15 L38 19 L38 13 L42 18 L46 14 L42 22 Z" opacity="0.85"/></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M32 56 Q30 42 28 30 Q26 18 24 10" stroke="#6b4a28" stroke-width="1.5" fill="none" stroke-linecap="round"/><path d="M32 56 Q34 42 36 30 Q38 18 40 10" stroke="#6b4a28" stroke-width="1.5" fill="none" stroke-linecap="round"/><g fill="#5fa347" stroke="#3d6825" stroke-width="0.6"><ellipse cx="22" cy="14" rx="1.5" ry="2.5" transform="rotate(-20 22 14)"/><ellipse cx="26" cy="18" rx="1.5" ry="2.5" transform="rotate(20 26 18)"/><ellipse cx="22" cy="22" rx="1.5" ry="2.5" transform="rotate(-30 22 22)"/><ellipse cx="26" cy="26" rx="1.5" ry="2.5" transform="rotate(15 26 26)"/><ellipse cx="24" cy="32" rx="1.5" ry="2.5" transform="rotate(-25 24 32)"/><ellipse cx="28" cy="36" rx="1.5" ry="2.5" transform="rotate(20 28 36)"/><ellipse cx="26" cy="42" rx="1.5" ry="2.5" transform="rotate(-15 26 42)"/><ellipse cx="30" cy="48" rx="1.5" ry="2.5" transform="rotate(25 30 48)"/><ellipse cx="42" cy="14" rx="1.5" ry="2.5" transform="rotate(20 42 14)"/><ellipse cx="38" cy="18" rx="1.5" ry="2.5" transform="rotate(-20 38 18)"/><ellipse cx="42" cy="22" rx="1.5" ry="2.5" transform="rotate(30 42 22)"/><ellipse cx="38" cy="26" rx="1.5" ry="2.5" transform="rotate(-15 38 26)"/><ellipse cx="40" cy="32" rx="1.5" ry="2.5" transform="rotate(25 40 32)"/><ellipse cx="36" cy="36" rx="1.5" ry="2.5" transform="rotate(-20 36 36)"/><ellipse cx="38" cy="42" rx="1.5" ry="2.5" transform="rotate(15 38 42)"/><ellipse cx="34" cy="48" rx="1.5" ry="2.5" transform="rotate(-25 34 48)"/></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M14 34 Q12 24 22 18 Q32 14 44 18 Q54 22 54 32 Q56 44 46 50 Q34 54 22 50 Q12 44 14 34 Z" fill="#c7a074" stroke="#8b6a3f" stroke-width="1.3"/><path d="M20 30 Q28 24 36 26 Q46 28 48 36 Q44 42 34 42 Q22 40 20 34 Z" fill="#d8b890" opacity="0.6"/><g fill="#8b6a3f" opacity="0.7"><ellipse cx="22" cy="28" rx="1.5" ry="1"/><ellipse cx="30" cy="38" rx="1.2" ry="0.8"/><ellipse cx="42" cy="32" rx="1.3" ry="0.8"/><ellipse cx="36" cy="44" rx="1" ry="0.8"/><ellipse cx="48" cy="40" rx="1.2" ry="0.8"/></g><g fill="#6b4a28" opacity="0.5"><circle cx="24" cy="34" r="0.6"/><circle cx="38" cy="30" r="0.6"/><circle cx="30" cy="44" r="0.6"/><circle cx="44" cy="46" r="0.6"/></g><ellipse cx="24" cy="26" rx="5" ry="2" fill="#fff" opacity="0.25"/></svg>

After

Width:  |  Height:  |  Size: 804 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -65,11 +65,11 @@ function renderAppBootError(message) {
if (!appContainer) return;
appContainer.innerHTML = `
<div class="flex h-full items-center justify-center px-6 text-center" style="background:#2d2e2b !important;">
<div class="max-w-[18rem] rounded-[1.5rem] border px-5 py-6" style="background:#2f2f2d !important; border-color:#444442 !important;">
<p class="text-sm font-semibold" style="color:#f2efe8 !important;">Nie udało się uruchomić aplikacji</p>
<p class="mt-2 text-xs leading-relaxed" style="color:#b7ada1 !important;">${message}</p>
<button type="button" onclick="window.location.reload()" class="mt-4 h-10 px-4 rounded-full border text-[12px] font-semibold" style="background:#23221e !important; border-color:#787876 !important; color:#f2efe8 !important;">Odśwież aplikację</button>
<div class="flex h-full items-center justify-center px-6 text-center" style="background:rgb(var(--app-bg-rgb)) !important;">
<div class="max-w-[18rem] rounded-[1.5rem] border px-5 py-6" style="background:rgb(var(--card-soft-rgb)) !important; border-color:rgb(var(--card-strong-rgb)) !important;">
<p class="text-sm font-semibold" style="color:rgb(var(--text-emphasis-rgb)) !important;">Nie udało się uruchomić aplikacji</p>
<p class="mt-2 text-xs leading-relaxed" style="color:rgb(var(--text-muted-rgb)) !important;">${message}</p>
<button type="button" onclick="window.location.reload()" class="mt-4 h-10 px-4 rounded-full border text-[12px] font-semibold" style="background:rgb(var(--sunken-rgb)) !important; border-color:rgb(var(--border-input-rgb)) !important; color:rgb(var(--text-emphasis-rgb)) !important;">Odśwież aplikację</button>
</div>
</div>
`;

View File

@@ -38,7 +38,6 @@ export const INGREDIENTS = {
/* ── Nabiał ───────────────────────────────────────── */
jajko: {
id: 'jajko',
image: 'images/ingredients/jajko.jpg',
name: 'Jajka',
category: 'nabial',
pantryUnit: 'szt',
@@ -85,6 +84,22 @@ export const INGREDIENTS = {
purchasePack: { amount: 150, label: 'opakowanie 150 g' },
nutritionPer100g: { kcal: 230, protein: 6, fat: 21, carbs: 4 },
},
mleko: {
id: 'mleko',
name: 'Mleko',
category: 'nabial',
pantryUnit: 'ml',
purchasePack: { amount: 1000, label: 'karton 1 l' },
nutritionPer100g: { kcal: 62, protein: 3.2, fat: 3.5, carbs: 4.7 },
},
skyr: {
id: 'skyr',
name: 'Skyr naturalny',
category: 'nabial',
pantryUnit: 'g',
purchasePack: { amount: 150, label: 'opakowanie 150 g' },
nutritionPer100g: { kcal: 63, protein: 11, fat: 0.2, carbs: 4 },
},
/* ── Mięso i ryby ─────────────────────────────────── */
szynka_parmenska: {
id: 'szynka_parmenska',
@@ -104,17 +119,23 @@ export const INGREDIENTS = {
},
losos_wedzony: {
id: 'losos_wedzony',
image: 'images/ingredients/losos_wedzony.jpg',
name: 'Łosoś wędzony',
category: 'mieso_ryby',
pantryUnit: 'g',
purchasePack: { amount: 100, label: 'opakowanie 100 g' },
nutritionPer100g: { kcal: 150, protein: 20, fat: 7, carbs: 0 },
},
piers_kurczaka: {
id: 'piers_kurczaka',
name: 'Pierś z kurczaka',
category: 'mieso_ryby',
pantryUnit: 'g',
purchasePack: { amount: 500, label: 'opakowanie ~500 g' },
nutritionPer100g: { kcal: 110, protein: 23, fat: 1.5, carbs: 0 },
},
/* ── Warzywa ──────────────────────────────────────── */
pomidor: {
id: 'pomidor',
image: 'images/ingredients/pomidor.jpg',
name: 'Pomidor',
category: 'warzywa',
pantryUnit: 'szt',
@@ -123,7 +144,6 @@ export const INGREDIENTS = {
},
pomidorki_koktajlowe: {
id: 'pomidorki_koktajlowe',
image: 'images/ingredients/pomidorki_koktajlowe.jpg',
name: 'Pomidorki koktajlowe',
category: 'warzywa',
pantryUnit: 'g',
@@ -132,7 +152,6 @@ export const INGREDIENTS = {
},
papryka_czerwona: {
id: 'papryka_czerwona',
image: 'images/ingredients/papryka_czerwona.jpg',
name: 'Papryka czerwona',
category: 'warzywa',
pantryUnit: 'szt',
@@ -141,7 +160,6 @@ export const INGREDIENTS = {
},
ogorek: {
id: 'ogorek',
image: 'images/ingredients/ogorek.jpg',
name: 'Ogórek',
category: 'warzywa',
pantryUnit: 'szt',
@@ -150,7 +168,6 @@ export const INGREDIENTS = {
},
czosnek: {
id: 'czosnek',
image: 'images/ingredients/czosnek.jpg',
name: 'Czosnek',
category: 'warzywa',
pantryUnit: 'szt',
@@ -164,10 +181,71 @@ export const INGREDIENTS = {
pantryUnit: 'g',
nutritionPer100g: { kcal: 28, protein: 3, fat: 0.6, carbs: 3.5 },
},
kapusta_biala: {
id: 'kapusta_biala',
name: 'Kapusta biała',
category: 'warzywa',
pantryUnit: 'g',
purchasePack: { amount: 800, label: 'główka ~800 g' },
nutritionPer100g: { kcal: 25, protein: 1.3, fat: 0.1, carbs: 6 },
},
marchewka: {
id: 'marchewka',
name: 'Marchewka',
category: 'warzywa',
pantryUnit: 'szt',
weightPerPiece: 80,
nutritionPer100g: { kcal: 41, protein: 0.9, fat: 0.2, carbs: 10 },
},
papryczka_chilli: {
id: 'papryczka_chilli',
name: 'Papryczka chilli',
category: 'warzywa',
pantryUnit: 'szt',
weightPerPiece: 15,
nutritionPer100g: { kcal: 40, protein: 2, fat: 0.4, carbs: 9 },
},
pietruszka_korzen: {
id: 'pietruszka_korzen',
name: 'Pietruszka (korzeń)',
category: 'warzywa',
pantryUnit: 'szt',
weightPerPiece: 80,
nutritionPer100g: { kcal: 54, protein: 2.9, fat: 0.6, carbs: 10 },
},
seler: {
id: 'seler',
name: 'Seler korzeniowy',
category: 'warzywa',
pantryUnit: 'g',
purchasePack: { amount: 500, label: 'bulwa ~500 g' },
nutritionPer100g: { kcal: 42, protein: 1.5, fat: 0.3, carbs: 9 },
},
cukinia: {
id: 'cukinia',
name: 'Cukinia',
category: 'warzywa',
pantryUnit: 'g',
purchasePack: { amount: 300, label: 'sztuka ~300 g' },
nutritionPer100g: { kcal: 17, protein: 1.2, fat: 0.3, carbs: 3.1 },
},
ziemniaki: {
id: 'ziemniaki',
name: 'Ziemniaki',
category: 'warzywa',
pantryUnit: 'g',
nutritionPer100g: { kcal: 77, protein: 2, fat: 0.1, carbs: 17 },
},
imbir: {
id: 'imbir',
name: 'Imbir',
category: 'warzywa',
pantryUnit: 'g',
nutritionPer100g: { kcal: 80, protein: 1.8, fat: 0.8, carbs: 18 },
},
/* ── Owoce ────────────────────────────────────────── */
truskawki: {
id: 'truskawki',
image: 'images/ingredients/truskawki.jpg',
name: 'Truskawki',
category: 'owoce',
pantryUnit: 'g',
@@ -175,7 +253,6 @@ export const INGREDIENTS = {
},
borowki_amerykanskie: {
id: 'borowki_amerykanskie',
image: 'images/ingredients/borowki_amerykanskie.jpg',
name: 'Borówki amerykańskie',
category: 'owoce',
pantryUnit: 'g',
@@ -183,7 +260,6 @@ export const INGREDIENTS = {
},
banany: {
id: 'banany',
image: 'images/ingredients/banany.jpg',
name: 'Banany',
category: 'owoce',
pantryUnit: 'g',
@@ -191,7 +267,6 @@ export const INGREDIENTS = {
},
jagody: {
id: 'jagody',
image: 'images/ingredients/jagody.jpg',
name: 'Jagody',
category: 'owoce',
pantryUnit: 'g',
@@ -204,10 +279,17 @@ export const INGREDIENTS = {
pantryUnit: 'g',
nutritionPer100g: { kcal: 82, protein: 1.2, fat: 5.4, carbs: 5.5 },
},
limonka: {
id: 'limonka',
name: 'Limonka',
category: 'owoce',
pantryUnit: 'szt',
weightPerPiece: 60,
nutritionPer100g: { kcal: 30, protein: 0.7, fat: 0.2, carbs: 11 },
},
/* ── Suche i kasze ────────────────────────────────── */
makaron_suchy: {
id: 'makaron_suchy',
image: 'images/ingredients/makaron_suchy.jpg',
name: 'Makaron',
category: 'suche',
pantryUnit: 'g',
@@ -243,7 +325,6 @@ export const INGREDIENTS = {
},
migdaly: {
id: 'migdaly',
image: 'images/ingredients/migdaly.jpg',
name: 'Migdały',
category: 'suche',
pantryUnit: 'g',
@@ -256,6 +337,30 @@ export const INGREDIENTS = {
pantryUnit: 'g',
nutritionPer100g: { kcal: 691, protein: 9, fat: 72, carbs: 14 },
},
maka_pszenna: {
id: 'maka_pszenna',
name: 'Mąka pszenna',
category: 'suche',
pantryUnit: 'g',
purchasePack: { amount: 1000, label: 'torebka 1 kg' },
nutritionPer100g: { kcal: 364, protein: 10, fat: 1, carbs: 76 },
},
sezam: {
id: 'sezam',
name: 'Sezam',
category: 'suche',
pantryUnit: 'g',
purchasePack: { amount: 100, label: 'opakowanie 100 g' },
nutritionPer100g: { kcal: 573, protein: 18, fat: 50, carbs: 23 },
},
platki_owsiane: {
id: 'platki_owsiane',
name: 'Płatki owsiane',
category: 'suche',
pantryUnit: 'g',
purchasePack: { amount: 500, label: 'paczka 500 g' },
nutritionPer100g: { kcal: 379, protein: 13, fat: 7, carbs: 68 },
},
/* ── Przyprawy i zioła ────────────────────────────── */
bazylia_swieza: {
id: 'bazylia_swieza',
@@ -292,6 +397,34 @@ export const INGREDIENTS = {
pantryUnit: 'g',
nutritionPer100g: { kcal: 44, protein: 1, fat: 0.5, carbs: 8 },
},
kolendra_swieza: {
id: 'kolendra_swieza',
name: 'Kolendra świeża',
category: 'przyprawy',
pantryUnit: 'g',
nutritionPer100g: { kcal: 23, protein: 2.1, fat: 0.5, carbs: 3.7 },
},
natka_pietruszki: {
id: 'natka_pietruszki',
name: 'Natka pietruszki',
category: 'przyprawy',
pantryUnit: 'g',
nutritionPer100g: { kcal: 36, protein: 3, fat: 0.8, carbs: 6.3 },
},
majeranek: {
id: 'majeranek',
name: 'Majeranek suszony',
category: 'przyprawy',
pantryUnit: 'g',
nutritionPer100g: { kcal: 271, protein: 12.7, fat: 7, carbs: 60 },
},
cynamon: {
id: 'cynamon',
name: 'Cynamon',
category: 'przyprawy',
pantryUnit: 'g',
nutritionPer100g: { kcal: 247, protein: 4, fat: 1.2, carbs: 81 },
},
/* ── Inne ─────────────────────────────────────────── */
miod: {
id: 'miod',
@@ -302,7 +435,6 @@ export const INGREDIENTS = {
},
oliwa: {
id: 'oliwa',
image: 'images/ingredients/oliwa.jpg',
name: 'Oliwa z oliwek',
category: 'inne',
pantryUnit: 'ml',
@@ -310,15 +442,81 @@ export const INGREDIENTS = {
},
hummus: {
id: 'hummus',
image: 'images/ingredients/hummus.jpg',
name: 'Hummus',
category: 'inne',
pantryUnit: 'g',
purchasePack: { amount: 200, label: 'opakowanie 200 g' },
nutritionPer100g: { kcal: 166, protein: 8, fat: 10, carbs: 14 },
},
olej_rzepakowy: {
id: 'olej_rzepakowy',
name: 'Olej rzepakowy',
category: 'inne',
pantryUnit: 'ml',
purchasePack: { amount: 1000, label: 'butelka 1 l' },
nutritionPer100g: { kcal: 884, protein: 0, fat: 100, carbs: 0 },
},
erytrol: {
id: 'erytrol',
name: 'Erytrol',
category: 'inne',
pantryUnit: 'g',
purchasePack: { amount: 500, label: 'opakowanie 500 g' },
nutritionPer100g: { kcal: 0, protein: 0, fat: 0, carbs: 0 },
},
ekstrakt_waniliowy: {
id: 'ekstrakt_waniliowy',
name: 'Ekstrakt waniliowy',
category: 'inne',
pantryUnit: 'ml',
purchasePack: { amount: 30, label: 'buteleczka 30 ml' },
nutritionPer100g: { kcal: 288, protein: 0.1, fat: 0.1, carbs: 12.7 },
},
maslo_orzechowe: {
id: 'maslo_orzechowe',
name: 'Masło orzechowe',
category: 'inne',
pantryUnit: 'g',
purchasePack: { amount: 350, label: 'słoik 350 g' },
nutritionPer100g: { kcal: 594, protein: 25, fat: 50, carbs: 20 },
},
sos_sojowy: {
id: 'sos_sojowy',
name: 'Sos sojowy',
category: 'inne',
pantryUnit: 'ml',
purchasePack: { amount: 150, label: 'butelka 150 ml' },
nutritionPer100g: { kcal: 53, protein: 8, fat: 0, carbs: 5 },
},
syrop_z_agawy: {
id: 'syrop_z_agawy',
name: 'Syrop z agawy',
category: 'inne',
pantryUnit: 'g',
purchasePack: { amount: 350, label: 'butelka 350 g' },
nutritionPer100g: { kcal: 310, protein: 0, fat: 0, carbs: 76 },
},
czekolada: {
id: 'czekolada',
name: 'Czekolada gorzka',
category: 'inne',
pantryUnit: 'g',
purchasePack: { amount: 100, label: 'tabliczka 100 g' },
nutritionPer100g: { kcal: 546, protein: 8, fat: 31, carbs: 61 },
},
bulion_warzywny: {
id: 'bulion_warzywny',
name: 'Bulion warzywny',
category: 'inne',
pantryUnit: 'ml',
nutritionPer100g: { kcal: 6, protein: 0.5, fat: 0.1, carbs: 0.5 },
},
};
for (const [id, def] of Object.entries(INGREDIENTS)) {
def.image = `icons/ingredients/${id}.svg`;
}
/**
* @typedef {{ ingredientId: string, amount: number, unit: string, alternatives?: string[] }} RecipeIngredientDef
* @typedef {{ id: string, title: string, minutes: number, thumbLabel: string, image?: string, allowedSlots: string[], tags?: string[], nutritionPerServing: NutritionPer100, ingredients: RecipeIngredientDef[], steps: string[] }} RecipeDef
@@ -470,6 +668,129 @@ export const RECIPES = {
'Umyj owoce (ew. pokrój na połówki) i ułóż na wierzchu. Gotowe!',
],
},
dutch_baby: {
id: 'dutch_baby',
title: 'Dutch baby z owocami',
minutes: 45,
thumbLabel: 'Dutch baby',
image: 'images/recipes/dutch_baby.jpg',
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
tags: ['wegetariańskie', 'wysokobiałkowe'],
nutritionPerServing: { kcal: 910, protein: 57, fat: 37, carbs: 88 },
ingredients: [
{ ingredientId: 'maka_pszenna', amount: 60, unit: 'g' },
{ ingredientId: 'mleko', amount: 125, unit: 'ml' },
{ ingredientId: 'jajko', amount: 2, unit: 'szt.' },
{ ingredientId: 'erytrol', amount: 15, unit: 'g' },
{ ingredientId: 'olej_rzepakowy', amount: 10, unit: 'ml' },
{ ingredientId: 'ekstrakt_waniliowy', amount: 1.5, unit: 'ml' },
{ ingredientId: 'skyr', amount: 225, unit: 'g' },
{ ingredientId: 'maslo_orzechowe', amount: 20, unit: 'g' },
{ ingredientId: 'borowki_amerykanskie', amount: 100, unit: 'g', alternatives: ['jagody'] },
{ ingredientId: 'truskawki', amount: 100, unit: 'g' },
],
steps: [
'Nagrzej piekarnik do 210°C wraz z patelnią żeliwną w środku.',
'Zmiksuj jajka, mąkę, mleko, ekstrakt waniliowy, 10 g erytrolu i szczyptę soli. Odstaw ciasto na 20 minut.',
'Wyjmij nagrzaną patelnię, rozprowadź olej rzepakowy i wlej ciasto.',
'Piecz do zbrązowienia brzegów (ok. 10 minut). Ostudź.',
'Wymieszaj skyr z pozostałym erytrolem (5 g) i masłem orzechowym. Nałóż na naleśnika.',
'Ułóż na wierzchu umyte borówki i truskawki.',
],
},
placki_kapusta_losos: {
id: 'placki_kapusta_losos',
title: 'Placki z białej kapusty z łososiem i słodkim sosem sojowym',
minutes: 35,
thumbLabel: 'Placki kapuściane',
image: 'images/recipes/placki_kapusta_losos.jpg',
allowedSlots: ['obiad', 'kolacja'],
nutritionPerServing: { kcal: 775, protein: 44, fat: 34, carbs: 80 },
ingredients: [
{ ingredientId: 'kapusta_biala', amount: 300, unit: 'g' },
{ ingredientId: 'marchewka', amount: 0.5, unit: 'szt.' },
{ ingredientId: 'czosnek', amount: 6, unit: 'g' },
{ ingredientId: 'papryczka_chilli', amount: 0.5, unit: 'szt.' },
{ ingredientId: 'szczypiorek', amount: 15, unit: 'g' },
{ ingredientId: 'kolendra_swieza', amount: 4, unit: 'g' },
{ ingredientId: 'jajko', amount: 1, unit: 'szt.' },
{ ingredientId: 'maka_pszenna', amount: 36, unit: 'g' },
{ ingredientId: 'sezam', amount: 20, unit: 'g' },
{ ingredientId: 'olej_rzepakowy', amount: 10, unit: 'ml' },
{ ingredientId: 'sos_sojowy', amount: 40, unit: 'ml' },
{ ingredientId: 'miod', amount: 24, unit: 'g' },
{ ingredientId: 'imbir', amount: 3, unit: 'g' },
{ ingredientId: 'limonka', amount: 3, unit: 'g' },
{ ingredientId: 'losos_wedzony', amount: 100, unit: 'g' },
],
steps: [
'Kapustę poszatkuj w cienkie paski i dodatkowo posiekaj nożem. Marchewkę zetrzyj na grubej tarce, czosnek na drobnej. Posiekaj szczypiorek i kolendrę, drobno pokrój chili.',
'W rondelku wymieszaj sos sojowy, miód, starty imbir i sok z limonki. Podgrzewaj 5 minut do uzyskania konsystencji syropu.',
'W misce połącz kapustę, marchewkę, czosnek, chili, 10 g szczypiorku, kolendrę, sól, pieprz, paprykę wędzoną i 10 g sezamu. Dodaj jajko i mąkę, dokładnie wymieszaj.',
'Rozgrzej olej rzepakowy na patelni. Nakładaj łyżką porcje ciasta, spłaszczając je w kształt placków. Smaż z każdej strony 34 minuty do zrumienienia.',
'Ułóż placki na talerzu, na każdym połóż plastry wędzonego łososia.',
'Polej słodkim sosem sojowym, posyp pozostałym szczypiorkiem (5 g) i sezamem (10 g).',
],
},
zupa_jarzynowa: {
id: 'zupa_jarzynowa',
title: 'Lekkostrawna zupa jarzynowa',
minutes: 50,
thumbLabel: 'Zupa jarzynowa',
image: 'images/recipes/zupa_jarzynowa.jpg',
allowedSlots: ['obiad'],
tags: ['wysokobiałkowe'],
nutritionPerServing: { kcal: 657, protein: 50, fat: 15, carbs: 81 },
ingredients: [
{ ingredientId: 'piers_kurczaka', amount: 150, unit: 'g' },
{ ingredientId: 'marchewka', amount: 150, unit: 'g' },
{ ingredientId: 'pietruszka_korzen', amount: 100, unit: 'g' },
{ ingredientId: 'seler', amount: 100, unit: 'g' },
{ ingredientId: 'cukinia', amount: 100, unit: 'g' },
{ ingredientId: 'ziemniaki', amount: 210, unit: 'g' },
{ ingredientId: 'bulion_warzywny', amount: 750, unit: 'ml' },
{ ingredientId: 'natka_pietruszki', amount: 10, unit: 'g' },
{ ingredientId: 'majeranek', amount: 4, unit: 'g' },
{ ingredientId: 'tymianek', amount: 3, unit: 'g' },
{ ingredientId: 'oliwa', amount: 10, unit: 'ml' },
],
steps: [
'Oczyść warzywa. Marchewkę, pietruszkę i seler pokrój w kostkę, ziemniaki w mniejszą kostkę, cukinię w ćwierćplasterki, mięso w większe kostki.',
'Wlej bulion do garnka. Dodaj marchewkę, pietruszkę, seler, ziemniaki i pierś z kurczaka. Doprowadź do wrzenia, wrzuć liść laurowy i ziele angielskie.',
'Zmniejsz ogień i gotuj pod częściowym przykryciem 20 minut.',
'Dodaj cukinię, majeranek i tymianek. Gotuj kolejne 10 minut, aż warzywa będą miękkie.',
'Dopraw solą, białym pieprzem i lubczykiem. Wmieszaj posiekaną natkę pietruszki oraz oliwę.',
'Przykryj i odstaw na 5 minut przed podaniem.',
],
},
ciasteczka_owsiane: {
id: 'ciasteczka_owsiane',
title: 'Ciasteczka owsiane z czekoladą',
minutes: 35,
thumbLabel: 'Ciasteczka owsiane',
image: 'images/recipes/ciasteczka_owsiane.jpg',
allowedSlots: ['drugie_sniadanie', 'przekaska'],
tags: ['wegetariańskie'],
nutritionPerServing: { kcal: 183, protein: 5, fat: 5, carbs: 30 },
ingredients: [
{ ingredientId: 'banany', amount: 240, unit: 'g' },
{ ingredientId: 'maslo_orzechowe', amount: 30, unit: 'g' },
{ ingredientId: 'syrop_z_agawy', amount: 15, unit: 'g', alternatives: ['miod'] },
{ ingredientId: 'ekstrakt_waniliowy', amount: 9, unit: 'ml' },
{ ingredientId: 'cynamon', amount: 2, unit: 'g' },
{ ingredientId: 'platki_owsiane', amount: 220, unit: 'g' },
{ ingredientId: 'czekolada', amount: 30, unit: 'g' },
],
steps: [
'Nagrzej piekarnik do 180°C (góra-dół). Wyłóż blachę papierem do pieczenia.',
'Obierz banany i rozgnieć je widelcem w misce na gładką papkę.',
'Dodaj masło orzechowe, syrop z agawy, ekstrakt waniliowy, cynamon i szczyptę soli. Wymieszaj przez ok. minutę.',
'Wsyp płatki owsiane i mieszaj aż do uzyskania gęstej, lepkiej masy.',
'Posiekaj czekoladę na drobne kawałki, wmieszaj do masy.',
'Formuj kulki wielkości orzecha włoskiego (ok. 8 sztuk) i układaj na blasze, lekko spłaszczając.',
'Piecz ok. 15 minut, aż brzegi się zrumienią. Studź ok. 10 minut przed zdjęciem z blachy.',
],
},
};
/* ══════════════════════════════════════════════════════════════════════

View File

@@ -1,5 +1,5 @@
import { INGREDIENTS, CATEGORY_LABELS, PRODUCTS, ingredientHasProducts } from '../data/catalog.js?v=8';
import { PANTRY_STORAGE_KEY, PANTRY_STORAGE_KEY_V2, SHOPPING_STORAGE_KEY } from '../storageKeys.js';
import { INGREDIENTS, CATEGORY_LABELS, PRODUCTS, ingredientHasProducts } from '../data/catalog.js?v=9';
import { PANTRY_STORAGE_KEY, PANTRY_STORAGE_KEY_V2, SHOPPING_STORAGE_KEY, SHOPPING_SESSION_KEY } from '../storageKeys.js';
export const KITCHEN_LIST_ID = 'kitchen';
export const MISC_LIST_ID = 'misc';
@@ -535,6 +535,148 @@ export function setPantryProductQty(ingredientId, productId, qty) {
return pantry;
}
/**
* Dodaj delta do spiżarni (używane przy zakupie ze listy).
* @param {string} ingredientId
* @param {string|undefined} productId
* @param {number} amount
*/
export function addAmountToPantry(ingredientId, productId, amount) {
const pantry = loadPantry();
const rounded = Math.round(amount * 1000) / 1000;
if (productId && PRODUCTS[productId]) {
let val = pantry[ingredientId];
if (!val || typeof val === 'number') {
val = { items: [], _total: 0 };
pantry[ingredientId] = val;
}
const idx = val.items.findIndex((i) => i.productId === productId);
if (idx >= 0) val.items[idx].qty = Math.round((val.items[idx].qty + rounded) * 1000) / 1000;
else val.items.push({ productId, qty: rounded });
recalcTotal(val);
} else {
const cur = typeof pantry[ingredientId] === 'number' ? pantry[ingredientId] : 0;
pantry[ingredientId] = Math.round((cur + rounded) * 1000) / 1000;
}
savePantry(pantry);
return pantry;
}
/**
* Odejmij delta ze spiżarni (undo zakupu).
* @param {string} ingredientId
* @param {string|undefined} productId
* @param {number} amount
*/
export function subtractFromPantry(ingredientId, productId, amount) {
const pantry = loadPantry();
if (productId && PRODUCTS[productId]) {
const val = pantry[ingredientId];
if (!val || typeof val === 'number') return pantry;
const idx = val.items.findIndex((i) => i.productId === productId);
if (idx < 0) return pantry;
val.items[idx].qty = Math.max(0, Math.round((val.items[idx].qty - amount) * 1000) / 1000);
if (val.items[idx].qty <= 0) val.items.splice(idx, 1);
recalcTotal(val);
if (val._total <= 0) delete pantry[ingredientId];
} else {
const cur = typeof pantry[ingredientId] === 'number' ? pantry[ingredientId] : 0;
const next = Math.max(0, Math.round((cur - amount) * 1000) / 1000);
if (next <= 0) delete pantry[ingredientId];
else pantry[ingredientId] = next;
}
savePantry(pantry);
return pantry;
}
/* ══════════════════════════════════════════════════════════════════════
* Session zakupowy — selectedDays + log zakupów bieżącej sesji
* ══════════════════════════════════════════════════════════════════════ */
/**
* @typedef {{ id: string, ingredientId: string, productId?: string, name: string, addedAmount: number, unit: string, category: string, timestamp: number }} SessionLogEntry
* @typedef {{ selectedDays: string[], sessionLog: SessionLogEntry[] }} ShoppingSessionState
*/
function defaultShoppingSession() {
return { selectedDays: [], sessionLog: [] };
}
function normalizeShoppingSession(raw) {
if (!raw || typeof raw !== 'object') return defaultShoppingSession();
const selectedDays = Array.isArray(raw.selectedDays)
? raw.selectedDays.filter((d) => typeof d === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(d))
: [];
const sessionLog = Array.isArray(raw.sessionLog)
? raw.sessionLog.filter((e) => e && typeof e.ingredientId === 'string' && Number.isFinite(e.addedAmount))
: [];
return { selectedDays, sessionLog };
}
/** @returns {ShoppingSessionState} */
export function loadShoppingSession() {
try {
const raw = localStorage.getItem(SHOPPING_SESSION_KEY);
if (!raw) return defaultShoppingSession();
return normalizeShoppingSession(JSON.parse(raw));
} catch {
return defaultShoppingSession();
}
}
/** @param {ShoppingSessionState} s */
export function saveShoppingSession(s) {
try {
localStorage.setItem(SHOPPING_SESSION_KEY, JSON.stringify(s));
} catch { /* ignore */ }
}
/** @returns {string[]} */
export function getSelectedDays() {
return loadShoppingSession().selectedDays;
}
/** @param {string[]} days */
export function setSelectedDays(days) {
const s = loadShoppingSession();
s.selectedDays = days;
saveShoppingSession(s);
}
/**
* @param {{ ingredientId: string, productId?: string, name: string, addedAmount: number, unit: string, category: string }} entry
* @returns {string} new entry id
*/
export function addSessionLogEntry(entry) {
const s = loadShoppingSession();
const id = newId('sl');
s.sessionLog.push({
id,
ingredientId: entry.ingredientId,
productId: entry.productId || undefined,
name: entry.name,
addedAmount: Math.round(entry.addedAmount * 100) / 100,
unit: entry.unit,
category: entry.category,
timestamp: Date.now(),
});
saveShoppingSession(s);
return id;
}
/** @param {string} entryId */
export function removeSessionLogEntry(entryId) {
const s = loadShoppingSession();
s.sessionLog = s.sessionLog.filter((e) => e.id !== entryId);
saveShoppingSession(s);
}
export function clearSessionLog() {
const s = loadShoppingSession();
s.sessionLog = [];
saveShoppingSession(s);
}
/** Kupione ze listy kuchennej → spiżarnia (zawsze ta lista, niezależnie od aktywnej zakładki). */
export function applyCheckedKitchenListToPantry() {
const s = loadShoppingState();

View File

@@ -1,7 +1,7 @@
import { INGREDIENTS, RECIPES, PRODUCTS, getProductsForIngredient } from '../data/catalog.js?v=8';
import { INGREDIENTS, RECIPES, PRODUCTS, getProductsForIngredient } from '../data/catalog.js?v=9';
import { MEAL_SLOTS } from '../planner/mealSlots.js';
import { addDays } from './dateUtils.js';
import { getDayPlan } from './planStore.js?v=2';
import { dateKey, getDayPlan } from './planStore.js?v=2';
import { getPantryTotal } from './pantryShopping.js?v=2';
export function dayHasAnyMeal(plans, d) {
@@ -182,6 +182,71 @@ export function aggregateWeekIngredientNeed(plans, weekStart) {
return mergeIngredientLines(all);
}
/**
* Zapotrzebowanie składników od startDate przez numDays dni.
* Jak aggregateWeekIngredientNeed, ale z dowolnym zakresem i informacją
* w które dni dany składnik jest potrzebny.
* @param {Record<string, unknown>} plans
* @param {Date} startDate
* @param {number} numDays
* @returns {Array<{ingredientId: string, name: string, category: string, amount: number, unit: string, days: string[]}>}
*/
export function aggregateRangeIngredientNeed(plans, startDate, numDays) {
/** @type {Map<string, {ingredientId: string, name: string, category: string, amount: number, unit: string, days: Set<string>}>} */
const map = new Map();
for (let i = 0; i < numDays; i++) {
const day = addDays(startDate, i);
const dk = dateKey(day);
const dayPlan = getDayPlan(plans, day);
const lines = flattenDayIngredientLines(dayPlan);
for (const line of lines) {
const key = `${line.ingredientId}\t${line.unit}`;
const cur = map.get(key);
if (!cur) {
map.set(key, { ...line, days: new Set([dk]) });
} else {
cur.amount = Math.round((cur.amount + line.amount) * 10) / 10;
cur.days.add(dk);
}
}
}
return [...map.values()]
.map((item) => ({ ...item, days: [...item.days].sort() }))
.sort((a, b) => {
const c = a.category.localeCompare(b.category);
return c !== 0 ? c : a.name.localeCompare(b.name, 'pl');
});
}
/**
* Zapotrzebowanie składników dla konkretnych dni (tablica dateKey-ów).
* @param {Record<string, unknown>} plans
* @param {string[]} selectedDays — tablica dateKey-ów ('YYYY-MM-DD')
*/
export function aggregateSelectedDaysIngredientNeed(plans, selectedDays) {
if (!selectedDays || selectedDays.length === 0) return [];
const map = new Map();
for (const dk of selectedDays) {
const [y, m, d] = dk.split('-').map(Number);
const day = new Date(y, m - 1, d);
const dayPlan = getDayPlan(plans, day);
const lines = flattenDayIngredientLines(dayPlan);
for (const line of lines) {
const key = `${line.ingredientId}\t${line.unit}`;
const cur = map.get(key);
if (!cur) {
map.set(key, { ...line });
} else {
cur.amount = Math.round((cur.amount + line.amount) * 10) / 10;
}
}
}
return [...map.values()].sort((a, b) => {
const c = a.category.localeCompare(b.category);
return c !== 0 ? c : a.name.localeCompare(b.name, 'pl');
});
}
/**
* Jedna grupa na porę dnia: nagłówek pory raz, potem bloki przepisów ze składnikami.
*/

View File

@@ -1,4 +1,4 @@
import { INGREDIENTS, RECIPES, PRODUCTS } from '../data/catalog.js?v=8';
import { INGREDIENTS, RECIPES, PRODUCTS } from '../data/catalog.js?v=9';
import { MEAL_SLOTS } from '../planner/mealSlots.js';
import { PLANS_STORAGE_KEY } from '../storageKeys.js';
import { startOfDay } from './dateUtils.js';

View File

@@ -2,3 +2,4 @@ export const PLANS_STORAGE_KEY = 'recipe-planner-plans-v1';
export const PANTRY_STORAGE_KEY = 'recipe-pantry-v1';
export const PANTRY_STORAGE_KEY_V2 = 'recipe-pantry-v2';
export const SHOPPING_STORAGE_KEY = 'recipe-shopping-v1';
export const SHOPPING_SESSION_KEY = 'recipe-shopping-session-v1';

View File

@@ -1,56 +1,32 @@
function syncThemeToggleButton(btn, isDark) {
if (!btn) return;
const icon = btn.querySelector('i');
if (icon) icon.className = isDark ? 'fas fa-sun' : 'fas fa-moon';
btn.setAttribute('aria-label', isDark ? 'Włącz jasny motyw' : 'Włącz ciemny motyw');
btn.title = isDark ? 'Jasny motyw' : 'Ciemny motyw';
}
function setupThemeToggle() {
const btn = document.getElementById('nav-theme-toggle');
if (!btn) return;
btn.addEventListener('click', () => {
const html = document.documentElement;
const isDark = html.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
syncThemeToggleButton(btn, isDark);
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', isDark ? '#161513' : '#f3efe9');
});
}
export function getBottomNavHTML() {
const isDark = document.documentElement.classList.contains('dark');
return `
<nav id="app-bottom-nav" aria-label="Główna nawigacja">
<div class="bottom-dock">
<div id="app-bottom-nav-dock" class="bottom-dock">
<button type="button" id="recipe-nav-toggle" class="recipe-nav-toggle" aria-label="Otwórz menu" aria-expanded="false" aria-controls="app-bottom-nav-dock">
<i id="recipe-nav-toggle-icon" class="far fa-calendar-alt" aria-hidden="true"></i>
</button>
<div class="nav-slot">
<button type="button" data-tab="planner" id="nav-planner" class="nav-tab is-active" aria-label="Planer" aria-current="page">
<i class="far fa-calendar-alt" aria-hidden="true"></i>
<span class="nav-label">Planer</span>
</button>
</div>
<div class="nav-slot">
<button type="button" data-tab="recipes" id="nav-recipes" class="nav-tab" aria-label="Przepisy">
<i class="fas fa-search" aria-hidden="true"></i>
<button type="button" data-tab="recipes" id="nav-recipes" class="nav-tab" aria-label="Katalog">
<i class="fas fa-book-open" aria-hidden="true"></i>
<span class="nav-label">Katalog</span>
</button>
</div>
<div class="nav-slot">
<button type="button" data-tab="pantry" id="nav-pantry" class="nav-tab" aria-label="Spiżarnia">
<i class="fas fa-warehouse" aria-hidden="true"></i>
<span class="nav-label">Spiżarnia</span>
</button>
</div>
<div class="nav-slot" style="position:relative;">
<button type="button" data-tab="shopping" id="nav-shopping" class="nav-tab" aria-label="Zakupy">
<i class="fas fa-cart-shopping" aria-hidden="true"></i>
</button>
<span id="nav-shopping-badge" class="hidden absolute -top-0.5 -right-0.5 min-w-[16px] h-4 rounded-full flex items-center justify-center text-[9px] font-bold leading-none px-1" style="background:rgb(var(--accent-rgb)); color:#1a1a1a;"></span>
</div>
<div class="nav-slot">
<button type="button" id="nav-theme-toggle" class="nav-action" aria-label="${isDark ? 'Włącz jasny motyw' : 'Włącz ciemny motyw'}" title="${isDark ? 'Jasny motyw' : 'Ciemny motyw'}">
<i class="${isDark ? 'fas fa-sun' : 'fas fa-moon'}" aria-hidden="true"></i>
<span class="nav-label">Zakupy</span>
</button>
</div>
</div>
@@ -67,12 +43,130 @@ export function setupBottomNav({ refreshPantry, refreshShoppingList } = {}) {
if (!main || !planner || !pantry || !shopping || !nav) return;
const TABS = ['recipes', 'planner', 'pantry', 'shopping'];
const COLLAPSED_TABS = new Set();
const EXTRA_CONTROL_SLOTS = { recipes: 1, planner: 1, pantry: 1, shopping: 1 };
const DOUBLE_COMPACT_TABS = new Set();
const COLLAPSED_TAB_ICON = {
recipes: 'fas fa-book-open',
planner: 'far fa-calendar-alt',
pantry: 'fas fa-warehouse',
shopping: 'fas fa-cart-shopping',
};
const COLLAPSED_TAB_LABEL = { recipes: 'Otwórz menu', planner: 'Otwórz menu', pantry: 'Otwórz menu', shopping: 'Otwórz menu' };
let isRecipeMenuOpen = false;
let activeTab = 'planner';
let previousTab = 'planner';
let collapseTimer = null;
const syncRecipeNavMetrics = () => {
const rootFontSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16;
const navStyles = window.getComputedStyle(nav);
const navPadLeft = parseFloat(navStyles.paddingLeft) || 0;
const navPadRight = parseFloat(navStyles.paddingRight) || 0;
const navContentWidth = nav.clientWidth - navPadLeft - navPadRight;
const isCompact = window.matchMedia('(max-width: 380px)').matches;
const dockInset = (isCompact ? 1.6 : 2.4) * rootFontSize;
const dockMax = (isCompact ? 22.5 : 24.5) * rootFontSize;
const dockWidth = Math.min(navContentWidth - dockInset, dockMax);
if (dockWidth <= 0) return;
const padLeft = (isCompact ? 0.38 : 0.42) * rootFontSize;
const padRight = (isCompact ? 0.38 : 0.42) * rootFontSize;
const columnGap = (isCompact ? 0.05 : 0.06) * rootFontSize;
const dockLeft = navPadLeft + ((navContentWidth - dockWidth) / 2);
const controlSize = (isCompact ? 2.95 : 3.05) * rootFontSize;
const bottomControlSize = controlSize * 1.28;
const controlGap = 0.5 * rootFontSize;
const inlineSearchControlSize = bottomControlSize * 0.8;
const controlsLift = 0;
const collapsedDockWidth = bottomControlSize;
const collapsedSlotWidth = Math.max(32, collapsedDockWidth - padLeft - padRight);
const inlineCollapsedSlotWidth = Math.max(28, inlineSearchControlSize - padLeft - padRight);
const controlCluster = bottomControlSize + controlGap;
const compactExtraWidth = (EXTRA_CONTROL_SLOTS[activeTab] || 0) * controlCluster;
const toolbarButtonLeft = dockLeft + dockWidth - bottomControlSize;
const searchRight = Math.max(16, nav.clientWidth - toolbarButtonLeft + controlGap);
const inlineSearchCloseLeft = dockLeft + dockWidth - inlineSearchControlSize;
const inlineSearchFieldLeft = dockLeft + inlineSearchControlSize + controlGap;
const inlineSearchFieldRight = Math.max(16, nav.clientWidth - inlineSearchCloseLeft + controlGap);
nav.style.setProperty('--recipe-dock-width', `${dockWidth}px`);
nav.style.setProperty('--recipe-collapsed-dock-width', `${collapsedDockWidth}px`);
nav.style.setProperty('--recipe-toggle-size', `${collapsedSlotWidth}px`);
nav.style.setProperty('--recipe-inline-search-control-size', `${inlineSearchControlSize}px`);
nav.style.setProperty('--recipe-inline-toggle-size', `${inlineCollapsedSlotWidth}px`);
nav.style.setProperty('--nav-compact-extra-width', `${compactExtraWidth}px`);
nav.style.setProperty('--nav-compact-translate-x', `${compactExtraWidth * -0.5}px`);
document.documentElement.style.setProperty('--recipe-dock-width', `${dockWidth}px`);
document.documentElement.style.setProperty('--catalog-menu-left', `${dockLeft}px`);
document.documentElement.style.setProperty('--catalog-menu-width', `${collapsedDockWidth}px`);
document.documentElement.style.setProperty('--catalog-filter-left', `${toolbarButtonLeft}px`);
document.documentElement.style.setProperty('--catalog-search-btn-left', `${toolbarButtonLeft}px`);
document.documentElement.style.setProperty('--catalog-search-right', `${searchRight}px`);
document.documentElement.style.setProperty('--catalog-inline-search-close-left', `${inlineSearchCloseLeft}px`);
document.documentElement.style.setProperty('--catalog-inline-search-field-left', `${inlineSearchFieldLeft}px`);
document.documentElement.style.setProperty('--catalog-inline-search-field-right', `${inlineSearchFieldRight}px`);
document.documentElement.style.setProperty('--recipe-control-size', `${controlSize}px`);
document.documentElement.style.setProperty('--recipe-bottom-control-size', `${bottomControlSize}px`);
document.documentElement.style.setProperty('--recipe-inline-search-control-size', `${inlineSearchControlSize}px`);
document.documentElement.style.setProperty('--recipe-controls-lift', `${controlsLift}px`);
};
const updateToggleForTab = (tab) => {
const icon = document.getElementById('recipe-nav-toggle-icon');
const button = document.getElementById('recipe-nav-toggle');
const nextIconClass = COLLAPSED_TAB_ICON[tab];
if (icon && nextIconClass) {
icon.className = nextIconClass;
}
if (button && COLLAPSED_TAB_LABEL[tab]) {
button.setAttribute('aria-label', COLLAPSED_TAB_LABEL[tab]);
}
};
const setRecipeMenuOpen = (open) => {
syncRecipeNavMetrics();
window.clearTimeout(collapseTimer);
isRecipeMenuOpen = open;
nav.classList.remove('is-nav-collapsing');
nav.classList.toggle('is-nav-menu-open', open);
document.documentElement.classList.toggle('is-nav-menu-open', open);
document.getElementById('recipe-nav-toggle')?.setAttribute('aria-expanded', open ? 'true' : 'false');
};
const apply = (tab) => {
const wasCollapsedTab = COLLAPSED_TABS.has(previousTab);
const isCollapsedTab = COLLAPSED_TABS.has(tab);
const isCompactTab = (EXTRA_CONTROL_SLOTS[tab] || 0) > 0;
const isDoubleCompact = DOUBLE_COMPACT_TABS.has(tab);
main.classList.toggle('hidden', tab !== 'recipes');
planner.classList.toggle('hidden', tab !== 'planner');
pantry.classList.toggle('hidden', tab !== 'pantry');
shopping.classList.toggle('hidden', tab !== 'shopping');
nav.classList.toggle('is-collapsed-tab', isCollapsedTab);
nav.classList.toggle('is-compact-tab', isCompactTab);
nav.classList.toggle('is-double-compact', isDoubleCompact);
activeTab = tab;
updateToggleForTab(tab);
syncRecipeNavMetrics();
setRecipeMenuOpen(false);
if (isCollapsedTab && (!wasCollapsedTab || tab !== previousTab)) {
nav.classList.add('is-nav-menu-open', 'is-nav-collapsing');
document.documentElement.classList.add('is-nav-menu-open');
document.getElementById('recipe-nav-toggle')?.setAttribute('aria-expanded', 'false');
requestAnimationFrame(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
nav.classList.remove('is-nav-menu-open');
document.documentElement.classList.remove('is-nav-menu-open');
collapseTimer = window.setTimeout(() => {
nav.classList.remove('is-nav-collapsing');
}, 500);
});
});
});
}
if (tab === 'pantry' && typeof refreshPantry === 'function') refreshPantry();
if (tab === 'shopping' && typeof refreshShoppingList === 'function') refreshShoppingList();
@@ -87,18 +181,60 @@ export function setupBottomNav({ refreshPantry, refreshShoppingList } = {}) {
else btn.removeAttribute('aria-current');
}
});
window.dispatchEvent(new CustomEvent('app-tab-change', { detail: { tab } }));
previousTab = tab;
};
nav.addEventListener('click', (e) => {
const toggle = e.target.closest('#recipe-nav-toggle');
if (toggle) {
e.stopPropagation();
const wasInlineSearchOpen = document.documentElement.classList.contains('is-inline-search-open');
if (wasInlineSearchOpen) {
setRecipeMenuOpen(false);
window.closeRecipeSearch?.();
window.closePantrySearch?.();
window.closePantryFilter?.();
window.closeShoppingCalendar?.();
window.closeShoppingBoughtPopup?.();
window.closeFilters?.();
return;
}
setRecipeMenuOpen(!isRecipeMenuOpen);
window.closeRecipeSearch?.();
window.closePantrySearch?.();
window.closePantryFilter?.();
window.closeShoppingCalendar?.();
window.closeShoppingBoughtPopup?.();
window.closeFilters?.();
return;
}
const btn = e.target.closest('.nav-tab[data-tab]');
if (!btn || btn.hasAttribute('disabled')) return;
const tab = btn.getAttribute('data-tab');
if (TABS.includes(tab)) apply(tab);
});
setupThemeToggle();
document.addEventListener('click', (e) => {
if (!isRecipeMenuOpen || !nav.classList.contains('is-collapsed-tab')) return;
if (e.composedPath().includes(nav)) return;
setRecipeMenuOpen(false);
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isRecipeMenuOpen) setRecipeMenuOpen(false);
});
window.addEventListener('resize', syncRecipeNavMetrics);
apply('planner');
window.switchAppTab = (tab) => {
if (TABS.includes(tab)) apply(tab);
};
window.refreshStockViews = () => {
if (typeof refreshPantry === 'function') refreshPantry();
};

270
js/ui/calendarPopover.js Normal file
View File

@@ -0,0 +1,270 @@
const STYLE_ID = 'calendar-popover-liquid-styles';
const DEFAULT_POPOVER_CLASS = 'absolute left-0 right-0 top-full mt-2 z-[50] transition-all duration-200 pointer-events-none';
const DEFAULT_POPOVER_STYLE = 'opacity:0; transform:translateY(-6px) scale(0.98);';
const DEFAULT_PANEL_CLASS = 'calendar-liquid-panel rounded-[1.35rem] py-3';
const DEFAULT_PANEL_STYLE = 'background-image:none !important;';
const DEFAULT_OPEN_TRIGGER_STYLE = {
background: 'rgb(var(--sunken-rgb))',
borderColor: 'rgb(var(--border-input-rgb))',
};
const DEFAULT_CLOSED_TRIGGER_STYLE = {
background: 'rgb(var(--card-rgb))',
borderColor: 'rgb(var(--border-card-rgb))',
};
export function ensureCalendarPopoverStyles() {
if (typeof document === 'undefined') return;
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
.calendar-liquid-panel {
background: rgba(255, 255, 255, 0.2) !important;
border: 1px solid rgba(255, 255, 255, 0.32) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.6),
inset 0 -1px 0 rgba(var(--overlay-rgb), 0.06),
0 8px 20px rgba(var(--overlay-rgb), 0.14),
0 18px 38px rgba(var(--overlay-rgb), 0.18) !important;
backdrop-filter: blur(28px) saturate(180%);
-webkit-backdrop-filter: blur(28px) saturate(180%);
}
.dark .calendar-liquid-panel {
background: rgba(255, 255, 255, 0.04) !important;
border: 1px solid rgba(255, 255, 255, 0.12) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.24),
inset 0 -1px 0 rgba(0, 0, 0, 0.22),
0 10px 24px rgba(0, 0, 0, 0.3),
0 24px 54px rgba(0, 0, 0, 0.38) !important;
}
.calendar-liquid-btn {
background: rgba(255, 255, 255, 0.16) !important;
border: 1px solid rgba(255, 255, 255, 0.24) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.45),
inset 0 -1px 0 rgba(var(--overlay-rgb), 0.08),
0 6px 14px rgba(var(--overlay-rgb), 0.14) !important;
backdrop-filter: blur(22px) saturate(165%);
-webkit-backdrop-filter: blur(22px) saturate(165%);
transition: background 180ms ease, border-color 180ms ease, transform 180ms ease;
}
.calendar-liquid-btn:hover {
background: rgba(255, 255, 255, 0.22) !important;
transform: translateY(-1px);
}
.dark .calendar-liquid-btn {
background: rgba(255, 255, 255, 0.06) !important;
border: 1px solid rgba(255, 255, 255, 0.12) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.2),
inset 0 -1px 0 rgba(0, 0, 0, 0.2),
0 8px 18px rgba(0, 0, 0, 0.28) !important;
}
.dark .calendar-liquid-btn:hover {
background: rgba(255, 255, 255, 0.1) !important;
}
.calendar-liquid-btn input,
.calendar-liquid-btn input:focus,
.calendar-liquid-btn input:active {
background: transparent !important;
background-color: transparent !important;
background-image: none !important;
border: none !important;
box-shadow: none !important;
outline: none !important;
appearance: none !important;
-webkit-appearance: none !important;
-moz-appearance: textfield !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
filter: none !important;
}
.calendar-liquid-btn input[type='number']::-webkit-outer-spin-button,
.calendar-liquid-btn input[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.calendar-liquid-btn input:-webkit-autofill,
.calendar-liquid-btn input:-webkit-autofill:hover,
.calendar-liquid-btn input:-webkit-autofill:focus {
-webkit-text-fill-color: rgb(var(--text-body-rgb));
-webkit-box-shadow: 0 0 0 1000px transparent inset !important;
box-shadow: 0 0 0 1000px transparent inset !important;
transition: background-color 9999s ease-in-out 0s;
}
`;
document.head.appendChild(style);
}
function byId(idOrElement) {
if (!idOrElement) return null;
if (typeof idOrElement === 'string') return document.getElementById(idOrElement);
return idOrElement;
}
function setStyles(el, styles = {}, important = false) {
if (!el) return;
Object.entries(styles).forEach(([name, value]) => {
if (value == null) return;
if (important) {
const cssName = name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
el.style.setProperty(cssName, value, 'important');
}
else el.style[name] = value;
});
}
export function createCalendarPopoverHTML({
id,
calendarHTML,
popoverClass = DEFAULT_POPOVER_CLASS,
popoverStyle = DEFAULT_POPOVER_STYLE,
panelClass = DEFAULT_PANEL_CLASS,
panelStyle = DEFAULT_PANEL_STYLE,
wrapInPanel = true,
}) {
ensureCalendarPopoverStyles();
const body = wrapInPanel
? `<div class="${panelClass}" style="${panelStyle}">${calendarHTML}</div>`
: calendarHTML;
return `
<div id="${id}" class="${popoverClass}" style="${popoverStyle}">
${body}
</div>
`;
}
export function syncCalendarPopoverVisibility({
popup,
isOpen,
chevron,
chevronOpenTransform = 'rotate(180deg)',
chevronClosedTransform = 'rotate(0deg)',
trigger,
openTriggerStyle = DEFAULT_OPEN_TRIGGER_STYLE,
closedTriggerStyle = DEFAULT_CLOSED_TRIGGER_STYLE,
triggerImportant = false,
}) {
const popupEl = byId(popup);
if (popupEl) {
popupEl.style.opacity = isOpen ? '1' : '0';
popupEl.style.transform = isOpen ? 'translateY(0) scale(1)' : 'translateY(-6px) scale(0.98)';
popupEl.style.pointerEvents = isOpen ? 'auto' : 'none';
}
const chevronEl = byId(chevron);
if (chevronEl) chevronEl.style.transform = isOpen ? chevronOpenTransform : chevronClosedTransform;
setStyles(
byId(trigger),
isOpen ? openTriggerStyle : closedTriggerStyle,
triggerImportant,
);
}
export function stabilizeSwipeCalendarLayout({
calendar,
viewport,
hideViewport = false,
maxAttempts = 8,
}) {
const viewportEl = byId(viewport);
if (hideViewport && viewportEl) {
viewportEl.style.opacity = '0';
viewportEl.style.visibility = 'hidden';
viewportEl.style.transition = 'opacity 120ms ease';
}
calendar?.render?.();
const ensureStableLayout = (attempt = 0) => {
const width = viewportEl ? (viewportEl.clientWidth || viewportEl.getBoundingClientRect().width) : 0;
if (viewportEl && width < 8 && attempt < maxAttempts) {
requestAnimationFrame(() => ensureStableLayout(attempt + 1));
return;
}
calendar?.reapplyLayout?.();
calendar?.resetTrackPosition?.();
requestAnimationFrame(() => {
calendar?.reapplyLayout?.();
calendar?.resetTrackPosition?.();
if (hideViewport && viewportEl) {
viewportEl.style.visibility = 'visible';
viewportEl.style.opacity = '1';
}
});
};
requestAnimationFrame(() => ensureStableLayout());
}
export function createCalendarPopoverController({
popupId,
viewportId,
triggerId,
chevronId,
chevronOpenTransform,
chevronClosedTransform,
getCalendar,
openTriggerStyle = DEFAULT_OPEN_TRIGGER_STYLE,
closedTriggerStyle = DEFAULT_CLOSED_TRIGGER_STYLE,
triggerImportant = false,
hideViewportDuringLayout = false,
}) {
const calendar = () => (typeof getCalendar === 'function' ? getCalendar() : null);
const sync = (isOpen) => {
syncCalendarPopoverVisibility({
popup: popupId,
isOpen,
chevron: chevronId,
chevronOpenTransform,
chevronClosedTransform,
trigger: triggerId,
openTriggerStyle,
closedTriggerStyle,
triggerImportant,
});
};
const stabilize = () => {
stabilizeSwipeCalendarLayout({
calendar: calendar(),
viewport: viewportId,
hideViewport: hideViewportDuringLayout,
});
};
const close = ({ clearPendingRange = false } = {}) => {
sync(false);
const instance = calendar();
if (clearPendingRange) instance?.clearPendingRange?.();
instance?.resetTrackPosition?.();
};
const open = () => {
sync(true);
stabilize();
};
return {
sync,
open,
close,
stabilize,
};
}

75
js/ui/filterPopover.js Normal file
View File

@@ -0,0 +1,75 @@
const STYLE_ID = 'filter-popover-liquid-styles';
export function ensureFilterPopoverStyles() {
if (typeof document === 'undefined') return;
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
.filter-liquid-surface {
--filter-liquid-panel-bg: rgba(var(--app-bg-rgb), 0.78);
--filter-liquid-border: rgba(255, 255, 255, 0.32);
--filter-liquid-chip-bg: rgba(var(--surface-rgb), 0.55);
--filter-liquid-chip-active-bg: rgba(255, 255, 255, 0.95);
--filter-liquid-chip-active-border: rgba(255, 255, 255, 0.6);
--filter-liquid-track-bg: rgba(var(--overlay-rgb), 0.16);
--filter-liquid-accent-bg: rgba(255, 255, 255, 0.72);
--filter-liquid-text-active: rgb(var(--text-emphasis-rgb));
--filter-liquid-text-secondary: rgb(var(--text-body-soft-rgb));
--filter-liquid-text-muted: rgb(var(--text-muted-rgb));
--filter-liquid-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.6),
inset 0 -1px 0 rgba(var(--overlay-rgb), 0.06),
0 8px 20px rgba(var(--overlay-rgb), 0.16),
0 22px 52px rgba(var(--overlay-rgb), 0.24);
}
.dark .filter-liquid-surface {
--filter-liquid-panel-bg: rgba(var(--app-bg-rgb), 0.82);
--filter-liquid-border: rgba(255, 255, 255, 0.12);
--filter-liquid-chip-bg: rgba(255, 255, 255, 0.06);
--filter-liquid-chip-active-bg: rgba(255, 255, 255, 0.3);
--filter-liquid-chip-active-border: rgba(255, 255, 255, 0.38);
--filter-liquid-track-bg: rgba(255, 255, 255, 0.1);
--filter-liquid-accent-bg: rgba(255, 255, 255, 0.32);
--filter-liquid-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.24),
inset 0 -1px 0 rgba(0, 0, 0, 0.22),
0 10px 24px rgba(0, 0, 0, 0.36),
0 26px 60px rgba(0, 0, 0, 0.46);
}
.filter-liquid-panel {
isolation: isolate;
background: var(--filter-liquid-panel-bg) !important;
background-image: none !important;
border: 1px solid var(--filter-liquid-border) !important;
box-shadow: var(--filter-liquid-shadow) !important;
backdrop-filter: blur(28px) saturate(180%);
-webkit-backdrop-filter: blur(28px) saturate(180%);
}
.filter-liquid-surface.filter-liquid-panel-soft {
--filter-liquid-panel-bg: rgba(var(--app-bg-rgb), 0.52);
}
.dark .filter-liquid-surface.filter-liquid-panel-soft {
--filter-liquid-panel-bg: rgba(var(--app-bg-rgb), 0.6);
}
`;
document.head.appendChild(style);
}
ensureFilterPopoverStyles();
export function filterChipStyle(active) {
const background = active ? 'var(--filter-liquid-chip-active-bg)' : 'var(--filter-liquid-chip-bg)';
const color = active ? 'var(--filter-liquid-text-active)' : 'var(--filter-liquid-text-secondary)';
const borderRule = active
? 'border:1px solid var(--filter-liquid-chip-active-border);'
: 'border:1px solid transparent;';
const shadow = active
? 'box-shadow:inset 0 1px 0 rgba(255,255,255,0.5), 0 2px 6px rgba(var(--overlay-rgb),0.18);'
: 'box-shadow:none;';
return `background:${background}; ${borderRule} color:${color}; ${shadow}`;
}

View File

@@ -5,7 +5,7 @@ import {
getProductsForIngredient,
ingredientHasProducts,
pantryQtyStep,
} from '../data/catalog.js?v=8';
} from '../data/catalog.js?v=9';
import {
addOrMergeShoppingLines,
KITCHEN_LIST_ID,
@@ -19,6 +19,7 @@ import {
updateKitchenItemAmount,
} from '../services/pantryShopping.js?v=2';
import { showAppToast } from './toast.js';
import { ensureCalendarPopoverStyles } from './calendarPopover.js';
const CATEGORY_ICONS = {
pieczywo: 'fa-bread-slice',
@@ -76,13 +77,14 @@ function macroLine(n) {
function mediaHtml(image, icon, sizeClass = 'w-9 h-9', radiusClass = 'rounded-lg') {
if (image) {
return `<img src="${esc(image)}" alt="" class="${sizeClass} ${radiusClass} object-cover shrink-0">`;
const fit = image.endsWith('.svg') ? 'object-contain' : 'object-cover';
return `<img src="${esc(image)}" alt="" class="${sizeClass} ${radiusClass} ${fit} shrink-0">`;
}
return `<div class="${sizeClass} ${radiusClass} flex items-center justify-center shrink-0" style="background:#2f2f2d;"><i class="fas ${icon} text-sm" style="color:#8f8b84;"></i></div>`;
return `<div class="${sizeClass} ${radiusClass} flex items-center justify-center shrink-0" style="background:transparent;"><i class="fas ${icon} text-sm" style="color:rgb(var(--text-faint-rgb));"></i></div>`;
}
function compactMetaText(text, tone = 'default') {
const color = tone === 'success' ? '#6ee7b7' : tone === 'muted' ? '#9b978f' : '#d7d2c8';
const color = tone === 'success' ? 'rgb(var(--success-rgb))' : tone === 'muted' ? 'rgb(var(--text-dim-rgb))' : 'rgb(var(--text-body-soft-rgb))';
return `<span class="text-[10px] font-medium" style="color:${color};">${esc(text)}</span>`;
}
@@ -122,6 +124,11 @@ function formatPackCount(amount, packSize) {
return `${formatPreciseQty((Number(amount) || 0) / Number(packSize))} opak.`;
}
function parseQtyInput(value) {
const normalized = String(value ?? '').trim().replace(',', '.');
return normalizeQty(Number(normalized) || 0);
}
function getQtyStepMeta(def, product = null) {
const productPackSize = Number(product?.packSize);
if (Number.isFinite(productPackSize) && productPackSize > 0) {
@@ -151,33 +158,39 @@ function getQtyStepMeta(def, product = null) {
export function getIngredientCardHTML({
idBase,
overlayClass = 'fixed inset-0 z-[70] hidden opacity-0 transition-opacity duration-200 flex items-center justify-center p-5',
overlayStyle = 'pointer-events:none; background:rgba(0,0,0,0.5);',
cardClass = 'relative w-full max-w-xs rounded-2xl shadow-2xl overflow-hidden',
cardStyle = 'background:#2d2e2b; pointer-events:auto; max-height:85vh; overflow-y:auto; transform:translateY(0.75rem); opacity:0; transition:transform 220ms ease, opacity 220ms ease;',
heroHeightClass = 'h-[180px]',
overlayStyle = 'pointer-events:none;',
cardClass = 'calendar-liquid-panel relative w-full max-w-xs rounded-2xl overflow-hidden',
cardStyle = 'pointer-events:auto; max-height:85vh; overflow-y:auto; transform:translateY(0.75rem); opacity:0; transition:transform 220ms ease, opacity 220ms ease;',
} = {}) {
if (!idBase) throw new Error('getIngredientCardHTML requires idBase');
ensureCalendarPopoverStyles();
return `
<div id="${idBase}-overlay" class="${overlayClass}" style="${overlayStyle}">
<div id="${idBase}" class="${cardClass}" style="${cardStyle}">
<div class="relative w-full ${heroHeightClass} overflow-hidden" style="background:#393937;">
<img id="${idBase}-img" class="w-full h-full object-cover hidden" alt="" />
<div id="${idBase}-fallback" class="w-full h-full flex items-center justify-center">
<i id="${idBase}-fallback-icon" class="fas fa-box-open text-3xl" style="color:#6d6c67;"></i>
<div class="relative px-4 pt-4 pb-2">
<div class="flex items-start gap-3 pr-10">
<div id="${idBase}-hero" class="relative w-20 h-20 rounded-2xl flex items-center justify-center shrink-0 overflow-hidden" style="background:transparent;">
<img id="${idBase}-img" class="w-full h-full hidden" alt="" />
<div id="${idBase}-fallback" class="absolute inset-0 flex items-center justify-center">
<i id="${idBase}-fallback-icon" class="fas fa-box-open text-2xl" style="color:rgb(var(--text-subdued-rgb));"></i>
</div>
</div>
<div class="flex-1 min-w-0 pt-0.5">
<div class="flex items-center gap-1.5">
<button type="button" id="${idBase}-back" class="calendar-liquid-btn hidden w-5 h-5 rounded-full items-center justify-center shrink-0" style="color:rgb(var(--text-body-rgb));" aria-label="Wróć do składnika">
<i class="fas fa-chevron-left text-[9px]"></i>
</button>
<p id="${idBase}-category" class="text-[10px] font-semibold uppercase tracking-wider truncate" style="color:rgb(var(--success-rgb));"></p>
</div>
<h3 id="${idBase}-name" class="text-[15px] font-bold leading-snug mt-0.5" style="color:rgb(var(--text-body-rgb));"></h3>
<p id="${idBase}-subtitle" class="text-[11px] mt-0.5 hidden" style="color:rgb(var(--text-dim-rgb));"></p>
</div>
</div>
<button type="button" id="${idBase}-back" class="absolute top-3 left-3 w-8 h-8 rounded-full hidden flex items-center justify-center" style="background:rgba(0,0,0,0.5); color:#fff;" aria-label="Wróć do składnika">
<i class="fas fa-chevron-left text-sm"></i>
</button>
<button type="button" id="${idBase}-close" class="absolute top-3 right-3 w-8 h-8 rounded-full flex items-center justify-center" style="background:rgba(0,0,0,0.5); color:#fff;" aria-label="Zamknij">
<button type="button" id="${idBase}-close" class="calendar-liquid-btn absolute top-3 right-3 w-8 h-8 rounded-full flex items-center justify-center" style="color:rgb(var(--text-body-rgb));" aria-label="Zamknij">
<i class="fas fa-times text-sm"></i>
</button>
</div>
<div class="px-4 pt-3 pb-4 space-y-3">
<div>
<p id="${idBase}-category" class="text-[10px] font-semibold uppercase tracking-wider" style="color:#6ee7b7;"></p>
<h3 id="${idBase}-name" class="text-[15px] font-bold leading-snug mt-0.5" style="color:#ddd6ca;"></h3>
<p id="${idBase}-subtitle" class="text-[11px] mt-0.5 hidden" style="color:#9b978f;"></p>
</div>
<div class="px-4 pt-2 pb-4 space-y-3">
<div id="${idBase}-nutrition"></div>
<div id="${idBase}-stock"></div>
<div id="${idBase}-products"></div>
@@ -215,55 +228,63 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
state.shopDraftQty = null;
}
function getCurrentShoppingItem(def) {
function getShoppingItemFor(def, product) {
const shopping = loadShoppingState();
const kitchen = shopping.lists.find((list) => list.id === KITCHEN_LIST_ID && list.type === 'kitchen');
if (!kitchen || kitchen.type !== 'kitchen') return null;
const unit = unitLabel(def.pantryUnit);
const targetProductId = product?.id || '';
return kitchen.items.find((item) => {
if (item.checked) return false;
return item.ingredientId === state.ingredientId
return item.ingredientId === def.id
&& item.unit === unit
&& (item.productId || '') === (state.productId || '');
&& (item.productId || '') === targetProductId;
}) || null;
}
function renderHeader(def, product, pantry) {
const hasProducts = ingredientHasProducts(def.id);
const isListMode = hasProducts && state.allowProductSelection && !state.productId;
const isBackAvailable = hasProducts && state.allowProductSelection && state.productId;
const icon = CATEGORY_ICONS[def.category] || 'fa-jar';
const image = product?.image || def.image;
const heroEl = el('hero');
const img = el('img');
const fallback = el('fallback');
const fallbackIcon = el('fallback-icon');
if (img && fallback) {
const image = isListMode ? def.image : (product?.image || def.image);
const altName = isListMode ? def.name : (product?.name || def.name);
if (image) {
img.src = image;
img.alt = product?.name || def.name;
img.alt = altName;
const isSvg = image.endsWith('.svg');
img.classList.toggle('object-contain', isSvg);
img.classList.toggle('object-cover', !isSvg);
img.style.padding = isSvg ? '4px' : '';
img.classList.remove('hidden');
fallback.classList.add('hidden');
} else {
img.classList.add('hidden');
fallback.classList.remove('hidden');
if (fallbackIcon) fallbackIcon.className = `fas ${icon} text-3xl`;
if (fallbackIcon) fallbackIcon.className = `fas ${icon} text-2xl`;
}
}
const totalQty = getPantryTotal(def.id, pantry);
const categoryEl = el('category');
const nameEl = el('name');
const subtitleEl = el('subtitle');
const backBtn = el('back');
if (categoryEl) categoryEl.textContent = product?.brand || CATEGORY_LABELS[def.category] || def.category;
if (nameEl) nameEl.textContent = product?.name || def.name;
const displayProduct = isListMode ? null : product;
if (categoryEl) categoryEl.textContent = displayProduct?.brand || CATEGORY_LABELS[def.category] || def.category;
if (nameEl) nameEl.textContent = displayProduct?.name || def.name;
if (subtitleEl) {
let subtitle = '';
if (product) {
subtitle = [def.name, product.packLabel].filter(Boolean).join(' • ');
} else if (hasProducts) {
subtitle = `${productCountLabel(getProductsForIngredient(def.id).length)}${formatQtyWithUnit(totalQty, def.pantryUnit)} na stanie`;
} else if (def.purchasePack?.label) {
if (displayProduct) {
subtitle = [def.name, displayProduct.packLabel].filter(Boolean).join(' • ');
} else if (!hasProducts && def.purchasePack?.label) {
subtitle = def.purchasePack.label;
}
@@ -276,20 +297,26 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
}
if (backBtn) {
backBtn.classList.toggle('hidden', !(hasProducts && state.productId && state.allowProductSelection));
backBtn.classList.toggle('hidden', !isBackAvailable);
backBtn.classList.toggle('flex', Boolean(isBackAvailable));
}
}
function renderNutrition(def, product) {
const wrap = el('nutrition');
if (!wrap) return;
const hasProducts = ingredientHasProducts(def.id);
const isListMode = hasProducts && state.allowProductSelection && !state.productId;
if (isListMode) {
wrap.innerHTML = '';
return;
}
const nutrition = product?.nutritionPer100g || def.nutritionPer100g;
if (!nutrition) {
wrap.innerHTML = '';
return;
}
const hasProducts = ingredientHasProducts(def.id);
const unitScope = def.pantryUnit === 'ml' ? 'na 100 ml' : 'na 100 g';
const hint = product
? ''
@@ -299,62 +326,33 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
const nutritionMeta = hint ? `${unitScope}${hint}` : unitScope;
wrap.innerHTML = `
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:#9b978f;">Wartości odżywcze</p>
<p class="text-[10px] mb-1.5" style="color:#9b978f;">${esc(nutritionMeta)}</p>
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:rgb(var(--text-dim-rgb));">Wartości odżywcze</p>
<p class="text-[10px] mb-1.5" style="color:rgb(var(--text-dim-rgb));">${esc(nutritionMeta)}</p>
<div class="grid grid-cols-4 gap-1.5">
<div class="rounded-xl px-2 py-1.5 text-center" style="background:#393937;">
<p class="text-[15px] font-bold tabular-nums leading-tight" style="color:#ddd6ca;">${nutrition.kcal}</p>
<p class="text-[9px] font-medium" style="color:#9b978f;">kcal</p>
<div class="rounded-xl px-2 py-1.5 text-center" style="background:rgb(var(--card-rgb));">
<p class="text-[15px] font-bold tabular-nums leading-tight" style="color:rgb(var(--text-body-rgb));">${nutrition.kcal}</p>
<p class="text-[9px] font-medium" style="color:rgb(var(--text-dim-rgb));">kcal</p>
</div>
<div class="rounded-xl px-2 py-1.5 text-center" style="background:#393937;">
<div class="rounded-xl px-2 py-1.5 text-center" style="background:rgb(var(--card-rgb));">
<p class="text-[15px] font-bold text-blue-400 tabular-nums leading-tight">${formatQty(nutrition.protein)}g</p>
<p class="text-[9px] font-medium" style="color:#9b978f;">białko</p>
<p class="text-[9px] font-medium" style="color:rgb(var(--text-dim-rgb));">białko</p>
</div>
<div class="rounded-xl px-2 py-1.5 text-center" style="background:#393937;">
<div class="rounded-xl px-2 py-1.5 text-center" style="background:rgb(var(--card-rgb));">
<p class="text-[15px] font-bold text-amber-400 tabular-nums leading-tight">${formatQty(nutrition.fat)}g</p>
<p class="text-[9px] font-medium" style="color:#9b978f;">tłuszcz</p>
<p class="text-[9px] font-medium" style="color:rgb(var(--text-dim-rgb));">tłuszcz</p>
</div>
<div class="rounded-xl px-2 py-1.5 text-center" style="background:#393937;">
<div class="rounded-xl px-2 py-1.5 text-center" style="background:rgb(var(--card-rgb));">
<p class="text-[15px] font-bold text-orange-400 tabular-nums leading-tight">${formatQty(nutrition.carbs)}g</p>
<p class="text-[9px] font-medium" style="color:#9b978f;">węgl.</p>
<p class="text-[9px] font-medium" style="color:rgb(var(--text-dim-rgb));">węgl.</p>
</div>
</div>`;
}
function renderStock() {
const wrap = el('stock');
if (!wrap || !state.ingredientId) return;
const def = INGREDIENTS[state.ingredientId];
if (!def) return;
const pantry = loadPantry();
const hasProducts = ingredientHasProducts(state.ingredientId);
const product = state.productId ? PRODUCTS[state.productId] : null;
const totalQty = getPantryTotal(state.ingredientId, pantry);
function renderStockEditorInto(wrap, def, product, pantry) {
const totalQty = getPantryTotal(def.id, pantry);
const unit = unitLabel(def.pantryUnit);
if (hasProducts && !product) {
const stockedCount = getPantryProducts(state.ingredientId, pantry).filter((i) => i.qty > 0).length;
const helperChip = state.allowProductSelection
? '<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-semibold shrink-0" style="background:#2f2f2d; color:#d7d2c8;">Wybierz produkt</span>'
: '';
wrap.innerHTML = `
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:#9b978f;">Zapas</p>
<div class="rounded-2xl border px-3 py-3" style="background:#393937; border-color:#444442;">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-[10px] font-semibold uppercase tracking-wide" style="color:#9b978f;">Stan łączny</p>
<p class="text-[16px] font-bold tabular-nums mt-1" style="color:#6ee7b7;">${esc(formatQty(totalQty))} ${esc(unit)}</p>
<p class="text-[11px] mt-1 leading-snug" style="color:#9b978f;">${stockedCount} z ${getProductsForIngredient(state.ingredientId).length} produktów ma zapas</p>
</div>
${helperChip}
</div>
</div>`;
return;
}
const qty = product
? (getPantryProducts(state.ingredientId, pantry).find((i) => i.productId === state.productId)?.qty || 0)
? (getPantryProducts(def.id, pantry).find((i) => i.productId === product.id)?.qty || 0)
: totalQty;
const { step, usesPackStep } = getQtyStepMeta(def, product);
const packSize = product?.packSize || def.purchasePack?.amount || 0;
@@ -373,35 +371,35 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
const actionLabel = state.stockEditorOpen ? 'Anuluj' : 'Zmień';
wrap.innerHTML = `
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:#9b978f;">Zapas</p>
<div class="rounded-2xl border px-3 py-3" style="background:#393937; border-color:#444442;">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-[16px] font-bold tabular-nums" style="color:#6ee7b7;">${esc(stockValueLabel)}</p>
${stockSubLabel ? `<p class="text-[11px] mt-1" style="color:#9b978f;">${esc(stockSubLabel)}</p>` : ''}
</div>
<button type="button" class="ingredient-card-stock-toggle inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-semibold shrink-0" style="background:${state.stockEditorOpen ? '#23221e' : '#2f2f2d'}; color:${state.stockEditorOpen ? '#f2efe8' : '#d7d2c8'};">
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:rgb(var(--text-dim-rgb));">Zapas</p>
<div class="rounded-2xl border px-3 py-3" style="background:rgb(var(--card-rgb)); border-color:rgb(var(--card-strong-rgb));">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-[16px] font-bold tabular-nums" style="color:rgb(var(--success-rgb));">${esc(stockValueLabel)}</p>
${stockSubLabel ? `<p class="text-[11px] mt-1" style="color:rgb(var(--text-dim-rgb));">${esc(stockSubLabel)}</p>` : ''}
</div>
<button type="button" class="calendar-liquid-btn ingredient-card-stock-toggle inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-semibold shrink-0" style="color:${state.stockEditorOpen ? 'rgb(var(--text-emphasis-rgb))' : 'rgb(var(--text-body-soft-rgb))'};">
${esc(actionLabel)}
</button>
</div>
${state.stockEditorOpen ? `
<div class="mt-3 pt-3 border-t" style="border-color:#444442;">
<div class="mt-3 pt-3 border-t" style="border-color:rgb(var(--card-strong-rgb));">
<div class="flex items-center gap-2">
<button type="button" class="ingredient-card-stock-step w-9 h-9 rounded-xl flex items-center justify-center shrink-0" style="background:#2f2f2d; color:#d7d2c8;" data-dir="-1" aria-label="Zmniejsz szkic zapasu">
<button type="button" class="calendar-liquid-btn ingredient-card-stock-step w-9 h-9 rounded-xl flex items-center justify-center shrink-0" style="color:rgb(var(--text-body-soft-rgb));" data-dir="-1" aria-label="Zmniejsz szkic zapasu">
<i class="fas fa-minus text-xs"></i>
</button>
<label class="flex-1 rounded-xl px-3 py-2 flex items-center justify-center gap-2" style="background:#2f2f2d;">
<input type="number" min="0" step="${usesPackStep ? '1' : step}" value="${draftInputValue}" class="ingredient-card-stock-input w-20 bg-transparent text-center text-[14px] font-semibold tabular-nums outline-none appearance-none" style="color:#ddd6ca; background:transparent !important; border:none !important; box-shadow:none !important; -webkit-appearance:none; -moz-appearance:textfield;">
<span class="text-[12px] font-medium shrink-0" style="color:#9b978f;">${esc(draftInputUnit)}</span>
<label class="calendar-liquid-btn flex-1 rounded-xl px-3 py-2 flex items-center justify-center gap-2">
<input type="text" inputmode="decimal" value="${draftInputValue}" class="ingredient-card-stock-input w-20 bg-transparent text-center text-[14px] font-semibold tabular-nums outline-none appearance-none" style="color:rgb(var(--text-body-rgb)); background:transparent !important; border:none !important; box-shadow:none !important; -webkit-appearance:none; -moz-appearance:textfield;">
<span class="text-[12px] font-medium shrink-0" style="color:rgb(var(--text-dim-rgb));">${esc(draftInputUnit)}</span>
</label>
<button type="button" class="ingredient-card-stock-step w-9 h-9 rounded-xl flex items-center justify-center shrink-0" style="background:#2f2f2d; color:#d7d2c8;" data-dir="1" aria-label="Zwiększ szkic zapasu">
<button type="button" class="calendar-liquid-btn ingredient-card-stock-step w-9 h-9 rounded-xl flex items-center justify-center shrink-0" style="color:rgb(var(--text-body-soft-rgb));" data-dir="1" aria-label="Zwiększ szkic zapasu">
<i class="fas fa-plus text-xs"></i>
</button>
</div>
${usesPackStep ? `<p class="text-[10px] mt-2 text-right" style="color:#9b978f;">${esc(formatQtyWithUnit(draftQty, def.pantryUnit))}</p>` : ''}
${usesPackStep ? `<p class="text-[10px] mt-2 text-right" style="color:rgb(var(--text-dim-rgb));">${esc(formatQtyWithUnit(draftQty, def.pantryUnit))}</p>` : ''}
<div class="flex items-center justify-between gap-3 mt-3">
<button type="button" class="ingredient-card-stock-clear text-[11px] font-semibold" style="color:#9b978f;">Wyzeruj</button>
<button type="button" class="ingredient-card-stock-save inline-flex items-center rounded-full px-3 py-1.5 text-[11px] font-semibold" style="background:#ddd6ca; color:#2d2e2b;">Zapisz</button>
<button type="button" class="ingredient-card-stock-clear text-[11px] font-semibold" style="color:rgb(var(--text-dim-rgb));">Wyzeruj</button>
<button type="button" class="calendar-liquid-btn ingredient-card-stock-save inline-flex items-center rounded-full px-3 py-1.5 text-[11px] font-semibold" style="color:rgb(var(--text-emphasis-rgb));">Zapisz</button>
</div>
</div>` : ''}
</div>`;
@@ -429,8 +427,8 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
wrap.querySelector('.ingredient-card-stock-input')?.addEventListener('input', (event) => {
state.stockDraftQty = usesPackStep
? normalizeQty((Number(event.target.value) || 0) * step)
: normalizeQty(event.target.value);
? normalizeQty(parseQtyInput(event.target.value) * step)
: parseQtyInput(event.target.value);
});
wrap.querySelector('.ingredient-card-stock-clear')?.addEventListener('click', () => {
@@ -441,12 +439,12 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
wrap.querySelector('.ingredient-card-stock-save')?.addEventListener('click', () => {
const input = wrap.querySelector('.ingredient-card-stock-input');
const nextQty = usesPackStep
? normalizeQty((Number(input?.value) || 0) * step)
: normalizeQty(input?.value ?? state.stockDraftQty ?? qty);
if (state.productId) {
setPantryProductQty(state.ingredientId, state.productId, nextQty);
? normalizeQty(parseQtyInput(input?.value) * step)
: parseQtyInput(input?.value ?? state.stockDraftQty ?? qty);
if (product) {
setPantryProductQty(def.id, product.id, nextQty);
} else {
setPantryQty(state.ingredientId, nextQty);
setPantryQty(def.id, nextQty);
}
state.stockEditorOpen = false;
state.stockDraftQty = null;
@@ -455,76 +453,13 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
});
}
function productRowHtml(ingredientId, productId, pantry, selectedProductId) {
const def = INGREDIENTS[ingredientId];
const product = PRODUCTS[productId];
const icon = CATEGORY_ICONS[def.category] || 'fa-jar';
const qty = getPantryProducts(ingredientId, pantry).find((i) => i.productId === productId)?.qty || 0;
const isSelected = selectedProductId === productId;
return `<button type="button" class="ingredient-card-product-row w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left transition-colors active:scale-[0.99]" style="background:${isSelected ? '#23221e' : '#393937'}; border:${isSelected ? '1px solid #787876' : '1px solid transparent'};" data-product-id="${esc(productId)}">
${mediaHtml(product.image || def.image, icon)}
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between gap-2">
<span class="text-[13px] font-semibold truncate block" style="color:#ddd6ca;">${esc(product.name)}</span>
<span class="text-[12px] font-bold tabular-nums shrink-0" style="color:${qty > 0 ? '#ddd6ca' : '#6d6c67'};">${esc(formatQty(qty))} ${esc(unitLabel(def.pantryUnit))}</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
${compactMetaText(product.packLabel || '', 'muted')}
${isSelected ? compactMetaText('wybrany', 'success') : ''}
</div>
</div>
<i class="fas fa-chevron-right text-[10px] shrink-0" style="color:#8f8b84;"></i>
</button>`;
}
function renderProducts() {
const wrap = el('products');
if (!wrap || !state.ingredientId) return;
if (!ingredientHasProducts(state.ingredientId) || !state.allowProductSelection) {
wrap.innerHTML = '';
return;
}
const pantry = loadPantry();
const products = sortProductsByStock(getProductsForIngredient(state.ingredientId), getPantryProducts(state.ingredientId, pantry));
const selectedProductId = state.selectedProductId || state.productId;
const subtitle = state.productId
? 'Wróć lub wybierz inny wariant.'
: 'Wybierz wariant, aby zobaczyć szczegóły.';
wrap.innerHTML = `
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:#9b978f;">Produkty</p>
<p class="text-[10px] mb-1.5" style="color:#9b978f;">${esc(subtitle)}</p>
<div class="space-y-1.5">
${products.map((product) => productRowHtml(state.ingredientId, product.id, pantry, selectedProductId)).join('')}
</div>`;
wrap.querySelectorAll('.ingredient-card-product-row').forEach((btn) => {
btn.addEventListener('click', () => {
const nextProductId = btn.dataset.productId || null;
state.productId = nextProductId;
state.selectedProductId = nextProductId;
resetInlineEditors();
if (nextProductId) state.onProductChange?.(nextProductId);
render();
state.onAfterChange?.();
});
});
}
function renderShop() {
const wrap = el('shop');
if (!wrap || !state.ingredientId) return;
const def = INGREDIENTS[state.ingredientId];
if (!def) return;
const product = state.productId ? PRODUCTS[state.productId] : null;
function renderShopEditorInto(wrap, def, product) {
const { step, usesPackStep } = getQtyStepMeta(def, product);
const packSize = product?.packSize || def.purchasePack?.amount;
const packLabel = product?.packLabel || def.purchasePack?.label;
const usesPacks = Boolean(packSize && packSize > 0);
const defaultAmount = step;
const shoppingItem = getCurrentShoppingItem(def);
const shoppingItem = getShoppingItemFor(def, product);
const hasShoppingItem = Boolean(shoppingItem);
const shoppingAmount = shoppingItem?.amount || 0;
const draftQty = state.shopEditorOpen
@@ -542,40 +477,40 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
? formatPreciseQty(draftQty / step)
: formatPreciseQty(draftQty);
const shopInputUnit = usesPackStep ? 'opak.' : unitLabel(def.pantryUnit);
const actionLabel = state.shopEditorOpen ? 'Anuluj' : 'Zmień';
const actionLabel = state.shopEditorOpen ? 'Anuluj' : (hasShoppingItem ? 'Zmień' : 'Dodaj');
wrap.innerHTML = `
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:#9b978f;">Lista zakupów</p>
<div class="rounded-2xl border px-3 py-3" style="background:#393937; border-color:#444442;">
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:rgb(var(--text-dim-rgb));">Lista zakupów</p>
<div class="rounded-2xl border px-3 py-3" style="background:rgb(var(--card-rgb)); border-color:rgb(var(--card-strong-rgb));">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-[16px] font-bold tabular-nums" style="color:${hasShoppingItem ? '#ddd6ca' : '#9b978f'};">${esc(shopValueLabel)}</p>
${shopSubLabel ? `<p class="text-[11px] mt-1" style="color:#9b978f;">${esc(shopSubLabel)}</p>` : ''}
<p class="text-[16px] font-bold tabular-nums" style="color:${hasShoppingItem ? 'rgb(var(--text-body-rgb))' : 'rgb(var(--text-dim-rgb))'};">${esc(shopValueLabel)}</p>
${shopSubLabel ? `<p class="text-[11px] mt-1" style="color:rgb(var(--text-dim-rgb));">${esc(shopSubLabel)}</p>` : ''}
</div>
<button type="button" class="ingredient-card-shop-toggle inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-semibold shrink-0" style="background:${state.shopEditorOpen ? '#23221e' : '#2f2f2d'}; color:${state.shopEditorOpen ? '#f2efe8' : '#d7d2c8'};">
<button type="button" class="calendar-liquid-btn ingredient-card-shop-toggle inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-semibold shrink-0" style="color:${state.shopEditorOpen ? 'rgb(var(--text-emphasis-rgb))' : 'rgb(var(--text-body-soft-rgb))'};">
${esc(actionLabel)}
</button>
</div>
${state.shopEditorOpen ? `
<div class="mt-3 pt-3 border-t" style="border-color:#444442;">
<div class="mt-3 pt-3 border-t" style="border-color:rgb(var(--card-strong-rgb));">
<div class="flex items-center gap-2">
<button type="button" class="ingredient-card-shop-step w-9 h-9 rounded-xl flex items-center justify-center shrink-0" style="background:#2f2f2d; color:#d7d2c8;" data-dir="-1" aria-label="Zmniejsz ilość na liście">
<button type="button" class="calendar-liquid-btn ingredient-card-shop-step w-9 h-9 rounded-xl flex items-center justify-center shrink-0" style="color:rgb(var(--text-body-soft-rgb));" data-dir="-1" aria-label="Zmniejsz ilość na liście">
<i class="fas fa-minus text-xs"></i>
</button>
<label class="flex-1 rounded-xl px-3 py-2 flex items-center justify-center gap-2" style="background:#2f2f2d;">
<input type="number" min="0" step="${usesPackStep ? '1' : defaultAmount}" value="${shopInputValue}" class="ingredient-card-shop-input w-20 bg-transparent text-center text-[14px] font-semibold tabular-nums outline-none appearance-none" style="color:#ddd6ca; background:transparent !important; border:none !important; box-shadow:none !important; -webkit-appearance:none; -moz-appearance:textfield;">
<span class="text-[12px] font-medium shrink-0" style="color:#9b978f;">${esc(shopInputUnit)}</span>
<label class="calendar-liquid-btn flex-1 rounded-xl px-3 py-2 flex items-center justify-center gap-2">
<input type="text" inputmode="decimal" value="${shopInputValue}" class="ingredient-card-shop-input w-20 bg-transparent text-center text-[14px] font-semibold tabular-nums outline-none appearance-none" style="color:rgb(var(--text-body-rgb)); background:transparent !important; border:none !important; box-shadow:none !important; -webkit-appearance:none; -moz-appearance:textfield;">
<span class="text-[12px] font-medium shrink-0" style="color:rgb(var(--text-dim-rgb));">${esc(shopInputUnit)}</span>
</label>
<button type="button" class="ingredient-card-shop-step w-9 h-9 rounded-xl flex items-center justify-center shrink-0" style="background:#2f2f2d; color:#d7d2c8;" data-dir="1" aria-label="Zwiększ ilość na liście">
<button type="button" class="calendar-liquid-btn ingredient-card-shop-step w-9 h-9 rounded-xl flex items-center justify-center shrink-0" style="color:rgb(var(--text-body-soft-rgb));" data-dir="1" aria-label="Zwiększ ilość na liście">
<i class="fas fa-plus text-xs"></i>
</button>
</div>
${usesPackStep ? `<p class="text-[10px] mt-2 text-right" style="color:#9b978f;">${esc(formatQtyWithUnit(draftQty, def.pantryUnit))}</p>` : ''}
${usesPackStep ? `<p class="text-[10px] mt-2 text-right" style="color:rgb(var(--text-dim-rgb));">${esc(formatQtyWithUnit(draftQty, def.pantryUnit))}</p>` : ''}
<div class="flex items-center justify-between gap-3 mt-3">
${hasShoppingItem
? '<button type="button" class="ingredient-card-shop-remove text-[11px] font-semibold" style="color:#9b978f;">Usuń z listy</button>'
? '<button type="button" class="ingredient-card-shop-remove text-[11px] font-semibold" style="color:rgb(var(--text-dim-rgb));">Usuń z listy</button>'
: '<span></span>'}
<button type="button" class="ingredient-card-shop-save inline-flex items-center rounded-full px-3 py-1.5 text-[11px] font-semibold" style="background:#ddd6ca; color:#2d2e2b;">Zapisz</button>
<button type="button" class="calendar-liquid-btn ingredient-card-shop-save inline-flex items-center rounded-full px-3 py-1.5 text-[11px] font-semibold" style="color:rgb(var(--text-emphasis-rgb));">Zapisz</button>
</div>
</div>` : ''}
</div>`;
@@ -603,8 +538,8 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
wrap.querySelector('.ingredient-card-shop-input')?.addEventListener('input', (event) => {
state.shopDraftQty = usesPackStep
? normalizeQty((Number(event.target.value) || 0) * step)
: normalizeQty(event.target.value);
? normalizeQty(parseQtyInput(event.target.value) * step)
: parseQtyInput(event.target.value);
});
wrap.querySelector('.ingredient-card-shop-remove')?.addEventListener('click', () => {
@@ -621,8 +556,8 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
wrap.querySelector('.ingredient-card-shop-save')?.addEventListener('click', () => {
const input = wrap.querySelector('.ingredient-card-shop-input');
const nextAmount = usesPackStep
? normalizeQty((Number(input?.value) || 0) * step)
: normalizeQty(input?.value ?? state.shopDraftQty ?? defaultAmount);
? normalizeQty(parseQtyInput(input?.value) * step)
: parseQtyInput(input?.value ?? state.shopDraftQty ?? defaultAmount);
let toastText = null;
if (shoppingItem) {
updateKitchenItemAmount(KITCHEN_LIST_ID, shoppingItem.id, nextAmount);
@@ -632,14 +567,14 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
} else if (nextAmount > 0) {
const note = usesPacks ? (packLabel || `${formatQty(packSize)} ${unitLabel(def.pantryUnit)}`) : undefined;
const line = {
ingredientId: state.ingredientId,
ingredientId: def.id,
amount: nextAmount,
unit: unitLabel(def.pantryUnit),
name: product?.name || def.name,
category: def.category,
sourceNote: note || state.sourceNote || defaultSourceNote,
};
if (state.productId) line.productId = state.productId;
if (product) line.productId = product.id;
addOrMergeShoppingLines([line]);
toastText = `Dodano ${product?.name || def.name}.`;
}
@@ -652,6 +587,111 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
});
}
function renderStock() {
const wrap = el('stock');
if (!wrap || !state.ingredientId) return;
const def = INGREDIENTS[state.ingredientId];
if (!def) return;
const hasProducts = ingredientHasProducts(state.ingredientId);
const isListMode = hasProducts && state.allowProductSelection && !state.productId;
if (isListMode) {
wrap.innerHTML = '';
return;
}
const pantry = loadPantry();
const product = state.productId ? PRODUCTS[state.productId] : null;
renderStockEditorInto(wrap, def, product, pantry);
}
function productRowHtml(ingredientId, productId, pantry, kitchenItems) {
const def = INGREDIENTS[ingredientId];
const product = PRODUCTS[productId];
const icon = CATEGORY_ICONS[def.category] || 'fa-jar';
const unit = unitLabel(def.pantryUnit);
const pantryQty = getPantryProducts(ingredientId, pantry).find((i) => i.productId === productId)?.qty || 0;
const shoppingItem = kitchenItems.find((item) => !item.checked
&& item.ingredientId === ingredientId
&& item.unit === unit
&& (item.productId || '') === productId);
const shoppingAmount = shoppingItem?.amount || 0;
const pantryLabel = pantryQty > 0 ? `${formatQty(pantryQty)} ${unit}` : '—';
const pantryColor = pantryQty > 0 ? 'rgb(var(--text-body-rgb))' : 'rgb(var(--text-subdued-rgb))';
const shoppingLabel = shoppingAmount > 0 ? `${formatQty(shoppingAmount)} ${unit}` : '';
return `<button type="button" class="ingredient-card-product-row w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left transition-colors active:scale-[0.99]" style="background:rgb(var(--card-rgb)); border:1px solid transparent;" data-product-id="${esc(productId)}">
${mediaHtml(product.image || def.image, icon)}
<div class="flex-1 min-w-0">
<span class="text-[13px] font-semibold truncate block" style="color:rgb(var(--text-body-rgb));">${esc(product.name)}</span>
${product.packLabel ? `<span class="text-[10px] block mt-0.5" style="color:rgb(var(--text-dim-rgb));">${esc(product.packLabel)}</span>` : ''}
</div>
<div class="flex flex-col items-end gap-0.5 shrink-0 tabular-nums">
<span class="text-[11px] font-semibold inline-flex items-center gap-1" style="color:${pantryColor};">
<i class="fas fa-box text-[9px]" style="color:rgb(var(--text-faint-rgb));"></i>${esc(pantryLabel)}
</span>
${shoppingLabel ? `<span class="text-[11px] font-semibold inline-flex items-center gap-1" style="color:rgb(var(--text-body-rgb));">
<i class="fas fa-cart-shopping text-[9px]" style="color:rgb(var(--text-faint-rgb));"></i>${esc(shoppingLabel)}
</span>` : ''}
</div>
<i class="fas fa-chevron-right text-[10px] shrink-0" style="color:rgb(var(--text-faint-rgb));"></i>
</button>`;
}
function renderProducts() {
const wrap = el('products');
if (!wrap || !state.ingredientId) return;
const hasProducts = ingredientHasProducts(state.ingredientId);
const isListMode = hasProducts && state.allowProductSelection && !state.productId;
if (!isListMode) {
wrap.innerHTML = '';
return;
}
const pantry = loadPantry();
const products = sortProductsByStock(getProductsForIngredient(state.ingredientId), getPantryProducts(state.ingredientId, pantry));
const shopping = loadShoppingState();
const kitchen = shopping.lists.find((list) => list.id === KITCHEN_LIST_ID && list.type === 'kitchen');
const kitchenItems = (kitchen && kitchen.type === 'kitchen') ? kitchen.items : [];
wrap.innerHTML = `
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:rgb(var(--text-dim-rgb));">Produkty</p>
<div class="space-y-1.5">
${products.map((product) => productRowHtml(state.ingredientId, product.id, pantry, kitchenItems)).join('')}
</div>`;
wrap.querySelectorAll('.ingredient-card-product-row').forEach((btn) => {
btn.addEventListener('click', () => {
const nextProductId = btn.dataset.productId || null;
if (!nextProductId) return;
state.productId = nextProductId;
state.selectedProductId = nextProductId;
resetInlineEditors();
state.onProductChange?.(nextProductId);
render();
state.onAfterChange?.();
});
});
}
function renderShop() {
const wrap = el('shop');
if (!wrap || !state.ingredientId) return;
const def = INGREDIENTS[state.ingredientId];
if (!def) return;
const hasProducts = ingredientHasProducts(state.ingredientId);
const isListMode = hasProducts && state.allowProductSelection && !state.productId;
if (isListMode) {
wrap.innerHTML = '';
return;
}
const product = state.productId ? PRODUCTS[state.productId] : null;
renderShopEditorInto(wrap, def, product);
}
function render() {
if (!state.ingredientId) return;
const def = INGREDIENTS[state.ingredientId];

View File

@@ -1,6 +1,9 @@
import {
addDays,
addMonths,
addWeeks,
sameDay,
sameMonth,
startOfDay,
startOfMonth,
startOfWeekMonday,
@@ -19,31 +22,75 @@ export const CALENDAR_MONTHS_SHORT = [
export const CALENDAR_WEEKDAYS_SHORT = ['pn', 'wt', 'śr', 'cz', 'pt', 'so', 'nd'];
export const CALENDAR_DAY_ATTR = 'data-calendar-day';
export const CALENDAR_HANDLE_CLASS = 'block h-1 w-10 rounded-full bg-[#6d6c67]/75';
export const CALENDAR_HANDLE_CLASS = 'block h-1 w-10 rounded-full bg-[rgb(var(--text-subdued-rgb))]/75';
function getCalendarDayHTML(day, meta, dayState, dayAttr) {
const { mode, selectedDate, inCurrentMonth } = meta;
const isSelected = selectedDate && sameDay(day, selectedDate);
const showIndicator = !!dayState.showIndicator;
function escapeAttrValue(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;');
}
function getCalendarDayHTML(day, meta, dayState, dayAttr, theme = {}, options = {}) {
const { mode, selectedDate } = meta;
const isSelected = typeof meta.isSelected === 'boolean'
? meta.isSelected
: !!(selectedDate && sameDay(day, selectedDate));
const showIndicator = !!(dayState.showIndicator || dayState.showDot);
const isDisabled = !!dayState.disabled;
const isDimmed = !!dayState.dimmed && !isSelected;
const bg = isSelected ? '#23221e' : '#2f2f2d';
const border = isSelected ? '#787876' : '#444442';
const text = isSelected ? '#f2efe8' : (isDimmed ? '#7d7a74' : '#d7d2c8');
const dot = isSelected ? '#f2efe8' : '#a59f92';
const opacity = isDimmed ? '0.72' : '1';
const outerClass = `${mode === 'month' ? 'mx-auto ' : ''}flex h-[2.05rem] w-full min-w-0 max-w-full items-center justify-center rounded-full border text-xs font-medium transition-colors leading-tight overflow-hidden`;
const defaultBg = 'rgb(var(--card-soft-rgb))';
const defaultBorder = 'rgb(var(--card-strong-rgb))';
const defaultText = 'rgb(var(--text-body-soft-rgb))';
let bg;
let borderColor;
let text;
let borderClass = theme.borderClass || 'border';
let shadow = theme.shadow || 'none';
if (isSelected) {
const keepDimmedBg = !!dayState.dimmed
&& theme.selectedBg == null
&& theme.selectedUsesDimmedBg !== false;
bg = theme.selectedBg
|| (keepDimmedBg ? (theme.dimmedBg ?? 'transparent') : null)
|| theme.bg
|| 'rgb(var(--card-rgb))';
borderColor = theme.selectedBorder || 'rgb(var(--border-input-rgb))';
text = theme.selectedText || 'rgb(var(--text-emphasis-rgb))';
shadow = theme.selectedShadow || shadow;
borderClass = theme.selectedBorderClass || 'border';
} else if (isDimmed) {
bg = theme.dimmedBg ?? 'transparent';
text = theme.dimText || 'rgb(var(--text-faint-rgb))';
borderClass = theme.dimmedBorderClass || 'border-0';
} else {
bg = theme.bg || defaultBg;
borderColor = theme.border || defaultBorder;
text = theme.text || defaultText;
}
const dot = isSelected ? (theme.selectedDot || 'rgb(var(--text-emphasis-rgb))') : (theme.dot || 'rgb(var(--text-faint-rgb))');
const opacity = isDimmed ? String(theme.dimOpacity ?? 0.72) : '1';
const borderStyle = borderColor ? `border-color:${borderColor};` : 'border:none;';
const extraClass = !isDisabled && options.dayClassName ? ` ${options.dayClassName}` : '';
const outerClass = `${mode === 'month' ? 'mx-auto ' : ''}flex h-[2.05rem] w-full min-w-0 max-w-full items-center justify-center rounded-full ${borderClass}${extraClass} text-xs font-medium transition-colors leading-tight overflow-hidden`;
const innerClass = mode === 'month'
? 'relative flex h-full w-full flex-col items-center justify-center'
: 'relative flex h-full w-full items-center justify-center';
const dotBottom = mode === 'month' ? '0.24rem' : '0.2rem';
const tagName = isDisabled ? 'div' : 'button';
const buttonAttrs = isDisabled ? '' : ` type="button" ${dayAttr}="${day.getTime()}"`;
const dayAttrValue = typeof options.getDayAttrValue === 'function'
? options.getDayAttrValue(day, meta)
: day.getTime();
const buttonAttrs = isDisabled ? '' : ` type="button" ${dayAttr}="${escapeAttrValue(dayAttrValue)}"`;
const dayStyle = options.dayStyle || '';
return `
<${tagName}${buttonAttrs}
class="${outerClass}"
style="background:${bg};border-color:${border};color:${text};opacity:${opacity};">
style="background:${bg};${borderStyle}color:${text};opacity:${opacity};box-shadow:${shadow};${dayStyle}">
<span class="${innerClass}">
<span class="text-[13px] font-semibold leading-none ${showIndicator ? '-translate-y-[0.18rem]' : ''}">${day.getDate()}</span>
${showIndicator
@@ -54,12 +101,13 @@ function getCalendarDayHTML(day, meta, dayState, dayAttr) {
`;
}
function getMonthCells(monthAnchor) {
export function getCalendarMonthCells(monthAnchor, { fixedWeekCount = null } = {}) {
const first = startOfMonth(monthAnchor);
const startGrid = startOfWeekMonday(first);
const cells = [];
for (let i = 0; i < 42; i++) cells.push(addDays(startGrid, i));
while (cells.length > 35 && cells.slice(-7).every((day) => day.getMonth() !== first.getMonth())) {
const maxCells = fixedWeekCount ? fixedWeekCount * 7 : 42;
for (let i = 0; i < maxCells; i++) cells.push(addDays(startGrid, i));
while (!fixedWeekCount && cells.length > 35 && cells.slice(-7).every((day) => day.getMonth() !== first.getMonth())) {
cells.splice(-7);
}
return { cells, month: first.getMonth() };
@@ -78,47 +126,79 @@ function getDayState(day, meta, resolveDayState) {
return {
disabled: !!resolved.disabled,
dimmed: !!resolved.dimmed,
showIndicator: !!resolved.showIndicator,
showIndicator: !!(resolved.showIndicator || resolved.showDot),
};
}
export function createCalendarWeekdayHeaderHTML(labels = CALENDAR_WEEKDAYS_SHORT) {
export function createCalendarWeekdayHeaderHTML(labels = CALENDAR_WEEKDAYS_SHORT, {
wrapperClass = 'grid grid-cols-7 gap-1.5 text-center text-[8px] font-medium text-gray-400 uppercase tracking-wide mb-1 leading-none',
} = {}) {
return `
<div class="grid grid-cols-7 gap-1.5 text-center text-[8px] font-medium text-gray-400 uppercase tracking-wide mb-1 leading-none">
<div class="${wrapperClass}">
${labels.map((label) => `<div>${label}</div>`).join('')}
</div>
`;
}
export function createCalendarTopbarHTML({
titleId,
prevId,
todayId,
nextId,
wrapperClass = 'px-4 pt-4 pb-3 flex items-center gap-3',
titleClass = 'text-[18px] font-semibold text-gray-900 leading-none tracking-[-0.03em]',
wrapperClass = 'px-4 pt-4 pb-3 flex items-center justify-end',
controlsStyle = 'background:transparent;border-color:rgb(var(--card-strong-rgb));',
todayButtonActiveClass = 'h-full shrink-0 inline-flex min-w-[7.25rem] max-w-[12.5rem] items-center justify-center rounded-full bg-transparent px-3 text-[10px] font-semibold leading-none tabular-nums text-[rgb(var(--text-body-soft-rgb))] active:bg-transparent whitespace-nowrap',
todayButtonDimClass = 'h-full shrink-0 inline-flex items-center justify-center rounded-full px-3 text-[10px] font-semibold leading-none text-[rgb(var(--text-faint-rgb))] cursor-default',
}) {
return `
<div class="${wrapperClass}">
<div class="min-w-0 flex-1">
<p id="${titleId}" class="${titleClass}"></p>
</div>
<div class="shrink-0 flex h-[2.3rem] items-center gap-0.5 rounded-full border px-1" style="background:#2f2f2d;border-color:#444442;">
<button type="button" id="${prevId}" class="shrink-0 w-8 h-full flex items-center justify-center rounded-full border-0 bg-transparent text-[#d7d2c8] transition-colors" aria-label="Poprzedni okres">
<i class="fas fa-chevron-left text-[11px]" aria-hidden="true"></i>
</button>
<button type="button" id="${todayId}" title="Dziś" aria-label="Przejdź do dzisiejszego dnia"
class="h-full shrink-0 inline-flex items-center justify-center rounded-full px-2.5 text-[11px] font-semibold leading-none text-[#d7d2c8] transition-colors hover:bg-[#3a3a37]">
Dziś
</button>
<button type="button" id="${nextId}" class="shrink-0 w-8 h-full flex items-center justify-center rounded-full border-0 bg-transparent text-[#d7d2c8] transition-colors" aria-label="Następny okres">
<i class="fas fa-chevron-right text-[11px]" aria-hidden="true"></i>
<div class="flex h-[2.05rem] min-w-0 max-w-[min(100%,20rem)] items-center justify-center rounded-full border" style="${controlsStyle}">
<button type="button" id="${todayId}"
class="${todayButtonActiveClass}"
data-cal-active-class="${todayButtonActiveClass}"
data-cal-dim-class="${todayButtonDimClass}">
</button>
</div>
</div>
`;
}
export function createCollapsibleCalendarHTML({
idPrefix = 'calendar',
swipeZoneId = `${idPrefix}-swipe-zone`,
weekWrapId = `${idPrefix}-week-wrap`,
weekGridId = `${idPrefix}-week-grid`,
monthWrapId = `${idPrefix}-month-wrap`,
monthGridId = `${idPrefix}-month-grid`,
toggleId = `${idPrefix}-mode-toggle`,
iconId = `${idPrefix}-handle-icon`,
wrapperClass = 'overflow-x-hidden bg-[rgb(var(--app-bg-rgb))]',
wrapperStyle = 'touch-action: none',
weekWrapClass = 'px-3 overflow-x-hidden bg-[rgb(var(--app-bg-rgb))]',
weekWrapStyle = 'overflow: hidden; max-height: 10rem; opacity: 1; padding-bottom: 0.75rem',
monthWrapClass = 'px-3 bg-[rgb(var(--app-bg-rgb))]',
monthWrapStyle = 'overflow: hidden; max-height: 0; opacity: 0; padding-bottom: 0',
weekGridClass = 'grid grid-cols-7 gap-1.5 max-w-full overflow-x-hidden',
monthGridClass = 'grid grid-cols-7 gap-1.5',
toggleClass = 'w-full flex items-center justify-center py-1 pb-2 pt-0.5 text-[rgb(var(--text-faint-rgb))] hover:text-[rgb(var(--text-body-soft-rgb))] transition-colors',
toggleLabel = 'Przełącz widok kalendarza',
weekdayLabels = CALENDAR_WEEKDAYS_SHORT,
weekdayHeaderOptions = {},
} = {}) {
return `
<div id="${swipeZoneId}" class="${wrapperClass}" style="${wrapperStyle}">
<div id="${weekWrapId}" class="${weekWrapClass}" style="${weekWrapStyle}">
${createCalendarWeekdayHeaderHTML(weekdayLabels, weekdayHeaderOptions)}
<div id="${weekGridId}" class="${weekGridClass}"></div>
</div>
<div id="${monthWrapId}" class="${monthWrapClass}" style="${monthWrapStyle}">
${createCalendarWeekdayHeaderHTML(weekdayLabels, weekdayHeaderOptions)}
<div id="${monthGridId}" class="${monthGridClass}"></div>
</div>
<button id="${toggleId}" type="button" class="${toggleClass}" aria-label="${toggleLabel}">
<i id="${iconId}" class="fas fa-chevron-down text-[10px]"></i>
</button>
</div>
`;
}
export function formatCalendarMonthYear(date) {
return `${CALENDAR_MONTHS_LONG[date.getMonth()]} ${date.getFullYear()}`;
}
@@ -127,6 +207,27 @@ export function formatCalendarSelectedDate(date) {
return `${date.getDate()} ${CALENDAR_MONTHS_SHORT[date.getMonth()]} ${date.getFullYear()}`;
}
export function formatCalendarWeekRange(weekAnchorDate) {
const start = startOfWeekMonday(weekAnchorDate);
const end = addDays(start, 6);
const sameYear = start.getFullYear() === end.getFullYear();
const sameMonth = sameYear && start.getMonth() === end.getMonth();
if (sameMonth) {
return `${start.getDate()}${end.getDate()} ${CALENDAR_MONTHS_SHORT[end.getMonth()]} ${end.getFullYear()}`;
}
if (sameYear) {
return `${start.getDate()} ${CALENDAR_MONTHS_SHORT[start.getMonth()]} ${end.getDate()} ${CALENDAR_MONTHS_SHORT[end.getMonth()]} ${end.getFullYear()}`;
}
return `${start.getDate()} ${CALENDAR_MONTHS_SHORT[start.getMonth()]} ${start.getFullYear()} ${end.getDate()} ${CALENDAR_MONTHS_SHORT[end.getMonth()]} ${end.getFullYear()}`;
}
export function formatCalendarPeriodLabel(mode, weekAnchorDate, monthAnchorDate) {
return mode === 'week'
? formatCalendarWeekRange(weekAnchorDate)
: formatCalendarMonthYear(monthAnchorDate);
}
export function isCalendarOnToday(mode, weekStart, monthAnchor, selectedDate) {
const today = startOfDay(new Date());
if (!sameDay(selectedDate, today)) return false;
@@ -134,13 +235,28 @@ export function isCalendarOnToday(mode, weekStart, monthAnchor, selectedDate) {
return startOfMonth(monthAnchor).getTime() === startOfMonth(today).getTime();
}
export function syncCalendarTodayButton(buttonEl, isOnToday) {
/**
* Środkowy przycisk pokazuje wybraną datę; działa jak „Dziś” (skok do bieżącego okresu).
* Styl pozostaje jak aktywny — bez wyciszania przy isOnToday.
*/
export function syncCalendarTodayButton(buttonEl, isOnToday, selectedDate, options = {}) {
if (!buttonEl) return;
const base = 'h-full shrink-0 inline-flex items-center justify-center rounded-full px-2.5 text-[11px] font-semibold leading-none transition-colors';
const active = `${base} text-[#d7d2c8] hover:bg-[#3a3a37]`;
const dim = `${base} text-[#7d7a74] cursor-default`;
buttonEl.className = isOnToday ? dim : active;
buttonEl.disabled = isOnToday;
const {
ariaLabelGo = 'Przejdź do dzisiejszego dnia',
ariaLabelCurrent = 'Widok jest ustawiony na bieżący okres',
labelText,
} = options;
const active = buttonEl.dataset.calActiveClass
|| 'h-full shrink-0 inline-flex min-w-[7.25rem] max-w-[12.5rem] items-center justify-center rounded-full bg-transparent px-3 text-[10px] font-semibold leading-none tabular-nums text-[rgb(var(--text-body-soft-rgb))] active:bg-transparent whitespace-nowrap';
if (labelText != null) {
buttonEl.textContent = labelText;
} else if (selectedDate != null) {
buttonEl.textContent = formatCalendarSelectedDate(selectedDate);
}
buttonEl.className = active;
buttonEl.removeAttribute('disabled');
buttonEl.setAttribute('aria-disabled', isOnToday ? 'true' : 'false');
buttonEl.setAttribute('aria-label', isOnToday ? ariaLabelCurrent : ariaLabelGo);
}
export function renderCalendarGrid({
@@ -148,8 +264,14 @@ export function renderCalendarGrid({
mode,
anchorDate,
selectedDate,
isSelectedDate,
resolveDayState,
dayAttr = CALENDAR_DAY_ATTR,
getDayAttrValue,
dayClassName = '',
dayStyle = '',
fixedWeekCount = null,
theme,
}) {
if (!gridEl) return;
@@ -158,25 +280,48 @@ export function renderCalendarGrid({
const cells = [];
for (let i = 0; i < 7; i++) {
const day = addDays(weekStart, i);
const selected = typeof isSelectedDate === 'function'
? !!isSelectedDate(day, { mode, selectedDate, inCurrentMonth: true })
: !!(selectedDate && sameDay(day, selectedDate));
const meta = {
mode,
selectedDate,
inCurrentMonth: true,
isSelected: selected,
};
cells.push(getCalendarDayHTML(day, meta, getDayState(day, meta, resolveDayState), dayAttr));
cells.push(getCalendarDayHTML(
day,
meta,
getDayState(day, meta, resolveDayState),
dayAttr,
theme,
{ getDayAttrValue, dayClassName, dayStyle },
));
}
gridEl.innerHTML = cells.join('');
return;
}
const { cells, month } = getMonthCells(anchorDate);
const { cells, month } = getCalendarMonthCells(anchorDate, { fixedWeekCount });
gridEl.innerHTML = cells.map((day) => {
const inCurrentMonth = day.getMonth() === month;
const selected = typeof isSelectedDate === 'function'
? !!isSelectedDate(day, { mode, selectedDate, inCurrentMonth })
: !!(selectedDate && sameDay(day, selectedDate));
const meta = {
mode,
selectedDate,
inCurrentMonth: day.getMonth() === month,
inCurrentMonth,
isSelected: selected,
};
return getCalendarDayHTML(day, meta, getDayState(day, meta, resolveDayState), dayAttr);
return getCalendarDayHTML(
day,
meta,
getDayState(day, meta, resolveDayState),
dayAttr,
theme,
{ getDayAttrValue, dayClassName, dayStyle },
);
}).join('');
}
@@ -188,6 +333,7 @@ export function renderCollapsibleCalendar({
selectedDate,
resolveDayState,
dayAttr = CALENDAR_DAY_ATTR,
theme,
}) {
renderCalendarGrid({
gridEl: weekGridEl,
@@ -196,6 +342,7 @@ export function renderCollapsibleCalendar({
selectedDate,
resolveDayState,
dayAttr,
theme,
});
renderCalendarGrid({
gridEl: monthGridEl,
@@ -204,6 +351,7 @@ export function renderCollapsibleCalendar({
selectedDate,
resolveDayState,
dayAttr,
theme,
});
}
@@ -229,6 +377,253 @@ export function syncCollapsibleCalendarMode({
if (handleEl) handleEl.className = CALENDAR_HANDLE_CLASS;
}
export function syncCollapsibleCalendarToggleIcon(iconEl, mode) {
if (iconEl) iconEl.className = mode === 'month' ? 'fas fa-chevron-up text-[10px]' : 'fas fa-chevron-down text-[10px]';
}
export function bindCollapsibleCalendarSwipeGesture({
zoneEl,
weekWrapEl,
monthWrapEl,
getMode,
setMode,
getWeekAnchor,
setWeekAnchor,
getMonthAnchor,
setMonthAnchor,
getSelectedDate,
setSelectedDate,
rerender,
resolveDayState,
dayAttr = CALENDAR_DAY_ATTR,
theme,
selectOnNavigateOutside = true,
enableVerticalModeSwipe = true,
threshold = 40,
animationMs = 260,
} = {}) {
if (!zoneEl) return () => {};
let startX = 0;
let startY = 0;
let ptrId = null;
let moved = false;
let axisLocked = null;
let suppressClickUntil = 0;
let animatingNav = false;
let dragWrap = null;
let wrapWidth = 0;
let prevGhost = null;
let nextGhost = null;
let prevWrapPosition = '';
let prevWrapOverflow = '';
const getActiveWrap = () => (getMode?.() === 'week' ? weekWrapEl : monthWrapEl);
const buildGhost = (anchorDate, mode) => {
if (!dragWrap) return null;
const ghost = dragWrap.cloneNode(true);
ghost.removeAttribute('id');
ghost.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
ghost.style.position = 'absolute';
ghost.style.top = '0';
ghost.style.width = '100%';
ghost.style.pointerEvents = 'none';
ghost.setAttribute('aria-hidden', 'true');
let ghostGridEl = null;
ghost.querySelectorAll('.grid-cols-7').forEach((grid) => {
if (!grid.classList.contains('text-center')) ghostGridEl = grid;
});
if (ghostGridEl) {
renderCalendarGrid({
gridEl: ghostGridEl,
mode,
anchorDate,
selectedDate: getSelectedDate?.(),
resolveDayState,
dayAttr,
theme,
});
}
return ghost;
};
const activateCarousel = () => {
const mode = getMode?.() || 'week';
dragWrap = getActiveWrap();
if (!dragWrap) return;
wrapWidth = dragWrap.getBoundingClientRect().width || zoneEl.getBoundingClientRect().width;
if (wrapWidth <= 0) return;
prevWrapPosition = dragWrap.style.position;
prevWrapOverflow = dragWrap.style.overflow;
dragWrap.style.position = 'relative';
dragWrap.style.overflow = 'visible';
const prevAnchor = mode === 'week'
? addWeeks(getWeekAnchor?.() || new Date(), -1)
: addMonths(getMonthAnchor?.() || new Date(), -1);
const nextAnchor = mode === 'week'
? addWeeks(getWeekAnchor?.() || new Date(), 1)
: addMonths(getMonthAnchor?.() || new Date(), 1);
prevGhost = buildGhost(prevAnchor, mode);
nextGhost = buildGhost(nextAnchor, mode);
if (prevGhost) {
prevGhost.style.left = `-${wrapWidth}px`;
dragWrap.appendChild(prevGhost);
}
if (nextGhost) {
nextGhost.style.left = `${wrapWidth}px`;
dragWrap.appendChild(nextGhost);
}
dragWrap.style.willChange = 'transform';
dragWrap.style.transition = 'none';
};
const clearCarousel = () => {
if (prevGhost?.parentNode) prevGhost.parentNode.removeChild(prevGhost);
if (nextGhost?.parentNode) nextGhost.parentNode.removeChild(nextGhost);
prevGhost = null;
nextGhost = null;
if (dragWrap) {
dragWrap.style.transition = '';
dragWrap.style.transform = '';
dragWrap.style.willChange = '';
dragWrap.style.position = prevWrapPosition;
dragWrap.style.overflow = prevWrapOverflow;
}
dragWrap = null;
};
const setTranslate = (x, ms) => {
if (!dragWrap) return;
dragWrap.style.transition = ms ? `transform ${ms}ms ease` : 'none';
dragWrap.style.transform = `translate3d(${x}px, 0, 0)`;
};
const onPointerDown = (event) => {
if (ptrId !== null || animatingNav) return;
if (event.pointerType === 'mouse' && event.button !== 0) return;
startX = event.clientX;
startY = event.clientY;
ptrId = event.pointerId;
moved = false;
axisLocked = null;
try { zoneEl.setPointerCapture(event.pointerId); } catch (_) {}
};
const onPointerMove = (event) => {
if (event.pointerId !== ptrId) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
if (!moved && (Math.abs(dx) > 6 || Math.abs(dy) > 6)) {
moved = true;
axisLocked = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y';
if (axisLocked === 'x') activateCarousel();
}
if (axisLocked === 'x' && dragWrap) {
setTranslate(dx, 0);
}
};
const onPointerUp = (event) => {
if (event.pointerId !== ptrId) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
ptrId = null;
if (!moved) return;
const horizontal = axisLocked === 'x';
if (horizontal && dragWrap) {
if (Math.abs(dx) < threshold) {
setTranslate(0, animationMs);
setTimeout(clearCarousel, animationMs + 20);
return;
}
suppressClickUntil = performance.now() + 500;
animatingNav = true;
const targetX = dx > 0 ? wrapWidth : -wrapWidth;
setTranslate(targetX, animationMs);
setTimeout(() => {
const mode = getMode?.() || 'week';
const sign = dx > 0 ? -1 : 1;
const selected = getSelectedDate?.();
if (mode === 'week') {
const nextWeek = addWeeks(getWeekAnchor?.() || selected || new Date(), sign);
setWeekAnchor?.(nextWeek);
if (selectOnNavigateOutside && selected && !weekContains(nextWeek, selected)) {
setSelectedDate?.(new Date(nextWeek));
}
} else {
const nextMonth = addMonths(getMonthAnchor?.() || selected || new Date(), sign);
setMonthAnchor?.(nextMonth);
if (selectOnNavigateOutside && selected && !sameMonth(nextMonth, selected)) {
setSelectedDate?.(startOfMonth(nextMonth));
}
}
clearCarousel();
rerender?.();
animatingNav = false;
}, animationMs);
return;
}
if (enableVerticalModeSwipe && !horizontal && Math.abs(dy) >= 30) {
const mode = getMode?.() || 'week';
const selected = getSelectedDate?.() || new Date();
let triggered = false;
if (mode === 'week' && dy > 0) {
setMode?.('month');
setMonthAnchor?.(startOfMonth(selected));
triggered = true;
} else if (mode === 'month' && dy < 0) {
setMode?.('week');
setWeekAnchor?.(startOfWeekMonday(selected));
triggered = true;
}
if (triggered) {
suppressClickUntil = performance.now() + 350;
rerender?.();
}
}
};
const onClickCapture = (event) => {
if (performance.now() < suppressClickUntil) {
event.stopPropagation();
event.preventDefault();
suppressClickUntil = 0;
}
};
const onPointerCancel = () => {
ptrId = null;
moved = false;
if (dragWrap) {
setTranslate(0, animationMs);
setTimeout(clearCarousel, animationMs + 20);
}
};
zoneEl.addEventListener('pointerdown', onPointerDown);
zoneEl.addEventListener('pointermove', onPointerMove);
zoneEl.addEventListener('pointerup', onPointerUp);
zoneEl.addEventListener('click', onClickCapture, { capture: true });
zoneEl.addEventListener('pointercancel', onPointerCancel);
return () => {
zoneEl.removeEventListener('pointerdown', onPointerDown);
zoneEl.removeEventListener('pointermove', onPointerMove);
zoneEl.removeEventListener('pointerup', onPointerUp);
zoneEl.removeEventListener('click', onClickCapture, { capture: true });
zoneEl.removeEventListener('pointercancel', onPointerCancel);
if (dragWrap) clearCarousel();
};
}
export function bindCalendarDayClicks(containerEl, onSelect, dayAttr = CALENDAR_DAY_ATTR) {
if (!containerEl || typeof onSelect !== 'function') return;
containerEl.addEventListener('click', (event) => {
@@ -239,3 +634,208 @@ export function bindCalendarDayClicks(containerEl, onSelect, dayAttr = CALENDAR_
onSelect(new Date(ts), button, event);
});
}
/**
* Binds a carousel-style horizontal swipe on zoneEl. Swipe right → onPrev,
* swipe left → onNext. Pass `renderGhost(ghostGridEl, direction)` to render
* adjacent periods that appear alongside the zone during the gesture. The
* callback can return `false` to block that direction (ghost not added).
* Returns an unbind function.
*/
export function bindCalendarHorizontalSwipe(zoneEl, {
onPrev,
onNext,
renderGhost,
threshold = 40,
animationMs = 260,
} = {}) {
if (!zoneEl) return () => {};
let ptrId = null;
let startX = 0;
let startY = 0;
let moved = false;
let axisLocked = null;
let suppressClickUntil = 0;
let animatingNav = false;
let wrapWidth = 0;
let prevGhost = null;
let nextGhost = null;
let savedStyles = null;
let carouselActive = false;
const prevTouchAction = zoneEl.style.touchAction;
const prevUserSelect = zoneEl.style.userSelect;
zoneEl.style.touchAction = 'pan-y';
zoneEl.style.userSelect = 'none';
const buildGhost = (direction) => {
if (typeof renderGhost !== 'function') return null;
const ghost = zoneEl.cloneNode(false);
ghost.removeAttribute('id');
ghost.style.position = 'absolute';
ghost.style.top = '0';
ghost.style.width = '100%';
ghost.style.pointerEvents = 'none';
ghost.setAttribute('aria-hidden', 'true');
let ok = true;
try {
const res = renderGhost(ghost, direction);
if (res === false) ok = false;
} catch (_) { ok = false; }
return ok ? ghost : null;
};
const activateCarousel = () => {
if (typeof renderGhost !== 'function') return;
wrapWidth = zoneEl.getBoundingClientRect().width;
if (wrapWidth <= 0) return;
const parentEl = zoneEl.parentElement;
savedStyles = {
position: zoneEl.style.position,
overflow: zoneEl.style.overflow,
parentEl,
parentOverflow: parentEl ? parentEl.style.overflow : '',
};
zoneEl.style.position = 'relative';
zoneEl.style.overflow = 'visible';
if (parentEl) parentEl.style.overflow = 'hidden';
prevGhost = buildGhost('prev');
if (prevGhost) {
prevGhost.style.left = `-${wrapWidth}px`;
zoneEl.appendChild(prevGhost);
}
nextGhost = buildGhost('next');
if (nextGhost) {
nextGhost.style.left = `${wrapWidth}px`;
zoneEl.appendChild(nextGhost);
}
zoneEl.style.willChange = 'transform';
zoneEl.style.transition = 'none';
carouselActive = true;
};
const clearCarousel = () => {
if (prevGhost?.parentNode) prevGhost.parentNode.removeChild(prevGhost);
if (nextGhost?.parentNode) nextGhost.parentNode.removeChild(nextGhost);
prevGhost = null;
nextGhost = null;
if (savedStyles) {
zoneEl.style.position = savedStyles.position;
zoneEl.style.overflow = savedStyles.overflow;
if (savedStyles.parentEl) savedStyles.parentEl.style.overflow = savedStyles.parentOverflow;
savedStyles = null;
}
zoneEl.style.transition = '';
zoneEl.style.transform = '';
zoneEl.style.willChange = '';
carouselActive = false;
};
const setTranslate = (x, ms) => {
zoneEl.style.transition = ms ? `transform ${ms}ms ease` : 'none';
zoneEl.style.transform = `translate3d(${x}px, 0, 0)`;
};
const onDown = (e) => {
if (ptrId !== null || animatingNav) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
startX = e.clientX;
startY = e.clientY;
ptrId = e.pointerId;
moved = false;
axisLocked = null;
try { zoneEl.setPointerCapture(e.pointerId); } catch (_) {}
if (e.pointerType === 'mouse') e.preventDefault();
};
const onMove = (e) => {
if (e.pointerId !== ptrId) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!moved && (Math.abs(dx) > 6 || Math.abs(dy) > 6)) {
moved = true;
axisLocked = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y';
if (axisLocked === 'x') activateCarousel();
}
if (axisLocked === 'x' && carouselActive) {
let tx = dx;
if (dx > 0 && !prevGhost) tx = dx * 0.15;
if (dx < 0 && !nextGhost) tx = dx * 0.15;
setTranslate(tx, 0);
}
};
const onUp = (e) => {
if (e.pointerId !== ptrId) return;
const dx = e.clientX - startX;
ptrId = null;
if (!moved || axisLocked !== 'x' || !carouselActive) {
if (carouselActive) {
setTranslate(0, animationMs);
setTimeout(clearCarousel, animationMs + 20);
}
return;
}
const directionGhost = dx > 0 ? prevGhost : nextGhost;
const handler = dx > 0 ? onPrev : onNext;
const passes = Math.abs(dx) >= threshold
&& directionGhost
&& typeof handler === 'function';
if (!passes) {
setTranslate(0, animationMs);
setTimeout(clearCarousel, animationMs + 20);
return;
}
suppressClickUntil = performance.now() + 500;
animatingNav = true;
const targetX = dx > 0 ? wrapWidth : -wrapWidth;
setTranslate(targetX, animationMs);
setTimeout(() => {
clearCarousel();
handler();
animatingNav = false;
}, animationMs);
};
const onClickCapture = (ev) => {
if (performance.now() < suppressClickUntil) {
ev.stopPropagation();
ev.preventDefault();
suppressClickUntil = 0;
}
};
const onCancel = () => {
ptrId = null;
moved = false;
if (carouselActive) {
setTranslate(0, animationMs);
setTimeout(clearCarousel, animationMs + 20);
}
};
zoneEl.addEventListener('pointerdown', onDown);
zoneEl.addEventListener('pointermove', onMove);
zoneEl.addEventListener('pointerup', onUp);
zoneEl.addEventListener('pointercancel', onCancel);
zoneEl.addEventListener('click', onClickCapture, { capture: true });
return () => {
zoneEl.removeEventListener('pointerdown', onDown);
zoneEl.removeEventListener('pointermove', onMove);
zoneEl.removeEventListener('pointerup', onUp);
zoneEl.removeEventListener('pointercancel', onCancel);
zoneEl.removeEventListener('click', onClickCapture, { capture: true });
zoneEl.style.touchAction = prevTouchAction;
zoneEl.style.userSelect = prevUserSelect;
if (carouselActive) clearCarousel();
};
}

View File

@@ -1,10 +1,7 @@
import { INGREDIENTS, RECIPES, PRODUCTS, getProductsForIngredient } from '../data/catalog.js?v=8';
import { INGREDIENTS, RECIPES, PRODUCTS, getProductsForIngredient } from '../data/catalog.js?v=9';
import { MEAL_SLOTS } from '../planner/mealSlots.js';
import {
addDays,
addMonths,
sameDay,
sameMonth,
startOfDay,
startOfWeekMonday,
} from '../services/dateUtils.js';
@@ -17,16 +14,18 @@ import {
import { dayHasAnyMeal, autoSelectProducts, saveLastProductSelection } from '../services/planIngredients.js?v=4';
import { loadPantry } from '../services/pantryShopping.js?v=2';
import {
bindCollapsibleCalendarSwipeGesture,
bindCalendarDayClicks,
createCollapsibleCalendarHTML,
createCalendarTopbarHTML,
createCalendarWeekdayHeaderHTML,
formatCalendarMonthYear,
formatCalendarSelectedDate,
formatCalendarPeriodLabel,
isCalendarOnToday,
renderCalendarGrid,
renderCollapsibleCalendar,
syncCalendarTodayButton,
} from './mealCalendar.js?v=1';
import { createIngredientCardController, getIngredientCardHTML } from './ingredientCard.js?v=20260410-107';
syncCollapsibleCalendarMode,
syncCollapsibleCalendarToggleIcon,
} from './mealCalendar.js?v=15';
import { createIngredientCardController, getIngredientCardHTML } from './ingredientCard.js?v=20260417-116';
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
@@ -39,42 +38,52 @@ const slotLabel = Object.fromEntries(MEAL_SLOTS.map((s) => [s.id, s.label]));
export function getMealPlanEditorHTML() {
return `
<div id="mpe-overlay" class="absolute inset-0 z-[55] bg-black/45 hidden flex items-end" style="pointer-events:none">
<div id="mpe-sheet" class="w-full bg-[#2d2e2b] rounded-t-3xl shadow-lg flex flex-col overflow-hidden" style="pointer-events:auto; background:#2d2e2b !important; background-image:none !important; backdrop-filter:none !important; height:100dvh; max-height:100dvh; transform:translateY(100%); transition:transform 300ms cubic-bezier(0.32,0.72,0,1)">
<div class="shrink-0 px-5 pt-3 pb-2.5 border-b border-gray-100 bg-[#2d2e2b]" style="background:#2d2e2b !important; background-image:none !important;">
<div id="mpe-sheet" class="w-full bg-[rgb(var(--app-bg-rgb))] rounded-t-3xl shadow-lg flex flex-col overflow-hidden" style="pointer-events:auto; background:rgb(var(--app-bg-rgb)) !important; background-image:none !important; backdrop-filter:none !important; height:100dvh; max-height:100dvh; transform:translateY(100%); transition:transform 300ms cubic-bezier(0.32,0.72,0,1)">
<div class="shrink-0 px-5 pt-3 pb-2.5 border-b border-gray-100 bg-[rgb(var(--app-bg-rgb))]" style="background:rgb(var(--app-bg-rgb)) !important; background-image:none !important;">
<div class="w-10 h-1 bg-gray-200 rounded-full mx-auto mb-3"></div>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<h2 id="mpe-title" class="text-[15px] font-bold text-gray-900 leading-tight"></h2>
<p id="mpe-subtitle" class="text-[11px] text-gray-500 mt-0.5 truncate"></p>
</div>
<button id="mpe-confirm-btn" type="button" aria-label="Dodaj do planu" class="shrink-0 mt-0.5 border h-8 px-3 rounded-full font-semibold text-[12px] transition-colors inline-flex items-center justify-center gap-1.5" style="background:#dcd6cb !important; color:#2d2e2b !important; background-image:none !important; border-color:#dcd6cb !important; box-shadow:0 2px 10px rgba(0,0,0,0.18);">
<button id="mpe-confirm-btn" type="button" aria-label="Dodaj do planu" class="shrink-0 mt-0.5 border h-8 px-3 rounded-full font-semibold text-[12px] transition-colors inline-flex items-center justify-center gap-1.5" style="background:rgb(var(--text-body-rgb)) !important; color:rgb(var(--app-bg-rgb)) !important; background-image:none !important; border-color:rgb(var(--text-body-rgb)) !important; box-shadow:0 2px 10px rgba(var(--overlay-rgb),0.18);">
<i id="mpe-confirm-icon" class="fas fa-plus text-[10px]" aria-hidden="true"></i>
<span id="mpe-confirm-label">Dodaj</span>
</button>
</div>
</div>
<div id="mpe-cal-wrap" class="hidden relative z-[1] shrink-0 px-5 pt-3 pb-3 bg-[#2d2e2b]" style="background:#2d2e2b !important; background-image:none !important;">
<div id="mpe-cal-wrap" class="hidden relative z-[2] shrink-0 px-5 pt-3 pb-3 bg-[rgb(var(--app-bg-rgb))]" style="background:rgb(var(--app-bg-rgb)) !important; background-image:none !important;">
<div id="mpe-top-shadow" class="pointer-events-none absolute inset-x-0 -bottom-3 h-3 opacity-0 transition-opacity duration-200" style="background:linear-gradient(to bottom, rgba(var(--overlay-rgb),0.12), rgba(var(--overlay-rgb),0.03), rgba(var(--overlay-rgb),0));"></div>
<div id="mpe-cal-section" class="hidden">
${createCalendarTopbarHTML({
titleId: 'mpe-cal-title',
prevId: 'mpe-cal-prev',
todayId: 'mpe-cal-today',
nextId: 'mpe-cal-next',
wrapperClass: 'mb-2 flex items-center gap-3',
wrapperClass: 'mb-2 flex items-center justify-end gap-3',
})}
${createCollapsibleCalendarHTML({
idPrefix: 'mpe-cal',
swipeZoneId: 'mpe-cal-swipe-zone',
weekWrapId: 'mpe-cal-week-wrap',
weekGridId: 'mpe-cal-week-grid',
monthWrapId: 'mpe-cal-month-wrap',
monthGridId: 'mpe-cal-month-grid',
toggleId: 'mpe-cal-toggle',
iconId: 'mpe-cal-toggle-icon',
weekWrapClass: 'overflow-x-hidden bg-[rgb(var(--app-bg-rgb))]',
monthWrapClass: 'bg-[rgb(var(--app-bg-rgb))]',
weekWrapStyle: 'overflow: hidden; max-height: 10rem; opacity: 1; padding-bottom: 0.25rem',
toggleClass: 'w-full flex items-center justify-center py-1 mt-1 text-gray-400 hover:text-gray-600 transition-colors',
})}
${createCalendarWeekdayHeaderHTML()}
<div id="mpe-cal-grid" class="grid grid-cols-7 gap-1.5"></div>
<button id="mpe-cal-toggle" type="button" class="w-full flex items-center justify-center py-1 mt-1 text-gray-400 hover:text-gray-600 transition-colors"><i id="mpe-cal-toggle-icon" class="fas fa-chevron-down text-[10px]"></i></button>
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mt-3 mb-2">Pora posiłku</p>
<div id="mpe-slot-chips" class="flex flex-wrap gap-1.5"></div>
</div>
</div>
<div id="mpe-summary-wrap" class="relative z-[1] shrink-0 px-5 pb-3 bg-[#2d2e2b]" style="background:#2d2e2b !important; background-image:none !important;">
<div id="mpe-nutrition-section"></div>
<div id="mpe-servings-row" class="mt-3"></div>
<div id="mpe-top-shadow" class="pointer-events-none absolute inset-x-0 -bottom-3 h-3 opacity-0 transition-opacity duration-200" style="background:linear-gradient(to bottom, rgba(0,0,0,0.12), rgba(0,0,0,0.03), rgba(0,0,0,0));"></div>
</div>
<div id="mpe-ing-scroll" class="flex-1 min-h-0 overflow-y-auto no-scrollbar px-5 bg-[#2d2e2b]" style="background:#2d2e2b !important; background-image:none !important; padding-bottom:calc(1.5rem + env(safe-area-inset-bottom));">
<div id="mpe-ing-scroll" class="flex-1 min-h-0 overflow-y-auto no-scrollbar px-5 bg-[rgb(var(--app-bg-rgb))]" style="background:rgb(var(--app-bg-rgb)) !important; background-image:none !important; padding-bottom:calc(1.5rem + env(safe-area-inset-bottom));">
<div id="mpe-slot-section" class="pt-3 pb-1 hidden">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-2">Pora posiłku</p>
<div id="mpe-slot-chips" class="flex flex-wrap gap-1.5"></div>
</div>
<div id="mpe-summary-wrap" class="relative z-[1] pt-3 pb-3 bg-[rgb(var(--app-bg-rgb))]" style="background:rgb(var(--app-bg-rgb)) !important; background-image:none !important;">
<div id="mpe-nutrition-section"></div>
<div id="mpe-servings-row" class="mt-3"></div>
</div>
<div id="mpe-ing-section" class="mb-4">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-2">Składniki</p>
<div id="mpe-ing-list" class="space-y-1.5"></div>
@@ -159,6 +168,18 @@ export function setupMealPlanEditor() {
/* ── Calendar ──────────────────────────────────── */
function resolveCalendarDayState(day, meta, plans = loadPlans(), today = startOfDay(new Date())) {
const isSelected = S.date && sameDay(day, S.date);
const isPast = day.getTime() < today.getTime();
return {
disabled: isPast && !isSelected,
dimmed: (isPast || (meta.mode === 'month' && !meta.inCurrentMonth)) && !isSelected,
showIndicator: meta.mode === 'month'
? meta.inCurrentMonth && dayHasAnyMeal(plans, day)
: dayHasAnyMeal(plans, day),
};
}
function renderCal() {
const wrap = document.getElementById('mpe-cal-wrap');
const sec = document.getElementById('mpe-cal-section');
@@ -173,54 +194,54 @@ export function setupMealPlanEditor() {
}
wrap.classList.remove('hidden');
sec.classList.remove('hidden');
const grid = document.getElementById('mpe-cal-grid');
const title = document.getElementById('mpe-cal-title');
const weekGrid = document.getElementById('mpe-cal-week-grid');
const monthGrid = document.getElementById('mpe-cal-month-grid');
const todayBtn = document.getElementById('mpe-cal-today');
const icon = document.getElementById('mpe-cal-toggle-icon');
if (!grid || !title) return;
if (!weekGrid || !monthGrid) return;
const today = startOfDay(new Date());
const plans = loadPlans();
const mode = S.calExpanded ? 'month' : 'week';
title.textContent = S.calExpanded ? formatCalendarMonthYear(S.calDate) : formatCalendarSelectedDate(S.date);
if (icon) {
icon.className = S.calExpanded ? 'fas fa-chevron-up text-[10px]' : 'fas fa-chevron-down text-[10px]';
}
syncCollapsibleCalendarMode({
mode,
weekWrapEl: document.getElementById('mpe-cal-week-wrap'),
monthWrapEl: document.getElementById('mpe-cal-month-wrap'),
activePaddingBottom: '0.25rem',
});
syncCollapsibleCalendarToggleIcon(icon, mode);
syncCalendarTodayButton(
todayBtn,
isCalendarOnToday(mode, startOfWeekMonday(S.calDate), S.calDate, S.date),
S.date,
{
labelText: formatCalendarPeriodLabel(mode, S.calDate, S.calDate),
},
);
renderCalendarGrid({
gridEl: grid,
mode,
anchorDate: S.calDate,
renderCollapsibleCalendar({
weekGridEl: weekGrid,
monthGridEl: monthGrid,
weekAnchorDate: S.calDate,
monthAnchorDate: S.calDate,
selectedDate: S.date,
resolveDayState: (day, meta) => {
const isSelected = S.date && sameDay(day, S.date);
const isPast = day.getTime() < today.getTime();
return {
disabled: isPast && !isSelected,
dimmed: (isPast || (meta.mode === 'month' && !meta.inCurrentMonth)) && !isSelected,
showIndicator: meta.mode === 'month'
? meta.inCurrentMonth && dayHasAnyMeal(plans, day)
: dayHasAnyMeal(plans, day),
};
},
resolveDayState: (day, meta) => resolveCalendarDayState(day, meta, plans, today),
});
syncScrollShadows();
}
function renderSlots() {
const el = document.getElementById('mpe-slot-chips');
const sec = document.getElementById('mpe-slot-section');
if (sec) sec.classList.toggle('hidden', !S.showCal);
if (!el || !S.showCal) return;
const r = RECIPES[S.recipeId];
if (!r) return;
el.innerHTML = MEAL_SLOTS.filter((s) => r.allowedSlots.includes(s.id)).map((s) => {
const sel = s.id === S.slotId;
const bg = sel ? '#23221e' : '#2f2f2d';
const border = sel ? '#787876' : '#444442';
const text = sel ? '#f2efe8' : '#d7d2c8';
const bg = sel ? 'rgb(var(--sunken-rgb))' : 'rgb(var(--card-soft-rgb))';
const border = sel ? 'rgb(var(--border-input-rgb))' : 'rgb(var(--card-strong-rgb))';
const text = sel ? 'rgb(var(--text-emphasis-rgb))' : 'rgb(var(--text-body-soft-rgb))';
return `<button type="button" class="mpe-slot-btn px-3 py-1.5 rounded-full border text-[12px] font-semibold transition-colors" data-slot-id="${s.id}" style="background:${bg} !important; background-image:none !important; box-shadow:none !important; border-color:${border} !important; color:${text} !important;">${esc(s.label)}</button>`;
}).join('');
}
@@ -249,12 +270,12 @@ export function setupMealPlanEditor() {
el.innerHTML = `
<div class="flex items-center justify-between gap-3">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Porcje</p>
<div class="flex h-[2rem] w-[5.25rem] shrink-0 items-center gap-0.5 rounded-full border px-0.5" style="background:#2f2f2d;border-color:#444442;box-shadow:0 2px 8px rgba(0,0,0,0.25);">
<button type="button" id="mpe-serv-minus" class="shrink-0 w-7 h-full flex items-center justify-center rounded-full border-0 bg-transparent text-[#d7d2c8] transition-colors" aria-label="Zmniejsz liczbę porcji">
<div class="flex h-[2rem] w-[5.25rem] shrink-0 items-center gap-0.5 rounded-full border px-0.5" style="background:rgb(var(--card-soft-rgb));border-color:rgb(var(--card-strong-rgb));box-shadow:var(--shadow-card);">
<button type="button" id="mpe-serv-minus" class="shrink-0 w-7 h-full flex items-center justify-center rounded-full border-0 bg-transparent text-[rgb(var(--text-body-soft-rgb))] transition-colors" aria-label="Zmniejsz liczbę porcji">
<i class="fas fa-minus text-[10px]"></i>
</button>
<span id="mpe-serv-count" class="flex-1 h-full inline-flex items-center justify-center px-0.5 text-[12px] font-semibold leading-none text-[#d7d2c8] tabular-nums">${S.servings}</span>
<button type="button" id="mpe-serv-plus" class="shrink-0 w-7 h-full flex items-center justify-center rounded-full border-0 bg-transparent text-[#d7d2c8] transition-colors" aria-label="Zwiększ liczbę porcji">
<span id="mpe-serv-count" class="flex-1 h-full inline-flex items-center justify-center px-0.5 text-[12px] font-semibold leading-none text-[rgb(var(--text-body-soft-rgb))] tabular-nums">${S.servings}</span>
<button type="button" id="mpe-serv-plus" class="shrink-0 w-7 h-full flex items-center justify-center rounded-full border-0 bg-transparent text-[rgb(var(--text-body-soft-rgb))] transition-colors" aria-label="Zwiększ liczbę porcji">
<i class="fas fa-plus text-[10px]"></i>
</button>
</div>
@@ -273,7 +294,7 @@ export function setupMealPlanEditor() {
const removeBtn = (cls, attrs) =>
`<button type="button" class="${cls} shrink-0 w-6 h-6 rounded-full border border-gray-200 text-gray-300 hover:text-red-500 hover:border-red-200 hover:bg-red-50 flex items-center justify-center transition-colors" ${attrs}><i class="fas fa-minus text-[8px]"></i></button>`;
const ingredientRowClass = 'mpe-ing-row rounded-xl px-3 py-3';
const ingredientRowStyle = 'background:#393937 !important; background-image:none !important; box-shadow:0 2px 8px rgba(0,0,0,0.25) !important; border:none !important;';
const ingredientRowStyle = 'background:rgb(var(--card-rgb)) !important; background-image:none !important; box-shadow:var(--shadow-card) !important; border:none !important;';
for (const ing of r.ingredients) {
const id = ing.ingredientId;
@@ -321,12 +342,12 @@ export function setupMealPlanEditor() {
const isSel = eid === altId;
const checkbox = `
<span class="ml-auto self-center w-[18px] h-[18px] rounded-full shrink-0 flex items-center justify-center"
style="border:1.5px solid #56534f; background:transparent;">
${isSel ? '<i class="fas fa-check" style="color:#9b978f; font-size:8px; line-height:1; display:block; transform:translateY(0.5px);"></i>' : ''}
style="border:1.5px solid rgba(var(--border-input-rgb), 0.58); background:transparent;">
${isSel ? '<i class="fas fa-check" style="color:rgb(var(--text-dim-rgb)); font-size:8px; line-height:1; display:block; transform:translateY(0.5px);"></i>' : ''}
</span>`;
const n = nutFor(altId, disp, ing.unit);
const nLine = n ? `<div class="text-[10px] text-gray-400 mt-0.5 tabular-nums">${n.kcal} kcal · ${n.protein}g B · ${n.fat}g T · ${n.carbs}g W</div>` : '';
html += `<button type="button" class="mpe-alt-pick w-full text-left p-2.5 rounded-lg transition-all" style="background:#2f2f2d !important; background-image:none !important; border:none !important; box-shadow:none !important;" data-orig-id="${esc(id)}" data-alt-id="${esc(altId)}"><div class="flex items-center gap-3"><div class="min-w-0 flex-1"><div class="text-[11px] font-semibold text-gray-900">${esc(name)}</div>${nLine}</div>${checkbox}</div></button>`;
html += `<button type="button" class="mpe-alt-pick w-full text-left p-2.5 rounded-lg transition-all" style="background:rgb(var(--card-soft-rgb)) !important; background-image:none !important; border:none !important; box-shadow:none !important;" data-orig-id="${esc(id)}" data-alt-id="${esc(altId)}"><div class="flex items-center gap-3"><div class="min-w-0 flex-1"><div class="text-[11px] font-semibold text-gray-900">${esc(name)}</div>${nLine}</div>${checkbox}</div></button>`;
}
html += '</div>';
}
@@ -345,7 +366,7 @@ export function setupMealPlanEditor() {
const addedBadge = addedProduct
? `<div class="flex items-center gap-1 mt-0.5"><span class="text-[10px] text-emerald-400 truncate">${esc(addedProduct.name)}</span></div>`
: '';
html += `<div class="flex-1 min-w-0 mpe-open-product-card cursor-pointer" data-eid="${esc(a.ingredientId)}" data-pid="${esc(addedPid)}"><div class="flex items-center gap-1.5"><span class="text-[12px] font-semibold text-gray-900 truncate">${esc(name)}</span><span class="shrink-0 inline-flex items-center justify-center text-[#8f8b84]" title="Dodany składnik" aria-label="Dodany składnik"><i class="fas fa-plus text-[8px]"></i></span></div>${addedBadge}</div>`;
html += `<div class="flex-1 min-w-0 mpe-open-product-card cursor-pointer" data-eid="${esc(a.ingredientId)}" data-pid="${esc(addedPid)}"><div class="flex items-center gap-1.5"><span class="text-[12px] font-semibold text-gray-900 truncate">${esc(name)}</span><span class="shrink-0 inline-flex items-center justify-center text-[rgb(var(--text-faint-rgb))]" title="Dodany składnik" aria-label="Dodany składnik"><i class="fas fa-plus text-[8px]"></i></span></div>${addedBadge}</div>`;
html += `<button type="button" class="mpe-edit-amt shrink-0 flex items-center gap-1 px-2 py-1 rounded-lg hover:bg-gray-100 transition-colors" data-ing-id="${esc(a.ingredientId)}" data-type="added">`;
html += `<span class="text-[12px] font-semibold text-gray-900 tabular-nums">${fmtAmt(disp)}</span>`;
html += `<span class="text-[11px] text-gray-500">${esc(a.unit)}</span></button>`;
@@ -369,7 +390,7 @@ export function setupMealPlanEditor() {
const el = document.getElementById('mpe-add-area');
if (!el) return;
if (!S.addOpen) {
el.innerHTML = `<button type="button" id="mpe-add-btn" class="w-full py-2 rounded-xl border border-dashed text-[12px] font-semibold transition-colors" style="border-color:#444442; color:#9b978f; background:transparent !important; box-shadow:none !important; -webkit-tap-highlight-color:transparent;"><i class="fas fa-plus text-[10px] mr-1.5 opacity-70"></i>Dodaj składnik</button>`;
el.innerHTML = `<button type="button" id="mpe-add-btn" class="w-full py-2 rounded-xl border border-dashed text-[12px] font-semibold transition-colors" style="border-color:rgb(var(--card-strong-rgb)); color:rgb(var(--text-dim-rgb)); background:transparent !important; box-shadow:none !important; -webkit-tap-highlight-color:transparent;"><i class="fas fa-plus text-[10px] mr-1.5 opacity-70"></i>Dodaj składnik</button>`;
return;
}
const recipe = RECIPES[S.recipeId];
@@ -380,15 +401,15 @@ export function setupMealPlanEditor() {
const q = S.addQuery.toLowerCase().trim();
const avail = Object.values(INGREDIENTS).filter((i) => !usedIds.has(i.id) && (!q || i.name.toLowerCase().includes(q)));
el.innerHTML = `
<div class="rounded-xl p-3" style="background:#393937 !important; border:1px solid #444442;">
<div class="rounded-xl p-3" style="background:rgb(var(--card-rgb)) !important; border:1px solid rgb(var(--card-strong-rgb));">
<div class="flex items-center gap-2 mb-2">
<input type="text" id="mpe-add-search" class="flex-1 rounded-lg px-3 py-1.5 text-[12px] outline-none placeholder:text-[#8f8b84]" style="background:#2f2f2d !important; border:1px solid #444442; color:#ddd6ca;" placeholder="Szukaj składnika…" value="${esc(S.addQuery)}">
<button type="button" id="mpe-add-cancel" class="text-[11px] font-semibold px-2 py-1 transition-colors" style="color:#9b978f;">Anuluj</button>
<input type="text" id="mpe-add-search" class="flex-1 rounded-lg px-3 py-1.5 text-[12px] outline-none placeholder:text-[rgb(var(--text-faint-rgb))]" style="background:rgb(var(--card-soft-rgb)) !important; border:1px solid rgb(var(--card-strong-rgb)); color:rgb(var(--text-body-rgb));" placeholder="Szukaj składnika…" value="${esc(S.addQuery)}">
<button type="button" id="mpe-add-cancel" class="text-[11px] font-semibold px-2 py-1 transition-colors" style="color:rgb(var(--text-dim-rgb));">Anuluj</button>
</div>
<div class="max-h-40 overflow-y-auto space-y-1 no-scrollbar" id="mpe-add-results">
${avail.length === 0
? '<p class="rounded-lg px-2.5 py-3 text-[11px] text-center" style="background:#2f2f2d !important; color:#9b978f;">Brak wyników</p>'
: avail.slice(0, 20).map((i) => `<button type="button" class="mpe-add-pick w-full text-left px-3 py-3 rounded-lg transition-colors text-[12px] font-medium" style="background:#2f2f2d !important; color:#ddd6ca;" data-ing-id="${esc(i.id)}">${esc(i.name)}</button>`).join('')}
? '<p class="rounded-lg px-2.5 py-3 text-[11px] text-center" style="background:rgb(var(--card-soft-rgb)) !important; color:rgb(var(--text-dim-rgb));">Brak wyników</p>'
: avail.slice(0, 20).map((i) => `<button type="button" class="mpe-add-pick w-full text-left px-3 py-3 rounded-lg transition-colors text-[12px] font-medium" style="background:rgb(var(--card-soft-rgb)) !important; color:rgb(var(--text-body-rgb));" data-ing-id="${esc(i.id)}">${esc(i.name)}</button>`).join('')}
</div>
</div>`;
}
@@ -404,8 +425,8 @@ export function setupMealPlanEditor() {
const q = S.addQuery.toLowerCase().trim();
const avail = Object.values(INGREDIENTS).filter((i) => !usedIds.has(i.id) && (!q || i.name.toLowerCase().includes(q)));
results.innerHTML = avail.length === 0
? '<p class="rounded-lg px-2.5 py-3 text-[11px] text-center" style="background:#2f2f2d !important; color:#9b978f;">Brak wyników</p>'
: avail.slice(0, 20).map((i) => `<button type="button" class="mpe-add-pick w-full text-left px-3 py-3 rounded-lg transition-colors text-[12px] font-medium" style="background:#2f2f2d !important; color:#ddd6ca;" data-ing-id="${esc(i.id)}">${esc(i.name)}</button>`).join('');
? '<p class="rounded-lg px-2.5 py-3 text-[11px] text-center" style="background:rgb(var(--card-soft-rgb)) !important; color:rgb(var(--text-dim-rgb));">Brak wyników</p>'
: avail.slice(0, 20).map((i) => `<button type="button" class="mpe-add-pick w-full text-left px-3 py-3 rounded-lg transition-colors text-[12px] font-medium" style="background:rgb(var(--card-soft-rgb)) !important; color:rgb(var(--text-body-rgb));" data-ing-id="${esc(i.id)}">${esc(i.name)}</button>`).join('');
}
function renderIngredients() {
@@ -421,23 +442,23 @@ export function setupMealPlanEditor() {
if (!el) return;
const n = totalNutrition();
el.innerHTML = `
<div class="h-full pb-2 flex flex-col" style="background:#2d2e2b !important; background-image:none !important; box-shadow:none !important;">
<div class="h-full pb-2 flex flex-col" style="background:rgb(var(--app-bg-rgb)) !important; background-image:none !important; box-shadow:none !important;">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-2">Wartości odżywcze</p>
<div class="flex-1 flex items-center">
<div class="grid grid-cols-4 gap-1.5 w-full">
<div class="rounded-xl px-2 py-[0.5625rem] text-center" style="background:#393937;">
<div class="rounded-xl px-2 py-[0.5625rem] text-center" style="background:rgb(var(--card-rgb));">
<p class="text-[15px] font-bold text-gray-100 tabular-nums leading-tight">${n.kcal}</p>
<p class="text-[9px] text-gray-500 font-medium">kcal</p>
</div>
<div class="rounded-xl px-2 py-[0.5625rem] text-center" style="background:#393937;">
<div class="rounded-xl px-2 py-[0.5625rem] text-center" style="background:rgb(var(--card-rgb));">
<p class="text-[15px] font-bold text-blue-400 tabular-nums leading-tight">${n.protein}g</p>
<p class="text-[9px] text-gray-500 font-medium">białko</p>
</div>
<div class="rounded-xl px-2 py-[0.5625rem] text-center" style="background:#393937;">
<div class="rounded-xl px-2 py-[0.5625rem] text-center" style="background:rgb(var(--card-rgb));">
<p class="text-[15px] font-bold text-amber-400 tabular-nums leading-tight">${n.fat}g</p>
<p class="text-[9px] text-gray-500 font-medium">tłuszcz</p>
</div>
<div class="rounded-xl px-2 py-[0.5625rem] text-center" style="background:#393937;">
<div class="rounded-xl px-2 py-[0.5625rem] text-center" style="background:rgb(var(--card-rgb));">
<p class="text-[15px] font-bold text-orange-400 tabular-nums leading-tight">${n.carbs}g</p>
<p class="text-[9px] text-gray-500 font-medium">węglowodany</p>
</div>
@@ -616,21 +637,39 @@ export function setupMealPlanEditor() {
});
}
document.getElementById('mpe-confirm-btn')?.addEventListener('click', handleConfirm);
bindCalendarDayClicks(document.getElementById('mpe-cal-grid'), (date) => {
const selectCalendarDate = (date) => {
S.date = date;
S.calDate = new Date(date);
renderCal();
});
};
document.getElementById('mpe-cal-prev')?.addEventListener('click', () => {
if (!S.showCal) return;
S.calDate = S.calExpanded ? addMonths(S.calDate, -1) : addDays(S.calDate, -7);
renderCal();
});
document.getElementById('mpe-cal-next')?.addEventListener('click', () => {
if (!S.showCal) return;
S.calDate = S.calExpanded ? addMonths(S.calDate, 1) : addDays(S.calDate, 7);
renderCal();
bindCalendarDayClicks(document.getElementById('mpe-cal-week-grid'), selectCalendarDate);
bindCalendarDayClicks(document.getElementById('mpe-cal-month-grid'), selectCalendarDate);
bindCollapsibleCalendarSwipeGesture({
zoneEl: document.getElementById('mpe-cal-swipe-zone'),
weekWrapEl: document.getElementById('mpe-cal-week-wrap'),
monthWrapEl: document.getElementById('mpe-cal-month-wrap'),
getMode: () => (S.calExpanded ? 'month' : 'week'),
setMode: (mode) => {
S.calExpanded = mode === 'month';
},
getWeekAnchor: () => S.calDate,
setWeekAnchor: (date) => {
S.calDate = startOfDay(date);
},
getMonthAnchor: () => S.calDate,
setMonthAnchor: (date) => {
S.calDate = startOfDay(date);
},
getSelectedDate: () => S.date,
setSelectedDate: (date) => {
S.date = startOfDay(date);
},
rerender: renderCal,
resolveDayState: (day, meta) => resolveCalendarDayState(day, meta),
selectOnNavigateOutside: false,
enableVerticalModeSwipe: false,
});
document.getElementById('mpe-cal-today')?.addEventListener('click', () => {
const today = startOfDay(new Date());
@@ -757,7 +796,7 @@ export function setupMealPlanEditor() {
const ingBase = S.overrides[origId] ?? recipeIng?.amount ?? 0;
const ingDisp = ingBase * S.servings;
const checkmark = (sel) => `<span class="ml-auto self-center w-[18px] h-[18px] rounded-full shrink-0 flex items-center justify-center" style="border:1.5px solid #56534f; background:transparent;">${sel ? '<i class="fas fa-check" style="color:#9b978f; font-size:8px; line-height:1; display:block; transform:translateY(0.5px);"></i>' : ''}</span>`;
const checkmark = (sel) => `<span class="ml-auto self-center w-[18px] h-[18px] rounded-full shrink-0 flex items-center justify-center" style="border:1.5px solid rgba(var(--border-input-rgb), 0.58); background:transparent;">${sel ? '<i class="fas fa-check" style="color:rgb(var(--text-dim-rgb)); font-size:8px; line-height:1; display:block; transform:translateY(0.5px);"></i>' : ''}</span>`;
let pickerHtml = '<div class="mpe-product-picker mt-2 ml-1 space-y-1">';
for (const p of products) {
@@ -770,7 +809,7 @@ export function setupMealPlanEditor() {
const f = g / 100;
const n = pNut ? { kcal: Math.round(pNut.kcal * f), protein: Math.round(pNut.protein * f * 10) / 10, fat: Math.round(pNut.fat * f * 10) / 10, carbs: Math.round(pNut.carbs * f * 10) / 10 } : null;
const nLine = n ? `<div class="text-[10px] text-gray-400 mt-0.5 tabular-nums">${n.kcal} kcal · ${n.protein}g B · ${n.fat}g T · ${n.carbs}g W</div>` : '';
pickerHtml += `<button type="button" class="mpe-prod-pick w-full text-left p-2.5 rounded-lg transition-all" style="background:#2f2f2d !important; background-image:none !important; border:none !important; box-shadow:none !important;" data-eid="${esc(eid)}" data-prod-id="${esc(p.id)}">
pickerHtml += `<button type="button" class="mpe-prod-pick w-full text-left p-2.5 rounded-lg transition-all" style="background:rgb(var(--card-soft-rgb)) !important; background-image:none !important; border:none !important; box-shadow:none !important;" data-eid="${esc(eid)}" data-prod-id="${esc(p.id)}">
<div class="flex items-center gap-3"><div class="min-w-0 flex-1"><div class="text-[11px] font-semibold text-gray-900">${esc(p.name)}</div>${nLine}</div>${checkmark(isSel)}</div>
</button>`;
}
@@ -800,7 +839,10 @@ export function setupMealPlanEditor() {
if (e.target.closest('#mpe-add-btn')) {
S.addOpen = true; S.addQuery = '';
renderAddArea();
document.getElementById('mpe-add-search')?.focus();
requestAnimationFrame(() => {
document.getElementById('mpe-add-area')?.scrollIntoView({ behavior: 'smooth', block: 'end' });
document.getElementById('mpe-add-search')?.focus({ preventScroll: true });
});
return;
}

View File

@@ -33,22 +33,22 @@ function renderRecipeCard(recipe, { showSlotLabels = true, cardClassName = '' }
const className = ['recipe-browser-card', cardClassName].filter(Boolean).join(' ');
return `
<button type="button" data-recipe-id="${escapeHtml(recipe.id)}" class="${className} rounded-xl overflow-hidden flex flex-col bg-[#393937] cursor-pointer text-left transition-shadow" style="background:#393937 !important; border:none !important; box-shadow:0 2px 8px rgba(0,0,0,0.28) !important;">
<div class="recipe-browser-card-media h-32 bg-[#d4d4d4] relative overflow-hidden">
<button type="button" data-recipe-id="${escapeHtml(recipe.id)}" class="${className} rounded-xl overflow-hidden flex flex-col bg-[rgb(var(--card-rgb))] cursor-pointer text-left transition-shadow" style="background:rgb(var(--card-rgb)) !important; border:none !important; box-shadow:var(--shadow-card) !important;">
<div class="recipe-browser-card-media h-32 bg-[rgb(var(--skeleton-rgb))] relative overflow-hidden">
${recipe.image
? `<img src="${escapeHtml(recipe.image)}" alt="${escapeHtml(recipe.title)}" class="w-full h-full object-cover">`
: `<span class="absolute inset-0 flex items-center justify-center text-white font-medium text-xs">${escapeHtml(recipe.thumbLabel)}</span>`}
</div>
<div class="recipe-browser-card-body p-3 flex flex-col flex-1">
<h3 class="recipe-browser-card-title text-sm font-medium underline decoration-1 underline-offset-2 text-[#f1ede4] mb-3 line-clamp-2">${escapeHtml(recipe.title)}</h3>
<h3 class="recipe-browser-card-title text-sm font-medium underline decoration-1 underline-offset-2 text-[rgb(var(--text-primary-rgb))] mb-3 line-clamp-2">${escapeHtml(recipe.title)}</h3>
<div class="recipe-browser-card-footer mt-auto">
<div class="recipe-browser-card-meta flex items-center justify-between text-[11px] text-[#c2bcb2] font-medium mb-2">
<div class="flex items-center gap-1"><i class="fas fa-clock text-[#8f8b84]" aria-hidden="true"></i><span>${recipe.minutes} min</span></div>
<div class="flex items-center gap-1"><i class="fas fa-fire text-[#8f8b84]" aria-hidden="true"></i><span>${recipe.nutritionPerServing.kcal} kcal</span></div>
<div class="recipe-browser-card-meta flex items-center justify-between text-[11px] text-[rgb(var(--text-muted-rgb))] font-medium mb-2">
<div class="flex items-center gap-1"><i class="fas fa-clock text-[rgb(var(--text-faint-rgb))]" aria-hidden="true"></i><span>${recipe.minutes} min</span></div>
<div class="flex items-center gap-1"><i class="fas fa-fire text-[rgb(var(--text-faint-rgb))]" aria-hidden="true"></i><span>${recipe.nutritionPerServing.kcal} kcal</span></div>
</div>
${labels.length > 0
? `<div class="recipe-browser-card-labels flex flex-wrap gap-1">
${labels.map((label) => `<span class="recipe-browser-card-label px-2 py-0.5 bg-[#2f2f2d] text-[#d7d2c8] text-[10px] rounded-md font-medium">${escapeHtml(label)}</span>`).join('')}
${labels.map((label) => `<span class="recipe-browser-card-label px-2 py-0.5 bg-[rgb(var(--card-soft-rgb))] text-[rgb(var(--text-body-soft-rgb))] text-[10px] rounded-md font-medium">${escapeHtml(label)}</span>`).join('')}
</div>`
: ''}
</div>
@@ -71,14 +71,14 @@ export function getRecipeGridSectionHTML({
scrollId,
gridId,
emptyStateId,
scrollClassName = 'relative flex-1 overflow-y-auto px-4 pt-20 pb-24 bg-[#2d2e2b]',
gridClassName = 'grid grid-cols-2 gap-3 bg-[#2d2e2b]',
scrollClassName = 'relative flex-1 overflow-y-auto px-4 pt-20 pb-24 bg-[rgb(var(--app-bg-rgb))]',
gridClassName = 'grid grid-cols-2 gap-3 bg-[rgb(var(--app-bg-rgb))]',
emptyTitle = 'Brak wyników',
emptyMessage = 'Zmień kryteria wyszukiwania lub filtry',
} = {}) {
return `
<div id="${escapeHtml(scrollId)}" class="${scrollClassName}" style="background:#2d2e2b !important;">
<div id="${escapeHtml(gridId)}" class="${gridClassName}" style="background:#2d2e2b !important;"></div>
<div id="${escapeHtml(scrollId)}" class="${scrollClassName}" style="background:rgb(var(--app-bg-rgb)) !important;">
<div id="${escapeHtml(gridId)}" class="${gridClassName}" style="background:rgb(var(--app-bg-rgb)) !important;"></div>
${getEmptyStateHTML({
emptyStateId,
title: emptyTitle,

View File

@@ -1,5 +1,5 @@
export const RECIPE_SEARCH_SHELL_BASE_SHADOW =
'0 5px 10px rgba(0,0,0,0.16), 0 14px 22px rgba(0,0,0,0.24), 0 22px 34px rgba(0,0,0,0.18), inset 0 1px 0 rgba(255,255,255,0.04), inset 0 2px 6px rgba(0,0,0,0.16), inset 0 -1px 2px rgba(255,255,255,0.02)';
'var(--shadow-shell), inset 0 2px 6px rgba(var(--overlay-rgb),0.16), inset 0 -1px 2px rgba(255,255,255,0.02)';
function escapeHtml(s) {
return String(s)
@@ -27,11 +27,11 @@ export function getRecipeSearchFieldHTML({
const ariaLabel = inputAriaLabel || placeholder;
return `
<div id="${escapeHtml(shellId)}" class="relative z-[1] mx-auto flex items-center w-full overflow-hidden" style="width:min(calc(100% - 0.5rem), 22.4rem); background:#393937 !important; border:1px solid #41423f !important; border-radius:999px !important; box-shadow:${RECIPE_SEARCH_SHELL_BASE_SHADOW} !important; transition:box-shadow 180ms ease;">
<div id="${escapeHtml(shellId)}" class="relative z-[1] mx-auto flex items-center w-full overflow-hidden" style="width:min(calc(100% - 0.5rem), 22.4rem); background:rgb(var(--card-rgb)) !important; border:1px solid rgb(var(--border-card-rgb)) !important; border-radius:999px !important; box-shadow:${RECIPE_SEARCH_SHELL_BASE_SHADOW} !important; transition:box-shadow 180ms ease;">
<input type="text" id="${escapeHtml(inputId)}" value="${escapeHtml(inputValue)}" placeholder="${escapeHtml(placeholder)}" aria-label="${escapeHtml(ariaLabel)}" class="w-full bg-transparent outline-none text-[15px] text-center py-[12px] ${inputPadding}" style="background:transparent !important; border:none !important; box-shadow:none !important; backdrop-filter:none !important;">
${hasFilterButton
? `
<button id="${escapeHtml(filterButtonId)}"${actionAttr} class="absolute right-2 top-1/2 -translate-y-1/2 w-9 h-9 text-[#c9c3b8] hover:text-[#f0e8dc] flex items-center justify-center transition-colors" style="background:transparent !important; border:none !important; box-shadow:none !important;" aria-label="${escapeHtml(filterButtonLabel)}">
<button id="${escapeHtml(filterButtonId)}"${actionAttr} class="absolute right-2 top-1/2 -translate-y-1/2 w-9 h-9 text-[rgb(var(--text-body-soft-rgb))] hover:text-[rgb(var(--text-emphasis-rgb))] flex items-center justify-center transition-colors" style="background:transparent !important; border:none !important; box-shadow:none !important;" aria-label="${escapeHtml(filterButtonLabel)}">
<i class="fas fa-sliders-h" aria-hidden="true"></i>
</button>`
: ''}

View File

@@ -0,0 +1,355 @@
import { addDays, startOfMonth } from '../services/dateUtils.js';
import { renderCalendarGrid } from './mealCalendar.js?v=15';
const DEFAULT_WEEKDAYS = ['pn', 'wt', 'śr', 'cz', 'pt', 'sb', 'nd'];
const DEFAULT_MONTHS_LONG = [
'Styczeń', 'Luty', 'Marzec', 'Kwiecień', 'Maj', 'Czerwiec',
'Lipiec', 'Sierpień', 'Wrzesień', 'Październik', 'Listopad', 'Grudzień',
];
const DEFAULT_THEME = {
selectedBorder: 'rgba(var(--text-emphasis-rgb),0.34)',
selectedBorderClass: 'border',
selectedText: 'rgb(var(--text-emphasis-rgb))',
selectedDot: 'rgb(var(--text-emphasis-rgb))',
selectedShadow: '0 0 0 1px rgba(var(--text-emphasis-rgb),0.10)',
bg: 'rgb(var(--app-bg-rgb))',
border: 'transparent',
borderClass: 'border-0',
text: 'rgb(var(--text-body-soft-rgb))',
dimmedBg: 'transparent',
dimmedBorderClass: 'border-0',
dimText: 'rgb(var(--text-faint-rgb))',
dimOpacity: 0.58,
dot: 'rgb(var(--text-faint-rgb))',
};
function dateKeyLocal(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function keyToDate(key) {
return new Date(`${key}T00:00:00`);
}
function normalizeMonth(date) {
return startOfMonth(date instanceof Date ? date : new Date());
}
function monthLabel(date, monthsLong) {
return `${monthsLong[date.getMonth()]} ${date.getFullYear()}`;
}
function dayRange(aKey, bKey) {
const a = keyToDate(aKey);
const b = keyToDate(bKey);
const [from, to] = a <= b ? [a, b] : [b, a];
const out = [];
for (let d = new Date(from); d <= to; d = addDays(d, 1)) out.push(dateKeyLocal(d));
return out;
}
export function createSwipePopoverCalendarHTML({
idPrefix,
weekdays = DEFAULT_WEEKDAYS,
monthLabelTextClass = 'text-[10px] font-semibold leading-none tabular-nums whitespace-nowrap',
}) {
const weekdayHeader = `
<div class="grid grid-cols-7 gap-1.5 text-center text-[8px] font-medium uppercase tracking-wide mb-1.5 leading-none" style="color:rgb(var(--text-dim-rgb));">
${weekdays.map((d) => `<div>${d}</div>`).join('')}
</div>
`;
return `
<div class="pb-3 px-3 flex items-center justify-end gap-3">
<div class="flex h-[2.05rem] min-w-0 max-w-[min(100%,20rem)] items-center justify-center rounded-full border" style="background:transparent; border-color:rgb(var(--border-input-rgb));">
<span id="${idPrefix}-month-label" class="h-full shrink-0 inline-flex min-w-[5.75rem] max-w-[9rem] items-center justify-center px-3 ${monthLabelTextClass}" style="color:rgb(var(--text-body-soft-rgb));"></span>
</div>
</div>
<div id="${idPrefix}-viewport" style="overflow:hidden; position:relative; touch-action:pan-y; -webkit-user-select:none; user-select:none; cursor:grab;">
<div id="${idPrefix}-track" style="display:flex; align-items:flex-start; position:relative; will-change:transform;">
<div class="swc-panel" data-panel="prev" style="flex-shrink:0; pointer-events:none; position:relative; overflow:hidden;">
${weekdayHeader}
<div id="${idPrefix}-prev-grid" class="grid grid-cols-7 gap-1.5"></div>
</div>
<div class="swc-panel" data-panel="current" style="flex-shrink:0; position:relative; overflow:hidden;">
${weekdayHeader}
<div id="${idPrefix}-grid" class="grid grid-cols-7 gap-1.5"></div>
</div>
<div class="swc-panel" data-panel="next" style="flex-shrink:0; pointer-events:none; position:relative; overflow:hidden;">
${weekdayHeader}
<div id="${idPrefix}-next-grid" class="grid grid-cols-7 gap-1.5"></div>
</div>
</div>
</div>
`;
}
export function initSwipePopoverCalendar({
idPrefix,
selectionMode = 'single',
monthsLong = DEFAULT_MONTHS_LONG,
theme = DEFAULT_THEME,
getMonthAnchor,
setMonthAnchor,
canNavigateToMonth,
getSelectionKeys,
onSelectionCommit,
resolveDayState,
panelHandlePx = null,
panelHandleRatio = 0.045,
panelHandleMin = 10,
panelHandleMax = 16,
}) {
const monthLabelEl = document.getElementById(`${idPrefix}-month-label`);
const gridEl = document.getElementById(`${idPrefix}-grid`);
const prevGridEl = document.getElementById(`${idPrefix}-prev-grid`);
const nextGridEl = document.getElementById(`${idPrefix}-next-grid`);
const track = document.getElementById(`${idPrefix}-track`);
const viewport = document.getElementById(`${idPrefix}-viewport`);
if (!gridEl || !track || !viewport) {
return {
render: () => {},
reapplyLayout: () => {},
resetTrackPosition: () => {},
};
}
const MOVE_THRESHOLD = 6;
const SWIPE_THRESHOLD = 40;
const ANIMATION_MS = 260;
let panelWidth = 0;
let panelInset = 0;
let restOffset = 0;
let animatingNav = false;
let pendingRangeStart = null;
let suppressClickUntil = 0;
const panels = Array.from(track.querySelectorAll('.swc-panel'));
const applyLayout = () => {
const vw = viewport.clientWidth || viewport.getBoundingClientRect().width;
if (!vw) return;
const computedInset = panelHandlePx == null
? Math.round(vw * panelHandleRatio)
: panelHandlePx;
panelInset = Math.max(panelHandleMin, Math.min(panelHandleMax, computedInset));
panelWidth = vw;
restOffset = -panelWidth;
panels.forEach((panel) => {
panel.style.width = `${panelWidth}px`;
panel.style.boxSizing = 'border-box';
panel.style.paddingLeft = `${panelInset}px`;
panel.style.paddingRight = `${panelInset}px`;
});
track.style.transition = 'none';
track.style.transform = `translate3d(${restOffset}px, 0, 0)`;
};
const resetTrackPosition = () => {
track.style.transition = 'none';
track.style.transform = `translate3d(${restOffset}px, 0, 0)`;
};
const setDragTranslate = (dx, ms) => {
track.style.transition = ms ? `transform ${ms}ms ease` : 'none';
track.style.transform = `translate3d(${restOffset + dx}px, 0, 0)`;
};
const snapBack = () => {
setDragTranslate(0, ANIMATION_MS);
};
const getNavigationTarget = (monthDelta) => {
const anchor = normalizeMonth(getMonthAnchor());
return startOfMonth(new Date(anchor.getFullYear(), anchor.getMonth() + monthDelta, 1));
};
const canNavigate = (monthDelta) => {
if (typeof canNavigateToMonth !== 'function') return true;
const anchor = normalizeMonth(getMonthAnchor());
const target = getNavigationTarget(monthDelta);
return canNavigateToMonth(target, { currentMonth: anchor, monthDelta }) !== false;
};
const getAllowedDragDx = (dx) => {
if (dx === 0) return 0;
return canNavigate(dx > 0 ? -1 : 1) ? dx : 0;
};
const getSelectedSet = (previewSelection = null) => {
const raw = previewSelection ?? getSelectionKeys();
if (Array.isArray(raw)) return new Set(raw);
if (typeof raw === 'string' && raw) return new Set([raw]);
return new Set();
};
const renderMonthGrid = (targetGrid, monthAnchor, selectedSet) => {
if (!targetGrid) return;
const calendarTheme = {
...DEFAULT_THEME,
...theme,
borderClass: theme.borderClass || DEFAULT_THEME.borderClass,
dimmedBorderClass: theme.dimmedBorderClass || DEFAULT_THEME.dimmedBorderClass,
selectedBorderClass: theme.selectedBorderClass || DEFAULT_THEME.selectedBorderClass,
};
renderCalendarGrid({
gridEl: targetGrid,
mode: 'month',
anchorDate: monthAnchor,
fixedWeekCount: 6,
selectedDate: null,
isSelectedDate: (day) => selectedSet.has(dateKeyLocal(day)),
dayAttr: 'data-dk',
getDayAttrValue: dateKeyLocal,
dayClassName: 'swc-day',
dayStyle: 'touch-action:pan-y;',
theme: calendarTheme,
resolveDayState: (day, { inCurrentMonth, isSelected }) => {
const resolved = (typeof resolveDayState === 'function'
? resolveDayState(day, { inCurrentMonth, isSelected })
: {}) || {};
return {
disabled: !!resolved.disabled,
dimmed: !!resolved.dimmed,
showIndicator: !!resolved.showDot,
};
},
});
};
const render = (previewSelection = null) => {
const anchor = normalizeMonth(getMonthAnchor());
const rangePreview = selectionMode === 'range' && pendingRangeStart ? [pendingRangeStart] : null;
const selectedSet = getSelectedSet(previewSelection ?? rangePreview);
if (monthLabelEl) monthLabelEl.textContent = monthLabel(anchor, monthsLong);
renderMonthGrid(gridEl, anchor, selectedSet);
renderMonthGrid(prevGridEl, new Date(anchor.getFullYear(), anchor.getMonth() - 1, 1), selectedSet);
renderMonthGrid(nextGridEl, new Date(anchor.getFullYear(), anchor.getMonth() + 1, 1), selectedSet);
applyLayout();
};
const commitNavigation = (monthDelta) => {
if (!canNavigate(monthDelta)) {
snapBack();
return;
}
animatingNav = true;
const targetDx = monthDelta < 0 ? panelWidth : -panelWidth;
setDragTranslate(targetDx, ANIMATION_MS);
setTimeout(() => {
setMonthAnchor(getNavigationTarget(monthDelta));
render();
resetTrackPosition();
animatingNav = false;
}, ANIMATION_MS);
};
if (selectionMode === 'range') {
gridEl.addEventListener('click', (e) => {
const btn = e.target.closest('.swc-day');
if (!btn) return;
e.stopPropagation();
const selectedKey = btn.dataset.dk;
if (!pendingRangeStart) {
pendingRangeStart = selectedKey;
if (typeof onSelectionCommit === 'function') onSelectionCommit([selectedKey]);
render([selectedKey]);
return;
}
const range = dayRange(pendingRangeStart, selectedKey);
pendingRangeStart = null;
if (typeof onSelectionCommit === 'function') onSelectionCommit(range);
render();
});
} else {
gridEl.addEventListener('click', (e) => {
const btn = e.target.closest('.swc-day');
if (!btn) return;
e.stopPropagation();
if (typeof onSelectionCommit === 'function') onSelectionCommit(btn.dataset.dk);
render();
});
}
let ptrId = null;
let startX = 0;
let startY = 0;
let moved = false;
let axis = null;
let hasPointerCapture = false;
viewport.addEventListener('pointerdown', (e) => {
if (animatingNav || ptrId !== null) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
if (!panelWidth) applyLayout();
ptrId = e.pointerId;
startX = e.clientX;
startY = e.clientY;
moved = false;
axis = null;
hasPointerCapture = false;
});
viewport.addEventListener('pointermove', (e) => {
if (e.pointerId !== ptrId || animatingNav) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!moved && (Math.abs(dx) > MOVE_THRESHOLD || Math.abs(dy) > MOVE_THRESHOLD)) {
moved = true;
axis = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y';
}
if (axis === 'x') {
e.preventDefault();
suppressClickUntil = Date.now() + 450;
if (!hasPointerCapture) {
try {
viewport.setPointerCapture(e.pointerId);
hasPointerCapture = true;
} catch (_) {}
}
setDragTranslate(getAllowedDragDx(dx), 0);
}
});
const endGesture = (e) => {
if (e && e.pointerId !== ptrId) return;
if (e && hasPointerCapture) {
try { viewport.releasePointerCapture(e.pointerId); } catch (_) {}
}
hasPointerCapture = false;
ptrId = null;
if (!moved || axis !== 'x') return;
const dx = e ? e.clientX - startX : 0;
const monthDelta = dx > 0 ? -1 : 1;
if (Math.abs(dx) >= SWIPE_THRESHOLD && canNavigate(monthDelta)) commitNavigation(monthDelta);
else {
snapBack();
}
moved = false;
axis = null;
};
viewport.addEventListener('click', (e) => {
if (Date.now() > suppressClickUntil) return;
suppressClickUntil = 0;
e.preventDefault();
e.stopPropagation();
}, true);
window.addEventListener('pointerup', endGesture);
window.addEventListener('pointercancel', endGesture);
window.addEventListener('resize', applyLayout);
const clearPendingRange = () => {
pendingRangeStart = null;
render();
};
return { render, reapplyLayout: applyLayout, resetTrackPosition, clearPendingRange };
}

View File

@@ -1,29 +1,28 @@
import { RECIPES } from '../data/catalog.js?v=8';
import { RECIPES, CATEGORY_LABELS } from '../data/catalog.js?v=9';
import { MEAL_SLOTS } from '../planner/mealSlots.js';
import { applyFilters, getFilterState } from './RecipeList.js';
import { ensureFilterPopoverStyles, filterChipStyle } from '../ui/filterPopover.js?v=1';
const PANTRY_CATEGORY_ORDER = ['pieczywo', 'nabial', 'mieso_ryby', 'warzywa', 'owoce', 'suche', 'przyprawy', 'inne'];
const PANTRY_SECTION_FILTERS = [
{ id: 'shortfalls', label: 'Potrzebne' },
{ id: 'sufficient', label: 'W spiżarni' },
{ id: 'notPlanned', label: 'Poza planem' },
];
const FILTER_PANEL_TRANSITION = 'opacity 180ms ease, transform 180ms ease';
const FILTER_SURFACE = '#2d2e2b';
const FILTER_SURFACE_SOFT = '#2f2f2d';
const FILTER_BORDER = '#444442';
const FILTER_BORDER_ACTIVE = '#787876';
const FILTER_CHIP_ACTIVE = '#23221e';
const FILTER_TEXT_PRIMARY = '#ddd6ca';
const FILTER_TEXT_SECONDARY = '#d7d2c8';
const FILTER_TEXT_MUTED = '#9b978f';
const FILTER_TEXT_DIM = '#7d7a74';
const FILTER_TEXT_ACTIVE = '#f2efe8';
const FILTER_TRACK = '#393937';
const FILTER_TRACK_FILL = '#56534f';
const FILTER_TEXT_MUTED = 'var(--filter-liquid-text-muted)';
const FILTER_TEXT_ACTIVE = 'var(--filter-liquid-text-active)';
const FILTER_TRACK = 'var(--filter-liquid-track-bg)';
const FILTER_TRACK_FILL = 'var(--filter-liquid-accent-bg)';
const PREP_TIME_MIN = 5;
const PREP_TIME_MAX = 120;
const PREP_TIME_STEP = 5;
const PREP_TIME_MIN_GAP = PREP_TIME_STEP;
const FILTER_RECIPE_BLUR = 'blur(3px) saturate(0.94)';
const FILTER_CONTEXTS = {
recipes: {
anchorShellId: 'recipe-search-shell',
buttonId: 'recipe-filter-btn',
anchorShellId: 'recipe-filter-float-btn',
buttonId: 'recipe-filter-float-btn',
getState: () => getFilterState(),
applyState: (nextState) => applyFilters(nextState),
showSlots: true,
@@ -65,6 +64,15 @@ export function getFilterHTML() {
pointer-events: auto;
}
#filter-panel::before {
display: none;
}
#filter-panel > * {
position: relative;
z-index: 1;
}
#filter-view .prep-time-range-track,
#filter-view .prep-time-range-fill {
border-radius: 9999px;
@@ -75,42 +83,50 @@ export function getFilterHTML() {
width: 1rem;
height: 1rem;
border-radius: 9999px;
border: 1px solid rgba(242,239,232,0.16);
background: ${FILTER_TRACK_FILL};
box-shadow: 0 0 0 1px rgba(0,0,0,0.12);
border: 1px solid rgba(255,255,255,0.34);
background: rgba(var(--surface-rgb),0.42);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.42),
0 0 0 1px rgba(var(--overlay-rgb),0.1),
0 4px 10px rgba(var(--overlay-rgb),0.18);
touch-action: none;
outline: none;
}
.dark #filter-view .prep-time-range-handle {
border-color: rgba(255,255,255,0.18);
background: rgba(255,255,255,0.26);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.24),
0 0 0 1px rgba(0,0,0,0.22),
0 4px 12px rgba(0,0,0,0.34);
}
</style>
<div id="filter-view" class="absolute inset-0 z-[70] hidden opacity-0 transition-opacity duration-150" style="pointer-events:none; background:rgba(0,0,0,0.5) !important; background-image:none !important;" aria-hidden="true">
<div id="filter-panel" class="absolute flex flex-col overflow-hidden rounded-[1.5rem] border" style="background:${FILTER_SURFACE} !important; background-image:none !important; border-color:${FILTER_BORDER} !important; opacity:0; transform:translateY(-0.5rem) scale(0.98); transform-origin:top center; transition:${FILTER_PANEL_TRANSITION}; box-shadow:0 18px 40px rgba(0,0,0,0.34), 0 4px 12px rgba(0,0,0,0.18); width:min(calc(100% - 1.5rem), 22rem);">
<div class="pointer-events-none absolute inset-x-0 top-0 h-px" style="background:rgba(242,239,232,0.12);" aria-hidden="true"></div>
<div class="shrink-0 px-3.5 pt-3 pb-2 flex justify-end" style="background:${FILTER_SURFACE} !important; background-image:none !important;">
<div class="min-w-0 flex items-center justify-end gap-2">
<button id="filter-clear-btn" type="button" class="shrink-0 h-8 px-3 rounded-full border text-[11px] font-semibold transition-colors" style="background:${FILTER_SURFACE_SOFT} !important; border-color:${FILTER_BORDER} !important; color:${FILTER_TEXT_SECONDARY} !important;">Wyczyść</button>
</div>
<div id="filter-view" class="filter-liquid-surface absolute inset-0 z-[70] hidden opacity-0 transition-opacity duration-150" style="pointer-events:none; background:transparent !important; background-image:none !important;" aria-hidden="true">
<div id="filter-panel" class="filter-liquid-panel absolute flex flex-col overflow-hidden rounded-[1.35rem]" style="opacity:0; transform:translateY(0.65rem) scale(0.98); transform-origin:bottom center; transition:${FILTER_PANEL_TRANSITION}; width:min(calc(100% - 1.5rem), 22rem);">
<div class="shrink-0 px-3 pt-3 pb-2 flex items-center justify-between gap-3">
<p class="text-[11px] font-semibold leading-none" style="color:${FILTER_TEXT_ACTIVE};">Filtry</p>
<button id="filter-clear-btn" type="button" class="h-8 px-2 rounded-full text-[11px] font-semibold transition-colors" style="background:transparent; border:none; color:${FILTER_TEXT_MUTED};">Wyczyść</button>
</div>
<div id="filter-panel-body" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-4 pb-4 space-y-2.5" style="background:${FILTER_SURFACE} !important; background-image:none !important;">
<section id="filter-slot-section" class="p-3.5" style="background:${FILTER_SURFACE} !important; background-image:none !important;">
<p class="text-[10px] font-bold uppercase tracking-wider mb-3" style="color:${FILTER_TEXT_MUTED};">Pora posiłku</p>
<div id="filter-slot-chips" class="flex flex-wrap gap-2"></div>
<div id="filter-panel-body" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-3 pb-3 space-y-4">
<section id="filter-slot-section">
<p class="text-[10px] font-bold uppercase tracking-wider mb-3 px-0.5" style="color:${FILTER_TEXT_MUTED};">Pora posiłku</p>
<div id="filter-slot-chips" class="flex flex-wrap gap-2 px-1.5"></div>
</section>
<section class="p-3.5" style="background:${FILTER_SURFACE} !important; background-image:none !important;">
<p class="text-[10px] font-bold uppercase tracking-wider mb-3" style="color:${FILTER_TEXT_MUTED};">Dieta i tagi</p>
<div id="filter-tag-chips" class="flex flex-wrap gap-2"></div>
<section>
<p class="text-[10px] font-bold uppercase tracking-wider mb-3 px-0.5" style="color:${FILTER_TEXT_MUTED};">Dieta i tagi</p>
<div id="filter-tag-chips" class="flex flex-wrap gap-2 px-1.5"></div>
</section>
<section class="p-3.5" style="background:${FILTER_SURFACE} !important; background-image:none !important;">
<div class="flex items-center justify-between gap-3 mb-3">
<div class="min-w-0">
<p class="text-[10px] font-bold uppercase tracking-wider" style="color:${FILTER_TEXT_MUTED};">Czas przygotowania</p>
</div>
<section>
<div class="flex items-center justify-between gap-3 mb-3 px-0.5">
<p class="text-[10px] font-bold uppercase tracking-wider" style="color:${FILTER_TEXT_MUTED};">Czas przygotowania</p>
<span id="time-display-range" class="shrink-0 text-[11px] font-semibold tabular-nums text-right" style="color:${FILTER_TEXT_ACTIVE};">5 min - 120 min</span>
</div>
<div class="px-1">
<div class="relative h-9">
<div class="mx-1.5">
<div class="relative h-9 mx-2">
<div class="prep-time-range-track absolute inset-x-0 top-1/2 -translate-y-1/2" style="background:${FILTER_TRACK};" aria-hidden="true"></div>
<div id="prep-time-range-fill" class="prep-time-range-fill absolute top-1/2 -translate-y-1/2" style="background:${FILTER_TRACK_FILL}; left:0%; width:100%;" aria-hidden="true"></div>
<button
@@ -182,13 +198,6 @@ function formatTimeRangeSummary(minMinutes, maxMinutes) {
return `${formatTimeValue(minMinutes)} - ${formatTimeValue(maxMinutes)}`;
}
function getChipStyle(active) {
const background = active ? FILTER_CHIP_ACTIVE : FILTER_SURFACE_SOFT;
const border = active ? FILTER_BORDER_ACTIVE : FILTER_BORDER;
const color = active ? FILTER_TEXT_ACTIVE : FILTER_TEXT_SECONDARY;
return `background:${background} !important; background-image:none !important; box-shadow:none !important; border-color:${border} !important; color:${color} !important;`;
}
function clampTimeValue(value) {
return Math.min(Math.max(value, PREP_TIME_MIN), PREP_TIME_MAX);
}
@@ -202,6 +211,12 @@ function getSliderPercent(value) {
return ((value - PREP_TIME_MIN) / (PREP_TIME_MAX - PREP_TIME_MIN)) * 100;
}
function getClosedPanelTransform(panel) {
return panel?.dataset.placement === 'below'
? 'translateY(-0.5rem) scale(0.98)'
: 'translateY(0.65rem) scale(0.98)';
}
function setActiveTimeHandle(activeHandle) {
const minHandle = document.getElementById('prep-time-min-handle');
const maxHandle = document.getElementById('prep-time-max-handle');
@@ -249,19 +264,33 @@ function positionFilterPanel() {
const viewRect = view.getBoundingClientRect();
const anchorRect = (searchShell || button).getBoundingClientRect();
const gap = 8;
const isRecipeContext = activeFilterContext === 'recipes';
const gap = isRecipeContext ? 12 : 8;
const margin = 12;
const width = Math.min(anchorRect.width, viewRect.width - margin * 2);
const top = Math.max(margin, anchorRect.bottom - viewRect.top + gap);
const left = Math.max(
margin,
Math.min(anchorRect.left - viewRect.left, viewRect.width - width - margin),
);
const maxHeight = Math.max(220, viewRect.height - top - margin);
const maxPanelWidth = isRecipeContext ? 352 : anchorRect.width;
const width = Math.min(maxPanelWidth, viewRect.width - margin * 2);
const spaceBelow = viewRect.bottom - anchorRect.bottom - margin;
const preferredMaxHeight = Math.min(420, viewRect.height - margin * 2);
const spaceAbove = anchorRect.top - viewRect.top - gap - margin;
const opensUpward = isRecipeContext || (spaceBelow < 260 && anchorRect.top - viewRect.top > spaceBelow);
const maxHeight = opensUpward
? Math.max(220, Math.min(preferredMaxHeight, spaceAbove))
: Math.max(220, viewRect.height - Math.max(margin, anchorRect.bottom - viewRect.top + gap) - margin);
const leftBase = isRecipeContext
? (viewRect.width - width) / 2
: anchorRect.left - viewRect.left;
const left = Math.max(margin, Math.min(leftBase, viewRect.width - width - margin));
panel.style.width = `${width}px`;
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
panel.style.transformOrigin = opensUpward ? 'bottom center' : 'top center';
panel.dataset.placement = opensUpward ? 'above' : 'below';
if (opensUpward) {
panel.style.top = 'auto';
panel.style.bottom = `${Math.max(margin, viewRect.bottom - anchorRect.top + gap)}px`;
} else {
panel.style.top = `${Math.max(margin, anchorRect.bottom - viewRect.top + gap)}px`;
panel.style.bottom = 'auto';
}
panel.style.maxHeight = `${maxHeight}px`;
if (body) body.style.maxHeight = `${maxHeight - 56}px`;
}
@@ -281,8 +310,11 @@ function showFilterPanel() {
view.style.pointerEvents = 'auto';
view.setAttribute('aria-hidden', 'false');
positionFilterPanel();
panel.style.transform = getClosedPanelTransform(panel);
setRecipeAreaBlur(true);
syncPanelCount();
requestAnimationFrame(() => {
view.classList.add('opacity-100');
panel.style.opacity = '1';
@@ -299,8 +331,9 @@ function hideFilterPanel() {
view.style.pointerEvents = 'none';
view.setAttribute('aria-hidden', 'true');
panel.style.opacity = '0';
panel.style.transform = 'translateY(-0.5rem) scale(0.98)';
panel.style.transform = getClosedPanelTransform(panel);
setRecipeAreaBlur(false);
syncPanelCount();
closeTimer = setTimeout(() => {
view.classList.add('hidden');
@@ -317,7 +350,7 @@ function renderSlotChips() {
wrap.innerHTML = MEAL_SLOTS.map((slot) => {
const active = localSlots.includes(slot.id);
return `<button type="button" data-filter-slot="${escapeHtml(slot.id)}" class="px-3 py-1.5 rounded-full border text-[12px] font-semibold transition-colors" style="${getChipStyle(active)}">${escapeHtml(slot.label)}</button>`;
return `<button type="button" data-filter-slot="${escapeHtml(slot.id)}" class="px-3 py-2 rounded-full text-[11px] font-semibold transition-colors" style="${filterChipStyle(active)}">${escapeHtml(slot.label)}</button>`;
}).join('');
wrap.querySelectorAll('[data-filter-slot]').forEach((btn) => {
@@ -339,7 +372,7 @@ function renderTagChips() {
const allTags = collectAllTags();
wrap.innerHTML = allTags.map((tag) => {
const active = localTags.includes(tag.toLowerCase());
return `<button type="button" data-filter-tag="${escapeHtml(tag)}" class="px-3 py-1.5 rounded-full border text-[12px] font-semibold transition-colors" style="${getChipStyle(active)}">${escapeHtml(tag)}</button>`;
return `<button type="button" data-filter-tag="${escapeHtml(tag)}" class="px-3 py-2 rounded-full text-[11px] font-semibold transition-colors" style="${filterChipStyle(active)}">${escapeHtml(tag)}</button>`;
}).join('');
wrap.querySelectorAll('[data-filter-tag]').forEach((btn) => {
@@ -360,6 +393,40 @@ function syncFilterSections() {
slotSection.classList.toggle('hidden', !getActiveFilterConfig().showSlots);
}
function getActiveFilterCount() {
const config = getActiveFilterConfig();
let count = localTags.length;
if (config.showSlots) count += localSlots.length;
if (localMinMinutes > PREP_TIME_MIN || localMaxMinutes < PREP_TIME_MAX) count += 1;
return count;
}
function syncPanelCount() {
const count = getActiveFilterCount();
const { buttonId } = getActiveFilterConfig();
const button = buttonId ? document.getElementById(buttonId) : null;
if (button) {
const highlight = isFilterPanelOpen() || count > 0;
const isRecipeGlassButton = button.classList.contains('recipe-glass-btn');
if (isRecipeGlassButton) {
button.style.removeProperty('background');
button.style.removeProperty('border-color');
button.style.removeProperty('color');
} else {
button.style.setProperty('background', highlight ? 'rgba(var(--overlay-rgb), 0.12)' : 'rgb(var(--card-rgb))', 'important');
button.style.setProperty('border-color', highlight ? 'rgb(var(--border-input-rgb))' : 'rgb(var(--border-card-rgb))', 'important');
button.style.setProperty('color', highlight ? 'rgb(var(--text-emphasis-rgb))' : 'rgb(var(--text-body-rgb))', 'important');
}
}
const badge = button?.querySelector('[id$="-filter-count"]');
if (!badge) return;
badge.textContent = String(count);
badge.classList.toggle('hidden', count === 0);
badge.classList.toggle('flex', count > 0);
}
function syncLiveFilters() {
const config = getActiveFilterConfig();
config.applyState?.({
@@ -368,9 +435,11 @@ function syncLiveFilters() {
minMinutes: localMinMinutes,
maxMinutes: localMaxMinutes,
});
syncPanelCount();
}
export function setupFilter() {
ensureFilterPopoverStyles();
const rangeTrack = document.getElementById('prep-time-range-fill')?.parentElement;
const minHandle = document.getElementById('prep-time-min-handle');
const maxHandle = document.getElementById('prep-time-max-handle');
@@ -522,6 +591,7 @@ export function setupFilter() {
renderSlotChips();
renderTagChips();
syncFilterSections();
syncPanelCount();
showFilterPanel();
};

View File

@@ -1,13 +1,10 @@
import { INGREDIENTS, RECIPES } from '../data/catalog.js?v=8';
import { INGREDIENTS, RECIPES } from '../data/catalog.js?v=9';
import { MEAL_SLOTS } from '../planner/mealSlots.js';
import {
addMonths,
addWeeks,
sameMonth,
sameDay,
startOfDay,
startOfMonth,
startOfWeekMonday,
weekContains,
} from '../services/dateUtils.js';
import {
computeEntryNutrition,
@@ -25,18 +22,18 @@ import {
savePlans,
} from '../services/planStore.js?v=2';
import {
CALENDAR_HANDLE_CLASS,
CALENDAR_MONTHS_SHORT,
bindCollapsibleCalendarSwipeGesture,
bindCalendarDayClicks,
createCollapsibleCalendarHTML,
createCalendarTopbarHTML,
createCalendarWeekdayHeaderHTML,
formatCalendarMonthYear,
formatCalendarSelectedDate,
formatCalendarPeriodLabel,
isCalendarOnToday,
renderCollapsibleCalendar,
syncCalendarTodayButton,
syncCollapsibleCalendarMode,
} from '../ui/mealCalendar.js?v=1';
syncCollapsibleCalendarToggleIcon,
} from '../ui/mealCalendar.js?v=15';
import {
filterRecipesByQuery,
renderRecipeGrid,
@@ -66,54 +63,45 @@ function syncTodayButton(mode, weekStart, monthAnchor, selected) {
syncCalendarTodayButton(
document.getElementById('cal-go-today'),
isCalendarOnToday(mode, weekStart, monthAnchor, selected),
selected,
{
labelText: formatCalendarPeriodLabel(mode, weekStart, monthAnchor),
},
);
}
export function getMealPlannerHTML() {
return `
<div id="planner-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden bg-[#2d2e2b] z-10">
<div id="planner-cal-bar" class="shrink-0 bg-[#2d2e2b] border-b border-[#444442] mt-3 relative z-10">
${createCalendarTopbarHTML({
titleId: 'cal-period-label',
prevId: 'cal-prev',
<div id="planner-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden bg-[rgb(var(--app-bg-rgb))] z-10">
<div id="planner-cal-bar" class="shrink-0 bg-[rgb(var(--app-bg-rgb))] border-b border-[rgb(var(--card-strong-rgb))] mt-3 relative z-10">
<div class="min-h-12 px-4 pt-4 pb-3 flex items-center justify-end gap-3 min-w-0">
${createCalendarTopbarHTML({
todayId: 'cal-go-today',
nextId: 'cal-next',
titleClass: 'text-[18px] font-semibold text-[#ddd6ca] leading-none tracking-[-0.03em]',
wrapperClass: 'flex shrink-0 items-center justify-end',
})}
<div id="calendar-swipe-zone" class="overflow-x-hidden bg-[#2d2e2b]" style="touch-action: none">
<div id="calendar-week-wrap" class="px-3 overflow-x-hidden bg-[#2d2e2b]" style="overflow: hidden; max-height: 10rem; opacity: 1; padding-bottom: 0.75rem">
${createCalendarWeekdayHeaderHTML()}
<div id="calendar-week-grid" class="grid grid-cols-7 gap-1.5 max-w-full overflow-x-hidden"></div>
</div>
<div id="calendar-month-wrap" class="px-3 bg-[#2d2e2b]" style="overflow: hidden; max-height: 0; opacity: 0; padding-bottom: 0">
${createCalendarWeekdayHeaderHTML()}
<div id="calendar-month-grid" class="grid grid-cols-7 gap-1.5"></div>
</div>
<div id="calendar-drag-handle" class="flex items-center justify-center pb-2 pt-0.5">
<span id="calendar-handle-icon" class="${CALENDAR_HANDLE_CLASS}" aria-hidden="true"></span>
</div>
</div>
${createCollapsibleCalendarHTML({ idPrefix: 'calendar' })}
</div>
<div id="planner-scroll" class="flex-1 overflow-y-auto px-4 pt-3 pb-24 bg-[#2d2e2b]">
<div id="planner-scroll" class="flex-1 overflow-y-auto px-4 pt-3 pb-32 bg-[rgb(var(--app-bg-rgb))]">
<div id="planner-summary-card" class="mb-3">
<div class="h-full flex flex-col" style="background:#2d2e2b !important; background-image:none !important; box-shadow:none !important;">
<div class="h-full flex flex-col" style="background:rgb(var(--app-bg-rgb)) !important; background-image:none !important; box-shadow:none !important;">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-2">Wartości odżywcze</p>
<div class="flex-1 flex items-center">
<div class="grid grid-cols-4 gap-1.5 w-full">
<div class="rounded-xl px-2 py-[0.5625rem] text-center" style="background:#393937;">
<div class="rounded-xl px-2 py-[0.5625rem] text-center" style="background:rgb(var(--card-rgb));">
<p id="planner-nutrition-kcal" class="text-[15px] font-bold text-gray-100 tabular-nums leading-tight">—</p>
<p class="text-[9px] text-gray-500 font-medium">kcal</p>
</div>
<div class="rounded-xl px-2 py-[0.5625rem] text-center" style="background:#393937;">
<div class="rounded-xl px-2 py-[0.5625rem] text-center" style="background:rgb(var(--card-rgb));">
<p id="planner-nutrition-p" class="text-[15px] font-bold text-blue-400 tabular-nums leading-tight">—</p>
<p class="text-[9px] text-gray-500 font-medium">białko</p>
</div>
<div class="rounded-xl px-2 py-[0.5625rem] text-center" style="background:#393937;">
<div class="rounded-xl px-2 py-[0.5625rem] text-center" style="background:rgb(var(--card-rgb));">
<p id="planner-nutrition-f" class="text-[15px] font-bold text-amber-400 tabular-nums leading-tight">—</p>
<p class="text-[9px] text-gray-500 font-medium">tłuszcz</p>
</div>
<div class="rounded-xl px-2 py-[0.5625rem] text-center" style="background:#393937;">
<div class="rounded-xl px-2 py-[0.5625rem] text-center" style="background:rgb(var(--card-rgb));">
<p id="planner-nutrition-c" class="text-[15px] font-bold text-orange-400 tabular-nums leading-tight">—</p>
<p class="text-[9px] text-gray-500 font-medium">węglowodany</p>
</div>
@@ -121,19 +109,19 @@ export function getMealPlannerHTML() {
</div>
</div>
</div>
<button type="button" id="planner-open-ingredients" class="hidden w-full mb-3 flex items-center justify-center gap-2 py-2.5 rounded-xl border border-dashed border-[#444442] bg-[#2d2e2b] text-[13px] font-semibold text-[#d7d2c8] hover:border-[#6d6c67] hover:bg-[#3a3a37] transition-colors">
<i class="fas fa-shopping-basket text-[#9b978f] text-xs" aria-hidden="true"></i>
<button type="button" id="planner-open-ingredients" class="hidden w-full mb-3 flex items-center justify-center gap-2 py-2.5 rounded-xl border border-dashed border-[rgb(var(--card-strong-rgb))] bg-[rgb(var(--app-bg-rgb))] text-[13px] font-semibold text-[rgb(var(--text-body-soft-rgb))] hover:border-[rgb(var(--text-subdued-rgb))] hover:bg-[rgb(var(--card-raised-rgb))] transition-colors">
<i class="fas fa-shopping-basket text-[rgb(var(--text-dim-rgb))] text-xs" aria-hidden="true"></i>
Składniki na ten dzień
</button>
<div id="planner-meal-slots" class="space-y-3 pb-2 bg-[#2d2e2b]"></div>
<div id="planner-meal-slots" class="space-y-3 pb-2 bg-[rgb(var(--app-bg-rgb))]"></div>
</div>
</div>
<div id="planner-picker-backdrop" class="absolute inset-0 z-[55] bg-black/45 hidden opacity-0 transition-opacity duration-200" aria-hidden="true"></div>
<div id="planner-picker-sheet" data-off-transform="${PLANNER_PICKER_OFF_TRANSFORM}" class="absolute inset-x-0 bottom-0 z-[60] flex flex-col will-change-transform rounded-t-[1.85rem] overflow-hidden" style="top:calc(env(safe-area-inset-top) + 0.35rem); visibility: hidden; transform: ${PLANNER_PICKER_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1); background:#2d2e2b !important; background-image:none !important;" role="dialog" aria-label="Wybierz przepis" aria-modal="true">
<div id="planner-picker-sheet" data-off-transform="${PLANNER_PICKER_OFF_TRANSFORM}" class="absolute inset-x-0 bottom-0 z-[60] flex flex-col will-change-transform rounded-t-[1.85rem] overflow-hidden" style="top:calc(env(safe-area-inset-top) + 0.35rem); visibility: hidden; transform: ${PLANNER_PICKER_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1); background:rgb(var(--app-bg-rgb)) !important; background-image:none !important;" role="dialog" aria-label="Wybierz przepis" aria-modal="true">
<div class="pointer-events-none absolute inset-x-0 top-0 z-[2] px-4 pt-3">
<div class="pointer-events-auto pb-4 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-[#6d6c67]/75 rounded-full mx-auto" aria-hidden="true"></div>
<div class="w-10 h-1 bg-[rgb(var(--text-subdued-rgb))]/75 rounded-full mx-auto" aria-hidden="true"></div>
</div>
<div class="pointer-events-auto">
${getRecipeSearchFieldHTML({
@@ -147,8 +135,8 @@ export function getMealPlannerHTML() {
})}
</div>
</div>
<div id="planner-picker-scroll" class="relative min-h-0 flex-1 overflow-y-auto px-4 pt-28 pb-8 bg-[#2d2e2b]" style="background:#2d2e2b !important;">
<div id="planner-picker-grid" class="grid grid-cols-3 gap-2 bg-[#2d2e2b]" style="background:#2d2e2b !important;"></div>
<div id="planner-picker-scroll" class="relative min-h-0 flex-1 overflow-y-auto px-4 pt-28 pb-8 bg-[rgb(var(--app-bg-rgb))]" style="background:rgb(var(--app-bg-rgb)) !important;">
<div id="planner-picker-grid" class="grid grid-cols-3 gap-2 bg-[rgb(var(--app-bg-rgb))]" style="background:rgb(var(--app-bg-rgb)) !important;"></div>
<div id="planner-picker-empty-state" class="hidden 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" aria-hidden="true"></i>
@@ -160,20 +148,20 @@ export function getMealPlannerHTML() {
</div>
<div id="planner-ing-backdrop" class="absolute inset-0 z-[55] bg-black/45 hidden opacity-0 transition-opacity duration-200" aria-hidden="true"></div>
<div id="planner-ing-sheet" class="absolute left-0 right-0 bottom-0 z-[60] rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] flex flex-col will-change-transform" style="visibility: hidden; height: auto; max-height: calc(100vh - env(safe-area-inset-top) - 1rem); transform: ${PLANNER_SHEET_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1); background:#2d2e2b !important; background-image:none !important;" role="dialog" aria-labelledby="planner-ing-title" aria-modal="true">
<div class="shrink-0 px-4 pt-3 pb-2 border-b border-[#444442] 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-[#6d6c67]/75 rounded-full mx-auto mb-2.5" aria-hidden="true"></div>
<h2 id="planner-ing-title" class="text-[15px] font-bold text-[#ddd6ca] leading-tight pr-2">Składniki i spiżarnia</h2>
<p id="planner-ing-sub" class="text-[11px] text-[#9b978f] mt-1">Porównanie potrzeb z zapasami.</p>
<div id="planner-ing-sheet" class="absolute left-0 right-0 bottom-0 z-[60] rounded-t-3xl shadow-[0_-10px_40px_rgba(var(--overlay-rgb),0.12)] flex flex-col will-change-transform" style="visibility: hidden; height: auto; max-height: calc(100vh - env(safe-area-inset-top) - 1rem); transform: ${PLANNER_SHEET_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1); background:rgb(var(--app-bg-rgb)) !important; background-image:none !important;" role="dialog" aria-labelledby="planner-ing-title" aria-modal="true">
<div class="shrink-0 px-4 pt-3 pb-2 border-b border-[rgb(var(--card-strong-rgb))] 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-[rgb(var(--text-subdued-rgb))]/75 rounded-full mx-auto mb-2.5" aria-hidden="true"></div>
<h2 id="planner-ing-title" class="text-[15px] font-bold text-[rgb(var(--text-body-rgb))] leading-tight pr-2">Składniki i spiżarnia</h2>
<p id="planner-ing-sub" class="text-[11px] text-[rgb(var(--text-dim-rgb))] mt-1">Porównanie potrzeb z zapasami.</p>
</div>
<div id="planner-ing-body" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-4 py-2 pb-2"></div>
<div id="planner-ing-footer" class="shrink-0 p-4 pt-2 pb-5 border-t border-[#444442] bg-[#2d2e2b] space-y-2">
<div id="planner-ing-footer" class="shrink-0 p-4 pt-2 pb-5 border-t border-[rgb(var(--card-strong-rgb))] bg-[rgb(var(--app-bg-rgb))] space-y-2">
<button type="button" id="planner-ing-add-all" 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">
<i class="fas fa-cart-plus text-xs" aria-hidden="true"></i>
Dodaj braki na dziś do listy
</button>
<button type="button" id="planner-ing-add-btn" class="hidden w-full border border-[#444442] bg-[#2d2e2b] text-[#d7d2c8] hover:bg-[#3a3a37] py-2.5 rounded-xl font-semibold text-[13px] flex items-center justify-center gap-2 transition-colors">
<i class="fas fa-calendar-week text-[#9b978f] text-[11px]" aria-hidden="true"></i>
<button type="button" id="planner-ing-add-btn" class="hidden w-full border border-[rgb(var(--card-strong-rgb))] bg-[rgb(var(--app-bg-rgb))] text-[rgb(var(--text-body-soft-rgb))] hover:bg-[rgb(var(--card-raised-rgb))] py-2.5 rounded-xl font-semibold text-[13px] flex items-center justify-center gap-2 transition-colors">
<i class="fas fa-calendar-week text-[rgb(var(--text-dim-rgb))] text-[11px]" aria-hidden="true"></i>
Dodaj braki na cały tydzień
</button>
</div>
@@ -185,77 +173,13 @@ export function getMealPlannerHTML() {
`;
}
function updatePeriodLabel(mode, weekStart, monthAnchor, selected) {
const el = document.getElementById('cal-period-label');
if (!el) return;
if (mode === 'week') {
el.textContent = formatCalendarSelectedDate(selected);
} else {
el.textContent = formatCalendarMonthYear(monthAnchor);
}
}
function syncModeToggle(mode) {
syncCollapsibleCalendarMode({
mode,
weekWrapEl: document.getElementById('calendar-week-wrap'),
monthWrapEl: document.getElementById('calendar-month-wrap'),
handleEl: document.getElementById('calendar-handle-icon'),
});
}
function bindCalendarSwipeGesture(state, rerender) {
const zone = document.getElementById('calendar-swipe-zone');
if (!zone) return;
let startY = 0;
let ptrId = null;
let moved = false;
zone.addEventListener('pointerdown', (e) => {
if (ptrId !== null) return;
startY = e.clientY;
ptrId = e.pointerId;
moved = false;
});
zone.addEventListener('pointermove', (e) => {
if (e.pointerId !== ptrId) return;
if (Math.abs(e.clientY - startY) > 10) moved = true;
});
zone.addEventListener('pointerup', (e) => {
if (e.pointerId !== ptrId) return;
const dy = e.clientY - startY;
ptrId = null;
if (!moved || Math.abs(dy) < 30) return;
let switched = false;
if (state.mode === 'week' && dy > 30) {
state.mode = 'month';
state.monthAnchor = startOfMonth(state.selected);
switched = true;
} else if (state.mode === 'month' && dy < -30) {
state.mode = 'week';
state.weekStart = startOfWeekMonday(state.selected);
switched = true;
}
if (switched) {
zone.addEventListener('click', (ev) => {
ev.stopPropagation();
ev.preventDefault();
}, { capture: true, once: true });
rerender();
}
});
zone.addEventListener('pointercancel', () => {
ptrId = null;
moved = false;
});
syncCollapsibleCalendarToggleIcon(document.getElementById('calendar-handle-icon'), mode);
}
function showPlannerToast(message) {
@@ -479,27 +403,27 @@ function renderDayContent(state, onMealRemoved = null) {
(entry.addedIngredients?.length > 0) ||
(entry.substitutions && Object.keys(entry.substitutions).length > 0);
const customDot = hasCustom ? '<span class="w-1.5 h-1.5 rounded-full bg-amber-400 inline-block shrink-0 ml-1"></span>' : '';
const servLabel = servings > 1 ? `<span class="mx-1.5 text-[#6d6c67]">·</span>×${servings}` : '';
const servLabel = servings > 1 ? `<span class="mx-1.5 text-[rgb(var(--text-subdued-rgb))]">·</span>×${servings}` : '';
const rowStyle = `--planner-swipe-progress:${isPendingDelete ? '1' : '0'};`;
const rowAttrs = isPendingDelete ? 'data-pending-delete="true"' : '';
const backgroundStyle = isPendingDelete
? 'background:linear-gradient(90deg, rgba(65,64,60,0.08), rgba(65,64,60,0.2));'
: 'background:rgba(203,74,72, calc(0.18 + var(--planner-swipe-progress) * 0.5));';
? 'background:linear-gradient(90deg, rgba(var(--border-card-rgb),0.08), rgba(var(--border-card-rgb),0.2));'
: 'background:rgba(var(--danger-rgb), calc(0.18 + var(--planner-swipe-progress) * 0.5));';
const backgroundLabel = isPendingDelete
? `<span class="inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-wide uppercase" style="color:rgba(210,204,194,0.42);">
? `<span class="inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-wide uppercase" style="color:rgba(var(--text-body-soft-rgb),0.42);">
<i class="fas fa-hourglass-half text-[10px]" aria-hidden="true"></i>
Usuwanie
</span>`
: `<span class="inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-wide uppercase" style="color:rgba(250,234,234, calc(0.55 + var(--planner-swipe-progress) * 0.45));">
: `<span class="inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-wide uppercase" style="color:rgba(var(--text-emphasis-rgb), calc(0.55 + var(--planner-swipe-progress) * 0.45));">
<i class="fas fa-trash text-[10px]" aria-hidden="true"></i>
Usuń
</span>`;
const titleClass = isPendingDelete
? 'text-[13px] font-normal text-[#bcb5ab] truncate'
: 'text-[13px] font-normal text-[#ddd6ca] truncate';
? 'text-[13px] font-normal text-[rgb(var(--text-muted-rgb))] truncate'
: 'text-[13px] font-normal text-[rgb(var(--text-body-rgb))] truncate';
const metaClass = isPendingDelete
? 'text-[11px] text-[#878079] mt-0.5 tabular-nums'
: 'text-[11px] text-[#9b978f] mt-0.5 tabular-nums';
? 'text-[11px] text-[rgb(var(--text-faint-rgb))] mt-0.5 tabular-nums'
: 'text-[11px] text-[rgb(var(--text-dim-rgb))] mt-0.5 tabular-nums';
const actionWrapClass = isPendingDelete
? 'relative z-[2] flex items-center shrink-0 self-center'
: 'flex items-center gap-1 shrink-0 self-center';
@@ -511,22 +435,22 @@ function renderDayContent(state, onMealRemoved = null) {
: 0;
const entryAction = isPendingDelete
? `<button type="button" class="planner-cancel-pending-remove rounded-full p-[1.5px] transition-transform hover:scale-[1.02] active:scale-[0.98]" style="background:${getPendingMealRemovalButtonStyle(remainingProgress)};" data-pending-delete-progress data-day-key="${selectedDayKey}" data-slot-id="${slot.id}" data-entry-id="${eid}" aria-label="Anuluj usunięcie posiłku">
<span class="flex h-7 w-7 items-center justify-center rounded-full bg-[#343530] text-[#e2ddd4] shadow-[0_1px_2px_rgba(0,0,0,0.28)]">
<span class="flex h-7 w-7 items-center justify-center rounded-full bg-[rgb(var(--card-raised-rgb))] text-[rgb(var(--text-body-rgb))] shadow-[0_1px_2px_rgba(var(--overlay-rgb),0.28)]">
<i class="fas fa-rotate-left text-[10px]" aria-hidden="true"></i>
</span>
</button>`
: `<button type="button" class="planner-edit-meal w-6 h-6 rounded-full border border-[#444442] text-[#9b978f] hover:text-[#ddd6ca] hover:border-[#6d6c67] hover:bg-[#3a3a37] flex items-center justify-center transition-colors" data-slot-id="${slot.id}" data-entry-id="${eid}" aria-label="Edytuj ten przepis">
<i class="fas fa-pencil text-[9px]" aria-hidden="true"></i>
: `<button type="button" class="planner-edit-meal w-7 h-7 rounded-full border border-[rgb(var(--card-strong-rgb))] text-[rgb(var(--text-dim-rgb))] hover:text-[rgb(var(--text-body-rgb))] hover:border-[rgb(var(--text-subdued-rgb))] hover:bg-[rgb(var(--card-raised-rgb))] flex items-center justify-center transition-colors" data-slot-id="${slot.id}" data-entry-id="${eid}" aria-label="Edytuj ten przepis">
<i class="fas fa-pencil text-[10px]" aria-hidden="true"></i>
</button>`;
return `
<div class="relative overflow-hidden rounded-lg ${isPendingDelete ? 'ring-1 ring-white/5' : ''}" data-planner-swipe-row style="${rowStyle}" data-slot-id="${slot.id}" data-entry-id="${eid}" ${rowAttrs}>
<div class="pointer-events-none absolute inset-0 flex items-center justify-end px-4" style="${backgroundStyle}">
${backgroundLabel}
</div>
<div class="relative z-[1] rounded-lg p-2 planner-open-recipe cursor-pointer" style="background:${isPendingDelete ? 'rgba(45,45,43,0.76)' : '#2d2e2b'}; box-shadow:inset 0 1px 3px rgba(0,0,0,0.3); transform:${isPendingDelete ? 'translateX(0) scale(0.988)' : 'translateX(0)'}; transition:transform 180ms cubic-bezier(0.22, 1, 0.36, 1), opacity 180ms ease, background-color 180ms ease; touch-action:pan-y; opacity:1;" data-planner-swipe-card data-slot-id="${slot.id}" data-entry-id="${eid}" data-recipe-id="${escapeHtml(recipe.id)}">
<div class="relative z-[1] rounded-lg p-2 planner-open-recipe cursor-pointer" style="background:${isPendingDelete ? 'rgba(var(--app-bg-rgb),0.76)' : 'rgb(var(--app-bg-rgb))'}; box-shadow:inset 0 1px 3px rgba(var(--overlay-rgb),0.3); transform:${isPendingDelete ? 'translateX(0) scale(0.988)' : 'translateX(0)'}; transition:transform 180ms cubic-bezier(0.22, 1, 0.36, 1), opacity 180ms ease, background-color 180ms ease; touch-action:pan-y; opacity:1;" data-planner-swipe-card data-slot-id="${slot.id}" data-entry-id="${eid}" data-recipe-id="${escapeHtml(recipe.id)}">
<div class="relative flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0" style="${contentToneStyle}">
<div class="w-8 h-8 rounded-lg bg-[#3a3a37] overflow-hidden shrink-0">
<div class="w-8 h-8 rounded-lg bg-[rgb(var(--card-raised-rgb))] overflow-hidden shrink-0">
${recipe.image
? `<img src="${escapeHtml(recipe.image)}" alt="" class="w-full h-full object-cover">`
: `<span class="w-full h-full flex items-center justify-center text-white text-[8px] font-medium">${escapeHtml(recipe.thumbLabel)}</span>`}
@@ -534,9 +458,9 @@ function renderDayContent(state, onMealRemoved = null) {
<div class="min-w-0">
<div class="flex items-center"><p class="${titleClass}">${escapeHtml(recipe.title)}</p>${customDot}</div>
<p class="${metaClass}">
<i class="fas fa-clock text-[#7d7a74] mr-0.5" aria-hidden="true"></i>${recipe.minutes} min
<span class="mx-1.5 text-[#6d6c67]">·</span>
<i class="fas fa-fire text-[#7d7a74] mr-0.5" aria-hidden="true"></i>${entryN.kcal} kcal${servLabel}
<i class="fas fa-clock text-[rgb(var(--text-faint-rgb))] mr-0.5" aria-hidden="true"></i>${recipe.minutes} min
<span class="mx-1.5 text-[rgb(var(--text-subdued-rgb))]">·</span>
<i class="fas fa-fire text-[rgb(var(--text-faint-rgb))] mr-0.5" aria-hidden="true"></i>${entryN.kcal} kcal${servLabel}
</p>
</div>
</div>
@@ -548,36 +472,36 @@ function renderDayContent(state, onMealRemoved = null) {
</div>`;
}).join('');
const addBtn = `<button type="button" class="planner-add-meal w-7 h-7 rounded-full border border-[#444442] text-[#9b978f] flex items-center justify-center shrink-0" data-slot-id="${slot.id}" aria-label="Dodaj przepis"><i class="fas fa-plus text-[10px]"></i></button>`;
const addBtn = `<button type="button" class="planner-add-meal w-7 h-7 rounded-full border border-[rgb(var(--card-strong-rgb))] text-[rgb(var(--text-dim-rgb))] flex items-center justify-center shrink-0" data-slot-id="${slot.id}" aria-label="Dodaj przepis"><i class="fas fa-plus text-[10px]"></i></button>`;
const kcalPill = slotKcal > 0
? `<span class="text-[10px] font-semibold tabular-nums shrink-0 px-2 py-0.5 rounded-full" style="background:#2d2e2b; color:#d7d2c8;">${slotKcal} kcal</span>`
? `<span class="planner-kcal-pill text-[10px] font-semibold tabular-nums shrink-0 px-2 py-0.5 rounded-full" style="color:rgb(var(--text-body-soft-rgb));">${slotKcal} kcal</span>`
: '';
const filledCard = `
<div class="rounded-xl bg-[#393937] overflow-hidden" style="background:#393937 !important; box-shadow:0 2px 8px rgba(0,0,0,0.25);" data-slot-id="${slot.id}">
<div class="flex items-center gap-2 px-3 py-2.5 bg-[#393937]" style="background:#393937 !important;">
<i class="fas ${slot.icon} w-7 text-center text-[13px] text-[#9b978f] shrink-0" aria-hidden="true"></i>
<span class="text-[13px] font-semibold text-[#ddd6ca] truncate min-w-0">${slot.label}</span>
<div class="rounded-2xl bg-[rgb(var(--card-rgb))] overflow-hidden" style="background:rgb(var(--card-rgb)) !important; box-shadow:var(--shadow-card);" data-slot-id="${slot.id}">
<div class="flex items-center gap-2 px-4 py-3 bg-[rgb(var(--card-rgb))]" style="background:rgb(var(--card-rgb)) !important;">
<i class="fas ${slot.icon} w-7 text-center text-[13px] text-[rgb(var(--text-dim-rgb))] shrink-0" aria-hidden="true"></i>
<span class="text-[13px] font-semibold text-[rgb(var(--text-body-rgb))] truncate min-w-0">${slot.label}</span>
<span class="ml-auto"></span>
${kcalPill}
${addBtn}
</div>
${entries.length > 0 ? `<div class="px-2.5 pb-2.5 space-y-2 border-t border-[#444442]" style="padding-top:0.625rem;">${entryCards}</div>` : ''}
${entries.length > 0 ? `<div class="px-3 pb-3 space-y-2 border-t border-[rgb(var(--card-strong-rgb))]" style="padding-top:0.75rem;">${entryCards}</div>` : ''}
</div>`;
if (entries.length > 0) return filledCard;
return `
<div class="rounded-xl bg-[#393937] overflow-hidden" style="background:#393937 !important; box-shadow:0 2px 8px rgba(0,0,0,0.25);" data-slot-id="${slot.id}">
<div class="flex items-center gap-2 px-3 py-2.5">
<i class="fas ${slot.icon} w-7 text-center text-[13px] text-[#9b978f] shrink-0" aria-hidden="true"></i>
<span class="text-[13px] font-semibold text-[#ddd6ca] truncate min-w-0">${slot.label}</span>
<div class="rounded-2xl bg-[rgb(var(--card-rgb))] overflow-hidden" style="background:rgb(var(--card-rgb)) !important; box-shadow:var(--shadow-card);" data-slot-id="${slot.id}">
<div class="flex items-center gap-2 px-4 py-3">
<i class="fas ${slot.icon} w-7 text-center text-[13px] text-[rgb(var(--text-dim-rgb))] shrink-0" aria-hidden="true"></i>
<span class="text-[13px] font-semibold text-[rgb(var(--text-body-rgb))] truncate min-w-0">${slot.label}</span>
<span class="ml-auto"></span>
${addBtn}
</div>
<div class="px-3 pb-2.5 -mt-0.5">
<p class="text-[11px] text-[#7d7a74] italic pl-9">Zaplanuj posiłek</p>
<div class="px-4 pb-3 -mt-0.5">
<p class="text-[11px] text-[rgb(var(--text-faint-rgb))] italic pl-9">Zaplanuj posiłek</p>
</div>
</div>`;
}).join('');
@@ -604,7 +528,7 @@ function getPendingMealRemovalProgress(state, dayKey, slotId, entryId) {
function getPendingMealRemovalButtonStyle(progress) {
const clamped = Math.max(0, Math.min(1, progress));
const angle = Math.round(clamped * 360);
return `conic-gradient(from -90deg, rgba(229,223,214,0.96) 0deg, rgba(229,223,214,0.96) ${angle}deg, rgba(109,108,103,0.24) ${angle}deg, rgba(109,108,103,0.24) 360deg)`;
return `conic-gradient(from -90deg, rgba(var(--text-body-rgb),0.96) 0deg, rgba(var(--text-body-rgb),0.96) ${angle}deg, rgba(var(--text-subdued-rgb),0.24) ${angle}deg, rgba(var(--text-subdued-rgb),0.24) 360deg)`;
}
function stopPendingMealRemovalTicker(state) {
@@ -980,7 +904,7 @@ function renderIngredientsSheet(state) {
if (subEl) subEl.textContent = 'Porównanie potrzeb z zapasami w spiżarni.';
if (!today || today.items.length === 0) {
body.innerHTML = '<p class="text-sm text-[#9b978f] text-center py-8">Najpierw zaplanuj posiłki.</p>';
body.innerHTML = '<p class="text-sm text-[rgb(var(--text-dim-rgb))] text-center py-8">Najpierw zaplanuj posiłki.</p>';
updateIngButtons(state);
return;
}
@@ -990,8 +914,8 @@ function renderIngredientsSheet(state) {
let html = '';
if (shortItems.length === 0) {
html += `<div class="rounded-xl bg-[#2d2e2b] border border-emerald-400/40 p-3 mb-4 flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-[#24352a] flex items-center justify-center shrink-0">
html += `<div class="rounded-xl bg-[rgb(var(--app-bg-rgb))] border border-emerald-400/40 p-3 mb-4 flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-[rgba(var(--success-rgb), 0.14)] flex items-center justify-center shrink-0">
<i class="fas fa-check text-emerald-600 text-sm"></i>
</div>
<div>
@@ -1000,8 +924,8 @@ function renderIngredientsSheet(state) {
</div>
</div>`;
} else {
html += `<div class="rounded-xl bg-[#2d2e2b] border border-red-300/40 p-3 mb-4 flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-[#3a2326] flex items-center justify-center shrink-0">
html += `<div class="rounded-xl bg-[rgb(var(--app-bg-rgb))] border border-red-300/40 p-3 mb-4 flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-[rgba(var(--danger-rgb), 0.14)] flex items-center justify-center shrink-0">
<i class="fas fa-exclamation text-red-500 text-sm"></i>
</div>
<div>
@@ -1016,15 +940,15 @@ function renderIngredientsSheet(state) {
<p class="text-[10px] font-bold text-red-400 uppercase tracking-wider mb-2 px-0.5">
<i class="fas fa-cart-shopping text-[9px] mr-1"></i>Do kupienia
</p>
<ul class="border border-red-300/30 rounded-xl overflow-hidden bg-[#2d2e2b] divide-y divide-[#3a2326]">
<ul class="border border-red-300/30 rounded-xl overflow-hidden bg-[rgb(var(--app-bg-rgb))] divide-y divide-[rgba(var(--danger-rgb), 0.14)]">
${shortItems.map((ing) => `
<li class="flex items-start gap-3 py-3 px-3">
<div class="w-2 h-2 rounded-full bg-red-400 mt-1.5 shrink-0"></div>
<div class="flex-1 min-w-0">
<p class="text-[13px] font-semibold text-[#ddd6ca]">${escapeHtml(ing.name)}</p>
<p class="text-[11px] text-[#9b978f] mt-0.5">
potrzeba <span class="font-medium text-[#d7d2c8]">${formatAmount(ing.amount)} ${escapeHtml(ing.pantryUnit)}</span>
<span class="mx-1 text-[#6d6c67]">&middot;</span>
<p class="text-[13px] font-semibold text-[rgb(var(--text-body-rgb))]">${escapeHtml(ing.name)}</p>
<p class="text-[11px] text-[rgb(var(--text-dim-rgb))] mt-0.5">
potrzeba <span class="font-medium text-[rgb(var(--text-body-soft-rgb))]">${formatAmount(ing.amount)} ${escapeHtml(ing.pantryUnit)}</span>
<span class="mx-1 text-[rgb(var(--text-subdued-rgb))]">&middot;</span>
w spiżarni <span class="font-medium ${ing.pantryQty > 0 ? 'text-amber-600' : 'text-gray-400'}">${ing.pantryQty > 0 ? formatAmount(ing.pantryQty) + ' ' + escapeHtml(ing.pantryUnit) : 'brak'}</span>
</p>
</div>
@@ -1042,15 +966,15 @@ function renderIngredientsSheet(state) {
<p class="text-[10px] font-bold text-emerald-500 uppercase tracking-wider mb-2 px-0.5">
<i class="fas fa-check text-[9px] mr-1"></i>W spiżarni
</p>
<ul class="border border-[#444442] rounded-xl overflow-hidden bg-[#2d2e2b] divide-y divide-[#353632]">
<ul class="border border-[rgb(var(--card-strong-rgb))] rounded-xl overflow-hidden bg-[rgb(var(--app-bg-rgb))] divide-y divide-[rgb(var(--card-raised-rgb))]">
${okItems.map((ing) => `
<li class="flex items-start gap-3 py-2.5 px-3">
<div class="w-2 h-2 rounded-full bg-emerald-400 mt-1.5 shrink-0"></div>
<div class="flex-1 min-w-0">
<p class="text-[13px] font-medium text-[#d7d2c8]">${escapeHtml(ing.name)}</p>
<p class="text-[11px] text-[#9b978f] mt-0.5">
potrzeba <span class="font-medium text-[#d7d2c8]">${formatAmount(ing.amount)} ${escapeHtml(ing.pantryUnit)}</span>
<span class="mx-1 text-[#6d6c67]">&middot;</span>
<p class="text-[13px] font-medium text-[rgb(var(--text-body-soft-rgb))]">${escapeHtml(ing.name)}</p>
<p class="text-[11px] text-[rgb(var(--text-dim-rgb))] mt-0.5">
potrzeba <span class="font-medium text-[rgb(var(--text-body-soft-rgb))]">${formatAmount(ing.amount)} ${escapeHtml(ing.pantryUnit)}</span>
<span class="mx-1 text-[rgb(var(--text-subdued-rgb))]">&middot;</span>
masz <span class="font-medium text-emerald-600">${formatAmount(ing.pantryQty)} ${escapeHtml(ing.pantryUnit)}</span>
</p>
</div>
@@ -1069,7 +993,7 @@ function renderIngredientsSheet(state) {
const wd = WEEKDAYS_LONG[day.date.getDay()];
const label = `${wd}, ${day.date.getDate()} ${CALENDAR_MONTHS_SHORT[day.date.getMonth()]}`;
const shorts = day.items.filter((it) => !it.enough);
return `<div class="rounded-xl border border-amber-200/80 bg-[#2d2e2b] p-3">
return `<div class="rounded-xl border border-amber-200/80 bg-[rgb(var(--app-bg-rgb))] p-3">
<p class="text-[12px] font-semibold text-amber-900">
<i class="fas fa-calendar-day text-[10px] mr-1.5 text-amber-500"></i>${escapeHtml(label)}
</p>
@@ -1138,9 +1062,21 @@ export function setupMealPlanner() {
maxMinutes: PICKER_FILTER_MAX_MINUTES,
};
const resolveCalendarDayState = (day, meta) => {
const today = startOfDay(new Date());
const isSelected = sameDay(day, state.selected);
const isPast = day.getTime() < today.getTime();
return {
disabled: isPast && !isSelected,
dimmed: (isPast || (meta.mode === 'month' && !meta.inCurrentMonth)) && !isSelected,
showIndicator: meta.mode === 'month'
? meta.inCurrentMonth && dayHasAnyMeal(state.plans, day)
: dayHasAnyMeal(state.plans, day),
};
};
const rerender = () => {
syncModeToggle(state.mode);
updatePeriodLabel(state.mode, state.weekStart, state.monthAnchor, state.selected);
syncTodayButton(state.mode, state.weekStart, state.monthAnchor, state.selected);
renderCollapsibleCalendar({
weekGridEl: weekGrid,
@@ -1148,12 +1084,7 @@ export function setupMealPlanner() {
weekAnchorDate: state.weekStart,
monthAnchorDate: state.monthAnchor,
selectedDate: state.selected,
resolveDayState: (day, meta) => ({
dimmed: meta.mode === 'month' && !meta.inCurrentMonth,
showIndicator: meta.mode === 'month'
? meta.inCurrentMonth && dayHasAnyMeal(state.plans, day)
: dayHasAnyMeal(state.plans, day),
}),
resolveDayState: resolveCalendarDayState,
});
renderDayContent(state, persist);
};
@@ -1164,15 +1095,11 @@ export function setupMealPlanner() {
};
/* ── calendar scroll shadow ─────────────────── */
const plannerScroll = document.getElementById('planner-scroll');
const calBar = document.getElementById('planner-cal-bar');
if (plannerScroll && calBar) {
if (calBar) {
const shadow = document.createElement('div');
shadow.style.cssText = 'position:absolute;left:0;right:0;bottom:-8px;height:8px;background:linear-gradient(to bottom,rgba(0,0,0,0.25),transparent);opacity:0;transition:opacity 0.2s;pointer-events:none;';
shadow.style.cssText = 'position:absolute;left:0;right:0;bottom:-8px;height:8px;background:linear-gradient(to bottom,rgba(var(--overlay-rgb),0.25),transparent);pointer-events:none;';
calBar.appendChild(shadow);
plannerScroll.addEventListener('scroll', () => {
shadow.style.opacity = plannerScroll.scrollTop > 2 ? '1' : '0';
});
}
bindCalendarDayClicks(weekGrid, (date) => {
@@ -1184,36 +1111,6 @@ export function setupMealPlanner() {
rerender();
});
document.getElementById('cal-prev')?.addEventListener('click', () => {
if (state.mode === 'week') {
state.weekStart = addWeeks(state.weekStart, -1);
if (!weekContains(state.weekStart, state.selected)) {
state.selected = new Date(state.weekStart);
}
} else {
state.monthAnchor = addMonths(state.monthAnchor, -1);
if (!sameMonth(state.monthAnchor, state.selected)) {
state.selected = startOfMonth(state.monthAnchor);
}
}
rerender();
});
document.getElementById('cal-next')?.addEventListener('click', () => {
if (state.mode === 'week') {
state.weekStart = addWeeks(state.weekStart, 1);
if (!weekContains(state.weekStart, state.selected)) {
state.selected = new Date(state.weekStart);
}
} else {
state.monthAnchor = addMonths(state.monthAnchor, 1);
if (!sameMonth(state.monthAnchor, state.selected)) {
state.selected = startOfMonth(state.monthAnchor);
}
}
rerender();
});
document.getElementById('cal-go-today')?.addEventListener('click', () => {
const today = startOfDay(new Date());
state.selected = today;
@@ -1431,7 +1328,41 @@ export function setupMealPlanner() {
rerender();
};
bindCalendarSwipeGesture(state, rerender);
bindCollapsibleCalendarSwipeGesture({
zoneEl: document.getElementById('calendar-swipe-zone'),
weekWrapEl: document.getElementById('calendar-week-wrap'),
monthWrapEl: document.getElementById('calendar-month-wrap'),
getMode: () => state.mode,
setMode: (mode) => {
state.mode = mode;
},
getWeekAnchor: () => state.weekStart,
setWeekAnchor: (date) => {
state.weekStart = startOfWeekMonday(date);
},
getMonthAnchor: () => state.monthAnchor,
setMonthAnchor: (date) => {
state.monthAnchor = startOfMonth(date);
},
getSelectedDate: () => state.selected,
setSelectedDate: (date) => {
state.selected = startOfDay(date);
},
rerender,
resolveDayState: resolveCalendarDayState,
selectOnNavigateOutside: false,
});
document.getElementById('calendar-mode-toggle')?.addEventListener('click', () => {
if (state.mode === 'week') {
state.mode = 'month';
state.monthAnchor = startOfMonth(state.selected);
} else {
state.mode = 'week';
state.weekStart = startOfWeekMonday(state.selected);
}
rerender();
});
requestAnimationFrame(() => {
const ww = document.getElementById('calendar-week-wrap');

Some files were not shown because too many files have changed in this diff Show More