Compare commits

..

29 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
100 changed files with 3974 additions and 1267 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

File diff suppressed because it is too large Load Diff

View File

@@ -65,11 +65,11 @@ function renderAppBootError(message) {
if (!appContainer) return; if (!appContainer) return;
appContainer.innerHTML = ` appContainer.innerHTML = `
<div class="flex h-full items-center justify-center px-6 text-center" style="background:#2d2e2b !important;"> <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:#2f2f2d !important; border-color:#444442 !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:#f2efe8 !important;">Nie udało się uruchomić aplikacji</p> <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:#b7ada1 !important;">${message}</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:#23221e !important; border-color:#787876 !important; color:#f2efe8 !important;">Odśwież aplikację</button> <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>
</div> </div>
`; `;

View File

@@ -38,7 +38,6 @@ export const INGREDIENTS = {
/* ── Nabiał ───────────────────────────────────────── */ /* ── Nabiał ───────────────────────────────────────── */
jajko: { jajko: {
id: 'jajko', id: 'jajko',
image: 'images/ingredients/jajko.jpg',
name: 'Jajka', name: 'Jajka',
category: 'nabial', category: 'nabial',
pantryUnit: 'szt', pantryUnit: 'szt',
@@ -120,7 +119,6 @@ export const INGREDIENTS = {
}, },
losos_wedzony: { losos_wedzony: {
id: 'losos_wedzony', id: 'losos_wedzony',
image: 'images/ingredients/losos_wedzony.jpg',
name: 'Łosoś wędzony', name: 'Łosoś wędzony',
category: 'mieso_ryby', category: 'mieso_ryby',
pantryUnit: 'g', pantryUnit: 'g',
@@ -138,7 +136,6 @@ export const INGREDIENTS = {
/* ── Warzywa ──────────────────────────────────────── */ /* ── Warzywa ──────────────────────────────────────── */
pomidor: { pomidor: {
id: 'pomidor', id: 'pomidor',
image: 'images/ingredients/pomidor.jpg',
name: 'Pomidor', name: 'Pomidor',
category: 'warzywa', category: 'warzywa',
pantryUnit: 'szt', pantryUnit: 'szt',
@@ -147,7 +144,6 @@ export const INGREDIENTS = {
}, },
pomidorki_koktajlowe: { pomidorki_koktajlowe: {
id: 'pomidorki_koktajlowe', id: 'pomidorki_koktajlowe',
image: 'images/ingredients/pomidorki_koktajlowe.jpg',
name: 'Pomidorki koktajlowe', name: 'Pomidorki koktajlowe',
category: 'warzywa', category: 'warzywa',
pantryUnit: 'g', pantryUnit: 'g',
@@ -156,7 +152,6 @@ export const INGREDIENTS = {
}, },
papryka_czerwona: { papryka_czerwona: {
id: 'papryka_czerwona', id: 'papryka_czerwona',
image: 'images/ingredients/papryka_czerwona.jpg',
name: 'Papryka czerwona', name: 'Papryka czerwona',
category: 'warzywa', category: 'warzywa',
pantryUnit: 'szt', pantryUnit: 'szt',
@@ -165,7 +160,6 @@ export const INGREDIENTS = {
}, },
ogorek: { ogorek: {
id: 'ogorek', id: 'ogorek',
image: 'images/ingredients/ogorek.jpg',
name: 'Ogórek', name: 'Ogórek',
category: 'warzywa', category: 'warzywa',
pantryUnit: 'szt', pantryUnit: 'szt',
@@ -174,7 +168,6 @@ export const INGREDIENTS = {
}, },
czosnek: { czosnek: {
id: 'czosnek', id: 'czosnek',
image: 'images/ingredients/czosnek.jpg',
name: 'Czosnek', name: 'Czosnek',
category: 'warzywa', category: 'warzywa',
pantryUnit: 'szt', pantryUnit: 'szt',
@@ -253,7 +246,6 @@ export const INGREDIENTS = {
/* ── Owoce ────────────────────────────────────────── */ /* ── Owoce ────────────────────────────────────────── */
truskawki: { truskawki: {
id: 'truskawki', id: 'truskawki',
image: 'images/ingredients/truskawki.jpg',
name: 'Truskawki', name: 'Truskawki',
category: 'owoce', category: 'owoce',
pantryUnit: 'g', pantryUnit: 'g',
@@ -261,7 +253,6 @@ export const INGREDIENTS = {
}, },
borowki_amerykanskie: { borowki_amerykanskie: {
id: 'borowki_amerykanskie', id: 'borowki_amerykanskie',
image: 'images/ingredients/borowki_amerykanskie.jpg',
name: 'Borówki amerykańskie', name: 'Borówki amerykańskie',
category: 'owoce', category: 'owoce',
pantryUnit: 'g', pantryUnit: 'g',
@@ -269,7 +260,6 @@ export const INGREDIENTS = {
}, },
banany: { banany: {
id: 'banany', id: 'banany',
image: 'images/ingredients/banany.jpg',
name: 'Banany', name: 'Banany',
category: 'owoce', category: 'owoce',
pantryUnit: 'g', pantryUnit: 'g',
@@ -277,7 +267,6 @@ export const INGREDIENTS = {
}, },
jagody: { jagody: {
id: 'jagody', id: 'jagody',
image: 'images/ingredients/jagody.jpg',
name: 'Jagody', name: 'Jagody',
category: 'owoce', category: 'owoce',
pantryUnit: 'g', pantryUnit: 'g',
@@ -301,7 +290,6 @@ export const INGREDIENTS = {
/* ── Suche i kasze ────────────────────────────────── */ /* ── Suche i kasze ────────────────────────────────── */
makaron_suchy: { makaron_suchy: {
id: 'makaron_suchy', id: 'makaron_suchy',
image: 'images/ingredients/makaron_suchy.jpg',
name: 'Makaron', name: 'Makaron',
category: 'suche', category: 'suche',
pantryUnit: 'g', pantryUnit: 'g',
@@ -337,7 +325,6 @@ export const INGREDIENTS = {
}, },
migdaly: { migdaly: {
id: 'migdaly', id: 'migdaly',
image: 'images/ingredients/migdaly.jpg',
name: 'Migdały', name: 'Migdały',
category: 'suche', category: 'suche',
pantryUnit: 'g', pantryUnit: 'g',
@@ -448,7 +435,6 @@ export const INGREDIENTS = {
}, },
oliwa: { oliwa: {
id: 'oliwa', id: 'oliwa',
image: 'images/ingredients/oliwa.jpg',
name: 'Oliwa z oliwek', name: 'Oliwa z oliwek',
category: 'inne', category: 'inne',
pantryUnit: 'ml', pantryUnit: 'ml',
@@ -456,7 +442,6 @@ export const INGREDIENTS = {
}, },
hummus: { hummus: {
id: 'hummus', id: 'hummus',
image: 'images/ingredients/hummus.jpg',
name: 'Hummus', name: 'Hummus',
category: 'inne', category: 'inne',
pantryUnit: 'g', pantryUnit: 'g',
@@ -528,6 +513,10 @@ export const INGREDIENTS = {
}, },
}; };
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 {{ 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 * @typedef {{ id: string, title: string, minutes: number, thumbLabel: string, image?: string, allowedSlots: string[], tags?: string[], nutritionPerServing: NutritionPer100, ingredients: RecipeIngredientDef[], steps: string[] }} RecipeDef

View File

@@ -1,5 +1,5 @@
import { INGREDIENTS, CATEGORY_LABELS, PRODUCTS, ingredientHasProducts } from '../data/catalog.js?v=8'; import { INGREDIENTS, CATEGORY_LABELS, PRODUCTS, ingredientHasProducts } from '../data/catalog.js?v=9';
import { PANTRY_STORAGE_KEY, PANTRY_STORAGE_KEY_V2, SHOPPING_STORAGE_KEY } from '../storageKeys.js'; 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 KITCHEN_LIST_ID = 'kitchen';
export const MISC_LIST_ID = 'misc'; export const MISC_LIST_ID = 'misc';
@@ -535,6 +535,148 @@ export function setPantryProductQty(ingredientId, productId, qty) {
return pantry; 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). */ /** Kupione ze listy kuchennej → spiżarnia (zawsze ta lista, niezależnie od aktywnej zakładki). */
export function applyCheckedKitchenListToPantry() { export function applyCheckedKitchenListToPantry() {
const s = loadShoppingState(); const s = loadShoppingState();

View File

@@ -1,4 +1,4 @@
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 { MEAL_SLOTS } from '../planner/mealSlots.js';
import { addDays } from './dateUtils.js'; import { addDays } from './dateUtils.js';
import { dateKey, getDayPlan } from './planStore.js?v=2'; import { dateKey, getDayPlan } from './planStore.js?v=2';
@@ -218,6 +218,35 @@ export function aggregateRangeIngredientNeed(plans, startDate, numDays) {
}); });
} }
/**
* 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. * 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 { MEAL_SLOTS } from '../planner/mealSlots.js';
import { PLANS_STORAGE_KEY } from '../storageKeys.js'; import { PLANS_STORAGE_KEY } from '../storageKeys.js';
import { startOfDay } from './dateUtils.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 = 'recipe-pantry-v1';
export const PANTRY_STORAGE_KEY_V2 = 'recipe-pantry-v2'; export const PANTRY_STORAGE_KEY_V2 = 'recipe-pantry-v2';
export const SHOPPING_STORAGE_KEY = 'recipe-shopping-v1'; 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() { export function getBottomNavHTML() {
const isDark = document.documentElement.classList.contains('dark');
return ` return `
<nav id="app-bottom-nav" aria-label="Główna nawigacja"> <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"> <div class="nav-slot">
<button type="button" data-tab="planner" id="nav-planner" class="nav-tab is-active" aria-label="Planer" aria-current="page"> <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> <i class="far fa-calendar-alt" aria-hidden="true"></i>
<span class="nav-label">Planer</span>
</button> </button>
</div> </div>
<div class="nav-slot"> <div class="nav-slot">
<button type="button" data-tab="recipes" id="nav-recipes" class="nav-tab" aria-label="Przepisy"> <button type="button" data-tab="recipes" id="nav-recipes" class="nav-tab" aria-label="Katalog">
<i class="fas fa-search" aria-hidden="true"></i> <i class="fas fa-book-open" aria-hidden="true"></i>
<span class="nav-label">Katalog</span>
</button> </button>
</div> </div>
<div class="nav-slot"> <div class="nav-slot">
<button type="button" data-tab="pantry" id="nav-pantry" class="nav-tab" aria-label="Spiżarnia"> <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> <i class="fas fa-warehouse" aria-hidden="true"></i>
<span class="nav-label">Spiżarnia</span>
</button> </button>
</div> </div>
<div class="nav-slot" style="position:relative;"> <div class="nav-slot" style="position:relative;">
<button type="button" data-tab="shopping" id="nav-shopping" class="nav-tab" aria-label="Zakupy"> <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> <i class="fas fa-cart-shopping" aria-hidden="true"></i>
</button> <span class="nav-label">Zakupy</span>
<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>
</button> </button>
</div> </div>
</div> </div>
@@ -67,12 +43,130 @@ export function setupBottomNav({ refreshPantry, refreshShoppingList } = {}) {
if (!main || !planner || !pantry || !shopping || !nav) return; if (!main || !planner || !pantry || !shopping || !nav) return;
const TABS = ['recipes', 'planner', 'pantry', 'shopping']; 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 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'); main.classList.toggle('hidden', tab !== 'recipes');
planner.classList.toggle('hidden', tab !== 'planner'); planner.classList.toggle('hidden', tab !== 'planner');
pantry.classList.toggle('hidden', tab !== 'pantry'); pantry.classList.toggle('hidden', tab !== 'pantry');
shopping.classList.toggle('hidden', tab !== 'shopping'); 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 === 'pantry' && typeof refreshPantry === 'function') refreshPantry();
if (tab === 'shopping' && typeof refreshShoppingList === 'function') refreshShoppingList(); if (tab === 'shopping' && typeof refreshShoppingList === 'function') refreshShoppingList();
@@ -87,18 +181,60 @@ export function setupBottomNav({ refreshPantry, refreshShoppingList } = {}) {
else btn.removeAttribute('aria-current'); else btn.removeAttribute('aria-current');
} }
}); });
window.dispatchEvent(new CustomEvent('app-tab-change', { detail: { tab } }));
previousTab = tab;
}; };
nav.addEventListener('click', (e) => { 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]'); const btn = e.target.closest('.nav-tab[data-tab]');
if (!btn || btn.hasAttribute('disabled')) return; if (!btn || btn.hasAttribute('disabled')) return;
const tab = btn.getAttribute('data-tab'); const tab = btn.getAttribute('data-tab');
if (TABS.includes(tab)) apply(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'); apply('planner');
window.switchAppTab = (tab) => {
if (TABS.includes(tab)) apply(tab);
};
window.refreshStockViews = () => { window.refreshStockViews = () => {
if (typeof refreshPantry === 'function') refreshPantry(); 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, getProductsForIngredient,
ingredientHasProducts, ingredientHasProducts,
pantryQtyStep, pantryQtyStep,
} from '../data/catalog.js?v=8'; } from '../data/catalog.js?v=9';
import { import {
addOrMergeShoppingLines, addOrMergeShoppingLines,
KITCHEN_LIST_ID, KITCHEN_LIST_ID,
@@ -19,6 +19,7 @@ import {
updateKitchenItemAmount, updateKitchenItemAmount,
} from '../services/pantryShopping.js?v=2'; } from '../services/pantryShopping.js?v=2';
import { showAppToast } from './toast.js'; import { showAppToast } from './toast.js';
import { ensureCalendarPopoverStyles } from './calendarPopover.js';
const CATEGORY_ICONS = { const CATEGORY_ICONS = {
pieczywo: 'fa-bread-slice', pieczywo: 'fa-bread-slice',
@@ -76,13 +77,14 @@ function macroLine(n) {
function mediaHtml(image, icon, sizeClass = 'w-9 h-9', radiusClass = 'rounded-lg') { function mediaHtml(image, icon, sizeClass = 'w-9 h-9', radiusClass = 'rounded-lg') {
if (image) { 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') { 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>`; 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.`; 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) { function getQtyStepMeta(def, product = null) {
const productPackSize = Number(product?.packSize); const productPackSize = Number(product?.packSize);
if (Number.isFinite(productPackSize) && productPackSize > 0) { if (Number.isFinite(productPackSize) && productPackSize > 0) {
@@ -151,33 +158,39 @@ function getQtyStepMeta(def, product = null) {
export function getIngredientCardHTML({ export function getIngredientCardHTML({
idBase, idBase,
overlayClass = 'fixed inset-0 z-[70] hidden opacity-0 transition-opacity duration-200 flex items-center justify-center p-5', 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);', overlayStyle = 'pointer-events:none;',
cardClass = 'relative w-full max-w-xs rounded-2xl shadow-2xl overflow-hidden', cardClass = 'calendar-liquid-panel relative w-full max-w-xs rounded-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;', cardStyle = '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]',
} = {}) { } = {}) {
if (!idBase) throw new Error('getIngredientCardHTML requires idBase'); if (!idBase) throw new Error('getIngredientCardHTML requires idBase');
ensureCalendarPopoverStyles();
return ` return `
<div id="${idBase}-overlay" class="${overlayClass}" style="${overlayStyle}"> <div id="${idBase}-overlay" class="${overlayClass}" style="${overlayStyle}">
<div id="${idBase}" class="${cardClass}" style="${cardStyle}"> <div id="${idBase}" class="${cardClass}" style="${cardStyle}">
<div id="${idBase}-hero" class="relative w-full ${heroHeightClass} overflow-hidden" style="background:#393937;"> <div class="relative px-4 pt-4 pb-2">
<img id="${idBase}-img" class="w-full h-full object-cover hidden" alt="" /> <div class="flex items-start gap-3 pr-10">
<div id="${idBase}-fallback" class="w-full h-full flex items-center justify-center"> <div id="${idBase}-hero" class="relative w-20 h-20 rounded-2xl flex items-center justify-center shrink-0 overflow-hidden" style="background:transparent;">
<i id="${idBase}-fallback-icon" class="fas fa-box-open text-3xl" style="color:#6d6c67;"></i> <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>
<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"> </div>
<i class="fas fa-chevron-left text-sm"></i> <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> </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"> <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}-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> <i class="fas fa-times text-sm"></i>
</button> </button>
</div> </div>
<div class="px-4 pt-3 pb-4 space-y-3"> <div class="px-4 pt-2 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 id="${idBase}-nutrition"></div> <div id="${idBase}-nutrition"></div>
<div id="${idBase}-stock"></div> <div id="${idBase}-stock"></div>
<div id="${idBase}-products"></div> <div id="${idBase}-products"></div>
@@ -239,23 +252,22 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
const fallback = el('fallback'); const fallback = el('fallback');
const fallbackIcon = el('fallback-icon'); const fallbackIcon = el('fallback-icon');
if (heroEl) {
heroEl.style.height = '';
heroEl.style.background = '#393937';
}
if (img && fallback) { if (img && fallback) {
const image = isListMode ? def.image : (product?.image || def.image); const image = isListMode ? def.image : (product?.image || def.image);
const altName = isListMode ? def.name : (product?.name || def.name); const altName = isListMode ? def.name : (product?.name || def.name);
if (image) { if (image) {
img.src = image; img.src = image;
img.alt = altName; 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'); img.classList.remove('hidden');
fallback.classList.add('hidden'); fallback.classList.add('hidden');
} else { } else {
img.classList.add('hidden'); img.classList.add('hidden');
fallback.classList.remove('hidden'); fallback.classList.remove('hidden');
if (fallbackIcon) fallbackIcon.className = `fas ${icon} text-3xl`; if (fallbackIcon) fallbackIcon.className = `fas ${icon} text-2xl`;
} }
} }
@@ -286,6 +298,7 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
if (backBtn) { if (backBtn) {
backBtn.classList.toggle('hidden', !isBackAvailable); backBtn.classList.toggle('hidden', !isBackAvailable);
backBtn.classList.toggle('flex', Boolean(isBackAvailable));
} }
} }
@@ -313,24 +326,24 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
const nutritionMeta = hint ? `${unitScope}${hint}` : unitScope; const nutritionMeta = hint ? `${unitScope}${hint}` : unitScope;
wrap.innerHTML = ` 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-[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:#9b978f;">${esc(nutritionMeta)}</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="grid grid-cols-4 gap-1.5">
<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 tabular-nums leading-tight" style="color:#ddd6ca;">${nutrition.kcal}</p> <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:#9b978f;">kcal</p> <p class="text-[9px] font-medium" style="color:rgb(var(--text-dim-rgb));">kcal</p>
</div> </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-[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>
<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-[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>
<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-[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>
</div>`; </div>`;
} }
@@ -358,35 +371,35 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
const actionLabel = state.stockEditorOpen ? 'Anuluj' : 'Zmień'; const actionLabel = state.stockEditorOpen ? 'Anuluj' : 'Zmień';
wrap.innerHTML = ` wrap.innerHTML = `
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:#9b978f;">Zapas</p> <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:#393937; border-color:#444442;"> <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="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-[16px] font-bold tabular-nums" style="color:#6ee7b7;">${esc(stockValueLabel)}</p> <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:#9b978f;">${esc(stockSubLabel)}</p>` : ''} ${stockSubLabel ? `<p class="text-[11px] mt-1" style="color:rgb(var(--text-dim-rgb));">${esc(stockSubLabel)}</p>` : ''}
</div> </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'};"> <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)} ${esc(actionLabel)}
</button> </button>
</div> </div>
${state.stockEditorOpen ? ` ${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"> <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> <i class="fas fa-minus text-xs"></i>
</button> </button>
<label class="flex-1 rounded-xl px-3 py-2 flex items-center justify-center gap-2" style="background:#2f2f2d;"> <label class="calendar-liquid-btn flex-1 rounded-xl px-3 py-2 flex items-center justify-center gap-2">
<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;"> <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:#9b978f;">${esc(draftInputUnit)}</span> <span class="text-[12px] font-medium shrink-0" style="color:rgb(var(--text-dim-rgb));">${esc(draftInputUnit)}</span>
</label> </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> <i class="fas fa-plus text-xs"></i>
</button> </button>
</div> </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"> <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-clear text-[11px] font-semibold" style="color:rgb(var(--text-dim-rgb));">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="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>` : ''} </div>` : ''}
</div>`; </div>`;
@@ -414,8 +427,8 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
wrap.querySelector('.ingredient-card-stock-input')?.addEventListener('input', (event) => { wrap.querySelector('.ingredient-card-stock-input')?.addEventListener('input', (event) => {
state.stockDraftQty = usesPackStep state.stockDraftQty = usesPackStep
? normalizeQty((Number(event.target.value) || 0) * step) ? normalizeQty(parseQtyInput(event.target.value) * step)
: normalizeQty(event.target.value); : parseQtyInput(event.target.value);
}); });
wrap.querySelector('.ingredient-card-stock-clear')?.addEventListener('click', () => { wrap.querySelector('.ingredient-card-stock-clear')?.addEventListener('click', () => {
@@ -426,8 +439,8 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
wrap.querySelector('.ingredient-card-stock-save')?.addEventListener('click', () => { wrap.querySelector('.ingredient-card-stock-save')?.addEventListener('click', () => {
const input = wrap.querySelector('.ingredient-card-stock-input'); const input = wrap.querySelector('.ingredient-card-stock-input');
const nextQty = usesPackStep const nextQty = usesPackStep
? normalizeQty((Number(input?.value) || 0) * step) ? normalizeQty(parseQtyInput(input?.value) * step)
: normalizeQty(input?.value ?? state.stockDraftQty ?? qty); : parseQtyInput(input?.value ?? state.stockDraftQty ?? qty);
if (product) { if (product) {
setPantryProductQty(def.id, product.id, nextQty); setPantryProductQty(def.id, product.id, nextQty);
} else { } else {
@@ -467,37 +480,37 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
const actionLabel = state.shopEditorOpen ? 'Anuluj' : (hasShoppingItem ? 'Zmień' : 'Dodaj'); const actionLabel = state.shopEditorOpen ? 'Anuluj' : (hasShoppingItem ? 'Zmień' : 'Dodaj');
wrap.innerHTML = ` wrap.innerHTML = `
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:#9b978f;">Lista zakupów</p> <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:#393937; border-color:#444442;"> <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="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-[16px] font-bold tabular-nums" style="color:${hasShoppingItem ? '#ddd6ca' : '#9b978f'};">${esc(shopValueLabel)}</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:#9b978f;">${esc(shopSubLabel)}</p>` : ''} ${shopSubLabel ? `<p class="text-[11px] mt-1" style="color:rgb(var(--text-dim-rgb));">${esc(shopSubLabel)}</p>` : ''}
</div> </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)} ${esc(actionLabel)}
</button> </button>
</div> </div>
${state.shopEditorOpen ? ` ${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"> <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> <i class="fas fa-minus text-xs"></i>
</button> </button>
<label class="flex-1 rounded-xl px-3 py-2 flex items-center justify-center gap-2" style="background:#2f2f2d;"> <label class="calendar-liquid-btn flex-1 rounded-xl px-3 py-2 flex items-center justify-center gap-2">
<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;"> <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:#9b978f;">${esc(shopInputUnit)}</span> <span class="text-[12px] font-medium shrink-0" style="color:rgb(var(--text-dim-rgb));">${esc(shopInputUnit)}</span>
</label> </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> <i class="fas fa-plus text-xs"></i>
</button> </button>
</div> </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"> <div class="flex items-center justify-between gap-3 mt-3">
${hasShoppingItem ${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>'} : '<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>` : ''} </div>` : ''}
</div>`; </div>`;
@@ -525,8 +538,8 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
wrap.querySelector('.ingredient-card-shop-input')?.addEventListener('input', (event) => { wrap.querySelector('.ingredient-card-shop-input')?.addEventListener('input', (event) => {
state.shopDraftQty = usesPackStep state.shopDraftQty = usesPackStep
? normalizeQty((Number(event.target.value) || 0) * step) ? normalizeQty(parseQtyInput(event.target.value) * step)
: normalizeQty(event.target.value); : parseQtyInput(event.target.value);
}); });
wrap.querySelector('.ingredient-card-shop-remove')?.addEventListener('click', () => { wrap.querySelector('.ingredient-card-shop-remove')?.addEventListener('click', () => {
@@ -543,8 +556,8 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
wrap.querySelector('.ingredient-card-shop-save')?.addEventListener('click', () => { wrap.querySelector('.ingredient-card-shop-save')?.addEventListener('click', () => {
const input = wrap.querySelector('.ingredient-card-shop-input'); const input = wrap.querySelector('.ingredient-card-shop-input');
const nextAmount = usesPackStep const nextAmount = usesPackStep
? normalizeQty((Number(input?.value) || 0) * step) ? normalizeQty(parseQtyInput(input?.value) * step)
: normalizeQty(input?.value ?? state.shopDraftQty ?? defaultAmount); : parseQtyInput(input?.value ?? state.shopDraftQty ?? defaultAmount);
let toastText = null; let toastText = null;
if (shoppingItem) { if (shoppingItem) {
updateKitchenItemAmount(KITCHEN_LIST_ID, shoppingItem.id, nextAmount); updateKitchenItemAmount(KITCHEN_LIST_ID, shoppingItem.id, nextAmount);
@@ -605,24 +618,24 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
const shoppingAmount = shoppingItem?.amount || 0; const shoppingAmount = shoppingItem?.amount || 0;
const pantryLabel = pantryQty > 0 ? `${formatQty(pantryQty)} ${unit}` : '—'; const pantryLabel = pantryQty > 0 ? `${formatQty(pantryQty)} ${unit}` : '—';
const pantryColor = pantryQty > 0 ? '#ddd6ca' : '#6d6c67'; const pantryColor = pantryQty > 0 ? 'rgb(var(--text-body-rgb))' : 'rgb(var(--text-subdued-rgb))';
const shoppingLabel = shoppingAmount > 0 ? `${formatQty(shoppingAmount)} ${unit}` : ''; 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:#393937; border:1px solid transparent;" data-product-id="${esc(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:rgb(var(--card-rgb)); border:1px solid transparent;" data-product-id="${esc(productId)}">
${mediaHtml(product.image || def.image, icon)} ${mediaHtml(product.image || def.image, icon)}
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<span class="text-[13px] font-semibold truncate block" style="color:#ddd6ca;">${esc(product.name)}</span> <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:#9b978f;">${esc(product.packLabel)}</span>` : ''} ${product.packLabel ? `<span class="text-[10px] block mt-0.5" style="color:rgb(var(--text-dim-rgb));">${esc(product.packLabel)}</span>` : ''}
</div> </div>
<div class="flex flex-col items-end gap-0.5 shrink-0 tabular-nums"> <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};"> <span class="text-[11px] font-semibold inline-flex items-center gap-1" style="color:${pantryColor};">
<i class="fas fa-box text-[9px]" style="color:#8f8b84;"></i>${esc(pantryLabel)} <i class="fas fa-box text-[9px]" style="color:rgb(var(--text-faint-rgb));"></i>${esc(pantryLabel)}
</span> </span>
${shoppingLabel ? `<span class="text-[11px] font-semibold inline-flex items-center gap-1" style="color:#ddd6ca;"> ${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:#8f8b84;"></i>${esc(shoppingLabel)} <i class="fas fa-cart-shopping text-[9px]" style="color:rgb(var(--text-faint-rgb));"></i>${esc(shoppingLabel)}
</span>` : ''} </span>` : ''}
</div> </div>
<i class="fas fa-chevron-right text-[10px] shrink-0" style="color:#8f8b84;"></i> <i class="fas fa-chevron-right text-[10px] shrink-0" style="color:rgb(var(--text-faint-rgb));"></i>
</button>`; </button>`;
} }
@@ -643,7 +656,7 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
const kitchenItems = (kitchen && kitchen.type === 'kitchen') ? kitchen.items : []; const kitchenItems = (kitchen && kitchen.type === 'kitchen') ? kitchen.items : [];
wrap.innerHTML = ` wrap.innerHTML = `
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:#9b978f;">Produkty</p> <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"> <div class="space-y-1.5">
${products.map((product) => productRowHtml(state.ingredientId, product.id, pantry, kitchenItems)).join('')} ${products.map((product) => productRowHtml(state.ingredientId, product.id, pantry, kitchenItems)).join('')}
</div>`; </div>`;

View File

@@ -1,6 +1,9 @@
import { import {
addDays, addDays,
addMonths,
addWeeks,
sameDay, sameDay,
sameMonth,
startOfDay, startOfDay,
startOfMonth, startOfMonth,
startOfWeekMonday, startOfWeekMonday,
@@ -19,52 +22,75 @@ export const CALENDAR_MONTHS_SHORT = [
export const CALENDAR_WEEKDAYS_SHORT = ['pn', 'wt', 'śr', 'cz', 'pt', 'so', 'nd']; export const CALENDAR_WEEKDAYS_SHORT = ['pn', 'wt', 'śr', 'cz', 'pt', 'so', 'nd'];
export const CALENDAR_DAY_ATTR = 'data-calendar-day'; 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, theme = {}) { 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 { mode, selectedDate } = meta;
const isSelected = selectedDate && sameDay(day, selectedDate); const isSelected = typeof meta.isSelected === 'boolean'
const showIndicator = !!dayState.showIndicator; ? meta.isSelected
: !!(selectedDate && sameDay(day, selectedDate));
const showIndicator = !!(dayState.showIndicator || dayState.showDot);
const isDisabled = !!dayState.disabled; const isDisabled = !!dayState.disabled;
const isDimmed = !!dayState.dimmed && !isSelected; const isDimmed = !!dayState.dimmed && !isSelected;
const defaultBg = '#2f2f2d'; const defaultBg = 'rgb(var(--card-soft-rgb))';
const defaultBorder = '#444442'; const defaultBorder = 'rgb(var(--card-strong-rgb))';
const defaultText = '#d7d2c8'; const defaultText = 'rgb(var(--text-body-soft-rgb))';
let bg; let bg;
let borderColor; let borderColor;
let text; let text;
let borderClass = 'border'; let borderClass = theme.borderClass || 'border';
let shadow = theme.shadow || 'none';
if (isSelected) { if (isSelected) {
bg = theme.selectedBg || '#23221e'; const keepDimmedBg = !!dayState.dimmed
borderColor = theme.selectedBorder || '#787876'; && theme.selectedBg == null
text = theme.selectedText || '#f2efe8'; && 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) { } else if (isDimmed) {
bg = theme.dimmedBg ?? theme.bg ?? defaultBg; bg = theme.dimmedBg ?? 'transparent';
text = theme.dimText || '#7d7a74'; text = theme.dimText || 'rgb(var(--text-faint-rgb))';
borderClass = 'border-0'; borderClass = theme.dimmedBorderClass || 'border-0';
} else { } else {
bg = theme.bg || defaultBg; bg = theme.bg || defaultBg;
borderColor = theme.border || defaultBorder; borderColor = theme.border || defaultBorder;
text = theme.text || defaultText; text = theme.text || defaultText;
} }
const dot = isSelected ? (theme.selectedDot || '#f2efe8') : (theme.dot || '#a59f92'); 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 opacity = isDimmed ? String(theme.dimOpacity ?? 0.72) : '1';
const borderStyle = isDimmed ? 'border:none;' : `border-color:${borderColor};`; const borderStyle = borderColor ? `border-color:${borderColor};` : 'border:none;';
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} text-xs font-medium transition-colors leading-tight overflow-hidden`; 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' const innerClass = mode === 'month'
? 'relative flex h-full w-full flex-col items-center justify-center' ? 'relative flex h-full w-full flex-col items-center justify-center'
: 'relative flex h-full w-full items-center justify-center'; : 'relative flex h-full w-full items-center justify-center';
const dotBottom = mode === 'month' ? '0.24rem' : '0.2rem'; const dotBottom = mode === 'month' ? '0.24rem' : '0.2rem';
const tagName = isDisabled ? 'div' : 'button'; 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 ` return `
<${tagName}${buttonAttrs} <${tagName}${buttonAttrs}
class="${outerClass}" class="${outerClass}"
style="background:${bg};${borderStyle}color:${text};opacity:${opacity};"> style="background:${bg};${borderStyle}color:${text};opacity:${opacity};box-shadow:${shadow};${dayStyle}">
<span class="${innerClass}"> <span class="${innerClass}">
<span class="text-[13px] font-semibold leading-none ${showIndicator ? '-translate-y-[0.18rem]' : ''}">${day.getDate()}</span> <span class="text-[13px] font-semibold leading-none ${showIndicator ? '-translate-y-[0.18rem]' : ''}">${day.getDate()}</span>
${showIndicator ${showIndicator
@@ -75,12 +101,13 @@ function getCalendarDayHTML(day, meta, dayState, dayAttr, theme = {}) {
`; `;
} }
function getMonthCells(monthAnchor) { export function getCalendarMonthCells(monthAnchor, { fixedWeekCount = null } = {}) {
const first = startOfMonth(monthAnchor); const first = startOfMonth(monthAnchor);
const startGrid = startOfWeekMonday(first); const startGrid = startOfWeekMonday(first);
const cells = []; const cells = [];
for (let i = 0; i < 42; i++) cells.push(addDays(startGrid, i)); const maxCells = fixedWeekCount ? fixedWeekCount * 7 : 42;
while (cells.length > 35 && cells.slice(-7).every((day) => day.getMonth() !== first.getMonth())) { 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); cells.splice(-7);
} }
return { cells, month: first.getMonth() }; return { cells, month: first.getMonth() };
@@ -99,7 +126,7 @@ function getDayState(day, meta, resolveDayState) {
return { return {
disabled: !!resolved.disabled, disabled: !!resolved.disabled,
dimmed: !!resolved.dimmed, dimmed: !!resolved.dimmed,
showIndicator: !!resolved.showIndicator, showIndicator: !!(resolved.showIndicator || resolved.showDot),
}; };
} }
@@ -114,34 +141,64 @@ export function createCalendarWeekdayHeaderHTML(labels = CALENDAR_WEEKDAYS_SHORT
} }
export function createCalendarTopbarHTML({ export function createCalendarTopbarHTML({
prevId,
todayId, todayId,
nextId,
wrapperClass = 'px-4 pt-4 pb-3 flex items-center justify-end', wrapperClass = 'px-4 pt-4 pb-3 flex items-center justify-end',
controlsStyle = 'background:#2f2f2d;border-color:#444442;', controlsStyle = 'background:transparent;border-color:rgb(var(--card-strong-rgb));',
navButtonClass = 'shrink-0 w-7 h-full flex items-center justify-center rounded-full border-0 bg-transparent text-[#d7d2c8] transition-colors', 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',
todayButtonActiveClass = 'h-full shrink-0 inline-flex min-w-[5.75rem] max-w-[9rem] items-center justify-center rounded-full bg-transparent px-1.5 text-[10px] font-semibold leading-none tabular-nums text-[#d7d2c8] 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',
todayButtonDimClass = 'h-full shrink-0 inline-flex items-center justify-center rounded-full px-2 text-[10px] font-semibold leading-none text-[#7d7a74] cursor-default',
}) { }) {
return ` return `
<div class="${wrapperClass}"> <div class="${wrapperClass}">
<div class="flex h-[2.05rem] min-w-0 max-w-[min(100%,20rem)] items-center gap-px rounded-full border px-0.5" style="${controlsStyle}"> <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="${prevId}" class="${navButtonClass}" aria-label="Poprzedni okres">
<i class="fas fa-chevron-left text-[10px]" aria-hidden="true"></i>
</button>
<button type="button" id="${todayId}" <button type="button" id="${todayId}"
class="${todayButtonActiveClass}" class="${todayButtonActiveClass}"
data-cal-active-class="${todayButtonActiveClass}" data-cal-active-class="${todayButtonActiveClass}"
data-cal-dim-class="${todayButtonDimClass}"> data-cal-dim-class="${todayButtonDimClass}">
</button> </button>
<button type="button" id="${nextId}" class="${navButtonClass}" aria-label="Następny okres">
<i class="fas fa-chevron-right text-[10px]" aria-hidden="true"></i>
</button>
</div> </div>
</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) { export function formatCalendarMonthYear(date) {
return `${CALENDAR_MONTHS_LONG[date.getMonth()]} ${date.getFullYear()}`; return `${CALENDAR_MONTHS_LONG[date.getMonth()]} ${date.getFullYear()}`;
} }
@@ -150,6 +207,27 @@ export function formatCalendarSelectedDate(date) {
return `${date.getDate()} ${CALENDAR_MONTHS_SHORT[date.getMonth()]} ${date.getFullYear()}`; 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) { export function isCalendarOnToday(mode, weekStart, monthAnchor, selectedDate) {
const today = startOfDay(new Date()); const today = startOfDay(new Date());
if (!sameDay(selectedDate, today)) return false; if (!sameDay(selectedDate, today)) return false;
@@ -166,10 +244,13 @@ export function syncCalendarTodayButton(buttonEl, isOnToday, selectedDate, optio
const { const {
ariaLabelGo = 'Przejdź do dzisiejszego dnia', ariaLabelGo = 'Przejdź do dzisiejszego dnia',
ariaLabelCurrent = 'Widok jest ustawiony na bieżący okres', ariaLabelCurrent = 'Widok jest ustawiony na bieżący okres',
labelText,
} = options; } = options;
const active = buttonEl.dataset.calActiveClass const active = buttonEl.dataset.calActiveClass
|| 'h-full shrink-0 inline-flex min-w-[5.75rem] max-w-[9rem] items-center justify-center rounded-full bg-transparent px-1.5 text-[10px] font-semibold leading-none tabular-nums text-[#d7d2c8] active:bg-transparent whitespace-nowrap'; || '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 (selectedDate != null) { if (labelText != null) {
buttonEl.textContent = labelText;
} else if (selectedDate != null) {
buttonEl.textContent = formatCalendarSelectedDate(selectedDate); buttonEl.textContent = formatCalendarSelectedDate(selectedDate);
} }
buttonEl.className = active; buttonEl.className = active;
@@ -183,8 +264,13 @@ export function renderCalendarGrid({
mode, mode,
anchorDate, anchorDate,
selectedDate, selectedDate,
isSelectedDate,
resolveDayState, resolveDayState,
dayAttr = CALENDAR_DAY_ATTR, dayAttr = CALENDAR_DAY_ATTR,
getDayAttrValue,
dayClassName = '',
dayStyle = '',
fixedWeekCount = null,
theme, theme,
}) { }) {
if (!gridEl) return; if (!gridEl) return;
@@ -194,25 +280,48 @@ export function renderCalendarGrid({
const cells = []; const cells = [];
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
const day = addDays(weekStart, i); const day = addDays(weekStart, i);
const selected = typeof isSelectedDate === 'function'
? !!isSelectedDate(day, { mode, selectedDate, inCurrentMonth: true })
: !!(selectedDate && sameDay(day, selectedDate));
const meta = { const meta = {
mode, mode,
selectedDate, selectedDate,
inCurrentMonth: true, inCurrentMonth: true,
isSelected: selected,
}; };
cells.push(getCalendarDayHTML(day, meta, getDayState(day, meta, resolveDayState), dayAttr, theme)); cells.push(getCalendarDayHTML(
day,
meta,
getDayState(day, meta, resolveDayState),
dayAttr,
theme,
{ getDayAttrValue, dayClassName, dayStyle },
));
} }
gridEl.innerHTML = cells.join(''); gridEl.innerHTML = cells.join('');
return; return;
} }
const { cells, month } = getMonthCells(anchorDate); const { cells, month } = getCalendarMonthCells(anchorDate, { fixedWeekCount });
gridEl.innerHTML = cells.map((day) => { 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 = { const meta = {
mode, mode,
selectedDate, selectedDate,
inCurrentMonth: day.getMonth() === month, inCurrentMonth,
isSelected: selected,
}; };
return getCalendarDayHTML(day, meta, getDayState(day, meta, resolveDayState), dayAttr, theme); return getCalendarDayHTML(
day,
meta,
getDayState(day, meta, resolveDayState),
dayAttr,
theme,
{ getDayAttrValue, dayClassName, dayStyle },
);
}).join(''); }).join('');
} }
@@ -224,6 +333,7 @@ export function renderCollapsibleCalendar({
selectedDate, selectedDate,
resolveDayState, resolveDayState,
dayAttr = CALENDAR_DAY_ATTR, dayAttr = CALENDAR_DAY_ATTR,
theme,
}) { }) {
renderCalendarGrid({ renderCalendarGrid({
gridEl: weekGridEl, gridEl: weekGridEl,
@@ -232,6 +342,7 @@ export function renderCollapsibleCalendar({
selectedDate, selectedDate,
resolveDayState, resolveDayState,
dayAttr, dayAttr,
theme,
}); });
renderCalendarGrid({ renderCalendarGrid({
gridEl: monthGridEl, gridEl: monthGridEl,
@@ -240,6 +351,7 @@ export function renderCollapsibleCalendar({
selectedDate, selectedDate,
resolveDayState, resolveDayState,
dayAttr, dayAttr,
theme,
}); });
} }
@@ -265,6 +377,253 @@ export function syncCollapsibleCalendarMode({
if (handleEl) handleEl.className = CALENDAR_HANDLE_CLASS; 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) { export function bindCalendarDayClicks(containerEl, onSelect, dayAttr = CALENDAR_DAY_ATTR) {
if (!containerEl || typeof onSelect !== 'function') return; if (!containerEl || typeof onSelect !== 'function') return;
containerEl.addEventListener('click', (event) => { containerEl.addEventListener('click', (event) => {
@@ -275,3 +634,208 @@ export function bindCalendarDayClicks(containerEl, onSelect, dayAttr = CALENDAR_
onSelect(new Date(ts), button, event); 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 { MEAL_SLOTS } from '../planner/mealSlots.js';
import { import {
addDays,
addMonths,
sameDay, sameDay,
sameMonth,
startOfDay, startOfDay,
startOfWeekMonday, startOfWeekMonday,
} from '../services/dateUtils.js'; } from '../services/dateUtils.js';
@@ -17,14 +14,18 @@ import {
import { dayHasAnyMeal, autoSelectProducts, saveLastProductSelection } from '../services/planIngredients.js?v=4'; import { dayHasAnyMeal, autoSelectProducts, saveLastProductSelection } from '../services/planIngredients.js?v=4';
import { loadPantry } from '../services/pantryShopping.js?v=2'; import { loadPantry } from '../services/pantryShopping.js?v=2';
import { import {
bindCollapsibleCalendarSwipeGesture,
bindCalendarDayClicks, bindCalendarDayClicks,
createCollapsibleCalendarHTML,
createCalendarTopbarHTML, createCalendarTopbarHTML,
createCalendarWeekdayHeaderHTML, formatCalendarPeriodLabel,
isCalendarOnToday, isCalendarOnToday,
renderCalendarGrid, renderCollapsibleCalendar,
syncCalendarTodayButton, syncCalendarTodayButton,
} from './mealCalendar.js?v=11'; syncCollapsibleCalendarMode,
import { createIngredientCardController, getIngredientCardHTML } from './ingredientCard.js?v=20260417-113'; syncCollapsibleCalendarToggleIcon,
} from './mealCalendar.js?v=15';
import { createIngredientCardController, getIngredientCardHTML } from './ingredientCard.js?v=20260417-116';
function esc(s) { function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
@@ -37,41 +38,52 @@ const slotLabel = Object.fromEntries(MEAL_SLOTS.map((s) => [s.id, s.label]));
export function getMealPlanEditorHTML() { export function getMealPlanEditorHTML() {
return ` 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-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 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-[#2d2e2b]" style="background:#2d2e2b !important; background-image:none !important;"> <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="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="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<h2 id="mpe-title" class="text-[15px] font-bold text-gray-900 leading-tight"></h2> <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> <p id="mpe-subtitle" class="text-[11px] text-gray-500 mt-0.5 truncate"></p>
</div> </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> <i id="mpe-confirm-icon" class="fas fa-plus text-[10px]" aria-hidden="true"></i>
<span id="mpe-confirm-label">Dodaj</span> <span id="mpe-confirm-label">Dodaj</span>
</button> </button>
</div> </div>
</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"> <div id="mpe-cal-section" class="hidden">
${createCalendarTopbarHTML({ ${createCalendarTopbarHTML({
prevId: 'mpe-cal-prev',
todayId: 'mpe-cal-today', todayId: 'mpe-cal-today',
nextId: 'mpe-cal-next',
wrapperClass: 'mb-2 flex items-center justify-end gap-3', wrapperClass: 'mb-2 flex items-center justify-end gap-3',
})} })}
${createCalendarWeekdayHeaderHTML()} ${createCollapsibleCalendarHTML({
<div id="mpe-cal-grid" class="grid grid-cols-7 gap-1.5"></div> idPrefix: 'mpe-cal',
<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> swipeZoneId: 'mpe-cal-swipe-zone',
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mt-3 mb-2">Pora posiłku</p> 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',
})}
</div>
</div>
<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 id="mpe-slot-chips" class="flex flex-wrap gap-1.5"></div>
</div> </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-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-nutrition-section"></div>
<div id="mpe-servings-row" class="mt-3"></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>
<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-section" class="mb-4"> <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> <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> <div id="mpe-ing-list" class="space-y-1.5"></div>
@@ -156,6 +168,18 @@ export function setupMealPlanEditor() {
/* ── Calendar ──────────────────────────────────── */ /* ── 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() { function renderCal() {
const wrap = document.getElementById('mpe-cal-wrap'); const wrap = document.getElementById('mpe-cal-wrap');
const sec = document.getElementById('mpe-cal-section'); const sec = document.getElementById('mpe-cal-section');
@@ -170,53 +194,54 @@ export function setupMealPlanEditor() {
} }
wrap.classList.remove('hidden'); wrap.classList.remove('hidden');
sec.classList.remove('hidden'); sec.classList.remove('hidden');
const grid = document.getElementById('mpe-cal-grid'); const weekGrid = document.getElementById('mpe-cal-week-grid');
const monthGrid = document.getElementById('mpe-cal-month-grid');
const todayBtn = document.getElementById('mpe-cal-today'); const todayBtn = document.getElementById('mpe-cal-today');
const icon = document.getElementById('mpe-cal-toggle-icon'); const icon = document.getElementById('mpe-cal-toggle-icon');
if (!grid) return; if (!weekGrid || !monthGrid) return;
const today = startOfDay(new Date()); const today = startOfDay(new Date());
const plans = loadPlans(); const plans = loadPlans();
const mode = S.calExpanded ? 'month' : 'week'; const mode = S.calExpanded ? 'month' : 'week';
if (icon) { syncCollapsibleCalendarMode({
icon.className = S.calExpanded ? 'fas fa-chevron-up text-[10px]' : 'fas fa-chevron-down text-[10px]'; mode,
} weekWrapEl: document.getElementById('mpe-cal-week-wrap'),
monthWrapEl: document.getElementById('mpe-cal-month-wrap'),
activePaddingBottom: '0.25rem',
});
syncCollapsibleCalendarToggleIcon(icon, mode);
syncCalendarTodayButton( syncCalendarTodayButton(
todayBtn, todayBtn,
isCalendarOnToday(mode, startOfWeekMonday(S.calDate), S.calDate, S.date), isCalendarOnToday(mode, startOfWeekMonday(S.calDate), S.calDate, S.date),
S.date, S.date,
{
labelText: formatCalendarPeriodLabel(mode, S.calDate, S.calDate),
},
); );
renderCalendarGrid({ renderCollapsibleCalendar({
gridEl: grid, weekGridEl: weekGrid,
mode, monthGridEl: monthGrid,
anchorDate: S.calDate, weekAnchorDate: S.calDate,
monthAnchorDate: S.calDate,
selectedDate: S.date, selectedDate: S.date,
resolveDayState: (day, meta) => { resolveDayState: (day, meta) => resolveCalendarDayState(day, meta, plans, today),
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),
};
},
}); });
syncScrollShadows(); syncScrollShadows();
} }
function renderSlots() { function renderSlots() {
const el = document.getElementById('mpe-slot-chips'); 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; if (!el || !S.showCal) return;
const r = RECIPES[S.recipeId]; const r = RECIPES[S.recipeId];
if (!r) return; if (!r) return;
el.innerHTML = MEAL_SLOTS.filter((s) => r.allowedSlots.includes(s.id)).map((s) => { el.innerHTML = MEAL_SLOTS.filter((s) => r.allowedSlots.includes(s.id)).map((s) => {
const sel = s.id === S.slotId; const sel = s.id === S.slotId;
const bg = sel ? '#23221e' : '#2f2f2d'; const bg = sel ? 'rgb(var(--sunken-rgb))' : 'rgb(var(--card-soft-rgb))';
const border = sel ? '#787876' : '#444442'; const border = sel ? 'rgb(var(--border-input-rgb))' : 'rgb(var(--card-strong-rgb))';
const text = sel ? '#f2efe8' : '#d7d2c8'; 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>`; 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(''); }).join('');
} }
@@ -245,12 +270,12 @@ export function setupMealPlanEditor() {
el.innerHTML = ` el.innerHTML = `
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Porcje</p> <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);"> <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-[#d7d2c8] transition-colors" aria-label="Zmniejsz liczbę porcji"> <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> <i class="fas fa-minus text-[10px]"></i>
</button> </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> <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-[#d7d2c8] transition-colors" aria-label="Zwiększ liczbę porcji"> <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> <i class="fas fa-plus text-[10px]"></i>
</button> </button>
</div> </div>
@@ -269,7 +294,7 @@ export function setupMealPlanEditor() {
const removeBtn = (cls, attrs) => 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>`; `<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 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) { for (const ing of r.ingredients) {
const id = ing.ingredientId; const id = ing.ingredientId;
@@ -317,12 +342,12 @@ export function setupMealPlanEditor() {
const isSel = eid === altId; const isSel = eid === altId;
const checkbox = ` const checkbox = `
<span class="ml-auto self-center w-[18px] h-[18px] rounded-full shrink-0 flex items-center justify-center" <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;"> style="border:1.5px solid rgba(var(--border-input-rgb), 0.58); background:transparent;">
${isSel ? '<i class="fas fa-check" style="color:#9b978f; font-size:8px; line-height:1; display:block; transform:translateY(0.5px);"></i>' : ''} ${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>`; </span>`;
const n = nutFor(altId, disp, ing.unit); 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>` : ''; 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>'; html += '</div>';
} }
@@ -341,7 +366,7 @@ export function setupMealPlanEditor() {
const addedBadge = addedProduct 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>` ? `<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 += `<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-[12px] font-semibold text-gray-900 tabular-nums">${fmtAmt(disp)}</span>`;
html += `<span class="text-[11px] text-gray-500">${esc(a.unit)}</span></button>`; html += `<span class="text-[11px] text-gray-500">${esc(a.unit)}</span></button>`;
@@ -365,7 +390,7 @@ export function setupMealPlanEditor() {
const el = document.getElementById('mpe-add-area'); const el = document.getElementById('mpe-add-area');
if (!el) return; if (!el) return;
if (!S.addOpen) { 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; return;
} }
const recipe = RECIPES[S.recipeId]; const recipe = RECIPES[S.recipeId];
@@ -376,15 +401,15 @@ export function setupMealPlanEditor() {
const q = S.addQuery.toLowerCase().trim(); const q = S.addQuery.toLowerCase().trim();
const avail = Object.values(INGREDIENTS).filter((i) => !usedIds.has(i.id) && (!q || i.name.toLowerCase().includes(q))); const avail = Object.values(INGREDIENTS).filter((i) => !usedIds.has(i.id) && (!q || i.name.toLowerCase().includes(q)));
el.innerHTML = ` 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"> <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)}"> <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:#9b978f;">Anuluj</button> <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>
<div class="max-h-40 overflow-y-auto space-y-1 no-scrollbar" id="mpe-add-results"> <div class="max-h-40 overflow-y-auto space-y-1 no-scrollbar" id="mpe-add-results">
${avail.length === 0 ${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>' ? '<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:#2f2f2d !important; color:#ddd6ca;" data-ing-id="${esc(i.id)}">${esc(i.name)}</button>`).join('')} : 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>
</div>`; </div>`;
} }
@@ -400,8 +425,8 @@ export function setupMealPlanEditor() {
const q = S.addQuery.toLowerCase().trim(); const q = S.addQuery.toLowerCase().trim();
const avail = Object.values(INGREDIENTS).filter((i) => !usedIds.has(i.id) && (!q || i.name.toLowerCase().includes(q))); const avail = Object.values(INGREDIENTS).filter((i) => !usedIds.has(i.id) && (!q || i.name.toLowerCase().includes(q)));
results.innerHTML = avail.length === 0 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>' ? '<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:#2f2f2d !important; color:#ddd6ca;" data-ing-id="${esc(i.id)}">${esc(i.name)}</button>`).join(''); : 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() { function renderIngredients() {
@@ -417,23 +442,23 @@ export function setupMealPlanEditor() {
if (!el) return; if (!el) return;
const n = totalNutrition(); const n = totalNutrition();
el.innerHTML = ` 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> <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="flex-1 flex items-center">
<div class="grid grid-cols-4 gap-1.5 w-full"> <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-[15px] font-bold text-gray-100 tabular-nums leading-tight">${n.kcal}</p>
<p class="text-[9px] text-gray-500 font-medium">kcal</p> <p class="text-[9px] text-gray-500 font-medium">kcal</p>
</div> </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-[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> <p class="text-[9px] text-gray-500 font-medium">białko</p>
</div> </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-[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> <p class="text-[9px] text-gray-500 font-medium">tłuszcz</p>
</div> </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-[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> <p class="text-[9px] text-gray-500 font-medium">węglowodany</p>
</div> </div>
@@ -612,21 +637,39 @@ export function setupMealPlanEditor() {
}); });
} }
document.getElementById('mpe-confirm-btn')?.addEventListener('click', handleConfirm); document.getElementById('mpe-confirm-btn')?.addEventListener('click', handleConfirm);
bindCalendarDayClicks(document.getElementById('mpe-cal-grid'), (date) => { const selectCalendarDate = (date) => {
S.date = date; S.date = date;
S.calDate = new Date(date); S.calDate = new Date(date);
renderCal(); renderCal();
}); };
document.getElementById('mpe-cal-prev')?.addEventListener('click', () => { bindCalendarDayClicks(document.getElementById('mpe-cal-week-grid'), selectCalendarDate);
if (!S.showCal) return; bindCalendarDayClicks(document.getElementById('mpe-cal-month-grid'), selectCalendarDate);
S.calDate = S.calExpanded ? addMonths(S.calDate, -1) : addDays(S.calDate, -7);
renderCal(); bindCollapsibleCalendarSwipeGesture({
}); zoneEl: document.getElementById('mpe-cal-swipe-zone'),
document.getElementById('mpe-cal-next')?.addEventListener('click', () => { weekWrapEl: document.getElementById('mpe-cal-week-wrap'),
if (!S.showCal) return; monthWrapEl: document.getElementById('mpe-cal-month-wrap'),
S.calDate = S.calExpanded ? addMonths(S.calDate, 1) : addDays(S.calDate, 7); getMode: () => (S.calExpanded ? 'month' : 'week'),
renderCal(); 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', () => { document.getElementById('mpe-cal-today')?.addEventListener('click', () => {
const today = startOfDay(new Date()); const today = startOfDay(new Date());
@@ -753,7 +796,7 @@ export function setupMealPlanEditor() {
const ingBase = S.overrides[origId] ?? recipeIng?.amount ?? 0; const ingBase = S.overrides[origId] ?? recipeIng?.amount ?? 0;
const ingDisp = ingBase * S.servings; 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">'; let pickerHtml = '<div class="mpe-product-picker mt-2 ml-1 space-y-1">';
for (const p of products) { for (const p of products) {
@@ -766,7 +809,7 @@ export function setupMealPlanEditor() {
const f = g / 100; 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 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>` : ''; 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> <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>`; </button>`;
} }
@@ -796,7 +839,10 @@ export function setupMealPlanEditor() {
if (e.target.closest('#mpe-add-btn')) { if (e.target.closest('#mpe-add-btn')) {
S.addOpen = true; S.addQuery = ''; S.addOpen = true; S.addQuery = '';
renderAddArea(); 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; return;
} }

View File

@@ -33,22 +33,22 @@ function renderRecipeCard(recipe, { showSlotLabels = true, cardClassName = '' }
const className = ['recipe-browser-card', cardClassName].filter(Boolean).join(' '); const className = ['recipe-browser-card', cardClassName].filter(Boolean).join(' ');
return ` 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;"> <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-[#d4d4d4] relative overflow-hidden"> <div class="recipe-browser-card-media h-32 bg-[rgb(var(--skeleton-rgb))] relative overflow-hidden">
${recipe.image ${recipe.image
? `<img src="${escapeHtml(recipe.image)}" alt="${escapeHtml(recipe.title)}" class="w-full h-full object-cover">` ? `<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>`} : `<span class="absolute inset-0 flex items-center justify-center text-white font-medium text-xs">${escapeHtml(recipe.thumbLabel)}</span>`}
</div> </div>
<div class="recipe-browser-card-body p-3 flex flex-col flex-1"> <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-footer mt-auto">
<div class="recipe-browser-card-meta flex items-center justify-between text-[11px] text-[#c2bcb2] font-medium mb-2"> <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-[#8f8b84]" aria-hidden="true"></i><span>${recipe.minutes} min</span></div> <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-[#8f8b84]" aria-hidden="true"></i><span>${recipe.nutritionPerServing.kcal} kcal</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> </div>
${labels.length > 0 ${labels.length > 0
? `<div class="recipe-browser-card-labels flex flex-wrap gap-1"> ? `<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>`
: ''} : ''}
</div> </div>
@@ -71,14 +71,14 @@ export function getRecipeGridSectionHTML({
scrollId, scrollId,
gridId, gridId,
emptyStateId, emptyStateId,
scrollClassName = 'relative flex-1 overflow-y-auto px-4 pt-20 pb-24 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-[#2d2e2b]', gridClassName = 'grid grid-cols-2 gap-3 bg-[rgb(var(--app-bg-rgb))]',
emptyTitle = 'Brak wyników', emptyTitle = 'Brak wyników',
emptyMessage = 'Zmień kryteria wyszukiwania lub filtry', emptyMessage = 'Zmień kryteria wyszukiwania lub filtry',
} = {}) { } = {}) {
return ` return `
<div id="${escapeHtml(scrollId)}" class="${scrollClassName}" style="background:#2d2e2b !important;"> <div id="${escapeHtml(scrollId)}" class="${scrollClassName}" style="background:rgb(var(--app-bg-rgb)) !important;">
<div id="${escapeHtml(gridId)}" class="${gridClassName}" style="background:#2d2e2b !important;"></div> <div id="${escapeHtml(gridId)}" class="${gridClassName}" style="background:rgb(var(--app-bg-rgb)) !important;"></div>
${getEmptyStateHTML({ ${getEmptyStateHTML({
emptyStateId, emptyStateId,
title: emptyTitle, title: emptyTitle,

View File

@@ -1,5 +1,5 @@
export const RECIPE_SEARCH_SHELL_BASE_SHADOW = 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) { function escapeHtml(s) {
return String(s) return String(s)
@@ -27,11 +27,11 @@ export function getRecipeSearchFieldHTML({
const ariaLabel = inputAriaLabel || placeholder; const ariaLabel = inputAriaLabel || placeholder;
return ` 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;"> <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 ${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> <i class="fas fa-sliders-h" aria-hidden="true"></i>
</button>` </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,26 +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 { MEAL_SLOTS } from '../planner/mealSlots.js';
import { applyFilters, getFilterState } from './RecipeList.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_PANEL_TRANSITION = 'opacity 180ms ease, transform 180ms ease';
const FILTER_SURFACE = '#23221e'; const FILTER_TEXT_MUTED = 'var(--filter-liquid-text-muted)';
const FILTER_SURFACE_SOFT = '#2d2e2b'; const FILTER_TEXT_ACTIVE = 'var(--filter-liquid-text-active)';
const FILTER_BORDER = '#787876'; const FILTER_TRACK = 'var(--filter-liquid-track-bg)';
const FILTER_CHIP_ACTIVE_BG = '#393937'; const FILTER_TRACK_FILL = 'var(--filter-liquid-accent-bg)';
const FILTER_TEXT_SECONDARY = '#d7d2c8';
const FILTER_TEXT_MUTED = '#b5afa5';
const FILTER_TEXT_ACTIVE = '#f2efe8';
const FILTER_TRACK = '#393937';
const FILTER_TRACK_FILL = '#56534f';
const FILTER_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)';
const PREP_TIME_MIN = 5; const PREP_TIME_MIN = 5;
const PREP_TIME_MAX = 120; const PREP_TIME_MAX = 120;
const PREP_TIME_STEP = 5; const PREP_TIME_STEP = 5;
const PREP_TIME_MIN_GAP = PREP_TIME_STEP; const PREP_TIME_MIN_GAP = PREP_TIME_STEP;
const FILTER_CONTEXTS = { const FILTER_CONTEXTS = {
recipes: { recipes: {
anchorShellId: 'recipe-topbar', anchorShellId: 'recipe-filter-float-btn',
buttonId: 'recipe-filter-btn', buttonId: 'recipe-filter-float-btn',
getState: () => getFilterState(), getState: () => getFilterState(),
applyState: (nextState) => applyFilters(nextState), applyState: (nextState) => applyFilters(nextState),
showSlots: true, showSlots: true,
@@ -62,6 +64,15 @@ export function getFilterHTML() {
pointer-events: auto; 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-track,
#filter-view .prep-time-range-fill { #filter-view .prep-time-range-fill {
border-radius: 9999px; border-radius: 9999px;
@@ -72,15 +83,27 @@ export function getFilterHTML() {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
border-radius: 9999px; border-radius: 9999px;
border: 1px solid rgba(242,239,232,0.16); border: 1px solid rgba(255,255,255,0.34);
background: ${FILTER_TRACK_FILL}; background: rgba(var(--surface-rgb),0.42);
box-shadow: 0 0 0 1px rgba(0,0,0,0.12); 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; touch-action: none;
outline: 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> </style>
<div id="filter-view" class="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-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="absolute flex flex-col overflow-hidden rounded-[1.35rem]" style="background:${FILTER_SURFACE} !important; background-image:none !important; border:1px solid ${FILTER_BORDER} !important; opacity:0; transform:translateY(-0.5rem) scale(0.98); transform-origin:top center; transition:${FILTER_PANEL_TRANSITION}; box-shadow:${FILTER_SHADOW}; width:min(calc(100% - 1.5rem), 22rem);"> <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"> <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> <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> <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>
@@ -175,16 +198,6 @@ function formatTimeRangeSummary(minMinutes, maxMinutes) {
return `${formatTimeValue(minMinutes)} - ${formatTimeValue(maxMinutes)}`; return `${formatTimeValue(minMinutes)} - ${formatTimeValue(maxMinutes)}`;
} }
function getChipStyle(active) {
const background = active ? FILTER_CHIP_ACTIVE_BG : FILTER_SURFACE_SOFT;
const color = active ? FILTER_TEXT_ACTIVE : FILTER_TEXT_SECONDARY;
const borderRule = active ? `border:1px solid ${FILTER_BORDER};` : 'border:none;';
const shadow = active
? 'box-shadow:inset 0 1px 0 rgba(255,255,255,0.04), 0 0 0 1px rgba(0,0,0,0.08);'
: '';
return `background:${background}; ${borderRule} color:${color}; ${shadow}`;
}
function clampTimeValue(value) { function clampTimeValue(value) {
return Math.min(Math.max(value, PREP_TIME_MIN), PREP_TIME_MAX); return Math.min(Math.max(value, PREP_TIME_MIN), PREP_TIME_MAX);
} }
@@ -198,6 +211,12 @@ function getSliderPercent(value) {
return ((value - PREP_TIME_MIN) / (PREP_TIME_MAX - PREP_TIME_MIN)) * 100; 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) { function setActiveTimeHandle(activeHandle) {
const minHandle = document.getElementById('prep-time-min-handle'); const minHandle = document.getElementById('prep-time-min-handle');
const maxHandle = document.getElementById('prep-time-max-handle'); const maxHandle = document.getElementById('prep-time-max-handle');
@@ -245,19 +264,33 @@ function positionFilterPanel() {
const viewRect = view.getBoundingClientRect(); const viewRect = view.getBoundingClientRect();
const anchorRect = (searchShell || button).getBoundingClientRect(); const anchorRect = (searchShell || button).getBoundingClientRect();
const gap = 8; const isRecipeContext = activeFilterContext === 'recipes';
const gap = isRecipeContext ? 12 : 8;
const margin = 12; const margin = 12;
const width = Math.min(anchorRect.width, viewRect.width - margin * 2); const maxPanelWidth = isRecipeContext ? 352 : anchorRect.width;
const top = Math.max(margin, anchorRect.bottom - viewRect.top + gap); const width = Math.min(maxPanelWidth, viewRect.width - margin * 2);
const left = Math.max( const spaceBelow = viewRect.bottom - anchorRect.bottom - margin;
margin, const preferredMaxHeight = Math.min(420, viewRect.height - margin * 2);
Math.min(anchorRect.left - viewRect.left, viewRect.width - width - margin), const spaceAbove = anchorRect.top - viewRect.top - gap - margin;
); const opensUpward = isRecipeContext || (spaceBelow < 260 && anchorRect.top - viewRect.top > spaceBelow);
const maxHeight = Math.max(220, viewRect.height - top - margin); 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.width = `${width}px`;
panel.style.left = `${left}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`; panel.style.maxHeight = `${maxHeight}px`;
if (body) body.style.maxHeight = `${maxHeight - 56}px`; if (body) body.style.maxHeight = `${maxHeight - 56}px`;
} }
@@ -277,6 +310,7 @@ function showFilterPanel() {
view.style.pointerEvents = 'auto'; view.style.pointerEvents = 'auto';
view.setAttribute('aria-hidden', 'false'); view.setAttribute('aria-hidden', 'false');
positionFilterPanel(); positionFilterPanel();
panel.style.transform = getClosedPanelTransform(panel);
setRecipeAreaBlur(true); setRecipeAreaBlur(true);
syncPanelCount(); syncPanelCount();
@@ -297,7 +331,7 @@ function hideFilterPanel() {
view.style.pointerEvents = 'none'; view.style.pointerEvents = 'none';
view.setAttribute('aria-hidden', 'true'); view.setAttribute('aria-hidden', 'true');
panel.style.opacity = '0'; panel.style.opacity = '0';
panel.style.transform = 'translateY(-0.5rem) scale(0.98)'; panel.style.transform = getClosedPanelTransform(panel);
setRecipeAreaBlur(false); setRecipeAreaBlur(false);
syncPanelCount(); syncPanelCount();
@@ -316,7 +350,7 @@ function renderSlotChips() {
wrap.innerHTML = MEAL_SLOTS.map((slot) => { wrap.innerHTML = MEAL_SLOTS.map((slot) => {
const active = localSlots.includes(slot.id); const active = localSlots.includes(slot.id);
return `<button type="button" data-filter-slot="${escapeHtml(slot.id)}" class="px-3 py-2 rounded-full text-[11px] 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(''); }).join('');
wrap.querySelectorAll('[data-filter-slot]').forEach((btn) => { wrap.querySelectorAll('[data-filter-slot]').forEach((btn) => {
@@ -338,7 +372,7 @@ function renderTagChips() {
const allTags = collectAllTags(); const allTags = collectAllTags();
wrap.innerHTML = allTags.map((tag) => { wrap.innerHTML = allTags.map((tag) => {
const active = localTags.includes(tag.toLowerCase()); const active = localTags.includes(tag.toLowerCase());
return `<button type="button" data-filter-tag="${escapeHtml(tag)}" class="px-3 py-2 rounded-full text-[11px] 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(''); }).join('');
wrap.querySelectorAll('[data-filter-tag]').forEach((btn) => { wrap.querySelectorAll('[data-filter-tag]').forEach((btn) => {
@@ -374,9 +408,16 @@ function syncPanelCount() {
if (button) { if (button) {
const highlight = isFilterPanelOpen() || count > 0; const highlight = isFilterPanelOpen() || count > 0;
button.style.setProperty('background', highlight ? '#23221e' : '#393937', 'important'); const isRecipeGlassButton = button.classList.contains('recipe-glass-btn');
button.style.setProperty('border-color', highlight ? '#787876' : '#41423f', 'important'); if (isRecipeGlassButton) {
button.style.setProperty('color', highlight ? '#f2efe8' : '#ddd6ca', 'important'); 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"]'); const badge = button?.querySelector('[id$="-filter-count"]');
@@ -398,6 +439,7 @@ function syncLiveFilters() {
} }
export function setupFilter() { export function setupFilter() {
ensureFilterPopoverStyles();
const rangeTrack = document.getElementById('prep-time-range-fill')?.parentElement; const rangeTrack = document.getElementById('prep-time-range-fill')?.parentElement;
const minHandle = document.getElementById('prep-time-min-handle'); const minHandle = document.getElementById('prep-time-min-handle');
const maxHandle = document.getElementById('prep-time-max-handle'); const maxHandle = document.getElementById('prep-time-max-handle');

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 { MEAL_SLOTS } from '../planner/mealSlots.js';
import { import {
addMonths, sameDay,
addWeeks,
sameMonth,
startOfDay, startOfDay,
startOfMonth, startOfMonth,
startOfWeekMonday, startOfWeekMonday,
weekContains,
} from '../services/dateUtils.js'; } from '../services/dateUtils.js';
import { import {
computeEntryNutrition, computeEntryNutrition,
@@ -25,16 +22,18 @@ import {
savePlans, savePlans,
} from '../services/planStore.js?v=2'; } from '../services/planStore.js?v=2';
import { import {
CALENDAR_HANDLE_CLASS,
CALENDAR_MONTHS_SHORT, CALENDAR_MONTHS_SHORT,
bindCollapsibleCalendarSwipeGesture,
bindCalendarDayClicks, bindCalendarDayClicks,
createCollapsibleCalendarHTML,
createCalendarTopbarHTML, createCalendarTopbarHTML,
createCalendarWeekdayHeaderHTML, formatCalendarPeriodLabel,
isCalendarOnToday, isCalendarOnToday,
renderCollapsibleCalendar, renderCollapsibleCalendar,
syncCalendarTodayButton, syncCalendarTodayButton,
syncCollapsibleCalendarMode, syncCollapsibleCalendarMode,
} from '../ui/mealCalendar.js?v=11'; syncCollapsibleCalendarToggleIcon,
} from '../ui/mealCalendar.js?v=15';
import { import {
filterRecipesByQuery, filterRecipesByQuery,
renderRecipeGrid, renderRecipeGrid,
@@ -65,56 +64,44 @@ function syncTodayButton(mode, weekStart, monthAnchor, selected) {
document.getElementById('cal-go-today'), document.getElementById('cal-go-today'),
isCalendarOnToday(mode, weekStart, monthAnchor, selected), isCalendarOnToday(mode, weekStart, monthAnchor, selected),
selected, selected,
{
labelText: formatCalendarPeriodLabel(mode, weekStart, monthAnchor),
},
); );
} }
export function getMealPlannerHTML() { export function getMealPlannerHTML() {
return ` return `
<div id="planner-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden bg-[#2d2e2b] z-10"> <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-[#2d2e2b] border-b border-[#444442] mt-3 relative 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-between gap-3 min-w-0"> <div class="min-h-12 px-4 pt-4 pb-3 flex items-center justify-end gap-3 min-w-0">
<h1 class="min-w-0 flex-1 truncate" style="margin:0;padding:0;color:#f2efe8;font-family:var(--app-font);font-size:18px;font-weight:700;line-height:1.2;letter-spacing:-0.02em;">Plan posiłków</h1>
${createCalendarTopbarHTML({ ${createCalendarTopbarHTML({
prevId: 'cal-prev',
todayId: 'cal-go-today', todayId: 'cal-go-today',
nextId: 'cal-next',
wrapperClass: 'flex shrink-0 items-center justify-end', wrapperClass: 'flex shrink-0 items-center justify-end',
})} })}
</div> </div>
<div id="calendar-swipe-zone" class="overflow-x-hidden bg-[#2d2e2b]" style="touch-action: none"> ${createCollapsibleCalendarHTML({ idPrefix: 'calendar' })}
<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>
</div> </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 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> <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="flex-1 flex items-center">
<div class="grid grid-cols-4 gap-1.5 w-full"> <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 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> <p class="text-[9px] text-gray-500 font-medium">kcal</p>
</div> </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 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> <p class="text-[9px] text-gray-500 font-medium">białko</p>
</div> </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 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> <p class="text-[9px] text-gray-500 font-medium">tłuszcz</p>
</div> </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 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> <p class="text-[9px] text-gray-500 font-medium">węglowodany</p>
</div> </div>
@@ -122,19 +109,19 @@ export function getMealPlannerHTML() {
</div> </div>
</div> </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"> <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-[#9b978f] text-xs" aria-hidden="true"></i> <i class="fas fa-shopping-basket text-[rgb(var(--text-dim-rgb))] text-xs" aria-hidden="true"></i>
Składniki na ten dzień Składniki na ten dzień
</button> </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> </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-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-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="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>
<div class="pointer-events-auto"> <div class="pointer-events-auto">
${getRecipeSearchFieldHTML({ ${getRecipeSearchFieldHTML({
@@ -148,8 +135,8 @@ export function getMealPlannerHTML() {
})} })}
</div> </div>
</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-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-[#2d2e2b]" style="background:#2d2e2b !important;"></div> <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 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"> <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> <i class="fas fa-search text-2xl text-gray-300" aria-hidden="true"></i>
@@ -161,20 +148,20 @@ export function getMealPlannerHTML() {
</div> </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-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 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-[#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="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-[#6d6c67]/75 rounded-full mx-auto mb-2.5" aria-hidden="true"></div> <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-[#ddd6ca] leading-tight pr-2">Składniki i spiżarnia</h2> <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-[#9b978f] mt-1">Porównanie potrzeb z zapasami.</p> <p id="planner-ing-sub" class="text-[11px] text-[rgb(var(--text-dim-rgb))] mt-1">Porównanie potrzeb z zapasami.</p>
</div> </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-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"> <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> <i class="fas fa-cart-plus text-xs" aria-hidden="true"></i>
Dodaj braki na dziś do listy Dodaj braki na dziś do listy
</button> </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"> <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-[#9b978f] text-[11px]" aria-hidden="true"></i> <i class="fas fa-calendar-week text-[rgb(var(--text-dim-rgb))] text-[11px]" aria-hidden="true"></i>
Dodaj braki na cały tydzień Dodaj braki na cały tydzień
</button> </button>
</div> </div>
@@ -191,61 +178,8 @@ function syncModeToggle(mode) {
mode, mode,
weekWrapEl: document.getElementById('calendar-week-wrap'), weekWrapEl: document.getElementById('calendar-week-wrap'),
monthWrapEl: document.getElementById('calendar-month-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) { function showPlannerToast(message) {
@@ -469,27 +403,27 @@ function renderDayContent(state, onMealRemoved = null) {
(entry.addedIngredients?.length > 0) || (entry.addedIngredients?.length > 0) ||
(entry.substitutions && Object.keys(entry.substitutions).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 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 rowStyle = `--planner-swipe-progress:${isPendingDelete ? '1' : '0'};`;
const rowAttrs = isPendingDelete ? 'data-pending-delete="true"' : ''; const rowAttrs = isPendingDelete ? 'data-pending-delete="true"' : '';
const backgroundStyle = isPendingDelete const backgroundStyle = isPendingDelete
? 'background:linear-gradient(90deg, rgba(65,64,60,0.08), rgba(65,64,60,0.2));' ? 'background:linear-gradient(90deg, rgba(var(--border-card-rgb),0.08), rgba(var(--border-card-rgb),0.2));'
: 'background:rgba(203,74,72, calc(0.18 + var(--planner-swipe-progress) * 0.5));'; : 'background:rgba(var(--danger-rgb), calc(0.18 + var(--planner-swipe-progress) * 0.5));';
const backgroundLabel = isPendingDelete 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> <i class="fas fa-hourglass-half text-[10px]" aria-hidden="true"></i>
Usuwanie Usuwanie
</span>` </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> <i class="fas fa-trash text-[10px]" aria-hidden="true"></i>
Usuń Usuń
</span>`; </span>`;
const titleClass = isPendingDelete const titleClass = isPendingDelete
? 'text-[13px] font-normal text-[#bcb5ab] truncate' ? 'text-[13px] font-normal text-[rgb(var(--text-muted-rgb))] truncate'
: 'text-[13px] font-normal text-[#ddd6ca] truncate'; : 'text-[13px] font-normal text-[rgb(var(--text-body-rgb))] truncate';
const metaClass = isPendingDelete const metaClass = isPendingDelete
? 'text-[11px] text-[#878079] mt-0.5 tabular-nums' ? 'text-[11px] text-[rgb(var(--text-faint-rgb))] mt-0.5 tabular-nums'
: 'text-[11px] text-[#9b978f] mt-0.5 tabular-nums'; : 'text-[11px] text-[rgb(var(--text-dim-rgb))] mt-0.5 tabular-nums';
const actionWrapClass = isPendingDelete const actionWrapClass = isPendingDelete
? 'relative z-[2] flex items-center shrink-0 self-center' ? 'relative z-[2] flex items-center shrink-0 self-center'
: 'flex items-center gap-1 shrink-0 self-center'; : 'flex items-center gap-1 shrink-0 self-center';
@@ -501,22 +435,22 @@ function renderDayContent(state, onMealRemoved = null) {
: 0; : 0;
const entryAction = isPendingDelete 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"> ? `<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> <i class="fas fa-rotate-left text-[10px]" aria-hidden="true"></i>
</span> </span>
</button>` </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"> : `<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-[9px]" aria-hidden="true"></i> <i class="fas fa-pencil text-[10px]" aria-hidden="true"></i>
</button>`; </button>`;
return ` 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="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}"> <div class="pointer-events-none absolute inset-0 flex items-center justify-end px-4" style="${backgroundStyle}">
${backgroundLabel} ${backgroundLabel}
</div> </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="relative flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0" style="${contentToneStyle}"> <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 ${recipe.image
? `<img src="${escapeHtml(recipe.image)}" alt="" class="w-full h-full object-cover">` ? `<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>`} : `<span class="w-full h-full flex items-center justify-center text-white text-[8px] font-medium">${escapeHtml(recipe.thumbLabel)}</span>`}
@@ -524,9 +458,9 @@ function renderDayContent(state, onMealRemoved = null) {
<div class="min-w-0"> <div class="min-w-0">
<div class="flex items-center"><p class="${titleClass}">${escapeHtml(recipe.title)}</p>${customDot}</div> <div class="flex items-center"><p class="${titleClass}">${escapeHtml(recipe.title)}</p>${customDot}</div>
<p class="${metaClass}"> <p class="${metaClass}">
<i class="fas fa-clock text-[#7d7a74] mr-0.5" aria-hidden="true"></i>${recipe.minutes} min <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-[#6d6c67]">·</span> <span class="mx-1.5 text-[rgb(var(--text-subdued-rgb))]">·</span>
<i class="fas fa-fire text-[#7d7a74] mr-0.5" aria-hidden="true"></i>${entryN.kcal} kcal${servLabel} <i class="fas fa-fire text-[rgb(var(--text-faint-rgb))] mr-0.5" aria-hidden="true"></i>${entryN.kcal} kcal${servLabel}
</p> </p>
</div> </div>
</div> </div>
@@ -538,36 +472,36 @@ function renderDayContent(state, onMealRemoved = null) {
</div>`; </div>`;
}).join(''); }).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 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 = ` 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="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-3 py-2.5 bg-[#393937]" style="background:#393937 !important;"> <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-[#9b978f] shrink-0" aria-hidden="true"></i> <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-[#ddd6ca] truncate min-w-0">${slot.label}</span> <span class="text-[13px] font-semibold text-[rgb(var(--text-body-rgb))] truncate min-w-0">${slot.label}</span>
<span class="ml-auto"></span> <span class="ml-auto"></span>
${kcalPill} ${kcalPill}
${addBtn} ${addBtn}
</div> </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>`; </div>`;
if (entries.length > 0) return filledCard; if (entries.length > 0) return filledCard;
return ` 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="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-3 py-2.5"> <div class="flex items-center gap-2 px-4 py-3">
<i class="fas ${slot.icon} w-7 text-center text-[13px] text-[#9b978f] shrink-0" aria-hidden="true"></i> <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-[#ddd6ca] truncate min-w-0">${slot.label}</span> <span class="text-[13px] font-semibold text-[rgb(var(--text-body-rgb))] truncate min-w-0">${slot.label}</span>
<span class="ml-auto"></span> <span class="ml-auto"></span>
${addBtn} ${addBtn}
</div> </div>
<div class="px-3 pb-2.5 -mt-0.5"> <div class="px-4 pb-3 -mt-0.5">
<p class="text-[11px] text-[#7d7a74] italic pl-9">Zaplanuj posiłek</p> <p class="text-[11px] text-[rgb(var(--text-faint-rgb))] italic pl-9">Zaplanuj posiłek</p>
</div> </div>
</div>`; </div>`;
}).join(''); }).join('');
@@ -594,7 +528,7 @@ function getPendingMealRemovalProgress(state, dayKey, slotId, entryId) {
function getPendingMealRemovalButtonStyle(progress) { function getPendingMealRemovalButtonStyle(progress) {
const clamped = Math.max(0, Math.min(1, progress)); const clamped = Math.max(0, Math.min(1, progress));
const angle = Math.round(clamped * 360); 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) { function stopPendingMealRemovalTicker(state) {
@@ -970,7 +904,7 @@ function renderIngredientsSheet(state) {
if (subEl) subEl.textContent = 'Porównanie potrzeb z zapasami w spiżarni.'; if (subEl) subEl.textContent = 'Porównanie potrzeb z zapasami w spiżarni.';
if (!today || today.items.length === 0) { 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); updateIngButtons(state);
return; return;
} }
@@ -980,8 +914,8 @@ function renderIngredientsSheet(state) {
let html = ''; let html = '';
if (shortItems.length === 0) { 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"> 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-[#24352a] flex items-center justify-center shrink-0"> <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> <i class="fas fa-check text-emerald-600 text-sm"></i>
</div> </div>
<div> <div>
@@ -990,8 +924,8 @@ function renderIngredientsSheet(state) {
</div> </div>
</div>`; </div>`;
} else { } else {
html += `<div class="rounded-xl bg-[#2d2e2b] border border-red-300/40 p-3 mb-4 flex items-center gap-2.5"> 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-[#3a2326] flex items-center justify-center shrink-0"> <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> <i class="fas fa-exclamation text-red-500 text-sm"></i>
</div> </div>
<div> <div>
@@ -1006,15 +940,15 @@ function renderIngredientsSheet(state) {
<p class="text-[10px] font-bold text-red-400 uppercase tracking-wider mb-2 px-0.5"> <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 <i class="fas fa-cart-shopping text-[9px] mr-1"></i>Do kupienia
</p> </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) => ` ${shortItems.map((ing) => `
<li class="flex items-start gap-3 py-3 px-3"> <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="w-2 h-2 rounded-full bg-red-400 mt-1.5 shrink-0"></div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-[13px] font-semibold text-[#ddd6ca]">${escapeHtml(ing.name)}</p> <p class="text-[13px] font-semibold text-[rgb(var(--text-body-rgb))]">${escapeHtml(ing.name)}</p>
<p class="text-[11px] text-[#9b978f] mt-0.5"> <p class="text-[11px] text-[rgb(var(--text-dim-rgb))] mt-0.5">
potrzeba <span class="font-medium text-[#d7d2c8]">${formatAmount(ing.amount)} ${escapeHtml(ing.pantryUnit)}</span> potrzeba <span class="font-medium text-[rgb(var(--text-body-soft-rgb))]">${formatAmount(ing.amount)} ${escapeHtml(ing.pantryUnit)}</span>
<span class="mx-1 text-[#6d6c67]">&middot;</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> 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> </p>
</div> </div>
@@ -1032,15 +966,15 @@ function renderIngredientsSheet(state) {
<p class="text-[10px] font-bold text-emerald-500 uppercase tracking-wider mb-2 px-0.5"> <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 <i class="fas fa-check text-[9px] mr-1"></i>W spiżarni
</p> </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) => ` ${okItems.map((ing) => `
<li class="flex items-start gap-3 py-2.5 px-3"> <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="w-2 h-2 rounded-full bg-emerald-400 mt-1.5 shrink-0"></div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-[13px] font-medium text-[#d7d2c8]">${escapeHtml(ing.name)}</p> <p class="text-[13px] font-medium text-[rgb(var(--text-body-soft-rgb))]">${escapeHtml(ing.name)}</p>
<p class="text-[11px] text-[#9b978f] mt-0.5"> <p class="text-[11px] text-[rgb(var(--text-dim-rgb))] mt-0.5">
potrzeba <span class="font-medium text-[#d7d2c8]">${formatAmount(ing.amount)} ${escapeHtml(ing.pantryUnit)}</span> potrzeba <span class="font-medium text-[rgb(var(--text-body-soft-rgb))]">${formatAmount(ing.amount)} ${escapeHtml(ing.pantryUnit)}</span>
<span class="mx-1 text-[#6d6c67]">&middot;</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> masz <span class="font-medium text-emerald-600">${formatAmount(ing.pantryQty)} ${escapeHtml(ing.pantryUnit)}</span>
</p> </p>
</div> </div>
@@ -1059,7 +993,7 @@ function renderIngredientsSheet(state) {
const wd = WEEKDAYS_LONG[day.date.getDay()]; const wd = WEEKDAYS_LONG[day.date.getDay()];
const label = `${wd}, ${day.date.getDate()} ${CALENDAR_MONTHS_SHORT[day.date.getMonth()]}`; const label = `${wd}, ${day.date.getDate()} ${CALENDAR_MONTHS_SHORT[day.date.getMonth()]}`;
const shorts = day.items.filter((it) => !it.enough); 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"> <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)} <i class="fas fa-calendar-day text-[10px] mr-1.5 text-amber-500"></i>${escapeHtml(label)}
</p> </p>
@@ -1128,6 +1062,19 @@ export function setupMealPlanner() {
maxMinutes: PICKER_FILTER_MAX_MINUTES, 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 = () => { const rerender = () => {
syncModeToggle(state.mode); syncModeToggle(state.mode);
syncTodayButton(state.mode, state.weekStart, state.monthAnchor, state.selected); syncTodayButton(state.mode, state.weekStart, state.monthAnchor, state.selected);
@@ -1137,12 +1084,7 @@ export function setupMealPlanner() {
weekAnchorDate: state.weekStart, weekAnchorDate: state.weekStart,
monthAnchorDate: state.monthAnchor, monthAnchorDate: state.monthAnchor,
selectedDate: state.selected, selectedDate: state.selected,
resolveDayState: (day, meta) => ({ resolveDayState: resolveCalendarDayState,
dimmed: meta.mode === 'month' && !meta.inCurrentMonth,
showIndicator: meta.mode === 'month'
? meta.inCurrentMonth && dayHasAnyMeal(state.plans, day)
: dayHasAnyMeal(state.plans, day),
}),
}); });
renderDayContent(state, persist); renderDayContent(state, persist);
}; };
@@ -1153,15 +1095,11 @@ export function setupMealPlanner() {
}; };
/* ── calendar scroll shadow ─────────────────── */ /* ── calendar scroll shadow ─────────────────── */
const plannerScroll = document.getElementById('planner-scroll');
const calBar = document.getElementById('planner-cal-bar'); const calBar = document.getElementById('planner-cal-bar');
if (plannerScroll && calBar) { if (calBar) {
const shadow = document.createElement('div'); 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); calBar.appendChild(shadow);
plannerScroll.addEventListener('scroll', () => {
shadow.style.opacity = plannerScroll.scrollTop > 2 ? '1' : '0';
});
} }
bindCalendarDayClicks(weekGrid, (date) => { bindCalendarDayClicks(weekGrid, (date) => {
@@ -1173,36 +1111,6 @@ export function setupMealPlanner() {
rerender(); 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', () => { document.getElementById('cal-go-today')?.addEventListener('click', () => {
const today = startOfDay(new Date()); const today = startOfDay(new Date());
state.selected = today; state.selected = today;
@@ -1420,7 +1328,41 @@ export function setupMealPlanner() {
rerender(); 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(() => { requestAnimationFrame(() => {
const ww = document.getElementById('calendar-week-wrap'); const ww = document.getElementById('calendar-week-wrap');

View File

@@ -1,19 +1,22 @@
import { import {
INGREDIENTS, INGREDIENTS,
CATEGORY_LABELS, CATEGORY_LABELS,
} from '../data/catalog.js?v=8'; } from '../data/catalog.js?v=9';
import { loadPantry, getPantryTotal } from '../services/pantryShopping.js?v=2'; import { loadPantry, getPantryTotal } from '../services/pantryShopping.js?v=2';
import { loadPlans } from '../services/planStore.js?v=2'; import { loadPlans, dateKey } from '../services/planStore.js?v=2';
import { addDays, addMonths, sameDay, sameMonth, startOfDay, startOfMonth } from '../services/dateUtils.js'; import { addDays, sameDay, startOfDay, startOfMonth } from '../services/dateUtils.js';
import { aggregateRangeIngredientNeed, dayHasAnyMeal } from '../services/planIngredients.js?v=4'; import { aggregateRangeIngredientNeed, dayHasAnyMeal } from '../services/planIngredients.js?v=4';
import { import {
bindCalendarDayClicks, createSwipePopoverCalendarHTML,
createCalendarTopbarHTML, initSwipePopoverCalendar,
createCalendarWeekdayHeaderHTML, } from '../ui/swipePopoverCalendar.js';
renderCalendarGrid, import {
syncCalendarTodayButton, createCalendarPopoverHTML,
} from '../ui/mealCalendar.js?v=11'; stabilizeSwipeCalendarLayout,
import { createIngredientCardController, getIngredientCardHTML } from '../ui/ingredientCard.js?v=20260417-113'; syncCalendarPopoverVisibility,
} from '../ui/calendarPopover.js';
import { createIngredientCardController, getIngredientCardHTML } from '../ui/ingredientCard.js?v=20260417-116';
import { ensureFilterPopoverStyles, filterChipStyle } from '../ui/filterPopover.js?v=1';
/* ── helpers ── */ /* ── helpers ── */
@@ -48,16 +51,6 @@ function getActivePantryFilterCount() {
return pantryFilters.categories.length + pantryFilters.sections.length; return pantryFilters.categories.length + pantryFilters.sections.length;
} }
function filterChipStyle(active) {
const background = active ? '#393937' : '#2d2e2b';
const color = active ? '#f2efe8' : '#d7d2c8';
const borderRule = active ? 'border:1px solid #787876;' : 'border:none;';
const shadow = active
? 'box-shadow:inset 0 1px 0 rgba(255,255,255,0.04), 0 0 0 1px rgba(0,0,0,0.08);'
: '';
return `background:${background}; ${borderRule} color:${color}; ${shadow}`;
}
const CATEGORY_ICONS = { const CATEGORY_ICONS = {
pieczywo: 'fa-bread-slice', pieczywo: 'fa-bread-slice',
nabial: 'fa-cheese', nabial: 'fa-cheese',
@@ -78,34 +71,34 @@ const PANTRY_SECTION_FILTERS = [
const DAY_NAMES_SHORT = ['nd.', 'pon.', 'wt.', 'śr.', 'czw.', 'pt.', 'sob.']; const DAY_NAMES_SHORT = ['nd.', 'pon.', 'wt.', 'śr.', 'czw.', 'pt.', 'sob.'];
const MONTHS_SHORT = ['sty', 'lut', 'mar', 'kwi', 'maj', 'cze', 'lip', 'sie', 'wrz', 'paź', 'lis', 'gru']; const MONTHS_SHORT = ['sty', 'lut', 'mar', 'kwi', 'maj', 'cze', 'lip', 'sie', 'wrz', 'paź', 'lis', 'gru'];
const SEARCH_SHELL_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)';
const DEFAULT_HORIZON_DAYS = 7; const DEFAULT_HORIZON_DAYS = 7;
const PANTRY_CALENDAR_DAY_ATTR = 'data-pantry-calendar-day'; const SHORTFALL_ACCENT = 'rgb(var(--danger-rgb))';
const SHORTFALL_ACCENT = '#CB4A48';
const PANTRY_CALENDAR_THEME = { const PANTRY_CALENDAR_THEME = {
bg: '#272622', bg: 'rgba(255,255,255,0.08)',
border: '#34312c', border: 'rgb(var(--card-raised-rgb))',
text: '#d7d2c8', text: 'rgb(var(--text-body-soft-rgb))',
dimText: '#5a5752', dimText: 'rgb(var(--text-faint-rgb))',
dimOpacity: 0.38, dimOpacity: 0.58,
dimmedBg: 'transparent', dimmedBg: 'transparent',
dimmedBorder: 'transparent', dimmedBorder: 'transparent',
dot: '#7d7a74', dot: 'rgb(var(--text-faint-rgb))',
selectedBg: '#393937', selectedBorder: 'rgba(var(--text-emphasis-rgb),0.34)',
selectedBorder: '#787876', selectedText: 'rgb(var(--text-emphasis-rgb))',
selectedText: '#f2efe8', selectedDot: 'rgb(var(--text-emphasis-rgb))',
selectedDot: '#f2efe8', selectedShadow: '0 0 0 1px rgba(var(--text-emphasis-rgb),0.10)',
}; };
/* ── state ── */ /* ── state ── */
let ingredientCard = null; let ingredientCard = null;
let horizonEndDate = addDays(startOfDay(new Date()), DEFAULT_HORIZON_DAYS - 1); let horizonEndDate = addDays(startOfDay(new Date()), DEFAULT_HORIZON_DAYS - 1);
let isSearchExpanded = false;
let isCalendarOpen = false; let isCalendarOpen = false;
let isFilterOpen = false; let isFilterOpen = false;
let calendarMonthAnchor = startOfMonth(horizonEndDate); let calendarMonthAnchor = startOfMonth(horizonEndDate);
let pantryGlobalListenersBound = false; let pantryGlobalListenersBound = false;
let pantryCalendar = null;
let pantrySearchQuery = '';
let pantrySearchOpen = false;
let pantryFilters = { let pantryFilters = {
categories: [], categories: [],
sections: [], sections: [],
@@ -141,10 +134,6 @@ function formatHorizonLabel(date) {
return sameDay(date, getToday()) ? 'Do dziś' : `Do ${formatEndDate(date)}`; return sameDay(date, getToday()) ? 'Do dziś' : `Do ${formatEndDate(date)}`;
} }
function formatRangeSummary(date) {
return sameDay(date, getToday()) ? 'Zakres: tylko dziś' : `Zakres: dziś - ${formatEndDate(date)}`;
}
function formatDayContext(dayStrings) { function formatDayContext(dayStrings) {
const dayNames = dayStrings.map((ds) => { const dayNames = dayStrings.map((ds) => {
const d = new Date(ds + 'T00:00:00'); const d = new Date(ds + 'T00:00:00');
@@ -162,8 +151,8 @@ function photoStripMedia(image, icon, accentBg) {
<img src="${esc(image)}" alt="" class="absolute inset-0 w-full h-full object-cover"> <img src="${esc(image)}" alt="" class="absolute inset-0 w-full h-full object-cover">
</div>`; </div>`;
} }
return `<div class="w-[4.5rem] shrink-0 flex items-center justify-center self-stretch" style="background:#333331;"> return `<div class="w-[4.5rem] shrink-0 flex items-center justify-center self-stretch" style="background:rgb(var(--card-soft-rgb));">
<i class="fas ${icon} text-[22px]" style="color:rgba(255,255,255,0.10);"></i> <i class="fas ${icon} text-[22px]" style="color:var(--icon-watermark);"></i>
</div>`; </div>`;
} }
@@ -171,89 +160,98 @@ function photoStripMedia(image, icon, accentBg) {
export function getPantryHTML() { export function getPantryHTML() {
return ` return `
<div id="pantry-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden z-10" style="background:#2d2e2b !important;"> <div id="pantry-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden z-10" style="background:rgb(var(--app-bg-rgb)) !important;">
<style id="pantry-view-styles">
.pv2-tile {
background: rgba(var(--surface-rgb), 0.62) !important;
border: 1px solid rgba(255, 255, 255, 0.26) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.48),
inset 0 -1px 0 rgba(var(--overlay-rgb), 0.04),
0 1px 2px rgba(var(--overlay-rgb), 0.06),
0 6px 14px rgba(var(--overlay-rgb), 0.1) !important;
backdrop-filter: blur(18px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%);
}
<!-- ── floating top bar ── --> .dark .pv2-tile {
<div id="pantry-topbar-outer" class="pointer-events-none absolute inset-x-0 top-0 z-[12] px-4 pt-4 pb-4" style="background:#2d2e2b !important; border:none !important;"> background: rgba(255, 255, 255, 0.06) !important;
<div class="pointer-events-auto relative z-[1] mx-auto" style="width:min(calc(100% - 0.5rem), 22.4rem);"> border-color: rgba(255, 255, 255, 0.1) !important;
<div id="pantry-topbar" class="relative min-h-12"> box-shadow:
<div id="pantry-default-row" class="flex min-h-12 items-center gap-2 transition-all duration-200" style="opacity:1; transform:translateY(0) scale(1);"> inset 0 1px 0 rgba(255, 255, 255, 0.18),
<h1 class="flex-1 min-w-0 truncate" style="margin:0;padding:0;color:#f2efe8;font-family:var(--app-font);font-size:18px;font-weight:700;line-height:1.2;letter-spacing:-0.02em;">Zapasy</h1> inset 0 -1px 0 rgba(0, 0, 0, 0.22),
0 2px 4px rgba(0, 0, 0, 0.24),
0 8px 18px rgba(0, 0, 0, 0.3) !important;
}
<button type="button" id="pantry-horizon-compact" class="min-w-0 max-w-[12rem] h-10 rounded-full flex items-center gap-1.5 px-2.5 transition-all shrink" style="background:#393937 !important; border:1px solid #41423f !important; box-shadow:${SEARCH_SHELL_SHADOW} !important;"> .pv2-track {
<span id="pantry-horizon-compact-label" class="min-w-0 flex-1 text-left text-[13px] font-normal truncate" style="color:#ddd6ca;"></span> background: rgba(var(--overlay-rgb), 0.12);
<i class="fas fa-chevron-down text-[10px] shrink-0" style="color:#9b978f;"></i> }
.dark .pv2-track {
background: rgba(255, 255, 255, 0.1);
}
</style>
<!-- ── floating horizon pill above bottom nav ── -->
<div id="pantry-top-controls" class="pointer-events-none absolute inset-x-0 z-[24] transition-all duration-200" style="bottom:calc(1.58rem + env(safe-area-inset-bottom) + var(--recipe-controls-lift, 0.335rem) + var(--recipe-bottom-control-size, 3.9rem) + 0.65rem); height:var(--bottom-calendar-pill-height, calc(var(--recipe-control-size, 3.05rem) * 0.86)); background:transparent !important; border:none !important; box-shadow:none !important; backdrop-filter:none !important; -webkit-backdrop-filter:none !important;">
<div id="pantry-horizon-wrap" class="pointer-events-auto absolute bottom-0" style="--bottom-filter-pill-width:var(--recipe-bottom-control-size, 3.9rem); left:var(--catalog-menu-left, 1rem); width:calc(var(--recipe-dock-width, calc(100% - 2rem)) - var(--bottom-filter-pill-width) - 0.5rem); height:var(--bottom-calendar-pill-height, calc(var(--recipe-control-size, 3.05rem) * 0.86));">
<button type="button" id="pantry-horizon-compact" class="recipe-glass-btn w-full h-full rounded-full flex items-center gap-1.5 px-3">
<span id="pantry-horizon-compact-label" class="min-w-0 flex-1 text-left text-[13px] font-normal truncate" style="color:rgb(var(--text-body-rgb));"></span>
<i id="pantry-horizon-chevron" class="fas fa-chevron-up text-[10px] shrink-0 transition-transform duration-200" style="color:rgb(var(--text-dim-rgb));"></i>
</button> </button>
${createCalendarPopoverHTML({
<div id="pantry-filter-wrap" class="relative shrink-0"> id: 'pantry-calendar-popover',
<button type="button" id="pantry-filter-toggle" class="relative w-11 h-11 rounded-full shrink-0 flex items-center justify-center transition-all duration-200" style="background:#393937; border:1px solid #41423f; box-shadow:${SEARCH_SHELL_SHADOW}; color:#ddd6ca;"> calendarHTML: createSwipePopoverCalendarHTML({ idPrefix: 'pantry-cal' }),
<i class="fas fa-sliders-h text-[12px]"></i> popoverClass: 'absolute left-0 right-0 bottom-full mb-2 z-[50] transition-all duration-200 pointer-events-none',
<span id="pantry-filter-count" class="hidden absolute -top-1 -right-1 min-w-[1.1rem] h-[1.1rem] px-1 rounded-full text-[9px] font-bold leading-none items-center justify-center" style="background:#23221e; border:1px solid #787876; color:#f2efe8;"></span> popoverStyle: 'left:0; right:auto; width:var(--recipe-dock-width, calc(100% - 2rem)); opacity:0; transform:translateY(-6px) scale(0.98);',
})}
</div>
<div id="pantry-filter-pill-wrap" class="pointer-events-auto absolute bottom-0" style="--bottom-filter-pill-width:var(--recipe-bottom-control-size, 3.9rem); left:calc(var(--catalog-menu-left, 1rem) + var(--recipe-dock-width, calc(100% - 2rem)) - var(--bottom-filter-pill-width)); width:var(--bottom-filter-pill-width); height:var(--bottom-calendar-pill-height, calc(var(--recipe-control-size, 3.05rem) * 0.86));">
<button type="button" id="pantry-filter-pill-btn" class="recipe-glass-btn w-full h-full rounded-full relative flex items-center justify-center transition-all duration-200" aria-label="Otwórz filtry">
<i class="fas fa-sliders-h" aria-hidden="true"></i>
<span id="pantry-filter-pill-count" class="hidden absolute -top-1 -right-1 min-w-[1.1rem] h-[1.1rem] px-1 rounded-full text-[9px] font-bold leading-none items-center justify-center" style="background:rgba(var(--text-emphasis-rgb),0.86); background-image:none !important; border:1px solid rgba(255,255,255,0.42); color:rgb(var(--app-bg-rgb)); box-shadow:0 2px 6px rgba(var(--overlay-rgb),0.22);"></span>
</button> </button>
</div>
</div>
<div id="pantry-filter-popover" class="absolute right-0 top-full mt-2 w-[19rem] max-w-[calc(100vw-2rem)] rounded-[1.35rem] px-3 py-3 transition-all duration-200 pointer-events-none" style="background:#23221e !important; border:1px solid #787876 !important; box-shadow:${SEARCH_SHELL_SHADOW} !important; opacity:0; transform:translateY(-6px) scale(0.98);"> <!-- ── scrollable content ── -->
<div class="flex items-center justify-between gap-3 px-0.5 pb-3"> <div id="pantry-scroll" class="flex-1 overflow-y-auto no-scrollbar px-4" style="background:rgb(var(--app-bg-rgb)) !important; padding-top:1rem; scroll-padding-top:1rem; padding-bottom:calc(1.58rem + env(safe-area-inset-bottom) + var(--recipe-bottom-control-size, 3.9rem) + var(--bottom-calendar-pill-height, calc(var(--recipe-control-size, 3.05rem) * 0.86)) + 2.25rem);">
<p class="text-[11px] font-semibold leading-none" style="color:#f2efe8;">Filtry</p> <div id="pantry-board"></div>
<button type="button" id="pantry-filter-clear" class="h-8 px-2 rounded-full text-[11px] font-semibold transition-colors" style="background:transparent; border:none; color:#b5afa5;"> </div>
<!-- ── floating bottom controls (search + filter) ── -->
<div id="pantry-filter-popover" class="filter-liquid-surface filter-liquid-panel absolute z-[25] pointer-events-none rounded-[1.35rem] px-3 pt-3 pb-3 transition-all duration-200" style="left:50%; width:min(calc(100% - 1.5rem), 22rem); transform:translateX(-50%) translateY(0.5rem) scale(0.98); transform-origin:bottom center; bottom:calc(1.58rem + env(safe-area-inset-bottom) + var(--recipe-controls-lift, 0.335rem) + var(--recipe-bottom-control-size, 3.9rem) + var(--bottom-calendar-pill-height, calc(var(--recipe-control-size, 3.05rem) * 0.86)) + 1.15rem); opacity:0;">
<div class="flex items-center justify-between gap-3 px-0.5 pb-2">
<p class="text-[11px] font-semibold leading-none" style="color:rgb(var(--text-emphasis-rgb));">Filtry</p>
<button type="button" id="pantry-filter-clear" class="h-8 px-2 rounded-full text-[11px] font-semibold transition-colors" style="background:transparent; border:none; color:rgb(var(--text-muted-rgb));">
Wyczyść Wyczyść
</button> </button>
</div> </div>
<div id="pantry-filter-panel-body" class="space-y-4"></div> <div id="pantry-filter-panel-body" class="space-y-4"></div>
</div> </div>
</div>
<button type="button" id="pantry-search-toggle" class="w-11 h-11 rounded-full shrink-0 flex items-center justify-center transition-all duration-200" style="background:#393937 !important; border:1px solid #41423f !important; box-shadow:${SEARCH_SHELL_SHADOW} !important; color:#ddd6ca;"> <div id="pantry-bottom-controls" class="pointer-events-none absolute inset-x-0 z-[34] h-[3.9rem]" style="bottom:calc(1.58rem + env(safe-area-inset-bottom) + var(--recipe-controls-lift, 0.335rem)); height:var(--recipe-bottom-control-size, 3.9rem); background:transparent !important; border:none !important; box-shadow:none !important; backdrop-filter:none !important; -webkit-backdrop-filter:none !important;">
<i class="fas fa-search text-[13px]"></i> <div id="pantry-search-wrap" class="pointer-events-auto absolute bottom-0">
<button type="button" id="pantry-search-btn" class="recipe-glass-btn recipe-bottom-action relative flex items-center justify-center transition-all duration-200" aria-label="Szukaj w spiżarni">
<i class="fas fa-search" aria-hidden="true"></i>
<span id="pantry-search-active-dot" class="hidden absolute -top-1 -right-1 w-[0.65rem] h-[0.65rem] rounded-full" style="background:rgb(var(--text-emphasis-rgb)); border:1px solid rgba(255,255,255,0.42); box-shadow:0 2px 6px rgba(var(--overlay-rgb),0.22);"></span>
</button> </button>
</div> </div>
<div id="pantry-search-shell" class="recipe-glass-btn recipe-search-field pointer-events-none absolute bottom-0 flex items-center gap-2 px-3" style="opacity:0; transform:translateY(0.45rem) scale(0.98); transition:opacity 0.2s ease, transform 0.2s ease;">
<div id="pantry-horizon-expanded" class="absolute inset-0 transition-all duration-200 pointer-events-none" style="opacity:0; transform:translateY(-2px) scale(0.98);"> <i class="fas fa-search shrink-0" aria-hidden="true"></i>
<div id="pantry-horizon-wrap" class="relative"> <input type="search" id="pantry-search-input" autocomplete="off" placeholder="Szukaj w spiżarni…"
<button type="button" id="pantry-horizon-toggle" class="w-full h-10 rounded-full flex items-center gap-1.5 px-2.5 transition-all" style="background:#23221e !important; border:1px solid #787876 !important; box-shadow:${SEARCH_SHELL_SHADOW} !important;"> class="flex-1 min-w-0 h-full bg-transparent outline-none text-[15px] leading-none py-0" style="appearance:none; -webkit-appearance:none; background:transparent !important; background-color:transparent !important; background-image:none !important; border:none !important; border-radius:0 !important; box-shadow:none !important; backdrop-filter:none !important; -webkit-backdrop-filter:none !important; color:rgb(var(--text-body-rgb)); margin:0; padding:0 !important;">
<span id="pantry-horizon-label" class="flex-1 min-w-0 text-left text-[13px] font-normal truncate" style="color:#ddd6ca;"></span>
<i id="pantry-horizon-chevron" class="fas fa-chevron-down text-[10px] shrink-0 transition-transform duration-200" style="color:#9b978f;"></i>
</button>
<div id="pantry-calendar-popover" class="absolute left-0 right-0 top-full mt-2 rounded-[1.35rem] px-3 py-3 transition-all duration-200 pointer-events-none" style="background:#23221e !important; border:1px solid #787876 !important; box-shadow:${SEARCH_SHELL_SHADOW} !important; opacity:0; transform:translateY(-6px) scale(0.98);">
${createCalendarTopbarHTML({
prevId: 'pantry-cal-prev',
todayId: 'pantry-cal-today',
nextId: 'pantry-cal-next',
wrapperClass: 'pb-3 flex items-center justify-end gap-3',
controlsStyle: `background:${PANTRY_CALENDAR_THEME.bg};border-color:${PANTRY_CALENDAR_THEME.border};`,
navButtonClass: 'shrink-0 w-7 h-full flex items-center justify-center rounded-full border-0 bg-transparent text-[#d7d2c8] transition-colors',
todayButtonActiveClass: 'h-full shrink-0 inline-flex min-w-[5.75rem] max-w-[9rem] items-center justify-center rounded-full bg-transparent px-1.5 text-[10px] font-semibold leading-none tabular-nums text-[#d7d2c8] active:bg-transparent whitespace-nowrap',
todayButtonDimClass: 'h-full shrink-0 inline-flex items-center justify-center rounded-full px-2 text-[10px] font-semibold leading-none text-[#7d7a74] cursor-default',
})}
${createCalendarWeekdayHeaderHTML(undefined, {
wrapperClass: 'grid grid-cols-7 gap-1.5 text-center text-[8px] font-medium text-[#9b978f] uppercase tracking-wide mb-1 leading-none',
})}
<div id="pantry-calendar-grid" class="grid grid-cols-7 gap-1.5"></div>
<div class="mt-3 pt-3 border-t" style="border-color:#444442;">
<p id="pantry-cal-selection" class="min-w-0 text-[11px] leading-snug" style="color:#9b978f;"></p>
</div>
</div>
</div>
</div> </div>
<div id="pantry-search-shell" class="absolute inset-0 flex items-center gap-2 rounded-full px-3 transition-all duration-200 pointer-events-none" style="background:#23221e !important; border:1px solid #787876 !important; box-shadow:${SEARCH_SHELL_SHADOW} !important; opacity:0; transform:translateY(-2px) scale(0.98);"> <div id="pantry-filter-bottom-wrap" class="pointer-events-auto absolute bottom-0 right-4">
<i class="fas fa-search text-[13px] shrink-0" style="color:#9b978f;"></i> <button type="button" id="pantry-filter-bottom-btn" class="recipe-glass-btn recipe-bottom-action relative flex items-center justify-center transition-all duration-200" aria-label="Otwórz filtry">
<input type="search" id="pantry-search" autocomplete="off" placeholder="Szukaj w spiżarni…" <i id="pantry-right-btn-icon" class="fas fa-sliders-h" aria-hidden="true"></i>
class="flex-1 min-w-0 h-full bg-transparent outline-none text-[15px] leading-none py-0" style="background:transparent !important; border:none !important; box-shadow:none !important; color:#ddd6ca; margin:0;"> <span id="pantry-filter-count" class="hidden absolute -top-1 -right-1 min-w-[1.1rem] h-[1.1rem] px-1 rounded-full text-[9px] font-bold leading-none items-center justify-center" style="background:rgba(var(--text-emphasis-rgb),0.86); background-image:none !important; border:1px solid rgba(255,255,255,0.42); color:rgb(var(--app-bg-rgb)); box-shadow:0 2px 6px rgba(var(--overlay-rgb),0.22);"></span>
<button type="button" id="pantry-search-close" class="w-8 h-8 rounded-full shrink-0 flex items-center justify-center transition-colors" style="background:#2f2f2d; border:none; color:#9b978f;">
<i class="fas fa-xmark text-[13px]"></i>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- ── scrollable content ── -->
<div id="pantry-scroll" class="flex-1 overflow-y-auto no-scrollbar px-4 pt-[5.35rem] pb-24" style="background:#2d2e2b !important;">
<div id="pantry-board"></div>
</div>
</div>
${getIngredientCardHTML({ idBase: 'pv2-card' })}`; ${getIngredientCardHTML({ idBase: 'pv2-card' })}`;
} }
@@ -262,117 +260,106 @@ export function getPantryHTML() {
function syncHorizonUI() { function syncHorizonUI() {
ensureValidHorizonDate(); ensureValidHorizonDate();
const defaultRow = document.getElementById('pantry-default-row');
const horizonExpanded = document.getElementById('pantry-horizon-expanded');
const searchShell = document.getElementById('pantry-search-shell');
const popover = document.getElementById('pantry-calendar-popover'); const popover = document.getElementById('pantry-calendar-popover');
const filterPopover = document.getElementById('pantry-filter-popover'); const filterPopover = document.getElementById('pantry-filter-popover');
const filterToggle = document.getElementById('pantry-filter-toggle');
const filterCount = document.getElementById('pantry-filter-count'); const filterCount = document.getElementById('pantry-filter-count');
const filterPillCount = document.getElementById('pantry-filter-pill-count');
const searchWrap = document.getElementById('pantry-search-wrap');
const searchShell = document.getElementById('pantry-search-shell');
const rightWrap = document.getElementById('pantry-filter-bottom-wrap');
const rightBtn = document.getElementById('pantry-filter-bottom-btn');
const rightIcon = document.getElementById('pantry-right-btn-icon');
const searchDot = document.getElementById('pantry-search-active-dot');
const compactLabel = document.getElementById('pantry-horizon-compact-label'); const compactLabel = document.getElementById('pantry-horizon-compact-label');
const horizonLabel = document.getElementById('pantry-horizon-label'); const compactPill = document.getElementById('pantry-horizon-compact');
const chevron = document.getElementById('pantry-horizon-chevron'); const chevron = document.getElementById('pantry-horizon-chevron');
const selectionEl = document.getElementById('pantry-cal-selection');
const prevBtn = document.getElementById('pantry-cal-prev');
const todayBtn = document.getElementById('pantry-cal-today');
if (compactLabel) compactLabel.textContent = formatHorizonLabel(horizonEndDate); if (compactLabel) compactLabel.textContent = formatHorizonLabel(horizonEndDate);
if (horizonLabel) horizonLabel.textContent = formatHorizonLabel(horizonEndDate);
if (selectionEl) selectionEl.textContent = formatRangeSummary(horizonEndDate);
const showCalendar = isCalendarOpen && !isSearchExpanded;
const showDefault = !isSearchExpanded && !isCalendarOpen;
const showFilter = isFilterOpen && showDefault;
const activeFilterCount = getActivePantryFilterCount(); const activeFilterCount = getActivePantryFilterCount();
if (defaultRow) { syncCalendarPopoverVisibility({
defaultRow.style.opacity = showDefault ? '1' : '0'; popup: popover,
defaultRow.style.transform = showDefault ? 'translateY(0) scale(1)' : 'translateY(-2px) scale(0.98)'; isOpen: isCalendarOpen,
defaultRow.style.pointerEvents = showDefault ? 'auto' : 'none'; chevron,
} chevronOpenTransform: 'rotate(180deg)',
chevronClosedTransform: 'rotate(0deg)',
if (horizonExpanded) { trigger: compactPill,
horizonExpanded.style.opacity = showCalendar ? '1' : '0'; openTriggerStyle: {},
horizonExpanded.style.transform = showCalendar ? 'translateY(0) scale(1)' : 'translateY(-2px) scale(0.98)'; closedTriggerStyle: {},
horizonExpanded.style.pointerEvents = showCalendar ? 'auto' : 'none'; triggerImportant: true,
} });
if (popover) {
popover.style.opacity = showCalendar ? '1' : '0';
popover.style.transform = showCalendar ? 'translateY(0) scale(1)' : 'translateY(-6px) scale(0.98)';
popover.style.pointerEvents = showCalendar ? 'auto' : 'none';
}
if (chevron) {
chevron.style.transform = showCalendar ? 'rotate(180deg)' : 'rotate(0deg)';
}
if (filterPopover) { if (filterPopover) {
filterPopover.style.opacity = showFilter ? '1' : '0'; filterPopover.style.opacity = isFilterOpen ? '1' : '0';
filterPopover.style.transform = showFilter ? 'translateY(0) scale(1)' : 'translateY(-6px) scale(0.98)'; filterPopover.style.transform = isFilterOpen
filterPopover.style.pointerEvents = showFilter ? 'auto' : 'none'; ? 'translateX(-50%) translateY(0) scale(1)'
} : 'translateX(-50%) translateY(0.5rem) scale(0.98)';
if (filterToggle) { filterPopover.style.pointerEvents = isFilterOpen ? 'auto' : 'none';
const isActive = showFilter || hasActivePantryFilters();
filterToggle.style.setProperty('background', isActive ? '#23221e' : '#393937', 'important');
filterToggle.style.setProperty('border-color', isActive ? '#787876' : '#41423f', 'important');
filterToggle.style.setProperty('color', isActive ? '#f2efe8' : '#ddd6ca', 'important');
} }
if (filterCount) { if (filterCount) {
filterCount.textContent = String(activeFilterCount); filterCount.textContent = String(activeFilterCount);
filterCount.classList.toggle('hidden', activeFilterCount === 0); filterCount.classList.toggle('hidden', true);
filterCount.classList.toggle('flex', activeFilterCount > 0); filterCount.classList.toggle('flex', false);
} }
if (filterPillCount) {
if (prevBtn) { filterPillCount.textContent = String(activeFilterCount);
const isCurrentMonth = sameMonth(calendarMonthAnchor, getToday()); filterPillCount.classList.toggle('hidden', activeFilterCount === 0);
prevBtn.disabled = isCurrentMonth; filterPillCount.classList.toggle('flex', activeFilterCount > 0);
prevBtn.style.opacity = isCurrentMonth ? '0.45' : '1';
prevBtn.style.cursor = isCurrentMonth ? 'default' : 'pointer';
} }
syncCalendarTodayButton( if (searchWrap) searchWrap.classList.toggle('hidden', pantrySearchOpen);
todayBtn,
sameMonth(calendarMonthAnchor, getToday()),
horizonEndDate,
{
ariaLabelGo: 'Pokaż bieżący miesiąc w kalendarzu',
ariaLabelCurrent: 'Wyświetlany jest bieżący miesiąc',
},
);
if (searchShell) { if (searchShell) {
searchShell.style.opacity = isSearchExpanded ? '1' : '0'; searchShell.style.opacity = pantrySearchOpen ? '1' : '0';
searchShell.style.transform = isSearchExpanded ? 'translateY(0) scale(1)' : 'translateY(-2px) scale(0.98)'; searchShell.style.pointerEvents = pantrySearchOpen ? 'auto' : 'none';
searchShell.style.pointerEvents = isSearchExpanded ? 'auto' : 'none'; searchShell.style.transform = pantrySearchOpen ? 'translateY(0) scale(1)' : 'translateY(0.45rem) scale(0.98)';
} }
if (rightIcon) rightIcon.className = 'fas fa-xmark';
if (rightBtn) rightBtn.setAttribute('aria-label', 'Zamknij wyszukiwanie');
if (rightWrap) rightWrap.classList.toggle('hidden', !pantrySearchOpen);
if (searchDot) searchDot.classList.toggle('hidden', !pantrySearchQuery);
renderCalendarPopover(); renderCalendarPopover();
renderFilterPopover(); renderFilterPopover();
} }
function renderCalendarPopover() { function renderCalendarPopover() {
const gridEl = document.getElementById('pantry-calendar-grid'); pantryCalendar?.render();
if (!gridEl) return; }
ensureValidHorizonDate(); function bindPantryCalendarInteractions() {
const today = getToday(); pantryCalendar = initSwipePopoverCalendar({
const plans = loadPlans(); idPrefix: 'pantry-cal',
selectionMode: 'single',
renderCalendarGrid({ panelHandlePx: 10,
gridEl, panelHandleMin: 8,
mode: 'month', panelHandleMax: 12,
anchorDate: calendarMonthAnchor, getMonthAnchor: () => calendarMonthAnchor,
selectedDate: horizonEndDate, setMonthAnchor: (nextMonth) => {
resolveDayState: (day, meta) => { const nextAnchor = startOfMonth(nextMonth);
const isPast = day.getTime() < today.getTime(); const minAnchor = startOfMonth(getToday());
if (nextAnchor.getTime() < minAnchor.getTime()) return;
calendarMonthAnchor = nextAnchor;
},
canNavigateToMonth: (nextMonth) => {
const nextAnchor = startOfMonth(nextMonth);
const minAnchor = startOfMonth(getToday());
return nextAnchor.getTime() >= minAnchor.getTime();
},
getSelectionKeys: () => dateKey(horizonEndDate),
onSelectionCommit: (selectedKey) => {
selectHorizonDate(new Date(`${selectedKey}T00:00:00`));
},
resolveDayState: (day, { inCurrentMonth }) => {
const isPast = day.getTime() < getToday().getTime();
return { return {
disabled: isPast, disabled: isPast,
dimmed: isPast || !meta.inCurrentMonth, dimmed: isPast || !inCurrentMonth,
showIndicator: dayHasAnyMeal(plans, day), showDot: dayHasAnyMeal(loadPlans(), day),
}; };
}, },
dayAttr: PANTRY_CALENDAR_DAY_ATTR,
theme: PANTRY_CALENDAR_THEME, theme: PANTRY_CALENDAR_THEME,
}); });
pantryCalendar.render();
} }
function filterChipHtml(kind, value, label, active) { function filterChipHtml(kind, value, label, active) {
@@ -409,51 +396,64 @@ function renderFilterPopover() {
body.innerHTML = ` body.innerHTML = `
<section> <section>
<p class="text-[10px] font-bold uppercase tracking-wider mb-3 px-0.5" style="color:#b5afa5;">Kategorie</p> <p class="text-[10px] font-bold uppercase tracking-wider mb-3 px-0.5" style="color:rgb(var(--text-muted-rgb));">Kategorie</p>
<div class="flex flex-wrap gap-2">${categoryChips}</div> <div class="flex flex-wrap gap-2">${categoryChips}</div>
</section> </section>
<section> <section>
<p class="text-[10px] font-bold uppercase tracking-wider mb-3 px-0.5" style="color:#b5afa5;">Sekcje</p> <p class="text-[10px] font-bold uppercase tracking-wider mb-3 px-0.5" style="color:rgb(var(--text-muted-rgb));">Sekcje</p>
<div class="flex flex-wrap gap-2">${sectionChips}</div> <div class="flex flex-wrap gap-2">${sectionChips}</div>
</section> </section>
`; `;
} }
function closeSearch() { function clearSearchInput() {
const input = document.getElementById('pantry-search'); const hadQuery = Boolean(pantrySearchQuery);
const hadQuery = Boolean(input?.value); pantrySearchQuery = '';
if (input) {
input.value = '';
input.blur();
}
isSearchExpanded = false;
syncHorizonUI();
if (hadQuery) renderBoard(); if (hadQuery) renderBoard();
} }
function openSearch() { function setPantrySearchOpen(open, { clearQuery = false, focusInput = false } = {}) {
isSearchExpanded = true; const hadQuery = Boolean(pantrySearchQuery);
pantrySearchOpen = open;
if (open) {
isCalendarOpen = false; isCalendarOpen = false;
isFilterOpen = false; isFilterOpen = false;
}
document.documentElement.classList.toggle('is-inline-search-open', pantrySearchOpen);
if (clearQuery) pantrySearchQuery = '';
const input = document.getElementById('pantry-search-input');
if (input) {
if (open) {
input.value = pantrySearchQuery;
if (focusInput) {
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
}
} else {
input.blur();
}
}
syncHorizonUI(); syncHorizonUI();
window.requestAnimationFrame(() => { if (clearQuery && hadQuery) renderBoard();
document.getElementById('pantry-search')?.focus();
});
} }
function closeCalendar() { function closeCalendar() {
if (!isCalendarOpen) return; if (!isCalendarOpen) return;
isCalendarOpen = false; isCalendarOpen = false;
pantryCalendar?.resetTrackPosition();
syncHorizonUI(); syncHorizonUI();
} }
function openCalendar() { function openCalendar() {
ensureValidHorizonDate(); ensureValidHorizonDate();
calendarMonthAnchor = startOfMonth(horizonEndDate); calendarMonthAnchor = startOfMonth(horizonEndDate);
isSearchExpanded = false;
isFilterOpen = false; isFilterOpen = false;
isCalendarOpen = true; isCalendarOpen = true;
syncHorizonUI(); syncHorizonUI();
stabilizeSwipeCalendarLayout({
calendar: pantryCalendar,
viewport: 'pantry-cal-viewport',
});
} }
function closeFilter() { function closeFilter() {
@@ -463,8 +463,8 @@ function closeFilter() {
} }
function toggleFilterPanel() { function toggleFilterPanel() {
if (pantrySearchOpen) return;
isCalendarOpen = false; isCalendarOpen = false;
isSearchExpanded = false;
isFilterOpen = !isFilterOpen; isFilterOpen = !isFilterOpen;
syncHorizonUI(); syncHorizonUI();
} }
@@ -550,6 +550,7 @@ function classifyIngredients(searchQuery) {
name: def.name, name: def.name,
qty: getPantryTotal(id, pantry), qty: getPantryTotal(id, pantry),
unit: def.pantryUnit, unit: def.pantryUnit,
image: def.image || null,
icon: CATEGORY_ICONS[def.category] || 'fa-jar', icon: CATEGORY_ICONS[def.category] || 'fa-jar',
}; };
}) })
@@ -560,21 +561,28 @@ function classifyIngredients(searchQuery) {
/* ══════════════════════ TILE RENDERING ══════════════════════ */ /* ══════════════════════ TILE RENDERING ══════════════════════ */
function tileIconHtml(item, size = 'sm') {
const wrap = size === 'lg' ? 'w-11 h-11' : 'w-7 h-7';
const iconSize = size === 'lg' ? 'text-[22px]' : 'text-[15px]';
if (item.image) {
return `<div class="${wrap} shrink-0 overflow-hidden"><img src="${esc(item.image)}" alt="" class="w-full h-full object-contain"></div>`;
}
return `<div class="${wrap} flex items-center justify-center shrink-0"><i class="fas ${item.icon} ${iconSize}" style="color:rgb(var(--text-faint-rgb));"></i></div>`;
}
function shortfallTileHtml(item) { function shortfallTileHtml(item) {
const clamp = item.name.length > 25 ? ' min-w-0' : ''; const clamp = item.name.length > 25 ? ' min-w-0' : '';
return ` return `
<button type="button" class="pv2-tile text-left rounded-2xl flex flex-col gap-2 px-2.5 py-2${clamp} transition-all active:scale-[0.98]" style="flex:1 0 6rem; background:#393937; border:none; box-shadow:0 2px 8px rgba(0,0,0,0.28);" data-id="${esc(item.ingredientId)}"> <button type="button" class="pv2-tile text-left rounded-2xl flex items-center gap-2 px-2.5 py-2${clamp} transition-all active:scale-[0.98]" style="flex:1 0 auto; min-width:8.5rem; max-width:100%;" data-id="${esc(item.ingredientId)}">
<div class="flex items-center gap-1.5 min-w-0"> ${tileIconHtml(item, 'lg')}
<div class="w-6 h-6 rounded-md flex items-center justify-center shrink-0" style="background:#2f2f2d;"> <div class="flex-1 min-w-0 flex flex-col gap-1">
<i class="fas ${item.icon} text-[11px]" style="color:${SHORTFALL_ACCENT};"></i> <p class="text-[11px] font-normal leading-tight truncate" style="color:rgb(var(--text-body-rgb));">${esc(item.name)}</p>
</div> <div class="flex items-center gap-2">
<p class="text-[11px] font-normal leading-tight truncate min-w-0" style="color:#ddd6ca;">${esc(item.name)}</p> <div class="pv2-track flex-1 h-1 rounded-full overflow-hidden">
</div>
<div class="w-full flex items-center gap-2">
<div class="flex-1 h-1 rounded-full overflow-hidden" style="background:#2a2a28;">
<div class="h-full rounded-full" style="width:${item.fillPct}%; background:${SHORTFALL_ACCENT};"></div> <div class="h-full rounded-full" style="width:${item.fillPct}%; background:${SHORTFALL_ACCENT};"></div>
</div> </div>
<span class="shrink-0 text-[10px] font-semibold tabular-nums" style="color:#ddd6ca;">${esc(formatQty(item.pantryQty))}<span class="font-medium" style="color:#9b978f;">/${esc(formatQty(item.needed))} ${esc(unitLabel(item.unit))}</span></span> <span class="shrink-0 text-[10px] font-semibold tabular-nums" style="color:rgb(var(--text-body-rgb));">${esc(formatQty(item.pantryQty))}<span class="font-medium" style="color:rgb(var(--text-dim-rgb));">/${esc(formatQty(item.needed))} ${esc(unitLabel(item.unit))}</span></span>
</div>
</div> </div>
</button>`; </button>`;
} }
@@ -582,18 +590,16 @@ function shortfallTileHtml(item) {
function sufficientTileHtml(item) { function sufficientTileHtml(item) {
const clamp = item.name.length > 25 ? ' min-w-0' : ''; const clamp = item.name.length > 25 ? ' min-w-0' : '';
return ` return `
<button type="button" class="pv2-tile text-left rounded-2xl flex flex-col gap-2 px-2.5 py-2${clamp} transition-all active:scale-[0.98]" style="flex:1 0 6rem; background:#393937; border:none; box-shadow:0 2px 8px rgba(0,0,0,0.28);" data-id="${esc(item.ingredientId)}"> <button type="button" class="pv2-tile text-left rounded-2xl flex items-center gap-2 px-2.5 py-2${clamp} transition-all active:scale-[0.98]" style="flex:1 0 auto; min-width:8.5rem; max-width:100%;" data-id="${esc(item.ingredientId)}">
<div class="flex items-center gap-1.5 min-w-0"> ${tileIconHtml(item, 'lg')}
<div class="w-6 h-6 rounded-md flex items-center justify-center shrink-0" style="background:#2f2f2d;"> <div class="flex-1 min-w-0 flex flex-col gap-1">
<i class="fas ${item.icon} text-[11px]" style="color:#6ee7b7;"></i> <p class="text-[11px] font-normal leading-tight truncate" style="color:rgb(var(--text-body-rgb));">${esc(item.name)}</p>
<div class="flex items-center gap-2">
<div class="pv2-track flex-1 h-1 rounded-full overflow-hidden">
<div class="h-full rounded-full" style="width:100%; background:rgb(var(--success-rgb));"></div>
</div> </div>
<p class="text-[11px] font-normal leading-tight truncate min-w-0" style="color:#ddd6ca;">${esc(item.name)}</p> <span class="shrink-0 text-[10px] font-semibold tabular-nums" style="color:rgb(var(--success-rgb));">${esc(formatQty(item.pantryQty))}<span class="font-medium" style="color:rgb(var(--text-dim-rgb));">/${esc(formatQty(item.needed))} ${esc(unitLabel(item.unit))}</span></span>
</div> </div>
<div class="w-full flex items-center gap-2">
<div class="flex-1 h-1 rounded-full overflow-hidden" style="background:#2a2a28;">
<div class="h-full rounded-full" style="width:100%; background:#6ee7b7;"></div>
</div>
<span class="shrink-0 text-[10px] font-semibold tabular-nums" style="color:#6ee7b7;">${esc(formatQty(item.pantryQty))}<span class="font-medium" style="color:#9b978f;">/${esc(formatQty(item.needed))} ${esc(unitLabel(item.unit))}</span></span>
</div> </div>
</button>`; </button>`;
} }
@@ -602,12 +608,10 @@ function notPlannedChipHtml(item) {
const hasStock = item.qty > 0; const hasStock = item.qty > 0;
const clamp = item.name.length > 25 ? ' min-w-0' : ''; const clamp = item.name.length > 25 ? ' min-w-0' : '';
return ` return `
<button type="button" class="pv2-tile text-left rounded-2xl flex items-center gap-1.5 px-2.5 py-2${clamp} transition-all active:scale-[0.98]" style="flex:1 0 6rem; background:#393937; border:none; box-shadow:0 2px 8px rgba(0,0,0,0.28);" data-id="${esc(item.ingredientId)}"> <button type="button" class="pv2-tile text-left rounded-2xl flex items-center gap-1.5 px-2.5 py-2${clamp} transition-all active:scale-[0.98]" style="flex:1 0 auto; min-width:6rem; max-width:100%;" data-id="${esc(item.ingredientId)}">
<div class="w-6 h-6 rounded-md flex items-center justify-center shrink-0" style="background:#2f2f2d;"> ${tileIconHtml(item)}
<i class="fas ${item.icon} text-[11px]" style="color:#7d7a74;"></i> <p class="text-[11px] font-normal leading-tight truncate min-w-0" style="color:${hasStock ? 'rgb(var(--text-muted-rgb))' : 'rgb(var(--text-dim-rgb))'};">${esc(item.name)}</p>
</div> <span class="text-[10px] font-semibold tabular-nums shrink-0 ml-auto" style="color:${hasStock ? 'rgb(var(--text-dim-rgb))' : 'rgb(var(--text-subdued-rgb))'};">${esc(formatQty(item.qty))} ${esc(unitLabel(item.unit))}</span>
<p class="text-[11px] font-normal leading-tight truncate min-w-0" style="color:${hasStock ? '#b7ada1' : '#9b978f'};">${esc(item.name)}</p>
<span class="text-[10px] font-semibold tabular-nums shrink-0 ml-auto" style="color:${hasStock ? '#9b978f' : '#6d6c67'};">${esc(formatQty(item.qty))} ${esc(unitLabel(item.unit))}</span>
</button>`; </button>`;
} }
@@ -617,7 +621,7 @@ function sectionHeaderHtml(icon, iconBg, iconColor, title, titleColor, count) {
return ` return `
<div class="flex items-center gap-2 mb-2.5 px-0.5"> <div class="flex items-center gap-2 mb-2.5 px-0.5">
<span class="text-[10px] font-bold uppercase tracking-wider" style="color:${titleColor};">${esc(title)}</span> <span class="text-[10px] font-bold uppercase tracking-wider" style="color:${titleColor};">${esc(title)}</span>
<span class="text-[10px]" style="color:#6d6c67;">${count}</span> <span class="text-[10px]" style="color:rgb(var(--text-subdued-rgb));">${count}</span>
</div>`; </div>`;
} }
@@ -625,13 +629,13 @@ function renderBoard() {
const root = document.getElementById('pantry-board'); const root = document.getElementById('pantry-board');
if (!root) return; if (!root) return;
const q = document.getElementById('pantry-search')?.value || ''; const q = pantrySearchQuery;
const hasFilters = hasActivePantryFilters(); const hasFilters = hasActivePantryFilters();
const { shortfalls, sufficient, notPlanned } = classifyIngredients(q); const { shortfalls, sufficient, notPlanned } = classifyIngredients(q);
const totalVisible = shortfalls.length + sufficient.length + notPlanned.length; const totalVisible = shortfalls.length + sufficient.length + notPlanned.length;
if (totalVisible === 0 && (q || hasFilters)) { if (totalVisible === 0 && (q || hasFilters)) {
root.innerHTML = `<p class="text-sm text-center py-10" style="color:#9b978f;">Brak wyników — zmień filtry lub wyszukiwanie.</p>`; root.innerHTML = `<p class="text-sm text-center py-10" style="color:rgb(var(--text-dim-rgb));">Brak wyników — zmień filtry lub wyszukiwanie.</p>`;
return; return;
} }
@@ -640,7 +644,7 @@ function renderBoard() {
// Section 1: Potrzebne (shortfalls) // Section 1: Potrzebne (shortfalls)
if (shortfalls.length > 0) { if (shortfalls.length > 0) {
html += `<section class="mb-5">`; html += `<section class="mb-5">`;
html += sectionHeaderHtml('fa-exclamation', '#2f2f2d', SHORTFALL_ACCENT, 'Potrzebne', '#7B756D', shortfalls.length); html += sectionHeaderHtml('fa-exclamation', 'rgb(var(--card-soft-rgb))', SHORTFALL_ACCENT, 'Potrzebne', 'rgb(var(--text-faint-rgb))', shortfalls.length);
html += `<div class="flex flex-wrap gap-2">`; html += `<div class="flex flex-wrap gap-2">`;
html += shortfalls.sort((a, b) => a.name.length - b.name.length).map(shortfallTileHtml).join(''); html += shortfalls.sort((a, b) => a.name.length - b.name.length).map(shortfallTileHtml).join('');
html += `</div></section>`; html += `</div></section>`;
@@ -649,7 +653,7 @@ function renderBoard() {
// Section 2: W spiżarni (sufficient) // Section 2: W spiżarni (sufficient)
if (sufficient.length > 0) { if (sufficient.length > 0) {
html += `<section class="mb-5">`; html += `<section class="mb-5">`;
html += sectionHeaderHtml('fa-check', '#1a2e22', '#6ee7b7', 'W spiżarni', '#7B756D', sufficient.length); html += sectionHeaderHtml('fa-check', 'rgba(var(--success-rgb), 0.14)', 'rgb(var(--success-rgb))', 'W spiżarni', 'rgb(var(--text-faint-rgb))', sufficient.length);
html += `<div class="flex flex-wrap gap-2">`; html += `<div class="flex flex-wrap gap-2">`;
html += sufficient.sort((a, b) => a.name.length - b.name.length).map(sufficientTileHtml).join(''); html += sufficient.sort((a, b) => a.name.length - b.name.length).map(sufficientTileHtml).join('');
html += `</div></section>`; html += `</div></section>`;
@@ -658,7 +662,7 @@ function renderBoard() {
// Section 3: Poza planem // Section 3: Poza planem
if (notPlanned.length > 0) { if (notPlanned.length > 0) {
html += `<section class="mb-5">`; html += `<section class="mb-5">`;
html += sectionHeaderHtml('fa-minus', '#2a2a28', '#6d6c67', 'Poza planem', '#7B756D', notPlanned.length); html += sectionHeaderHtml('fa-minus', 'rgb(var(--app-bg-rgb))', 'rgb(var(--text-subdued-rgb))', 'Poza planem', 'rgb(var(--text-faint-rgb))', notPlanned.length);
html += `<div class="flex flex-wrap gap-2">`; html += `<div class="flex flex-wrap gap-2">`;
html += notPlanned.map(notPlannedChipHtml).join(''); html += notPlanned.map(notPlannedChipHtml).join('');
html += `</div></section>`; html += `</div></section>`;
@@ -668,11 +672,11 @@ function renderBoard() {
if (shortfalls.length === 0 && sufficient.length === 0 && !q && !hasFilters) { if (shortfalls.length === 0 && sufficient.length === 0 && !q && !hasFilters) {
html = ` html = `
<div class="flex flex-col items-center justify-center py-10 text-center mb-6"> <div class="flex flex-col items-center justify-center py-10 text-center mb-6">
<div class="w-12 h-12 rounded-2xl flex items-center justify-center mb-3" style="background:#393937;"> <div class="w-12 h-12 rounded-2xl flex items-center justify-center mb-3" style="background:rgb(var(--card-rgb));">
<i class="fas fa-calendar-xmark text-lg" style="color:#6d6c67;"></i> <i class="fas fa-calendar-xmark text-lg" style="color:rgb(var(--text-subdued-rgb));"></i>
</div> </div>
<p class="text-[13px] font-semibold mb-1" style="color:#ddd6ca;">Brak zaplanowanych posiłków</p> <p class="text-[13px] font-semibold mb-1" style="color:rgb(var(--text-body-rgb));">Brak zaplanowanych posiłków</p>
<p class="text-[11px] max-w-[16rem]" style="color:#9b978f;">Zaplanuj posiłki, a spiżarnia pokaże czego potrzebujesz i co masz na stanie.</p> <p class="text-[11px] max-w-[16rem]" style="color:rgb(var(--text-dim-rgb));">Zaplanuj posiłki, a spiżarnia pokaże czego potrzebujesz i co masz na stanie.</p>
</div>` + html; </div>` + html;
} }
@@ -704,6 +708,8 @@ export function refreshPantry() {
} }
export function setupPantry() { export function setupPantry() {
ensureFilterPopoverStyles();
if (!ingredientCard) { if (!ingredientCard) {
ingredientCard = createIngredientCardController({ idBase: 'pv2-card', defaultSourceNote: 'Ze spiżarni' }); ingredientCard = createIngredientCardController({ idBase: 'pv2-card', defaultSourceNote: 'Ze spiżarni' });
ingredientCard.bind(); ingredientCard.bind();
@@ -712,27 +718,34 @@ export function setupPantry() {
syncHorizonUI(); syncHorizonUI();
renderBoard(); renderBoard();
// Scroll shadow under top bar
const pantryScroll = document.getElementById('pantry-scroll');
const topbarOuter = document.getElementById('pantry-topbar-outer');
if (pantryScroll && topbarOuter) {
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;';
topbarOuter.appendChild(shadow);
pantryScroll.addEventListener('scroll', () => {
shadow.style.opacity = pantryScroll.scrollTop > 2 ? '1' : '0';
});
}
// Search // Search
document.getElementById('pantry-search')?.addEventListener('input', () => renderBoard()); document.getElementById('pantry-search-btn')?.addEventListener('click', (event) => {
document.getElementById('pantry-search')?.addEventListener('keydown', (event) => { event.stopPropagation();
if (event.key === 'Escape') closeSearch(); setPantrySearchOpen(true, { focusInput: true });
}); });
document.getElementById('pantry-search-toggle')?.addEventListener('click', () => openSearch()); document.getElementById('pantry-search-input')?.addEventListener('input', (event) => {
document.getElementById('pantry-search-close')?.addEventListener('click', () => closeSearch()); pantrySearchQuery = event.target.value.trim();
document.getElementById('pantry-filter-toggle')?.addEventListener('click', () => toggleFilterPanel()); renderBoard();
document.getElementById('pantry-filter-clear')?.addEventListener('click', () => { });
document.getElementById('pantry-search-input')?.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
event.stopPropagation();
setPantrySearchOpen(false, { clearQuery: true });
});
document.getElementById('pantry-filter-bottom-btn')?.addEventListener('click', (event) => {
event.stopPropagation();
if (pantrySearchOpen) {
setPantrySearchOpen(false, { clearQuery: true });
}
});
document.getElementById('pantry-filter-pill-btn')?.addEventListener('click', (event) => {
event.stopPropagation();
if (pantrySearchOpen) return;
closeCalendar();
toggleFilterPanel();
});
document.getElementById('pantry-filter-clear')?.addEventListener('click', (event) => {
event.stopPropagation();
pantryFilters = { categories: [], sections: [] }; pantryFilters = { categories: [], sections: [] };
syncHorizonUI(); syncHorizonUI();
renderBoard(); renderBoard();
@@ -744,6 +757,8 @@ export function setupPantry() {
const chip = target.closest('[data-pantry-filter-kind]'); const chip = target.closest('[data-pantry-filter-kind]');
if (!(chip instanceof Element)) return; if (!(chip instanceof Element)) return;
event.stopPropagation();
const kind = chip.getAttribute('data-pantry-filter-kind'); const kind = chip.getAttribute('data-pantry-filter-kind');
const value = chip.getAttribute('data-pantry-filter-value'); const value = chip.getAttribute('data-pantry-filter-value');
if (!kind || !value) return; if (!kind || !value) return;
@@ -768,32 +783,9 @@ export function setupPantry() {
// Horizon pill + calendar // Horizon pill + calendar
document.getElementById('pantry-horizon-compact')?.addEventListener('click', (event) => { document.getElementById('pantry-horizon-compact')?.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
openCalendar(); isCalendarOpen ? closeCalendar() : openCalendar();
}); });
document.getElementById('pantry-horizon-toggle')?.addEventListener('click', () => { bindPantryCalendarInteractions();
if (isCalendarOpen) {
closeCalendar();
return;
}
openCalendar();
});
document.getElementById('pantry-cal-prev')?.addEventListener('click', () => {
const prevMonth = addMonths(calendarMonthAnchor, -1);
if (prevMonth.getTime() < startOfMonth(getToday()).getTime()) return;
calendarMonthAnchor = prevMonth;
syncHorizonUI();
});
document.getElementById('pantry-cal-next')?.addEventListener('click', () => {
calendarMonthAnchor = addMonths(calendarMonthAnchor, 1);
syncHorizonUI();
});
document.getElementById('pantry-cal-today')?.addEventListener('click', () => {
calendarMonthAnchor = startOfMonth(getToday());
syncHorizonUI();
});
bindCalendarDayClicks(document.getElementById('pantry-calendar-grid'), (date) => {
selectHorizonDate(date);
}, PANTRY_CALENDAR_DAY_ATTR);
if (!pantryGlobalListenersBound) { if (!pantryGlobalListenersBound) {
pantryGlobalListenersBound = true; pantryGlobalListenersBound = true;
@@ -803,10 +795,30 @@ export function setupPantry() {
if (isCalendarOpen && !target.closest('#pantry-horizon-wrap, #pantry-horizon-compact')) { if (isCalendarOpen && !target.closest('#pantry-horizon-wrap, #pantry-horizon-compact')) {
closeCalendar(); closeCalendar();
} }
if (isFilterOpen && !target.closest('#pantry-filter-wrap')) { if (isFilterOpen && !target.closest('#pantry-filter-popover, #pantry-filter-pill-btn')) {
closeFilter(); closeFilter();
} }
}); });
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
if (pantrySearchOpen) {
setPantrySearchOpen(false, { clearQuery: true });
return;
}
if (pantrySearchQuery) clearSearchInput();
if (isFilterOpen) closeFilter();
});
window.addEventListener('app-tab-change', () => {
if (pantrySearchOpen) setPantrySearchOpen(false);
closeFilter();
closeCalendar();
});
window.closePantrySearch = () => {
if (pantrySearchOpen) setPantrySearchOpen(false);
};
window.closePantryFilter = () => {
closeFilter();
};
} }
window.refreshPantry = refreshPantry; window.refreshPantry = refreshPantry;

View File

@@ -1,5 +1,5 @@
import { RECIPES, INGREDIENTS, PRODUCTS } from '../data/catalog.js?v=8'; import { RECIPES, INGREDIENTS, PRODUCTS } from '../data/catalog.js?v=9';
import { createIngredientCardController, getIngredientCardHTML } from '../ui/ingredientCard.js?v=20260417-113'; import { createIngredientCardController, getIngredientCardHTML } from '../ui/ingredientCard.js?v=20260417-116';
function escapeHtml(s) { function escapeHtml(s) {
return String(s) return String(s)
@@ -10,15 +10,15 @@ function escapeHtml(s) {
} }
const RD_THEME = Object.freeze({ const RD_THEME = Object.freeze({
surface: '#393937', surface: 'rgb(var(--card-rgb))',
surfaceSoft: '#2f2f2d', surfaceSoft: 'rgb(var(--card-soft-rgb))',
surfaceActive: '#23221e', surfaceActive: 'rgb(var(--sunken-rgb))',
border: '#444442', border: 'rgb(var(--card-strong-rgb))',
borderSoft: '#56534f', borderSoft: 'rgba(var(--border-input-rgb), 0.58)',
borderStrong: '#787876', borderStrong: 'rgb(var(--border-input-rgb))',
textPrimary: '#ddd6ca', textPrimary: 'rgb(var(--text-body-rgb))',
textSecondary: '#d7d2c8', textSecondary: 'rgb(var(--text-body-soft-rgb))',
textMuted: '#9b978f', textMuted: 'rgb(var(--text-dim-rgb))',
}); });
function forceBg(bg) { function forceBg(bg) {
@@ -31,27 +31,27 @@ function forceBgBorder(bg, border) {
export function getRecipeDetailHTML() { export function getRecipeDetailHTML() {
return ` return `
<div id="recipe-detail-view" class="absolute inset-0 bg-[#2d2e2b] z-30 transition-all duration-300 ease-in-out translate-x-full opacity-0 pointer-events-none overflow-hidden" style="background:#2d2e2b !important; background-image:none !important;"> <div id="recipe-detail-view" class="absolute inset-0 bg-[rgb(var(--app-bg-rgb))] z-30 transition-all duration-300 ease-in-out translate-x-full opacity-0 pointer-events-none overflow-hidden" style="background:rgb(var(--app-bg-rgb)) !important; background-image:none !important;">
<div class="absolute top-0 w-full p-3.5 flex justify-between z-40 mt-3"> <div class="absolute top-0 w-full p-3.5 flex justify-between z-40 mt-3">
<button id="rd-back-btn" onclick="closeRecipeDetail()" class="w-9 h-9 rounded-full flex items-center justify-center transition-opacity opacity-95 hover:opacity-100" style="background:rgba(57,57,55,0.93) !important; backdrop-filter:none !important; box-shadow:0 4px 9px rgba(0,0,0,0.33) !important; color:#ddd6ca !important; transition:box-shadow 180ms ease, background-color 180ms ease, opacity 180ms ease;"> <button id="rd-back-btn" onclick="closeRecipeDetail()" class="w-9 h-9 rounded-full flex items-center justify-center transition-opacity opacity-95 hover:opacity-100" style="background:rgba(var(--card-rgb),0.93) !important; backdrop-filter:none !important; box-shadow:0 4px 9px rgba(var(--overlay-rgb),0.33) !important; color:rgb(var(--text-body-rgb)) !important; transition:box-shadow 180ms ease, background-color 180ms ease, opacity 180ms ease;">
<i class="fas fa-arrow-left text-[13px]"></i> <i class="fas fa-arrow-left text-[13px]"></i>
</button> </button>
<button id="rd-add-to-planner-btn" class="h-9 px-3 rounded-full flex items-center justify-center gap-1.5 transition-opacity opacity-95 hover:opacity-100 text-[12px] font-semibold" style="background:rgba(57,57,55,0.93) !important; backdrop-filter:none !important; box-shadow:0 3px 8px rgba(0,0,0,0.28) !important; color:#ddd6ca !important; transition:box-shadow 180ms ease, background-color 180ms ease, opacity 180ms ease;"> <button id="rd-add-to-planner-btn" class="h-9 px-3 rounded-full flex items-center justify-center gap-1.5 transition-opacity opacity-95 hover:opacity-100 text-[12px] font-semibold" style="background:rgba(var(--card-rgb),0.93) !important; backdrop-filter:none !important; box-shadow:0 3px 8px rgba(var(--overlay-rgb),0.28) !important; color:rgb(var(--text-body-rgb)) !important; transition:box-shadow 180ms ease, background-color 180ms ease, opacity 180ms ease;">
<i class="fas fa-calendar-plus text-[11px]"></i> Zaplanuj <i class="fas fa-calendar-plus text-[11px]"></i> Zaplanuj
</button> </button>
</div> </div>
<div id="rd-scroll-container" class="absolute inset-0 z-10 overflow-y-auto no-scrollbar" style="overscroll-behavior-y:none;"> <div id="rd-scroll-container" class="absolute inset-0 z-10 overflow-y-auto no-scrollbar" style="overscroll-behavior-y:none;">
<div id="rd-hero" class="h-[236px] w-full relative overflow-hidden" style="background:linear-gradient(180deg, #3a3937 0%, #23221e 100%) !important; will-change:height,opacity;"> <div id="rd-hero" class="h-[236px] w-full relative overflow-hidden" style="background:linear-gradient(180deg, rgb(var(--card-raised-rgb)) 0%, rgb(var(--sunken-rgb)) 100%) !important; will-change:height,opacity;">
<img id="rd-hero-img" src="" alt="" class="w-full h-full object-cover hidden" style="will-change:transform;"> <img id="rd-hero-img" src="" alt="" class="w-full h-full object-cover hidden" style="will-change:transform;">
<div class="absolute inset-0 pointer-events-none" style="background:linear-gradient(to bottom, rgba(45,46,43,0.1), rgba(45,46,43,0.4), rgba(45,46,43,0.92));"></div> <div class="absolute inset-0 pointer-events-none" style="background:linear-gradient(to bottom, rgba(var(--app-bg-rgb),0.1), rgba(var(--app-bg-rgb),0.4), rgba(var(--app-bg-rgb),0.92));"></div>
<span id="rd-hero-label" class="absolute inset-0 z-10 flex items-center justify-center font-medium text-[15px]" style="color:#ddd6ca;"></span> <span id="rd-hero-label" class="absolute inset-0 z-10 flex items-center justify-center font-medium text-[15px]" style="color:rgb(var(--text-body-rgb));"></span>
</div> </div>
<div id="rd-content-body" class="bg-[#2d2e2b] rounded-t-3xl -mt-6 relative z-30 pt-6 min-h-screen" style="background:#2d2e2b !important; background-image:none !important; box-shadow:0 -8px 20px rgba(0,0,0,0.35) !important;"> <div id="rd-content-body" class="bg-[rgb(var(--app-bg-rgb))] rounded-t-3xl -mt-6 relative z-30 pt-6 min-h-screen" style="background:rgb(var(--app-bg-rgb)) !important; background-image:none !important; box-shadow:0 -8px 20px rgba(var(--overlay-rgb),0.35) !important;">
<div class="mb-3 px-5"> <div class="mb-3 px-5">
<div class="flex justify-between items-start mb-2.5"> <div class="flex justify-between items-start mb-2.5">
<h1 id="rd-title" class="text-xl font-bold leading-tight" style="color:#ddd6ca;"></h1> <h1 id="rd-title" class="text-xl font-bold leading-tight" style="color:rgb(var(--text-body-rgb));"></h1>
</div> </div>
</div> </div>
@@ -253,17 +253,17 @@ function renderNutritionSummary(recipe) {
? ` ? `
<div class="mt-3 flex items-center justify-between gap-3"> <div class="mt-3 flex items-center justify-between gap-3">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Porcje</p> <p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Porcje</p>
<p class="pr-1 text-[13px] font-semibold leading-none text-[#d7d2c8] tabular-nums">${currentServings}</p> <p class="pr-1 text-[13px] font-semibold leading-none text-[rgb(var(--text-body-soft-rgb))] tabular-nums">${currentServings}</p>
</div>` </div>`
: ` : `
<div class="mt-3 flex items-center justify-between gap-3"> <div class="mt-3 flex items-center justify-between gap-3">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Porcje</p> <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);"> <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="rd-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"> <button type="button" id="rd-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> <i class="fas fa-minus text-[10px]"></i>
</button> </button>
<span id="rd-servings" class="flex-1 h-full inline-flex items-center justify-center px-0.5 text-[12px] font-semibold leading-none text-[#d7d2c8] tabular-nums">${currentServings}</span> <span id="rd-servings" 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">${currentServings}</span>
<button type="button" id="rd-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"> <button type="button" id="rd-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> <i class="fas fa-plus text-[10px]"></i>
</button> </button>
</div> </div>
@@ -271,23 +271,23 @@ function renderNutritionSummary(recipe) {
return ` return `
<div class="mb-4"> <div class="mb-4">
<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> <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="flex-1 flex items-center">
<div class="grid grid-cols-4 gap-1.5 w-full"> <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">${total.kcal}</p> <p class="text-[15px] font-bold text-gray-100 tabular-nums leading-tight">${total.kcal}</p>
<p class="text-[9px] text-gray-500 font-medium">kcal</p> <p class="text-[9px] text-gray-500 font-medium">kcal</p>
</div> </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">${total.protein}g</p> <p class="text-[15px] font-bold text-blue-400 tabular-nums leading-tight">${total.protein}g</p>
<p class="text-[9px] text-gray-500 font-medium">białko</p> <p class="text-[9px] text-gray-500 font-medium">białko</p>
</div> </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">${total.fat}g</p> <p class="text-[15px] font-bold text-amber-400 tabular-nums leading-tight">${total.fat}g</p>
<p class="text-[9px] text-gray-500 font-medium">tłuszcz</p> <p class="text-[9px] text-gray-500 font-medium">tłuszcz</p>
</div> </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">${total.carbs}g</p> <p class="text-[15px] font-bold text-orange-400 tabular-nums leading-tight">${total.carbs}g</p>
<p class="text-[9px] text-gray-500 font-medium">węglowodany</p> <p class="text-[9px] text-gray-500 font-medium">węglowodany</p>
</div> </div>
@@ -306,12 +306,12 @@ function renderIngredients(recipe) {
const items = buildVisibleIngredients(recipe); const items = buildVisibleIngredients(recipe);
const rows = items.map((item) => { const rows = items.map((item) => {
const rowClass = 'rd-ing-row rounded-xl px-3 py-3 w-full text-left cursor-pointer transition-colors active:scale-[0.99]'; const rowClass = 'rd-ing-row rounded-xl px-3 py-3 w-full text-left cursor-pointer transition-colors active:scale-[0.99]';
const rowStyle = 'background:#393937 !important; background-image:none !important; box-shadow:0 2px 8px rgba(0,0,0,0.25) !important; border:none !important;'; const rowStyle = 'background:rgb(var(--card-rgb)) !important; background-image:none !important; box-shadow:var(--shadow-card) !important; border:none !important;';
const productBadge = item.productName const productBadge = item.productName
? `<div class="flex items-center gap-1 mt-0.5"><span class="text-[10px] text-emerald-400 truncate">${escapeHtml(item.productName)}</span></div>` ? `<div class="flex items-center gap-1 mt-0.5"><span class="text-[10px] text-emerald-400 truncate">${escapeHtml(item.productName)}</span></div>`
: ''; : '';
const addedMark = item.added const addedMark = item.added
? '<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>' ? '<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>'
: ''; : '';
return `<li> return `<li>
<button type="button" class="${rowClass}" style="${rowStyle}" data-rd-open-ingredient data-rd-ingredient-id="${escapeHtml(item.ingredientId)}" data-rd-product-id="${escapeHtml(item.productId || '')}"> <button type="button" class="${rowClass}" style="${rowStyle}" data-rd-open-ingredient data-rd-ingredient-id="${escapeHtml(item.ingredientId)}" data-rd-product-id="${escapeHtml(item.productId || '')}">
@@ -364,7 +364,7 @@ function renderIngredients(recipe) {
const scaledAmount = ing.amount * currentServings; const scaledAmount = ing.amount * currentServings;
const isExpanded = expandedAlternatives.has(origId); const isExpanded = expandedAlternatives.has(origId);
const rowClass = 'rd-ing-row rounded-xl px-3 py-3'; const rowClass = 'rd-ing-row rounded-xl px-3 py-3';
const rowStyle = 'background:#393937 !important; background-image:none !important; box-shadow:0 2px 8px rgba(0,0,0,0.25) !important; border:none !important;'; const rowStyle = 'background:rgb(var(--card-rgb)) !important; background-image:none !important; box-shadow:var(--shadow-card) !important; border:none !important;';
const toggleBtn = hasAlts const toggleBtn = hasAlts
? `<button type="button" class="rd-alt-toggle shrink-0 w-5 h-5 flex items-center justify-center transition-colors text-gray-400 hover:text-gray-300" style="background:transparent !important; box-shadow:none !important;" data-original-id="${escapeHtml(origId)}" aria-label="Wybierz zamiennik składnika"><i class="fas fa-shuffle text-[10px]"></i></button>` ? `<button type="button" class="rd-alt-toggle shrink-0 w-5 h-5 flex items-center justify-center transition-colors text-gray-400 hover:text-gray-300" style="background:transparent !important; box-shadow:none !important;" data-original-id="${escapeHtml(origId)}" aria-label="Wybierz zamiennik składnika"><i class="fas fa-shuffle text-[10px]"></i></button>`
@@ -392,14 +392,14 @@ function renderIngredients(recipe) {
const altNutrition = nutritionForAmount(altId, scaledAmount, ing.unit); const altNutrition = nutritionForAmount(altId, scaledAmount, ing.unit);
const checkbox = ` const checkbox = `
<span class="ml-auto self-center w-[18px] h-[18px] rounded-full shrink-0 flex items-center justify-center" <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;"> style="border:1.5px solid rgba(var(--border-input-rgb), 0.58); background:transparent;">
${isSelected ? '<i class="fas fa-check" style="color:#9b978f; font-size:8px; line-height:1; display:block; transform:translateY(0.5px);"></i>' : ''} ${isSelected ? '<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>`; </span>`;
const nutritionLine = altNutrition const nutritionLine = altNutrition
? `<div class="text-[10px] text-gray-400 mt-0.5 tabular-nums">${altNutrition.kcal} kcal · ${altNutrition.protein}g B · ${altNutrition.fat}g T · ${altNutrition.carbs}g W</div>` ? `<div class="text-[10px] text-gray-400 mt-0.5 tabular-nums">${altNutrition.kcal} kcal · ${altNutrition.protein}g B · ${altNutrition.fat}g T · ${altNutrition.carbs}g W</div>`
: ''; : '';
return `<button type="button" class="rd-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-original-id="${escapeHtml(origId)}" data-alt-id="${escapeHtml(altId)}"><div class="flex items-center gap-3"><div class="min-w-0 flex-1"><div class="text-[11px] font-semibold text-gray-900">${escapeHtml(altName)}</div>${nutritionLine}</div>${checkbox}</div></button>`; return `<button type="button" class="rd-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-original-id="${escapeHtml(origId)}" data-alt-id="${escapeHtml(altId)}"><div class="flex items-center gap-3"><div class="min-w-0 flex-1"><div class="text-[11px] font-semibold text-gray-900">${escapeHtml(altName)}</div>${nutritionLine}</div>${checkbox}</div></button>`;
}); });
altListHtml = ` altListHtml = `
<div class="mt-2 ml-1 space-y-1 rd-alt-options" data-original-id="${escapeHtml(origId)}"> <div class="mt-2 ml-1 space-y-1 rd-alt-options" data-original-id="${escapeHtml(origId)}">
@@ -506,8 +506,8 @@ export function setupRecipeDetail() {
const effectiveShadowY = isRoundButton ? shadowY + 1 : shadowY; const effectiveShadowY = isRoundButton ? shadowY + 1 : shadowY;
const effectiveShadowBlur = isRoundButton ? shadowBlur + 1 : shadowBlur; const effectiveShadowBlur = isRoundButton ? shadowBlur + 1 : shadowBlur;
const effectiveShadowAlpha = isRoundButton ? shadowAlpha + 0.05 : shadowAlpha; const effectiveShadowAlpha = isRoundButton ? shadowAlpha + 0.05 : shadowAlpha;
button.style.boxShadow = `0 ${effectiveShadowY}px ${effectiveShadowBlur}px rgba(0,0,0,${effectiveShadowAlpha})`; button.style.boxShadow = `0 ${effectiveShadowY}px ${effectiveShadowBlur}px rgba(var(--overlay-rgb),${effectiveShadowAlpha})`;
button.style.background = `rgba(57,57,55,${backgroundAlpha})`; button.style.background = `rgba(var(--card-rgb),${backgroundAlpha})`;
}); });
} }

View File

@@ -1,12 +1,9 @@
import { RECIPES } from '../data/catalog.js?v=8'; import { RECIPES } from '../data/catalog.js?v=9';
import { getRecipeGridSectionHTML, renderRecipeGrid } from '../ui/recipeGrid.js'; import { getRecipeGridSectionHTML, renderRecipeGrid } from '../ui/recipeGrid.js';
const DEFAULT_MIN_MINUTES = 5; const DEFAULT_MIN_MINUTES = 5;
const DEFAULT_MAX_MINUTES = 120; const DEFAULT_MAX_MINUTES = 120;
/** Jak w spiżarni — cień „pigówek” i powłoki wyszukiwania */
const SEARCH_SHELL_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)';
let filterState = { let filterState = {
query: '', query: '',
slots: [], slots: [],
@@ -15,8 +12,8 @@ let filterState = {
maxMinutes: DEFAULT_MAX_MINUTES, maxMinutes: DEFAULT_MAX_MINUTES,
}; };
let isSearchExpanded = false;
let recipeListDocListenersBound = false; let recipeListDocListenersBound = false;
let recipeSearchOpen = false;
function matchesFilters(recipe) { function matchesFilters(recipe) {
const { query, slots, tags, minMinutes, maxMinutes } = filterState; const { query, slots, tags, minMinutes, maxMinutes } = filterState;
@@ -46,53 +43,80 @@ function getFilteredRecipes() {
return Object.values(RECIPES).filter(matchesFilters); return Object.values(RECIPES).filter(matchesFilters);
} }
function getActiveRecipeFilterCount() {
let count = filterState.slots.length + filterState.tags.length;
if (filterState.minMinutes > DEFAULT_MIN_MINUTES || filterState.maxMinutes < DEFAULT_MAX_MINUTES) count += 1;
return count;
}
function syncRecipeScrollShadow() { function syncRecipeScrollShadow() {
const searchShell = document.getElementById('recipe-search-shell'); syncRecipeTopbarUI();
if (searchShell) {
searchShell.style.boxShadow = SEARCH_SHELL_SHADOW;
}
} }
function syncRecipeTopbarUI() { function syncRecipeTopbarUI() {
const defaultRow = document.getElementById('recipe-default-row'); const searchWrap = document.getElementById('recipe-search-wrap');
const searchShell = document.getElementById('recipe-search-shell'); const searchShell = document.getElementById('recipe-search-shell');
const rightWrap = document.getElementById('recipe-filter-wrap');
const showSearch = isSearchExpanded; const rightBtn = document.getElementById('recipe-filter-btn');
const rightIcon = document.getElementById('recipe-right-btn-icon');
if (defaultRow) { const filterCount = document.getElementById('recipe-filter-count');
defaultRow.style.opacity = showSearch ? '0' : '1'; const floatingFilterCount = document.getElementById('recipe-filter-float-count');
defaultRow.style.transform = showSearch ? 'translateY(-2px) scale(0.98)' : 'translateY(0) scale(1)'; const dot = document.getElementById('recipe-search-active-dot');
defaultRow.style.pointerEvents = showSearch ? 'none' : 'auto'; const isOpen = recipeSearchOpen;
} if (searchWrap) searchWrap.classList.toggle('hidden', isOpen);
if (searchShell) { if (searchShell) {
searchShell.style.opacity = showSearch ? '1' : '0'; searchShell.style.opacity = isOpen ? '1' : '0';
searchShell.style.transform = showSearch ? 'translateY(0) scale(1)' : 'translateY(-2px) scale(0.98)'; searchShell.style.pointerEvents = isOpen ? 'auto' : 'none';
searchShell.style.pointerEvents = showSearch ? 'auto' : 'none'; searchShell.style.transform = isOpen ? 'translateY(0) scale(1)' : 'translateY(0.45rem) scale(0.98)';
searchShell.style.boxShadow = SEARCH_SHELL_SHADOW; }
if (rightIcon) {
rightIcon.className = 'fas fa-xmark';
}
if (rightBtn) {
rightBtn.setAttribute('aria-label', 'Zamknij wyszukiwanie');
}
if (rightWrap) {
rightWrap.classList.toggle('hidden', !isOpen);
}
if (filterCount) {
const showCount = false;
filterCount.classList.toggle('hidden', !showCount);
filterCount.classList.toggle('flex', showCount);
}
if (floatingFilterCount) {
const activeCount = getActiveRecipeFilterCount();
floatingFilterCount.textContent = String(activeCount);
floatingFilterCount.classList.toggle('hidden', activeCount === 0);
floatingFilterCount.classList.toggle('flex', activeCount > 0);
}
if (dot) {
const hasQuery = Boolean(filterState.query);
dot.classList.toggle('hidden', !hasQuery);
} }
} }
function closeSearch() { function setSearchOpen(open, { clearQuery = false, focusInput = false } = {}) {
const hadQuery = Boolean(filterState.query);
recipeSearchOpen = open;
if (open) window.closeFilters?.();
document.documentElement.classList.toggle('is-inline-search-open', recipeSearchOpen);
if (clearQuery) {
filterState.query = '';
}
const input = document.getElementById('recipe-search-input'); const input = document.getElementById('recipe-search-input');
const hadQuery = Boolean(input?.value);
if (input) { if (input) {
input.value = ''; if (open) {
input.value = filterState.query;
if (focusInput) {
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
}
} else {
input.blur(); input.blur();
} }
filterState.query = ''; }
isSearchExpanded = false;
syncRecipeTopbarUI(); syncRecipeTopbarUI();
if (hadQuery) renderGrid(); if (clearQuery && hadQuery) renderGrid();
}
function openSearch() {
isSearchExpanded = true;
window.closeFilters?.();
syncRecipeTopbarUI();
window.requestAnimationFrame(() => {
document.getElementById('recipe-search-input')?.focus();
});
} }
function renderGrid() { function renderGrid() {
@@ -105,7 +129,7 @@ function renderGrid() {
emptyStateEl: emptyState, emptyStateEl: emptyState,
recipes: getFilteredRecipes(), recipes: getFilteredRecipes(),
showSlotLabels: false, showSlotLabels: false,
cardClassName: 'recipe-list-card', cardClassName: 'recipe-list-card recipe-catalog-card',
}); });
syncRecipeTopbarUI(); syncRecipeTopbarUI();
requestAnimationFrame(syncRecipeScrollShadow); requestAnimationFrame(syncRecipeScrollShadow);
@@ -113,47 +137,44 @@ function renderGrid() {
export function getRecipeListHTML() { export function getRecipeListHTML() {
return ` return `
<div id="main-view" class="flex flex-col h-full absolute inset-0 bg-[#2d2e2b] z-10" style="background:#2d2e2b !important;"> <div id="main-view" class="flex flex-col h-full absolute inset-0 bg-[rgb(var(--app-bg-rgb))] z-10" style="background:rgb(var(--app-bg-rgb)) !important;">
<div id="recipe-top-bar" class="pointer-events-none absolute inset-x-0 top-0 z-[12] px-4 pt-4 pb-4" style="background:#2d2e2b !important; border:none !important;">
<div class="pointer-events-auto relative z-[1] mx-auto" style="width:min(calc(100% - 0.5rem), 22.4rem);">
<div id="recipe-topbar" class="relative min-h-12">
<div id="recipe-default-row" class="flex min-h-12 items-center gap-2 transition-all duration-200" style="opacity:1; transform:translateY(0) scale(1);">
<h1 class="min-w-0 flex-1 truncate" style="margin:0;padding:0;color:#f2efe8;font-family:var(--app-font);font-size:18px;font-weight:700;line-height:1.2;letter-spacing:-0.02em;">Katalog przepisów</h1>
<div id="recipe-filter-wrap" class="relative shrink-0">
<button type="button" id="recipe-filter-btn" class="relative w-11 h-11 rounded-full shrink-0 flex items-center justify-center transition-all duration-200" style="background:#393937; border:1px solid #41423f; box-shadow:${SEARCH_SHELL_SHADOW}; color:#ddd6ca;" aria-label="Otwórz filtry">
<i class="fas fa-sliders-h text-[12px]" aria-hidden="true"></i>
<span id="recipe-filter-count" class="hidden absolute -top-1 -right-1 min-w-[1.1rem] h-[1.1rem] px-1 rounded-full text-[9px] font-bold leading-none items-center justify-center" style="background:#23221e; border:1px solid #787876; color:#f2efe8;"></span>
</button>
</div>
<button type="button" id="recipe-search-toggle" class="w-11 h-11 rounded-full shrink-0 flex items-center justify-center transition-all duration-200" style="background:#393937 !important; border:1px solid #41423f !important; box-shadow:${SEARCH_SHELL_SHADOW} !important; color:#ddd6ca;" aria-label="Szukaj">
<i class="fas fa-search text-[13px]" aria-hidden="true"></i>
</button>
</div>
<div id="recipe-search-shell" class="absolute inset-0 flex items-center gap-2 rounded-full px-3 transition-all duration-200 pointer-events-none" style="background:#23221e !important; border:1px solid #787876 !important; box-shadow:${SEARCH_SHELL_SHADOW} !important; opacity:0; transform:translateY(-2px) scale(0.98);">
<i class="fas fa-search text-[13px] shrink-0" style="color:#9b978f;"></i>
<input type="search" id="recipe-search-input" autocomplete="off" placeholder="Szukaj przepisów…"
class="flex-1 min-w-0 h-full bg-transparent outline-none text-[15px] leading-none py-0" style="background:transparent !important; border:none !important; box-shadow:none !important; color:#ddd6ca; margin:0;">
<button type="button" id="recipe-search-close" class="w-8 h-8 rounded-full shrink-0 flex items-center justify-center transition-colors" style="background:#2f2f2d; border:none; color:#9b978f;">
<i class="fas fa-xmark text-[13px]"></i>
</button>
</div>
</div>
</div>
</div>
${getRecipeGridSectionHTML({ ${getRecipeGridSectionHTML({
scrollId: 'recipe-scroll', scrollId: 'recipe-scroll',
gridId: 'recipe-grid', gridId: 'recipe-grid',
emptyStateId: 'recipe-empty-state', emptyStateId: 'recipe-empty-state',
scrollClassName: 'relative flex-1 overflow-y-auto px-4 pt-[5.35rem] pb-24 bg-[#2d2e2b]', scrollClassName: 'relative flex-1 overflow-y-auto px-4 pt-4 pb-32 bg-[rgb(var(--app-bg-rgb))]',
gridClassName: 'grid grid-cols-3 gap-2 bg-[#2d2e2b]', gridClassName: 'grid grid-cols-3 gap-2 bg-[rgb(var(--app-bg-rgb))]',
emptyTitle: 'Brak wyników', emptyTitle: 'Brak wyników',
emptyMessage: 'Zmień kryteria wyszukiwania lub filtry', emptyMessage: 'Zmień kryteria wyszukiwania lub filtry',
})} })}
<div id="recipe-bottom-controls" class="pointer-events-none absolute inset-x-0 z-[34] h-[3.9rem]" style="bottom:calc(1.58rem + env(safe-area-inset-bottom) + var(--recipe-controls-lift, 0.335rem)); height:var(--recipe-bottom-control-size, 3.9rem); background:transparent !important; border:none !important; box-shadow:none !important; backdrop-filter:none !important; -webkit-backdrop-filter:none !important;">
<div id="recipe-search-wrap" class="pointer-events-auto absolute bottom-0">
<button type="button" id="recipe-search-btn" class="recipe-glass-btn recipe-bottom-action relative flex items-center justify-center transition-all duration-200" aria-label="Szukaj przepisów">
<i class="fas fa-search" aria-hidden="true"></i>
<span id="recipe-search-active-dot" class="hidden absolute -top-1 -right-1 w-[0.65rem] h-[0.65rem] rounded-full" style="background:rgb(var(--text-emphasis-rgb)); border:1px solid rgba(255,255,255,0.42); box-shadow:0 2px 6px rgba(var(--overlay-rgb),0.22);"></span>
</button>
</div>
<div id="recipe-search-shell" class="recipe-glass-btn recipe-search-field pointer-events-none absolute bottom-0 flex items-center gap-2 px-3" style="opacity:0; transform:translateY(0.45rem) scale(0.98); transition:opacity 0.2s ease, transform 0.2s ease;">
<i class="fas fa-search shrink-0" aria-hidden="true"></i>
<input type="search" id="recipe-search-input" autocomplete="off" placeholder="Szukaj przepisów…"
class="flex-1 min-w-0 h-full bg-transparent outline-none text-[15px] leading-none py-0" style="appearance:none; -webkit-appearance:none; background:transparent !important; background-color:transparent !important; background-image:none !important; border:none !important; border-radius:0 !important; box-shadow:none !important; backdrop-filter:none !important; -webkit-backdrop-filter:none !important; color:rgb(var(--text-body-rgb)); margin:0; padding:0 !important;">
</div>
<div id="recipe-filter-wrap" class="pointer-events-auto absolute bottom-0 right-4">
<button type="button" id="recipe-filter-btn" class="recipe-glass-btn recipe-bottom-action relative flex items-center justify-center transition-all duration-200" aria-label="Otwórz filtry">
<i id="recipe-right-btn-icon" class="fas fa-sliders-h" aria-hidden="true"></i>
<span id="recipe-filter-count" class="hidden absolute -top-1 -right-1 min-w-[1.1rem] h-[1.1rem] px-1 rounded-full text-[9px] font-bold leading-none items-center justify-center" style="background:rgba(var(--text-emphasis-rgb),0.86); background-image:none !important; border:1px solid rgba(255,255,255,0.42); color:rgb(var(--app-bg-rgb)); box-shadow:0 2px 6px rgba(var(--overlay-rgb),0.22);"></span>
</button>
</div>
</div>
<div id="recipe-filter-float-controls" class="pointer-events-none absolute inset-x-0 z-[33] transition-all duration-200" style="bottom:calc(1.58rem + env(safe-area-inset-bottom) + var(--recipe-controls-lift, 0.335rem) + var(--recipe-bottom-control-size, 3.9rem) + 0.65rem); height:var(--bottom-calendar-pill-height, calc(var(--recipe-control-size, 3.05rem) * 0.86)); background:transparent !important; border:none !important; box-shadow:none !important; backdrop-filter:none !important; -webkit-backdrop-filter:none !important;">
<div id="recipe-filter-float-wrap" class="pointer-events-auto absolute bottom-0" style="--bottom-filter-pill-width:var(--recipe-bottom-control-size, 3.9rem); left:calc(var(--catalog-menu-left, 1rem) + var(--recipe-dock-width, calc(100% - 2rem)) - var(--bottom-filter-pill-width)); width:var(--bottom-filter-pill-width); height:var(--bottom-calendar-pill-height, calc(var(--recipe-control-size, 3.05rem) * 0.86));">
<button type="button" id="recipe-filter-float-btn" class="recipe-glass-btn w-full h-full rounded-full relative flex items-center justify-center transition-all duration-200" aria-label="Otwórz filtry">
<i class="fas fa-sliders-h" aria-hidden="true"></i>
<span id="recipe-filter-float-count" class="hidden absolute -top-1 -right-1 min-w-[1.1rem] h-[1.1rem] px-1 rounded-full text-[9px] font-bold leading-none items-center justify-center" style="background:rgba(var(--text-emphasis-rgb),0.86); background-image:none !important; border:1px solid rgba(255,255,255,0.42); color:rgb(var(--app-bg-rgb)); box-shadow:0 2px 6px rgba(var(--overlay-rgb),0.22);"></span>
</button>
</div>
</div>
</div> </div>
`; `;
} }
@@ -178,23 +199,28 @@ export function refreshRecipeList() {
export function setupRecipeList() { export function setupRecipeList() {
renderGrid(); renderGrid();
document.getElementById('recipe-search-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
setSearchOpen(true, { focusInput: true });
});
document.getElementById('recipe-search-input')?.addEventListener('input', (e) => { document.getElementById('recipe-search-input')?.addEventListener('input', (e) => {
filterState.query = e.target.value.trim(); filterState.query = e.target.value.trim();
renderGrid(); renderGrid();
}); });
document.getElementById('recipe-search-input')?.addEventListener('keydown', (e) => { document.getElementById('recipe-search-input')?.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeSearch(); if (e.key !== 'Escape') return;
e.stopPropagation();
setSearchOpen(false, { clearQuery: true });
}); });
document.getElementById('recipe-search-toggle')?.addEventListener('click', () => openSearch());
document.getElementById('recipe-search-close')?.addEventListener('click', () => closeSearch());
document.getElementById('recipe-filter-btn')?.addEventListener('click', (e) => { document.getElementById('recipe-filter-btn')?.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
if (isSearchExpanded) { if (recipeSearchOpen) {
isSearchExpanded = false; setSearchOpen(false, { clearQuery: true });
syncRecipeTopbarUI();
} }
});
document.getElementById('recipe-filter-float-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
if (recipeSearchOpen) return;
window.openFilters?.('recipes'); window.openFilters?.('recipes');
}); });
@@ -205,24 +231,24 @@ export function setupRecipeList() {
if (recipeId) window.openRecipeDetail?.(recipeId); if (recipeId) window.openRecipeDetail?.(recipeId);
}); });
const recipeScroll = document.getElementById('recipe-scroll');
const recipeTopBar = document.getElementById('recipe-top-bar');
if (recipeScroll && recipeTopBar) {
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;';
recipeTopBar.appendChild(shadow);
recipeScroll.addEventListener('scroll', () => {
shadow.style.opacity = recipeScroll.scrollTop > 2 ? '1' : '0';
});
}
if (!recipeListDocListenersBound) { if (!recipeListDocListenersBound) {
recipeListDocListenersBound = true; recipeListDocListenersBound = true;
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape' || !isSearchExpanded) return; const isRecipeViewVisible = !document.getElementById('main-view')?.classList.contains('hidden');
if (!document.getElementById('main-view')?.classList.contains('hidden')) { if (e.key !== 'Escape') return;
closeSearch(); if (isRecipeViewVisible) {
if (recipeSearchOpen) setSearchOpen(false, { clearQuery: true });
} }
}); });
window.addEventListener('app-tab-change', () => {
if (recipeSearchOpen) setSearchOpen(false);
syncRecipeTopbarUI();
});
window.closeRecipeSearch = () => {
if (recipeSearchOpen) setSearchOpen(false);
syncRecipeTopbarUI();
};
} }
} }

View File

@@ -1,20 +1,24 @@
import { INGREDIENTS, CATEGORY_LABELS } from '../data/catalog.js?v=8'; import { INGREDIENTS } from '../data/catalog.js?v=9';
import { import {
KITCHEN_LIST_ID,
loadShoppingState,
saveShoppingState,
addOrMergeShoppingLines,
removeItemFromList,
clearCheckedInList,
loadPantry, loadPantry,
savePantry,
computeShortfalls, computeShortfalls,
categoryLabel, categoryLabel,
addAmountToPantry,
subtractFromPantry,
getSelectedDays,
setSelectedDays,
addSessionLogEntry,
removeSessionLogEntry,
clearSessionLog,
loadShoppingSession,
} from '../services/pantryShopping.js?v=2'; } from '../services/pantryShopping.js?v=2';
import { aggregateWeekIngredientNeed } from '../services/planIngredients.js?v=2'; import { aggregateSelectedDaysIngredientNeed } from '../services/planIngredients.js?v=2';
import { loadPlans } from '../services/planStore.js?v=2'; import { loadPlans, dateKey } from '../services/planStore.js?v=2';
import { startOfWeekMonday } from '../services/dateUtils.js'; import { addDays, startOfDay, startOfMonth } from '../services/dateUtils.js';
import { createSwipePopoverCalendarHTML, initSwipePopoverCalendar } from '../ui/swipePopoverCalendar.js';
import { createCalendarPopoverController, createCalendarPopoverHTML } from '../ui/calendarPopover.js';
import { showAppToast } from '../ui/toast.js'; import { showAppToast } from '../ui/toast.js';
import { ensureFilterPopoverStyles } from '../ui/filterPopover.js?v=1';
/* ── helpers ── */ /* ── helpers ── */
@@ -22,15 +26,20 @@ function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
} }
function unitLabel(u) {
return u === 'szt' ? 'szt.' : u;
}
function formatQty(n) { function formatQty(n) {
const rounded = Math.round((Number(n) || 0) * 10) / 10; const rounded = Math.round((Number(n) || 0) * 10) / 10;
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1).replace(/\.0$/, ''); return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1).replace(/\.0$/, '');
} }
function unitLabel(u) {
return u === 'szt' ? 'szt.' : String(u);
}
function stepForUnit(unit) {
return (unit === 'szt.' || unit === 'szt') ? 1 : 10;
}
const CATEGORY_ICONS = { const CATEGORY_ICONS = {
pieczywo: 'fa-bread-slice', pieczywo: 'fa-bread-slice',
nabial: 'fa-cheese', nabial: 'fa-cheese',
@@ -41,46 +50,59 @@ const CATEGORY_ICONS = {
przyprawy: 'fa-leaf', przyprawy: 'fa-leaf',
inne: 'fa-jar', inne: 'fa-jar',
}; };
const CATEGORY_ORDER = ['pieczywo', 'nabial', 'mieso_ryby', 'warzywa', 'owoce', 'suche', 'przyprawy', 'inne']; const CATEGORY_ORDER = ['pieczywo', 'nabial', 'mieso_ryby', 'warzywa', 'owoce', 'suche', 'przyprawy', 'inne'];
const DAY_ABBR = ['Nd', 'Pn', 'Wt', 'Śr', 'Cz', 'Pt', 'Sb'];
const DAY_NAMES_SHORT = ['nd.', 'pon.', 'wt.', 'śr.', 'czw.', 'pt.', 'sob.'];
const WEEKDAY_SHORT = ['pn', 'wt', 'śr', 'cz', 'pt', 'sb', 'nd'];
const MONTHS_LONG = ['Styczeń','Luty','Marzec','Kwiecień','Maj','Czerwiec','Lipiec','Sierpień','Wrzesień','Październik','Listopad','Grudzień'];
const MONTHS_SHORT = ['sty','lut','mar','kwi','maj','cze','lip','sie','wrz','paź','lis','gru'];
const CALENDAR_DIM_TEXT = 'rgb(var(--text-faint-rgb))';
const CALENDAR_DIM_OPACITY = '0.58';
/* ══════════════════════ HTML SHELL ══════════════════════ */ /* ── module state ── */
let boughtPopupOpen = false;
let expandedIngredientId = null;
let expandedAmount = 0;
let calendarOpen = false;
let calendarMonth = startOfMonth(new Date());
let shoppingCalendar = null;
let shoppingCalendarPopover = null;
export function getShoppingListHTML() { /* ── day helpers ── */
return `
<div id="shopping-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden z-10" style="background:#2d2e2b !important;">
<!-- ── header ── --> function todayKey() { return dateKey(new Date()); }
<div class="shrink-0 px-4 pt-5 pb-2">
<div class="flex items-center justify-between mb-3">
<h1 class="text-[18px] font-bold" style="color:#f2efe8;">Lista zakupów</h1>
<button type="button" id="sl-clear-checked" class="text-[11px] font-semibold px-3 py-1.5 rounded-full transition-colors" style="background:#393937; color:#9b978f; border:1px solid #444442;" aria-label="Usuń kupione">
Wyczyść kupione
</button>
</div>
<button type="button" id="sl-generate" class="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-[13px] font-semibold transition-colors active:scale-[0.98]" style="background:#393937; color:#ddd6ca; border:1px solid #444442;">
<i class="fas fa-wand-magic-sparkles text-[12px]" style="color:rgb(var(--accent-rgb));"></i>
Generuj braki z planera
</button>
</div>
<!-- ── scrollable list ── --> function getDefaultSelectedDays() {
<div id="sl-scroll" class="flex-1 overflow-y-auto no-scrollbar px-4 pt-2 pb-24" style="background:#2d2e2b !important;"> const today = startOfDay(new Date());
<div id="sl-board"></div> return Array.from({ length: 7 }, (_, i) => dateKey(addDays(today, i)));
</div>
</div>`;
} }
/* ══════════════════════ RENDERING ══════════════════════ */ function formatRangePill(selectedDays) {
if (selectedDays.length === 0) return 'Wybierz dni';
function getKitchenItems() { const sorted = [...selectedDays].sort();
const state = loadShoppingState(); const fmt = (dk) => {
const list = state.lists.find((l) => l.id === KITCHEN_LIST_ID && l.type === 'kitchen'); const d = new Date(dk + 'T00:00:00');
return list ? /** @type {import('../services/pantryShopping.js').KitchenShoppingItem[]} */ (list.items) : []; return `${DAY_NAMES_SHORT[d.getDay()]} ${d.getDate()} ${MONTHS_SHORT[d.getMonth()]}`;
};
if (sorted.length === 1) return fmt(sorted[0]);
return `${fmt(sorted[0])} ${fmt(sorted[sorted.length - 1])}`;
} }
function groupItemsByCategory(items) { /* ── computed data ── */
/** @type {Map<string, typeof items>} */
function computeActiveItems() {
const selectedDays = getSelectedDays();
if (selectedDays.length === 0) return [];
const plans = loadPlans();
const needLines = aggregateSelectedDaysIngredientNeed(plans, selectedDays);
return computeShortfalls(needLines, loadPantry());
}
function getSessionLog() {
return loadShoppingSession().sessionLog;
}
function groupByCategory(items) {
const groups = new Map(); const groups = new Map();
for (const item of items) { for (const item of items) {
const cat = item.category || 'inne'; const cat = item.category || 'inne';
@@ -92,194 +114,517 @@ function groupItemsByCategory(items) {
.map((cat) => ({ cat, items: groups.get(cat) })); .map((cat) => ({ cat, items: groups.get(cat) }));
} }
function itemRowHtml(item) { /* ══════════════════════ HTML SHELL ══════════════════════ */
const def = INGREDIENTS[item.ingredientId];
const icon = def ? (CATEGORY_ICONS[def.category] || 'fa-jar') : 'fa-jar';
const image = def?.image;
const checked = item.checked;
export function getShoppingListHTML() {
return `
<div id="shopping-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden z-10" style="background:rgb(var(--app-bg-rgb)) !important;">
<!-- ── floating range pill above bottom nav ── -->
<div id="shopping-top-controls" class="pointer-events-none absolute inset-x-0 z-[24] transition-all duration-200" style="bottom:calc(1.58rem + env(safe-area-inset-bottom) + var(--recipe-controls-lift, 0.335rem) + var(--recipe-bottom-control-size, 3.9rem) + 0.65rem); height:var(--bottom-calendar-pill-height, calc(var(--recipe-control-size, 3.05rem) * 0.86)); background:transparent !important; border:none !important; box-shadow:none !important; backdrop-filter:none !important; -webkit-backdrop-filter:none !important;">
<div id="shopping-range-wrap" class="pointer-events-auto absolute bottom-0" style="left:var(--catalog-menu-left, 1rem); width:var(--recipe-dock-width, calc(100% - 2rem)); height:var(--bottom-calendar-pill-height, calc(var(--recipe-control-size, 3.05rem) * 0.86)); position:relative;">
<button type="button" id="sl-range-pill" class="recipe-glass-btn w-full h-full rounded-full flex items-center gap-1.5 px-3 transition-all">
<span id="sl-range-label" class="min-w-0 flex-1 text-left text-[13px] font-normal truncate" style="color:rgb(var(--text-body-rgb));"></span>
<i id="sl-range-chevron" class="fas fa-chevron-up text-[10px] shrink-0 transition-transform duration-200" style="color:rgb(var(--text-dim-rgb));"></i>
</button>
<!-- popup calendar (absolute, overlays content above) -->
${createCalendarPopoverHTML({
id: 'sl-calendar-popup',
calendarHTML: createSwipePopoverCalendarHTML({
idPrefix: 'sl-cal',
weekdays: WEEKDAY_SHORT,
}),
popoverClass: 'absolute left-0 right-0 bottom-full mb-2 z-[50] transition-all duration-200 pointer-events-none',
})}
</div>
</div>
<!-- ── scrollable list ── -->
<div id="sl-scroll" class="flex-1 overflow-y-auto no-scrollbar px-4" style="background:rgb(var(--app-bg-rgb)) !important; padding-top:1rem; scroll-padding-top:1rem; padding-bottom:calc(1.58rem + env(safe-area-inset-bottom) + var(--recipe-bottom-control-size, 3.9rem) + var(--bottom-calendar-pill-height, calc(var(--recipe-control-size, 3.05rem) * 0.86)) + 2.25rem);">
<div id="sl-board"></div>
</div>
<div id="shopping-bottom-controls" class="pointer-events-none absolute inset-x-0 z-[24] h-[3.9rem]" style="bottom:calc(1.58rem + env(safe-area-inset-bottom) + var(--recipe-controls-lift, 0.335rem)); height:var(--recipe-bottom-control-size, 3.9rem); background:transparent !important; border:none !important; box-shadow:none !important; backdrop-filter:none !important; -webkit-backdrop-filter:none !important;">
<div id="sl-bought-wrap" class="pointer-events-auto absolute bottom-0" style="left:calc(var(--catalog-menu-left, 1rem) + var(--recipe-dock-width, calc(100% - 2rem)) - var(--recipe-bottom-control-size, 3.9rem));">
<button type="button" id="sl-bought-btn" class="recipe-glass-btn recipe-bottom-action relative flex items-center justify-center transition-all duration-200" aria-label="Kupione">
<i class="fas fa-check" aria-hidden="true"></i>
<span id="sl-bought-badge" class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full text-[9px] font-bold items-center justify-center" style="background:rgb(var(--success-rgb)); color:rgb(var(--on-accent-rgb)); display:none;">0</span>
</button>
<!-- popup bought (absolute, overlays content above controls) -->
<div id="sl-bought-popup" style="position:absolute; bottom:calc(100% + 0.5rem); right:0; width:min(calc(100vw - 1.5rem), 22rem); z-index:50; pointer-events:none; opacity:0; transform:translateY(6px) scale(0.98); transition: opacity 0.2s ease, transform 0.2s ease;">
<div class="filter-liquid-surface filter-liquid-panel rounded-[1.35rem] px-3 py-3">
<div class="flex items-center justify-between mb-2 px-1">
<span class="text-[11px] font-bold uppercase tracking-wider" style="color:rgb(var(--text-dim-rgb));">Kupione (<span id="sl-bought-popup-count">0</span>)</span>
<button type="button" id="sl-clear-session" class="h-8 px-2 rounded-full text-[11px] font-semibold transition-colors" style="background:transparent; border:none; color:rgb(var(--text-muted-rgb));">
Wyczyść
</button>
</div>
<div id="sl-bought-list" class="max-h-[50vh] overflow-y-auto no-scrollbar"></div>
</div>
</div>
</div>
</div>
</div>`;
}
/* ══════════════════════ CALENDAR ══════════════════════ */
function initShoppingCalendar() {
shoppingCalendar = initSwipePopoverCalendar({
idPrefix: 'sl-cal',
selectionMode: 'range',
monthsLong: MONTHS_LONG,
getMonthAnchor: () => calendarMonth,
setMonthAnchor: (nextMonth) => {
calendarMonth = startOfMonth(nextMonth);
},
getSelectionKeys: () => getSelectedDays(),
onSelectionCommit: (rangeKeys) => {
setSelectedDays(rangeKeys);
expandedIngredientId = null;
updatePillLabel();
renderAll();
},
resolveDayState: (day, { inCurrentMonth, isSelected }) => ({
disabled: false,
dimmed: !inCurrentMonth,
showDot: dateKey(day) === todayKey() && !isSelected,
}),
theme: {
selectedBorder: 'rgba(var(--text-emphasis-rgb),0.34)',
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: 'rgba(255,255,255,0.08)',
border: 'transparent',
text: 'rgb(var(--text-body-soft-rgb))',
dimmedBg: 'transparent',
dimText: CALENDAR_DIM_TEXT,
dimOpacity: Number(CALENDAR_DIM_OPACITY),
dot: 'rgb(var(--text-faint-rgb))',
},
});
shoppingCalendarPopover = createCalendarPopoverController({
popupId: 'sl-calendar-popup',
viewportId: 'sl-cal-viewport',
triggerId: 'sl-range-pill',
chevronId: 'sl-range-chevron',
chevronOpenTransform: 'rotate(180deg)',
chevronClosedTransform: 'rotate(0deg)',
getCalendar: () => shoppingCalendar,
hideViewportDuringLayout: true,
});
}
function updatePillLabel() {
const el = document.getElementById('sl-range-label');
if (el) el.textContent = formatRangePill(getSelectedDays());
}
function openCalendar() {
if (boughtPopupOpen) closeBoughtPopup();
calendarOpen = true;
calendarMonth = startOfMonth(new Date());
shoppingCalendarPopover?.open();
}
function closeCalendar() {
calendarOpen = false;
shoppingCalendarPopover?.close({ clearPendingRange: true });
}
function toggleCalendar() {
calendarOpen ? closeCalendar() : openCalendar();
}
function openBoughtPopup() {
if (calendarOpen) closeCalendar();
boughtPopupOpen = true;
const popup = document.getElementById('sl-bought-popup');
const btn = document.getElementById('sl-bought-btn');
if (popup) {
popup.style.pointerEvents = 'auto';
popup.style.opacity = '1';
popup.style.transform = 'translateY(0) scale(1)';
}
if (btn) {
btn.style.background = 'rgb(var(--sunken-rgb))';
btn.style.borderColor = 'rgb(var(--border-input-rgb))';
}
}
function closeBoughtPopup() {
boughtPopupOpen = false;
const popup = document.getElementById('sl-bought-popup');
const btn = document.getElementById('sl-bought-btn');
if (popup) {
popup.style.pointerEvents = 'none';
popup.style.opacity = '0';
popup.style.transform = 'translateY(6px) scale(0.98)';
}
if (btn) {
btn.style.background = 'rgb(var(--card-rgb))';
btn.style.borderColor = 'rgb(var(--border-card-rgb))';
}
}
function toggleBoughtPopup() {
boughtPopupOpen ? closeBoughtPopup() : openBoughtPopup();
}
/* ══════════════════════ ITEM ROWS ══════════════════════ */
function activeItemHtml(item) {
const def = INGREDIENTS[item.ingredientId];
const icon = CATEGORY_ICONS[def?.category] || 'fa-jar';
const image = def?.image;
const mediaFit = image?.endsWith('.svg') ? 'object-contain' : 'object-cover';
const mediaHtml = image const mediaHtml = image
? `<img src="${esc(image)}" alt="" class="w-8 h-8 rounded-lg object-cover shrink-0">` ? `<img src="${esc(image)}" alt="" class="w-8 h-8 rounded-lg ${mediaFit} shrink-0">`
: `<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0" style="background:#2f2f2d;"><i class="fas ${icon} text-xs" style="color:#8f8b84;"></i></div>`; : `<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0" style="background:rgb(var(--card-soft-rgb));"><i class="fas ${icon} text-xs" style="color:rgb(var(--text-faint-rgb));"></i></div>`;
const isExpanded = expandedIngredientId === item.ingredientId;
const step = stepForUnit(item.unit);
const stepAmt = isExpanded ? expandedAmount : Math.max(step, Math.round(item.shortfall / step) * step);
return ` return `
<div class="sl-row flex items-center gap-3 py-2.5 px-3 rounded-xl mb-1.5 transition-all duration-200 ${checked ? 'sl-checked' : ''}" style="background:${checked ? '#2f2f2d' : '#393937'}; border:1px solid ${checked ? '#3a3a38' : '#444442'};" data-id="${esc(item.id)}"> <div class="sl-swipe-wrap relative rounded-xl mb-1.5 overflow-hidden" style="box-shadow:var(--shadow-card);">
<button type="button" class="sl-check shrink-0 w-6 h-6 rounded-full flex items-center justify-center transition-colors" style="background:${checked ? 'rgb(var(--success-rgb))' : 'transparent'}; border:2px solid ${checked ? 'rgb(var(--success-rgb))' : '#6d6c67'};" aria-label="${checked ? 'Oznacz jako niekupione' : 'Oznacz jako kupione'}"> <div class="sl-swipe-bg-buy absolute inset-0 flex items-center pr-5 justify-end" style="background:rgb(var(--success-rgb)); opacity:0;">
${checked ? '<i class="fas fa-check text-[10px]" style="color:#1a1a1a;"></i>' : ''} <i class="fas fa-check text-white text-lg"></i>
</button>
${mediaHtml}
<div class="flex-1 min-w-0 ${checked ? 'opacity-40' : ''}">
<span class="block text-[13px] font-medium leading-tight truncate ${checked ? 'line-through' : ''}" style="color:#ddd6ca;">${esc(item.name)}</span>
</div> </div>
<span class="text-[13px] font-semibold tabular-nums shrink-0 ${checked ? 'opacity-40 line-through' : ''}" style="color:#b7ada1;">${esc(formatQty(item.amount))} ${esc(unitLabel(item.unit))}</span> <div class="sl-swipe-inner" style="background:rgb(var(--card-rgb)); position:relative;"
<button type="button" class="sl-remove shrink-0 w-7 h-7 rounded-full flex items-center justify-center transition-colors" style="background:transparent; color:#6d6c67;" aria-label="Usuń"> data-id="${esc(item.ingredientId)}" data-unit="${esc(item.unit)}" data-shortfall="${item.shortfall}">
<i class="fas fa-xmark text-xs"></i> <div class="sl-item-main flex items-center gap-3 py-1.5 px-3 cursor-pointer select-none">
${mediaHtml}
<div class="flex-1 min-w-0">
<span class="block text-[13px] font-medium leading-tight truncate" style="color:rgb(var(--text-body-rgb));">${esc(item.name)}</span>
</div>
<span class="text-[13px] font-semibold tabular-nums shrink-0" style="color:rgb(var(--text-muted-rgb));">${esc(formatQty(item.shortfall))} ${esc(unitLabel(item.unit))}</span>
<i class="fas fa-chevron-${isExpanded ? 'up' : 'down'} text-[9px] shrink-0" style="color:rgb(var(--text-subdued-rgb));"></i>
</div>
<div class="sl-step-row overflow-hidden" data-step-for="${esc(item.ingredientId)}"
style="max-height:${isExpanded ? '60px' : '0'}; transition: max-height 0.2s ease;">
<div class="flex items-center gap-2 px-3 pb-3">
<button type="button" class="sl-step-minus w-9 h-9 rounded-xl flex items-center justify-center shrink-0 active:scale-95" style="background:rgb(var(--card-soft-rgb)); color:rgb(var(--text-body-soft-rgb));" aria-label="Zmniejsz ilość">
<i class="fas fa-minus text-xs"></i>
</button> </button>
<div class="flex-1 rounded-xl px-3 py-2 flex items-center justify-center gap-2" style="background:rgb(var(--card-soft-rgb));">
<span class="sl-exp-amount text-[14px] font-semibold tabular-nums" style="color:rgb(var(--text-body-rgb));">${esc(formatQty(stepAmt))}</span>
<span class="text-[12px] font-medium shrink-0" style="color:rgb(var(--text-dim-rgb));">${esc(unitLabel(item.unit))}</span>
</div>
<button type="button" class="sl-step-plus w-9 h-9 rounded-xl flex items-center justify-center shrink-0 active:scale-95" style="background:rgb(var(--card-soft-rgb)); color:rgb(var(--text-body-soft-rgb));" aria-label="Zwiększ ilość">
<i class="fas fa-plus text-xs"></i>
</button>
<button type="button" class="sl-step-confirm px-3 py-1.5 rounded-lg text-[12px] font-semibold shrink-0 active:scale-95" style="background:rgb(var(--text-body-rgb)); color:rgb(var(--app-bg-rgb));">Dodaj</button>
</div>
</div>
</div>
</div>`; </div>`;
} }
function boughtItemHtml(entry) {
const def = INGREDIENTS[entry.ingredientId];
const icon = CATEGORY_ICONS[def?.category] || 'fa-jar';
const image = def?.image;
const mediaFit = image?.endsWith('.svg') ? 'object-contain' : 'object-cover';
const mediaHtml = image
? `<img src="${esc(image)}" alt="" class="w-8 h-8 rounded-lg ${mediaFit} shrink-0">`
: `<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0" style="background:rgb(var(--card-soft-rgb));"><i class="fas ${icon} text-xs" style="color:rgb(var(--text-faint-rgb));"></i></div>`;
return `
<div class="sl-swipe-wrap relative rounded-xl mb-1.5 overflow-hidden" style="box-shadow:var(--shadow-card);">
<div class="sl-swipe-bg-undo absolute inset-0 flex items-center pl-5 justify-start" style="background:rgb(var(--danger-rgb)); opacity:0;">
<i class="fas fa-rotate-left text-white text-lg"></i>
</div>
<div class="sl-swipe-inner flex items-center gap-3 py-1.5 px-3" style="background:rgb(var(--card-rgb)); position:relative;" data-entry-id="${esc(entry.id)}">
${mediaHtml}
<div class="flex-1 min-w-0">
<span class="block text-[13px] font-medium leading-tight truncate" style="color:rgb(var(--text-body-rgb));">${esc(entry.name)}</span>
</div>
<span class="text-[13px] font-semibold tabular-nums shrink-0" style="color:rgb(var(--text-muted-rgb));">${esc(formatQty(entry.addedAmount))} ${esc(unitLabel(entry.unit))}</span>
</div>
</div>`;
}
/* ══════════════════════ SWIPE ══════════════════════ */
function attachSwipe(container, opts) {
const inner = container.querySelector('.sl-swipe-inner');
if (!inner) return;
const bgBuy = container.querySelector('.sl-swipe-bg-buy');
const bgUndo = container.querySelector('.sl-swipe-bg-undo');
const showBg = (el) => { if (el) el.style.opacity = '1'; };
const hideBgs = () => { if (bgBuy) bgBuy.style.opacity = '0'; if (bgUndo) bgUndo.style.opacity = '0'; };
let startX = 0, startY = 0, dx = 0, tracking = false, decided = false, goingH = false;
container.addEventListener('pointerdown', (e) => {
startX = e.clientX; startY = e.clientY; dx = 0;
tracking = true; decided = false; goingH = false;
inner.style.transition = 'none';
container.setPointerCapture(e.pointerId);
});
container.addEventListener('pointermove', (e) => {
if (!tracking) return;
const ddx = e.clientX - startX;
const ddy = e.clientY - startY;
if (!decided) {
if (Math.abs(ddx) < 6 && Math.abs(ddy) < 6) return;
decided = true;
goingH = Math.abs(ddx) > Math.abs(ddy);
}
if (!goingH) { tracking = false; inner.style.transform = ''; hideBgs(); return; }
dx = ddx;
if (dx > 0 && opts.onRight) { inner.style.transform = `translateX(${Math.min(dx, 90)}px)`; showBg(bgBuy); }
else if (dx < 0 && opts.onLeft) { inner.style.transform = `translateX(${Math.max(dx, -90)}px)`; showBg(bgUndo); }
});
const finish = () => {
if (!tracking) return;
tracking = false;
inner.style.transition = 'transform 0.2s ease';
const thr = 65;
if (dx > thr && opts.onRight) {
inner.style.transform = 'translateX(120%)';
setTimeout(opts.onRight, 180);
} else if (dx < -thr && opts.onLeft) {
inner.style.transform = 'translateX(-120%)';
setTimeout(opts.onLeft, 180);
} else {
inner.style.transform = '';
hideBgs();
}
dx = 0;
};
container.addEventListener('pointerup', finish);
container.addEventListener('pointercancel', () => {
tracking = false;
inner.style.transition = 'transform 0.2s ease';
inner.style.transform = '';
hideBgs();
dx = 0;
});
}
/* ══════════════════════ EXPAND / STEPPER ══════════════════════ */
function toggleExpand(ingredientId, unit, shortfall) {
if (expandedIngredientId === ingredientId) {
expandedIngredientId = null;
} else {
expandedIngredientId = ingredientId;
const step = stepForUnit(unit);
expandedAmount = Math.max(step, Math.round(shortfall / step) * step);
}
renderBoard();
}
function updateExpandedAmountDisplay() {
const el = document.querySelector(`[data-step-for="${expandedIngredientId}"] .sl-exp-amount`);
if (el) el.textContent = formatQty(expandedAmount);
}
/* ══════════════════════ BUY / UNDO ══════════════════════ */
function buyItem(ingredientId, unit, amount) {
const def = INGREDIENTS[ingredientId];
if (!def) return;
addAmountToPantry(ingredientId, undefined, amount);
addSessionLogEntry({ ingredientId, name: def.name, addedAmount: amount, unit, category: def.category || 'inne' });
expandedIngredientId = null;
renderAll();
if (typeof window.refreshPantry === 'function') window.refreshPantry();
}
function undoBoughtEntry(entryId) {
const log = getSessionLog();
const entry = log.find((e) => e.id === entryId);
if (!entry) return;
subtractFromPantry(entry.ingredientId, entry.productId, entry.addedAmount);
removeSessionLogEntry(entryId);
renderAll();
if (typeof window.refreshPantry === 'function') window.refreshPantry();
showAppToast('Cofnięto zakup');
}
/* ══════════════════════ RENDERING ══════════════════════ */
function renderBoughtBadge(count) {
const badge = document.getElementById('sl-bought-badge');
if (!badge) return;
if (count > 0) {
badge.textContent = String(count);
badge.style.display = 'flex';
} else {
badge.style.display = 'none';
}
}
function renderBoard() { function renderBoard() {
const root = document.getElementById('sl-board'); const root = document.getElementById('sl-board');
if (!root) return; if (!root) return;
const items = getKitchenItems(); const selectedDays = getSelectedDays();
if (selectedDays.length === 0) {
if (items.length === 0) {
root.innerHTML = ` root.innerHTML = `
<div class="flex flex-col items-center justify-center py-16 text-center"> <div class="flex flex-col items-center justify-center py-16 text-center">
<div class="w-14 h-14 rounded-2xl flex items-center justify-center mb-4" style="background:#393937;"> <div class="w-14 h-14 rounded-2xl flex items-center justify-center mb-4" style="background:rgb(var(--card-rgb));">
<i class="fas fa-cart-shopping text-xl" style="color:#6d6c67;"></i> <i class="fas fa-calendar-days text-xl" style="color:rgb(var(--text-subdued-rgb));"></i>
</div> </div>
<p class="text-[14px] font-semibold mb-1" style="color:#ddd6ca;">Lista jest pusta</p> <p class="text-[14px] font-semibold mb-1" style="color:rgb(var(--text-body-rgb));">Wybierz dni</p>
<p class="text-[12px] max-w-[14rem]" style="color:#9b978f;">Kliknij „Generuj braki z planera" aby dodać składniki na bieżący tydzień.</p> <p class="text-[12px] max-w-[14rem]" style="color:rgb(var(--text-dim-rgb));">Otwórz kalendarz i zaznacz dni, na które chcesz zrobić zakupy.</p>
</div>`; </div>`;
return; return;
} }
const groups = groupItemsByCategory(items); const activeItems = computeActiveItems();
const html = groups.map(({ cat, items: catItems }) => {
const icon = CATEGORY_ICONS[cat] || 'fa-jar'; if (activeItems.length === 0) {
const uncheckedCount = catItems.filter((i) => !i.checked).length; root.innerHTML = `
return ` <div class="flex flex-col items-center justify-center py-16 text-center">
<section class="mb-4"> <div class="w-14 h-14 rounded-2xl flex items-center justify-center mb-4" style="background:rgb(var(--card-rgb));">
<div class="flex items-center gap-1.5 mb-2 px-1"> <i class="fas fa-check text-xl" style="color:rgb(var(--success-rgb));"></i>
<i class="fas ${icon} text-[10px]" style="color:#9b978f;"></i>
<p class="text-[10px] font-bold uppercase tracking-wider" style="color:#9b978f;">${esc(categoryLabel(cat))}</p>
<span class="text-[10px]" style="color:#6d6c67;">${uncheckedCount}/${catItems.length}</span>
</div> </div>
${catItems.map((item) => itemRowHtml(item)).join('')} <p class="text-[14px] font-semibold mb-1" style="color:rgb(var(--text-body-rgb));">Wszystko masz</p>
</section>`; <p class="text-[12px] max-w-[14rem]" style="color:rgb(var(--text-dim-rgb));">Spiżarnia pokrywa zapotrzebowanie na wybrane dni.</p>
}).join(''); </div>`;
root.innerHTML = html;
bindRowEvents(root);
}
/* ══════════════════════ EVENTS ══════════════════════ */
function bindRowEvents(root) {
root.querySelectorAll('.sl-row').forEach((row) => {
const id = row.dataset.id;
row.querySelector('.sl-check')?.addEventListener('click', () => {
handleToggle(id);
});
row.querySelector('.sl-remove')?.addEventListener('click', () => {
removeItemFromList(KITCHEN_LIST_ID, id);
renderBoard();
updateBadge();
});
});
}
function handleToggle(itemId) {
const state = loadShoppingState();
const list = state.lists.find((l) => l.id === KITCHEN_LIST_ID);
if (!list) return;
const item = list.items.find((i) => i.id === itemId);
if (!item) return;
const wasChecked = item.checked;
item.checked = !wasChecked;
saveShoppingState(state);
if (!wasChecked) {
// Just checked → apply to pantry
applyItemToPantry(item);
}
renderBoard();
updateBadge();
if (typeof window.refreshPantry === 'function') window.refreshPantry();
}
function applyItemToPantry(item) {
const def = INGREDIENTS[item.ingredientId];
if (!def) return;
const pantry = loadPantry();
if (item.productId) {
let val = pantry[item.ingredientId];
if (!val || typeof val === 'number') {
val = { items: [], _total: 0 };
pantry[item.ingredientId] = val;
}
const idx = val.items.findIndex((i) => i.productId === item.productId);
if (idx >= 0) val.items[idx].qty = Math.round((val.items[idx].qty + item.amount) * 1000) / 1000;
else val.items.push({ productId: item.productId, qty: Math.round(item.amount * 1000) / 1000 });
val._total = Math.round(val.items.reduce((s, i) => s + i.qty, 0) * 1000) / 1000;
} else {
const cur = typeof pantry[item.ingredientId] === 'number' ? pantry[item.ingredientId] : 0;
pantry[item.ingredientId] = Math.round((cur + item.amount) * 1000) / 1000;
}
savePantry(pantry);
}
function handleGenerate() {
const plans = loadPlans();
const weekStart = startOfWeekMonday(new Date());
const needLines = aggregateWeekIngredientNeed(plans, weekStart);
const pantry = loadPantry();
const shortfalls = computeShortfalls(needLines, pantry);
if (shortfalls.length === 0) {
showAppToast('Wszystko masz w spiżarni!');
return; return;
} }
const lines = shortfalls.map((s) => ({ const groups = groupByCategory(activeItems);
ingredientId: s.ingredientId, root.innerHTML = groups.map(({ cat, items: catItems }) => {
amount: s.shortfall, const icon = CATEGORY_ICONS[cat] || 'fa-jar';
unit: s.unit, return `
name: s.name, <section class="mb-4">
category: s.category, <div class="flex items-center gap-1.5 mb-2 px-1">
sourceNote: 'Z planera', <i class="fas ${icon} text-[10px]" style="color:rgb(var(--text-dim-rgb));"></i>
})); <p class="text-[10px] font-bold uppercase tracking-wider" style="color:rgb(var(--text-dim-rgb));">${esc(categoryLabel(cat))}</p>
<span class="text-[10px]" style="color:rgb(var(--text-subdued-rgb));">${catItems.length}</span>
</div>
${catItems.map((item) => activeItemHtml(item)).join('')}
</section>`;
}).join('');
addOrMergeShoppingLines(lines, KITCHEN_LIST_ID); bindActiveRowEvents(root, activeItems);
renderBoard();
updateBadge();
showAppToast(`Dodano ${shortfalls.length} pozycji z planera`);
} }
function handleClearChecked() { function renderBought() {
clearCheckedInList(KITCHEN_LIST_ID); const sessionLog = getSessionLog();
renderBoard(); const countEl = document.getElementById('sl-bought-popup-count');
updateBadge(); const list = document.getElementById('sl-bought-list');
} if (!list || !countEl) return;
/* ══════════════════════ BADGE ══════════════════════ */ countEl.textContent = String(sessionLog.length);
function updateBadge() { if (sessionLog.length === 0) {
const items = getKitchenItems(); list.innerHTML = `<div class="py-6 text-center text-[12px]" style="color:rgb(var(--text-dim-rgb));">Brak kupionych</div>`;
const uncheckedCount = items.filter((i) => !i.checked).length; return;
const badge = document.getElementById('nav-shopping-badge');
if (!badge) return;
if (uncheckedCount > 0) {
badge.textContent = String(uncheckedCount > 99 ? '99+' : uncheckedCount);
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
} }
list.innerHTML = [...sessionLog].reverse().map((e) => boughtItemHtml(e)).join('');
list.querySelectorAll('.sl-swipe-wrap').forEach((wrap) => {
const entryId = wrap.querySelector('.sl-swipe-inner')?.dataset.entryId;
if (!entryId) return;
attachSwipe(wrap, { onLeft: () => undoBoughtEntry(entryId) });
});
}
function renderAll() {
const sessionLog = getSessionLog();
renderBoughtBadge(sessionLog.length);
renderBoard();
renderBought();
}
/* ══════════════════════ ACTIVE ROW EVENTS ══════════════════════ */
function bindActiveRowEvents(root, activeItems) {
root.querySelectorAll('.sl-swipe-wrap').forEach((wrap) => {
const inner = wrap.querySelector('.sl-swipe-inner');
const ingredientId = inner?.dataset.id;
const unit = inner?.dataset.unit;
const shortfall = parseFloat(inner?.dataset.shortfall || '0');
if (!ingredientId) return;
const item = activeItems.find((i) => i.ingredientId === ingredientId);
attachSwipe(wrap, { onRight: item ? () => buyItem(item.ingredientId, item.unit, item.shortfall) : undefined });
inner.querySelector('.sl-item-main')?.addEventListener('click', () => toggleExpand(ingredientId, unit, shortfall));
inner.querySelector('.sl-step-minus')?.addEventListener('click', (e) => {
e.stopPropagation();
const step = stepForUnit(unit);
expandedAmount = Math.max(step, expandedAmount - step);
updateExpandedAmountDisplay();
});
inner.querySelector('.sl-step-plus')?.addEventListener('click', (e) => {
e.stopPropagation();
expandedAmount += stepForUnit(unit);
updateExpandedAmountDisplay();
});
inner.querySelector('.sl-step-confirm')?.addEventListener('click', (e) => {
e.stopPropagation();
if (expandedAmount > 0) buyItem(ingredientId, unit, expandedAmount);
});
});
} }
/* ══════════════════════ PUBLIC API ══════════════════════ */ /* ══════════════════════ PUBLIC API ══════════════════════ */
export function refreshShoppingList() { export function refreshShoppingList() {
renderBoard(); updatePillLabel();
updateBadge(); renderAll();
if (calendarOpen) shoppingCalendar?.render();
} }
export function setupShoppingList() { export function setupShoppingList() {
renderBoard(); ensureFilterPopoverStyles();
updateBadge();
document.getElementById('sl-generate')?.addEventListener('click', handleGenerate); if (getSelectedDays().length === 0) setSelectedDays(getDefaultSelectedDays());
document.getElementById('sl-clear-checked')?.addEventListener('click', handleClearChecked);
updatePillLabel();
renderAll();
initShoppingCalendar();
document.getElementById('sl-range-pill')?.addEventListener('click', (e) => {
e.stopPropagation();
toggleCalendar();
});
document.getElementById('sl-bought-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
toggleBoughtPopup();
});
document.addEventListener('click', (e) => {
if (calendarOpen) {
const popup = document.getElementById('sl-calendar-popup');
const pill = document.getElementById('sl-range-pill');
if (popup && !popup.contains(e.target) && pill && !pill.contains(e.target)) {
closeCalendar();
}
}
if (boughtPopupOpen) {
const popup = document.getElementById('sl-bought-popup');
const btn = document.getElementById('sl-bought-btn');
if (popup && !popup.contains(e.target) && btn && !btn.contains(e.target)) {
closeBoughtPopup();
}
}
});
document.getElementById('sl-clear-session')?.addEventListener('click', (e) => {
e.stopPropagation();
clearSessionLog();
renderAll();
});
window.closeShoppingCalendar = () => closeCalendar();
window.closeShoppingBoughtPopup = () => closeBoughtPopup();
window.refreshShoppingList = refreshShoppingList; window.refreshShoppingList = refreshShoppingList;
} }