demeter

Autonomous Hydroponic Intelligence
commit d99c4820fac662ec26fd7af6d4ba42046436baa4
parent 19605dea2fe7f0085fc7545fced2ed2f838ba7a8
Author: maydayv7 <maydayv7@gmail.com>
Date:   Fri, 27 Mar 2026 04:38:24 +0530

Frontend refinement

- Common UI Components
- Animations
- Behaviour

Diffstat:
Afrontend/src/components/ui/ChartTooltip.jsx | 27+++++++++++++++++++++++++++
Afrontend/src/components/ui/EmptyState.jsx | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afrontend/src/components/ui/FilterBar.jsx | 20++++++++++++++++++++
Afrontend/src/components/ui/FilterPill.jsx | 28++++++++++++++++++++++++++++
Afrontend/src/components/ui/IconButton.jsx | 31+++++++++++++++++++++++++++++++
Afrontend/src/components/ui/InlineLabel.jsx | 10++++++++++
Afrontend/src/components/ui/LoadingShimmer.jsx | 30++++++++++++++++++++++++++++++
Afrontend/src/components/ui/PageHeader.jsx | 22++++++++++++++++++++++
Afrontend/src/components/ui/PageShell.jsx | 27+++++++++++++++++++++++++++
Afrontend/src/components/ui/Pagination.jsx | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afrontend/src/components/ui/PrimaryButton.jsx | 35+++++++++++++++++++++++++++++++++++
Afrontend/src/components/ui/SearchBar.jsx | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afrontend/src/components/ui/SectionCard.jsx | 22++++++++++++++++++++++
Afrontend/src/components/ui/SelectField.jsx | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Afrontend/src/components/ui/StatusBadge.jsx | 44++++++++++++++++++++++++++++++++++++++++++++
Afrontend/src/components/ui/index.js | 15+++++++++++++++
Mfrontend/src/index.css | 27+++++++++++++++++++++++++++
Mfrontend/src/pages/AddCrop.jsx | 87+++++++++++++++++++++----------------------------------------------------------
Mfrontend/src/pages/Alerts.jsx | 535++++++++++++++++++++++++++++++++++++-------------------------------------------
Mfrontend/src/pages/Analytics.jsx | 1170++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mfrontend/src/pages/CropDetails.jsx | 1207+++++++++++++++++++++++++++++++++++++------------------------------------------
Mfrontend/src/pages/Dashboard.jsx | 979+++++++++++++++++++++++++++----------------------------------------------------
Mfrontend/src/pages/FarmIntelligence.jsx | 57+++++++++++++--------------------------------------------
Mfrontend/src/pages/Help.jsx | 76++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mfrontend/src/pages/Settings.jsx | 81++++++++++++++++++++++---------------------------------------------------------
Mfrontend/src/utils/translations.js | 2++
26 files changed, 2409 insertions(+), 2393 deletions(-)

