Compare commits
52 Commits
4706430316
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f902098a8 | |||
| 6d6194df37 | |||
| 68e5227db1 | |||
| 544df5175d | |||
| 53a7212dfe | |||
| ded24b53b4 | |||
| 3d62d88d48 | |||
| b9538a35b6 | |||
| 120959365e | |||
| 7049cb1d48 | |||
| bc505d6b4c | |||
| 7328b6ec4c | |||
| 2f362a7e56 | |||
| e914f93781 | |||
| 5499476a17 | |||
| 8702830f68 | |||
| 08a275093c | |||
| c43b3766cd | |||
| 63937ed7d1 | |||
| 570e44257f | |||
| 070a0a61db | |||
| d2618a5b45 | |||
| 1bca99a6bb | |||
| 3055ce53c1 | |||
| 5c21fb1e64 | |||
| 59340e8afd | |||
| 8e48ebdd95 | |||
| a90e8ba9d2 | |||
| 35b8babd0c | |||
| 9bd6627fe2 | |||
| 0429b0b945 | |||
| 92cc779dbf | |||
| f533db1743 | |||
| 8ed15bbe36 | |||
| 4d7a1a12ae | |||
| 230100b63f | |||
| d3a68a80eb | |||
| d642fbd687 | |||
| 71b91b50b4 | |||
| bb529b7bac | |||
| dff88b1c98 | |||
| 8a8a4ad3fd | |||
| 4c4a56a75c | |||
| 02e9856ff0 | |||
| 527324515a | |||
| 86d47f126d | |||
| 2883dc858e | |||
| 12369465d7 | |||
| f785706578 | |||
| 775fa38095 | |||
| 6bf50f67ad | |||
| 165f39d0b7 |
1
icons/ingredients/banany.svg
Normal 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 |
1
icons/ingredients/bazylia_swieza.svg
Normal 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 |
1
icons/ingredients/borowki_amerykanskie.svg
Normal 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 |
1
icons/ingredients/bulion_warzywny.svg
Normal 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 |
1
icons/ingredients/bulka_grahamka.svg
Normal 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 |
1
icons/ingredients/burrata.svg
Normal 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 |
1
icons/ingredients/chrzan.svg
Normal 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 |
1
icons/ingredients/cukinia.svg
Normal 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 |
1
icons/ingredients/cynamon.svg
Normal 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 |
1
icons/ingredients/czekolada.svg
Normal 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 |
1
icons/ingredients/czosnek.svg
Normal 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 |
1
icons/ingredients/ekstrakt_waniliowy.svg
Normal 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 |
1
icons/ingredients/erytrol.svg
Normal 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 |
1
icons/ingredients/hummus.svg
Normal 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 |
1
icons/ingredients/imbir.svg
Normal 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 |
1
icons/ingredients/jagody.svg
Normal 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 |
1
icons/ingredients/jajko.svg
Normal 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 |
1
icons/ingredients/kapusta_biala.svg
Normal 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 |
1
icons/ingredients/kielki_rzodkiewki.svg
Normal 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 |
1
icons/ingredients/kolendra_swieza.svg
Normal 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 |
1
icons/ingredients/koper_swiezy.svg
Normal 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 |
1
icons/ingredients/limonka.svg
Normal 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 |
1
icons/ingredients/losos_wedzony.svg
Normal 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 |
1
icons/ingredients/majeranek.svg
Normal 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 |
1
icons/ingredients/maka_pszenna.svg
Normal 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 |
1
icons/ingredients/makaron_suchy.svg
Normal 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 |
1
icons/ingredients/marchewka.svg
Normal 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 |
1
icons/ingredients/maslo_orzechowe.svg
Normal 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 |
1
icons/ingredients/migdaly.svg
Normal 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 |
1
icons/ingredients/miod.svg
Normal 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 |
1
icons/ingredients/mleko.svg
Normal 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 |
1
icons/ingredients/mozzarella.svg
Normal 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 |
1
icons/ingredients/nasiona_slonecznika.svg
Normal 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 |
1
icons/ingredients/natka_pietruszki.svg
Normal 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 |
1
icons/ingredients/ogorek.svg
Normal 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 |
1
icons/ingredients/olej_rzepakowy.svg
Normal 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 |
1
icons/ingredients/oliwa.svg
Normal 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 |
1
icons/ingredients/orzechy_laskowe.svg
Normal 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 |
1
icons/ingredients/orzechy_nerkowca.svg
Normal 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 |
1
icons/ingredients/orzechy_pekan.svg
Normal 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 |
1
icons/ingredients/orzechy_wloskie.svg
Normal 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 |
1
icons/ingredients/papryczka_chilli.svg
Normal 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 |
1
icons/ingredients/papryka_czerwona.svg
Normal 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 |
1
icons/ingredients/piers_kurczaka.svg
Normal 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 |
1
icons/ingredients/pietruszka_korzen.svg
Normal 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 |
1
icons/ingredients/platki_owsiane.svg
Normal 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 |
1
icons/ingredients/pomidor.svg
Normal 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 |
1
icons/ingredients/pomidorki_koktajlowe.svg
Normal 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 |
1
icons/ingredients/ricotta.svg
Normal 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 |
1
icons/ingredients/rokitnik_zwyczajny.svg
Normal 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 |
1
icons/ingredients/seler.svg
Normal 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 |
1
icons/ingredients/serek_smietankowy.svg
Normal 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 |
1
icons/ingredients/serek_wiejski.svg
Normal 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 |
1
icons/ingredients/sezam.svg
Normal 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 |
1
icons/ingredients/skyr.svg
Normal 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 |
1
icons/ingredients/sos_sojowy.svg
Normal 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 |
1
icons/ingredients/syrop_z_agawy.svg
Normal 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 |
1
icons/ingredients/szczypiorek.svg
Normal 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 |
1
icons/ingredients/szynka_parmenska.svg
Normal 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 |
1
icons/ingredients/szynka_z_kurczaka.svg
Normal 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 |
1
icons/ingredients/truskawki.svg
Normal 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 |
1
icons/ingredients/tymianek.svg
Normal 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 |
1
icons/ingredients/ziemniaki.svg
Normal 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 |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 48 KiB |
BIN
images/recipes/ciasteczka_owsiane.jpg
Normal file
|
After Width: | Height: | Size: 249 KiB |
BIN
images/recipes/dutch_baby.jpg
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
images/recipes/placki_kapusta_losos.jpg
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
images/recipes/zupa_jarzynowa.jpg
Normal file
|
After Width: | Height: | Size: 339 KiB |
999
index.html
20
js/app.js
@@ -13,6 +13,9 @@ let setupMealPlanner;
|
|||||||
let getPantryHTML;
|
let getPantryHTML;
|
||||||
let refreshPantry;
|
let refreshPantry;
|
||||||
let setupPantry;
|
let setupPantry;
|
||||||
|
let getShoppingListHTML;
|
||||||
|
let refreshShoppingList;
|
||||||
|
let setupShoppingList;
|
||||||
let getMealPlanEditorHTML;
|
let getMealPlanEditorHTML;
|
||||||
let setupMealPlanEditor;
|
let setupMealPlanEditor;
|
||||||
let getBottomNavHTML;
|
let getBottomNavHTML;
|
||||||
@@ -24,6 +27,7 @@ const moduleLoadPromise = Promise.all([
|
|||||||
import(`./views/RecipeDetailV2.js?v=${APP_ASSET_VERSION}`),
|
import(`./views/RecipeDetailV2.js?v=${APP_ASSET_VERSION}`),
|
||||||
import(`./views/MealPlanner.js?v=${APP_ASSET_VERSION}`),
|
import(`./views/MealPlanner.js?v=${APP_ASSET_VERSION}`),
|
||||||
import(`./views/Pantry.js?v=${APP_ASSET_VERSION}`),
|
import(`./views/Pantry.js?v=${APP_ASSET_VERSION}`),
|
||||||
|
import(`./views/ShoppingList.js?v=${APP_ASSET_VERSION}`),
|
||||||
import(`./ui/mealPlanEditor.js?v=${APP_ASSET_VERSION}`),
|
import(`./ui/mealPlanEditor.js?v=${APP_ASSET_VERSION}`),
|
||||||
import(`./ui/bottomNav.js?v=${APP_ASSET_VERSION}`),
|
import(`./ui/bottomNav.js?v=${APP_ASSET_VERSION}`),
|
||||||
]).then(([
|
]).then(([
|
||||||
@@ -32,6 +36,7 @@ const moduleLoadPromise = Promise.all([
|
|||||||
recipeDetailModule,
|
recipeDetailModule,
|
||||||
mealPlannerModule,
|
mealPlannerModule,
|
||||||
pantryModule,
|
pantryModule,
|
||||||
|
shoppingListModule,
|
||||||
mealPlanEditorModule,
|
mealPlanEditorModule,
|
||||||
bottomNavModule,
|
bottomNavModule,
|
||||||
]) => {
|
]) => {
|
||||||
@@ -40,6 +45,7 @@ const moduleLoadPromise = Promise.all([
|
|||||||
({ getRecipeDetailHTML, setupRecipeDetail } = recipeDetailModule);
|
({ getRecipeDetailHTML, setupRecipeDetail } = recipeDetailModule);
|
||||||
({ getMealPlannerHTML, setupMealPlanner } = mealPlannerModule);
|
({ getMealPlannerHTML, setupMealPlanner } = mealPlannerModule);
|
||||||
({ getPantryHTML, refreshPantry, setupPantry } = pantryModule);
|
({ getPantryHTML, refreshPantry, setupPantry } = pantryModule);
|
||||||
|
({ getShoppingListHTML, refreshShoppingList, setupShoppingList } = shoppingListModule);
|
||||||
({ getMealPlanEditorHTML, setupMealPlanEditor } = mealPlanEditorModule);
|
({ getMealPlanEditorHTML, setupMealPlanEditor } = mealPlanEditorModule);
|
||||||
({ getBottomNavHTML, setupBottomNav } = bottomNavModule);
|
({ getBottomNavHTML, setupBottomNav } = bottomNavModule);
|
||||||
});
|
});
|
||||||
@@ -59,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>
|
||||||
`;
|
`;
|
||||||
@@ -82,6 +88,7 @@ async function initApp() {
|
|||||||
${getRecipeListHTML()}
|
${getRecipeListHTML()}
|
||||||
${getMealPlannerHTML()}
|
${getMealPlannerHTML()}
|
||||||
${getPantryHTML()}
|
${getPantryHTML()}
|
||||||
|
${getShoppingListHTML()}
|
||||||
${getBottomNavHTML()}
|
${getBottomNavHTML()}
|
||||||
${getRecipeDetailHTML()}
|
${getRecipeDetailHTML()}
|
||||||
${getFilterHTML()}
|
${getFilterHTML()}
|
||||||
@@ -89,10 +96,11 @@ async function initApp() {
|
|||||||
${getAppToastHTML()}
|
${getAppToastHTML()}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
setupBottomNav({ refreshPantry });
|
setupBottomNav({ refreshPantry, refreshShoppingList });
|
||||||
setupRecipeList();
|
setupRecipeList();
|
||||||
setupMealPlanner();
|
setupMealPlanner();
|
||||||
setupPantry();
|
setupPantry();
|
||||||
|
setupShoppingList();
|
||||||
setupFilter();
|
setupFilter();
|
||||||
setupMealPlanEditor();
|
setupMealPlanEditor();
|
||||||
setupRecipeDetail();
|
setupRecipeDetail();
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -85,6 +84,22 @@ export const INGREDIENTS = {
|
|||||||
purchasePack: { amount: 150, label: 'opakowanie 150 g' },
|
purchasePack: { amount: 150, label: 'opakowanie 150 g' },
|
||||||
nutritionPer100g: { kcal: 230, protein: 6, fat: 21, carbs: 4 },
|
nutritionPer100g: { kcal: 230, protein: 6, fat: 21, carbs: 4 },
|
||||||
},
|
},
|
||||||
|
mleko: {
|
||||||
|
id: 'mleko',
|
||||||
|
name: 'Mleko',
|
||||||
|
category: 'nabial',
|
||||||
|
pantryUnit: 'ml',
|
||||||
|
purchasePack: { amount: 1000, label: 'karton 1 l' },
|
||||||
|
nutritionPer100g: { kcal: 62, protein: 3.2, fat: 3.5, carbs: 4.7 },
|
||||||
|
},
|
||||||
|
skyr: {
|
||||||
|
id: 'skyr',
|
||||||
|
name: 'Skyr naturalny',
|
||||||
|
category: 'nabial',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
purchasePack: { amount: 150, label: 'opakowanie 150 g' },
|
||||||
|
nutritionPer100g: { kcal: 63, protein: 11, fat: 0.2, carbs: 4 },
|
||||||
|
},
|
||||||
/* ── Mięso i ryby ─────────────────────────────────── */
|
/* ── Mięso i ryby ─────────────────────────────────── */
|
||||||
szynka_parmenska: {
|
szynka_parmenska: {
|
||||||
id: 'szynka_parmenska',
|
id: 'szynka_parmenska',
|
||||||
@@ -104,17 +119,23 @@ 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',
|
||||||
purchasePack: { amount: 100, label: 'opakowanie 100 g' },
|
purchasePack: { amount: 100, label: 'opakowanie 100 g' },
|
||||||
nutritionPer100g: { kcal: 150, protein: 20, fat: 7, carbs: 0 },
|
nutritionPer100g: { kcal: 150, protein: 20, fat: 7, carbs: 0 },
|
||||||
},
|
},
|
||||||
|
piers_kurczaka: {
|
||||||
|
id: 'piers_kurczaka',
|
||||||
|
name: 'Pierś z kurczaka',
|
||||||
|
category: 'mieso_ryby',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
purchasePack: { amount: 500, label: 'opakowanie ~500 g' },
|
||||||
|
nutritionPer100g: { kcal: 110, protein: 23, fat: 1.5, carbs: 0 },
|
||||||
|
},
|
||||||
/* ── Warzywa ──────────────────────────────────────── */
|
/* ── Warzywa ──────────────────────────────────────── */
|
||||||
pomidor: {
|
pomidor: {
|
||||||
id: 'pomidor',
|
id: 'pomidor',
|
||||||
image: 'images/ingredients/pomidor.jpg',
|
|
||||||
name: 'Pomidor',
|
name: 'Pomidor',
|
||||||
category: 'warzywa',
|
category: 'warzywa',
|
||||||
pantryUnit: 'szt',
|
pantryUnit: 'szt',
|
||||||
@@ -123,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',
|
||||||
@@ -132,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',
|
||||||
@@ -141,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',
|
||||||
@@ -150,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',
|
||||||
@@ -164,10 +181,71 @@ export const INGREDIENTS = {
|
|||||||
pantryUnit: 'g',
|
pantryUnit: 'g',
|
||||||
nutritionPer100g: { kcal: 28, protein: 3, fat: 0.6, carbs: 3.5 },
|
nutritionPer100g: { kcal: 28, protein: 3, fat: 0.6, carbs: 3.5 },
|
||||||
},
|
},
|
||||||
|
kapusta_biala: {
|
||||||
|
id: 'kapusta_biala',
|
||||||
|
name: 'Kapusta biała',
|
||||||
|
category: 'warzywa',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
purchasePack: { amount: 800, label: 'główka ~800 g' },
|
||||||
|
nutritionPer100g: { kcal: 25, protein: 1.3, fat: 0.1, carbs: 6 },
|
||||||
|
},
|
||||||
|
marchewka: {
|
||||||
|
id: 'marchewka',
|
||||||
|
name: 'Marchewka',
|
||||||
|
category: 'warzywa',
|
||||||
|
pantryUnit: 'szt',
|
||||||
|
weightPerPiece: 80,
|
||||||
|
nutritionPer100g: { kcal: 41, protein: 0.9, fat: 0.2, carbs: 10 },
|
||||||
|
},
|
||||||
|
papryczka_chilli: {
|
||||||
|
id: 'papryczka_chilli',
|
||||||
|
name: 'Papryczka chilli',
|
||||||
|
category: 'warzywa',
|
||||||
|
pantryUnit: 'szt',
|
||||||
|
weightPerPiece: 15,
|
||||||
|
nutritionPer100g: { kcal: 40, protein: 2, fat: 0.4, carbs: 9 },
|
||||||
|
},
|
||||||
|
pietruszka_korzen: {
|
||||||
|
id: 'pietruszka_korzen',
|
||||||
|
name: 'Pietruszka (korzeń)',
|
||||||
|
category: 'warzywa',
|
||||||
|
pantryUnit: 'szt',
|
||||||
|
weightPerPiece: 80,
|
||||||
|
nutritionPer100g: { kcal: 54, protein: 2.9, fat: 0.6, carbs: 10 },
|
||||||
|
},
|
||||||
|
seler: {
|
||||||
|
id: 'seler',
|
||||||
|
name: 'Seler korzeniowy',
|
||||||
|
category: 'warzywa',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
purchasePack: { amount: 500, label: 'bulwa ~500 g' },
|
||||||
|
nutritionPer100g: { kcal: 42, protein: 1.5, fat: 0.3, carbs: 9 },
|
||||||
|
},
|
||||||
|
cukinia: {
|
||||||
|
id: 'cukinia',
|
||||||
|
name: 'Cukinia',
|
||||||
|
category: 'warzywa',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
purchasePack: { amount: 300, label: 'sztuka ~300 g' },
|
||||||
|
nutritionPer100g: { kcal: 17, protein: 1.2, fat: 0.3, carbs: 3.1 },
|
||||||
|
},
|
||||||
|
ziemniaki: {
|
||||||
|
id: 'ziemniaki',
|
||||||
|
name: 'Ziemniaki',
|
||||||
|
category: 'warzywa',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 77, protein: 2, fat: 0.1, carbs: 17 },
|
||||||
|
},
|
||||||
|
imbir: {
|
||||||
|
id: 'imbir',
|
||||||
|
name: 'Imbir',
|
||||||
|
category: 'warzywa',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 80, protein: 1.8, fat: 0.8, carbs: 18 },
|
||||||
|
},
|
||||||
/* ── Owoce ────────────────────────────────────────── */
|
/* ── Owoce ────────────────────────────────────────── */
|
||||||
truskawki: {
|
truskawki: {
|
||||||
id: 'truskawki',
|
id: 'truskawki',
|
||||||
image: 'images/ingredients/truskawki.jpg',
|
|
||||||
name: 'Truskawki',
|
name: 'Truskawki',
|
||||||
category: 'owoce',
|
category: 'owoce',
|
||||||
pantryUnit: 'g',
|
pantryUnit: 'g',
|
||||||
@@ -175,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',
|
||||||
@@ -183,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',
|
||||||
@@ -191,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',
|
||||||
@@ -204,10 +279,17 @@ export const INGREDIENTS = {
|
|||||||
pantryUnit: 'g',
|
pantryUnit: 'g',
|
||||||
nutritionPer100g: { kcal: 82, protein: 1.2, fat: 5.4, carbs: 5.5 },
|
nutritionPer100g: { kcal: 82, protein: 1.2, fat: 5.4, carbs: 5.5 },
|
||||||
},
|
},
|
||||||
|
limonka: {
|
||||||
|
id: 'limonka',
|
||||||
|
name: 'Limonka',
|
||||||
|
category: 'owoce',
|
||||||
|
pantryUnit: 'szt',
|
||||||
|
weightPerPiece: 60,
|
||||||
|
nutritionPer100g: { kcal: 30, protein: 0.7, fat: 0.2, carbs: 11 },
|
||||||
|
},
|
||||||
/* ── Suche i kasze ────────────────────────────────── */
|
/* ── 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',
|
||||||
@@ -243,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',
|
||||||
@@ -256,6 +337,30 @@ export const INGREDIENTS = {
|
|||||||
pantryUnit: 'g',
|
pantryUnit: 'g',
|
||||||
nutritionPer100g: { kcal: 691, protein: 9, fat: 72, carbs: 14 },
|
nutritionPer100g: { kcal: 691, protein: 9, fat: 72, carbs: 14 },
|
||||||
},
|
},
|
||||||
|
maka_pszenna: {
|
||||||
|
id: 'maka_pszenna',
|
||||||
|
name: 'Mąka pszenna',
|
||||||
|
category: 'suche',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
purchasePack: { amount: 1000, label: 'torebka 1 kg' },
|
||||||
|
nutritionPer100g: { kcal: 364, protein: 10, fat: 1, carbs: 76 },
|
||||||
|
},
|
||||||
|
sezam: {
|
||||||
|
id: 'sezam',
|
||||||
|
name: 'Sezam',
|
||||||
|
category: 'suche',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
purchasePack: { amount: 100, label: 'opakowanie 100 g' },
|
||||||
|
nutritionPer100g: { kcal: 573, protein: 18, fat: 50, carbs: 23 },
|
||||||
|
},
|
||||||
|
platki_owsiane: {
|
||||||
|
id: 'platki_owsiane',
|
||||||
|
name: 'Płatki owsiane',
|
||||||
|
category: 'suche',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
purchasePack: { amount: 500, label: 'paczka 500 g' },
|
||||||
|
nutritionPer100g: { kcal: 379, protein: 13, fat: 7, carbs: 68 },
|
||||||
|
},
|
||||||
/* ── Przyprawy i zioła ────────────────────────────── */
|
/* ── Przyprawy i zioła ────────────────────────────── */
|
||||||
bazylia_swieza: {
|
bazylia_swieza: {
|
||||||
id: 'bazylia_swieza',
|
id: 'bazylia_swieza',
|
||||||
@@ -292,6 +397,34 @@ export const INGREDIENTS = {
|
|||||||
pantryUnit: 'g',
|
pantryUnit: 'g',
|
||||||
nutritionPer100g: { kcal: 44, protein: 1, fat: 0.5, carbs: 8 },
|
nutritionPer100g: { kcal: 44, protein: 1, fat: 0.5, carbs: 8 },
|
||||||
},
|
},
|
||||||
|
kolendra_swieza: {
|
||||||
|
id: 'kolendra_swieza',
|
||||||
|
name: 'Kolendra świeża',
|
||||||
|
category: 'przyprawy',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 23, protein: 2.1, fat: 0.5, carbs: 3.7 },
|
||||||
|
},
|
||||||
|
natka_pietruszki: {
|
||||||
|
id: 'natka_pietruszki',
|
||||||
|
name: 'Natka pietruszki',
|
||||||
|
category: 'przyprawy',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 36, protein: 3, fat: 0.8, carbs: 6.3 },
|
||||||
|
},
|
||||||
|
majeranek: {
|
||||||
|
id: 'majeranek',
|
||||||
|
name: 'Majeranek suszony',
|
||||||
|
category: 'przyprawy',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 271, protein: 12.7, fat: 7, carbs: 60 },
|
||||||
|
},
|
||||||
|
cynamon: {
|
||||||
|
id: 'cynamon',
|
||||||
|
name: 'Cynamon',
|
||||||
|
category: 'przyprawy',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 247, protein: 4, fat: 1.2, carbs: 81 },
|
||||||
|
},
|
||||||
/* ── Inne ─────────────────────────────────────────── */
|
/* ── Inne ─────────────────────────────────────────── */
|
||||||
miod: {
|
miod: {
|
||||||
id: 'miod',
|
id: 'miod',
|
||||||
@@ -302,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',
|
||||||
@@ -310,15 +442,81 @@ 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',
|
||||||
purchasePack: { amount: 200, label: 'opakowanie 200 g' },
|
purchasePack: { amount: 200, label: 'opakowanie 200 g' },
|
||||||
nutritionPer100g: { kcal: 166, protein: 8, fat: 10, carbs: 14 },
|
nutritionPer100g: { kcal: 166, protein: 8, fat: 10, carbs: 14 },
|
||||||
},
|
},
|
||||||
|
olej_rzepakowy: {
|
||||||
|
id: 'olej_rzepakowy',
|
||||||
|
name: 'Olej rzepakowy',
|
||||||
|
category: 'inne',
|
||||||
|
pantryUnit: 'ml',
|
||||||
|
purchasePack: { amount: 1000, label: 'butelka 1 l' },
|
||||||
|
nutritionPer100g: { kcal: 884, protein: 0, fat: 100, carbs: 0 },
|
||||||
|
},
|
||||||
|
erytrol: {
|
||||||
|
id: 'erytrol',
|
||||||
|
name: 'Erytrol',
|
||||||
|
category: 'inne',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
purchasePack: { amount: 500, label: 'opakowanie 500 g' },
|
||||||
|
nutritionPer100g: { kcal: 0, protein: 0, fat: 0, carbs: 0 },
|
||||||
|
},
|
||||||
|
ekstrakt_waniliowy: {
|
||||||
|
id: 'ekstrakt_waniliowy',
|
||||||
|
name: 'Ekstrakt waniliowy',
|
||||||
|
category: 'inne',
|
||||||
|
pantryUnit: 'ml',
|
||||||
|
purchasePack: { amount: 30, label: 'buteleczka 30 ml' },
|
||||||
|
nutritionPer100g: { kcal: 288, protein: 0.1, fat: 0.1, carbs: 12.7 },
|
||||||
|
},
|
||||||
|
maslo_orzechowe: {
|
||||||
|
id: 'maslo_orzechowe',
|
||||||
|
name: 'Masło orzechowe',
|
||||||
|
category: 'inne',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
purchasePack: { amount: 350, label: 'słoik 350 g' },
|
||||||
|
nutritionPer100g: { kcal: 594, protein: 25, fat: 50, carbs: 20 },
|
||||||
|
},
|
||||||
|
sos_sojowy: {
|
||||||
|
id: 'sos_sojowy',
|
||||||
|
name: 'Sos sojowy',
|
||||||
|
category: 'inne',
|
||||||
|
pantryUnit: 'ml',
|
||||||
|
purchasePack: { amount: 150, label: 'butelka 150 ml' },
|
||||||
|
nutritionPer100g: { kcal: 53, protein: 8, fat: 0, carbs: 5 },
|
||||||
|
},
|
||||||
|
syrop_z_agawy: {
|
||||||
|
id: 'syrop_z_agawy',
|
||||||
|
name: 'Syrop z agawy',
|
||||||
|
category: 'inne',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
purchasePack: { amount: 350, label: 'butelka 350 g' },
|
||||||
|
nutritionPer100g: { kcal: 310, protein: 0, fat: 0, carbs: 76 },
|
||||||
|
},
|
||||||
|
czekolada: {
|
||||||
|
id: 'czekolada',
|
||||||
|
name: 'Czekolada gorzka',
|
||||||
|
category: 'inne',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
purchasePack: { amount: 100, label: 'tabliczka 100 g' },
|
||||||
|
nutritionPer100g: { kcal: 546, protein: 8, fat: 31, carbs: 61 },
|
||||||
|
},
|
||||||
|
bulion_warzywny: {
|
||||||
|
id: 'bulion_warzywny',
|
||||||
|
name: 'Bulion warzywny',
|
||||||
|
category: 'inne',
|
||||||
|
pantryUnit: 'ml',
|
||||||
|
nutritionPer100g: { kcal: 6, protein: 0.5, fat: 0.1, carbs: 0.5 },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
for (const [id, def] of Object.entries(INGREDIENTS)) {
|
||||||
|
def.image = `icons/ingredients/${id}.svg`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {{ ingredientId: string, amount: number, unit: string, alternatives?: string[] }} RecipeIngredientDef
|
* @typedef {{ 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
|
||||||
@@ -470,6 +668,129 @@ export const RECIPES = {
|
|||||||
'Umyj owoce (ew. pokrój na połówki) i ułóż na wierzchu. Gotowe!',
|
'Umyj owoce (ew. pokrój na połówki) i ułóż na wierzchu. Gotowe!',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
dutch_baby: {
|
||||||
|
id: 'dutch_baby',
|
||||||
|
title: 'Dutch baby z owocami',
|
||||||
|
minutes: 45,
|
||||||
|
thumbLabel: 'Dutch baby',
|
||||||
|
image: 'images/recipes/dutch_baby.jpg',
|
||||||
|
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
||||||
|
tags: ['wegetariańskie', 'wysokobiałkowe'],
|
||||||
|
nutritionPerServing: { kcal: 910, protein: 57, fat: 37, carbs: 88 },
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: 'maka_pszenna', amount: 60, unit: 'g' },
|
||||||
|
{ ingredientId: 'mleko', amount: 125, unit: 'ml' },
|
||||||
|
{ ingredientId: 'jajko', amount: 2, unit: 'szt.' },
|
||||||
|
{ ingredientId: 'erytrol', amount: 15, unit: 'g' },
|
||||||
|
{ ingredientId: 'olej_rzepakowy', amount: 10, unit: 'ml' },
|
||||||
|
{ ingredientId: 'ekstrakt_waniliowy', amount: 1.5, unit: 'ml' },
|
||||||
|
{ ingredientId: 'skyr', amount: 225, unit: 'g' },
|
||||||
|
{ ingredientId: 'maslo_orzechowe', amount: 20, unit: 'g' },
|
||||||
|
{ ingredientId: 'borowki_amerykanskie', amount: 100, unit: 'g', alternatives: ['jagody'] },
|
||||||
|
{ ingredientId: 'truskawki', amount: 100, unit: 'g' },
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
'Nagrzej piekarnik do 210°C wraz z patelnią żeliwną w środku.',
|
||||||
|
'Zmiksuj jajka, mąkę, mleko, ekstrakt waniliowy, 10 g erytrolu i szczyptę soli. Odstaw ciasto na 20 minut.',
|
||||||
|
'Wyjmij nagrzaną patelnię, rozprowadź olej rzepakowy i wlej ciasto.',
|
||||||
|
'Piecz do zbrązowienia brzegów (ok. 10 minut). Ostudź.',
|
||||||
|
'Wymieszaj skyr z pozostałym erytrolem (5 g) i masłem orzechowym. Nałóż na naleśnika.',
|
||||||
|
'Ułóż na wierzchu umyte borówki i truskawki.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
placki_kapusta_losos: {
|
||||||
|
id: 'placki_kapusta_losos',
|
||||||
|
title: 'Placki z białej kapusty z łososiem i słodkim sosem sojowym',
|
||||||
|
minutes: 35,
|
||||||
|
thumbLabel: 'Placki kapuściane',
|
||||||
|
image: 'images/recipes/placki_kapusta_losos.jpg',
|
||||||
|
allowedSlots: ['obiad', 'kolacja'],
|
||||||
|
nutritionPerServing: { kcal: 775, protein: 44, fat: 34, carbs: 80 },
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: 'kapusta_biala', amount: 300, unit: 'g' },
|
||||||
|
{ ingredientId: 'marchewka', amount: 0.5, unit: 'szt.' },
|
||||||
|
{ ingredientId: 'czosnek', amount: 6, unit: 'g' },
|
||||||
|
{ ingredientId: 'papryczka_chilli', amount: 0.5, unit: 'szt.' },
|
||||||
|
{ ingredientId: 'szczypiorek', amount: 15, unit: 'g' },
|
||||||
|
{ ingredientId: 'kolendra_swieza', amount: 4, unit: 'g' },
|
||||||
|
{ ingredientId: 'jajko', amount: 1, unit: 'szt.' },
|
||||||
|
{ ingredientId: 'maka_pszenna', amount: 36, unit: 'g' },
|
||||||
|
{ ingredientId: 'sezam', amount: 20, unit: 'g' },
|
||||||
|
{ ingredientId: 'olej_rzepakowy', amount: 10, unit: 'ml' },
|
||||||
|
{ ingredientId: 'sos_sojowy', amount: 40, unit: 'ml' },
|
||||||
|
{ ingredientId: 'miod', amount: 24, unit: 'g' },
|
||||||
|
{ ingredientId: 'imbir', amount: 3, unit: 'g' },
|
||||||
|
{ ingredientId: 'limonka', amount: 3, unit: 'g' },
|
||||||
|
{ ingredientId: 'losos_wedzony', amount: 100, unit: 'g' },
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
'Kapustę poszatkuj w cienkie paski i dodatkowo posiekaj nożem. Marchewkę zetrzyj na grubej tarce, czosnek na drobnej. Posiekaj szczypiorek i kolendrę, drobno pokrój chili.',
|
||||||
|
'W rondelku wymieszaj sos sojowy, miód, starty imbir i sok z limonki. Podgrzewaj 5 minut do uzyskania konsystencji syropu.',
|
||||||
|
'W misce połącz kapustę, marchewkę, czosnek, chili, 10 g szczypiorku, kolendrę, sól, pieprz, paprykę wędzoną i 10 g sezamu. Dodaj jajko i mąkę, dokładnie wymieszaj.',
|
||||||
|
'Rozgrzej olej rzepakowy na patelni. Nakładaj łyżką porcje ciasta, spłaszczając je w kształt placków. Smaż z każdej strony 3–4 minuty do zrumienienia.',
|
||||||
|
'Ułóż placki na talerzu, na każdym połóż plastry wędzonego łososia.',
|
||||||
|
'Polej słodkim sosem sojowym, posyp pozostałym szczypiorkiem (5 g) i sezamem (10 g).',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
zupa_jarzynowa: {
|
||||||
|
id: 'zupa_jarzynowa',
|
||||||
|
title: 'Lekkostrawna zupa jarzynowa',
|
||||||
|
minutes: 50,
|
||||||
|
thumbLabel: 'Zupa jarzynowa',
|
||||||
|
image: 'images/recipes/zupa_jarzynowa.jpg',
|
||||||
|
allowedSlots: ['obiad'],
|
||||||
|
tags: ['wysokobiałkowe'],
|
||||||
|
nutritionPerServing: { kcal: 657, protein: 50, fat: 15, carbs: 81 },
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: 'piers_kurczaka', amount: 150, unit: 'g' },
|
||||||
|
{ ingredientId: 'marchewka', amount: 150, unit: 'g' },
|
||||||
|
{ ingredientId: 'pietruszka_korzen', amount: 100, unit: 'g' },
|
||||||
|
{ ingredientId: 'seler', amount: 100, unit: 'g' },
|
||||||
|
{ ingredientId: 'cukinia', amount: 100, unit: 'g' },
|
||||||
|
{ ingredientId: 'ziemniaki', amount: 210, unit: 'g' },
|
||||||
|
{ ingredientId: 'bulion_warzywny', amount: 750, unit: 'ml' },
|
||||||
|
{ ingredientId: 'natka_pietruszki', amount: 10, unit: 'g' },
|
||||||
|
{ ingredientId: 'majeranek', amount: 4, unit: 'g' },
|
||||||
|
{ ingredientId: 'tymianek', amount: 3, unit: 'g' },
|
||||||
|
{ ingredientId: 'oliwa', amount: 10, unit: 'ml' },
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
'Oczyść warzywa. Marchewkę, pietruszkę i seler pokrój w kostkę, ziemniaki w mniejszą kostkę, cukinię w ćwierćplasterki, mięso w większe kostki.',
|
||||||
|
'Wlej bulion do garnka. Dodaj marchewkę, pietruszkę, seler, ziemniaki i pierś z kurczaka. Doprowadź do wrzenia, wrzuć liść laurowy i ziele angielskie.',
|
||||||
|
'Zmniejsz ogień i gotuj pod częściowym przykryciem 20 minut.',
|
||||||
|
'Dodaj cukinię, majeranek i tymianek. Gotuj kolejne 10 minut, aż warzywa będą miękkie.',
|
||||||
|
'Dopraw solą, białym pieprzem i lubczykiem. Wmieszaj posiekaną natkę pietruszki oraz oliwę.',
|
||||||
|
'Przykryj i odstaw na 5 minut przed podaniem.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ciasteczka_owsiane: {
|
||||||
|
id: 'ciasteczka_owsiane',
|
||||||
|
title: 'Ciasteczka owsiane z czekoladą',
|
||||||
|
minutes: 35,
|
||||||
|
thumbLabel: 'Ciasteczka owsiane',
|
||||||
|
image: 'images/recipes/ciasteczka_owsiane.jpg',
|
||||||
|
allowedSlots: ['drugie_sniadanie', 'przekaska'],
|
||||||
|
tags: ['wegetariańskie'],
|
||||||
|
nutritionPerServing: { kcal: 183, protein: 5, fat: 5, carbs: 30 },
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: 'banany', amount: 240, unit: 'g' },
|
||||||
|
{ ingredientId: 'maslo_orzechowe', amount: 30, unit: 'g' },
|
||||||
|
{ ingredientId: 'syrop_z_agawy', amount: 15, unit: 'g', alternatives: ['miod'] },
|
||||||
|
{ ingredientId: 'ekstrakt_waniliowy', amount: 9, unit: 'ml' },
|
||||||
|
{ ingredientId: 'cynamon', amount: 2, unit: 'g' },
|
||||||
|
{ ingredientId: 'platki_owsiane', amount: 220, unit: 'g' },
|
||||||
|
{ ingredientId: 'czekolada', amount: 30, unit: 'g' },
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
'Nagrzej piekarnik do 180°C (góra-dół). Wyłóż blachę papierem do pieczenia.',
|
||||||
|
'Obierz banany i rozgnieć je widelcem w misce na gładką papkę.',
|
||||||
|
'Dodaj masło orzechowe, syrop z agawy, ekstrakt waniliowy, cynamon i szczyptę soli. Wymieszaj przez ok. minutę.',
|
||||||
|
'Wsyp płatki owsiane i mieszaj aż do uzyskania gęstej, lepkiej masy.',
|
||||||
|
'Posiekaj czekoladę na drobne kawałki, wmieszaj do masy.',
|
||||||
|
'Formuj kulki wielkości orzecha włoskiego (ok. 8 sztuk) i układaj na blasze, lekko spłaszczając.',
|
||||||
|
'Piecz ok. 15 minut, aż brzegi się zrumienią. Studź ok. 10 minut przed zdjęciem z blachy.',
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ══════════════════════════════════════════════════════════════════════
|
/* ══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { INGREDIENTS, RECIPES, PRODUCTS, getProductsForIngredient } from '../data/catalog.js?v=8';
|
import { INGREDIENTS, RECIPES, PRODUCTS, getProductsForIngredient } from '../data/catalog.js?v=9';
|
||||||
import { MEAL_SLOTS } from '../planner/mealSlots.js';
|
import { MEAL_SLOTS } from '../planner/mealSlots.js';
|
||||||
import { addDays } from './dateUtils.js';
|
import { addDays } from './dateUtils.js';
|
||||||
import { getDayPlan } from './planStore.js?v=2';
|
import { dateKey, getDayPlan } from './planStore.js?v=2';
|
||||||
import { getPantryTotal } from './pantryShopping.js?v=2';
|
import { getPantryTotal } from './pantryShopping.js?v=2';
|
||||||
|
|
||||||
export function dayHasAnyMeal(plans, d) {
|
export function dayHasAnyMeal(plans, d) {
|
||||||
@@ -182,6 +182,71 @@ export function aggregateWeekIngredientNeed(plans, weekStart) {
|
|||||||
return mergeIngredientLines(all);
|
return mergeIngredientLines(all);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zapotrzebowanie składników od startDate przez numDays dni.
|
||||||
|
* Jak aggregateWeekIngredientNeed, ale z dowolnym zakresem i informacją
|
||||||
|
* w które dni dany składnik jest potrzebny.
|
||||||
|
* @param {Record<string, unknown>} plans
|
||||||
|
* @param {Date} startDate
|
||||||
|
* @param {number} numDays
|
||||||
|
* @returns {Array<{ingredientId: string, name: string, category: string, amount: number, unit: string, days: string[]}>}
|
||||||
|
*/
|
||||||
|
export function aggregateRangeIngredientNeed(plans, startDate, numDays) {
|
||||||
|
/** @type {Map<string, {ingredientId: string, name: string, category: string, amount: number, unit: string, days: Set<string>}>} */
|
||||||
|
const map = new Map();
|
||||||
|
for (let i = 0; i < numDays; i++) {
|
||||||
|
const day = addDays(startDate, i);
|
||||||
|
const dk = dateKey(day);
|
||||||
|
const dayPlan = getDayPlan(plans, day);
|
||||||
|
const lines = flattenDayIngredientLines(dayPlan);
|
||||||
|
for (const line of lines) {
|
||||||
|
const key = `${line.ingredientId}\t${line.unit}`;
|
||||||
|
const cur = map.get(key);
|
||||||
|
if (!cur) {
|
||||||
|
map.set(key, { ...line, days: new Set([dk]) });
|
||||||
|
} else {
|
||||||
|
cur.amount = Math.round((cur.amount + line.amount) * 10) / 10;
|
||||||
|
cur.days.add(dk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...map.values()]
|
||||||
|
.map((item) => ({ ...item, days: [...item.days].sort() }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const c = a.category.localeCompare(b.category);
|
||||||
|
return c !== 0 ? c : a.name.localeCompare(b.name, 'pl');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zapotrzebowanie składników dla konkretnych dni (tablica dateKey-ów).
|
||||||
|
* @param {Record<string, unknown>} plans
|
||||||
|
* @param {string[]} selectedDays — tablica dateKey-ów ('YYYY-MM-DD')
|
||||||
|
*/
|
||||||
|
export function aggregateSelectedDaysIngredientNeed(plans, selectedDays) {
|
||||||
|
if (!selectedDays || selectedDays.length === 0) return [];
|
||||||
|
const map = new Map();
|
||||||
|
for (const dk of selectedDays) {
|
||||||
|
const [y, m, d] = dk.split('-').map(Number);
|
||||||
|
const day = new Date(y, m - 1, d);
|
||||||
|
const dayPlan = getDayPlan(plans, day);
|
||||||
|
const lines = flattenDayIngredientLines(dayPlan);
|
||||||
|
for (const line of lines) {
|
||||||
|
const key = `${line.ingredientId}\t${line.unit}`;
|
||||||
|
const cur = map.get(key);
|
||||||
|
if (!cur) {
|
||||||
|
map.set(key, { ...line });
|
||||||
|
} else {
|
||||||
|
cur.amount = Math.round((cur.amount + line.amount) * 10) / 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => {
|
||||||
|
const c = a.category.localeCompare(b.category);
|
||||||
|
return c !== 0 ? c : a.name.localeCompare(b.name, 'pl');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Jedna grupa na porę dnia: nagłówek pory raz, potem bloki przepisów ze składnikami.
|
* Jedna grupa na porę dnia: nagłówek pory raz, potem bloki przepisów ze składnikami.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -1,50 +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">
|
<div class="nav-slot" style="position:relative;">
|
||||||
<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'}">
|
<button type="button" data-tab="shopping" id="nav-shopping" class="nav-tab" aria-label="Zakupy">
|
||||||
<i class="${isDark ? 'fas fa-sun' : 'fas fa-moon'}" aria-hidden="true"></i>
|
<i class="fas fa-cart-shopping" aria-hidden="true"></i>
|
||||||
|
<span class="nav-label">Zakupy</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,42 +34,207 @@ export function getBottomNavHTML() {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setupBottomNav({ refreshPantry } = {}) {
|
export function setupBottomNav({ refreshPantry, refreshShoppingList } = {}) {
|
||||||
const main = document.getElementById('main-view');
|
const main = document.getElementById('main-view');
|
||||||
const planner = document.getElementById('planner-view');
|
const planner = document.getElementById('planner-view');
|
||||||
const pantry = document.getElementById('pantry-view');
|
const pantry = document.getElementById('pantry-view');
|
||||||
|
const shopping = document.getElementById('shopping-view');
|
||||||
const nav = document.getElementById('app-bottom-nav');
|
const nav = document.getElementById('app-bottom-nav');
|
||||||
if (!main || !planner || !pantry || !nav) return;
|
if (!main || !planner || !pantry || !shopping || !nav) return;
|
||||||
|
|
||||||
|
const TABS = ['recipes', 'planner', 'pantry', 'shopping'];
|
||||||
|
const COLLAPSED_TABS = new Set();
|
||||||
|
const EXTRA_CONTROL_SLOTS = { recipes: 1, planner: 1, pantry: 1, shopping: 1 };
|
||||||
|
const DOUBLE_COMPACT_TABS = new Set();
|
||||||
|
const COLLAPSED_TAB_ICON = {
|
||||||
|
recipes: 'fas fa-book-open',
|
||||||
|
planner: 'far fa-calendar-alt',
|
||||||
|
pantry: 'fas fa-warehouse',
|
||||||
|
shopping: 'fas fa-cart-shopping',
|
||||||
|
};
|
||||||
|
const COLLAPSED_TAB_LABEL = { recipes: 'Otwórz menu', planner: 'Otwórz menu', pantry: 'Otwórz menu', shopping: 'Otwórz menu' };
|
||||||
|
let isRecipeMenuOpen = false;
|
||||||
|
let activeTab = 'planner';
|
||||||
|
let previousTab = 'planner';
|
||||||
|
let collapseTimer = null;
|
||||||
|
|
||||||
|
const syncRecipeNavMetrics = () => {
|
||||||
|
const rootFontSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16;
|
||||||
|
const navStyles = window.getComputedStyle(nav);
|
||||||
|
const navPadLeft = parseFloat(navStyles.paddingLeft) || 0;
|
||||||
|
const navPadRight = parseFloat(navStyles.paddingRight) || 0;
|
||||||
|
const navContentWidth = nav.clientWidth - navPadLeft - navPadRight;
|
||||||
|
const isCompact = window.matchMedia('(max-width: 380px)').matches;
|
||||||
|
const dockInset = (isCompact ? 1.6 : 2.4) * rootFontSize;
|
||||||
|
const dockMax = (isCompact ? 22.5 : 24.5) * rootFontSize;
|
||||||
|
const dockWidth = Math.min(navContentWidth - dockInset, dockMax);
|
||||||
|
if (dockWidth <= 0) return;
|
||||||
|
|
||||||
|
const padLeft = (isCompact ? 0.38 : 0.42) * rootFontSize;
|
||||||
|
const padRight = (isCompact ? 0.38 : 0.42) * rootFontSize;
|
||||||
|
const columnGap = (isCompact ? 0.05 : 0.06) * rootFontSize;
|
||||||
|
const dockLeft = navPadLeft + ((navContentWidth - dockWidth) / 2);
|
||||||
|
const controlSize = (isCompact ? 2.95 : 3.05) * rootFontSize;
|
||||||
|
const bottomControlSize = controlSize * 1.28;
|
||||||
|
const controlGap = 0.5 * rootFontSize;
|
||||||
|
const inlineSearchControlSize = bottomControlSize * 0.8;
|
||||||
|
const controlsLift = 0;
|
||||||
|
const collapsedDockWidth = bottomControlSize;
|
||||||
|
const collapsedSlotWidth = Math.max(32, collapsedDockWidth - padLeft - padRight);
|
||||||
|
const inlineCollapsedSlotWidth = Math.max(28, inlineSearchControlSize - padLeft - padRight);
|
||||||
|
const controlCluster = bottomControlSize + controlGap;
|
||||||
|
const compactExtraWidth = (EXTRA_CONTROL_SLOTS[activeTab] || 0) * controlCluster;
|
||||||
|
const toolbarButtonLeft = dockLeft + dockWidth - bottomControlSize;
|
||||||
|
const searchRight = Math.max(16, nav.clientWidth - toolbarButtonLeft + controlGap);
|
||||||
|
const inlineSearchCloseLeft = dockLeft + dockWidth - inlineSearchControlSize;
|
||||||
|
const inlineSearchFieldLeft = dockLeft + inlineSearchControlSize + controlGap;
|
||||||
|
const inlineSearchFieldRight = Math.max(16, nav.clientWidth - inlineSearchCloseLeft + controlGap);
|
||||||
|
|
||||||
|
nav.style.setProperty('--recipe-dock-width', `${dockWidth}px`);
|
||||||
|
nav.style.setProperty('--recipe-collapsed-dock-width', `${collapsedDockWidth}px`);
|
||||||
|
nav.style.setProperty('--recipe-toggle-size', `${collapsedSlotWidth}px`);
|
||||||
|
nav.style.setProperty('--recipe-inline-search-control-size', `${inlineSearchControlSize}px`);
|
||||||
|
nav.style.setProperty('--recipe-inline-toggle-size', `${inlineCollapsedSlotWidth}px`);
|
||||||
|
nav.style.setProperty('--nav-compact-extra-width', `${compactExtraWidth}px`);
|
||||||
|
nav.style.setProperty('--nav-compact-translate-x', `${compactExtraWidth * -0.5}px`);
|
||||||
|
document.documentElement.style.setProperty('--recipe-dock-width', `${dockWidth}px`);
|
||||||
|
document.documentElement.style.setProperty('--catalog-menu-left', `${dockLeft}px`);
|
||||||
|
document.documentElement.style.setProperty('--catalog-menu-width', `${collapsedDockWidth}px`);
|
||||||
|
document.documentElement.style.setProperty('--catalog-filter-left', `${toolbarButtonLeft}px`);
|
||||||
|
document.documentElement.style.setProperty('--catalog-search-btn-left', `${toolbarButtonLeft}px`);
|
||||||
|
document.documentElement.style.setProperty('--catalog-search-right', `${searchRight}px`);
|
||||||
|
document.documentElement.style.setProperty('--catalog-inline-search-close-left', `${inlineSearchCloseLeft}px`);
|
||||||
|
document.documentElement.style.setProperty('--catalog-inline-search-field-left', `${inlineSearchFieldLeft}px`);
|
||||||
|
document.documentElement.style.setProperty('--catalog-inline-search-field-right', `${inlineSearchFieldRight}px`);
|
||||||
|
document.documentElement.style.setProperty('--recipe-control-size', `${controlSize}px`);
|
||||||
|
document.documentElement.style.setProperty('--recipe-bottom-control-size', `${bottomControlSize}px`);
|
||||||
|
document.documentElement.style.setProperty('--recipe-inline-search-control-size', `${inlineSearchControlSize}px`);
|
||||||
|
document.documentElement.style.setProperty('--recipe-controls-lift', `${controlsLift}px`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateToggleForTab = (tab) => {
|
||||||
|
const icon = document.getElementById('recipe-nav-toggle-icon');
|
||||||
|
const button = document.getElementById('recipe-nav-toggle');
|
||||||
|
const nextIconClass = COLLAPSED_TAB_ICON[tab];
|
||||||
|
if (icon && nextIconClass) {
|
||||||
|
icon.className = nextIconClass;
|
||||||
|
}
|
||||||
|
if (button && COLLAPSED_TAB_LABEL[tab]) {
|
||||||
|
button.setAttribute('aria-label', COLLAPSED_TAB_LABEL[tab]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRecipeMenuOpen = (open) => {
|
||||||
|
syncRecipeNavMetrics();
|
||||||
|
window.clearTimeout(collapseTimer);
|
||||||
|
isRecipeMenuOpen = open;
|
||||||
|
nav.classList.remove('is-nav-collapsing');
|
||||||
|
nav.classList.toggle('is-nav-menu-open', open);
|
||||||
|
document.documentElement.classList.toggle('is-nav-menu-open', open);
|
||||||
|
document.getElementById('recipe-nav-toggle')?.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
};
|
||||||
|
|
||||||
const apply = (tab) => {
|
const 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');
|
||||||
|
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();
|
||||||
|
|
||||||
nav.querySelectorAll('.nav-tab[data-tab]').forEach((btn) => {
|
nav.querySelectorAll('.nav-tab[data-tab]').forEach((btn) => {
|
||||||
const id = btn.getAttribute('data-tab');
|
const id = btn.getAttribute('data-tab');
|
||||||
if (btn.hasAttribute('disabled')) return;
|
if (btn.hasAttribute('disabled')) return;
|
||||||
if (id === 'recipes' || id === 'planner' || id === 'pantry') {
|
if (TABS.includes(id)) {
|
||||||
const isActive = id === tab;
|
const isActive = id === tab;
|
||||||
btn.classList.toggle('is-active', isActive);
|
btn.classList.toggle('is-active', isActive);
|
||||||
if (isActive) btn.setAttribute('aria-current', 'page');
|
if (isActive) btn.setAttribute('aria-current', 'page');
|
||||||
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 (tab === 'recipes' || tab === 'planner' || tab === 'pantry') 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
@@ -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
@@ -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}`;
|
||||||
|
}
|
||||||
789
js/ui/ingredientCard.js
Normal file
@@ -0,0 +1,789 @@
|
|||||||
|
import {
|
||||||
|
INGREDIENTS,
|
||||||
|
PRODUCTS,
|
||||||
|
CATEGORY_LABELS,
|
||||||
|
getProductsForIngredient,
|
||||||
|
ingredientHasProducts,
|
||||||
|
pantryQtyStep,
|
||||||
|
} from '../data/catalog.js?v=9';
|
||||||
|
import {
|
||||||
|
addOrMergeShoppingLines,
|
||||||
|
KITCHEN_LIST_ID,
|
||||||
|
loadPantry,
|
||||||
|
loadShoppingState,
|
||||||
|
removeItemFromList,
|
||||||
|
setPantryQty,
|
||||||
|
setPantryProductQty,
|
||||||
|
getPantryTotal,
|
||||||
|
getPantryProducts,
|
||||||
|
updateKitchenItemAmount,
|
||||||
|
} from '../services/pantryShopping.js?v=2';
|
||||||
|
import { showAppToast } from './toast.js';
|
||||||
|
import { ensureCalendarPopoverStyles } from './calendarPopover.js';
|
||||||
|
|
||||||
|
const CATEGORY_ICONS = {
|
||||||
|
pieczywo: 'fa-bread-slice',
|
||||||
|
nabial: 'fa-cheese',
|
||||||
|
mieso_ryby: 'fa-drumstick-bite',
|
||||||
|
warzywa: 'fa-carrot',
|
||||||
|
owoce: 'fa-apple-whole',
|
||||||
|
suche: 'fa-wheat-awn',
|
||||||
|
przyprawy: 'fa-leaf',
|
||||||
|
inne: 'fa-jar',
|
||||||
|
};
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitLabel(u) {
|
||||||
|
return u === 'szt' ? 'szt.' : u;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatQty(n) {
|
||||||
|
const rounded = Math.round((Number(n) || 0) * 10) / 10;
|
||||||
|
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1).replace(/\.0$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatQtyWithUnit(qty, unit) {
|
||||||
|
return `${formatQty(qty)} ${unitLabel(unit)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function productCountLabel(count) {
|
||||||
|
if (count === 1) return '1 produkt';
|
||||||
|
const mod10 = count % 10;
|
||||||
|
const mod100 = count % 100;
|
||||||
|
if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) return `${count} produkty`;
|
||||||
|
return `${count} produktów`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nutritionForQty(def, qty, nutrition = def?.nutritionPer100g) {
|
||||||
|
if (!def || !nutrition || !Number.isFinite(qty) || qty <= 0) return null;
|
||||||
|
let grams = qty;
|
||||||
|
if (def.pantryUnit === 'szt' && def.weightPerPiece) grams = qty * def.weightPerPiece;
|
||||||
|
const factor = grams / 100;
|
||||||
|
return {
|
||||||
|
kcal: Math.round(nutrition.kcal * factor),
|
||||||
|
protein: Math.round(nutrition.protein * factor * 10) / 10,
|
||||||
|
fat: Math.round(nutrition.fat * factor * 10) / 10,
|
||||||
|
carbs: Math.round(nutrition.carbs * factor * 10) / 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function macroLine(n) {
|
||||||
|
if (!n) return '';
|
||||||
|
return `${n.kcal} kcal · ${formatQty(n.protein)}g B · ${formatQty(n.fat)}g T · ${formatQty(n.carbs)}g W`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaHtml(image, icon, sizeClass = 'w-9 h-9', radiusClass = 'rounded-lg') {
|
||||||
|
if (image) {
|
||||||
|
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:transparent;"><i class="fas ${icon} text-sm" style="color:rgb(var(--text-faint-rgb));"></i></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactMetaText(text, tone = 'default') {
|
||||||
|
const color = tone === 'success' ? '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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortProductsByStock(products, pantryItems) {
|
||||||
|
return [...products].sort((a, b) => {
|
||||||
|
const aq = pantryItems.find((i) => i.productId === a.id)?.qty || 0;
|
||||||
|
const bq = pantryItems.find((i) => i.productId === b.id)?.qty || 0;
|
||||||
|
if (bq !== aq) return bq - aq;
|
||||||
|
return a.name.localeCompare(b.name, 'pl');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPackAwareAmount(amount, pantryUnit, packSize, packLabel) {
|
||||||
|
const qty = Number(amount) || 0;
|
||||||
|
const unit = unitLabel(pantryUnit);
|
||||||
|
if (packSize && packSize > 0) {
|
||||||
|
const packs = qty / packSize;
|
||||||
|
if (qty > 0 && Number.isFinite(packs) && Math.abs(packs - Math.round(packs)) < 0.001) {
|
||||||
|
return `${formatQty(Math.round(packs))} x ${packLabel || `${formatQty(packSize)} ${unit}`}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${formatQty(qty)} ${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQty(value) {
|
||||||
|
return Math.max(0, Math.round((Number(value) || 0) * 100) / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPreciseQty(n) {
|
||||||
|
const rounded = Math.round((Number(n) || 0) * 1000) / 1000;
|
||||||
|
if (Number.isInteger(rounded)) return String(rounded);
|
||||||
|
return rounded.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPackCount(amount, packSize) {
|
||||||
|
if (!Number.isFinite(Number(packSize)) || Number(packSize) <= 0) return '';
|
||||||
|
return `${formatPreciseQty((Number(amount) || 0) / Number(packSize))} opak.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQtyInput(value) {
|
||||||
|
const normalized = String(value ?? '').trim().replace(',', '.');
|
||||||
|
return normalizeQty(Number(normalized) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQtyStepMeta(def, product = null) {
|
||||||
|
const productPackSize = Number(product?.packSize);
|
||||||
|
if (Number.isFinite(productPackSize) && productPackSize > 0) {
|
||||||
|
return {
|
||||||
|
step: productPackSize,
|
||||||
|
usesPackStep: true,
|
||||||
|
stepLabel: product?.packLabel || formatQtyWithUnit(productPackSize, def.pantryUnit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ingredientPackSize = Number(def?.purchasePack?.amount);
|
||||||
|
if (Number.isFinite(ingredientPackSize) && ingredientPackSize > 0) {
|
||||||
|
return {
|
||||||
|
step: ingredientPackSize,
|
||||||
|
usesPackStep: true,
|
||||||
|
stepLabel: def.purchasePack?.label || formatQtyWithUnit(ingredientPackSize, def.pantryUnit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
step: pantryQtyStep(def.id),
|
||||||
|
usesPackStep: false,
|
||||||
|
stepLabel: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIngredientCardHTML({
|
||||||
|
idBase,
|
||||||
|
overlayClass = 'fixed inset-0 z-[70] hidden opacity-0 transition-opacity duration-200 flex items-center justify-center p-5',
|
||||||
|
overlayStyle = 'pointer-events:none;',
|
||||||
|
cardClass = 'calendar-liquid-panel relative w-full max-w-xs rounded-2xl overflow-hidden',
|
||||||
|
cardStyle = 'pointer-events:auto; max-height:85vh; overflow-y:auto; transform:translateY(0.75rem); opacity:0; transition:transform 220ms ease, opacity 220ms ease;',
|
||||||
|
} = {}) {
|
||||||
|
if (!idBase) throw new Error('getIngredientCardHTML requires idBase');
|
||||||
|
ensureCalendarPopoverStyles();
|
||||||
|
return `
|
||||||
|
<div id="${idBase}-overlay" class="${overlayClass}" style="${overlayStyle}">
|
||||||
|
<div id="${idBase}" class="${cardClass}" style="${cardStyle}">
|
||||||
|
<div class="relative px-4 pt-4 pb-2">
|
||||||
|
<div class="flex items-start gap-3 pr-10">
|
||||||
|
<div id="${idBase}-hero" class="relative w-20 h-20 rounded-2xl flex items-center justify-center shrink-0 overflow-hidden" style="background:transparent;">
|
||||||
|
<img id="${idBase}-img" class="w-full h-full hidden" alt="" />
|
||||||
|
<div id="${idBase}-fallback" class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<i id="${idBase}-fallback-icon" class="fas fa-box-open text-2xl" style="color:rgb(var(--text-subdued-rgb));"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0 pt-0.5">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<button type="button" id="${idBase}-back" class="calendar-liquid-btn hidden w-5 h-5 rounded-full items-center justify-center shrink-0" style="color:rgb(var(--text-body-rgb));" aria-label="Wróć do składnika">
|
||||||
|
<i class="fas fa-chevron-left text-[9px]"></i>
|
||||||
|
</button>
|
||||||
|
<p id="${idBase}-category" class="text-[10px] font-semibold uppercase tracking-wider truncate" style="color:rgb(var(--success-rgb));"></p>
|
||||||
|
</div>
|
||||||
|
<h3 id="${idBase}-name" class="text-[15px] font-bold leading-snug mt-0.5" style="color:rgb(var(--text-body-rgb));"></h3>
|
||||||
|
<p id="${idBase}-subtitle" class="text-[11px] mt-0.5 hidden" style="color:rgb(var(--text-dim-rgb));"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="${idBase}-close" class="calendar-liquid-btn absolute top-3 right-3 w-8 h-8 rounded-full flex items-center justify-center" style="color:rgb(var(--text-body-rgb));" aria-label="Zamknij">
|
||||||
|
<i class="fas fa-times text-sm"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 pt-2 pb-4 space-y-3">
|
||||||
|
<div id="${idBase}-nutrition"></div>
|
||||||
|
<div id="${idBase}-stock"></div>
|
||||||
|
<div id="${idBase}-products"></div>
|
||||||
|
<div id="${idBase}-shop"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze spiżarni' } = {}) {
|
||||||
|
if (!idBase) throw new Error('createIngredientCardController requires idBase');
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
ingredientId: null,
|
||||||
|
productId: null,
|
||||||
|
selectedProductId: null,
|
||||||
|
allowProductSelection: true,
|
||||||
|
sourceNote: defaultSourceNote,
|
||||||
|
onProductChange: null,
|
||||||
|
onAfterChange: null,
|
||||||
|
stockEditorOpen: false,
|
||||||
|
stockDraftQty: null,
|
||||||
|
shopEditorOpen: false,
|
||||||
|
shopDraftQty: null,
|
||||||
|
closeTimer: null,
|
||||||
|
};
|
||||||
|
let bound = false;
|
||||||
|
|
||||||
|
const el = (suffix = '') => document.getElementById(suffix ? `${idBase}-${suffix}` : idBase);
|
||||||
|
|
||||||
|
function resetInlineEditors() {
|
||||||
|
state.stockEditorOpen = false;
|
||||||
|
state.stockDraftQty = null;
|
||||||
|
state.shopEditorOpen = false;
|
||||||
|
state.shopDraftQty = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShoppingItemFor(def, product) {
|
||||||
|
const shopping = loadShoppingState();
|
||||||
|
const kitchen = shopping.lists.find((list) => list.id === KITCHEN_LIST_ID && list.type === 'kitchen');
|
||||||
|
if (!kitchen || kitchen.type !== 'kitchen') return null;
|
||||||
|
const unit = unitLabel(def.pantryUnit);
|
||||||
|
const targetProductId = product?.id || '';
|
||||||
|
return kitchen.items.find((item) => {
|
||||||
|
if (item.checked) return false;
|
||||||
|
return item.ingredientId === def.id
|
||||||
|
&& item.unit === unit
|
||||||
|
&& (item.productId || '') === targetProductId;
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeader(def, product, pantry) {
|
||||||
|
const hasProducts = ingredientHasProducts(def.id);
|
||||||
|
const isListMode = hasProducts && state.allowProductSelection && !state.productId;
|
||||||
|
const isBackAvailable = hasProducts && state.allowProductSelection && state.productId;
|
||||||
|
const icon = CATEGORY_ICONS[def.category] || 'fa-jar';
|
||||||
|
const heroEl = el('hero');
|
||||||
|
const img = el('img');
|
||||||
|
const fallback = el('fallback');
|
||||||
|
const fallbackIcon = el('fallback-icon');
|
||||||
|
|
||||||
|
if (img && fallback) {
|
||||||
|
const image = isListMode ? def.image : (product?.image || def.image);
|
||||||
|
const altName = isListMode ? def.name : (product?.name || def.name);
|
||||||
|
if (image) {
|
||||||
|
img.src = image;
|
||||||
|
img.alt = altName;
|
||||||
|
const isSvg = image.endsWith('.svg');
|
||||||
|
img.classList.toggle('object-contain', isSvg);
|
||||||
|
img.classList.toggle('object-cover', !isSvg);
|
||||||
|
img.style.padding = isSvg ? '4px' : '';
|
||||||
|
img.classList.remove('hidden');
|
||||||
|
fallback.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
img.classList.add('hidden');
|
||||||
|
fallback.classList.remove('hidden');
|
||||||
|
if (fallbackIcon) fallbackIcon.className = `fas ${icon} text-2xl`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryEl = el('category');
|
||||||
|
const nameEl = el('name');
|
||||||
|
const subtitleEl = el('subtitle');
|
||||||
|
const backBtn = el('back');
|
||||||
|
|
||||||
|
const displayProduct = isListMode ? null : product;
|
||||||
|
if (categoryEl) categoryEl.textContent = displayProduct?.brand || CATEGORY_LABELS[def.category] || def.category;
|
||||||
|
if (nameEl) nameEl.textContent = displayProduct?.name || def.name;
|
||||||
|
|
||||||
|
if (subtitleEl) {
|
||||||
|
let subtitle = '';
|
||||||
|
if (displayProduct) {
|
||||||
|
subtitle = [def.name, displayProduct.packLabel].filter(Boolean).join(' • ');
|
||||||
|
} else if (!hasProducts && def.purchasePack?.label) {
|
||||||
|
subtitle = def.purchasePack.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subtitle) {
|
||||||
|
subtitleEl.textContent = subtitle;
|
||||||
|
subtitleEl.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
subtitleEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backBtn) {
|
||||||
|
backBtn.classList.toggle('hidden', !isBackAvailable);
|
||||||
|
backBtn.classList.toggle('flex', Boolean(isBackAvailable));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNutrition(def, product) {
|
||||||
|
const wrap = el('nutrition');
|
||||||
|
if (!wrap) return;
|
||||||
|
const hasProducts = ingredientHasProducts(def.id);
|
||||||
|
const isListMode = hasProducts && state.allowProductSelection && !state.productId;
|
||||||
|
if (isListMode) {
|
||||||
|
wrap.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nutrition = product?.nutritionPer100g || def.nutritionPer100g;
|
||||||
|
if (!nutrition) {
|
||||||
|
wrap.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitScope = def.pantryUnit === 'ml' ? 'na 100 ml' : 'na 100 g';
|
||||||
|
const hint = product
|
||||||
|
? ''
|
||||||
|
: hasProducts
|
||||||
|
? 'orientacyjnie dla składnika'
|
||||||
|
: '';
|
||||||
|
const nutritionMeta = hint ? `${unitScope} • ${hint}` : unitScope;
|
||||||
|
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:rgb(var(--text-dim-rgb));">Wartości odżywcze</p>
|
||||||
|
<p class="text-[10px] mb-1.5" style="color:rgb(var(--text-dim-rgb));">${esc(nutritionMeta)}</p>
|
||||||
|
<div class="grid grid-cols-4 gap-1.5">
|
||||||
|
<div class="rounded-xl px-2 py-1.5 text-center" style="background:rgb(var(--card-rgb));">
|
||||||
|
<p class="text-[15px] font-bold tabular-nums leading-tight" style="color:rgb(var(--text-body-rgb));">${nutrition.kcal}</p>
|
||||||
|
<p class="text-[9px] font-medium" style="color:rgb(var(--text-dim-rgb));">kcal</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl px-2 py-1.5 text-center" style="background:rgb(var(--card-rgb));">
|
||||||
|
<p class="text-[15px] font-bold text-blue-400 tabular-nums leading-tight">${formatQty(nutrition.protein)}g</p>
|
||||||
|
<p class="text-[9px] font-medium" style="color:rgb(var(--text-dim-rgb));">białko</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl px-2 py-1.5 text-center" style="background:rgb(var(--card-rgb));">
|
||||||
|
<p class="text-[15px] font-bold text-amber-400 tabular-nums leading-tight">${formatQty(nutrition.fat)}g</p>
|
||||||
|
<p class="text-[9px] font-medium" style="color:rgb(var(--text-dim-rgb));">tłuszcz</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl px-2 py-1.5 text-center" style="background:rgb(var(--card-rgb));">
|
||||||
|
<p class="text-[15px] font-bold text-orange-400 tabular-nums leading-tight">${formatQty(nutrition.carbs)}g</p>
|
||||||
|
<p class="text-[9px] font-medium" style="color:rgb(var(--text-dim-rgb));">węgl.</p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStockEditorInto(wrap, def, product, pantry) {
|
||||||
|
const totalQty = getPantryTotal(def.id, pantry);
|
||||||
|
const unit = unitLabel(def.pantryUnit);
|
||||||
|
const qty = product
|
||||||
|
? (getPantryProducts(def.id, pantry).find((i) => i.productId === product.id)?.qty || 0)
|
||||||
|
: totalQty;
|
||||||
|
const { step, usesPackStep } = getQtyStepMeta(def, product);
|
||||||
|
const packSize = product?.packSize || def.purchasePack?.amount || 0;
|
||||||
|
const packLabel = product?.packLabel || def.purchasePack?.label || '';
|
||||||
|
const draftQty = state.stockEditorOpen
|
||||||
|
? normalizeQty(state.stockDraftQty ?? qty)
|
||||||
|
: qty;
|
||||||
|
const stockValueLabel = usesPackStep
|
||||||
|
? formatPackCount(qty, step)
|
||||||
|
: formatPackAwareAmount(qty, def.pantryUnit, packSize, packLabel);
|
||||||
|
const stockSubLabel = usesPackStep ? formatQtyWithUnit(qty, def.pantryUnit) : '';
|
||||||
|
const draftInputValue = usesPackStep
|
||||||
|
? formatPreciseQty(draftQty / step)
|
||||||
|
: formatPreciseQty(draftQty);
|
||||||
|
const draftInputUnit = usesPackStep ? 'opak.' : unit;
|
||||||
|
const actionLabel = state.stockEditorOpen ? 'Anuluj' : 'Zmień';
|
||||||
|
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:rgb(var(--text-dim-rgb));">Zapas</p>
|
||||||
|
<div class="rounded-2xl border px-3 py-3" style="background:rgb(var(--card-rgb)); border-color:rgb(var(--card-strong-rgb));">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-[16px] font-bold tabular-nums" style="color:rgb(var(--success-rgb));">${esc(stockValueLabel)}</p>
|
||||||
|
${stockSubLabel ? `<p class="text-[11px] mt-1" style="color:rgb(var(--text-dim-rgb));">${esc(stockSubLabel)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="calendar-liquid-btn ingredient-card-stock-toggle inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-semibold shrink-0" style="color:${state.stockEditorOpen ? 'rgb(var(--text-emphasis-rgb))' : 'rgb(var(--text-body-soft-rgb))'};">
|
||||||
|
${esc(actionLabel)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
${state.stockEditorOpen ? `
|
||||||
|
<div class="mt-3 pt-3 border-t" style="border-color:rgb(var(--card-strong-rgb));">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="button" class="calendar-liquid-btn ingredient-card-stock-step w-9 h-9 rounded-xl flex items-center justify-center shrink-0" style="color:rgb(var(--text-body-soft-rgb));" data-dir="-1" aria-label="Zmniejsz szkic zapasu">
|
||||||
|
<i class="fas fa-minus text-xs"></i>
|
||||||
|
</button>
|
||||||
|
<label class="calendar-liquid-btn flex-1 rounded-xl px-3 py-2 flex items-center justify-center gap-2">
|
||||||
|
<input type="text" inputmode="decimal" value="${draftInputValue}" class="ingredient-card-stock-input w-20 bg-transparent text-center text-[14px] font-semibold tabular-nums outline-none appearance-none" style="color:rgb(var(--text-body-rgb)); background:transparent !important; border:none !important; box-shadow:none !important; -webkit-appearance:none; -moz-appearance:textfield;">
|
||||||
|
<span class="text-[12px] font-medium shrink-0" style="color:rgb(var(--text-dim-rgb));">${esc(draftInputUnit)}</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="calendar-liquid-btn ingredient-card-stock-step w-9 h-9 rounded-xl flex items-center justify-center shrink-0" style="color:rgb(var(--text-body-soft-rgb));" data-dir="1" aria-label="Zwiększ szkic zapasu">
|
||||||
|
<i class="fas fa-plus text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
${usesPackStep ? `<p class="text-[10px] mt-2 text-right" style="color:rgb(var(--text-dim-rgb));">${esc(formatQtyWithUnit(draftQty, def.pantryUnit))}</p>` : ''}
|
||||||
|
<div class="flex items-center justify-between gap-3 mt-3">
|
||||||
|
<button type="button" class="ingredient-card-stock-clear text-[11px] font-semibold" style="color:rgb(var(--text-dim-rgb));">Wyzeruj</button>
|
||||||
|
<button type="button" class="calendar-liquid-btn ingredient-card-stock-save inline-flex items-center rounded-full px-3 py-1.5 text-[11px] font-semibold" style="color:rgb(var(--text-emphasis-rgb));">Zapisz</button>
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
wrap.querySelector('.ingredient-card-stock-toggle')?.addEventListener('click', () => {
|
||||||
|
if (state.stockEditorOpen) {
|
||||||
|
state.stockEditorOpen = false;
|
||||||
|
state.stockDraftQty = null;
|
||||||
|
} else {
|
||||||
|
state.stockEditorOpen = true;
|
||||||
|
state.shopEditorOpen = false;
|
||||||
|
state.stockDraftQty = qty;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
wrap.querySelectorAll('.ingredient-card-stock-step').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const dir = Number(btn.dataset.dir) || 1;
|
||||||
|
const next = normalizeQty((Number(state.stockDraftQty ?? qty) || 0) + step * dir);
|
||||||
|
state.stockDraftQty = next;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wrap.querySelector('.ingredient-card-stock-input')?.addEventListener('input', (event) => {
|
||||||
|
state.stockDraftQty = usesPackStep
|
||||||
|
? normalizeQty(parseQtyInput(event.target.value) * step)
|
||||||
|
: parseQtyInput(event.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
wrap.querySelector('.ingredient-card-stock-clear')?.addEventListener('click', () => {
|
||||||
|
state.stockDraftQty = 0;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
wrap.querySelector('.ingredient-card-stock-save')?.addEventListener('click', () => {
|
||||||
|
const input = wrap.querySelector('.ingredient-card-stock-input');
|
||||||
|
const nextQty = usesPackStep
|
||||||
|
? normalizeQty(parseQtyInput(input?.value) * step)
|
||||||
|
: parseQtyInput(input?.value ?? state.stockDraftQty ?? qty);
|
||||||
|
if (product) {
|
||||||
|
setPantryProductQty(def.id, product.id, nextQty);
|
||||||
|
} else {
|
||||||
|
setPantryQty(def.id, nextQty);
|
||||||
|
}
|
||||||
|
state.stockEditorOpen = false;
|
||||||
|
state.stockDraftQty = null;
|
||||||
|
render();
|
||||||
|
state.onAfterChange?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShopEditorInto(wrap, def, product) {
|
||||||
|
const { step, usesPackStep } = getQtyStepMeta(def, product);
|
||||||
|
const packSize = product?.packSize || def.purchasePack?.amount;
|
||||||
|
const packLabel = product?.packLabel || def.purchasePack?.label;
|
||||||
|
const usesPacks = Boolean(packSize && packSize > 0);
|
||||||
|
const defaultAmount = step;
|
||||||
|
const shoppingItem = getShoppingItemFor(def, product);
|
||||||
|
const hasShoppingItem = Boolean(shoppingItem);
|
||||||
|
const shoppingAmount = shoppingItem?.amount || 0;
|
||||||
|
const draftQty = state.shopEditorOpen
|
||||||
|
? normalizeQty(state.shopDraftQty ?? (shoppingAmount || defaultAmount))
|
||||||
|
: shoppingAmount;
|
||||||
|
const shopValueLabel = hasShoppingItem
|
||||||
|
? usesPackStep
|
||||||
|
? formatPackCount(shoppingAmount, step)
|
||||||
|
: formatPackAwareAmount(shoppingAmount, def.pantryUnit, packSize, packLabel)
|
||||||
|
: 'Brak na liście';
|
||||||
|
const shopSubLabel = hasShoppingItem && usesPackStep
|
||||||
|
? formatQtyWithUnit(shoppingAmount, def.pantryUnit)
|
||||||
|
: '';
|
||||||
|
const shopInputValue = usesPackStep
|
||||||
|
? formatPreciseQty(draftQty / step)
|
||||||
|
: formatPreciseQty(draftQty);
|
||||||
|
const shopInputUnit = usesPackStep ? 'opak.' : unitLabel(def.pantryUnit);
|
||||||
|
const actionLabel = state.shopEditorOpen ? 'Anuluj' : (hasShoppingItem ? 'Zmień' : 'Dodaj');
|
||||||
|
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:rgb(var(--text-dim-rgb));">Lista zakupów</p>
|
||||||
|
<div class="rounded-2xl border px-3 py-3" style="background:rgb(var(--card-rgb)); border-color:rgb(var(--card-strong-rgb));">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-[16px] font-bold tabular-nums" style="color:${hasShoppingItem ? 'rgb(var(--text-body-rgb))' : 'rgb(var(--text-dim-rgb))'};">${esc(shopValueLabel)}</p>
|
||||||
|
${shopSubLabel ? `<p class="text-[11px] mt-1" style="color:rgb(var(--text-dim-rgb));">${esc(shopSubLabel)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="calendar-liquid-btn ingredient-card-shop-toggle inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-semibold shrink-0" style="color:${state.shopEditorOpen ? 'rgb(var(--text-emphasis-rgb))' : 'rgb(var(--text-body-soft-rgb))'};">
|
||||||
|
${esc(actionLabel)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
${state.shopEditorOpen ? `
|
||||||
|
<div class="mt-3 pt-3 border-t" style="border-color:rgb(var(--card-strong-rgb));">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="button" class="calendar-liquid-btn ingredient-card-shop-step w-9 h-9 rounded-xl flex items-center justify-center shrink-0" style="color:rgb(var(--text-body-soft-rgb));" data-dir="-1" aria-label="Zmniejsz ilość na liście">
|
||||||
|
<i class="fas fa-minus text-xs"></i>
|
||||||
|
</button>
|
||||||
|
<label class="calendar-liquid-btn flex-1 rounded-xl px-3 py-2 flex items-center justify-center gap-2">
|
||||||
|
<input type="text" inputmode="decimal" value="${shopInputValue}" class="ingredient-card-shop-input w-20 bg-transparent text-center text-[14px] font-semibold tabular-nums outline-none appearance-none" style="color:rgb(var(--text-body-rgb)); background:transparent !important; border:none !important; box-shadow:none !important; -webkit-appearance:none; -moz-appearance:textfield;">
|
||||||
|
<span class="text-[12px] font-medium shrink-0" style="color:rgb(var(--text-dim-rgb));">${esc(shopInputUnit)}</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="calendar-liquid-btn ingredient-card-shop-step w-9 h-9 rounded-xl flex items-center justify-center shrink-0" style="color:rgb(var(--text-body-soft-rgb));" data-dir="1" aria-label="Zwiększ ilość na liście">
|
||||||
|
<i class="fas fa-plus text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
${usesPackStep ? `<p class="text-[10px] mt-2 text-right" style="color:rgb(var(--text-dim-rgb));">${esc(formatQtyWithUnit(draftQty, def.pantryUnit))}</p>` : ''}
|
||||||
|
<div class="flex items-center justify-between gap-3 mt-3">
|
||||||
|
${hasShoppingItem
|
||||||
|
? '<button type="button" class="ingredient-card-shop-remove text-[11px] font-semibold" style="color:rgb(var(--text-dim-rgb));">Usuń z listy</button>'
|
||||||
|
: '<span></span>'}
|
||||||
|
<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>`;
|
||||||
|
|
||||||
|
wrap.querySelector('.ingredient-card-shop-toggle')?.addEventListener('click', () => {
|
||||||
|
if (state.shopEditorOpen) {
|
||||||
|
state.shopEditorOpen = false;
|
||||||
|
state.shopDraftQty = null;
|
||||||
|
} else {
|
||||||
|
state.shopEditorOpen = true;
|
||||||
|
state.stockEditorOpen = false;
|
||||||
|
state.shopDraftQty = shoppingAmount || defaultAmount;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
wrap.querySelectorAll('.ingredient-card-shop-step').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const dir = Number(btn.dataset.dir) || 1;
|
||||||
|
const next = normalizeQty((Number(state.shopDraftQty ?? (shoppingAmount || defaultAmount)) || 0) + step * dir);
|
||||||
|
state.shopDraftQty = next;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wrap.querySelector('.ingredient-card-shop-input')?.addEventListener('input', (event) => {
|
||||||
|
state.shopDraftQty = usesPackStep
|
||||||
|
? normalizeQty(parseQtyInput(event.target.value) * step)
|
||||||
|
: parseQtyInput(event.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
wrap.querySelector('.ingredient-card-shop-remove')?.addEventListener('click', () => {
|
||||||
|
if (!shoppingItem) return;
|
||||||
|
removeItemFromList(KITCHEN_LIST_ID, shoppingItem.id);
|
||||||
|
state.shopEditorOpen = false;
|
||||||
|
state.shopDraftQty = null;
|
||||||
|
render();
|
||||||
|
state.onAfterChange?.();
|
||||||
|
window.refreshShopping?.();
|
||||||
|
showAppToast(`Usunieto ${product?.name || def.name} z listy.`);
|
||||||
|
});
|
||||||
|
|
||||||
|
wrap.querySelector('.ingredient-card-shop-save')?.addEventListener('click', () => {
|
||||||
|
const input = wrap.querySelector('.ingredient-card-shop-input');
|
||||||
|
const nextAmount = usesPackStep
|
||||||
|
? normalizeQty(parseQtyInput(input?.value) * step)
|
||||||
|
: parseQtyInput(input?.value ?? state.shopDraftQty ?? defaultAmount);
|
||||||
|
let toastText = null;
|
||||||
|
if (shoppingItem) {
|
||||||
|
updateKitchenItemAmount(KITCHEN_LIST_ID, shoppingItem.id, nextAmount);
|
||||||
|
toastText = nextAmount > 0
|
||||||
|
? `Zaktualizowano ${product?.name || def.name}.`
|
||||||
|
: `Usunieto ${product?.name || def.name} z listy.`;
|
||||||
|
} else if (nextAmount > 0) {
|
||||||
|
const note = usesPacks ? (packLabel || `${formatQty(packSize)} ${unitLabel(def.pantryUnit)}`) : undefined;
|
||||||
|
const line = {
|
||||||
|
ingredientId: def.id,
|
||||||
|
amount: nextAmount,
|
||||||
|
unit: unitLabel(def.pantryUnit),
|
||||||
|
name: product?.name || def.name,
|
||||||
|
category: def.category,
|
||||||
|
sourceNote: note || state.sourceNote || defaultSourceNote,
|
||||||
|
};
|
||||||
|
if (product) line.productId = product.id;
|
||||||
|
addOrMergeShoppingLines([line]);
|
||||||
|
toastText = `Dodano ${product?.name || def.name}.`;
|
||||||
|
}
|
||||||
|
state.shopEditorOpen = false;
|
||||||
|
state.shopDraftQty = null;
|
||||||
|
render();
|
||||||
|
state.onAfterChange?.();
|
||||||
|
window.refreshShopping?.();
|
||||||
|
if (toastText) showAppToast(toastText);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStock() {
|
||||||
|
const wrap = el('stock');
|
||||||
|
if (!wrap || !state.ingredientId) return;
|
||||||
|
const def = INGREDIENTS[state.ingredientId];
|
||||||
|
if (!def) return;
|
||||||
|
|
||||||
|
const hasProducts = ingredientHasProducts(state.ingredientId);
|
||||||
|
const isListMode = hasProducts && state.allowProductSelection && !state.productId;
|
||||||
|
if (isListMode) {
|
||||||
|
wrap.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pantry = loadPantry();
|
||||||
|
const product = state.productId ? PRODUCTS[state.productId] : null;
|
||||||
|
renderStockEditorInto(wrap, def, product, pantry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function productRowHtml(ingredientId, productId, pantry, kitchenItems) {
|
||||||
|
const def = INGREDIENTS[ingredientId];
|
||||||
|
const product = PRODUCTS[productId];
|
||||||
|
const icon = CATEGORY_ICONS[def.category] || 'fa-jar';
|
||||||
|
const unit = unitLabel(def.pantryUnit);
|
||||||
|
const pantryQty = getPantryProducts(ingredientId, pantry).find((i) => i.productId === productId)?.qty || 0;
|
||||||
|
const shoppingItem = kitchenItems.find((item) => !item.checked
|
||||||
|
&& item.ingredientId === ingredientId
|
||||||
|
&& item.unit === unit
|
||||||
|
&& (item.productId || '') === productId);
|
||||||
|
const shoppingAmount = shoppingItem?.amount || 0;
|
||||||
|
|
||||||
|
const pantryLabel = pantryQty > 0 ? `${formatQty(pantryQty)} ${unit}` : '—';
|
||||||
|
const pantryColor = pantryQty > 0 ? 'rgb(var(--text-body-rgb))' : 'rgb(var(--text-subdued-rgb))';
|
||||||
|
const shoppingLabel = shoppingAmount > 0 ? `${formatQty(shoppingAmount)} ${unit}` : '';
|
||||||
|
|
||||||
|
return `<button type="button" class="ingredient-card-product-row w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left transition-colors active:scale-[0.99]" style="background:rgb(var(--card-rgb)); border:1px solid transparent;" data-product-id="${esc(productId)}">
|
||||||
|
${mediaHtml(product.image || def.image, icon)}
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<span class="text-[13px] font-semibold truncate block" style="color:rgb(var(--text-body-rgb));">${esc(product.name)}</span>
|
||||||
|
${product.packLabel ? `<span class="text-[10px] block mt-0.5" style="color:rgb(var(--text-dim-rgb));">${esc(product.packLabel)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-end gap-0.5 shrink-0 tabular-nums">
|
||||||
|
<span class="text-[11px] font-semibold inline-flex items-center gap-1" style="color:${pantryColor};">
|
||||||
|
<i class="fas fa-box text-[9px]" style="color:rgb(var(--text-faint-rgb));"></i>${esc(pantryLabel)}
|
||||||
|
</span>
|
||||||
|
${shoppingLabel ? `<span class="text-[11px] font-semibold inline-flex items-center gap-1" style="color:rgb(var(--text-body-rgb));">
|
||||||
|
<i class="fas fa-cart-shopping text-[9px]" style="color:rgb(var(--text-faint-rgb));"></i>${esc(shoppingLabel)}
|
||||||
|
</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-chevron-right text-[10px] shrink-0" style="color:rgb(var(--text-faint-rgb));"></i>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProducts() {
|
||||||
|
const wrap = el('products');
|
||||||
|
if (!wrap || !state.ingredientId) return;
|
||||||
|
const hasProducts = ingredientHasProducts(state.ingredientId);
|
||||||
|
const isListMode = hasProducts && state.allowProductSelection && !state.productId;
|
||||||
|
if (!isListMode) {
|
||||||
|
wrap.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pantry = loadPantry();
|
||||||
|
const products = sortProductsByStock(getProductsForIngredient(state.ingredientId), getPantryProducts(state.ingredientId, pantry));
|
||||||
|
const shopping = loadShoppingState();
|
||||||
|
const kitchen = shopping.lists.find((list) => list.id === KITCHEN_LIST_ID && list.type === 'kitchen');
|
||||||
|
const kitchenItems = (kitchen && kitchen.type === 'kitchen') ? kitchen.items : [];
|
||||||
|
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:rgb(var(--text-dim-rgb));">Produkty</p>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
${products.map((product) => productRowHtml(state.ingredientId, product.id, pantry, kitchenItems)).join('')}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
wrap.querySelectorAll('.ingredient-card-product-row').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const nextProductId = btn.dataset.productId || null;
|
||||||
|
if (!nextProductId) return;
|
||||||
|
state.productId = nextProductId;
|
||||||
|
state.selectedProductId = nextProductId;
|
||||||
|
resetInlineEditors();
|
||||||
|
state.onProductChange?.(nextProductId);
|
||||||
|
render();
|
||||||
|
state.onAfterChange?.();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShop() {
|
||||||
|
const wrap = el('shop');
|
||||||
|
if (!wrap || !state.ingredientId) return;
|
||||||
|
const def = INGREDIENTS[state.ingredientId];
|
||||||
|
if (!def) return;
|
||||||
|
|
||||||
|
const hasProducts = ingredientHasProducts(state.ingredientId);
|
||||||
|
const isListMode = hasProducts && state.allowProductSelection && !state.productId;
|
||||||
|
if (isListMode) {
|
||||||
|
wrap.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = state.productId ? PRODUCTS[state.productId] : null;
|
||||||
|
renderShopEditorInto(wrap, def, product);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!state.ingredientId) return;
|
||||||
|
const def = INGREDIENTS[state.ingredientId];
|
||||||
|
if (!def) return;
|
||||||
|
const product = state.productId ? PRODUCTS[state.productId] : null;
|
||||||
|
const pantry = loadPantry();
|
||||||
|
|
||||||
|
renderHeader(def, product, pantry);
|
||||||
|
renderNutrition(def, product);
|
||||||
|
renderStock();
|
||||||
|
renderProducts();
|
||||||
|
renderShop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function open({
|
||||||
|
ingredientId,
|
||||||
|
productId = null,
|
||||||
|
selectedProductId = productId,
|
||||||
|
allowProductSelection = true,
|
||||||
|
sourceNote = defaultSourceNote,
|
||||||
|
onProductChange = null,
|
||||||
|
onAfterChange = null,
|
||||||
|
} = {}) {
|
||||||
|
const def = ingredientId ? INGREDIENTS[ingredientId] : null;
|
||||||
|
const overlay = el('overlay');
|
||||||
|
const card = el();
|
||||||
|
if (!def || !overlay || !card) return;
|
||||||
|
|
||||||
|
state.ingredientId = ingredientId;
|
||||||
|
state.productId = productId && PRODUCTS[productId] ? productId : null;
|
||||||
|
state.selectedProductId = selectedProductId && PRODUCTS[selectedProductId] ? selectedProductId : state.productId;
|
||||||
|
state.allowProductSelection = Boolean(allowProductSelection);
|
||||||
|
state.sourceNote = sourceNote;
|
||||||
|
state.onProductChange = onProductChange;
|
||||||
|
state.onAfterChange = onAfterChange;
|
||||||
|
resetInlineEditors();
|
||||||
|
render();
|
||||||
|
|
||||||
|
clearTimeout(state.closeTimer);
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
overlay.style.pointerEvents = 'auto';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
overlay.classList.add('opacity-100');
|
||||||
|
card.style.opacity = '1';
|
||||||
|
card.style.transform = 'translateY(0)';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
const overlay = el('overlay');
|
||||||
|
const card = el();
|
||||||
|
if (overlay && card) {
|
||||||
|
overlay.classList.remove('opacity-100');
|
||||||
|
overlay.style.pointerEvents = 'none';
|
||||||
|
card.style.opacity = '0';
|
||||||
|
card.style.transform = 'translateY(1.5rem)';
|
||||||
|
state.closeTimer = setTimeout(() => overlay.classList.add('hidden'), 220);
|
||||||
|
}
|
||||||
|
state.ingredientId = null;
|
||||||
|
state.productId = null;
|
||||||
|
state.selectedProductId = null;
|
||||||
|
state.allowProductSelection = true;
|
||||||
|
state.onProductChange = null;
|
||||||
|
state.onAfterChange = null;
|
||||||
|
state.sourceNote = defaultSourceNote;
|
||||||
|
resetInlineEditors();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bind() {
|
||||||
|
if (bound) return;
|
||||||
|
bound = true;
|
||||||
|
el('close')?.addEventListener('click', close);
|
||||||
|
el('back')?.addEventListener('click', () => {
|
||||||
|
if (!state.allowProductSelection) return;
|
||||||
|
state.productId = null;
|
||||||
|
resetInlineEditors();
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
el('overlay')?.addEventListener('click', (event) => {
|
||||||
|
if (event.target.id === `${idBase}-overlay`) close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (state.ingredientId) render();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bind,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
refresh,
|
||||||
|
isOpen: () => Boolean(state.ingredientId),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
|
addMonths,
|
||||||
|
addWeeks,
|
||||||
sameDay,
|
sameDay,
|
||||||
|
sameMonth,
|
||||||
startOfDay,
|
startOfDay,
|
||||||
startOfMonth,
|
startOfMonth,
|
||||||
startOfWeekMonday,
|
startOfWeekMonday,
|
||||||
@@ -19,31 +22,75 @@ export const CALENDAR_MONTHS_SHORT = [
|
|||||||
|
|
||||||
export const CALENDAR_WEEKDAYS_SHORT = ['pn', 'wt', 'śr', 'cz', 'pt', 'so', 'nd'];
|
export const CALENDAR_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) {
|
function escapeAttrValue(value) {
|
||||||
const { mode, selectedDate, inCurrentMonth } = meta;
|
return String(value)
|
||||||
const isSelected = selectedDate && sameDay(day, selectedDate);
|
.replace(/&/g, '&')
|
||||||
const showIndicator = !!dayState.showIndicator;
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCalendarDayHTML(day, meta, dayState, dayAttr, theme = {}, options = {}) {
|
||||||
|
const { mode, selectedDate } = meta;
|
||||||
|
const isSelected = typeof meta.isSelected === 'boolean'
|
||||||
|
? meta.isSelected
|
||||||
|
: !!(selectedDate && sameDay(day, selectedDate));
|
||||||
|
const showIndicator = !!(dayState.showIndicator || dayState.showDot);
|
||||||
const isDisabled = !!dayState.disabled;
|
const isDisabled = !!dayState.disabled;
|
||||||
const isDimmed = !!dayState.dimmed && !isSelected;
|
const isDimmed = !!dayState.dimmed && !isSelected;
|
||||||
const bg = isSelected ? '#23221e' : '#2f2f2d';
|
const defaultBg = 'rgb(var(--card-soft-rgb))';
|
||||||
const border = isSelected ? '#787876' : '#444442';
|
const defaultBorder = 'rgb(var(--card-strong-rgb))';
|
||||||
const text = isSelected ? '#f2efe8' : (isDimmed ? '#7d7a74' : '#d7d2c8');
|
const defaultText = 'rgb(var(--text-body-soft-rgb))';
|
||||||
const dot = isSelected ? '#f2efe8' : '#a59f92';
|
|
||||||
const opacity = isDimmed ? '0.72' : '1';
|
let bg;
|
||||||
const outerClass = `${mode === 'month' ? 'mx-auto ' : ''}flex h-[2.05rem] w-full min-w-0 max-w-full items-center justify-center rounded-full border text-xs font-medium transition-colors leading-tight overflow-hidden`;
|
let borderColor;
|
||||||
|
let text;
|
||||||
|
let borderClass = theme.borderClass || 'border';
|
||||||
|
let shadow = theme.shadow || 'none';
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
const keepDimmedBg = !!dayState.dimmed
|
||||||
|
&& theme.selectedBg == null
|
||||||
|
&& theme.selectedUsesDimmedBg !== false;
|
||||||
|
bg = theme.selectedBg
|
||||||
|
|| (keepDimmedBg ? (theme.dimmedBg ?? 'transparent') : null)
|
||||||
|
|| theme.bg
|
||||||
|
|| 'rgb(var(--card-rgb))';
|
||||||
|
borderColor = theme.selectedBorder || 'rgb(var(--border-input-rgb))';
|
||||||
|
text = theme.selectedText || 'rgb(var(--text-emphasis-rgb))';
|
||||||
|
shadow = theme.selectedShadow || shadow;
|
||||||
|
borderClass = theme.selectedBorderClass || 'border';
|
||||||
|
} else if (isDimmed) {
|
||||||
|
bg = theme.dimmedBg ?? 'transparent';
|
||||||
|
text = theme.dimText || 'rgb(var(--text-faint-rgb))';
|
||||||
|
borderClass = theme.dimmedBorderClass || 'border-0';
|
||||||
|
} else {
|
||||||
|
bg = theme.bg || defaultBg;
|
||||||
|
borderColor = theme.border || defaultBorder;
|
||||||
|
text = theme.text || defaultText;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dot = isSelected ? (theme.selectedDot || 'rgb(var(--text-emphasis-rgb))') : (theme.dot || 'rgb(var(--text-faint-rgb))');
|
||||||
|
const opacity = isDimmed ? String(theme.dimOpacity ?? 0.72) : '1';
|
||||||
|
const borderStyle = borderColor ? `border-color:${borderColor};` : 'border:none;';
|
||||||
|
const extraClass = !isDisabled && options.dayClassName ? ` ${options.dayClassName}` : '';
|
||||||
|
const outerClass = `${mode === 'month' ? 'mx-auto ' : ''}flex h-[2.05rem] w-full min-w-0 max-w-full items-center justify-center rounded-full ${borderClass}${extraClass} text-xs font-medium transition-colors leading-tight overflow-hidden`;
|
||||||
const innerClass = mode === 'month'
|
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};border-color:${border};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
|
||||||
@@ -54,12 +101,13 @@ function getCalendarDayHTML(day, meta, dayState, dayAttr) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
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() };
|
||||||
@@ -78,47 +126,79 @@ 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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCalendarWeekdayHeaderHTML(labels = CALENDAR_WEEKDAYS_SHORT) {
|
export function createCalendarWeekdayHeaderHTML(labels = CALENDAR_WEEKDAYS_SHORT, {
|
||||||
|
wrapperClass = 'grid grid-cols-7 gap-1.5 text-center text-[8px] font-medium text-gray-400 uppercase tracking-wide mb-1 leading-none',
|
||||||
|
} = {}) {
|
||||||
return `
|
return `
|
||||||
<div class="grid grid-cols-7 gap-1.5 text-center text-[8px] font-medium text-gray-400 uppercase tracking-wide mb-1 leading-none">
|
<div class="${wrapperClass}">
|
||||||
${labels.map((label) => `<div>${label}</div>`).join('')}
|
${labels.map((label) => `<div>${label}</div>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCalendarTopbarHTML({
|
export function createCalendarTopbarHTML({
|
||||||
titleId,
|
|
||||||
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 gap-3',
|
controlsStyle = 'background:transparent;border-color:rgb(var(--card-strong-rgb));',
|
||||||
titleClass = 'text-[18px] font-semibold text-gray-900 leading-none tracking-[-0.03em]',
|
todayButtonActiveClass = 'h-full shrink-0 inline-flex min-w-[7.25rem] max-w-[12.5rem] items-center justify-center rounded-full bg-transparent px-3 text-[10px] font-semibold leading-none tabular-nums text-[rgb(var(--text-body-soft-rgb))] active:bg-transparent whitespace-nowrap',
|
||||||
|
todayButtonDimClass = 'h-full shrink-0 inline-flex items-center justify-center rounded-full px-3 text-[10px] font-semibold leading-none text-[rgb(var(--text-faint-rgb))] cursor-default',
|
||||||
}) {
|
}) {
|
||||||
return `
|
return `
|
||||||
<div class="${wrapperClass}">
|
<div class="${wrapperClass}">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="flex h-[2.05rem] min-w-0 max-w-[min(100%,20rem)] items-center justify-center rounded-full border" style="${controlsStyle}">
|
||||||
<p id="${titleId}" class="${titleClass}"></p>
|
<button type="button" id="${todayId}"
|
||||||
</div>
|
class="${todayButtonActiveClass}"
|
||||||
<div class="shrink-0 flex h-[2.3rem] items-center gap-0.5 rounded-full border px-1" style="background:#2f2f2d;border-color:#444442;">
|
data-cal-active-class="${todayButtonActiveClass}"
|
||||||
<button type="button" id="${prevId}" class="shrink-0 w-8 h-full flex items-center justify-center rounded-full border-0 bg-transparent text-[#d7d2c8] transition-colors" aria-label="Poprzedni okres">
|
data-cal-dim-class="${todayButtonDimClass}">
|
||||||
<i class="fas fa-chevron-left text-[11px]" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" id="${todayId}" title="Dziś" aria-label="Przejdź do dzisiejszego dnia"
|
|
||||||
class="h-full shrink-0 inline-flex items-center justify-center rounded-full px-2.5 text-[11px] font-semibold leading-none text-[#d7d2c8] transition-colors hover:bg-[#3a3a37]">
|
|
||||||
Dziś
|
|
||||||
</button>
|
|
||||||
<button type="button" id="${nextId}" class="shrink-0 w-8 h-full flex items-center justify-center rounded-full border-0 bg-transparent text-[#d7d2c8] transition-colors" aria-label="Następny okres">
|
|
||||||
<i class="fas fa-chevron-right text-[11px]" aria-hidden="true"></i>
|
|
||||||
</button>
|
</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()}`;
|
||||||
}
|
}
|
||||||
@@ -127,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;
|
||||||
@@ -134,13 +235,28 @@ export function isCalendarOnToday(mode, weekStart, monthAnchor, selectedDate) {
|
|||||||
return startOfMonth(monthAnchor).getTime() === startOfMonth(today).getTime();
|
return startOfMonth(monthAnchor).getTime() === startOfMonth(today).getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncCalendarTodayButton(buttonEl, isOnToday) {
|
/**
|
||||||
|
* Środkowy przycisk pokazuje wybraną datę; działa jak „Dziś” (skok do bieżącego okresu).
|
||||||
|
* Styl pozostaje jak aktywny — bez wyciszania przy isOnToday.
|
||||||
|
*/
|
||||||
|
export function syncCalendarTodayButton(buttonEl, isOnToday, selectedDate, options = {}) {
|
||||||
if (!buttonEl) return;
|
if (!buttonEl) return;
|
||||||
const base = 'h-full shrink-0 inline-flex items-center justify-center rounded-full px-2.5 text-[11px] font-semibold leading-none transition-colors';
|
const {
|
||||||
const active = `${base} text-[#d7d2c8] hover:bg-[#3a3a37]`;
|
ariaLabelGo = 'Przejdź do dzisiejszego dnia',
|
||||||
const dim = `${base} text-[#7d7a74] cursor-default`;
|
ariaLabelCurrent = 'Widok jest ustawiony na bieżący okres',
|
||||||
buttonEl.className = isOnToday ? dim : active;
|
labelText,
|
||||||
buttonEl.disabled = isOnToday;
|
} = options;
|
||||||
|
const active = buttonEl.dataset.calActiveClass
|
||||||
|
|| 'h-full shrink-0 inline-flex min-w-[7.25rem] max-w-[12.5rem] items-center justify-center rounded-full bg-transparent px-3 text-[10px] font-semibold leading-none tabular-nums text-[rgb(var(--text-body-soft-rgb))] active:bg-transparent whitespace-nowrap';
|
||||||
|
if (labelText != null) {
|
||||||
|
buttonEl.textContent = labelText;
|
||||||
|
} else if (selectedDate != null) {
|
||||||
|
buttonEl.textContent = formatCalendarSelectedDate(selectedDate);
|
||||||
|
}
|
||||||
|
buttonEl.className = active;
|
||||||
|
buttonEl.removeAttribute('disabled');
|
||||||
|
buttonEl.setAttribute('aria-disabled', isOnToday ? 'true' : 'false');
|
||||||
|
buttonEl.setAttribute('aria-label', isOnToday ? ariaLabelCurrent : ariaLabelGo);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderCalendarGrid({
|
export function renderCalendarGrid({
|
||||||
@@ -148,8 +264,14 @@ 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,
|
||||||
}) {
|
}) {
|
||||||
if (!gridEl) return;
|
if (!gridEl) return;
|
||||||
|
|
||||||
@@ -158,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));
|
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);
|
return getCalendarDayHTML(
|
||||||
|
day,
|
||||||
|
meta,
|
||||||
|
getDayState(day, meta, resolveDayState),
|
||||||
|
dayAttr,
|
||||||
|
theme,
|
||||||
|
{ getDayAttrValue, dayClassName, dayStyle },
|
||||||
|
);
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,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,
|
||||||
@@ -196,6 +342,7 @@ export function renderCollapsibleCalendar({
|
|||||||
selectedDate,
|
selectedDate,
|
||||||
resolveDayState,
|
resolveDayState,
|
||||||
dayAttr,
|
dayAttr,
|
||||||
|
theme,
|
||||||
});
|
});
|
||||||
renderCalendarGrid({
|
renderCalendarGrid({
|
||||||
gridEl: monthGridEl,
|
gridEl: monthGridEl,
|
||||||
@@ -204,6 +351,7 @@ export function renderCollapsibleCalendar({
|
|||||||
selectedDate,
|
selectedDate,
|
||||||
resolveDayState,
|
resolveDayState,
|
||||||
dayAttr,
|
dayAttr,
|
||||||
|
theme,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,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) => {
|
||||||
@@ -239,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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { INGREDIENTS, RECIPES, PRODUCTS, CATEGORY_LABELS, 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';
|
||||||
@@ -15,18 +12,20 @@ import {
|
|||||||
savePlans,
|
savePlans,
|
||||||
} from '../services/planStore.js?v=2';
|
} from '../services/planStore.js?v=2';
|
||||||
import { dayHasAnyMeal, autoSelectProducts, saveLastProductSelection } from '../services/planIngredients.js?v=4';
|
import { dayHasAnyMeal, autoSelectProducts, saveLastProductSelection } from '../services/planIngredients.js?v=4';
|
||||||
import { loadPantry, getPantryTotal, getPantryProducts, setPantryQty, setPantryProductQty, addOrMergeShoppingLines } from '../services/pantryShopping.js?v=2';
|
import { loadPantry } from '../services/pantryShopping.js?v=2';
|
||||||
import { showAppToast } from './toast.js';
|
|
||||||
import {
|
import {
|
||||||
|
bindCollapsibleCalendarSwipeGesture,
|
||||||
bindCalendarDayClicks,
|
bindCalendarDayClicks,
|
||||||
|
createCollapsibleCalendarHTML,
|
||||||
createCalendarTopbarHTML,
|
createCalendarTopbarHTML,
|
||||||
createCalendarWeekdayHeaderHTML,
|
formatCalendarPeriodLabel,
|
||||||
formatCalendarMonthYear,
|
|
||||||
formatCalendarSelectedDate,
|
|
||||||
isCalendarOnToday,
|
isCalendarOnToday,
|
||||||
renderCalendarGrid,
|
renderCollapsibleCalendar,
|
||||||
syncCalendarTodayButton,
|
syncCalendarTodayButton,
|
||||||
} from './mealCalendar.js?v=1';
|
syncCollapsibleCalendarMode,
|
||||||
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
@@ -34,258 +33,66 @@ function esc(s) {
|
|||||||
|
|
||||||
const slotLabel = Object.fromEntries(MEAL_SLOTS.map((s) => [s.id, s.label]));
|
const slotLabel = Object.fromEntries(MEAL_SLOTS.map((s) => [s.id, s.label]));
|
||||||
|
|
||||||
/* ── Product Card Popup ────────────────────────────── */
|
|
||||||
|
|
||||||
function getProductCardHTML() {
|
|
||||||
return `
|
|
||||||
<div id="mpe-product-card-overlay" class="fixed inset-0 z-[70] bg-black/50 hidden flex items-center justify-center p-6" style="pointer-events:none">
|
|
||||||
<div id="mpe-product-card" class="relative w-full max-w-xs bg-[#2d2e2b] rounded-2xl shadow-2xl overflow-hidden" style="pointer-events:auto; max-height:80vh; overflow-y:auto;">
|
|
||||||
<div id="mpe-pc-hero" class="relative w-full h-[180px] bg-gray-800 overflow-hidden">
|
|
||||||
<img id="mpe-pc-img" class="w-full h-full object-cover hidden" alt="" />
|
|
||||||
<div id="mpe-pc-fallback" class="w-full h-full flex items-center justify-center">
|
|
||||||
<i class="fas fa-box-open text-3xl text-gray-600"></i>
|
|
||||||
</div>
|
|
||||||
<button type="button" id="mpe-pc-close" class="absolute top-3 right-3 w-8 h-8 rounded-full bg-black/50 text-white flex items-center justify-center hover:bg-black/70 transition-colors">
|
|
||||||
<i class="fas fa-times text-sm"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="px-4 pt-3 pb-4 space-y-3">
|
|
||||||
<div>
|
|
||||||
<p id="mpe-pc-brand" class="text-[10px] font-semibold uppercase tracking-wider text-emerald-400"></p>
|
|
||||||
<h3 id="mpe-pc-name" class="text-[15px] font-bold text-gray-100 leading-snug mt-0.5"></h3>
|
|
||||||
<p id="mpe-pc-category" class="text-[11px] text-gray-500 mt-0.5"></p>
|
|
||||||
</div>
|
|
||||||
<div id="mpe-pc-pack" class="hidden">
|
|
||||||
<span class="text-[10px] font-semibold uppercase tracking-wider text-gray-500">Opakowanie</span>
|
|
||||||
<p id="mpe-pc-pack-val" class="text-[13px] font-semibold text-gray-300 mt-0.5"></p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span id="mpe-pc-nut-label" class="text-[10px] font-semibold uppercase tracking-wider text-gray-500">Wartości odżywcze na 100 g</span>
|
|
||||||
<div class="grid grid-cols-4 gap-2 mt-1.5">
|
|
||||||
<div class="bg-[#393937] rounded-xl px-2.5 py-2 text-center">
|
|
||||||
<p id="mpe-pc-kcal" class="text-[15px] font-bold text-gray-100 tabular-nums"></p>
|
|
||||||
<p class="text-[9px] text-gray-500 font-medium mt-0.5">kcal</p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-[#393937] rounded-xl px-2.5 py-2 text-center">
|
|
||||||
<p id="mpe-pc-protein" class="text-[15px] font-bold text-blue-400 tabular-nums"></p>
|
|
||||||
<p class="text-[9px] text-gray-500 font-medium mt-0.5">białko</p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-[#393937] rounded-xl px-2.5 py-2 text-center">
|
|
||||||
<p id="mpe-pc-fat" class="text-[15px] font-bold text-amber-400 tabular-nums"></p>
|
|
||||||
<p class="text-[9px] text-gray-500 font-medium mt-0.5">tłuszcz</p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-[#393937] rounded-xl px-2.5 py-2 text-center">
|
|
||||||
<p id="mpe-pc-carbs" class="text-[15px] font-bold text-orange-400 tabular-nums"></p>
|
|
||||||
<p class="text-[9px] text-gray-500 font-medium mt-0.5">węgl.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="mpe-pc-stock" class="space-y-1.5"></div>
|
|
||||||
<div id="mpe-pc-shop"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openProductCard(ingredientId, productId) {
|
|
||||||
const overlay = document.getElementById('mpe-product-card-overlay');
|
|
||||||
if (!overlay) return;
|
|
||||||
|
|
||||||
const def = INGREDIENTS[ingredientId];
|
|
||||||
const product = productId ? PRODUCTS[productId] : null;
|
|
||||||
const name = product?.name || def?.name || ingredientId;
|
|
||||||
const brand = product?.brand || '';
|
|
||||||
const category = CATEGORY_LABELS[def?.category] || '';
|
|
||||||
const nutrition = product?.nutritionPer100g || def?.nutritionPer100g;
|
|
||||||
const image = product?.image || def?.image;
|
|
||||||
const packLabel = product?.packLabel || def?.purchasePack?.label || '';
|
|
||||||
const nutUnit = def?.pantryUnit === 'ml' ? '100 ml' : '100 g';
|
|
||||||
|
|
||||||
document.getElementById('mpe-pc-name').textContent = name;
|
|
||||||
document.getElementById('mpe-pc-brand').textContent = brand;
|
|
||||||
document.getElementById('mpe-pc-category').textContent = category;
|
|
||||||
document.getElementById('mpe-pc-nut-label').textContent = `Wartości odżywcze na ${nutUnit}`;
|
|
||||||
|
|
||||||
const img = document.getElementById('mpe-pc-img');
|
|
||||||
const fallback = document.getElementById('mpe-pc-fallback');
|
|
||||||
if (image) {
|
|
||||||
img.src = image;
|
|
||||||
img.alt = name;
|
|
||||||
img.classList.remove('hidden');
|
|
||||||
fallback.classList.add('hidden');
|
|
||||||
} else {
|
|
||||||
img.classList.add('hidden');
|
|
||||||
fallback.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
const packWrap = document.getElementById('mpe-pc-pack');
|
|
||||||
if (packLabel) {
|
|
||||||
packWrap.classList.remove('hidden');
|
|
||||||
document.getElementById('mpe-pc-pack-val').textContent = packLabel;
|
|
||||||
} else {
|
|
||||||
packWrap.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nutrition) {
|
|
||||||
document.getElementById('mpe-pc-kcal').textContent = nutrition.kcal;
|
|
||||||
document.getElementById('mpe-pc-protein').textContent = nutrition.protein + 'g';
|
|
||||||
document.getElementById('mpe-pc-fat').textContent = nutrition.fat + 'g';
|
|
||||||
document.getElementById('mpe-pc-carbs').textContent = nutrition.carbs + 'g';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stock section
|
|
||||||
renderPlannerCardStock(ingredientId, productId);
|
|
||||||
|
|
||||||
// Shop section
|
|
||||||
renderPlannerCardShop(ingredientId, productId);
|
|
||||||
|
|
||||||
overlay.classList.remove('hidden');
|
|
||||||
overlay.style.pointerEvents = 'auto';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPlannerCardStock(ingredientId, productId) {
|
|
||||||
const wrap = document.getElementById('mpe-pc-stock');
|
|
||||||
if (!wrap) return;
|
|
||||||
const def = INGREDIENTS[ingredientId];
|
|
||||||
if (!def) return;
|
|
||||||
const u = def.pantryUnit === 'szt' ? 'szt.' : def.pantryUnit;
|
|
||||||
const pantry = loadPantry();
|
|
||||||
const product = productId ? PRODUCTS[productId] : null;
|
|
||||||
|
|
||||||
let qty, step, pid;
|
|
||||||
if (product) {
|
|
||||||
const items = getPantryProducts(ingredientId, pantry);
|
|
||||||
qty = items.find(i => i.productId === productId)?.qty || 0;
|
|
||||||
step = product.packSize || 1;
|
|
||||||
pid = productId;
|
|
||||||
} else {
|
|
||||||
qty = getPantryTotal(ingredientId, pantry);
|
|
||||||
step = def.purchasePack?.amount || (def.pantryUnit === 'szt' ? 1 : 10);
|
|
||||||
pid = '_generic';
|
|
||||||
}
|
|
||||||
|
|
||||||
wrap.innerHTML = `
|
|
||||||
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5 text-gray-500">Zapas</p>
|
|
||||||
<div class="flex items-center justify-center gap-3 rounded-xl px-3 py-2 bg-[#393937]">
|
|
||||||
<button type="button" class="mpe-pc-stock-btn w-9 h-9 rounded-xl flex items-center justify-center active:scale-95 bg-[#2f2f2d] text-[#d7d2c8]" data-pid="${esc(pid)}" data-step="${step}" data-dir="-1"><i class="fas fa-minus text-xs"></i></button>
|
|
||||||
<span class="text-[17px] font-bold tabular-nums" style="color:#6ee7b7;">${Math.round(qty)} ${esc(u)}</span>
|
|
||||||
<button type="button" class="mpe-pc-stock-btn w-9 h-9 rounded-xl flex items-center justify-center active:scale-95 bg-[#2f2f2d] text-[#d7d2c8]" data-pid="${esc(pid)}" data-step="${step}" data-dir="1"><i class="fas fa-plus text-xs"></i></button>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
wrap.querySelectorAll('.mpe-pc-stock-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const bpid = btn.dataset.pid;
|
|
||||||
const bstep = Number(btn.dataset.step) || 1;
|
|
||||||
const dir = Number(btn.dataset.dir);
|
|
||||||
const p = loadPantry();
|
|
||||||
if (bpid === '_generic') {
|
|
||||||
const cur = getPantryTotal(ingredientId, p);
|
|
||||||
setPantryQty(ingredientId, Math.max(0, cur + bstep * dir));
|
|
||||||
} else {
|
|
||||||
const items = getPantryProducts(ingredientId, p);
|
|
||||||
const cur = items.find(i => i.productId === bpid)?.qty || 0;
|
|
||||||
setPantryProductQty(ingredientId, bpid, Math.max(0, cur + bstep * dir));
|
|
||||||
}
|
|
||||||
renderPlannerCardStock(ingredientId, productId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPlannerCardShop(ingredientId, productId) {
|
|
||||||
const wrap = document.getElementById('mpe-pc-shop');
|
|
||||||
if (!wrap) return;
|
|
||||||
const def = INGREDIENTS[ingredientId];
|
|
||||||
if (!def) return;
|
|
||||||
const u = def.pantryUnit === 'szt' ? 'szt.' : def.pantryUnit;
|
|
||||||
const product = productId ? PRODUCTS[productId] : null;
|
|
||||||
const packSize = product?.packSize || def.purchasePack?.amount;
|
|
||||||
const packLabel = product?.packLabel || def.purchasePack?.label;
|
|
||||||
const usesPacks = Boolean(packSize && packSize > 0);
|
|
||||||
const btnLabel = usesPacks ? `Dodaj na listę (${packLabel || `${packSize} ${u}`})` : 'Dodaj na listę';
|
|
||||||
|
|
||||||
wrap.innerHTML = `
|
|
||||||
<button type="button" id="mpe-pc-add-list" 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:#ddd6ca; color:#2d2e2b;">
|
|
||||||
<i class="fas fa-cart-plus text-[11px]"></i>${esc(btnLabel)}
|
|
||||||
</button>`;
|
|
||||||
|
|
||||||
document.getElementById('mpe-pc-add-list')?.addEventListener('click', () => {
|
|
||||||
const amt = usesPacks ? packSize : (def.pantryUnit === 'szt' ? 1 : 10);
|
|
||||||
const line = {
|
|
||||||
ingredientId,
|
|
||||||
amount: amt,
|
|
||||||
unit: u,
|
|
||||||
name: product?.name || def.name,
|
|
||||||
category: def.category,
|
|
||||||
sourceNote: packLabel || 'Z planera',
|
|
||||||
};
|
|
||||||
if (productId) line.productId = productId;
|
|
||||||
addOrMergeShoppingLines([line]);
|
|
||||||
// Quick visual feedback
|
|
||||||
const btn = document.getElementById('mpe-pc-add-list');
|
|
||||||
if (btn) { btn.textContent = '✓ Dodano'; setTimeout(() => { btn.innerHTML = `<i class="fas fa-cart-plus text-[11px]"></i>${esc(btnLabel)}`; }, 1200); }
|
|
||||||
window.refreshShopping?.();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeProductCard() {
|
|
||||||
const overlay = document.getElementById('mpe-product-card-overlay');
|
|
||||||
if (overlay) {
|
|
||||||
overlay.classList.add('hidden');
|
|
||||||
overlay.style.pointerEvents = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── HTML template ──────────────────────────────────── */
|
/* ── HTML template ──────────────────────────────────── */
|
||||||
|
|
||||||
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-center justify-between">
|
<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:rgb(var(--text-body-rgb)) !important; color:rgb(var(--app-bg-rgb)) !important; background-image:none !important; border-color:rgb(var(--text-body-rgb)) !important; box-shadow:0 2px 10px rgba(var(--overlay-rgb),0.18);">
|
||||||
|
<i id="mpe-confirm-icon" class="fas fa-plus text-[10px]" aria-hidden="true"></i>
|
||||||
|
<span id="mpe-confirm-label">Dodaj</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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({
|
||||||
titleId: 'mpe-cal-title',
|
|
||||||
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 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 pb-16 bg-[#2d2e2b]" style="background:#2d2e2b !important; background-image:none !important;">
|
|
||||||
<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>
|
||||||
<div id="mpe-add-area" class="mt-2"></div>
|
<div id="mpe-add-area" class="mt-2"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="mpe-footer-wrap" class="absolute bottom-0 left-0 right-0 z-[2] px-5 pb-3 flex justify-center" style="pointer-events:none; padding-bottom:calc(1.1rem + env(safe-area-inset-bottom));">
|
|
||||||
<button id="mpe-confirm-btn" type="button" class="border text-white px-6 py-3 rounded-full font-semibold text-[13px] transition-colors inline-flex items-center justify-center gap-2" style="pointer-events:auto; background:#2d2e2b !important; background-image:none !important; border-color:#444442 !important; box-shadow:0 4px 16px rgba(0,0,0,0.4), 0 1px 4px rgba(0,0,0,0.25);">
|
|
||||||
<span id="mpe-confirm-label">Dodaj do planu</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
${getIngredientCardHTML({ idBase: 'mpe-pc' })}`;
|
||||||
${getProductCardHTML()}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Setup ──────────────────────────────────────────── */
|
/* ── Setup ──────────────────────────────────────────── */
|
||||||
@@ -294,6 +101,8 @@ export function setupMealPlanEditor() {
|
|||||||
const overlay = document.getElementById('mpe-overlay');
|
const overlay = document.getElementById('mpe-overlay');
|
||||||
const sheet = document.getElementById('mpe-sheet');
|
const sheet = document.getElementById('mpe-sheet');
|
||||||
if (!overlay || !sheet) return;
|
if (!overlay || !sheet) return;
|
||||||
|
const ingredientCard = createIngredientCardController({ idBase: 'mpe-pc', defaultSourceNote: 'Z planera' });
|
||||||
|
ingredientCard.bind();
|
||||||
|
|
||||||
const S = {
|
const S = {
|
||||||
mode: null,
|
mode: null,
|
||||||
@@ -359,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');
|
||||||
@@ -373,54 +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 title = document.getElementById('mpe-cal-title');
|
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 || !title) 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';
|
||||||
|
|
||||||
title.textContent = S.calExpanded ? formatCalendarMonthYear(S.calDate) : formatCalendarSelectedDate(S.date);
|
syncCollapsibleCalendarMode({
|
||||||
if (icon) {
|
mode,
|
||||||
icon.className = S.calExpanded ? 'fas fa-chevron-up text-[10px]' : 'fas fa-chevron-down text-[10px]';
|
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,
|
||||||
|
{
|
||||||
|
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('');
|
||||||
}
|
}
|
||||||
@@ -449,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>
|
||||||
@@ -472,6 +293,8 @@ 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 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;
|
||||||
@@ -487,14 +310,12 @@ export function setupMealPlanEditor() {
|
|||||||
const disp = base * S.servings;
|
const disp = base * S.servings;
|
||||||
const modified = id in S.overrides;
|
const modified = id in S.overrides;
|
||||||
|
|
||||||
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 shuffleBtn = hasAlts
|
const shuffleBtn = hasAlts
|
||||||
? `<button type="button" class="mpe-shuffle 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-orig-id="${esc(id)}" aria-label="Wybierz zamiennik składnika"><i class="fas fa-shuffle text-[10px]"></i></button>`
|
? `<button type="button" class="mpe-shuffle 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-orig-id="${esc(id)}" aria-label="Wybierz zamiennik składnika"><i class="fas fa-shuffle text-[10px]"></i></button>`
|
||||||
: '';
|
: '';
|
||||||
const modDot = modified ? '<span class="w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0"></span>' : '';
|
const modDot = modified ? '<span class="w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0"></span>' : '';
|
||||||
|
|
||||||
html += `<div class="mpe-ing-row rounded-xl p-2.5" style="${rowStyle}" data-orig-id="${esc(id)}" data-type="recipe">`;
|
html += `<div class="${ingredientRowClass}" style="${ingredientRowStyle}" data-orig-id="${esc(id)}" data-type="recipe">`;
|
||||||
const selectedProductId = S.productSelections[eid];
|
const selectedProductId = S.productSelections[eid];
|
||||||
const selectedProduct = selectedProductId ? PRODUCTS[selectedProductId] : null;
|
const selectedProduct = selectedProductId ? PRODUCTS[selectedProductId] : null;
|
||||||
const productBadge = selectedProduct
|
const productBadge = selectedProduct
|
||||||
@@ -521,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>';
|
||||||
}
|
}
|
||||||
@@ -538,14 +359,14 @@ export function setupMealPlanEditor() {
|
|||||||
const def = INGREDIENTS[a.ingredientId];
|
const def = INGREDIENTS[a.ingredientId];
|
||||||
const name = def?.name || a.ingredientId;
|
const name = def?.name || a.ingredientId;
|
||||||
const disp = a.amount * S.servings;
|
const disp = a.amount * S.servings;
|
||||||
html += `<div class="mpe-ing-row rounded-xl p-2.5" style="background:#393937 !important; background-image:none !important; box-shadow:0 2px 8px rgba(0,0,0,0.25) !important; border:none !important;" data-ing-id="${esc(a.ingredientId)}" data-type="added">`;
|
html += `<div class="${ingredientRowClass}" style="${ingredientRowStyle}" data-ing-id="${esc(a.ingredientId)}" data-type="added">`;
|
||||||
html += `<div class="flex items-center gap-2">`;
|
html += `<div class="flex items-center gap-2">`;
|
||||||
const addedPid = S.productSelections[a.ingredientId] || '';
|
const addedPid = S.productSelections[a.ingredientId] || '';
|
||||||
const addedProduct = addedPid ? PRODUCTS[addedPid] : null;
|
const addedProduct = addedPid ? PRODUCTS[addedPid] : null;
|
||||||
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>`;
|
||||||
@@ -569,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];
|
||||||
@@ -580,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-2.5 py-2 rounded-lg transition-colors text-[12px] font-medium" style="background:#2f2f2d !important; color:#ddd6ca;" data-ing-id="${esc(i.id)}">${esc(i.name)} <span class="text-[10px]" style="color:#9b978f;">${esc(i.category)}</span></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>`;
|
||||||
}
|
}
|
||||||
@@ -604,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-2.5 py-2 rounded-lg transition-colors text-[12px] font-medium" style="background:#2f2f2d !important; color:#ddd6ca;" data-ing-id="${esc(i.id)}">${esc(i.name)} <span class="text-[10px]" style="color:#9b978f;">${esc(i.category)}</span></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() {
|
||||||
@@ -621,25 +442,25 @@ 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-1.5 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-1.5 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-1.5 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-1.5 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ęgl.</p>
|
<p class="text-[9px] text-gray-500 font-medium">węglowodany</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -695,7 +516,17 @@ export function setupMealPlanEditor() {
|
|||||||
|
|
||||||
document.getElementById('mpe-title').textContent = S.mode === 'edit' ? 'Edytuj posiłek' : 'Zaplanuj posiłek';
|
document.getElementById('mpe-title').textContent = S.mode === 'edit' ? 'Edytuj posiłek' : 'Zaplanuj posiłek';
|
||||||
document.getElementById('mpe-subtitle').textContent = recipe.title;
|
document.getElementById('mpe-subtitle').textContent = recipe.title;
|
||||||
document.getElementById('mpe-confirm-label').textContent = S.mode === 'edit' ? 'Zapisz zmiany' : 'Dodaj do planu';
|
document.getElementById('mpe-confirm-label').textContent = S.mode === 'edit' ? 'Zapisz' : 'Dodaj';
|
||||||
|
const confirmBtn = document.getElementById('mpe-confirm-btn');
|
||||||
|
if (confirmBtn) {
|
||||||
|
confirmBtn.setAttribute('aria-label', S.mode === 'edit' ? 'Zapisz zmiany' : 'Dodaj do planu');
|
||||||
|
}
|
||||||
|
const confirmIcon = document.getElementById('mpe-confirm-icon');
|
||||||
|
if (confirmIcon) {
|
||||||
|
confirmIcon.className = S.mode === 'edit'
|
||||||
|
? 'fas fa-check text-[10px]'
|
||||||
|
: 'fas fa-plus text-[10px]';
|
||||||
|
}
|
||||||
|
|
||||||
renderAll();
|
renderAll();
|
||||||
const body = document.getElementById('mpe-ing-scroll');
|
const body = document.getElementById('mpe-ing-scroll');
|
||||||
@@ -708,6 +539,7 @@ export function setupMealPlanEditor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeEditor() {
|
function closeEditor() {
|
||||||
|
ingredientCard.close();
|
||||||
sheet.style.transform = 'translateY(100%)';
|
sheet.style.transform = 'translateY(100%)';
|
||||||
setTimeout(() => { overlay.classList.add('hidden'); overlay.style.pointerEvents = 'none'; }, 300);
|
setTimeout(() => { overlay.classList.add('hidden'); overlay.style.pointerEvents = 'none'; }, 300);
|
||||||
}
|
}
|
||||||
@@ -805,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());
|
||||||
@@ -849,13 +699,6 @@ export function setupMealPlanEditor() {
|
|||||||
renderNutrition();
|
renderNutrition();
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── Product card ────────────────────────────── */
|
|
||||||
|
|
||||||
document.getElementById('mpe-pc-close')?.addEventListener('click', closeProductCard);
|
|
||||||
document.getElementById('mpe-product-card-overlay')?.addEventListener('click', (e) => {
|
|
||||||
if (e.target.id === 'mpe-product-card-overlay') closeProductCard();
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ── Ingredient section delegation ────────────── */
|
/* ── Ingredient section delegation ────────────── */
|
||||||
|
|
||||||
const ingSec = document.getElementById('mpe-ing-section');
|
const ingSec = document.getElementById('mpe-ing-section');
|
||||||
@@ -866,7 +709,24 @@ export function setupMealPlanEditor() {
|
|||||||
if (!changeProdEarly) {
|
if (!changeProdEarly) {
|
||||||
const cardBtn = e.target.closest('.mpe-open-product-card');
|
const cardBtn = e.target.closest('.mpe-open-product-card');
|
||||||
if (cardBtn) {
|
if (cardBtn) {
|
||||||
openProductCard(cardBtn.dataset.eid, cardBtn.dataset.pid || null);
|
const eid = cardBtn.dataset.eid;
|
||||||
|
if (!eid) return;
|
||||||
|
ingredientCard.open({
|
||||||
|
ingredientId: eid,
|
||||||
|
productId: cardBtn.dataset.pid || null,
|
||||||
|
selectedProductId: cardBtn.dataset.pid || null,
|
||||||
|
allowProductSelection: !cardBtn.dataset.pid,
|
||||||
|
sourceNote: 'Z planera',
|
||||||
|
onProductChange: (nextProductId) => {
|
||||||
|
if (!nextProductId) return;
|
||||||
|
S.productSelections[eid] = nextProductId;
|
||||||
|
saveLastProductSelection(eid, nextProductId);
|
||||||
|
},
|
||||||
|
onAfterChange: () => {
|
||||||
|
renderIngList();
|
||||||
|
renderNutrition();
|
||||||
|
},
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -936,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) {
|
||||||
@@ -949,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>`;
|
||||||
}
|
}
|
||||||
@@ -979,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
108
js/ui/recipeGrid.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { MEAL_SLOTS } from '../planner/mealSlots.js';
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
const slotLabelMap = Object.fromEntries(MEAL_SLOTS.map((slot) => [slot.id, slot.label]));
|
||||||
|
|
||||||
|
function slotLabelsFor(recipe) {
|
||||||
|
return (recipe.allowedSlots || [])
|
||||||
|
.map((id) => slotLabelMap[id])
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmptyStateHTML({ emptyStateId, title, message }) {
|
||||||
|
return `
|
||||||
|
<div id="${escapeHtml(emptyStateId)}" class="hidden flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<div class="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
||||||
|
<i class="fas fa-search text-2xl text-gray-300" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm font-semibold text-gray-700">${escapeHtml(title)}</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-1 max-w-[220px] leading-relaxed">${escapeHtml(message)}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecipeCard(recipe, { showSlotLabels = true, cardClassName = '' } = {}) {
|
||||||
|
const labels = showSlotLabels ? slotLabelsFor(recipe) : [];
|
||||||
|
const className = ['recipe-browser-card', cardClassName].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<button type="button" data-recipe-id="${escapeHtml(recipe.id)}" class="${className} rounded-xl overflow-hidden flex flex-col bg-[rgb(var(--card-rgb))] cursor-pointer text-left transition-shadow" style="background:rgb(var(--card-rgb)) !important; border:none !important; box-shadow:var(--shadow-card) !important;">
|
||||||
|
<div class="recipe-browser-card-media h-32 bg-[rgb(var(--skeleton-rgb))] relative overflow-hidden">
|
||||||
|
${recipe.image
|
||||||
|
? `<img src="${escapeHtml(recipe.image)}" alt="${escapeHtml(recipe.title)}" class="w-full h-full object-cover">`
|
||||||
|
: `<span class="absolute inset-0 flex items-center justify-center text-white font-medium text-xs">${escapeHtml(recipe.thumbLabel)}</span>`}
|
||||||
|
</div>
|
||||||
|
<div class="recipe-browser-card-body p-3 flex flex-col flex-1">
|
||||||
|
<h3 class="recipe-browser-card-title text-sm font-medium underline decoration-1 underline-offset-2 text-[rgb(var(--text-primary-rgb))] mb-3 line-clamp-2">${escapeHtml(recipe.title)}</h3>
|
||||||
|
<div class="recipe-browser-card-footer mt-auto">
|
||||||
|
<div class="recipe-browser-card-meta flex items-center justify-between text-[11px] text-[rgb(var(--text-muted-rgb))] font-medium mb-2">
|
||||||
|
<div class="flex items-center gap-1"><i class="fas fa-clock text-[rgb(var(--text-faint-rgb))]" aria-hidden="true"></i><span>${recipe.minutes} min</span></div>
|
||||||
|
<div class="flex items-center gap-1"><i class="fas fa-fire text-[rgb(var(--text-faint-rgb))]" aria-hidden="true"></i><span>${recipe.nutritionPerServing.kcal} kcal</span></div>
|
||||||
|
</div>
|
||||||
|
${labels.length > 0
|
||||||
|
? `<div class="recipe-browser-card-labels flex flex-wrap gap-1">
|
||||||
|
${labels.map((label) => `<span class="recipe-browser-card-label px-2 py-0.5 bg-[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>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterRecipesByQuery(recipes, query = '') {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return [...recipes];
|
||||||
|
|
||||||
|
return recipes.filter((recipe) => {
|
||||||
|
const haystack = `${recipe.title} ${(recipe.tags || []).join(' ')}`.toLowerCase();
|
||||||
|
return haystack.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecipeGridSectionHTML({
|
||||||
|
scrollId,
|
||||||
|
gridId,
|
||||||
|
emptyStateId,
|
||||||
|
scrollClassName = 'relative flex-1 overflow-y-auto px-4 pt-20 pb-24 bg-[rgb(var(--app-bg-rgb))]',
|
||||||
|
gridClassName = 'grid grid-cols-2 gap-3 bg-[rgb(var(--app-bg-rgb))]',
|
||||||
|
emptyTitle = 'Brak wyników',
|
||||||
|
emptyMessage = 'Zmień kryteria wyszukiwania lub filtry',
|
||||||
|
} = {}) {
|
||||||
|
return `
|
||||||
|
<div id="${escapeHtml(scrollId)}" class="${scrollClassName}" style="background:rgb(var(--app-bg-rgb)) !important;">
|
||||||
|
<div id="${escapeHtml(gridId)}" class="${gridClassName}" style="background:rgb(var(--app-bg-rgb)) !important;"></div>
|
||||||
|
${getEmptyStateHTML({
|
||||||
|
emptyStateId,
|
||||||
|
title: emptyTitle,
|
||||||
|
message: emptyMessage,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderRecipeGrid({
|
||||||
|
gridEl,
|
||||||
|
emptyStateEl,
|
||||||
|
recipes,
|
||||||
|
showSlotLabels = true,
|
||||||
|
cardClassName = '',
|
||||||
|
} = {}) {
|
||||||
|
if (!gridEl || !emptyStateEl) return;
|
||||||
|
|
||||||
|
const items = Array.isArray(recipes) ? recipes : [];
|
||||||
|
gridEl.innerHTML = items
|
||||||
|
.map((recipe) => renderRecipeCard(recipe, { showSlotLabels, cardClassName }))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const hasItems = items.length > 0;
|
||||||
|
gridEl.classList.toggle('hidden', !hasItems);
|
||||||
|
emptyStateEl.classList.toggle('hidden', hasItems);
|
||||||
|
}
|
||||||
45
js/ui/recipeSearchField.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
export const RECIPE_SEARCH_SHELL_BASE_SHADOW =
|
||||||
|
'var(--shadow-shell), inset 0 2px 6px rgba(var(--overlay-rgb),0.16), inset 0 -1px 2px rgba(255,255,255,0.02)';
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecipeSearchFieldHTML({
|
||||||
|
shellId,
|
||||||
|
inputId,
|
||||||
|
placeholder = 'Szukaj przepisów...',
|
||||||
|
inputAriaLabel = '',
|
||||||
|
inputValue = '',
|
||||||
|
filterButtonId = '',
|
||||||
|
filterButtonAction = '',
|
||||||
|
filterButtonLabel = 'Otwórz filtry',
|
||||||
|
} = {}) {
|
||||||
|
const hasFilterButton = Boolean(filterButtonId);
|
||||||
|
const actionAttr = hasFilterButton && filterButtonAction
|
||||||
|
? ` onclick="${escapeHtml(filterButtonAction)}"`
|
||||||
|
: '';
|
||||||
|
const inputPadding = hasFilterButton ? 'pl-8 pr-14' : 'pl-8 pr-8';
|
||||||
|
const ariaLabel = inputAriaLabel || placeholder;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div id="${escapeHtml(shellId)}" class="relative z-[1] mx-auto flex items-center w-full overflow-hidden" style="width:min(calc(100% - 0.5rem), 22.4rem); background:rgb(var(--card-rgb)) !important; border:1px solid rgb(var(--border-card-rgb)) !important; border-radius:999px !important; box-shadow:${RECIPE_SEARCH_SHELL_BASE_SHADOW} !important; transition:box-shadow 180ms ease;">
|
||||||
|
<input type="text" id="${escapeHtml(inputId)}" value="${escapeHtml(inputValue)}" placeholder="${escapeHtml(placeholder)}" aria-label="${escapeHtml(ariaLabel)}" class="w-full bg-transparent outline-none text-[15px] text-center py-[12px] ${inputPadding}" style="background:transparent !important; border:none !important; box-shadow:none !important; backdrop-filter:none !important;">
|
||||||
|
${hasFilterButton
|
||||||
|
? `
|
||||||
|
<button id="${escapeHtml(filterButtonId)}"${actionAttr} class="absolute right-2 top-1/2 -translate-y-1/2 w-9 h-9 text-[rgb(var(--text-body-soft-rgb))] hover:text-[rgb(var(--text-emphasis-rgb))] flex items-center justify-center transition-colors" style="background:transparent !important; border:none !important; box-shadow:none !important;" aria-label="${escapeHtml(filterButtonLabel)}">
|
||||||
|
<i class="fas fa-sliders-h" aria-hidden="true"></i>
|
||||||
|
</button>`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncRecipeSearchShellShadow(searchShell) {
|
||||||
|
if (!searchShell) return;
|
||||||
|
searchShell.style.boxShadow = RECIPE_SEARCH_SHELL_BASE_SHADOW;
|
||||||
|
}
|
||||||
355
js/ui/swipePopoverCalendar.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,25 +1,45 @@
|
|||||||
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 = '#2d2e2b';
|
const FILTER_TEXT_MUTED = 'var(--filter-liquid-text-muted)';
|
||||||
const FILTER_SURFACE_SOFT = '#2f2f2d';
|
const FILTER_TEXT_ACTIVE = 'var(--filter-liquid-text-active)';
|
||||||
const FILTER_BORDER = '#444442';
|
const FILTER_TRACK = 'var(--filter-liquid-track-bg)';
|
||||||
const FILTER_BORDER_ACTIVE = '#787876';
|
const FILTER_TRACK_FILL = 'var(--filter-liquid-accent-bg)';
|
||||||
const FILTER_CHIP_ACTIVE = '#23221e';
|
|
||||||
const FILTER_TEXT_PRIMARY = '#ddd6ca';
|
|
||||||
const FILTER_TEXT_SECONDARY = '#d7d2c8';
|
|
||||||
const FILTER_TEXT_MUTED = '#9b978f';
|
|
||||||
const FILTER_TEXT_DIM = '#7d7a74';
|
|
||||||
const FILTER_TEXT_ACTIVE = '#f2efe8';
|
|
||||||
const FILTER_TRACK = '#393937';
|
|
||||||
const FILTER_TRACK_FILL = '#56534f';
|
|
||||||
const 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_RECIPE_BLUR = 'blur(3px) saturate(0.94)';
|
const FILTER_CONTEXTS = {
|
||||||
|
recipes: {
|
||||||
|
anchorShellId: 'recipe-filter-float-btn',
|
||||||
|
buttonId: 'recipe-filter-float-btn',
|
||||||
|
getState: () => getFilterState(),
|
||||||
|
applyState: (nextState) => applyFilters(nextState),
|
||||||
|
showSlots: true,
|
||||||
|
},
|
||||||
|
plannerPicker: {
|
||||||
|
anchorShellId: 'planner-picker-search-shell',
|
||||||
|
buttonId: 'planner-picker-filter-btn',
|
||||||
|
getState: () => window.getPlannerPickerFilterState?.() || ({
|
||||||
|
slots: [],
|
||||||
|
tags: [],
|
||||||
|
minMinutes: PREP_TIME_MIN,
|
||||||
|
maxMinutes: PREP_TIME_MAX,
|
||||||
|
}),
|
||||||
|
applyState: (nextState) => window.applyPlannerPickerFilters?.(nextState),
|
||||||
|
showSlots: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
return String(s)
|
return String(s)
|
||||||
@@ -44,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;
|
||||||
@@ -54,42 +83,50 @@ 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-[55] hidden opacity-0 transition-opacity duration-150" style="pointer-events:none; background:rgba(0,0,0,0.5) !important; background-image:none !important;" aria-hidden="true">
|
<div id="filter-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.5rem] border" style="background:${FILTER_SURFACE} !important; background-image:none !important; border-color:${FILTER_BORDER} !important; opacity:0; transform:translateY(-0.5rem) scale(0.98); transform-origin:top center; transition:${FILTER_PANEL_TRANSITION}; box-shadow:0 18px 40px rgba(0,0,0,0.34), 0 4px 12px rgba(0,0,0,0.18); width:min(calc(100% - 1.5rem), 22rem);">
|
<div 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="pointer-events-none absolute inset-x-0 top-0 h-px" style="background:rgba(242,239,232,0.12);" aria-hidden="true"></div>
|
<div class="shrink-0 px-3 pt-3 pb-2 flex items-center justify-between gap-3">
|
||||||
<div class="shrink-0 px-3.5 pt-3 pb-2 flex justify-end" style="background:${FILTER_SURFACE} !important; background-image:none !important;">
|
<p class="text-[11px] font-semibold leading-none" style="color:${FILTER_TEXT_ACTIVE};">Filtry</p>
|
||||||
<div class="min-w-0 flex items-center justify-end gap-2">
|
<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="shrink-0 h-8 px-3 rounded-full border text-[11px] font-semibold transition-colors" style="background:${FILTER_SURFACE_SOFT} !important; border-color:${FILTER_BORDER} !important; color:${FILTER_TEXT_SECONDARY} !important;">Wyczyść</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="filter-panel-body" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-4 pb-4 space-y-2.5" style="background:${FILTER_SURFACE} !important; background-image:none !important;">
|
<div id="filter-panel-body" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-3 pb-3 space-y-4">
|
||||||
<section class="p-3.5" style="background:${FILTER_SURFACE} !important; background-image:none !important;">
|
<section id="filter-slot-section">
|
||||||
<p class="text-[10px] font-bold uppercase tracking-wider mb-3" style="color:${FILTER_TEXT_MUTED};">Pora posiłku</p>
|
<p class="text-[10px] font-bold uppercase tracking-wider mb-3 px-0.5" style="color:${FILTER_TEXT_MUTED};">Pora posiłku</p>
|
||||||
<div id="filter-slot-chips" class="flex flex-wrap gap-2"></div>
|
<div id="filter-slot-chips" class="flex flex-wrap gap-2 px-1.5"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="p-3.5" style="background:${FILTER_SURFACE} !important; background-image:none !important;">
|
<section>
|
||||||
<p class="text-[10px] font-bold uppercase tracking-wider mb-3" style="color:${FILTER_TEXT_MUTED};">Dieta i tagi</p>
|
<p class="text-[10px] font-bold uppercase tracking-wider mb-3 px-0.5" style="color:${FILTER_TEXT_MUTED};">Dieta i tagi</p>
|
||||||
<div id="filter-tag-chips" class="flex flex-wrap gap-2"></div>
|
<div id="filter-tag-chips" class="flex flex-wrap gap-2 px-1.5"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="p-3.5" style="background:${FILTER_SURFACE} !important; background-image:none !important;">
|
<section>
|
||||||
<div class="flex items-center justify-between gap-3 mb-3">
|
<div class="flex items-center justify-between gap-3 mb-3 px-0.5">
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="text-[10px] font-bold uppercase tracking-wider" style="color:${FILTER_TEXT_MUTED};">Czas przygotowania</p>
|
<p class="text-[10px] font-bold uppercase tracking-wider" style="color:${FILTER_TEXT_MUTED};">Czas przygotowania</p>
|
||||||
</div>
|
|
||||||
<span id="time-display-range" class="shrink-0 text-[11px] font-semibold tabular-nums text-right" style="color:${FILTER_TEXT_ACTIVE};">5 min - 120 min</span>
|
<span id="time-display-range" class="shrink-0 text-[11px] font-semibold tabular-nums text-right" style="color:${FILTER_TEXT_ACTIVE};">5 min - 120 min</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-1">
|
<div class="mx-1.5">
|
||||||
<div class="relative h-9">
|
<div class="relative h-9 mx-2">
|
||||||
<div class="prep-time-range-track absolute inset-x-0 top-1/2 -translate-y-1/2" style="background:${FILTER_TRACK};" aria-hidden="true"></div>
|
<div class="prep-time-range-track absolute inset-x-0 top-1/2 -translate-y-1/2" style="background:${FILTER_TRACK};" aria-hidden="true"></div>
|
||||||
<div id="prep-time-range-fill" class="prep-time-range-fill absolute top-1/2 -translate-y-1/2" style="background:${FILTER_TRACK_FILL}; left:0%; width:100%;" aria-hidden="true"></div>
|
<div id="prep-time-range-fill" class="prep-time-range-fill absolute top-1/2 -translate-y-1/2" style="background:${FILTER_TRACK_FILL}; left:0%; width:100%;" aria-hidden="true"></div>
|
||||||
<button
|
<button
|
||||||
@@ -132,6 +169,11 @@ let localTags = [];
|
|||||||
let localMinMinutes = PREP_TIME_MIN;
|
let localMinMinutes = PREP_TIME_MIN;
|
||||||
let localMaxMinutes = PREP_TIME_MAX;
|
let localMaxMinutes = PREP_TIME_MAX;
|
||||||
let closeTimer = null;
|
let closeTimer = null;
|
||||||
|
let activeFilterContext = 'recipes';
|
||||||
|
|
||||||
|
function getActiveFilterConfig() {
|
||||||
|
return FILTER_CONTEXTS[activeFilterContext] || FILTER_CONTEXTS.recipes;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTimeRange(minMinutes, maxMinutes) {
|
function normalizeTimeRange(minMinutes, maxMinutes) {
|
||||||
let nextMin = snapTimeValue(minMinutes);
|
let nextMin = snapTimeValue(minMinutes);
|
||||||
@@ -156,13 +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 : FILTER_SURFACE_SOFT;
|
|
||||||
const border = active ? FILTER_BORDER_ACTIVE : FILTER_BORDER;
|
|
||||||
const color = active ? FILTER_TEXT_ACTIVE : FILTER_TEXT_SECONDARY;
|
|
||||||
return `background:${background} !important; background-image:none !important; box-shadow:none !important; border-color:${border} !important; color:${color} !important;`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampTimeValue(value) {
|
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);
|
||||||
}
|
}
|
||||||
@@ -176,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');
|
||||||
@@ -216,25 +257,40 @@ function positionFilterPanel() {
|
|||||||
const view = document.getElementById('filter-view');
|
const view = document.getElementById('filter-view');
|
||||||
const panel = document.getElementById('filter-panel');
|
const panel = document.getElementById('filter-panel');
|
||||||
const body = document.getElementById('filter-panel-body');
|
const body = document.getElementById('filter-panel-body');
|
||||||
const searchShell = document.getElementById('recipe-search-shell');
|
const { anchorShellId, buttonId } = getActiveFilterConfig();
|
||||||
const button = document.getElementById('recipe-filter-btn');
|
const searchShell = document.getElementById(anchorShellId);
|
||||||
if (!view || !panel || !button) return;
|
const button = document.getElementById(buttonId);
|
||||||
|
if (!view || !panel || (!searchShell && !button)) return;
|
||||||
|
|
||||||
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`;
|
||||||
}
|
}
|
||||||
@@ -254,8 +310,11 @@ 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();
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
view.classList.add('opacity-100');
|
view.classList.add('opacity-100');
|
||||||
panel.style.opacity = '1';
|
panel.style.opacity = '1';
|
||||||
@@ -272,8 +331,9 @@ 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();
|
||||||
|
|
||||||
closeTimer = setTimeout(() => {
|
closeTimer = setTimeout(() => {
|
||||||
view.classList.add('hidden');
|
view.classList.add('hidden');
|
||||||
@@ -290,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-1.5 rounded-full border text-[12px] font-semibold transition-colors" style="${getChipStyle(active)}">${escapeHtml(slot.label)}</button>`;
|
return `<button type="button" data-filter-slot="${escapeHtml(slot.id)}" class="px-3 py-2 rounded-full text-[11px] font-semibold transition-colors" style="${filterChipStyle(active)}">${escapeHtml(slot.label)}</button>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
wrap.querySelectorAll('[data-filter-slot]').forEach((btn) => {
|
wrap.querySelectorAll('[data-filter-slot]').forEach((btn) => {
|
||||||
@@ -312,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-1.5 rounded-full border text-[12px] font-semibold transition-colors" style="${getChipStyle(active)}">${escapeHtml(tag)}</button>`;
|
return `<button type="button" data-filter-tag="${escapeHtml(tag)}" class="px-3 py-2 rounded-full text-[11px] font-semibold transition-colors" style="${filterChipStyle(active)}">${escapeHtml(tag)}</button>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
wrap.querySelectorAll('[data-filter-tag]').forEach((btn) => {
|
wrap.querySelectorAll('[data-filter-tag]').forEach((btn) => {
|
||||||
@@ -327,16 +387,59 @@ function renderTagChips() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncFilterSections() {
|
||||||
|
const slotSection = document.getElementById('filter-slot-section');
|
||||||
|
if (!slotSection) return;
|
||||||
|
slotSection.classList.toggle('hidden', !getActiveFilterConfig().showSlots);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveFilterCount() {
|
||||||
|
const config = getActiveFilterConfig();
|
||||||
|
let count = localTags.length;
|
||||||
|
if (config.showSlots) count += localSlots.length;
|
||||||
|
if (localMinMinutes > PREP_TIME_MIN || localMaxMinutes < PREP_TIME_MAX) count += 1;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPanelCount() {
|
||||||
|
const count = getActiveFilterCount();
|
||||||
|
const { buttonId } = getActiveFilterConfig();
|
||||||
|
const button = buttonId ? document.getElementById(buttonId) : null;
|
||||||
|
|
||||||
|
if (button) {
|
||||||
|
const highlight = isFilterPanelOpen() || count > 0;
|
||||||
|
const isRecipeGlassButton = button.classList.contains('recipe-glass-btn');
|
||||||
|
if (isRecipeGlassButton) {
|
||||||
|
button.style.removeProperty('background');
|
||||||
|
button.style.removeProperty('border-color');
|
||||||
|
button.style.removeProperty('color');
|
||||||
|
} else {
|
||||||
|
button.style.setProperty('background', highlight ? 'rgba(var(--overlay-rgb), 0.12)' : 'rgb(var(--card-rgb))', 'important');
|
||||||
|
button.style.setProperty('border-color', highlight ? 'rgb(var(--border-input-rgb))' : 'rgb(var(--border-card-rgb))', 'important');
|
||||||
|
button.style.setProperty('color', highlight ? 'rgb(var(--text-emphasis-rgb))' : 'rgb(var(--text-body-rgb))', 'important');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const badge = button?.querySelector('[id$="-filter-count"]');
|
||||||
|
if (!badge) return;
|
||||||
|
badge.textContent = String(count);
|
||||||
|
badge.classList.toggle('hidden', count === 0);
|
||||||
|
badge.classList.toggle('flex', count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
function syncLiveFilters() {
|
function syncLiveFilters() {
|
||||||
applyFilters({
|
const config = getActiveFilterConfig();
|
||||||
slots: localSlots,
|
config.applyState?.({
|
||||||
|
slots: config.showSlots ? localSlots : [],
|
||||||
tags: localTags,
|
tags: localTags,
|
||||||
minMinutes: localMinMinutes,
|
minMinutes: localMinMinutes,
|
||||||
maxMinutes: localMaxMinutes,
|
maxMinutes: localMaxMinutes,
|
||||||
});
|
});
|
||||||
|
syncPanelCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
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');
|
||||||
@@ -465,15 +568,17 @@ export function setupFilter() {
|
|||||||
syncLiveFilters();
|
syncLiveFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
window.openFilters = () => {
|
window.openFilters = (contextName = 'recipes') => {
|
||||||
if (isFilterPanelOpen()) {
|
if (isFilterPanelOpen() && activeFilterContext === contextName) {
|
||||||
window.closeFilters();
|
window.closeFilters();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = getFilterState();
|
activeFilterContext = FILTER_CONTEXTS[contextName] ? contextName : 'recipes';
|
||||||
localSlots = [...state.slots];
|
const config = getActiveFilterConfig();
|
||||||
localTags = [...state.tags];
|
const state = config.getState?.() || {};
|
||||||
|
localSlots = [...(state.slots || [])];
|
||||||
|
localTags = [...(state.tags || [])];
|
||||||
const normalized = normalizeTimeRange(
|
const normalized = normalizeTimeRange(
|
||||||
Number.isFinite(state.minMinutes) ? state.minMinutes : PREP_TIME_MIN,
|
Number.isFinite(state.minMinutes) ? state.minMinutes : PREP_TIME_MIN,
|
||||||
Number.isFinite(state.maxMinutes) ? state.maxMinutes : PREP_TIME_MAX,
|
Number.isFinite(state.maxMinutes) ? state.maxMinutes : PREP_TIME_MAX,
|
||||||
@@ -485,6 +590,8 @@ export function setupFilter() {
|
|||||||
|
|
||||||
renderSlotChips();
|
renderSlotChips();
|
||||||
renderTagChips();
|
renderTagChips();
|
||||||
|
syncFilterSections();
|
||||||
|
syncPanelCount();
|
||||||
|
|
||||||
showFilterPanel();
|
showFilterPanel();
|
||||||
};
|
};
|
||||||
|
|||||||