demeter
Autonomous Hydroponic Intelligencegit clone https://github.com/maydayv7/demeter
frontend/src/components/Sidebar.jsx (10853B)
1import React, { useState, useMemo } from "react";
2import { Link, useLocation } from "react-router-dom";
3import {
4 Leaf,
5 LayoutGrid,
6 BarChart3,
7 Bell,
8 Settings,
9 Sparkles,
10 HelpCircle,
11 ChevronLeft,
12 ChevronRight,
13 Shield,
14} from "lucide-react";
15import { useFarmData } from "../hooks/useFarmData";
16import { deriveCropStatus, isReadyToHarvest } from "../utils/dataUtils";
17import { useSettings } from "../hooks/useSettings";
18import { useT } from "../hooks/useTranslation";
19
20export default function Sidebar() {
21 const [collapsed, setCollapsed] = useState(false);
22 const loc = useLocation();
23 const { dashboard } = useFarmData();
24 const { settings } = useSettings();
25 const { t } = useT();
26
27 const alertCount = useMemo(() => {
28 if (!dashboard?.length) return 0;
29 return dashboard.filter((d) => {
30 const payload = d.payload || d;
31 const status = deriveCropStatus(payload);
32 return status === "Critical" || status === "Attention";
33 }).length;
34 }, [dashboard]);
35
36 const harvestCount = useMemo(() => {
37 if (!dashboard?.length) return 0;
38 return dashboard.filter((d) => {
39 const payload = d.payload || d;
40 return isReadyToHarvest(payload);
41 }).length;
42 }, [dashboard]);
43
44 const totalBadge = alertCount + harvestCount;
45
46 const NAV = [
47 { labelKey: "nav_crops", icon: LayoutGrid, path: "/dashboard" },
48 {
49 labelKey: "nav_alerts",
50 icon: Bell,
51 path: "/alerts",
52 badge: totalBadge || null,
53 harvest: harvestCount,
54 },
55 { labelKey: "nav_analytics", icon: BarChart3, path: "/analytics" },
56 { labelKey: "nav_intelligence", icon: Sparkles, path: "/intelligence" },
57 { labelKey: "nav_help", icon: HelpCircle, path: "/help" },
58 { labelKey: "nav_settings", icon: Settings, path: "/settings" },
59 ];
60
61 const initials =
62 settings.userInitials ||
63 (settings.userName || "?")
64 .split(" ")
65 .map((w) => w[0])
66 .join("")
67 .toUpperCase()
68 .slice(0, 2);
69
70 return (
71 <aside
72 style={{
73 width: collapsed ? 64 : 220,
74 background: "var(--bg-2)",
75 borderRight: "1px solid var(--border)",
76 flexShrink: 0,
77 display: "flex",
78 flexDirection: "column",
79 position: "relative",
80 transition: "width 300ms ease",
81 overflow: "visible",
82 }}
83 >
84 {/* Logo */}
85 <div
86 style={{
87 display: "flex",
88 alignItems: "center",
89 gap: 12,
90 padding: collapsed ? "20px 0" : "20px 16px",
91 justifyContent: collapsed ? "center" : "flex-start",
92 borderBottom: "1px solid var(--border)",
93 height: 64,
94 boxSizing: "border-box",
95 }}
96 >
97 <div
98 style={{
99 width: 32,
100 height: 32,
101 borderRadius: 10,
102 flexShrink: 0,
103 background: "linear-gradient(135deg, #2d7a44, #4ade80)",
104 display: "flex",
105 alignItems: "center",
106 justifyContent: "center",
107 }}
108 >
109 <Leaf size={15} fill="white" color="white" />
110 </div>
111 {!collapsed && (
112 <div style={{ overflow: "hidden" }}>
113 <div
114 style={{
115 fontWeight: 700,
116 fontSize: 15,
117 color: "var(--text)",
118 lineHeight: 1.2,
119 }}
120 >
121 Demeter
122 </div>
123 <div
124 style={{
125 fontSize: 9,
126 fontFamily: "DM Mono, monospace",
127 color: "var(--text-3)",
128 letterSpacing: "0.1em",
129 }}
130 >
131 {t("sidebar_agri_ai")}
132 </div>
133 </div>
134 )}
135 </div>
136
137 {/* Collapse toggle */}
138 <button
139 onClick={() => setCollapsed(!collapsed)}
140 style={{
141 position: "absolute",
142 top: 20,
143 right: -12,
144 width: 24,
145 height: 24,
146 borderRadius: "50%",
147 background: "var(--bg-3)",
148 border: "1px solid var(--border)",
149 color: "var(--text-3)",
150 display: "flex",
151 alignItems: "center",
152 justifyContent: "center",
153 cursor: "pointer",
154 zIndex: 10,
155 }}
156 >
157 {collapsed ? <ChevronRight size={11} /> : <ChevronLeft size={11} />}
158 </button>
159
160 {/* System status pill */}
161 {!collapsed && (
162 <div style={{ padding: "12px 12px 0" }}>
163 <div
164 style={{
165 display: "flex",
166 alignItems: "center",
167 gap: 6,
168 padding: "6px 10px",
169 borderRadius: 8,
170 background: "rgba(74,222,128,0.08)",
171 border: "1px solid rgba(74,222,128,0.2)",
172 }}
173 >
174 <span
175 className="status-dot"
176 style={{
177 width: 6,
178 height: 6,
179 borderRadius: "50%",
180 background: "var(--green)",
181 flexShrink: 0,
182 }}
183 />
184 <span
185 style={{
186 fontSize: 10,
187 fontFamily: "DM Mono, monospace",
188 color: "var(--green)",
189 }}
190 >
191 {t("nav_system_online")}
192 </span>
193 </div>
194 </div>
195 )}
196
197 {/* Nav */}
198 <nav
199 style={{
200 flex: 1,
201 padding: "12px 8px",
202 display: "flex",
203 flexDirection: "column",
204 gap: 2,
205 }}
206 >
207 {NAV.map(({ labelKey, icon: Icon, path, badge, harvest }) => {
208 const active = loc.pathname === path;
209 const label = t(labelKey);
210 return (
211 <Link
212 key={labelKey}
213 to={path}
214 title={collapsed ? label : undefined}
215 style={{
216 display: "flex",
217 alignItems: "center",
218 gap: 10,
219 padding: collapsed ? "10px 0" : "10px 12px",
220 justifyContent: collapsed ? "center" : "flex-start",
221 borderRadius: 10,
222 background: active ? "rgba(74,222,128,0.12)" : "transparent",
223 color: active ? "var(--green)" : "var(--text-2)",
224 border: active
225 ? "1px solid rgba(74,222,128,0.25)"
226 : "1px solid transparent",
227 textDecoration: "none",
228 position: "relative",
229 transition: "all 0.15s",
230 }}
231 >
232 <Icon size={17} style={{ flexShrink: 0 }} />
233 {!collapsed && (
234 <span style={{ fontSize: 13, fontWeight: 500 }}>{label}</span>
235 )}
236
237 {/* Badge (expanded) */}
238 {badge && !collapsed && (
239 <div
240 style={{
241 marginLeft: "auto",
242 display: "flex",
243 alignItems: "center",
244 gap: 4,
245 }}
246 >
247 {alertCount > 0 && (
248 <span
249 className="alert-pulse"
250 style={{
251 fontSize: 10,
252 fontFamily: "DM Mono, monospace",
253 padding: "1px 5px",
254 borderRadius: 4,
255 background: "rgba(248,113,113,0.2)",
256 color: "var(--red)",
257 }}
258 >
259 {alertCount}
260 </span>
261 )}
262 {harvest > 0 && (
263 <span
264 className="harvest-pulse"
265 style={{
266 fontSize: 10,
267 fontFamily: "DM Mono, monospace",
268 padding: "1px 5px",
269 borderRadius: 4,
270 background: "rgba(245,158,11,0.2)",
271 color: "var(--amber)",
272 }}
273 >
274 🌾{harvest}
275 </span>
276 )}
277 </div>
278 )}
279
280 {badge && collapsed && (
281 <span
282 className="alert-pulse"
283 style={{
284 position: "absolute",
285 top: 6,
286 right: 6,
287 width: 8,
288 height: 8,
289 borderRadius: "50%",
290 background: "var(--red)",
291 }}
292 />
293 )}
294 </Link>
295 );
296 })}
297 </nav>
298
299 {/* User avatar */}
300 <div
301 style={{
302 borderTop: "1px solid var(--border)",
303 padding: collapsed ? "12px 0" : "12px",
304 display: "flex",
305 alignItems: "center",
306 gap: 10,
307 justifyContent: collapsed ? "center" : "flex-start",
308 }}
309 >
310 {/* Avatar */}
311 <div
312 style={{
313 width: 34,
314 height: 34,
315 borderRadius: "50%",
316 background: "linear-gradient(135deg, #2d7a44, #4ade80)",
317 display: "flex",
318 alignItems: "center",
319 justifyContent: "center",
320 flexShrink: 0,
321 fontSize: 12,
322 fontWeight: 700,
323 color: "#fff",
324 letterSpacing: "0.05em",
325 boxShadow: "0 2px 6px rgba(74,222,128,0.3)",
326 }}
327 >
328 {initials}
329 </div>
330
331 {!collapsed && (
332 <div
333 style={{
334 minWidth: 0,
335 flex: 1,
336 animation: "fadeSlideIn 200ms ease",
337 }}
338 >
339 <div
340 style={{
341 fontSize: 13,
342 fontWeight: 600,
343 color: "var(--text)",
344 whiteSpace: "nowrap",
345 overflow: "hidden",
346 textOverflow: "ellipsis",
347 }}
348 >
349 {settings.userName}
350 </div>
351 <div
352 style={{
353 display: "flex",
354 alignItems: "center",
355 gap: 4,
356 marginTop: 1,
357 }}
358 >
359 <Shield
360 size={9}
361 style={{ color: "var(--green)", flexShrink: 0 }}
362 />
363 <span
364 style={{
365 fontSize: 10,
366 fontFamily: "DM Mono, monospace",
367 color: "var(--green)",
368 letterSpacing: "0.04em",
369 }}
370 >
371 {settings.userDesignation}
372 </span>
373 </div>
374 </div>
375 )}
376 </div>
377 </aside>
378 );
379}