feat: implement Favorites feature with persistent storage and sidebar integration and update lake data.
This commit is contained in:
+19
-1
@@ -6815,10 +6815,28 @@
|
|||||||
{
|
{
|
||||||
"timestamp": "2026-06-05T21:20:00.000Z",
|
"timestamp": "2026-06-05T21:20:00.000Z",
|
||||||
"level": 467.72,
|
"level": 467.72,
|
||||||
|
"flow": 0.7,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0,
|
||||||
|
"temperature": 14.4,
|
||||||
|
"precipitation": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T21:30:00.000Z",
|
||||||
|
"level": 467.72,
|
||||||
|
"flow": 0.7,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0,
|
||||||
|
"temperature": 14.4,
|
||||||
|
"precipitation": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T21:40:00.000Z",
|
||||||
|
"level": 467.72,
|
||||||
"flow": 0,
|
"flow": 0,
|
||||||
"inflow": 2.88,
|
"inflow": 2.88,
|
||||||
"volume": 26.49,
|
"volume": 26.49,
|
||||||
"temperature": 11.9,
|
"temperature": 11.6,
|
||||||
"precipitation": 0
|
"precipitation": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
+11
-2
@@ -6824,10 +6824,19 @@
|
|||||||
{
|
{
|
||||||
"timestamp": "2026-06-05T21:30:00.000Z",
|
"timestamp": "2026-06-05T21:30:00.000Z",
|
||||||
"level": 352.85,
|
"level": 352.85,
|
||||||
"flow": 0,
|
"flow": 2.53,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0,
|
||||||
|
"temperature": 12.1,
|
||||||
|
"precipitation": 5.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T21:40:00.000Z",
|
||||||
|
"level": 352.85,
|
||||||
|
"flow": 2.53,
|
||||||
"inflow": 1.46,
|
"inflow": 1.46,
|
||||||
"volume": 32.37,
|
"volume": 32.37,
|
||||||
"temperature": 12.1,
|
"temperature": 11.6,
|
||||||
"precipitation": 0
|
"precipitation": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
+19
-1
@@ -6816,9 +6816,27 @@
|
|||||||
"timestamp": "2026-06-05T21:20:00.000Z",
|
"timestamp": "2026-06-05T21:20:00.000Z",
|
||||||
"level": 369.79,
|
"level": 369.79,
|
||||||
"flow": 1.25,
|
"flow": 1.25,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0,
|
||||||
|
"temperature": 18.234727964853622,
|
||||||
|
"precipitation": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T21:30:00.000Z",
|
||||||
|
"level": 369.79,
|
||||||
|
"flow": 1.25,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0,
|
||||||
|
"temperature": 18.234727964853622,
|
||||||
|
"precipitation": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T21:40:00.000Z",
|
||||||
|
"level": 369.79,
|
||||||
|
"flow": 1.25,
|
||||||
"inflow": 10.82,
|
"inflow": 10.82,
|
||||||
"volume": 20.24,
|
"volume": 20.24,
|
||||||
"temperature": 11.3,
|
"temperature": 11.1,
|
||||||
"precipitation": 0
|
"precipitation": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
+19
-1
@@ -6816,9 +6816,27 @@
|
|||||||
"timestamp": "2026-06-05T21:20:00.000Z",
|
"timestamp": "2026-06-05T21:20:00.000Z",
|
||||||
"level": 352.43,
|
"level": 352.43,
|
||||||
"flow": 19.01,
|
"flow": 19.01,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0,
|
||||||
|
"temperature": 12.4,
|
||||||
|
"precipitation": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T21:30:00.000Z",
|
||||||
|
"level": 352.42,
|
||||||
|
"flow": 19.01,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0,
|
||||||
|
"temperature": 12.4,
|
||||||
|
"precipitation": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T21:40:00.000Z",
|
||||||
|
"level": 352.42,
|
||||||
|
"flow": 19.01,
|
||||||
"inflow": 14.13,
|
"inflow": 14.13,
|
||||||
"volume": 2.74,
|
"volume": 2.74,
|
||||||
"temperature": 11.3,
|
"temperature": 11.1,
|
||||||
"precipitation": 0
|
"precipitation": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -6816,6 +6816,24 @@
|
|||||||
"timestamp": "2026-06-05T21:20:00.000Z",
|
"timestamp": "2026-06-05T21:20:00.000Z",
|
||||||
"level": 723.08,
|
"level": 723.08,
|
||||||
"flow": 1.51,
|
"flow": 1.51,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0,
|
||||||
|
"temperature": 18.62002326908434,
|
||||||
|
"precipitation": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T21:30:00.000Z",
|
||||||
|
"level": 723.08,
|
||||||
|
"flow": 1.51,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0,
|
||||||
|
"temperature": 18.62002326908434,
|
||||||
|
"precipitation": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T21:40:00.000Z",
|
||||||
|
"level": 723.08,
|
||||||
|
"flow": 1.51,
|
||||||
"inflow": 2.51,
|
"inflow": 2.51,
|
||||||
"volume": 199.67,
|
"volume": 199.67,
|
||||||
"temperature": 11.5,
|
"temperature": 11.5,
|
||||||
|
|||||||
+20
-2
@@ -6797,7 +6797,7 @@
|
|||||||
{
|
{
|
||||||
"timestamp": "2026-06-05T21:00:00.000Z",
|
"timestamp": "2026-06-05T21:00:00.000Z",
|
||||||
"level": 559.91,
|
"level": 559.91,
|
||||||
"flow": 0,
|
"flow": 7.18,
|
||||||
"inflow": 0,
|
"inflow": 0,
|
||||||
"volume": 0,
|
"volume": 0,
|
||||||
"temperature": 17.97824695485787,
|
"temperature": 17.97824695485787,
|
||||||
@@ -6806,7 +6806,7 @@
|
|||||||
{
|
{
|
||||||
"timestamp": "2026-06-05T21:10:00.000Z",
|
"timestamp": "2026-06-05T21:10:00.000Z",
|
||||||
"level": 559.9,
|
"level": 559.9,
|
||||||
"flow": 0,
|
"flow": 7.18,
|
||||||
"inflow": 0,
|
"inflow": 0,
|
||||||
"volume": 0,
|
"volume": 0,
|
||||||
"temperature": 17.97824695485787,
|
"temperature": 17.97824695485787,
|
||||||
@@ -6816,6 +6816,24 @@
|
|||||||
"timestamp": "2026-06-05T21:20:00.000Z",
|
"timestamp": "2026-06-05T21:20:00.000Z",
|
||||||
"level": 559.89,
|
"level": 559.89,
|
||||||
"flow": 0,
|
"flow": 0,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0,
|
||||||
|
"temperature": 17.97824695485787,
|
||||||
|
"precipitation": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T21:30:00.000Z",
|
||||||
|
"level": 559.88,
|
||||||
|
"flow": 0,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0,
|
||||||
|
"temperature": 17.97824695485787,
|
||||||
|
"precipitation": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T21:40:00.000Z",
|
||||||
|
"level": 559.87,
|
||||||
|
"flow": 0,
|
||||||
"inflow": 3.71,
|
"inflow": 3.71,
|
||||||
"volume": 0.68,
|
"volume": 0.68,
|
||||||
"temperature": 8.7,
|
"temperature": 8.7,
|
||||||
|
|||||||
+19
-1
@@ -6816,9 +6816,27 @@
|
|||||||
"timestamp": "2026-06-05T21:20:00.000Z",
|
"timestamp": "2026-06-05T21:20:00.000Z",
|
||||||
"level": 345.27,
|
"level": 345.27,
|
||||||
"flow": 0,
|
"flow": 0,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0,
|
||||||
|
"temperature": 18.70045888971512,
|
||||||
|
"precipitation": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T21:30:00.000Z",
|
||||||
|
"level": 345.27,
|
||||||
|
"flow": 0,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0,
|
||||||
|
"temperature": 18.70045888971512,
|
||||||
|
"precipitation": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T21:40:00.000Z",
|
||||||
|
"level": 345.26,
|
||||||
|
"flow": 0,
|
||||||
"inflow": 23.84,
|
"inflow": 23.84,
|
||||||
"volume": 522.12,
|
"volume": 522.12,
|
||||||
"temperature": 12.2,
|
"temperature": 12,
|
||||||
"precipitation": 0
|
"precipitation": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
+19
-1
@@ -6816,9 +6816,27 @@
|
|||||||
"timestamp": "2026-06-05T21:20:00.000Z",
|
"timestamp": "2026-06-05T21:20:00.000Z",
|
||||||
"level": 269.83,
|
"level": 269.83,
|
||||||
"flow": 0,
|
"flow": 0,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0,
|
||||||
|
"temperature": 16.3,
|
||||||
|
"precipitation": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T21:30:00.000Z",
|
||||||
|
"level": 269.83,
|
||||||
|
"flow": 0,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0,
|
||||||
|
"temperature": 16.3,
|
||||||
|
"precipitation": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T21:40:00.000Z",
|
||||||
|
"level": 269.84,
|
||||||
|
"flow": 0,
|
||||||
"inflow": 46.5,
|
"inflow": 46.5,
|
||||||
"volume": 260.21,
|
"volume": 260.21,
|
||||||
"temperature": 11.8,
|
"temperature": 11.5,
|
||||||
"precipitation": 0
|
"precipitation": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
+19
-1
@@ -6816,9 +6816,27 @@
|
|||||||
"timestamp": "2026-06-05T21:20:00.000Z",
|
"timestamp": "2026-06-05T21:20:00.000Z",
|
||||||
"level": 218.64,
|
"level": 218.64,
|
||||||
"flow": 25.32,
|
"flow": 25.32,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0,
|
||||||
|
"temperature": 18.450684013836877,
|
||||||
|
"precipitation": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T21:30:00.000Z",
|
||||||
|
"level": 218.59,
|
||||||
|
"flow": 25.39,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0,
|
||||||
|
"temperature": 18.450684013836877,
|
||||||
|
"precipitation": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T21:40:00.000Z",
|
||||||
|
"level": 218.72,
|
||||||
|
"flow": 25.33,
|
||||||
"inflow": 19.85,
|
"inflow": 19.85,
|
||||||
"volume": 9.68,
|
"volume": 9.68,
|
||||||
"temperature": 11.7,
|
"temperature": 11.5,
|
||||||
"precipitation": 0
|
"precipitation": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -14,8 +14,6 @@
|
|||||||
"lat": 48.6322,
|
"lat": 48.6322,
|
||||||
"lng": 14.2215,
|
"lng": 14.2215,
|
||||||
"sparkline": [
|
"sparkline": [
|
||||||
1.49,
|
|
||||||
13.76,
|
|
||||||
34.78,
|
34.78,
|
||||||
37.78,
|
37.78,
|
||||||
33.61,
|
33.61,
|
||||||
@@ -25,6 +23,8 @@
|
|||||||
1.51,
|
1.51,
|
||||||
1.51,
|
1.51,
|
||||||
1.51,
|
1.51,
|
||||||
|
1.51,
|
||||||
|
1.51,
|
||||||
1.51
|
1.51
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -33,9 +33,9 @@
|
|||||||
"name": "Lipno II",
|
"name": "Lipno II",
|
||||||
"river": "Vltava",
|
"river": "Vltava",
|
||||||
"priority": false,
|
"priority": false,
|
||||||
"level": "559.89",
|
"level": "559.87",
|
||||||
"capacity": 100,
|
"capacity": 100,
|
||||||
"storageDiff": 48.39,
|
"storageDiff": 48.37,
|
||||||
"inflow": "3.7",
|
"inflow": "3.7",
|
||||||
"outflow": "0.0",
|
"outflow": "0.0",
|
||||||
"volume": 0.68,
|
"volume": 0.68,
|
||||||
@@ -43,8 +43,6 @@
|
|||||||
"lat": 48.625,
|
"lat": 48.625,
|
||||||
"lng": 14.318,
|
"lng": 14.318,
|
||||||
"sparkline": [
|
"sparkline": [
|
||||||
7.31,
|
|
||||||
7.34,
|
|
||||||
7.48,
|
7.48,
|
||||||
7.29,
|
7.29,
|
||||||
7.27,
|
7.27,
|
||||||
@@ -52,6 +50,8 @@
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
|
7.18,
|
||||||
|
7.18,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
0
|
0
|
||||||
@@ -72,8 +72,6 @@
|
|||||||
"lat": 49.183,
|
"lat": 49.183,
|
||||||
"lng": 14.444,
|
"lng": 14.444,
|
||||||
"sparkline": [
|
"sparkline": [
|
||||||
14.18,
|
|
||||||
14.18,
|
|
||||||
18.46,
|
18.46,
|
||||||
14.28,
|
14.28,
|
||||||
5,
|
5,
|
||||||
@@ -83,6 +81,8 @@
|
|||||||
1.25,
|
1.25,
|
||||||
1.25,
|
1.25,
|
||||||
1.25,
|
1.25,
|
||||||
|
1.25,
|
||||||
|
1.25,
|
||||||
1.25
|
1.25
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -91,9 +91,9 @@
|
|||||||
"name": "Kořensko",
|
"name": "Kořensko",
|
||||||
"river": "Vltava",
|
"river": "Vltava",
|
||||||
"priority": false,
|
"priority": false,
|
||||||
"level": "352.43",
|
"level": "352.42",
|
||||||
"capacity": 28.7,
|
"capacity": 28,
|
||||||
"storageDiff": -0.17,
|
"storageDiff": -0.18,
|
||||||
"inflow": "14.1",
|
"inflow": "14.1",
|
||||||
"outflow": "19.0",
|
"outflow": "19.0",
|
||||||
"volume": 2.74,
|
"volume": 2.74,
|
||||||
@@ -120,9 +120,9 @@
|
|||||||
"name": "Orlík",
|
"name": "Orlík",
|
||||||
"river": "Vltava",
|
"river": "Vltava",
|
||||||
"priority": true,
|
"priority": true,
|
||||||
"level": "345.27",
|
"level": "345.26",
|
||||||
"capacity": 63.6,
|
"capacity": 63.6,
|
||||||
"storageDiff": -4.63,
|
"storageDiff": -4.64,
|
||||||
"inflow": "23.8",
|
"inflow": "23.8",
|
||||||
"outflow": "0.0",
|
"outflow": "0.0",
|
||||||
"volume": 522.12,
|
"volume": 522.12,
|
||||||
@@ -130,8 +130,6 @@
|
|||||||
"lat": 49.606,
|
"lat": 49.606,
|
||||||
"lng": 14.17,
|
"lng": 14.17,
|
||||||
"sparkline": [
|
"sparkline": [
|
||||||
186.83,
|
|
||||||
454.38,
|
|
||||||
444.3,
|
444.3,
|
||||||
370.39,
|
370.39,
|
||||||
381.47,
|
381.47,
|
||||||
@@ -141,6 +139,8 @@
|
|||||||
432.41,
|
432.41,
|
||||||
377.67,
|
377.67,
|
||||||
137.48,
|
137.48,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
0
|
0
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -178,9 +178,9 @@
|
|||||||
"name": "Slapy",
|
"name": "Slapy",
|
||||||
"river": "Vltava",
|
"river": "Vltava",
|
||||||
"priority": true,
|
"priority": true,
|
||||||
"level": "269.83",
|
"level": "269.84",
|
||||||
"capacity": 77.3,
|
"capacity": 77.5,
|
||||||
"storageDiff": -0.77,
|
"storageDiff": -0.76,
|
||||||
"inflow": "46.5",
|
"inflow": "46.5",
|
||||||
"outflow": "0.0",
|
"outflow": "0.0",
|
||||||
"volume": 260.21,
|
"volume": 260.21,
|
||||||
@@ -188,8 +188,6 @@
|
|||||||
"lat": 49.822,
|
"lat": 49.822,
|
||||||
"lng": 14.436,
|
"lng": 14.436,
|
||||||
"sparkline": [
|
"sparkline": [
|
||||||
119.44,
|
|
||||||
137.14,
|
|
||||||
310.27,
|
310.27,
|
||||||
308.35,
|
308.35,
|
||||||
304.36,
|
304.36,
|
||||||
@@ -199,6 +197,8 @@
|
|||||||
287.91,
|
287.91,
|
||||||
217.32,
|
217.32,
|
||||||
79.38,
|
79.38,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
0
|
0
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -207,9 +207,9 @@
|
|||||||
"name": "Štěchovice",
|
"name": "Štěchovice",
|
||||||
"river": "Vltava",
|
"river": "Vltava",
|
||||||
"priority": false,
|
"priority": false,
|
||||||
"level": "218.64",
|
"level": "218.72",
|
||||||
"capacity": 65.6,
|
"capacity": 68.8,
|
||||||
"storageDiff": -0.76,
|
"storageDiff": -0.68,
|
||||||
"inflow": "19.9",
|
"inflow": "19.9",
|
||||||
"outflow": "25.3",
|
"outflow": "25.3",
|
||||||
"volume": 9.68,
|
"volume": 9.68,
|
||||||
@@ -217,8 +217,6 @@
|
|||||||
"lat": 49.845,
|
"lat": 49.845,
|
||||||
"lng": 14.412,
|
"lng": 14.412,
|
||||||
"sparkline": [
|
"sparkline": [
|
||||||
25.32,
|
|
||||||
70.8,
|
|
||||||
150.41,
|
150.41,
|
||||||
150.43,
|
150.43,
|
||||||
120.77,
|
120.77,
|
||||||
@@ -228,7 +226,9 @@
|
|||||||
85.34,
|
85.34,
|
||||||
85.17,
|
85.17,
|
||||||
52.56,
|
52.56,
|
||||||
25.32
|
25.32,
|
||||||
|
25.39,
|
||||||
|
25.33
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -310,11 +310,11 @@
|
|||||||
0.7,
|
0.7,
|
||||||
0.7,
|
0.7,
|
||||||
0.7,
|
0.7,
|
||||||
0.7,
|
|
||||||
0.7,
|
|
||||||
0,
|
0,
|
||||||
0.7,
|
0.7,
|
||||||
0.7,
|
0.7,
|
||||||
|
0.7,
|
||||||
|
0.7,
|
||||||
0
|
0
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -327,13 +327,12 @@
|
|||||||
"capacity": 0,
|
"capacity": 0,
|
||||||
"storageDiff": -1.25,
|
"storageDiff": -1.25,
|
||||||
"inflow": "1.5",
|
"inflow": "1.5",
|
||||||
"outflow": "0.0",
|
"outflow": "2.5",
|
||||||
"volume": 32.37,
|
"volume": 32.37,
|
||||||
"maxVolume": 56.7,
|
"maxVolume": 56.7,
|
||||||
"lat": 49.789,
|
"lat": 49.789,
|
||||||
"lng": 13.155,
|
"lng": 13.155,
|
||||||
"sparkline": [
|
"sparkline": [
|
||||||
2.52,
|
|
||||||
2.52,
|
2.52,
|
||||||
2.53,
|
2.53,
|
||||||
2.53,
|
2.53,
|
||||||
@@ -344,7 +343,8 @@
|
|||||||
2.53,
|
2.53,
|
||||||
2.53,
|
2.53,
|
||||||
2.53,
|
2.53,
|
||||||
0
|
2.53,
|
||||||
|
2.53
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -3,6 +3,7 @@ import { Routes, Route, useParams, useLocation, useNavigate, Navigate } from 're
|
|||||||
import LakeDetail from './components/LakeDetail';
|
import LakeDetail from './components/LakeDetail';
|
||||||
import LakesOverview from './components/LakesOverview';
|
import LakesOverview from './components/LakesOverview';
|
||||||
import LakeMap from './components/LakeMap';
|
import LakeMap from './components/LakeMap';
|
||||||
|
import FavoritesOverview from './components/FavoritesOverview';
|
||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
import Topbar from './components/Topbar';
|
import Topbar from './components/Topbar';
|
||||||
import SettingsModal from './components/SettingsModal';
|
import SettingsModal from './components/SettingsModal';
|
||||||
@@ -60,6 +61,7 @@ function App() {
|
|||||||
<Topbar language={language} onToggleMobileMenu={() => setIsMobileMenuOpen(!isMobileMenuOpen)} />
|
<Topbar language={language} onToggleMobileMenu={() => setIsMobileMenuOpen(!isMobileMenuOpen)} />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<LakesOverview language={language} />} />
|
<Route path="/" element={<LakesOverview language={language} />} />
|
||||||
|
<Route path="/favorites" element={<FavoritesOverview language={language} />} />
|
||||||
<Route path="/map" element={<LakeMap language={language} />} />
|
<Route path="/map" element={<LakeMap language={language} />} />
|
||||||
<Route path="/:slug" element={<LakeDetailWrapper language={language} />} />
|
<Route path="/:slug" element={<LakeDetailWrapper language={language} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { FiStar } from 'react-icons/fi';
|
||||||
|
import { type Language } from '../translations';
|
||||||
|
import { useFavorites } from '../hooks/useFavorites';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { slugify } from '../utils/slugify';
|
||||||
|
import { AreaChart, Area, ResponsiveContainer } from 'recharts';
|
||||||
|
import { FiTrendingUp, FiTrendingDown } from 'react-icons/fi';
|
||||||
|
|
||||||
|
interface Lake {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
river: string;
|
||||||
|
priority: boolean;
|
||||||
|
level: number;
|
||||||
|
capacity: number;
|
||||||
|
storageDiff?: number;
|
||||||
|
inflow: number;
|
||||||
|
outflow: number;
|
||||||
|
volume: number;
|
||||||
|
sparkline: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
language: Language;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CircularProgress = ({ value, size = 60, strokeWidth = 6 }: { value: number, size?: number, strokeWidth?: number }) => {
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = radius * 2 * Math.PI;
|
||||||
|
const offset = circumference - (value / 100) * circumference;
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', width: size, height: size }}>
|
||||||
|
<svg width={size} height={size}>
|
||||||
|
<circle stroke="rgba(255,255,255,0.1)" fill="transparent" strokeWidth={strokeWidth} r={radius} cx={size / 2} cy={size / 2} />
|
||||||
|
<circle
|
||||||
|
stroke="var(--color-cyan)" fill="transparent" strokeWidth={strokeWidth} strokeLinecap="round"
|
||||||
|
style={{ strokeDasharray: circumference, strokeDashoffset: offset, transition: 'stroke-dashoffset 0.5s ease-in-out' }}
|
||||||
|
r={radius} cx={size / 2} cy={size / 2} transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: size * 0.25, fontWeight: 'bold' }}>
|
||||||
|
{value > 0 ? `${value}%` : 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FavoritesOverview = ({ language }: Props) => {
|
||||||
|
const [lakes, setLakes] = useState<Lake[]>([]);
|
||||||
|
const { isFavorite, toggleFavorite } = useFavorites();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/data/lakes_index.json')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setLakes(data))
|
||||||
|
.catch(err => console.error(err));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const favoriteLakes = lakes.filter(l => isFavorite(l.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ fontSize: '1.75rem', fontWeight: 'bold', margin: '0 0 0.5rem 0', display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||||
|
<FiStar size={24} fill="#f59e0b" color="#f59e0b" />
|
||||||
|
{language === 'cs' ? 'Oblíbená' : 'Favourites'}
|
||||||
|
</h1>
|
||||||
|
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
||||||
|
{language === 'cs'
|
||||||
|
? 'Jezera připnutá v přehledu. Připnout nebo odepnout lze ikonou hvězdičky.'
|
||||||
|
: 'Lakes you pinned in the overview. Use the star icon to pin or unpin.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{favoriteLakes.length === 0 ? (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||||
|
gap: '1rem', padding: '4rem 2rem', color: 'var(--text-muted)', textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<FiStar size={48} strokeWidth={1.2} color="var(--text-muted)" />
|
||||||
|
<p style={{ margin: 0, fontSize: '1.1rem' }}>
|
||||||
|
{language === 'cs' ? 'Zatím žádná oblíbená jezera.' : 'No favourites yet.'}
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: 0, fontSize: '0.85rem' }}>
|
||||||
|
{language === 'cs'
|
||||||
|
? 'Přejdi na Jezera a nádrže a klikni na ⭐ u jezera, které tě zajímá.'
|
||||||
|
: 'Go to Lakes & Reservoirs and click the ⭐ on any lake to pin it here.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem' }}>
|
||||||
|
{favoriteLakes.map(lake => {
|
||||||
|
const chartData = lake.sparkline.map((val, i) => ({ name: i, value: val }));
|
||||||
|
const isFav = isFavorite(lake.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={lake.id}
|
||||||
|
className="kpi-card priority-lake-card"
|
||||||
|
onClick={() => navigate(`/${slugify(lake.name)}`)}
|
||||||
|
style={{ cursor: 'pointer', padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem', position: 'relative' }}
|
||||||
|
>
|
||||||
|
{/* Unpin button */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); toggleFavorite(lake.id); }}
|
||||||
|
title="Odepnout"
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: '1rem', right: '1rem',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
color: '#f59e0b', transition: 'transform 0.15s',
|
||||||
|
padding: '4px', display: 'flex', alignItems: 'center', zIndex: 2,
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => { e.currentTarget.style.transform = 'scale(1.2)'; }}
|
||||||
|
onMouseOut={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||||
|
>
|
||||||
|
<FiStar size={18} fill="#f59e0b" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0, paddingRight: '2rem' }}>
|
||||||
|
{lake.name} {lake.river ? `- ${lake.river}` : ''}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Water level</div>
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>{lake.level} <span style={{ fontSize: '1rem', fontWeight: 'normal', color: 'var(--text-muted)' }}>m n.m.</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
|
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '1.25rem', fontWeight: 'bold' }}>
|
||||||
|
<span style={{ color: lake.capacity >= 80 ? 'var(--color-green)' : lake.capacity < 40 ? 'var(--color-red)' : 'var(--text-main)' }}>
|
||||||
|
{lake.capacity > 0 ? `${lake.capacity}%` : 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{lake.storageDiff !== undefined && (
|
||||||
|
<div style={{ fontSize: '0.8rem', color: lake.storageDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)', fontWeight: 500 }}>
|
||||||
|
{lake.storageDiff > 0 ? '+' : ''}{lake.storageDiff.toFixed(2)} m
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.85rem' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<FiTrendingUp color="var(--color-green)" />
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>Inflow <span style={{ color: 'var(--color-green)' }}>{lake.inflow} m³/s</span></span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<FiTrendingDown color="var(--color-red)" />
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>Outflow <span style={{ color: 'var(--color-red)' }}>{lake.outflow} m³/s</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FavoritesOverview;
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { FiTrendingUp, FiTrendingDown } from 'react-icons/fi';
|
import { FiTrendingUp, FiTrendingDown, FiStar } from 'react-icons/fi';
|
||||||
import { type Language, t } from '../translations';
|
import { type Language, t } from '../translations';
|
||||||
import { AreaChart, Area, ResponsiveContainer } from 'recharts';
|
import { AreaChart, Area, ResponsiveContainer } from 'recharts';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { slugify } from '../utils/slugify';
|
import { slugify } from '../utils/slugify';
|
||||||
|
import { useFavorites } from '../hooks/useFavorites';
|
||||||
|
|
||||||
interface Lake {
|
interface Lake {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,6 +13,7 @@ interface Lake {
|
|||||||
priority: boolean;
|
priority: boolean;
|
||||||
level: number;
|
level: number;
|
||||||
capacity: number;
|
capacity: number;
|
||||||
|
storageDiff?: number;
|
||||||
inflow: number;
|
inflow: number;
|
||||||
outflow: number;
|
outflow: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
@@ -57,7 +59,7 @@ const CircularProgress = ({ value, size = 60, strokeWidth = 6 }: { value: number
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const LakeCard = ({ lake, language }: { lake: Lake, language: Language }) => {
|
const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language: Language, isFav: boolean, onToggleFav: (id: string) => void }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const chartData = lake.sparkline.map((val, i) => ({ name: i, value: val }));
|
const chartData = lake.sparkline.map((val, i) => ({ name: i, value: val }));
|
||||||
|
|
||||||
@@ -67,7 +69,27 @@ const LakeCard = ({ lake, language }: { lake: Lake, language: Language }) => {
|
|||||||
onClick={() => navigate(`/${slugify(lake.name)}`)}
|
onClick={() => navigate(`/${slugify(lake.name)}`)}
|
||||||
style={{ cursor: 'pointer', flex: 1, padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem', position: 'relative' }}
|
style={{ cursor: 'pointer', flex: 1, padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem', position: 'relative' }}
|
||||||
>
|
>
|
||||||
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>{lake.name} {lake.river ? `- ${lake.river}` : ''}</h3>
|
{/* Star / Favorite button */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggleFav(lake.id); }}
|
||||||
|
title={isFav ? 'Odepnout' : 'Připnout jako oblíbené'}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: '1rem', right: '1rem',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
color: isFav ? '#f59e0b' : 'var(--text-muted)',
|
||||||
|
opacity: isFav ? 1 : 0.4,
|
||||||
|
transition: 'color 0.2s, opacity 0.2s, transform 0.15s',
|
||||||
|
padding: '4px',
|
||||||
|
display: 'flex', alignItems: 'center',
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.transform = 'scale(1.2)'; }}
|
||||||
|
onMouseOut={(e) => { e.currentTarget.style.opacity = isFav ? '1' : '0.4'; e.currentTarget.style.transform = 'scale(1)'; }}
|
||||||
|
>
|
||||||
|
<FiStar size={18} fill={isFav ? '#f59e0b' : 'none'} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0, paddingRight: '2rem' }}>{lake.name} {lake.river ? `- ${lake.river}` : ''}</h3>
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||||
@@ -84,8 +106,16 @@ const LakeCard = ({ lake, language }: { lake: Lake, language: Language }) => {
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
|
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '1.25rem', fontWeight: 'bold' }}>{lake.capacity > 0 ? `${lake.capacity}%` : 'N/A'} / <span style={{ fontSize: '1rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>{lake.volume} mil. m³</span></div>
|
<div style={{ fontSize: '1.25rem', fontWeight: 'bold' }}>
|
||||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Volume</div>
|
<span style={{ color: lake.capacity >= 80 ? 'var(--color-green)' : lake.capacity < 40 ? 'var(--color-red)' : 'var(--text-main)' }}>
|
||||||
|
{lake.capacity > 0 ? `${lake.capacity}%` : 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{lake.storageDiff !== undefined && (
|
||||||
|
<div style={{ fontSize: '0.8rem', color: lake.storageDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)', fontWeight: 500 }}>
|
||||||
|
{lake.storageDiff > 0 ? '+' : ''}{lake.storageDiff.toFixed(2)} m
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,8 +126,8 @@ const LakeCard = ({ lake, language }: { lake: Lake, language: Language }) => {
|
|||||||
<AreaChart data={chartData}>
|
<AreaChart data={chartData}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="colorSpark" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="colorSpark" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.8}/>
|
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.8} />
|
||||||
<stop offset="95%" stopColor="var(--color-cyan)" stopOpacity={0}/>
|
<stop offset="95%" stopColor="var(--color-cyan)" stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<Area type="monotone" dataKey="value" stroke="var(--color-cyan)" fillOpacity={1} fill="url(#colorSpark)" />
|
<Area type="monotone" dataKey="value" stroke="var(--color-cyan)" fillOpacity={1} fill="url(#colorSpark)" />
|
||||||
@@ -120,9 +150,55 @@ const LakeCard = ({ lake, language }: { lake: Lake, language: Language }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SmallLakeCard = ({ lake, isFav, onToggleFav }: { lake: Lake, isFav: boolean, onToggleFav: (id: string) => void }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="kpi-card"
|
||||||
|
onClick={() => navigate(`/${slugify(lake.name)}`)}
|
||||||
|
style={{ cursor: 'pointer', padding: '1rem', display: 'flex', flexDirection: 'column', gap: '0.5rem', position: 'relative' }}
|
||||||
|
>
|
||||||
|
{/* Star button */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggleFav(lake.id); }}
|
||||||
|
title={isFav ? 'Odepnout' : 'Připnout jako oblíbené'}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: '0.6rem', right: '0.6rem',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
color: isFav ? '#f59e0b' : 'var(--text-muted)',
|
||||||
|
opacity: isFav ? 1 : 0.4,
|
||||||
|
transition: 'color 0.2s, opacity 0.2s, transform 0.15s',
|
||||||
|
padding: '2px',
|
||||||
|
display: 'flex', alignItems: 'center',
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.transform = 'scale(1.2)'; }}
|
||||||
|
onMouseOut={(e) => { e.currentTarget.style.opacity = isFav ? '1' : '0.4'; e.currentTarget.style.transform = 'scale(1)'; }}
|
||||||
|
>
|
||||||
|
<FiStar size={14} fill={isFav ? '#f59e0b' : 'none'} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ fontSize: '0.85rem', fontWeight: 'bold', paddingRight: '1.5rem', lineHeight: 1.2 }}>{lake.name}</div>
|
||||||
|
<div style={{ fontSize: '1.1rem', fontWeight: 'bold', color: 'var(--color-cyan)' }}>{lake.level} <span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>m n.m.</span></div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
||||||
|
<span style={{ color: lake.capacity >= 80 ? 'var(--color-green)' : lake.capacity < 40 ? 'var(--color-red)' : 'var(--text-muted)', fontWeight: 600 }}>
|
||||||
|
{lake.capacity > 0 ? `${lake.capacity}%` : 'N/A'}
|
||||||
|
</span>
|
||||||
|
{lake.storageDiff !== undefined && (
|
||||||
|
<span style={{ color: lake.storageDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)', marginLeft: '4px' }}>
|
||||||
|
({lake.storageDiff > 0 ? '+' : ''}{lake.storageDiff.toFixed(2)} m)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const LakesOverview = ({ language }: Props) => {
|
const LakesOverview = ({ language }: Props) => {
|
||||||
const [lakes, setLakes] = useState<Lake[]>([]);
|
const [lakes, setLakes] = useState<Lake[]>([]);
|
||||||
const [sortBy, setSortBy] = useState<'name' | 'level' | 'capacity' | 'inflow'>('name');
|
const [sortBy, setSortBy] = useState<'name' | 'level' | 'capacity' | 'inflow'>('name');
|
||||||
|
const { isFavorite, toggleFavorite, favorites } = useFavorites();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/data/lakes_index.json')
|
fetch('/data/lakes_index.json')
|
||||||
@@ -131,8 +207,9 @@ const LakesOverview = ({ language }: Props) => {
|
|||||||
.catch(err => console.error(err));
|
.catch(err => console.error(err));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const priorityLakes = lakes.filter(l => l.priority);
|
const favoriteLakes = lakes.filter(l => isFavorite(l.id));
|
||||||
const otherLakes = lakes.filter(l => !l.priority);
|
const priorityLakes = lakes.filter(l => l.priority && !isFavorite(l.id));
|
||||||
|
const otherLakes = lakes.filter(l => !l.priority && !isFavorite(l.id));
|
||||||
|
|
||||||
otherLakes.sort((a, b) => {
|
otherLakes.sort((a, b) => {
|
||||||
if (sortBy === 'name') return a.name.localeCompare(b.name);
|
if (sortBy === 'name') return a.name.localeCompare(b.name);
|
||||||
@@ -164,6 +241,24 @@ const LakesOverview = ({ language }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Favorites section */}
|
||||||
|
{favoriteLakes.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<FiStar size={16} fill="#f59e0b" color="#f59e0b" /> Oblíbená ({favoriteLakes.length})
|
||||||
|
</h2>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
||||||
|
gap: '1.5rem'
|
||||||
|
}}>
|
||||||
|
{favoriteLakes.map(lake => (
|
||||||
|
<LakeCard key={lake.id} lake={lake} language={language} isFav={true} onToggleFav={toggleFavorite} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{priorityLakes.length > 0 && (
|
{priorityLakes.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>Priority Reservoirs</h2>
|
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>Priority Reservoirs</h2>
|
||||||
@@ -172,7 +267,7 @@ const LakesOverview = ({ language }: Props) => {
|
|||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
||||||
gap: '1.5rem'
|
gap: '1.5rem'
|
||||||
}}>
|
}}>
|
||||||
{priorityLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} />)}
|
{priorityLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} isFav={false} onToggleFav={toggleFavorite} />)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -184,7 +279,9 @@ const LakesOverview = ({ language }: Props) => {
|
|||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||||
gap: '1rem'
|
gap: '1rem'
|
||||||
}}>
|
}}>
|
||||||
{otherLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} />)}
|
{otherLakes.map(lake => (
|
||||||
|
<SmallLakeCard key={lake.id} lake={lake} isFav={isFavorite(lake.id)} onToggleFav={toggleFavorite} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { FiDroplet, FiStar, FiMap, FiSettings, FiChevronLeft, FiChevronRight, FiDatabase } from 'react-icons/fi';
|
import { FiDroplet, FiStar, FiMap, FiSettings, FiChevronLeft, FiChevronRight, FiDatabase } from 'react-icons/fi';
|
||||||
import { type Language, t } from '../translations';
|
import { type Language, t } from '../translations';
|
||||||
|
import { useFavorites } from '../hooks/useFavorites';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
language: Language;
|
language: Language;
|
||||||
@@ -15,10 +16,11 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const dict = t[language].sidebar;
|
const dict = t[language].sidebar;
|
||||||
|
const { favorites } = useFavorites();
|
||||||
|
|
||||||
const isOverview = location.pathname === '/';
|
const isOverview = location.pathname === '/';
|
||||||
|
const isFavoritesPage = location.pathname === '/favorites';
|
||||||
const isMap = location.pathname === '/map';
|
const isMap = location.pathname === '/map';
|
||||||
const isDetail = !isOverview && !isMap;
|
|
||||||
|
|
||||||
const handleNavigate = (path: string) => {
|
const handleNavigate = (path: string) => {
|
||||||
navigate(path);
|
navigate(path);
|
||||||
@@ -49,14 +51,37 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="nav-links">
|
<div className="nav-links">
|
||||||
<div className={`nav-item ${isDetail ? 'active' : ''}`} onClick={() => handleNavigate('/lipno-1')}>
|
{/* Favourites */}
|
||||||
<FiStar />
|
<div className={`nav-item ${isFavoritesPage ? 'active' : ''}`} onClick={() => handleNavigate('/favorites')} style={{ position: 'relative' }}>
|
||||||
|
<FiStar fill={favorites.length > 0 ? '#f59e0b' : 'none'} color={favorites.length > 0 ? '#f59e0b' : 'currentColor'} />
|
||||||
<span className="sidebar-text">{dict.favorites}</span>
|
<span className="sidebar-text">{dict.favorites}</span>
|
||||||
|
{favorites.length > 0 && (
|
||||||
|
<span style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
backgroundColor: '#f59e0b',
|
||||||
|
color: '#000',
|
||||||
|
borderRadius: '999px',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
minWidth: '18px',
|
||||||
|
height: '18px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '0 5px',
|
||||||
|
}}>
|
||||||
|
{favorites.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Lakes & Reservoirs */}
|
||||||
<div className={`nav-item ${isOverview ? 'active' : ''}`} onClick={() => handleNavigate('/')}>
|
<div className={`nav-item ${isOverview ? 'active' : ''}`} onClick={() => handleNavigate('/')}>
|
||||||
<FiDatabase />
|
<FiDatabase />
|
||||||
<span className="sidebar-text">{dict.lakes}</span>
|
<span className="sidebar-text">{dict.lakes}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Map */}
|
||||||
<div className={`nav-item ${isMap ? 'active' : ''}`} onClick={() => handleNavigate('/map')}>
|
<div className={`nav-item ${isMap ? 'active' : ''}`} onClick={() => handleNavigate('/map')}>
|
||||||
<FiMap />
|
<FiMap />
|
||||||
<span className="sidebar-text">{dict.map}</span>
|
<span className="sidebar-text">{dict.map}</span>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'hladinator_favorites';
|
||||||
|
|
||||||
|
const loadFavorites = (): string[] => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FavoritesContextType {
|
||||||
|
favorites: string[];
|
||||||
|
toggleFavorite: (id: string) => void;
|
||||||
|
isFavorite: (id: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FavoritesContext = createContext<FavoritesContextType>({
|
||||||
|
favorites: [],
|
||||||
|
toggleFavorite: () => {},
|
||||||
|
isFavorite: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FavoritesProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [favorites, setFavorites] = useState<string[]>(loadFavorites);
|
||||||
|
|
||||||
|
const toggleFavorite = useCallback((id: string) => {
|
||||||
|
setFavorites(prev => {
|
||||||
|
const next = prev.includes(id) ? prev.filter(f => f !== id) : [...prev, id];
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isFavorite = useCallback((id: string) => favorites.includes(id), [favorites]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FavoritesContext.Provider value={{ favorites, toggleFavorite, isFavorite }}>
|
||||||
|
{children}
|
||||||
|
</FavoritesContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFavorites = () => useContext(FavoritesContext);
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { FavoritesProvider } from './hooks/useFavorites'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<FavoritesProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</FavoritesProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user