diff --git a/frontend/src/components/ui/ChartTooltip.jsx b/frontend/src/components/ui/ChartTooltip.jsx @@ -0,0 +1,27 @@ +/** + * Shared Custom Tooltip for Recharts components + */ +export default function ChartTooltip({ active, payload, label }) { + if (!active || !payload?.length) return null; + return ( + <div + style={{ + padding: "8px 12px", + borderRadius: 8, + fontSize: 12, + fontFamily: "DM Mono, monospace", + background: "var(--tooltip-bg)", + border: "1px solid var(--border)", + color: "var(--text)", + boxShadow: "var(--shadow)", + }} + > + <div style={{ color: "var(--text-3)", marginBottom: 4 }}>{label}</div> + {payload.map((p) => ( + <div key={p.dataKey} style={{ color: p.color, marginTop: 2 }}> + {p.name}: <strong>{p.value}</strong> + </div> + ))} + </div> + ); +} diff --git a/frontend/src/components/ui/EmptyState.jsx b/frontend/src/components/ui/EmptyState.jsx @@ -0,0 +1,67 @@ +export default function EmptyState({ + icon: Icon, + title, + description, + style = {}, + className = "", +}) { + return ( + <div + className={className} + style={{ + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + height: "100%", + gap: 16, + padding: 40, + ...style, + }} + > + {Icon && ( + <div + style={{ + width: 64, + height: 64, + borderRadius: 20, + background: "rgba(74,222,128,0.06)", + border: "1px solid rgba(74,222,128,0.15)", + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <Icon size={28} style={{ color: "var(--green)" }} /> + </div> + )} + <div style={{ textAlign: "center" }}> + {title && ( + <div + style={{ + fontWeight: 700, + fontSize: 15, + color: "var(--text-2)", + marginBottom: 8, + }} + > + {title} + </div> + )} + {description && ( + <div + style={{ + fontSize: 13, + color: "var(--text-3)", + maxWidth: 300, + lineHeight: 1.5, + margin: "0 auto", + }} + > + {description} + </div> + )} + </div> + </div> + ); +} diff --git a/frontend/src/components/ui/FilterBar.jsx b/frontend/src/components/ui/FilterBar.jsx @@ -0,0 +1,20 @@ +export default function FilterBar({ children, style = {}, className = "" }) { + return ( + <div + className={className} + style={{ + flexShrink: 0, + padding: "8px 24px", + borderBottom: "1px solid var(--border)", + background: "var(--bg-2)", + display: "flex", + alignItems: "center", + flexWrap: "wrap", + gap: 12, + ...style, + }} + > + {children} + </div> + ); +} diff --git a/frontend/src/components/ui/FilterPill.jsx b/frontend/src/components/ui/FilterPill.jsx @@ -0,0 +1,28 @@ +export default function FilterPill({ + active, + onClick, + children, + className = "", + style = {}, +}) { + return ( + <button + onClick={onClick} + className={className} + style={{ + padding: "5px 12px", + borderRadius: 20, + fontSize: 11, + fontFamily: "DM Mono, monospace", + cursor: "pointer", + background: active ? "rgba(74,222,128,0.15)" : "var(--surface)", + border: `1px solid ${active ? "rgba(74,222,128,0.4)" : "var(--border)"}`, + color: active ? "var(--green)" : "var(--text-3)", + flexShrink: 0, + ...style, + }} + > + {children} + </button> + ); +} diff --git a/frontend/src/components/ui/IconButton.jsx b/frontend/src/components/ui/IconButton.jsx @@ -0,0 +1,31 @@ +export default function IconButton({ + onClick, + children, + className = "", + style = {}, + ...props +}) { + return ( + <button + onClick={onClick} + className={className} + style={{ + width: 34, + height: 34, + borderRadius: 8, + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--text-3)", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + flexShrink: 0, + ...style, + }} + {...props} + > + {children} + </button> + ); +} diff --git a/frontend/src/components/ui/InlineLabel.jsx b/frontend/src/components/ui/InlineLabel.jsx @@ -0,0 +1,10 @@ +export default function InlineLabel({ children, className = "", style = {} }) { + return ( + <div + className={`section-label ${className}`} + style={{ marginBottom: 0, ...style }} + > + {children} + </div> + ); +} diff --git a/frontend/src/components/ui/LoadingShimmer.jsx b/frontend/src/components/ui/LoadingShimmer.jsx @@ -0,0 +1,30 @@ +export default function LoadingShimmer({ + count = 3, + height = 80, + style = {}, + className = "", +}) { + return ( + <div + style={{ + display: "flex", + flexDirection: "column", + gap: 12, + ...style, + }} + className={className} + > + {Array.from({ length: count }).map((_, i) => ( + <div + key={i} + className="shimmer" + style={{ + height: height, + borderRadius: 12, + border: "1px solid var(--border)", + }} + /> + ))} + </div> + ); +} diff --git a/frontend/src/components/ui/PageHeader.jsx b/frontend/src/components/ui/PageHeader.jsx @@ -0,0 +1,22 @@ +export default function PageHeader({ title, subtitle, children }) { + return ( + <header + style={{ + flexShrink: 0, + padding: "0 24px", + height: 64, + borderBottom: "1px solid var(--border)", + background: "var(--bg-2)", + display: "flex", + alignItems: "center", + gap: 16, + }} + > + <div> + <h1 className="page-title">{title}</h1> + {subtitle && <p className="page-subtitle">{subtitle}</p>} + </div> + {children} + </header> + ); +} diff --git a/frontend/src/components/ui/PageShell.jsx b/frontend/src/components/ui/PageShell.jsx @@ -0,0 +1,27 @@ +import Sidebar from "../Sidebar"; + +export default function PageShell({ children }) { + return ( + <div + style={{ + display: "flex", + height: "100vh", + overflow: "hidden", + background: "var(--bg)", + }} + > + <Sidebar /> + <main + className="animate-fade-in" + style={{ + flex: 1, + display: "flex", + flexDirection: "column", + overflow: "hidden", + }} + > + {children} + </main> + </div> + ); +} diff --git a/frontend/src/components/ui/Pagination.jsx b/frontend/src/components/ui/Pagination.jsx @@ -0,0 +1,83 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; + +export default function Pagination({ + page, + totalPages, + setPage, + style = {}, + className = "", +}) { + if (totalPages <= 1) return null; + return ( + <div + className={className} + style={{ + display: "flex", + justifyContent: "center", + alignItems: "center", + gap: 8, + marginTop: 20, + ...style, + }} + > + <button + onClick={() => setPage((p) => Math.max(1, p - 1))} + disabled={page === 1} + style={{ + width: 32, + height: 32, + borderRadius: 8, + cursor: page === 1 ? "not-allowed" : "pointer", + background: "var(--surface)", + border: "1px solid var(--border)", + color: page === 1 ? "var(--text-3)" : "var(--text-2)", + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <ChevronLeft size={14} /> + </button> + + {Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => ( + <button + key={n} + onClick={() => setPage(n)} + style={{ + width: 32, + height: 32, + borderRadius: 8, + cursor: "pointer", + fontFamily: "DM Mono, monospace", + fontSize: 12, + background: n === page ? "var(--green)" : "var(--surface)", + border: `1px solid ${n === page ? "transparent" : "var(--border)"}`, + color: n === page ? "var(--btn-on-green)" : "var(--text-2)", + fontWeight: n === page ? 700 : 400, + }} + > + {n} + </button> + ))} + + <button + onClick={() => setPage((p) => Math.min(totalPages, p + 1))} + disabled={page === totalPages} + style={{ + width: 32, + height: 32, + borderRadius: 8, + cursor: page === totalPages ? "not-allowed" : "pointer", + background: "var(--surface)", + border: "1px solid var(--border)", + color: page === totalPages ? "var(--text-3)" : "var(--text-2)", + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <ChevronRight size={14} /> + </button> + </div> + ); +} diff --git a/frontend/src/components/ui/PrimaryButton.jsx b/frontend/src/components/ui/PrimaryButton.jsx @@ -0,0 +1,35 @@ +export default function PrimaryButton({ + onClick, + children, + icon: Icon, + className = "", + style = {}, + ...props +}) { + return ( + <button + onClick={onClick} + className={className} + style={{ + display: "flex", + alignItems: "center", + gap: 7, + padding: "8px 18px", + borderRadius: 10, + fontSize: 13, + fontWeight: 600, + background: "var(--green)", + border: "none", + color: "var(--btn-on-green)", + cursor: "pointer", + boxShadow: "0 0 16px rgba(74,222,128,0.2)", + flexShrink: 0, + ...style, + }} + {...props} + > + {Icon && <Icon size={15} />} + {children} + </button> + ); +} diff --git a/frontend/src/components/ui/SearchBar.jsx b/frontend/src/components/ui/SearchBar.jsx @@ -0,0 +1,69 @@ +import { Search, X } from "lucide-react"; + +export default function SearchBar({ + value, + onChange, + onClear, + placeholder, + style = {}, + className = "", +}) { + return ( + <div + style={{ position: "relative", flex: 1, maxWidth: 360, ...style }} + className={className} + > + <Search + size={14} + style={{ + position: "absolute", + left: 10, + top: "50%", + transform: "translateY(-50%)", + color: "var(--text-3)", + }} + /> + <input + value={value} + onChange={(e) => onChange(e.target.value)} + placeholder={placeholder} + style={{ + width: "100%", + paddingLeft: 32, + paddingRight: value ? 28 : 12, + paddingTop: 7, + paddingBottom: 7, + borderRadius: 8, + fontSize: 13, + fontFamily: "DM Mono, monospace", + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--text)", + outline: "none", + caretColor: "var(--green)", + }} + /> + {value && ( + <button + onClick={onClear} + style={{ + position: "absolute", + right: 8, + top: "50%", + transform: "translateY(-50%)", + background: "none", + border: "none", + cursor: "pointer", + padding: 0, + color: "var(--text-3)", + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <X size={12} /> + </button> + )} + </div> + ); +} diff --git a/frontend/src/components/ui/SectionCard.jsx b/frontend/src/components/ui/SectionCard.jsx @@ -0,0 +1,22 @@ +export default function SectionCard({ + children, + className = "", + style = {}, + ...props +}) { + return ( + <div + className={className} + style={{ + borderRadius: 14, + padding: 20, + background: "var(--surface)", + border: "1px solid var(--border)", + ...style, + }} + {...props} + > + {children} + </div> + ); +} diff --git a/frontend/src/components/ui/SelectField.jsx b/frontend/src/components/ui/SelectField.jsx @@ -0,0 +1,51 @@ +import { ChevronDown } from "lucide-react"; + +export default function SelectField({ + value, + onChange, + options = [], + style = {}, + className = "", + ...props +}) { + // Options should be an array of { value, label } + return ( + <div style={{ position: "relative", ...style }} className={className}> + <select + value={value} + onChange={onChange} + style={{ + appearance: "none", + padding: "5px 24px 5px 10px", + borderRadius: 8, + fontSize: 12, + fontFamily: "DM Mono, monospace", + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--text-2)", + cursor: "pointer", + outline: "none", + width: "100%", + }} + {...props} + > + {options.map((opt, i) => ( + <option key={i} value={opt.value}> + {opt.label} + </option> + ))} + </select> + <ChevronDown + size={10} + style={{ + position: "absolute", + right: 8, + top: "50%", + transform: "translateY(-50%)", + pointerEvents: "none", + color: "var(--text-3)", + }} + /> + </div> + ); +} diff --git a/frontend/src/components/ui/StatusBadge.jsx b/frontend/src/components/ui/StatusBadge.jsx @@ -0,0 +1,44 @@ +export const STATUS_COLORS = { + Healthy: { + bg: "rgba(74,222,128,0.12)", + text: "var(--green)", + border: "rgba(74,222,128,0.3)", + }, + Attention: { + bg: "rgba(245,158,11,0.12)", + text: "var(--amber)", + border: "rgba(245,158,11,0.3)", + }, + Critical: { + bg: "rgba(248,113,113,0.12)", + text: "var(--red)", + border: "rgba(248,113,113,0.3)", + }, +}; + +export default function StatusBadge({ + status, + label, + className = "", + style = {}, +}) { + const st = STATUS_COLORS[status] || STATUS_COLORS.Healthy; + return ( + <div + className={className} + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + padding: "3px 8px", + borderRadius: 20, + background: st.bg, + color: st.text, + border: `1px solid ${st.border}`, + display: "inline-block", + ...style, + }} + > + {label || status.toUpperCase()} + </div> + ); +} diff --git a/frontend/src/components/ui/index.js b/frontend/src/components/ui/index.js @@ -0,0 +1,15 @@ +export { default as PageShell } from "./PageShell"; +export { default as PageHeader } from "./PageHeader"; +export { default as IconButton } from "./IconButton"; +export { default as PrimaryButton } from "./PrimaryButton"; +export { default as SearchBar } from "./SearchBar"; +export { default as FilterBar } from "./FilterBar"; +export { default as FilterPill } from "./FilterPill"; +export { default as SelectField } from "./SelectField"; +export { default as SectionCard } from "./SectionCard"; +export { default as ChartTooltip } from "./ChartTooltip"; +export { default as StatusBadge } from "./StatusBadge"; +export { default as LoadingShimmer } from "./LoadingShimmer"; +export { default as EmptyState } from "./EmptyState"; +export { default as Pagination } from "./Pagination"; +export { default as InlineLabel } from "./InlineLabel"; diff --git a/frontend/src/index.css b/frontend/src/index.css @@ -321,12 +321,39 @@ body { } } +@keyframes slideDownRight { + from { + opacity: 0; + transform: translate(-20px, -20px) scale(0.95); + } + to { + opacity: 1; + transform: translate(0, 0) scale(1); + } +} +@keyframes fadeScale { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + .animate-fade-up { animation: fadeUp 0.5s ease forwards; } .animate-fade-in { animation: fadeIn 0.3s ease forwards; } +.animate-slide-down-right { + animation: slideDownRight 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} +.animate-fade-scale { + animation: fadeScale 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} .status-dot { animation: statusPulse 2s ease-in-out infinite; } diff --git a/frontend/src/pages/AddCrop.jsx b/frontend/src/pages/AddCrop.jsx @@ -26,7 +26,12 @@ import { FlaskConical, Cpu, } from "lucide-react"; -import Sidebar from "../components/Sidebar"; +import { + PageShell, + PageHeader, + IconButton, + SectionCard, +} from "../components/ui"; // Agent Formatting const AGENT_META = { @@ -427,16 +432,7 @@ export default function AddCrop() { const INPUT_FIELDS = getInputFields(t); return ( - <div - style={{ - display: "flex", - height: "100vh", - overflow: "hidden", - background: "var(--bg)", - }} - > - <Sidebar /> - + <PageShell> {/* Toast */} {toast && ( <div @@ -482,55 +478,25 @@ export default function AddCrop() { }} > {/* Header */} - <header - style={{ - flexShrink: 0, - padding: "0 24px", - height: 64, - borderBottom: "1px solid var(--border)", - background: "var(--bg-2)", - display: "flex", - alignItems: "center", - gap: 12, - }} + <PageHeader + title={t("add_title")} + subtitle={t("add_subtitle")} + icon={Sprout} + iconColor="var(--green)" + iconBg="rgba(74,222,128,0.1)" > - <button - onClick={() => navigate("/dashboard")} - style={{ - width: 34, - height: 34, - borderRadius: 8, - background: "var(--surface)", - border: "1px solid var(--border)", - color: "var(--text-3)", - display: "flex", - alignItems: "center", - justifyContent: "center", - cursor: "pointer", - }} - > - <ArrowLeft size={15} /> - </button> - <div style={{ - width: 32, - height: 32, - borderRadius: 8, - flexShrink: 0, - background: "rgba(74,222,128,0.1)", - border: "1px solid rgba(74,222,128,0.2)", display: "flex", alignItems: "center", - justifyContent: "center", + gap: 8, + marginRight: 8, + order: -1, }} > - <Sprout size={15} style={{ color: "var(--green)" }} /> - </div> - - <div> - <h1 className="page-title">{t("add_title")}</h1> - <p className="page-subtitle">{t("add_subtitle")}</p> + <IconButton onClick={() => navigate("/dashboard")}> + <ArrowLeft size={15} /> + </IconButton> </div> {/* Cycle counter */} @@ -562,7 +528,7 @@ export default function AddCrop() { {t("add_cycles_done", { n: cycles, s: cycles !== 1 ? "S" : "" })} </div> )} - </header> + </PageHeader> {/* Pipeline phase strip */} {phase === "running" && ( @@ -828,14 +794,7 @@ export default function AddCrop() { </div> {/* Sensor inputs */} - <div - style={{ - borderRadius: 16, - padding: 22, - background: "var(--surface)", - border: "1px solid var(--border)", - }} - > + <SectionCard> <div className="section-label">{t("add_sensor_params")}</div> <div style={{ @@ -971,7 +930,7 @@ export default function AddCrop() { ), )} </div> - </div> + </SectionCard> </div> {/* Live agent log */} @@ -1279,6 +1238,6 @@ export default function AddCrop() { )} </div> </main> - </div> + </PageShell> ); } diff --git a/frontend/src/pages/Alerts.jsx b/frontend/src/pages/Alerts.jsx @@ -11,10 +11,18 @@ import { RefreshCw, SlidersHorizontal, Scissors, + RotateCcw, } from "lucide-react"; import { useFarmData } from "../hooks/useFarmData"; import { generateAlerts } from "../utils/dataUtils"; -import Sidebar from "../components/Sidebar"; +import { + PageShell, + PageHeader, + IconButton, + FilterPill, + LoadingShimmer, + EmptyState, +} from "../components/ui"; import { useT } from "../hooks/useTranslation"; // Severity @@ -59,7 +67,7 @@ const AGENT_COLORS = { HISTORIAN: "var(--text-3)", }; -function AlertCard({ alert, onAck, onDismiss, t, td }) { +function AlertCard({ alert, onAck, onUnack, onDismiss, t, td }) { const style = alert.isHarvestAlert ? HARVEST_STYLE : SEV[alert.severity]; const Icon = style.icon; @@ -179,7 +187,7 @@ function AlertCard({ alert, onAck, onDismiss, t, td }) { {/* Actions */} <div style={{ display: "flex", gap: 4, flexShrink: 0 }}> - {!alert.ack && ( + {!alert.ack ? ( <button onClick={() => onAck(alert.id)} title="Acknowledge" @@ -198,6 +206,25 @@ function AlertCard({ alert, onAck, onDismiss, t, td }) { > <CheckCircle2 size={13} /> </button> + ) : ( + <button + onClick={() => onUnack(alert.id)} + title="Unacknowledge" + style={{ + width: 28, + height: 28, + borderRadius: 8, + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--text-3)", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + }} + > + <RotateCcw size={13} /> + </button> )} <button onClick={() => onDismiss(alert.id)} @@ -238,22 +265,33 @@ export default function Alerts() { const ack = (id) => setAlerts((a) => a.map((al) => (al.id === id ? { ...al, ack: true } : al))); + const unack = (id) => + setAlerts((a) => + a.map((al) => (al.id === id ? { ...al, ack: false } : al)), + ); const dismiss = (id) => setAlerts((a) => a.filter((al) => al.id !== id)); const ackAll = () => setAlerts((a) => a.map((al) => ({ ...al, ack: true }))); const counts = useMemo( () => ({ - harvest: alerts.filter((a) => a.isHarvestAlert && !a.ack).length, + harvest: alerts.filter((a) => a.isHarvestAlert && (showAcked || !a.ack)) + .length, critical: alerts.filter( - (a) => a.severity === "critical" && !a.ack && !a.isHarvestAlert, + (a) => + a.severity === "critical" && + (showAcked || !a.ack) && + !a.isHarvestAlert, + ).length, + warning: alerts.filter( + (a) => a.severity === "warning" && (showAcked || !a.ack), ).length, - warning: alerts.filter((a) => a.severity === "warning" && !a.ack).length, info: alerts.filter( - (a) => a.severity === "info" && !a.ack && !a.isHarvestAlert, + (a) => + a.severity === "info" && (showAcked || !a.ack) && !a.isHarvestAlert, ).length, - total: alerts.filter((a) => !a.ack).length, + total: alerts.filter((a) => showAcked || !a.ack).length, }), - [alerts], + [alerts, showAcked], ); const filtered = useMemo( @@ -297,314 +335,225 @@ export default function Alerts() { ]; return ( - <div - style={{ - display: "flex", - height: "100vh", - overflow: "hidden", - background: "var(--bg)", - }} - > - <Sidebar /> - - <main - style={{ - flex: 1, - display: "flex", - flexDirection: "column", - overflow: "hidden", - }} + <PageShell> + {/* Header */} + <PageHeader + title={t("alerts_title")} + subtitle={ + loading + ? t("alerts_subtitle_loading") + : t("alerts_subtitle", { + unacked: counts.total, + total: alerts.length, + }) + } > - {/* Header */} - <header + <div style={{ - flexShrink: 0, - padding: "0 24px", - height: 64, - borderBottom: "1px solid var(--border)", - background: "var(--bg-2)", + marginLeft: "auto", display: "flex", alignItems: "center", - gap: 16, + gap: 8, }} > - <div> - <h1 className="page-title">{t("alerts_title")}</h1> - <p className="page-subtitle"> - {loading - ? t("alerts_subtitle_loading") - : t("alerts_subtitle", { - unacked: counts.total, - total: alerts.length, - })} - </p> - </div> + <IconButton onClick={refreshData}> + <RefreshCw size={14} className={loading ? "animate-spin" : ""} /> + </IconButton> - <div + <button + onClick={() => setShowAcked(!showAcked)} style={{ - marginLeft: "auto", display: "flex", alignItems: "center", - gap: 8, + gap: 6, + padding: "6px 12px", + borderRadius: 8, + fontSize: 11, + fontFamily: "DM Mono, monospace", + background: showAcked ? "rgba(74,222,128,0.1)" : "var(--surface)", + border: `1px solid ${showAcked ? "rgba(74,222,128,0.3)" : "var(--border)"}`, + color: showAcked ? "var(--green)" : "var(--text-3)", + cursor: "pointer", }} > - <button - onClick={refreshData} - style={{ - width: 34, - height: 34, - borderRadius: 8, - background: "var(--surface)", - border: "1px solid var(--border)", - color: "var(--text-3)", - display: "flex", - alignItems: "center", - justifyContent: "center", - cursor: "pointer", - }} - > - <RefreshCw size={14} className={loading ? "animate-spin" : ""} /> - </button> + {showAcked ? <BellOff size={12} /> : <Bell size={12} />} + {showAcked ? t("alerts_unacked_only") : t("alerts_show_all")} + </button> - <button - onClick={() => setShowAcked(!showAcked)} - style={{ - display: "flex", - alignItems: "center", - gap: 6, - padding: "6px 12px", - borderRadius: 8, - fontSize: 11, - fontFamily: "DM Mono, monospace", - background: showAcked - ? "rgba(74,222,128,0.1)" - : "var(--surface)", - border: `1px solid ${showAcked ? "rgba(74,222,128,0.3)" : "var(--border)"}`, - color: showAcked ? "var(--green)" : "var(--text-3)", - cursor: "pointer", - }} - > - {showAcked ? <Bell size={12} /> : <BellOff size={12} />} - {showAcked ? t("alerts_show_all") : t("alerts_unacked_only")} - </button> + <button + onClick={ackAll} + style={{ + display: "flex", + alignItems: "center", + gap: 6, + padding: "6px 12px", + borderRadius: 8, + fontSize: 11, + fontFamily: "DM Mono, monospace", + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--text-3)", + cursor: "pointer", + }} + > + <CheckCircle2 size={12} /> {t("alerts_ack_all_btn")} + </button> + </div> + </PageHeader> - <button - onClick={ackAll} - style={{ - display: "flex", - alignItems: "center", - gap: 6, - padding: "6px 12px", - borderRadius: 8, - fontSize: 11, - fontFamily: "DM Mono, monospace", - background: "var(--surface)", - border: "1px solid var(--border)", - color: "var(--text-3)", - cursor: "pointer", - }} - > - <CheckCircle2 size={12} /> {t("alerts_ack_all")} - </button> - </div> - </header> + {/* Filter bar */} + <div + style={{ + flexShrink: 0, + padding: "8px 24px", + borderBottom: "1px solid var(--border)", + background: "var(--bg-2)", + display: "flex", + alignItems: "center", + gap: 8, + overflowX: "auto", + }} + > + <SlidersHorizontal + size={14} + style={{ color: "var(--text-3)", flexShrink: 0 }} + /> + {FILTER_OPTIONS.map(({ key, label, count, color }) => ( + <FilterPill + key={key} + active={filter === key} + onClick={() => setFilter(key)} + style={{ + display: "flex", + alignItems: "center", + gap: 6, + color: filter === key ? color || "var(--text)" : "var(--text-3)", + }} + > + {count > 0 && ( + <span + style={{ + width: 18, + height: 18, + borderRadius: "50%", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: 9, + background: color ? `${color}30` : "var(--border)", + color: color || "var(--text-3)", + }} + > + {count} + </span> + )} + {label} + </FilterPill> + ))} + </div> - {/* Filter bar */} - <div - style={{ - flexShrink: 0, - padding: "8px 24px", - borderBottom: "1px solid var(--border)", - background: "var(--bg-2)", - display: "flex", - alignItems: "center", - gap: 8, - overflowX: "auto", - }} - > - <SlidersHorizontal - size={14} - style={{ color: "var(--text-3)", flexShrink: 0 }} + {/* Alert list */} + <div style={{ flex: 1, overflowY: "auto", padding: 24 }}> + {loading ? ( + <div + style={{ + maxWidth: 640, + margin: "0 auto", + }} + > + <LoadingShimmer count={3} height={80} /> + </div> + ) : filtered.length === 0 ? ( + <EmptyState + icon={CheckCircle2} + title={ + alerts.length === 0 + ? t("alerts_empty_nodata") + : t("alerts_empty_connected") + } /> - {FILTER_OPTIONS.map(({ key, label, count, color }) => ( - <button - key={key} - onClick={() => setFilter(key)} - style={{ - display: "flex", - alignItems: "center", - gap: 6, - padding: "6px 12px", - borderRadius: 20, - fontSize: 12, - fontFamily: "DM Mono, monospace", - flexShrink: 0, - cursor: "pointer", - background: filter === key ? "var(--surface)" : "transparent", - border: `1px solid ${filter === key ? "var(--border-bright)" : "transparent"}`, - color: - filter === key ? color || "var(--text)" : "var(--text-3)", - transition: "all 0.15s", - }} - > - {count > 0 && ( - <span + ) : ( + <div + style={{ + maxWidth: 640, + margin: "0 auto", + display: "flex", + flexDirection: "column", + gap: 24, + }} + > + {/* Unacked */} + {filtered.filter((a) => !a.ack).length > 0 && ( + <div> + <div style={{ - width: 18, - height: 18, - borderRadius: "50%", - display: "flex", - alignItems: "center", - justifyContent: "center", - fontSize: 9, - background: color ? `${color}30` : "var(--border)", - color: color || "var(--text-3)", + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginBottom: 12, }} > - {count} - </span> - )} - {label} - </button> - ))} - </div> + {t("alerts_unacked", { + n: filtered.filter((a) => !a.ack).length, + })} + </div> + <div + style={{ display: "flex", flexDirection: "column", gap: 8 }} + > + {filtered + .filter((a) => !a.ack) + .map((a) => ( + <AlertCard + key={a.id} + alert={a} + onAck={ack} + onUnack={unack} + onDismiss={dismiss} + t={t} + td={td} + /> + ))} + </div> + </div> + )} - {/* Alert list */} - <div style={{ flex: 1, overflowY: "auto", padding: 24 }}> - {loading ? ( - <div - style={{ - maxWidth: 640, - margin: "0 auto", - display: "flex", - flexDirection: "column", - gap: 12, - }} - > - {[1, 2, 3].map((i) => ( + {/* Acked */} + {showAcked && filtered.filter((a) => a.ack).length > 0 && ( + <div> <div - key={i} - className="shimmer" style={{ - height: 80, - borderRadius: 12, - border: "1px solid var(--border)", + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginBottom: 12, }} - /> - ))} - </div> - ) : filtered.length === 0 ? ( - <div - style={{ - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - height: "100%", - gap: 16, - }} - > - <div - style={{ - width: 56, - height: 56, - borderRadius: 16, - background: "var(--surface)", - border: "1px solid var(--border)", - display: "flex", - alignItems: "center", - justifyContent: "center", - }} - > - <CheckCircle2 size={24} style={{ color: "var(--green)" }} /> - </div> - <div style={{ color: "var(--text-2)" }}> - {alerts.length === 0 - ? t("alerts_empty_nodata") - : t("alerts_empty_connected")} - </div> - </div> - ) : ( - <div - style={{ - maxWidth: 640, - margin: "0 auto", - display: "flex", - flexDirection: "column", - gap: 24, - }} - > - {/* Unacked */} - {filtered.filter((a) => !a.ack).length > 0 && ( - <div> - <div - style={{ - fontSize: 10, - fontFamily: "DM Mono, monospace", - color: "var(--text-3)", - marginBottom: 12, - }} - > - {t("alerts_unacked", { - n: filtered.filter((a) => !a.ack).length, - })} - </div> - <div - style={{ display: "flex", flexDirection: "column", gap: 8 }} - > - {filtered - .filter((a) => !a.ack) - .map((a) => ( - <AlertCard - key={a.id} - alert={a} - onAck={ack} - onDismiss={dismiss} - t={t} - td={td} - /> - ))} - </div> + > + {t("alerts_acknowledged", { + n: filtered.filter((a) => a.ack).length, + })} </div> - )} - - {/* Acked */} - {showAcked && filtered.filter((a) => a.ack).length > 0 && ( - <div> - <div - style={{ - fontSize: 10, - fontFamily: "DM Mono, monospace", - color: "var(--text-3)", - marginBottom: 12, - }} - > - {t("alerts_acknowledged", { - n: filtered.filter((a) => a.ack).length, - })} - </div> - <div - style={{ display: "flex", flexDirection: "column", gap: 8 }} - > - {filtered - .filter((a) => a.ack) - .map((a) => ( - <AlertCard - key={a.id} - alert={a} - onAck={ack} - onDismiss={dismiss} - t={t} - td={td} - /> - ))} - </div> + <div + style={{ display: "flex", flexDirection: "column", gap: 8 }} + > + {filtered + .filter((a) => a.ack) + .map((a) => ( + <AlertCard + key={a.id} + alert={a} + onAck={ack} + onUnack={unack} + onDismiss={dismiss} + t={t} + td={td} + /> + ))} </div> - )} - </div> - )} - </div> - </main> - </div> + </div> + )} + </div> + )} + </div> + </PageShell> ); } diff --git a/frontend/src/pages/Analytics.jsx b/frontend/src/pages/Analytics.jsx @@ -28,33 +28,12 @@ import { buildAgentStats, } from "../utils/dataUtils"; import { TrendingUp, TrendingDown, Minus, Download } from "lucide-react"; -import Sidebar from "../components/Sidebar"; - -// Shared Tooltip -const CustomTooltip = ({ active, payload, label }) => { - if (!active || !payload?.length) return null; - return ( - <div - style={{ - padding: "8px 12px", - borderRadius: 8, - fontSize: 12, - fontFamily: "DM Mono, monospace", - background: "var(--tooltip-bg)", - border: "1px solid var(--border)", - color: "var(--text)", - boxShadow: "var(--shadow)", - }} - > - <div style={{ color: "var(--text-3)", marginBottom: 4 }}>{label}</div> - {payload.map((p) => ( - <div key={p.dataKey} style={{ color: p.color, marginTop: 2 }}> - {p.name}: <strong>{p.value}</strong> - </div> - ))} - </div> - ); -}; +import { + PageShell, + PageHeader, + ChartTooltip, + InlineLabel, +} from "../components/ui"; function MetricCard({ label, value, unit, change, color, loading, t }) { const up = change > 0; @@ -126,7 +105,7 @@ function MetricCard({ label, value, unit, change, color, loading, t }) { function SectionHead({ label, title }) { return ( <div style={{ marginBottom: 16 }}> - <div className="section-label">{label}</div> + <InlineLabel>{label}</InlineLabel> <h2 style={{ fontWeight: 700, @@ -232,339 +211,151 @@ export default function Analytics() { }; return ( - <div - style={{ - display: "flex", - height: "100vh", - overflow: "hidden", - background: "var(--bg)", - }} - > - <Sidebar /> - - <main - style={{ - flex: 1, - display: "flex", - flexDirection: "column", - overflow: "hidden", - }} + <PageShell> + {/* Header */} + <PageHeader + title={t("analytics_title")} + subtitle={ + loading + ? t("common_loading") + : t("analytics_subtitle", { + points: allPoints.length, + crops: dashboard.length, + }) + } > - {/* Header */} - <header + <div style={{ - flexShrink: 0, - padding: "0 24px", - height: 64, - borderBottom: "1px solid var(--border)", - background: "var(--bg-2)", + marginLeft: "auto", display: "flex", alignItems: "center", - gap: 16, + gap: 8, }} > - <div> - <h1 className="page-title">{t("analytics_title")}</h1> - <p className="page-subtitle"> - {loading - ? t("common_loading") - : t("analytics_subtitle", { - points: allPoints.length, - crops: dashboard.length, - })} - </p> - </div> - <div - style={{ - marginLeft: "auto", - display: "flex", - alignItems: "center", - gap: 8, - }} - > - {["24h", "7d", "30d"].map((r) => ( - <button - key={r} - onClick={() => setRange(r)} - style={{ - padding: "5px 12px", - borderRadius: 8, - fontSize: 12, - fontFamily: "DM Mono, monospace", - cursor: "pointer", - background: - range === r ? "rgba(74,222,128,0.12)" : "var(--surface)", - border: `1px solid ${range === r ? "rgba(74,222,128,0.3)" : "var(--border)"}`, - color: range === r ? "var(--green)" : "var(--text-3)", - }} - > - {r} - </button> - ))} + {["24h", "7d", "30d"].map((r) => ( <button - onClick={handleExport} + key={r} + onClick={() => setRange(r)} style={{ - display: "flex", - alignItems: "center", - gap: 6, padding: "5px 12px", borderRadius: 8, fontSize: 12, fontFamily: "DM Mono, monospace", - background: "var(--surface)", - border: "1px solid var(--border)", - color: "var(--text-3)", cursor: "pointer", + background: + range === r ? "rgba(74,222,128,0.12)" : "var(--surface)", + border: `1px solid ${range === r ? "rgba(74,222,128,0.3)" : "var(--border)"}`, + color: range === r ? "var(--green)" : "var(--text-3)", }} > - <Download size={12} /> {t("analytics_export")} + {r} </button> - </div> - </header> - - {/* Scrollable content */} - <div - style={{ - flex: 1, - overflowY: "auto", - padding: 24, - display: "flex", - flexDirection: "column", - gap: 28, - }} - > - {/* Metric Cards */} - <div - style={{ - display: "grid", - gridTemplateColumns: "repeat(4,1fr)", - gap: 12, - }} - > - <MetricCard - loading={loading} - label={t("analytics_avg_ph")} - value={latestSensors.ph} - unit="" - change={safePct(latestSensors.ph, prevSensors.ph)} - color="var(--green)" - t={t} - /> - <MetricCard - loading={loading} - label={t("analytics_avg_ec")} - value={latestSensors.ec} - unit="dS/m" - change={safePct(latestSensors.ec, prevSensors.ec)} - color="var(--amber)" - t={t} - /> - <MetricCard - loading={loading} - label={t("analytics_avg_temp")} - value={latestSensors.temp} - unit="°C" - change={safePct(latestSensors.temp, prevSensors.temp)} - color="var(--blue)" - t={t} - /> - <MetricCard - loading={loading} - label={t("analytics_total_seq")} - value={allPoints.length} - unit="" - change={safePct( - allPoints.length, - Math.max(allPoints.length - dashboard.length, 1), - )} - color="var(--text)" - t={t} - /> - </div> - - {/* pH + EC Charts */} - <div - style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }} - > - {[ - { - title: t("analytics_ph_over_time"), - key: "ph", - stroke: "var(--green)", - gradId: "phGradA", - gradColor: "#4ade80", - name: t("chart_ph"), - }, - { - title: t("analytics_ec_conc"), - key: "ec", - stroke: "var(--amber)", - gradId: "ecGradA", - gradColor: "#f59e0b", - name: t("chart_ec"), - }, - ].map(({ title, key, stroke, gradId, gradColor, name }) => ( - <div - key={key} - style={{ - borderRadius: 14, - padding: 20, - background: "var(--surface)", - border: "1px solid var(--border)", - }} - > - <SectionHead - label={t("analytics_trace", { range: range.toUpperCase() })} - title={title} - /> - {buckets.length < 2 ? ( - <EmptyChart message={t("analytics_no_data_range")} /> - ) : ( - <ResponsiveContainer width="100%" height={180}> - <AreaChart data={buckets}> - <defs> - <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1"> - <stop - offset="0%" - stopColor={gradColor} - stopOpacity={0.3} - /> - <stop - offset="100%" - stopColor={gradColor} - stopOpacity={0} - /> - </linearGradient> - </defs> - <CartesianGrid - stroke="var(--border)" - strokeDasharray="3 3" - vertical={false} - /> - <XAxis - dataKey="label" - tick={{ - fontSize: 10, - fill: "var(--text-3)", - fontFamily: "DM Mono", - }} - axisLine={false} - tickLine={false} - interval="preserveStartEnd" - /> - <YAxis - domain={["auto", "auto"]} - tick={{ - fontSize: 10, - fill: "var(--text-3)", - fontFamily: "DM Mono", - }} - axisLine={false} - tickLine={false} - /> - <Tooltip content={<CustomTooltip />} /> - <Area - type="monotone" - dataKey={key} - stroke={stroke} - fill={`url(#${gradId})`} - strokeWidth={2} - dot={false} - name={name} - /> - </AreaChart> - </ResponsiveContainer> - )} - </div> - ))} - </div> - - {/* Temp + Humidity */} - <div + ))} + <button + onClick={handleExport} style={{ - borderRadius: 14, - padding: 20, + display: "flex", + alignItems: "center", + gap: 6, + padding: "5px 12px", + borderRadius: 8, + fontSize: 12, + fontFamily: "DM Mono, monospace", background: "var(--surface)", border: "1px solid var(--border)", + color: "var(--text-3)", + cursor: "pointer", }} > - <SectionHead - label={t("analytics_trace", { range: range.toUpperCase() })} - title={t("analytics_temp_hum")} - /> - {buckets.length < 2 ? ( - <EmptyChart message={t("analytics_no_data_range")} /> - ) : ( - <ResponsiveContainer width="100%" height={180}> - <LineChart data={buckets}> - <CartesianGrid - stroke="var(--border)" - strokeDasharray="3 3" - vertical={false} - /> - <XAxis - dataKey="label" - tick={{ - fontSize: 10, - fill: "var(--text-3)", - fontFamily: "DM Mono", - }} - axisLine={false} - tickLine={false} - interval="preserveStartEnd" - /> - <YAxis - yAxisId="left" - domain={["auto", "auto"]} - tick={{ - fontSize: 10, - fill: "var(--text-3)", - fontFamily: "DM Mono", - }} - axisLine={false} - tickLine={false} - /> - <YAxis - yAxisId="right" - orientation="right" - domain={["auto", "auto"]} - tick={{ - fontSize: 10, - fill: "var(--text-3)", - fontFamily: "DM Mono", - }} - axisLine={false} - tickLine={false} - /> - <Tooltip content={<CustomTooltip />} /> - <Line - yAxisId="left" - type="monotone" - dataKey="temp" - stroke="#60a5fa" - strokeWidth={2} - dot={false} - name={t("chart_temp")} - /> - <Line - yAxisId="right" - type="monotone" - dataKey="humidity" - stroke="#a78bfa" - strokeWidth={2} - dot={false} - name={t("chart_humidity")} - /> - </LineChart> - </ResponsiveContainer> + <Download size={12} /> {t("analytics_export")} + </button> + </div> + </PageHeader> + + {/* Scrollable content */} + <div + style={{ + flex: 1, + overflowY: "auto", + padding: 24, + display: "flex", + flexDirection: "column", + gap: 28, + }} + > + {/* Metric Cards */} + <div + style={{ + display: "grid", + gridTemplateColumns: "repeat(4,1fr)", + gap: 12, + }} + > + <MetricCard + loading={loading} + label={t("analytics_avg_ph")} + value={latestSensors.ph} + unit="" + change={safePct(latestSensors.ph, prevSensors.ph)} + color="var(--green)" + t={t} + /> + <MetricCard + loading={loading} + label={t("analytics_avg_ec")} + value={latestSensors.ec} + unit="dS/m" + change={safePct(latestSensors.ec, prevSensors.ec)} + color="var(--amber)" + t={t} + /> + <MetricCard + loading={loading} + label={t("analytics_avg_temp")} + value={latestSensors.temp} + unit="°C" + change={safePct(latestSensors.temp, prevSensors.temp)} + color="var(--blue)" + t={t} + /> + <MetricCard + loading={loading} + label={t("analytics_total_seq")} + value={allPoints.length} + unit="" + change={safePct( + allPoints.length, + Math.max(allPoints.length - dashboard.length, 1), )} - </div> + color="var(--text)" + t={t} + /> + </div> - {/* Activity + Radar */} - <div - style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }} - > + {/* pH + EC Charts */} + <div + style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }} + > + {[ + { + title: t("analytics_ph_over_time"), + key: "ph", + stroke: "var(--green)", + gradId: "phGradA", + gradColor: "#4ade80", + name: t("chart_ph"), + }, + { + title: t("analytics_ec_conc"), + key: "ec", + stroke: "var(--amber)", + gradId: "ecGradA", + gradColor: "#f59e0b", + name: t("chart_ec"), + }, + ].map(({ title, key, stroke, gradId, gradColor, name }) => ( <div + key={key} style={{ borderRadius: 14, padding: 20, @@ -573,24 +364,35 @@ export default function Analytics() { }} > <SectionHead - label={t("analytics_daily_act")} - title={t("analytics_seq_per_day")} + label={t("analytics_trace", { range: range.toUpperCase() })} + title={title} /> - {activityData.length < 2 ? ( - <EmptyChart - height={180} - message={t("analytics_no_data_days")} - /> + {buckets.length < 2 ? ( + <EmptyChart message={t("analytics_no_data_range")} /> ) : ( <ResponsiveContainer width="100%" height={180}> - <BarChart data={activityData} barGap={4}> + <AreaChart data={buckets}> + <defs> + <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1"> + <stop + offset="0%" + stopColor={gradColor} + stopOpacity={0.3} + /> + <stop + offset="100%" + stopColor={gradColor} + stopOpacity={0} + /> + </linearGradient> + </defs> <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} /> <XAxis - dataKey="d" + dataKey="label" tick={{ fontSize: 10, fill: "var(--text-3)", @@ -598,8 +400,10 @@ export default function Analytics() { }} axisLine={false} tickLine={false} + interval="preserveStartEnd" /> <YAxis + domain={["auto", "auto"]} tick={{ fontSize: 10, fill: "var(--text-3)", @@ -607,244 +411,257 @@ export default function Analytics() { }} axisLine={false} tickLine={false} - allowDecimals={false} /> - <Tooltip content={<CustomTooltip />} /> - <Bar - dataKey="count" - fill="#2d7a44" - radius={[4, 4, 0, 0]} - name={t("chart_sequences")} + <Tooltip content={<ChartTooltip />} /> + <Area + type="monotone" + dataKey={key} + stroke={stroke} + fill={`url(#${gradId})`} + strokeWidth={2} + dot={false} + name={name} /> - </BarChart> + </AreaChart> </ResponsiveContainer> )} </div> + ))} + </div> - <div - style={{ - borderRadius: 14, - padding: 20, - background: "var(--surface)", - border: "1px solid var(--border)", - }} - > - <SectionHead - label={t("analytics_param_health")} - title={t("analytics_in_range_score")} - /> - {radarData.length < 2 ? ( - <EmptyChart - height={180} - message={t("analytics_no_data_points")} + {/* Temp + Humidity */} + <div + style={{ + borderRadius: 14, + padding: 20, + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <SectionHead + label={t("analytics_trace", { range: range.toUpperCase() })} + title={t("analytics_temp_hum")} + /> + {buckets.length < 2 ? ( + <EmptyChart message={t("analytics_no_data_range")} /> + ) : ( + <ResponsiveContainer width="100%" height={180}> + <LineChart data={buckets}> + <CartesianGrid + stroke="var(--border)" + strokeDasharray="3 3" + vertical={false} /> - ) : ( - <ResponsiveContainer width="100%" height={180}> - <RadarChart - data={radarData} - cx="50%" - cy="50%" - outerRadius="65%" - > - <PolarGrid stroke="var(--border)" /> - <PolarAngleAxis - dataKey="metric" - tick={{ - fontSize: 11, - fill: "var(--text-3)", - fontFamily: "DM Mono", - }} - /> - <Radar - dataKey="value" - stroke="var(--green)" - fill="rgba(74,222,128,0.15)" - strokeWidth={2} - name={t("chart_in_range")} - /> - <Tooltip content={<CustomTooltip />} /> - </RadarChart> - </ResponsiveContainer> - )} - </div> - </div> + <XAxis + dataKey="label" + tick={{ + fontSize: 10, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + interval="preserveStartEnd" + /> + <YAxis + yAxisId="left" + domain={["auto", "auto"]} + tick={{ + fontSize: 10, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <YAxis + yAxisId="right" + orientation="right" + domain={["auto", "auto"]} + tick={{ + fontSize: 10, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <Tooltip content={<ChartTooltip />} /> + <Line + yAxisId="left" + type="monotone" + dataKey="temp" + stroke="#60a5fa" + strokeWidth={2} + dot={false} + name={t("chart_temp")} + /> + <Line + yAxisId="right" + type="monotone" + dataKey="humidity" + stroke="#a78bfa" + strokeWidth={2} + dot={false} + name={t("chart_humidity")} + /> + </LineChart> + </ResponsiveContainer> + )} + </div> - {/* Crop Summary Table */} + {/* Activity + Radar */} + <div + style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }} + > <div style={{ borderRadius: 14, - overflow: "hidden", + padding: 20, background: "var(--surface)", border: "1px solid var(--border)", - flexShrink: 0, }} > - <div - style={{ - padding: "16px 20px", - borderBottom: "1px solid var(--border)", - }} - > - <SectionHead - label={t("analytics_per_crop")} - title={t("analytics_latest_sensor")} - /> - </div> - {loading ? ( - <div style={{ padding: 32, textAlign: "center" }}> - <span - style={{ - fontSize: 12, - fontFamily: "DM Mono, monospace", - color: "var(--text-3)", - }} - > - {t("common_loading")} - </span> - </div> - ) : cropSummaryRows.length === 0 ? ( - <div - style={{ - padding: 32, - textAlign: "center", - fontSize: 12, - fontFamily: "DM Mono, monospace", - color: "var(--text-3)", - }} - > - {t("dash_no_crops")} - </div> + <SectionHead + label={t("analytics_daily_act")} + title={t("analytics_seq_per_day")} + /> + {activityData.length < 2 ? ( + <EmptyChart height={180} message={t("analytics_no_data_days")} /> ) : ( - <table - className="data-table" - style={{ width: "100%", borderCollapse: "collapse" }} - > - <thead> - <tr> - {[ - t("analytics_th_crop_id"), - t("analytics_th_type"), - t("analytics_th_stage"), - t("analytics_th_ph"), - t("analytics_th_ec"), - t("analytics_th_temp"), - t("analytics_th_seq"), - ].map((h) => ( - <th key={h}>{h}</th> - ))} - </tr> - </thead> - <tbody> - {cropSummaryRows.map(({ p, s, key }) => ( - <tr - key={key} - style={{ transition: "background 0.12s" }} - onMouseEnter={(e) => - (e.currentTarget.style.background = "var(--bg-3)") - } - onMouseLeave={(e) => - (e.currentTarget.style.background = "transparent") - } - > - <td - style={{ - fontFamily: "DM Mono, monospace", - fontSize: 12, - color: "var(--text)", - fontWeight: 600, - }} - > - {p.crop_id || "—"} - </td> - <td>{td(p.crop) || "—"}</td> - <td - style={{ - color: "var(--text-3)", - fontSize: 12, - fontFamily: "DM Mono, monospace", - }} - > - {td(p.stage) || "—"} - </td> - <td> - <span - className="sensor-value-xs" - style={{ color: "var(--green)" }} - > - {s.ph} - </span> - </td> - <td> - <span - className="sensor-value-xs" - style={{ color: "var(--amber)" }} - > - {s.ec} - </span> - <span - style={{ - fontSize: 11, - color: "var(--text-3)", - marginLeft: 3, - }} - > - dS/m - </span> - </td> - <td> - <span - className="sensor-value-xs" - style={{ color: "var(--blue)" }} - > - {s.temp} - </span> - <span - style={{ - fontSize: 11, - color: "var(--text-3)", - marginLeft: 2, - }} - > - °C - </span> - </td> - <td - style={{ - fontFamily: "DM Mono, monospace", - fontSize: 13, - color: "var(--text-2)", - }} - > - {p.sequence_number || 1} - </td> - </tr> - ))} - </tbody> - </table> + <ResponsiveContainer width="100%" height={180}> + <BarChart data={activityData} barGap={4}> + <CartesianGrid + stroke="var(--border)" + strokeDasharray="3 3" + vertical={false} + /> + <XAxis + dataKey="d" + tick={{ + fontSize: 10, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <YAxis + tick={{ + fontSize: 10, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + allowDecimals={false} + /> + <Tooltip content={<ChartTooltip />} /> + <Bar + dataKey="count" + fill="#2d7a44" + radius={[4, 4, 0, 0]} + name={t("chart_sequences")} + /> + </BarChart> + </ResponsiveContainer> )} </div> - {/* Agent Activity Table */} <div style={{ borderRadius: 14, - overflow: "hidden", + padding: 20, background: "var(--surface)", border: "1px solid var(--border)", - flexShrink: 0, }} > + <SectionHead + label={t("analytics_param_health")} + title={t("analytics_in_range_score")} + /> + {radarData.length < 2 ? ( + <EmptyChart + height={180} + message={t("analytics_no_data_points")} + /> + ) : ( + <ResponsiveContainer width="100%" height={180}> + <RadarChart + data={radarData} + cx="50%" + cy="50%" + outerRadius="65%" + > + <PolarGrid stroke="var(--border)" /> + <PolarAngleAxis + dataKey="metric" + tick={{ + fontSize: 11, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + /> + <Radar + dataKey="value" + stroke="var(--green)" + fill="rgba(74,222,128,0.15)" + strokeWidth={2} + name={t("chart_in_range")} + /> + <Tooltip content={<ChartTooltip />} /> + </RadarChart> + </ResponsiveContainer> + )} + </div> + </div> + + {/* Crop Summary Table */} + <div + style={{ + borderRadius: 14, + overflow: "hidden", + background: "var(--surface)", + border: "1px solid var(--border)", + flexShrink: 0, + }} + > + <div + style={{ + padding: "16px 20px", + borderBottom: "1px solid var(--border)", + }} + > + <SectionHead + label={t("analytics_per_crop")} + title={t("analytics_latest_sensor")} + /> + </div> + {loading ? ( + <div style={{ padding: 32, textAlign: "center" }}> + <span + style={{ + fontSize: 12, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > + {t("common_loading")} + </span> + </div> + ) : cropSummaryRows.length === 0 ? ( <div style={{ - padding: "16px 20px", - borderBottom: "1px solid var(--border)", + padding: 32, + textAlign: "center", + fontSize: 12, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", }} > - <SectionHead - label={t("analytics_derived_act")} - title={t("analytics_agent_act")} - /> + {t("dash_no_crops")} </div> + ) : ( <table className="data-table" style={{ width: "100%", borderCollapse: "collapse" }} @@ -852,107 +669,236 @@ export default function Analytics() { <thead> <tr> {[ - t("analytics_th_agent"), - t("analytics_th_apps"), - t("analytics_th_success"), - t("analytics_th_status"), + t("analytics_th_crop_id"), + t("analytics_th_type"), + t("analytics_th_stage"), + t("analytics_th_ph"), + t("analytics_th_ec"), + t("analytics_th_temp"), + t("analytics_th_seq"), ].map((h) => ( <th key={h}>{h}</th> ))} </tr> </thead> <tbody> - {agentRows.map(({ name, decisions, accuracy }) => ( + {cropSummaryRows.map(({ p, s, key }) => ( <tr - key={name} + key={key} + style={{ transition: "background 0.12s" }} onMouseEnter={(e) => (e.currentTarget.style.background = "var(--bg-3)") } onMouseLeave={(e) => (e.currentTarget.style.background = "transparent") } - style={{ transition: "background 0.12s" }} > <td style={{ fontFamily: "DM Mono, monospace", - fontWeight: 600, - fontSize: 13, + fontSize: 12, color: "var(--text)", + fontWeight: 600, }} > - {name} + {p.crop_id || "—"} </td> + <td>{td(p.crop) || "—"}</td> <td - style={{ fontFamily: "DM Mono, monospace", fontSize: 13 }} + style={{ + color: "var(--text-3)", + fontSize: 12, + fontFamily: "DM Mono, monospace", + }} > - {decisions} + {td(p.stage) || "—"} </td> <td> - <div + <span + className="sensor-value-xs" + style={{ color: "var(--green)" }} + > + {s.ph} + </span> + </td> + <td> + <span + className="sensor-value-xs" + style={{ color: "var(--amber)" }} + > + {s.ec} + </span> + <span style={{ - display: "flex", - alignItems: "center", - gap: 10, + fontSize: 11, + color: "var(--text-3)", + marginLeft: 3, }} > - <div - style={{ - height: 6, - width: 100, - borderRadius: 3, - background: "var(--border)", - }} - > - <div - style={{ - height: "100%", - borderRadius: 3, - width: `${accuracy}%`, - background: - accuracy > 80 - ? "var(--green)" - : accuracy > 50 - ? "var(--amber)" - : "var(--red)", - transition: "width 0.6s ease", - }} - /> - </div> - <span - style={{ - fontFamily: "DM Mono, monospace", - fontSize: 13, - color: "var(--text-2)", - minWidth: 36, - }} - > - {accuracy}% - </span> - </div> + dS/m + </span> </td> <td> <span + className="sensor-value-xs" + style={{ color: "var(--blue)" }} + > + {s.temp} + </span> + <span style={{ fontSize: 11, - fontFamily: "DM Mono, monospace", - padding: "3px 10px", - borderRadius: 20, - background: "rgba(74,222,128,0.1)", - color: "var(--green)", - border: "1px solid rgba(74,222,128,0.2)", + color: "var(--text-3)", + marginLeft: 2, }} > - {t("analytics_online")} + °C </span> </td> + <td + style={{ + fontFamily: "DM Mono, monospace", + fontSize: 13, + color: "var(--text-2)", + }} + > + {p.sequence_number || 1} + </td> </tr> ))} </tbody> </table> + )} + </div> + + {/* Agent Activity Table */} + <div + style={{ + borderRadius: 14, + overflow: "hidden", + background: "var(--surface)", + border: "1px solid var(--border)", + flexShrink: 0, + }} + > + <div + style={{ + padding: "16px 20px", + borderBottom: "1px solid var(--border)", + }} + > + <SectionHead + label={t("analytics_derived_act")} + title={t("analytics_agent_act")} + /> </div> + <table + className="data-table" + style={{ width: "100%", borderCollapse: "collapse" }} + > + <thead> + <tr> + {[ + t("analytics_th_agent"), + t("analytics_th_apps"), + t("analytics_th_success"), + t("analytics_th_status"), + ].map((h) => ( + <th key={h}>{h}</th> + ))} + </tr> + </thead> + <tbody> + {agentRows.map(({ name, decisions, accuracy }) => ( + <tr + key={name} + onMouseEnter={(e) => + (e.currentTarget.style.background = "var(--bg-3)") + } + onMouseLeave={(e) => + (e.currentTarget.style.background = "transparent") + } + style={{ transition: "background 0.12s" }} + > + <td + style={{ + fontFamily: "DM Mono, monospace", + fontWeight: 600, + fontSize: 13, + color: "var(--text)", + }} + > + {name} + </td> + <td + style={{ fontFamily: "DM Mono, monospace", fontSize: 13 }} + > + {decisions} + </td> + <td> + <div + style={{ + display: "flex", + alignItems: "center", + gap: 10, + }} + > + <div + style={{ + height: 6, + width: 100, + borderRadius: 3, + background: "var(--border)", + }} + > + <div + style={{ + height: "100%", + borderRadius: 3, + width: `${accuracy}%`, + background: + accuracy > 80 + ? "var(--green)" + : accuracy > 50 + ? "var(--amber)" + : "var(--red)", + transition: "width 0.6s ease", + }} + /> + </div> + <span + style={{ + fontFamily: "DM Mono, monospace", + fontSize: 13, + color: "var(--text-2)", + minWidth: 36, + }} + > + {accuracy}% + </span> + </div> + </td> + <td> + <span + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + padding: "3px 10px", + borderRadius: 20, + background: "rgba(74,222,128,0.1)", + color: "var(--green)", + border: "1px solid rgba(74,222,128,0.2)", + }} + > + {t("analytics_online")} + </span> + </td> + </tr> + ))} + </tbody> + </table> </div> - </main> - </div> + </div> + </PageShell> ); } diff --git a/frontend/src/pages/CropDetails.jsx b/frontend/src/pages/CropDetails.jsx @@ -32,35 +32,16 @@ import { AgentActionWidget, AgentOutcomeWidget, } from "../components/AgentWidgets"; -import Sidebar from "../components/Sidebar"; +import { + PageShell, + PageHeader, + IconButton, + ChartTooltip, + InlineLabel, +} from "../components/ui"; import { useSettings } from "../hooks/useSettings"; import { useT } from "../hooks/useTranslation"; -const CustomTooltip = ({ active, payload, label }) => { - if (!active || !payload?.length) return null; - return ( - <div - style={{ - padding: "8px 12px", - borderRadius: 8, - fontSize: 12, - fontFamily: "DM Mono, monospace", - background: "var(--tooltip-bg)", - border: "1px solid var(--border)", - color: "var(--text)", - boxShadow: "var(--shadow)", - }} - > - <div style={{ color: "var(--text-3)", marginBottom: 4 }}>{label}</div> - {payload.map((p) => ( - <div key={p.dataKey} style={{ color: p.color, marginTop: 2 }}> - {p.name}: <strong>{p.value}</strong> - </div> - ))} - </div> - ); -}; - function StatBox({ icon: Icon, label, value, color, unit }) { return ( <div @@ -152,9 +133,9 @@ function ExplanationLogBlock({ log, t, td }) { > <Brain size={14} style={{ color: "#a78bfa" }} /> </div> - <div className="section-label" style={{ marginBottom: 0 }}> + <InlineLabel style={{ marginBottom: 0 }}> {t("details_ai_reasoning")} - </div> + </InlineLabel> <span style={{ fontSize: 9, @@ -228,9 +209,9 @@ function ExplanationLogBlock({ log, t, td }) { <Sparkles size={12} style={{ color: "#a78bfa" }} /> </div> <div style={{ flex: 1 }}> - <div className="section-label" style={{ marginBottom: 0 }}> + <InlineLabel style={{ marginBottom: 0 }}> {t("details_ai_reasoning")} - </div> + </InlineLabel> <div style={{ fontSize: 10, @@ -424,7 +405,6 @@ export default function CropDetails() { const [latest, setLatest] = useState(null); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState("details_tab_overview"); - const [showExp, setShowExp] = useState(false); useEffect(() => { fetchCropDetails(cropId).then((data) => { @@ -448,10 +428,7 @@ export default function CropDetails() { if (loading) return ( - <div - style={{ display: "flex", height: "100vh", background: "var(--bg)" }} - > - <Sidebar /> + <PageShell> <div style={{ flex: 1, @@ -470,27 +447,20 @@ export default function CropDetails() { {t("common_loading")} </span> </div> - </div> + </PageShell> ); if (!latest) return ( <div - style={{ display: "flex", height: "100vh", background: "var(--bg)" }} + style={{ + flex: 1, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} > - <Sidebar /> - <div - style={{ - flex: 1, - display: "flex", - alignItems: "center", - justifyContent: "center", - }} - > - <span style={{ color: "var(--text-3)" }}> - {t("details_not_found")} - </span> - </div> + <span style={{ color: "var(--text-3)" }}>{t("details_not_found")}</span> </div> ); @@ -511,273 +481,201 @@ export default function CropDetails() { })); return ( - <div - style={{ - display: "flex", - height: "100vh", - overflow: "hidden", - background: "var(--bg)", - }} - > - <Sidebar /> + <PageShell> + {/* Header */} + <PageHeader> + <IconButton onClick={() => navigate("/dashboard")}> + <ArrowLeft size={15} /> + </IconButton> - <main - style={{ - flex: 1, - display: "flex", - flexDirection: "column", - overflow: "hidden", - }} - > - {/* Header */} - <header + <div> + <h1 className="page-title"> + {td(p.crop) || t("common_unknown")}{" "} + <span + style={{ + color: "var(--text-3)", + fontWeight: 400, + fontSize: 16, + }} + > + #{p.sequence_number || 0} + </span> + </h1> + <p className="page-subtitle"> + {cropId} · {td(p.stage)} + </p> + </div> + + {/* Live badge */} + <div style={{ - flexShrink: 0, - padding: "0 24px", - height: 64, - borderBottom: "1px solid var(--border)", - background: "var(--bg-2)", display: "flex", alignItems: "center", - gap: 16, + gap: 6, + padding: "4px 10px", + borderRadius: 20, + background: "rgba(74,222,128,0.1)", + border: "1px solid rgba(74,222,128,0.25)", + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--green)", }} > - <button - onClick={() => navigate("/dashboard")} + <span + className="status-dot" style={{ - width: 34, - height: 34, - borderRadius: 8, - background: "var(--surface)", - border: "1px solid var(--border)", - color: "var(--text-3)", - display: "flex", - alignItems: "center", - justifyContent: "center", - cursor: "pointer", + width: 6, + height: 6, + borderRadius: "50%", + background: "var(--green)", }} - > - <ArrowLeft size={15} /> - </button> - - <div> - <h1 className="page-title"> - {td(p.crop) || t("common_unknown")}{" "} - <span - style={{ - color: "var(--text-3)", - fontWeight: 400, - fontSize: 16, - }} - > - #{p.sequence_number || 0} - </span> - </h1> - <p className="page-subtitle"> - {cropId} · {td(p.stage)} - </p> - </div> + /> + {t("details_live")} + </div> - {/* Live badge */} - <div - style={{ - display: "flex", - alignItems: "center", - gap: 6, - padding: "4px 10px", - borderRadius: 20, - background: "rgba(74,222,128,0.1)", - border: "1px solid rgba(74,222,128,0.25)", - fontSize: 11, - fontFamily: "DM Mono, monospace", - color: "var(--green)", - }} - > - <span - className="status-dot" + {/* Tabs */} + <div style={{ marginLeft: "auto", display: "flex", gap: 4 }}> + {TABS.map((tab) => ( + <button + key={tab} + onClick={() => setActiveTab(tab)} style={{ - width: 6, - height: 6, - borderRadius: "50%", - background: "var(--green)", + padding: "5px 14px", + borderRadius: 8, + fontSize: 12, + fontFamily: "DM Mono, monospace", + textTransform: "capitalize", + cursor: "pointer", + background: + activeTab === tab ? "var(--surface-2)" : "transparent", + color: activeTab === tab ? "var(--text)" : "var(--text-3)", + border: `1px solid ${activeTab === tab ? "var(--border-bright)" : "transparent"}`, }} - /> - {t("details_live")} - </div> - - {/* Tabs */} - <div style={{ marginLeft: "auto", display: "flex", gap: 4 }}> - {TABS.map((tab) => ( - <button - key={tab} - onClick={() => setActiveTab(tab)} - style={{ - padding: "5px 14px", - borderRadius: 8, - fontSize: 12, - fontFamily: "DM Mono, monospace", - textTransform: "capitalize", - cursor: "pointer", - background: - activeTab === tab ? "var(--surface-2)" : "transparent", - color: activeTab === tab ? "var(--text)" : "var(--text-3)", - border: `1px solid ${activeTab === tab ? "var(--border-bright)" : "transparent"}`, - }} - > - {t(tab)} - </button> - ))} - </div> - </header> - - {/* Content */} - <div - style={{ - flex: 1, - overflowY: "auto", - padding: 24, - display: "flex", - flexDirection: "column", - gap: 20, - }} - > - {activeTab === "details_tab_overview" && ( - <> - {/* Sensor stats */} - <div - style={{ - display: "grid", - gridTemplateColumns: "repeat(4,1fr)", - gap: 12, - }} - > - <StatBox - icon={Thermometer} - label={t("sensor_temp")} - value={formatNumber(sensors.temp)} - unit="°C" - color="var(--blue)" - /> - <StatBox - icon={Droplet} - label={t("sensor_ph")} - value={formatNumber(sensors.ph)} - unit="" - color="var(--green)" - /> - <StatBox - icon={Activity} - label={t("sensor_ec")} - value={formatNumber(sensors.ec)} - unit="dS/m" - color="var(--amber)" - /> - <StatBox - icon={Wind} - label={t("sensor_humidity")} - value={formatNumber(sensors.humidity)} - unit="%" - color="#a78bfa" - /> - </div> + > + {t(tab)} + </button> + ))} + </div> + </PageHeader> - {/* Outcome */} - {p.outcome && p.outcome !== "PENDING_OBSERVATION" && ( - <AgentOutcomeWidget - outcome={p.outcome} - rewardScore={p.reward_score} - strategicIntent={p.strategic_intent} - /> - )} - <ExplanationLogBlock log={p.explanation_log} t={t} td={td} /> - <div - style={{ - borderRadius: 14, - padding: 20, - background: "var(--surface)", - border: "1px solid var(--border)", - }} - > - <div className="section-label">{t("details_hist_ph")}</div> - <ResponsiveContainer width="100%" height={200}> - <AreaChart data={chartData}> - <defs> - <linearGradient id="phGradCD" x1="0" y1="0" x2="0" y2="1"> - <stop - offset="0%" - stopColor="#4ade80" - stopOpacity={0.3} - /> - <stop - offset="100%" - stopColor="#4ade80" - stopOpacity={0} - /> - </linearGradient> - </defs> - <CartesianGrid - stroke="var(--border)" - strokeDasharray="3 3" - vertical={false} - /> - <XAxis - dataKey="t" - tick={{ - fontSize: 10, - fill: "var(--text-3)", - fontFamily: "DM Mono", - }} - axisLine={false} - tickLine={false} - /> - <YAxis - domain={["auto", "auto"]} - tick={{ - fontSize: 10, - fill: "var(--text-3)", - fontFamily: "DM Mono", - }} - axisLine={false} - tickLine={false} - /> - <Tooltip content={<CustomTooltip />} /> - <Area - type="monotone" - dataKey="ph" - stroke="var(--green)" - fill="url(#phGradCD)" - strokeWidth={2} - dot={false} - name={t("chart_ph")} - /> - </AreaChart> - </ResponsiveContainer> - </div> + {/* Content */} + <div + style={{ + flex: 1, + overflowY: "auto", + padding: 24, + display: "flex", + flexDirection: "column", + gap: 20, + }} + > + {activeTab === "details_tab_overview" && ( + <> + {/* Sensor stats */} + <div + style={{ + display: "grid", + gridTemplateColumns: "repeat(4,1fr)", + gap: 12, + }} + > + <StatBox + icon={Thermometer} + label={t("sensor_temp")} + value={formatNumber(sensors.temp)} + unit="°C" + color="var(--blue)" + /> + <StatBox + icon={Droplet} + label={t("sensor_ph")} + value={formatNumber(sensors.ph)} + unit="" + color="var(--green)" + /> + <StatBox + icon={Activity} + label={t("sensor_ec")} + value={formatNumber(sensors.ec)} + unit="dS/m" + color="var(--amber)" + /> + <StatBox + icon={Wind} + label={t("sensor_humidity")} + value={formatNumber(sensors.humidity)} + unit="%" + color="#a78bfa" + /> + </div> - {/* Latest Action */} - {p.action_taken && p.action_taken !== "PENDING_ACTION" && ( - <div - style={{ - borderRadius: 14, - padding: 20, - background: "var(--surface)", - border: "1px solid var(--border)", - }} - > - <div className="section-label" style={{ marginBottom: 16 }}> - {t("details_latest_cmd")} - </div> - <AgentActionWidget - actionTaken={p.action_taken} - compact={false} + {/* Outcome */} + {p.outcome && p.outcome !== "PENDING_OBSERVATION" && ( + <AgentOutcomeWidget + outcome={p.outcome} + rewardScore={p.reward_score} + strategicIntent={p.strategic_intent} + /> + )} + <ExplanationLogBlock log={p.explanation_log} t={t} td={td} /> + <div + style={{ + borderRadius: 14, + padding: 20, + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <InlineLabel>{t("details_hist_ph")}</InlineLabel> + <ResponsiveContainer width="100%" height={200}> + <AreaChart data={chartData}> + <defs> + <linearGradient id="phGradCD" x1="0" y1="0" x2="0" y2="1"> + <stop offset="0%" stopColor="#4ade80" stopOpacity={0.3} /> + <stop offset="100%" stopColor="#4ade80" stopOpacity={0} /> + </linearGradient> + </defs> + <CartesianGrid + stroke="var(--border)" + strokeDasharray="3 3" + vertical={false} /> - </div> - )} - </> - )} + <XAxis + dataKey="t" + tick={{ + fontSize: 10, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <YAxis + domain={["auto", "auto"]} + tick={{ + fontSize: 10, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <Tooltip content={<ChartTooltip />} /> + <Area + type="monotone" + dataKey="ph" + stroke="var(--green)" + fill="url(#phGradCD)" + strokeWidth={2} + dot={false} + name={t("chart_ph")} + /> + </AreaChart> + </ResponsiveContainer> + </div> - {activeTab === "details_tab_sensors" && ( - <> + {/* Latest Action */} + {p.action_taken && p.action_taken !== "PENDING_ACTION" && ( <div style={{ borderRadius: 14, @@ -786,368 +684,385 @@ export default function CropDetails() { border: "1px solid var(--border)", }} > - <div className="section-label">{t("details_temp_hum")}</div> - <ResponsiveContainer width="100%" height={200}> - <LineChart data={chartData}> - <CartesianGrid - stroke="var(--border)" - strokeDasharray="3 3" - vertical={false} - /> - <XAxis - dataKey="t" - tick={{ - fontSize: 10, - fill: "var(--text-3)", - fontFamily: "DM Mono", - }} - axisLine={false} - tickLine={false} - /> - <YAxis - tick={{ - fontSize: 10, - fill: "var(--text-3)", - fontFamily: "DM Mono", - }} - axisLine={false} - tickLine={false} - /> - <Tooltip content={<CustomTooltip />} /> - <Line - type="monotone" - dataKey="temp" - stroke="#60a5fa" - strokeWidth={2} - dot={false} - name={t("chart_temp")} - /> - <Line - type="monotone" - dataKey="humidity" - stroke="#a78bfa" - strokeWidth={2} - dot={false} - name={t("chart_humidity")} - /> - </LineChart> - </ResponsiveContainer> + <InlineLabel style={{ marginBottom: 16 }}> + {t("details_latest_cmd")} + </InlineLabel> + <AgentActionWidget + actionTaken={p.action_taken} + compact={false} + /> </div> + )} + </> + )} - <div - style={{ - borderRadius: 14, - padding: 20, - background: "var(--surface)", - border: "1px solid var(--border)", - }} - > - <div className="section-label">{t("details_ec_conc")}</div> - <ResponsiveContainer width="100%" height={180}> - <AreaChart data={chartData}> - <defs> - <linearGradient id="ecGradCD" x1="0" y1="0" x2="0" y2="1"> - <stop - offset="0%" - stopColor="#f59e0b" - stopOpacity={0.25} - /> - <stop - offset="100%" - stopColor="#f59e0b" - stopOpacity={0} - /> - </linearGradient> - </defs> - <CartesianGrid - stroke="var(--border)" - strokeDasharray="3 3" - vertical={false} - /> - <XAxis - dataKey="t" - tick={{ - fontSize: 10, - fill: "var(--text-3)", - fontFamily: "DM Mono", - }} - axisLine={false} - tickLine={false} - /> - <YAxis - tick={{ - fontSize: 10, - fill: "var(--text-3)", - fontFamily: "DM Mono", - }} - axisLine={false} - tickLine={false} - /> - <Tooltip content={<CustomTooltip />} /> - <Area - type="monotone" - dataKey="ec" - stroke="var(--amber)" - fill="url(#ecGradCD)" - strokeWidth={2} - dot={false} - name={t("chart_ec")} - /> - </AreaChart> - </ResponsiveContainer> - </div> - </> - )} + {activeTab === "details_tab_sensors" && ( + <> + <div + style={{ + borderRadius: 14, + padding: 20, + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <InlineLabel>{t("details_temp_hum")}</InlineLabel> + <ResponsiveContainer width="100%" height={200}> + <LineChart data={chartData}> + <CartesianGrid + stroke="var(--border)" + strokeDasharray="3 3" + vertical={false} + /> + <XAxis + dataKey="t" + tick={{ + fontSize: 10, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <YAxis + tick={{ + fontSize: 10, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <Tooltip content={<ChartTooltip />} /> + <Line + type="monotone" + dataKey="temp" + stroke="#60a5fa" + strokeWidth={2} + dot={false} + name={t("chart_temp")} + /> + <Line + type="monotone" + dataKey="humidity" + stroke="#a78bfa" + strokeWidth={2} + dot={false} + name={t("chart_humidity")} + /> + </LineChart> + </ResponsiveContainer> + </div> - {activeTab === "details_tab_log" && ( <div style={{ borderRadius: 14, - overflow: "hidden", + padding: 20, background: "var(--surface)", border: "1px solid var(--border)", - flexShrink: 0, }} > - {/* Header row */} - <div - style={{ - padding: "14px 20px", - borderBottom: "1px solid var(--border)", - }} - > - <div className="section-label"> - {t("details_event_log", { - total: history.length, - limit: logLimit, - })} - </div> + <div className="section-label">{t("details_ec_conc")}</div> + <ResponsiveContainer width="100%" height={180}> + <AreaChart data={chartData}> + <defs> + <linearGradient id="ecGradCD" x1="0" y1="0" x2="0" y2="1"> + <stop + offset="0%" + stopColor="#f59e0b" + stopOpacity={0.25} + /> + <stop offset="100%" stopColor="#f59e0b" stopOpacity={0} /> + </linearGradient> + </defs> + <CartesianGrid + stroke="var(--border)" + strokeDasharray="3 3" + vertical={false} + /> + <XAxis + dataKey="t" + tick={{ + fontSize: 10, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <YAxis + tick={{ + fontSize: 10, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <Tooltip content={<ChartTooltip />} /> + <Area + type="monotone" + dataKey="ec" + stroke="var(--amber)" + fill="url(#ecGradCD)" + strokeWidth={2} + dot={false} + name={t("chart_ec")} + /> + </AreaChart> + </ResponsiveContainer> + </div> + </> + )} + + {activeTab === "details_tab_log" && ( + <div + style={{ + borderRadius: 14, + overflow: "hidden", + background: "var(--surface)", + border: "1px solid var(--border)", + flexShrink: 0, + }} + > + {/* Header row */} + <div + style={{ + padding: "14px 20px", + borderBottom: "1px solid var(--border)", + }} + > + <div className="section-label"> + {t("details_event_log", { + total: history.length, + limit: logLimit, + })} </div> + </div> - {/* Log rows */} - <div> - {[...history] - .reverse() - .slice(0, logLimit) - .map((h, i) => { - const hasExplanation = - h.payload?.explanation_log && - h.payload.explanation_log !== "PENDING_ANALYSIS"; - return ( - <div - key={i} - style={{ - borderBottom: "1px solid var(--border)", - transition: "background 0.12s", - cursor: "default", - }} - onMouseEnter={(e) => - (e.currentTarget.style.background = - "rgba(74,222,128,0.04)") - } - onMouseLeave={(e) => - (e.currentTarget.style.background = "transparent") - } - > - {/* Row header */} - <div - style={{ - display: "flex", - alignItems: "center", - gap: 10, - padding: "10px 20px", - }} - > - <span - style={{ - width: 7, - height: 7, - borderRadius: "50%", - background: logDotColor(h.payload), - flexShrink: 0, - }} - /> - <span - style={{ - fontSize: 11, - fontFamily: "DM Mono, monospace", - color: "var(--text-3)", - flexShrink: 0, - width: 52, - }} - > - {h.payload?.timestamp - ? new Date( - h.payload.timestamp, - ).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }) - : "--"} - </span> - <span - style={{ - fontSize: 11, - fontFamily: "DM Mono, monospace", - color: "var(--text-3)", - width: 36, - flexShrink: 0, - }} - > - #{h.payload?.sequence_number || i} - </span> + {/* Log rows */} + <div> + {[...history] + .reverse() + .slice(0, logLimit) + .map((h, i) => ( + <LogRow + key={i} + h={h} + i={i} + logLimit={logLimit} + t={t} + td={td} + formatNumber={formatNumber} + logDotColor={logDotColor} + /> + ))} + </div> + </div> + )} + </div> + </PageShell> + ); +} + +function LogRow({ h, i, logLimit, t, td, formatNumber, logDotColor }) { + const [expanded, setExpanded] = useState(false); + const hasExplanation = + h.payload?.explanation_log && + h.payload.explanation_log !== "PENDING_ANALYSIS"; + + return ( + <div + style={{ + borderBottom: "1px solid var(--border)", + transition: "background 0.12s", + cursor: "default", + }} + onMouseEnter={(e) => + (e.currentTarget.style.background = "rgba(74,222,128,0.04)") + } + onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")} + > + {/* Row header */} + <div + style={{ + display: "flex", + alignItems: "center", + gap: 10, + padding: "10px 20px", + }} + > + <span + style={{ + width: 7, + height: 7, + borderRadius: "50%", + background: logDotColor(h.payload), + flexShrink: 0, + }} + /> + <span + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + flexShrink: 0, + width: 52, + }} + > + {h.payload?.timestamp + ? new Date(h.payload.timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) + : "--"} + </span> + <span + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + width: 36, + flexShrink: 0, + }} + > + #{h.payload?.sequence_number || i} + </span> - {/* Sensor snapshot */} - <div - style={{ - display: "flex", - gap: 12, - flexWrap: "wrap", - }} - > - {[ - { - label: "pH", - value: h.cleanSensors?.ph, - color: "var(--green)", - }, - { - label: "EC", - value: h.cleanSensors?.ec, - color: "var(--amber)", - }, - { - label: "T", - value: h.cleanSensors?.temp + "°", - color: "var(--blue)", - }, - { - label: "H", - value: h.cleanSensors?.humidity + "%", - color: "#a78bfa", - }, - ].map(({ label, value, color }) => ( - <span - key={label} - style={{ - fontSize: 13, - fontFamily: "DM Mono, monospace", - display: "flex", - alignItems: "baseline", - gap: 3, - }} - > - <span - style={{ - color: "var(--text-3)", - fontSize: 11, - }} - > - {label} - </span> - <span style={{ color, fontWeight: 700 }}> - {formatNumber(value)} - </span> - </span> - ))} - </div> + {/* Sensor snapshot */} + <div + style={{ + display: "flex", + gap: 12, + flexWrap: "wrap", + }} + > + {[ + { + label: "pH", + value: h.cleanSensors?.ph, + color: "var(--green)", + }, + { + label: "EC", + value: h.cleanSensors?.ec, + color: "var(--amber)", + }, + { + label: "T", + value: h.cleanSensors?.temp + "°", + color: "var(--blue)", + }, + { + label: "H", + value: h.cleanSensors?.humidity + "%", + color: "#a78bfa", + }, + ].map(({ label, value, color }) => ( + <span + key={label} + style={{ + fontSize: 13, + fontFamily: "DM Mono, monospace", + display: "flex", + alignItems: "baseline", + gap: 3, + }} + > + <span + style={{ + color: "var(--text-3)", + fontSize: 11, + }} + > + {label} + </span> + <span style={{ color, fontWeight: 700 }}> + {formatNumber(value)} + </span> + </span> + ))} + </div> - {/* Outcome badge */} - {h.payload?.outcome && ( - <span style={{ marginLeft: "auto", flexShrink: 0 }}> - <AgentOutcomeWidget - outcome={h.payload.outcome} - rewardScore={h.payload.reward_score} - /> - </span> - )} + {/* Outcome badge */} + {h.payload?.outcome && ( + <span style={{ marginLeft: "auto", flexShrink: 0 }}> + <AgentOutcomeWidget + outcome={h.payload.outcome} + rewardScore={h.payload.reward_score} + /> + </span> + )} - {/* Explanation toggle */} - {hasExplanation && ( - <button - onClick={() => setShowExp((v) => !v)} - style={{ - flexShrink: 0, - display: "flex", - alignItems: "center", - gap: 4, - padding: "3px 8px", - borderRadius: 6, - fontSize: 9, - fontFamily: "DM Mono, monospace", - cursor: "pointer", - background: showExp - ? "rgba(167,139,250,0.12)" - : "var(--bg-3)", - border: `1px solid ${showExp ? "rgba(167,139,250,0.3)" : "var(--border)"}`, - color: showExp ? "#a78bfa" : "var(--text-3)", - }} - > - <Brain size={9} />{" "} - {showExp ? t("details_hide") : t("details_why")} - </button> - )} - </div> + {/* Explanation toggle */} + {hasExplanation && ( + <button + onClick={() => setExpanded((v) => !v)} + style={{ + flexShrink: 0, + display: "flex", + alignItems: "center", + gap: 4, + padding: "3px 8px", + borderRadius: 6, + fontSize: 9, + fontFamily: "DM Mono, monospace", + cursor: "pointer", + background: expanded ? "rgba(167,139,250,0.12)" : "var(--bg-3)", + border: `1px solid ${expanded ? "rgba(167,139,250,0.3)" : "var(--border)"}`, + color: expanded ? "#a78bfa" : "var(--text-3)", + }} + > + <Brain size={9} /> {expanded ? t("details_hide") : t("details_why")} + </button> + )} + </div> - {/* Action row */} - {h.payload?.action_taken && - h.payload.action_taken !== "PENDING_ACTION" && ( - <div style={{ padding: "0 20px 10px 46px" }}> - <AgentActionWidget - actionTaken={h.payload.action_taken} - compact - /> - </div> - )} + {/* Action row */} + {h.payload?.action_taken && + h.payload.action_taken !== "PENDING_ACTION" && ( + <div style={{ padding: "0 20px 10px 46px" }}> + <AgentActionWidget actionTaken={h.payload.action_taken} compact /> + </div> + )} - {/* Explanation log inline */} - {hasExplanation && showExp && ( - <div - className="animate-fade-in" - style={{ - margin: "0 20px 12px 46px", - padding: "12px 14px", - borderRadius: 10, - background: "rgba(167,139,250,0.05)", - border: "1px solid rgba(167,139,250,0.15)", - }} - > - <div - style={{ - fontSize: 9, - fontFamily: "DM Mono, monospace", - color: "#a78bfa", - marginBottom: 8, - }} - > - <Brain - size={9} - style={{ display: "inline", marginRight: 5 }} - />{" "} - {t("details_ai_reasoning")} - </div> - <pre - style={{ - fontSize: 11, - fontFamily: "DM Mono, monospace", - color: "var(--text-2)", - lineHeight: 1.7, - whiteSpace: "pre-wrap", - margin: 0, - }} - > - {td(h.payload.explanation_log)} - </pre> - </div> - )} - </div> - ); - })} - </div> - </div> - )} + {/* Explanation log inline */} + {hasExplanation && expanded && ( + <div + className="animate-fade-in" + style={{ + margin: "0 20px 12px 46px", + padding: "12px 14px", + borderRadius: 10, + background: "rgba(167,139,250,0.05)", + border: "1px solid rgba(167,139,250,0.15)", + }} + > + <div + style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + color: "#a78bfa", + marginBottom: 8, + }} + > + <Brain size={9} style={{ display: "inline", marginRight: 5 }} />{" "} + {t("details_ai_reasoning")} + </div> + <pre + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-2)", + lineHeight: 1.7, + whiteSpace: "pre-wrap", + margin: 0, + }} + > + {td(h.payload.explanation_log)} + </pre> </div> - </main> + )} </div> ); } diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx @@ -10,23 +10,31 @@ import { isReadyToHarvest, } from "../utils/dataUtils"; import { - Search, SlidersHorizontal, Thermometer, Droplet, ArrowUpRight, Clock, - ChevronDown, - X, RefreshCw, Leaf, Activity, - ChevronLeft, - ChevronRight, PlusCircle, Scissors, } from "lucide-react"; -import Sidebar from "../components/Sidebar"; +import { + PageShell, + PageHeader, + IconButton, + PrimaryButton, + SearchBar, + FilterBar, + FilterPill, + SelectField, + StatusBadge, + Pagination, + EmptyState, + LoadingShimmer, +} from "../components/ui"; const STAGES_KEYS = [ "stage_all", @@ -43,26 +51,7 @@ const STATUSES_KEYS = [ "dash_critical", ]; -const STATUS_COLORS = { - Healthy: { - bg: "rgba(74,222,128,0.12)", - text: "var(--green)", - border: "rgba(74,222,128,0.3)", - }, - Attention: { - bg: "rgba(245,158,11,0.12)", - text: "var(--amber)", - border: "rgba(245,158,11,0.3)", - }, - Critical: { - bg: "rgba(248,113,113,0.12)", - text: "var(--red)", - border: "rgba(248,113,113,0.3)", - }, -}; - function CropCard({ data, onClick, t, td }) { - const st = STATUS_COLORS[data.status] || STATUS_COLORS.Healthy; const maturity = data.maturity || 40; // Map backend status to translation key @@ -165,23 +154,11 @@ function CropCard({ data, onClick, t, td }) { }} /> - {/* Status badge */} - <div - style={{ - position: "absolute", - top: 10, - right: 10, - fontSize: 10, - fontFamily: "DM Mono, monospace", - padding: "3px 8px", - borderRadius: 20, - background: st.bg, - color: st.text, - border: `1px solid ${st.border}`, - }} - > - {t(statusKey).toUpperCase()} - </div> + <StatusBadge + status={data.status} + label={t(statusKey).toUpperCase()} + style={{ position: "absolute", top: 10, right: 10 }} + /> {/* Seq badge */} <div style={{ @@ -504,463 +481,319 @@ export default function Dashboard() { const STATUS_EN = ["All", "Healthy", "Attention", "Critical"]; return ( - <div - style={{ - display: "flex", - height: "100vh", - overflow: "hidden", - background: "var(--bg)", - }} - > - <Sidebar /> - - <main - style={{ - flex: 1, - display: "flex", - flexDirection: "column", - overflow: "hidden", - }} + <PageShell> + {/* Header */} + <PageHeader + title={t("dash_title")} + subtitle={t("dash_subtitle", { + filtered: filtered.length, + total: crops.length, + })} > - {/* Header */} - <header + {/* Summary chips */} + <div style={{ - flexShrink: 0, - padding: "0 24px", - height: 64, - borderBottom: "1px solid var(--border)", - background: "var(--bg-2)", display: "flex", alignItems: "center", - gap: 16, + gap: 8, + marginLeft: 16, }} > - <div> - <h1 className="page-title">{t("dash_title")}</h1> - <p className="page-subtitle"> - {t("dash_subtitle", { - filtered: filtered.length, - total: crops.length, - })} - </p> - </div> + {[ + { + labelKey: "dash_healthy", + count: summary.healthy, + color: "var(--green)", + }, + { + labelKey: "dash_attention", + count: summary.attention, + color: "var(--amber)", + }, + { + labelKey: "dash_critical", + count: summary.critical, + color: "var(--red)", + }, + ].map(({ labelKey, count, color }) => ( + <div + key={labelKey} + style={{ + display: "flex", + alignItems: "center", + gap: 5, + padding: "3px 10px", + borderRadius: 20, + background: "var(--surface)", + border: "1px solid var(--border)", + fontSize: 12, + fontFamily: "DM Mono, monospace", + color, + }} + > + <span style={{ fontWeight: 700 }}>{count}</span> + <span style={{ opacity: 0.7 }}>{t(labelKey)}</span> + </div> + ))} + </div> - {/* Summary chips */} - <div + <PrimaryButton + onClick={() => navigate("/add-crop")} + icon={PlusCircle} + style={{ marginLeft: "auto" }} + > + {t("dash_add_crop")} + </PrimaryButton> + + <IconButton onClick={refreshData}> + <RefreshCw size={15} className={loading ? "animate-spin" : ""} /> + </IconButton> + </PageHeader> + + {harvestReadyCrops.length > 0 && ( + <div + className="animate-fade-in" + style={{ + flexShrink: 0, + padding: "10px 24px", + background: "rgba(245,158,11,0.12)", + borderBottom: "1px solid rgba(245,158,11,0.3)", + display: "flex", + alignItems: "center", + gap: 12, + }} + > + <span + className="harvest-pulse" style={{ - display: "flex", - alignItems: "center", - gap: 8, - marginLeft: 16, + width: 10, + height: 10, + borderRadius: "50%", + background: "var(--amber)", + flexShrink: 0, }} - > - {[ - { - labelKey: "dash_healthy", - count: summary.healthy, - color: "var(--green)", - }, - { - labelKey: "dash_attention", - count: summary.attention, - color: "var(--amber)", - }, - { - labelKey: "dash_critical", - count: summary.critical, - color: "var(--red)", - }, - ].map(({ labelKey, count, color }) => ( - <div - key={labelKey} - style={{ - display: "flex", - alignItems: "center", - gap: 5, - padding: "3px 10px", - borderRadius: 20, - background: "var(--surface)", - border: "1px solid var(--border)", - fontSize: 12, - fontFamily: "DM Mono, monospace", - color, - }} - > - <span style={{ fontWeight: 700 }}>{count}</span> - <span style={{ opacity: 0.7 }}>{t(labelKey)}</span> - </div> - ))} + /> + <Scissors + size={16} + style={{ color: "var(--amber)", flexShrink: 0 }} + /> + <div style={{ flex: 1 }}> + <span + style={{ fontWeight: 700, fontSize: 13, color: "var(--amber)" }} + > + {t("dash_harvest_banner", { + n: harvestReadyCrops.length, + s: harvestReadyCrops.length !== 1 ? "s" : "", + })} + </span> + <span + style={{ + fontSize: 12, + color: "var(--text-3)", + marginLeft: 10, + fontFamily: "DM Mono, monospace", + }} + > + {t("dash_harvest_banner_sub")} + </span> </div> - - {/* Add Crop CTA */} <button - onClick={() => navigate("/add-crop")} - style={{ - marginLeft: "auto", - display: "flex", - alignItems: "center", - gap: 7, - padding: "8px 18px", - borderRadius: 10, - fontSize: 13, - fontWeight: 600, - background: "var(--green)", - border: "none", - color: "var(--btn-on-green)", - cursor: "pointer", - boxShadow: "0 0 16px rgba(74,222,128,0.2)", + onClick={() => { + setFilterReady(true); + setFilterStatus("All"); + setFilterStage("All"); + setFilterCrop("All"); + setSearch(""); + setPage(1); }} - > - <PlusCircle size={15} /> {t("dash_add_crop")} - </button> - - <button - onClick={refreshData} style={{ - width: 34, - height: 34, + padding: "5px 14px", borderRadius: 8, - background: "var(--surface)", - border: "1px solid var(--border)", - color: "var(--text-3)", - display: "flex", - alignItems: "center", - justifyContent: "center", + fontSize: 12, + fontFamily: "DM Mono, monospace", + background: "rgba(245,158,11,0.2)", + border: "1px solid rgba(245,158,11,0.4)", + color: "var(--amber)", cursor: "pointer", + fontWeight: 600, }} > - <RefreshCw size={15} className={loading ? "animate-spin" : ""} /> + {t("dash_harvest_action")} </button> - </header> + </div> + )} - {harvestReadyCrops.length > 0 && ( - <div - className="animate-fade-in" - style={{ - flexShrink: 0, - padding: "10px 24px", - background: "rgba(245,158,11,0.12)", - borderBottom: "1px solid rgba(245,158,11,0.3)", - display: "flex", - alignItems: "center", - gap: 12, - }} - > + <FilterBar> + <SearchBar + value={search} + onChange={(val) => { + setSearch(val); + setPage(1); + }} + onClear={() => setSearch("")} + placeholder={t("dash_search_placeholder")} + /> + + <button + onClick={() => setShowFilters(!showFilters)} + style={{ + display: "flex", + alignItems: "center", + gap: 6, + padding: "7px 12px", + borderRadius: 8, + fontSize: 12, + cursor: "pointer", + background: showFilters ? "rgba(74,222,128,0.1)" : "var(--surface)", + border: `1px solid ${showFilters ? "rgba(74,222,128,0.3)" : "var(--border)"}`, + color: showFilters ? "var(--green)" : "var(--text-2)", + }} + > + <SlidersHorizontal size={13} /> {t("dash_filters")} + {activeFilters > 0 && ( <span - className="harvest-pulse" style={{ - width: 10, - height: 10, - borderRadius: "50%", - background: "var(--amber)", - flexShrink: 0, + padding: "0 5px", + borderRadius: 4, + fontSize: 10, + fontFamily: "DM Mono, monospace", + background: "var(--green)", + color: "var(--btn-on-green)", }} - /> - <Scissors - size={16} - style={{ color: "var(--amber)", flexShrink: 0 }} - /> - <div style={{ flex: 1 }}> - <span - style={{ fontWeight: 700, fontSize: 13, color: "var(--amber)" }} - > - {t("dash_harvest_banner", { - n: harvestReadyCrops.length, - s: harvestReadyCrops.length !== 1 ? "s" : "", - })} - </span> - <span - style={{ - fontSize: 12, - color: "var(--text-3)", - marginLeft: 10, - fontFamily: "DM Mono, monospace", - }} - > - {t("dash_harvest_banner_sub")} - </span> - </div> - <button + > + {activeFilters} + </span> + )} + </button> + + {/* Quick stage pills */} + <div style={{ display: "flex", alignItems: "center", gap: 6 }}> + {STAGES_EN.slice(0, 4).map((s, i) => ( + <FilterPill + key={s} + active={filterStage === s} onClick={() => { - setFilterReady(true); - setFilterStatus("All"); - setFilterStage("All"); - setFilterCrop("All"); - setSearch(""); + setFilterStage(filterStage === s ? "All" : s); setPage(1); }} - style={{ - padding: "5px 14px", - borderRadius: 8, - fontSize: 12, - fontFamily: "DM Mono, monospace", - background: "rgba(245,158,11,0.2)", - border: "1px solid rgba(245,158,11,0.4)", - color: "var(--amber)", - cursor: "pointer", - fontWeight: 600, - }} > - {t("dash_harvest_action")} - </button> - </div> - )} + {t(STAGES_KEYS[i])} + </FilterPill> + ))} + </div> + </FilterBar> + {/* Expanded filters */} + {showFilters && ( <div + className="animate-fade-in" style={{ flexShrink: 0, padding: "8px 24px", borderBottom: "1px solid var(--border)", - background: "var(--bg-2)", + background: "var(--bg-3)", display: "flex", alignItems: "center", - gap: 12, + gap: 24, }} > - {/* Search */} - <div style={{ position: "relative", flex: 1, maxWidth: 360 }}> - <Search - size={14} - style={{ - position: "absolute", - left: 10, - top: "50%", - transform: "translateY(-50%)", - color: "var(--text-3)", - }} - /> - <input - value={search} - onChange={(e) => { - setSearch(e.target.value); + {[ + { + label: t("add_field_crop_type"), + value: filterCrop, + set: (v) => { + setFilterCrop(v); setPage(1); - }} - placeholder={t("dash_search_placeholder")} - style={{ - width: "100%", - paddingLeft: 32, - paddingRight: search ? 28 : 12, - paddingTop: 7, - paddingBottom: 7, - borderRadius: 8, - fontSize: 13, - fontFamily: "DM Mono, monospace", - background: "var(--surface)", - border: "1px solid var(--border)", - color: "var(--text)", - outline: "none", - caretColor: "var(--green)", - }} - /> - {search && ( - <button - onClick={() => setSearch("")} + }, + opts: CROPS, + optLabels: CROPS.map((o) => + o === "All" ? t("stage_all") : td(o), + ), + }, + { + label: t("add_field_stage"), + value: filterStage, + set: (v) => { + setFilterStage(v); + setPage(1); + }, + opts: STAGES_EN, + optLabels: STAGES_KEYS.map((k) => t(k)), + }, + { + label: t("analytics_th_status"), + value: filterStatus, + set: (v) => { + setFilterStatus(v); + setPage(1); + }, + opts: STATUS_EN, + optLabels: STATUSES_KEYS.map((k) => t(k)), + }, + ].map(({ label, value, set, opts, optLabels }) => ( + <div + key={label} + style={{ display: "flex", alignItems: "center", gap: 8 }} + > + <span style={{ - position: "absolute", - right: 8, - top: "50%", - transform: "translateY(-50%)", - background: "none", - border: "none", - cursor: "pointer", - padding: 0, + fontSize: 12, + fontFamily: "DM Mono, monospace", color: "var(--text-3)", }} > - <X size={12} /> - </button> - )} - </div> - - {/* Filter toggle */} + {label} + </span> + <SelectField + value={value} + onChange={(e) => set(e.target.value)} + options={opts.map((o, i) => ({ + value: o, + label: optLabels ? optLabels[i] : o, + }))} + /> + </div> + ))} <button - onClick={() => setShowFilters(!showFilters)} + onClick={() => { + setSearch(""); + setFilterStage("All"); + setFilterCrop("All"); + setFilterStatus("All"); + setFilterReady(false); + setPage(1); + }} style={{ - display: "flex", - alignItems: "center", - gap: 6, - padding: "7px 12px", - borderRadius: 8, - fontSize: 12, + marginLeft: "auto", + fontSize: 11, + fontFamily: "DM Mono, monospace", + background: "none", + border: "none", cursor: "pointer", - background: showFilters - ? "rgba(74,222,128,0.1)" - : "var(--surface)", - border: `1px solid ${showFilters ? "rgba(74,222,128,0.3)" : "var(--border)"}`, - color: showFilters ? "var(--green)" : "var(--text-2)", + color: "var(--text-3)", }} > - <SlidersHorizontal size={13} /> {t("dash_filters")} - {activeFilters > 0 && ( - <span - style={{ - padding: "0 5px", - borderRadius: 4, - fontSize: 10, - fontFamily: "DM Mono, monospace", - background: "var(--green)", - color: "var(--btn-on-green)", - }} - > - {activeFilters} - </span> - )} + {t("dash_clear_all")} </button> - - {/* Quick stage pills */} - <div style={{ display: "flex", alignItems: "center", gap: 6 }}> - {STAGES_EN.slice(0, 4).map((s, i) => ( - <button - key={s} - onClick={() => { - setFilterStage(filterStage === s ? "All" : s); - setPage(1); - }} - style={{ - padding: "5px 12px", - borderRadius: 20, - fontSize: 11, - fontFamily: "DM Mono, monospace", - cursor: "pointer", - background: - filterStage === s - ? "rgba(74,222,128,0.15)" - : "var(--surface)", - border: `1px solid ${filterStage === s ? "rgba(74,222,128,0.4)" : "var(--border)"}`, - color: filterStage === s ? "var(--green)" : "var(--text-3)", - }} - > - {t(STAGES_KEYS[i])} - </button> - ))} - </div> </div> + )} - {/* Expanded filters */} - {showFilters && ( + {/* Crop grid */} + <div style={{ flex: 1, overflowY: "auto", padding: 24 }}> + {loading ? ( <div - className="animate-fade-in" style={{ - flexShrink: 0, - padding: "8px 24px", - borderBottom: "1px solid var(--border)", - background: "var(--bg-3)", - display: "flex", - alignItems: "center", - gap: 24, + display: "grid", + gridTemplateColumns: "repeat(auto-fill,minmax(200px,1fr))", + gap: 16, }} > - {[ - { - label: t("add_field_crop_type"), - value: filterCrop, - set: (v) => { - setFilterCrop(v); - setPage(1); - }, - opts: CROPS, - optLabels: CROPS.map((o) => - o === "All" ? t("stage_all") : td(o), - ), - }, - { - label: t("add_field_stage"), - value: filterStage, - set: (v) => { - setFilterStage(v); - setPage(1); - }, - opts: STAGES_EN, - optLabels: STAGES_KEYS.map((k) => t(k)), - }, - { - label: t("analytics_th_status"), - value: filterStatus, - set: (v) => { - setFilterStatus(v); - setPage(1); - }, - opts: STATUS_EN, - optLabels: STATUSES_KEYS.map((k) => t(k)), - }, - ].map(({ label, value, set, opts, optLabels }) => ( - <div - key={label} - style={{ display: "flex", alignItems: "center", gap: 8 }} - > - <span - style={{ - fontSize: 12, - fontFamily: "DM Mono, monospace", - color: "var(--text-3)", - }} - > - {label} - </span> - <div style={{ position: "relative" }}> - <select - value={value} - onChange={(e) => set(e.target.value)} - style={{ - appearance: "none", - padding: "5px 24px 5px 10px", - borderRadius: 8, - fontSize: 12, - fontFamily: "DM Mono, monospace", - background: "var(--surface)", - border: "1px solid var(--border)", - color: "var(--text-2)", - cursor: "pointer", - outline: "none", - }} - > - {opts.map((o, i) => ( - <option key={o} value={o}> - {optLabels ? optLabels[i] : o} - </option> - ))} - </select> - <ChevronDown - size={10} - style={{ - position: "absolute", - right: 8, - top: "50%", - transform: "translateY(-50%)", - pointerEvents: "none", - color: "var(--text-3)", - }} - /> - </div> - </div> + {Array.from({ length: 8 }).map((_, i) => ( + <LoadingShimmer key={i} count={1} height={255} /> ))} - <button - onClick={() => { - setSearch(""); - setFilterStage("All"); - setFilterCrop("All"); - setFilterStatus("All"); - setFilterReady(false); - setPage(1); - }} - style={{ - marginLeft: "auto", - fontSize: 11, - fontFamily: "DM Mono, monospace", - background: "none", - border: "none", - cursor: "pointer", - color: "var(--text-3)", - }} - > - {t("dash_clear_all")} - </button> </div> - )} - - {/* Crop grid */} - <div style={{ flex: 1, overflowY: "auto", padding: 24 }}> - {loading ? ( + ) : paginated.length > 0 ? ( + <> <div style={{ display: "grid", @@ -968,206 +801,53 @@ export default function Dashboard() { gap: 16, }} > - {Array(8) - .fill(0) - .map((_, i) => ( - <div - key={i} - className="shimmer" - style={{ - height: 255, - borderRadius: 16, - border: "1px solid var(--border)", - }} - /> - ))} + {paginated.map((crop) => ( + <CropCard + key={crop.id} + data={crop} + t={t} + td={td} + onClick={() => navigate(`/crop/${crop.id}`)} + /> + ))} + {/* Always visible at end of last page */} + {page === totalPages && !filterReady && ( + <AddCropCard onClick={() => navigate("/add-crop")} t={t} /> + )} </div> - ) : paginated.length > 0 ? ( - <> - <div - style={{ - display: "grid", - gridTemplateColumns: "repeat(auto-fill,minmax(200px,1fr))", - gap: 16, - }} - > - {paginated.map((crop) => ( - <CropCard - key={crop.id} - data={crop} - t={t} - td={td} - onClick={() => navigate(`/crop/${crop.id}`)} - /> - ))} - {/* Always visible at end of first page */} - {page === 1 && !filterReady && ( - <AddCropCard onClick={() => navigate("/add-crop")} t={t} /> - )} - </div> - {/* Pagination */} - {totalPages > 1 && ( - <div - style={{ - display: "flex", - alignItems: "center", - justifyContent: "center", - gap: 8, - marginTop: 24, - }} - > - <button - onClick={() => setPage((p) => Math.max(1, p - 1))} - disabled={page === 1} - style={{ - width: 32, - height: 32, - borderRadius: 8, - cursor: "pointer", - background: "var(--surface)", - border: "1px solid var(--border)", - color: page === 1 ? "var(--text-3)" : "var(--text-2)", - display: "flex", - alignItems: "center", - justifyContent: "center", - }} - > - <ChevronLeft size={14} /> - </button> - {Array.from({ length: totalPages }, (_, i) => i + 1).map( - (n) => ( - <button - key={n} - onClick={() => setPage(n)} - style={{ - width: 32, - height: 32, - borderRadius: 8, - cursor: "pointer", - fontFamily: "DM Mono, monospace", - fontSize: 12, - background: - n === page ? "var(--green)" : "var(--surface)", - border: `1px solid ${n === page ? "transparent" : "var(--border)"}`, - color: - n === page - ? "var(--btn-on-green)" - : "var(--text-2)", - fontWeight: n === page ? 700 : 400, - }} - > - {n} - </button> - ), - )} - <button - onClick={() => setPage((p) => Math.min(totalPages, p + 1))} - disabled={page === totalPages} - style={{ - width: 32, - height: 32, - borderRadius: 8, - cursor: "pointer", - background: "var(--surface)", - border: "1px solid var(--border)", - color: - page === totalPages ? "var(--text-3)" : "var(--text-2)", - display: "flex", - alignItems: "center", - justifyContent: "center", - }} - > - <ChevronRight size={14} /> - </button> - </div> - )} - </> - ) : ( - <div - style={{ - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - height: "100%", - gap: 16, - }} - > - {crops.length === 0 ? ( - <> - <div - style={{ - width: 64, - height: 64, - borderRadius: 20, - background: "rgba(74,222,128,0.1)", - border: "1px solid rgba(74,222,128,0.2)", - display: "flex", - alignItems: "center", - justifyContent: "center", - }} - > - <PlusCircle size={28} style={{ color: "var(--green)" }} /> - </div> - <div style={{ textAlign: "center" }}> - <div - style={{ - fontWeight: 700, - fontSize: 15, - color: "var(--text-2)", - }} - > - {t("dash_no_crops")} - </div> - <div - style={{ - fontSize: 12, - color: "var(--text-3)", - marginTop: 6, - }} - > - {t("dash_no_crops_sub")} - </div> - </div> - <button - onClick={() => navigate("/add-crop")} - style={{ - display: "flex", - alignItems: "center", - gap: 7, - padding: "10px 22px", - borderRadius: 10, - fontSize: 13, - fontWeight: 600, - background: "var(--green)", - border: "none", - color: "var(--btn-on-green)", - cursor: "pointer", - }} - > - <PlusCircle size={15} /> {t("dash_add_first")} - </button> - </> - ) : ( - <> - <div - style={{ - width: 56, - height: 56, - borderRadius: 16, - background: "var(--surface)", - border: "1px solid var(--border)", - display: "flex", - alignItems: "center", - justifyContent: "center", - }} - > - <Activity size={24} style={{ color: "var(--text-3)" }} /> - </div> - <div style={{ color: "var(--text-2)", fontSize: 14 }}> - {t("dash_no_match")} - </div> + {/* Pagination */} + {totalPages > 1 && ( + <Pagination + page={page} + totalPages={totalPages} + setPage={setPage} + style={{ marginTop: 24 }} + /> + )} + </> + ) : ( + <div + style={{ + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + height: "100%", + gap: 16, + }} + > + {crops.length === 0 ? ( + <EmptyState + icon={PlusCircle} + title={t("dash_no_crops")} + description={t("dash_no_crops_sub")} + /> + ) : ( + <EmptyState + icon={Activity} + title={t("dash_no_match")} + description={ <button onClick={() => { setSearch(""); @@ -1186,16 +866,17 @@ export default function Dashboard() { border: "1px solid var(--border)", color: "var(--text-3)", cursor: "pointer", + marginTop: 16, }} > {t("dash_clear_filters")} </button> - </> - )} - </div> - )} - </div> - </main> - </div> + } + /> + )} + </div> + )} + </div> + </PageShell> ); } diff --git a/frontend/src/pages/FarmIntelligence.jsx b/frontend/src/pages/FarmIntelligence.jsx @@ -28,7 +28,7 @@ import { AgentActionWidget, AgentOutcomeWidget, } from "../components/AgentWidgets"; -import Sidebar from "../components/Sidebar"; +import { PageShell, PageHeader } from "../components/ui"; import { useFarmData } from "../hooks/useFarmData"; // Suggestion banks @@ -1232,16 +1232,7 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim(); : getGlobalSuggestions(t); return ( - <div - style={{ - display: "flex", - height: "100vh", - overflow: "hidden", - background: "var(--bg)", - }} - > - <Sidebar /> - + <PageShell> {toast && ( <div className="animate-fade-in" @@ -1275,37 +1266,13 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim(); }} > {/* Header */} - <header - style={{ - flexShrink: 0, - padding: "0 24px", - height: 64, - borderBottom: "1px solid var(--border)", - background: "var(--bg-2)", - display: "flex", - alignItems: "center", - gap: 12, - }} + <PageHeader + title={t("intel_title")} + subtitle={t("intel_subtitle")} + icon={Sparkles} + iconColor="#a78bfa" + iconBg="rgba(167,139,250,0.1)" > - <div - style={{ - width: 32, - height: 32, - borderRadius: 8, - background: "rgba(167,139,250,0.1)", - border: "1px solid rgba(167,139,250,0.2)", - display: "flex", - alignItems: "center", - justifyContent: "center", - }} - > - <Sparkles size={15} style={{ color: "#a78bfa" }} /> - </div> - <div> - <h1 className="page-title">{t("intel_title")}</h1> - <p className="page-subtitle">{t("intel_subtitle")}</p> - </div> - {/* Mode toggle */} <div style={{ @@ -1319,8 +1286,8 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim(); }} > {[ - { key: "search", label: t("intel_search"), icon: Search }, { key: "ask", label: t("intel_ask_ai"), icon: MessageSquare }, + { key: "search", label: t("intel_search"), icon: Search }, ].map(({ key, label, icon: Icon }) => ( <button key={key} @@ -1353,9 +1320,11 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim(); </button> ))} </div> - </header> + </PageHeader> <div + key={mode} + className="animate-fade-in" style={{ flex: 1, overflowY: "auto", @@ -1973,6 +1942,6 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim(); )} </div> </main> - </div> + </PageShell> ); } diff --git a/frontend/src/pages/Help.jsx b/frontend/src/pages/Help.jsx @@ -22,6 +22,7 @@ import { Zap, } from "lucide-react"; import { useT } from "../hooks/useTranslation"; +import { PrimaryButton } from "../components/ui"; // Data @@ -1048,6 +1049,7 @@ export default function Help() { return ( <div + className="animate-fade-scale" style={{ flex: 1, overflowY: "auto", @@ -1124,8 +1126,16 @@ export default function Help() { </div> </div> - {/* ── Section 1: Sensor Terms ── */} - <section style={{ marginBottom: 36 }}> + {/* Section 1: Sensor Terms */} + <section + className="animate-fade-up" + style={{ + marginBottom: 36, + animationDelay: "0.1s", + opacity: 0, + animationFillMode: "forwards", + }} + > <SectionHeader>{dict["help_section_terms"]}</SectionHeader> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}> {TERMS.map((term) => ( @@ -1134,8 +1144,16 @@ export default function Help() { </div> </section> - {/* ── Section 2: AI Agents ── */} - <section style={{ marginBottom: 36 }}> + {/* Section 2: AI Agents */} + <section + className="animate-fade-up" + style={{ + marginBottom: 36, + animationDelay: "0.2s", + opacity: 0, + animationFillMode: "forwards", + }} + > <SectionHeader>{dict["help_section_agents"]}</SectionHeader> {/* Pipeline flow visualization */} @@ -1183,8 +1201,16 @@ export default function Help() { </div> </section> - {/* ── Section 3: Growth Stages ── */} - <section style={{ marginBottom: 36 }}> + {/* Section 3: Growth Stages */} + <section + className="animate-fade-up" + style={{ + marginBottom: 36, + animationDelay: "0.3s", + opacity: 0, + animationFillMode: "forwards", + }} + > <SectionHeader>{dict["help_section_stages"]}</SectionHeader> <div style={{ @@ -1199,8 +1225,16 @@ export default function Help() { </div> </section> - {/* ── Section 4: Manual Intervention ── */} - <section style={{ marginBottom: 36 }}> + {/* Section 4: Manual Intervention */} + <section + className="animate-fade-up" + style={{ + marginBottom: 36, + animationDelay: "0.4s", + opacity: 0, + animationFillMode: "forwards", + }} + > <SectionHeader>{dict["help_section_manual"]}</SectionHeader> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}> {MANUAL_SCENARIOS.map((s) => ( @@ -1209,8 +1243,9 @@ export default function Help() { </div> </section> - {/* ── AI CTA ── */} + {/* AI CTA */} <div + className="animate-fade-scale" style={{ padding: "24px", borderRadius: 16, @@ -1223,6 +1258,9 @@ export default function Help() { gap: 16, flexWrap: "wrap", marginBottom: 8, + animationDelay: "0.5s", + opacity: 0, + animationFillMode: "forwards", }} > <div> @@ -1240,26 +1278,12 @@ export default function Help() { {dict["help_ai_cta_desc"]} </div> </div> - <button + <PrimaryButton onClick={() => navigate("/intelligence")} - style={{ - padding: "10px 20px", - borderRadius: 10, - background: "var(--green)", - border: "none", - color: "#0c1a0e", - fontWeight: 700, - fontSize: 13, - cursor: "pointer", - display: "flex", - alignItems: "center", - gap: 6, - flexShrink: 0, - }} + icon={Sparkles} > - <Sparkles size={14} /> {dict["help_ai_cta_btn"]} - </button> + </PrimaryButton> </div> </div> ); diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx @@ -14,7 +14,7 @@ import { Globe, HelpCircle, } from "lucide-react"; -import Sidebar from "../components/Sidebar"; +import { PageShell, PageHeader, SectionCard } from "../components/ui"; import { useSettings } from "../hooks/useSettings"; import { useT } from "../hooks/useTranslation"; import { USE_MOCK_DATA } from "../data/mockData"; @@ -59,22 +59,6 @@ function SectionHeader({ icon: Icon, title, sub }) { ); } -function Card({ children, style }) { - return ( - <div - style={{ - borderRadius: 16, - padding: 24, - background: "var(--surface)", - border: "1px solid var(--border)", - ...style, - }} - > - {children} - </div> - ); -} - function FieldRow({ label, hint, children }) { return ( <div style={{ display: "flex", flexDirection: "column", gap: 6 }}> @@ -227,16 +211,7 @@ export default function SettingsPage() { ); return ( - <div - style={{ - display: "flex", - height: "100vh", - overflow: "hidden", - background: "var(--bg)", - }} - > - <Sidebar /> - + <PageShell> {showOnboarding && ( <Onboarding onDone={() => { @@ -255,23 +230,11 @@ export default function SettingsPage() { }} > {/* Header */} - <header - style={{ - flexShrink: 0, - padding: "0 28px", - height: 64, - borderBottom: "1px solid var(--border)", - background: "var(--bg-2)", - display: "flex", - alignItems: "center", - justifyContent: "space-between", - }} + <PageHeader + title={t("settings_title")} + subtitle={t("settings_subtitle")} > - <div> - <h1 className="page-title">{t("settings_title")}</h1> - <p className="page-subtitle">{t("settings_subtitle")}</p> - </div> - <div style={{ display: "flex", gap: 10 }}> + <div style={{ display: "flex", gap: 10, marginLeft: "auto" }}> <button onClick={handleReset} style={{ @@ -317,7 +280,7 @@ export default function SettingsPage() { )} </button> </div> - </header> + </PageHeader> <div style={{ flex: 1, overflowY: "auto", padding: 28 }}> <div @@ -329,7 +292,7 @@ export default function SettingsPage() { }} > {/* Profile */} - <Card> + <SectionCard> <SectionHeader icon={User} title={t("settings_profile")} @@ -376,10 +339,10 @@ export default function SettingsPage() { /> </FieldRow> </div> - </Card> + </SectionCard> {/* Appearance */} - <Card> + <SectionCard> <SectionHeader icon={Sun} title={t("settings_appearance")} @@ -421,10 +384,10 @@ export default function SettingsPage() { /> </FieldRow> </div> - </Card> + </SectionCard> {/* Language */} - <Card> + <SectionCard> <SectionHeader icon={Globe} title={t("settings_language")} @@ -450,10 +413,10 @@ export default function SettingsPage() { हिंदी भाषा चुनी गई है। सहेजने के बाद पूरा ऐप हिंदी में दिखेगा। </div> )} - </Card> + </SectionCard> {/* Help & Onboarding */} - <Card> + <SectionCard> <SectionHeader icon={HelpCircle} title={t("settings_onboarding")} @@ -481,10 +444,10 @@ export default function SettingsPage() { <HelpCircle size={14} /> {t("settings_restart_onboarding")} </button> - </Card> + </SectionCard> {/* Display */} - <Card> + <SectionCard> <SectionHeader icon={LayoutGrid} title={t("settings_display_section")} @@ -534,10 +497,10 @@ export default function SettingsPage() { </select> </FieldRow> </div> - </Card> + </SectionCard> {/* Alerts */} - <Card> + <SectionCard> <SectionHeader icon={Bell} title={t("settings_alerts_section")} @@ -551,10 +514,10 @@ export default function SettingsPage() { disabledLabel={t("common_hiding_acked")} /> </FieldRow> - </Card> + </SectionCard> {/* Data Source */} - <Card> + <SectionCard> <SectionHeader icon={Database} title={t("settings_data_source")} @@ -605,10 +568,10 @@ export default function SettingsPage() { </div> </div> </div> - </Card> + </SectionCard> </div> </div> </main> - </div> + </PageShell> ); } diff --git a/frontend/src/utils/translations.js b/frontend/src/utils/translations.js @@ -174,6 +174,7 @@ const en = { alerts_subtitle_loading: "Analyzing sensor history…", alerts_subtitle: "{unacked} unacknowledged · {total} total", alerts_ack_all: "Ack all", + alerts_ack_all_btn: "Acknowledge All", alerts_unacked: "UNACKNOWLEDGED · {n}", alerts_acknowledged: "ACKNOWLEDGED · {n}", alerts_empty_connected: "All clear for the selected filter", @@ -619,6 +620,7 @@ const hi = { alerts_subtitle_loading: "सेंसर इतिहास की जांच हो रही है…", alerts_subtitle: "{unacked} अनदेखे · {total} कुल", alerts_ack_all: "सभी देखे", + alerts_ack_all_btn: "सभी स्वीकार करें", alerts_unacked: "अनदेखे · {n}", alerts_acknowledged: "देखे गए · {n}", alerts_empty_connected: "चुने फ़िल्टर के लिए सब ठीक है",