finished meteorologia and main dashboard
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "litoralregas-frontend",
|
||||
"title": "Litoral Central",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
|
||||
+4
-1
@@ -14,7 +14,10 @@ function App() {
|
||||
activePage === "meteo" ? (
|
||||
<MeteoPage theme={theme} />
|
||||
) : (
|
||||
<DashboardPage theme={theme} />
|
||||
<DashboardPage
|
||||
theme={theme}
|
||||
onOpenMeteo={() => setActivePage("meteo")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</AppShell>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 331 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 348 KiB |
@@ -1,16 +1,16 @@
|
||||
import { ReactNode, useState } from "react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { Sidebar } from "../navigation/Sidebar";
|
||||
import { TopBar } from "./TopBar";
|
||||
import { BottomStatusBar } from "./BottomStatusBar";
|
||||
import { TopBar } from "../layout/TopBar";
|
||||
import { useTelemetryStream } from "../../features/telemetry/hooks/useTelemetryStream";
|
||||
import { useNotifications } from "../../features/notifications/hooks/useNotifications";
|
||||
import { useCurrentUser } from "../../features/auth/hooks/useCurrentUser";
|
||||
import { useRuntimeConfig } from "../../features/system/hooks/useRuntimeConfig";
|
||||
import type { TelemetrySnapshot } from "../../types/telemetry";
|
||||
import type { AppPage } from "../../app/App";
|
||||
|
||||
type Theme = "dark" | "light";
|
||||
|
||||
type AppShellRenderProps = {
|
||||
theme: "dark" | "light";
|
||||
theme: Theme;
|
||||
snapshots: TelemetrySnapshot[];
|
||||
};
|
||||
|
||||
@@ -19,31 +19,71 @@ type AppShellProps = {
|
||||
onNavigate: (page: AppPage) => void;
|
||||
children: (props: AppShellRenderProps) => ReactNode;
|
||||
};
|
||||
|
||||
const THEME_STORAGE_KEY = "app-theme";
|
||||
|
||||
export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
|
||||
const telemetry = useTelemetryStream();
|
||||
const notifications = useNotifications();
|
||||
const currentUser = useCurrentUser();
|
||||
const runtime = useRuntimeConfig();
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
return stored === "light" || stored === "dark" ? stored : "dark";
|
||||
});
|
||||
|
||||
const isDark = theme === "dark";
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((current) => (current === "dark" ? "light" : "dark"));
|
||||
};
|
||||
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "h-screen overflow-hidden bg-[#0E1A24] text-[#EAF2FA]"
|
||||
: "h-screen overflow-hidden bg-[#F4F7FA] text-[#102030]"
|
||||
? "fixed inset-0 overflow-hidden bg-[#0b1220] text-slate-100"
|
||||
: "fixed inset-0 overflow-hidden bg-slate-100 text-slate-950"
|
||||
}
|
||||
>
|
||||
<style>{`
|
||||
.app-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: ${isDark ? "#334155 #0b1220" : "#94a3b8 #f1f5f9"};
|
||||
}
|
||||
|
||||
.app-scrollbar::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.app-scrollbar::-webkit-scrollbar-track {
|
||||
background: ${isDark ? "#0b1220" : "#f1f5f9"};
|
||||
border-left: 1px solid ${isDark ? "rgba(255,255,255,0.06)" : "#e2e8f0"};
|
||||
}
|
||||
|
||||
.app-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: ${isDark ? "#334155" : "#94a3b8"};
|
||||
border-radius: 999px;
|
||||
border: 2px solid ${isDark ? "#0b1220" : "#f1f5f9"};
|
||||
}
|
||||
|
||||
.app-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: ${isDark ? "#475569" : "#64748b"};
|
||||
}
|
||||
|
||||
.app-scrollbar::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<div className="relative h-full shrink-0 overflow-hidden">
|
||||
<aside className="relative h-full shrink-0 overflow-hidden">
|
||||
<Sidebar
|
||||
theme={theme}
|
||||
activePage={activePage}
|
||||
@@ -51,8 +91,15 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
|
||||
onNavigate={onNavigate}
|
||||
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
|
||||
/>
|
||||
<div className="pointer-events-none absolute right-0 top-0 h-full w-2 bg-gradient-to-r from-[#3A5064]/6 to-transparent blur-sm" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "pointer-events-none absolute right-0 top-0 h-full w-px bg-white/10"
|
||||
: "pointer-events-none absolute right-0 top-0 h-full w-px bg-slate-200"
|
||||
}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<TopBar
|
||||
@@ -68,23 +115,23 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
|
||||
<main
|
||||
className={
|
||||
isDark
|
||||
? "custom-scrollbar min-h-0 flex-1 overflow-y-auto bg-[#0E1A24] px-6 py-5"
|
||||
: "custom-scrollbar min-h-0 flex-1 overflow-y-auto bg-[#F4F7FA] px-6 py-5"
|
||||
? `app-scrollbar min-h-0 flex-1 border-t border-white/10 bg-[#0b1220] ${activePage === "dashboard"
|
||||
? "overflow-hidden p-0"
|
||||
: "overflow-y-auto p-4"
|
||||
}`
|
||||
: `app-scrollbar min-h-0 flex-1 border-t border-slate-200 bg-slate-100 ${activePage === "dashboard"
|
||||
? "overflow-hidden p-0"
|
||||
: "overflow-y-auto p-4"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<div className="h-full w-full">
|
||||
{children({
|
||||
theme,
|
||||
snapshots: telemetry.snapshots,
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<BottomStatusBar
|
||||
theme={theme}
|
||||
backendPort={runtime.runtimeConfig?.backendPort.toString()}
|
||||
mode={runtime.runtimeConfig?.mode}
|
||||
controllerName={runtime.runtimeConfig?.controllerName}
|
||||
controllerIp={runtime.runtimeConfig?.controllerIp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,8 @@ type TopBarProps = {
|
||||
onToggleTheme: () => void;
|
||||
};
|
||||
|
||||
const RADIUS = "rounded-[5px]";
|
||||
|
||||
export function TopBar({
|
||||
connected,
|
||||
lastTimestamp,
|
||||
@@ -40,9 +42,7 @@ export function TopBar({
|
||||
const isDark = theme === "dark";
|
||||
const ThemeIcon = isDark ? Moon : Sun;
|
||||
|
||||
const systemDate = lastTimestamp
|
||||
? new Date(lastTimestamp)
|
||||
: null;
|
||||
const systemDate = lastTimestamp ? new Date(lastTimestamp) : null;
|
||||
|
||||
const formattedTime = systemDate
|
||||
? systemDate.toLocaleTimeString("pt-PT", {
|
||||
@@ -61,39 +61,35 @@ export function TopBar({
|
||||
: "--/--/----";
|
||||
|
||||
const dropdownClass = isDark
|
||||
? "absolute right-0 top-12 z-50 rounded-2xl border border-[#24394A] bg-[#0F1D29] shadow-2xl shadow-black/40"
|
||||
: "absolute right-0 top-12 z-50 rounded-2xl border border-[#D5DDE6] bg-white shadow-2xl shadow-slate-300/40";
|
||||
? `absolute right-0 top-12 z-50 ${RADIUS} border border-white/[0.04] bg-[#111827] shadow-2xl`
|
||||
: `absolute right-0 top-12 z-50 ${RADIUS} border border-slate-200 bg-white shadow-xl`;
|
||||
|
||||
const dropdownTitleClass = isDark
|
||||
? "text-sm font-semibold text-[#E4EDF6]"
|
||||
: "text-sm font-semibold text-[#162434]";
|
||||
? "text-sm font-bold text-slate-100"
|
||||
: "text-sm font-bold text-slate-950";
|
||||
|
||||
const mutedTextClass = isDark
|
||||
? "text-[#8FA3B8]"
|
||||
: "text-[#607284]";
|
||||
const mutedTextClass = "text-slate-500";
|
||||
|
||||
const menuItemClass = isDark
|
||||
? "flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-[#D4DEE8] transition-colors hover:bg-[#182B3B]"
|
||||
: "flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-[#162434] transition-colors hover:bg-[#EEF3F7]";
|
||||
? `flex w-full items-center gap-3 ${RADIUS} px-3 py-2.5 text-left text-sm font-medium text-slate-300 transition hover:bg-white/[0.05] hover:text-slate-100`
|
||||
: `flex w-full items-center gap-3 ${RADIUS} px-3 py-2.5 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-100 hover:text-slate-950`;
|
||||
|
||||
const dividerClass = isDark
|
||||
? "my-2 h-px bg-[#24394A]"
|
||||
: "my-2 h-px bg-[#D5DDE6]";
|
||||
const dividerClass = isDark ? "my-2 h-px bg-white/10" : "my-2 h-px bg-slate-200";
|
||||
|
||||
return (
|
||||
<header
|
||||
className={
|
||||
isDark
|
||||
? "flex h-16 items-center justify-between bg-[#0E1A24] px-6"
|
||||
: "flex h-16 items-center justify-between bg-[#F4F7FA] px-6"
|
||||
? "flex h-16 items-center justify-between bg-[#0b1220] px-6"
|
||||
: "flex h-16 items-center justify-between border-b border-slate-200 bg-slate-50 px-6"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<h1
|
||||
className={
|
||||
isDark
|
||||
? "text-[28px] font-bold tracking-tight text-[#D4DEE8]"
|
||||
: "text-[28px] font-bold tracking-tight text-[#162434]"
|
||||
? "text-2xl font-black tracking-[-0.04em] text-slate-100"
|
||||
: "text-2xl font-black tracking-[-0.04em] text-slate-950"
|
||||
}
|
||||
>
|
||||
{pageTitle(activePage)}
|
||||
@@ -103,70 +99,57 @@ export function TopBar({
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "flex items-center gap-6 text-sm text-slate-300"
|
||||
: "flex items-center gap-6 text-sm text-[#445569]"
|
||||
? "flex items-center gap-5 text-sm text-slate-400"
|
||||
: "flex items-center gap-5 text-sm text-slate-500"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `hidden items-center gap-2 ${RADIUS} border border-white/[0.04] bg-white/[0.03] px-3 py-1.5 md:flex`
|
||||
: `hidden items-center gap-2 ${RADIUS} border border-slate-200 bg-white px-3 py-1.5 md:flex`
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={
|
||||
connected
|
||||
? "h-2.5 w-2.5 rounded-full bg-emerald-500"
|
||||
: "h-2.5 w-2.5 rounded-full bg-red-500"
|
||||
? "h-2 w-2 rounded-full bg-emerald-500"
|
||||
: "h-2 w-2 rounded-full bg-red-500"
|
||||
}
|
||||
/>
|
||||
|
||||
<span>
|
||||
{connected
|
||||
? "Ligado ao sistema"
|
||||
: "Sistema desligado"}
|
||||
<span className="font-medium">
|
||||
{connected ? "Ligado ao sistema" : "Sistema desligado"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock
|
||||
className={
|
||||
isDark
|
||||
? "h-4 w-4 text-slate-400"
|
||||
: "h-4 w-4 text-[#607284]"
|
||||
}
|
||||
/>
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Clock className="h-4 w-4 text-slate-500" />
|
||||
<span>{formattedTime}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarDays
|
||||
className={
|
||||
isDark
|
||||
? "h-4 w-4 text-slate-400"
|
||||
: "h-4 w-4 text-[#607284]"
|
||||
}
|
||||
/>
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<CalendarDays className="h-4 w-4 text-slate-500" />
|
||||
<span>{formattedDate}</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNotificationsOpen(!notificationsOpen);
|
||||
setUserMenuOpen(false);
|
||||
}}
|
||||
className={
|
||||
isDark
|
||||
? "relative flex h-9 w-9 items-center justify-center rounded-full transition-colors hover:bg-[#182531]"
|
||||
: "relative flex h-9 w-9 items-center justify-center rounded-full transition-colors hover:bg-[#E2E8F0]"
|
||||
? `relative grid h-10 w-10 place-items-center ${RADIUS} border border-white/[0.04] bg-white/[0.03] text-slate-400 transition hover:bg-white/[0.06] hover:text-slate-100`
|
||||
: `relative grid h-10 w-10 place-items-center ${RADIUS} border border-slate-200 bg-white text-slate-500 transition hover:bg-slate-100 hover:text-slate-950`
|
||||
}
|
||||
>
|
||||
<Bell
|
||||
className={
|
||||
isDark
|
||||
? "h-5 w-5 text-[#C8D3DF]"
|
||||
: "h-5 w-5 text-[#445569]"
|
||||
}
|
||||
/>
|
||||
<Bell className="h-5 w-5" />
|
||||
|
||||
{notificationCount > 0 && (
|
||||
<span className="absolute right-1 top-0 rounded-full bg-sky-500 px-1.5 text-[10px] font-bold text-white">
|
||||
<span className="absolute -right-1 -top-1 min-w-5 rounded-full bg-slate-900 px-1.5 text-[10px] font-bold leading-5 text-white">
|
||||
{notificationCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -175,9 +158,7 @@ export function TopBar({
|
||||
{notificationsOpen && (
|
||||
<div className={`${dropdownClass} w-80 p-4`}>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className={dropdownTitleClass}>
|
||||
Notificações
|
||||
</span>
|
||||
<span className={dropdownTitleClass}>Notificações</span>
|
||||
|
||||
<span className={`text-xs ${mutedTextClass}`}>
|
||||
{notificationCount} novas
|
||||
@@ -187,8 +168,8 @@ export function TopBar({
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "rounded-xl bg-[#132331] px-4 py-4 text-sm text-[#8FA3B8]"
|
||||
: "rounded-xl bg-[#EEF3F7] px-4 py-4 text-sm text-[#607284]"
|
||||
? `${RADIUS} border border-white/[0.04] bg-white/[0.03] px-4 py-4 text-sm text-slate-400`
|
||||
: `${RADIUS} border border-slate-200 bg-slate-50 px-4 py-4 text-sm text-slate-500`
|
||||
}
|
||||
>
|
||||
Sem notificações.
|
||||
@@ -199,51 +180,48 @@ export function TopBar({
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUserMenuOpen(!userMenuOpen);
|
||||
setNotificationsOpen(false);
|
||||
}}
|
||||
className={
|
||||
isDark
|
||||
? "flex items-center gap-2 rounded-full bg-[#182531] py-1 pl-1 pr-3 transition-colors hover:bg-[#223445]"
|
||||
: "flex items-center gap-2 rounded-full bg-[#E2E8F0] py-1 pl-1 pr-3 transition-colors hover:bg-[#D5DDE6]"
|
||||
? `flex items-center gap-2 ${RADIUS} border border-white/[0.04] bg-white/[0.03] py-1 pl-1 pr-3 transition hover:bg-white/[0.06]`
|
||||
: `flex items-center gap-2 ${RADIUS} border border-slate-200 bg-white py-1 pl-1 pr-3 transition hover:bg-slate-100`
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
isDark
|
||||
? "flex h-8 w-8 items-center justify-center rounded-full bg-[#24394A] text-xs font-semibold text-[#D4DEE8]"
|
||||
: "flex h-8 w-8 items-center justify-center rounded-full bg-[#CBD5E1] text-xs font-semibold text-[#162434]"
|
||||
? "flex h-8 w-8 items-center justify-center rounded-full bg-slate-200 text-xs font-black text-slate-950"
|
||||
: "flex h-8 w-8 items-center justify-center rounded-full bg-slate-900 text-xs font-black text-white"
|
||||
}
|
||||
>
|
||||
{userInitials}
|
||||
</span>
|
||||
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${isDark
|
||||
? "text-[#8FA3B8]"
|
||||
: "text-[#607284]"
|
||||
} ${userMenuOpen ? "rotate-180" : ""}`}
|
||||
className={`h-4 w-4 text-slate-500 transition-transform ${userMenuOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{userMenuOpen && (
|
||||
<div className={`${dropdownClass} w-64 p-3`}>
|
||||
<div className="mb-3 flex items-center gap-3 rounded-xl px-2 py-2">
|
||||
<div className={`mb-3 flex items-center gap-3 ${RADIUS} px-2 py-2`}>
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "flex h-10 w-10 items-center justify-center rounded-full bg-[#24394A] text-sm font-semibold text-[#D4DEE8]"
|
||||
: "flex h-10 w-10 items-center justify-center rounded-full bg-[#CBD5E1] text-sm font-semibold text-[#162434]"
|
||||
? "flex h-10 w-10 items-center justify-center rounded-full bg-slate-200 text-sm font-black text-slate-950"
|
||||
: "flex h-10 w-10 items-center justify-center rounded-full bg-slate-900 text-sm font-black text-white"
|
||||
}
|
||||
>
|
||||
{userInitials}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={dropdownTitleClass}>
|
||||
admin
|
||||
</div>
|
||||
<div className={dropdownTitleClass}>admin</div>
|
||||
|
||||
<div className={`text-xs ${mutedTextClass}`}>
|
||||
Administrador
|
||||
@@ -253,22 +231,23 @@ export function TopBar({
|
||||
|
||||
<div className={dividerClass} />
|
||||
|
||||
<button className={menuItemClass}>
|
||||
<button type="button" className={menuItemClass}>
|
||||
<User className={`h-4 w-4 ${mutedTextClass}`} />
|
||||
Perfil
|
||||
</button>
|
||||
|
||||
<button className={menuItemClass}>
|
||||
<button type="button" className={menuItemClass}>
|
||||
<SlidersHorizontal className={`h-4 w-4 ${mutedTextClass}`} />
|
||||
Preferências
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleTheme}
|
||||
className={
|
||||
isDark
|
||||
? "flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-left text-sm text-[#D4DEE8] transition-colors hover:bg-[#182B3B]"
|
||||
: "flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-left text-sm text-[#162434] transition-colors hover:bg-[#EEF3F7]"
|
||||
? `flex w-full items-center justify-between ${RADIUS} px-3 py-2.5 text-left text-sm font-medium text-slate-300 transition hover:bg-white/[0.05] hover:text-slate-100`
|
||||
: `flex w-full items-center justify-between ${RADIUS} px-3 py-2.5 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-100 hover:text-slate-950`
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
@@ -276,30 +255,31 @@ export function TopBar({
|
||||
Tema
|
||||
</span>
|
||||
|
||||
<ThemeIcon
|
||||
className={
|
||||
isDark
|
||||
? "h-4 w-4 text-[#8FA3B8]"
|
||||
: "h-4 w-4 text-[#607284]"
|
||||
}
|
||||
/>
|
||||
<ThemeIcon className="h-4 w-4 text-slate-500" />
|
||||
</button>
|
||||
|
||||
<div className={dividerClass} />
|
||||
|
||||
<button className={menuItemClass}>
|
||||
<button type="button" className={menuItemClass}>
|
||||
<CircleHelp className={`h-4 w-4 ${mutedTextClass}`} />
|
||||
Ajuda
|
||||
</button>
|
||||
|
||||
<button className={menuItemClass}>
|
||||
<button type="button" className={menuItemClass}>
|
||||
<Info className={`h-4 w-4 ${mutedTextClass}`} />
|
||||
Sobre
|
||||
</button>
|
||||
|
||||
<div className={dividerClass} />
|
||||
|
||||
<button className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-red-400 transition-colors hover:bg-red-500/10">
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
isDark
|
||||
? `flex w-full items-center gap-3 ${RADIUS} px-3 py-2.5 text-left text-sm font-medium text-red-300 transition hover:bg-red-500/10`
|
||||
: `flex w-full items-center gap-3 ${RADIUS} px-3 py-2.5 text-left text-sm font-medium text-red-600 transition hover:bg-red-50`
|
||||
}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Terminar sessão
|
||||
</button>
|
||||
@@ -314,7 +294,7 @@ export function TopBar({
|
||||
function pageTitle(page: AppPage | null) {
|
||||
switch (page) {
|
||||
case "dashboard":
|
||||
return "Painel Principal";
|
||||
return "";
|
||||
|
||||
case "meteo":
|
||||
return "Meteorologia";
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CloudSun,
|
||||
Droplet,
|
||||
Filter,
|
||||
Gauge,
|
||||
Home,
|
||||
Lightbulb,
|
||||
MonitorDot,
|
||||
Settings,
|
||||
TabletSmartphone,
|
||||
Waves,
|
||||
Wind,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -20,6 +28,8 @@ type SidebarProps = {
|
||||
onToggleCollapsed: () => void;
|
||||
};
|
||||
|
||||
const RADIUS = "rounded-[10px]";
|
||||
|
||||
const navigationItems: {
|
||||
label: string;
|
||||
page: AppPage;
|
||||
@@ -29,10 +39,24 @@ const navigationItems: {
|
||||
{ label: "Meteorologia", page: "meteo", icon: CloudSun },
|
||||
];
|
||||
|
||||
const disabledItems = [
|
||||
const climateItems = [
|
||||
{ label: "Iluminação", icon: Lightbulb },
|
||||
{ label: "Ventilação", icon: Wind },
|
||||
{ label: "Sinótico", icon: MonitorDot },
|
||||
{ label: "Gráficos", icon: BarChart3 },
|
||||
];
|
||||
|
||||
const irrigationItems = [
|
||||
{ label: "Regas", icon: Droplet },
|
||||
{ label: "Sinótico", icon: MonitorDot },
|
||||
{ label: "Filtros de Rega", icon: Filter },
|
||||
{ label: "Consumos", icon: Gauge },
|
||||
{ label: "Drenagem", icon: Waves },
|
||||
{ label: "Gráficos", icon: BarChart3 },
|
||||
];
|
||||
|
||||
const utilityItems = [
|
||||
{ label: "Consola (VNC)", icon: TabletSmartphone },
|
||||
{ label: "Rega", icon: Droplet },
|
||||
{ label: "Clima", icon: Wind },
|
||||
{ label: "Configurações", icon: Settings },
|
||||
];
|
||||
|
||||
@@ -45,47 +69,72 @@ export function Sidebar({
|
||||
}: SidebarProps) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const [climateOpen, setClimateOpen] = useState(false);
|
||||
const [irrigationOpen, setIrrigationOpen] = useState(false);
|
||||
const [activeTreeItem, setActiveTreeItem] = useState<string | null>(null);
|
||||
|
||||
const handleTreeClick = (key: string) => {
|
||||
setActiveTreeItem(key);
|
||||
};
|
||||
|
||||
const handleTreeToggle = (section: "climate" | "irrigation") => {
|
||||
if (collapsed) {
|
||||
onToggleCollapsed();
|
||||
}
|
||||
|
||||
if (section === "climate") {
|
||||
setClimateOpen((current) => !current);
|
||||
setIrrigationOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIrrigationOpen((current) => !current);
|
||||
setClimateOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={
|
||||
isDark
|
||||
? `${collapsed ? "w-20" : "w-64"} flex h-full flex-col bg-[#0B1620] px-4 py-5 shadow-[inset_-1px_0_0_0_rgba(80,100,120,0.25),2px_0_12px_rgba(0,0,0,0.15)] transition-all duration-200`
|
||||
: `${collapsed ? "w-20" : "w-64"} flex h-full flex-col bg-[#EEF3F7] px-4 py-5 shadow-[inset_-1px_0_0_0_rgba(180,190,200,0.5)] transition-all duration-200`
|
||||
? `${collapsed ? "w-20" : "w-[290px]"} flex h-full flex-col border-r border-white/10 bg-[#0F172A] px-4 py-5 text-slate-100 shadow-[8px_0_30px_rgba(0,0,0,0.18)] transition-all duration-200`
|
||||
: `${collapsed ? "w-20" : "w-[290px]"} flex h-full flex-col border-r border-[#D8DEE7] bg-[#F8FAFC] px-4 py-5 text-[#0F172A] shadow-[8px_0_28px_rgba(15,23,42,0.04)] transition-all duration-200`
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
collapsed
|
||||
? "mb-10 flex items-center justify-center"
|
||||
: "mb-10 flex items-center gap-3 px-2"
|
||||
? "mb-12 flex items-center justify-center"
|
||||
: "mb-12 flex items-center gap-4 px-1"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden border border-white/10 bg-white/[0.04] shadow-[0_10px_24px_rgba(0,0,0,0.18)]`
|
||||
: `${RADIUS} flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden border border-[#D8DEE7] bg-white shadow-sm`
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={logo}
|
||||
alt="LitoralRegas"
|
||||
className="h-12 w-12 shrink-0 object-contain"
|
||||
alt="Litoral Central"
|
||||
className="h-12 w-12 scale-[1.35] object-cover"
|
||||
/>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "truncate text-[16px] font-bold tracking-wide text-white"
|
||||
: "truncate text-[16px] font-bold tracking-wide text-[#162434]"
|
||||
}
|
||||
>
|
||||
LITORAL CENTRAL
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "mt-0.5 text-[10px] uppercase tracking-[0.18em] text-[#8FA3B8]"
|
||||
: "mt-0.5 text-[10px] uppercase tracking-[0.18em] text-[#607284]"
|
||||
? "truncate text-[16px] font-black tracking-[-0.02em] text-white"
|
||||
: "truncate text-[16px] font-black tracking-[-0.02em] text-[#0F172A]"
|
||||
}
|
||||
>
|
||||
OPERAÇÕES AGRÍCOLAS
|
||||
Litoral Central
|
||||
</div>
|
||||
|
||||
<div className="mt-1 whitespace-nowrap text-[10px] font-bold uppercase tracking-[0.1em] text-slate-500">
|
||||
Operações agrícolas
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -94,63 +143,95 @@ export function Sidebar({
|
||||
<nav className="space-y-2">
|
||||
{navigationItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = activePage === item.page;
|
||||
const active = activePage === item.page && activeTreeItem === null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => onNavigate(item.page)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveTreeItem(null);
|
||||
onNavigate(item.page);
|
||||
}}
|
||||
title={collapsed ? item.label : undefined}
|
||||
className={
|
||||
active
|
||||
? isDark
|
||||
? "flex w-full items-center gap-3 rounded-xl bg-[#18304B] px-4 py-3 text-left text-sm font-semibold text-white"
|
||||
: "flex w-full items-center gap-3 rounded-xl bg-[#DCE8F5] px-4 py-3 text-left text-sm font-semibold text-[#162434]"
|
||||
: isDark
|
||||
? "flex w-full items-center gap-3 rounded-xl px-4 py-3 text-left text-sm font-medium text-[#D8E2EC] hover:bg-[#132434] hover:text-white"
|
||||
: "flex w-full items-center gap-3 rounded-xl px-4 py-3 text-left text-sm font-medium text-[#445569] hover:bg-[#E2E8F0] hover:text-[#162434]"
|
||||
}
|
||||
className={navButtonClass(isDark, active, collapsed)}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
{active && <ActiveIndicator isDark={isDark} />}
|
||||
|
||||
<Icon className={navIconClass(isDark, active)} />
|
||||
|
||||
{!collapsed && <span className="truncate">{item.label}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{disabledItems.map((item) => {
|
||||
<SectionLabel collapsed={collapsed} label="Operação" />
|
||||
|
||||
<TreeSection
|
||||
theme={theme}
|
||||
collapsed={collapsed}
|
||||
label="Clima"
|
||||
icon={Wind}
|
||||
open={climateOpen}
|
||||
onToggle={() => handleTreeToggle("climate")}
|
||||
items={climateItems}
|
||||
sectionKey="climate"
|
||||
activeTreeItem={activeTreeItem}
|
||||
onItemClick={handleTreeClick}
|
||||
/>
|
||||
|
||||
<TreeSection
|
||||
theme={theme}
|
||||
collapsed={collapsed}
|
||||
label="Rega"
|
||||
icon={Droplet}
|
||||
open={irrigationOpen}
|
||||
onToggle={() => handleTreeToggle("irrigation")}
|
||||
items={irrigationItems}
|
||||
sectionKey="irrigation"
|
||||
activeTreeItem={activeTreeItem}
|
||||
onItemClick={handleTreeClick}
|
||||
/>
|
||||
|
||||
<SectionLabel collapsed={collapsed} label="Sistema" />
|
||||
|
||||
{utilityItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const key = `utility:${item.label}`;
|
||||
const active = activeTreeItem === key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
disabled
|
||||
type="button"
|
||||
onClick={() => handleTreeClick(key)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
className={
|
||||
isDark
|
||||
? "flex w-full cursor-not-allowed items-center gap-3 rounded-xl px-4 py-3 text-left text-sm font-medium text-[#607284] opacity-60"
|
||||
: "flex w-full cursor-not-allowed items-center gap-3 rounded-xl px-4 py-3 text-left text-sm font-medium text-[#8A9AAB] opacity-70"
|
||||
}
|
||||
className={navButtonClass(isDark, active, collapsed)}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
{active && <ActiveIndicator isDark={isDark} />}
|
||||
|
||||
<Icon className={navIconClass(isDark, active)} />
|
||||
|
||||
{!collapsed && <span className="truncate">{item.label}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapsed}
|
||||
className={
|
||||
isDark
|
||||
? "mt-auto flex items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm text-slate-400 hover:bg-[#132434] hover:text-white"
|
||||
: "mt-auto flex items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm text-[#607284] hover:bg-[#E2E8F0] hover:text-[#162434]"
|
||||
? `mt-auto flex items-center justify-center gap-2 ${RADIUS} border border-white/10 bg-white/[0.04] px-4 py-3.5 text-[15px] font-semibold text-slate-400 shadow-[0_10px_24px_rgba(0,0,0,0.12)] transition hover:bg-white/[0.06] hover:text-white`
|
||||
: `mt-auto flex items-center justify-center gap-2 ${RADIUS} border border-[#D8DEE7] bg-white px-4 py-3.5 text-[15px] font-semibold text-slate-500 shadow-sm transition hover:bg-[#F1F5F9] hover:text-[#0F172A]`
|
||||
}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
<ChevronRight className="h-[22px] w-[22px]" />
|
||||
) : (
|
||||
<>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
<ChevronLeft className="h-[22px] w-[22px]" />
|
||||
<span>Recolher menu</span>
|
||||
</>
|
||||
)}
|
||||
@@ -158,3 +239,161 @@ export function Sidebar({
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeSection({
|
||||
theme,
|
||||
collapsed,
|
||||
label,
|
||||
icon: Icon,
|
||||
open,
|
||||
onToggle,
|
||||
items,
|
||||
sectionKey,
|
||||
activeTreeItem,
|
||||
onItemClick,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
collapsed: boolean;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
items: { label: string; icon: React.ElementType }[];
|
||||
sectionKey: string;
|
||||
activeTreeItem: string | null;
|
||||
onItemClick: (key: string) => void;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
const hasActiveChild = items.some(
|
||||
(item) => activeTreeItem === `${sectionKey}:${item.label}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
title={collapsed ? label : undefined}
|
||||
className={navButtonClass(isDark, hasActiveChild, collapsed)}
|
||||
>
|
||||
{hasActiveChild && <ActiveIndicator isDark={isDark} />}
|
||||
|
||||
<Icon className={navIconClass(isDark, hasActiveChild)} />
|
||||
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 shrink-0 text-slate-500 transition-transform duration-200 ${open ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!collapsed && open && (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "ml-5 mt-2 space-y-1.5 border-l border-white/10 pl-3"
|
||||
: "ml-5 mt-2 space-y-1.5 border-l border-[#D8DEE7] pl-3"
|
||||
}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const SubIcon = item.icon;
|
||||
const key = `${sectionKey}:${item.label}`;
|
||||
const active = activeTreeItem === key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
onClick={() => onItemClick(key)}
|
||||
className={
|
||||
active
|
||||
? isDark
|
||||
? "flex w-full items-center gap-2 rounded-[8px] bg-white/[0.06] px-3.5 py-2.5 text-left text-[13px] font-bold text-white"
|
||||
: "flex w-full items-center gap-2 rounded-[8px] bg-white px-3.5 py-2.5 text-left text-[13px] font-bold text-[#0F172A] shadow-sm"
|
||||
: isDark
|
||||
? "flex w-full items-center gap-2 rounded-[8px] px-3.5 py-2.5 text-left text-[13px] font-semibold text-slate-500 transition hover:bg-white/[0.04] hover:text-slate-200"
|
||||
: "flex w-full items-center gap-2 rounded-[8px] px-3.5 py-2.5 text-left text-[13px] font-semibold text-slate-500 transition hover:bg-white hover:text-[#0F172A]"
|
||||
}
|
||||
>
|
||||
<SubIcon
|
||||
className={
|
||||
active
|
||||
? isDark
|
||||
? "h-4 w-4 shrink-0 text-emerald-300"
|
||||
: "h-4 w-4 shrink-0 text-[#0F766E]"
|
||||
: "h-4 w-4 shrink-0"
|
||||
}
|
||||
/>
|
||||
|
||||
<span className="truncate">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({
|
||||
collapsed,
|
||||
label,
|
||||
}: {
|
||||
collapsed: boolean;
|
||||
label: string;
|
||||
}) {
|
||||
if (collapsed) {
|
||||
return <div className="py-2" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-3">
|
||||
<p className="px-4 text-[11px] font-bold uppercase tracking-[0.18em] text-slate-500">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveIndicator({ isDark }: { isDark: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
isDark
|
||||
? "absolute left-0 top-1/2 h-7 w-1 -translate-y-1/2 rounded-r-full bg-emerald-400"
|
||||
: "absolute left-0 top-1/2 h-7 w-1 -translate-y-1/2 rounded-r-full bg-[#0F766E]"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function navButtonClass(isDark: boolean, active: boolean, collapsed: boolean) {
|
||||
const alignment = collapsed ? "justify-center px-0" : "px-4";
|
||||
|
||||
if (active) {
|
||||
return isDark
|
||||
? `relative flex w-full items-center gap-3 ${RADIUS} border border-emerald-400/20 bg-[#162033] ${alignment} py-[14px] text-left text-[15px] font-black text-white shadow-[0_12px_28px_rgba(0,0,0,0.22)]`
|
||||
: `relative flex w-full items-center gap-3 ${RADIUS} border border-[#D8DEE7] bg-white ${alignment} py-[14px] text-left text-[15px] font-black text-[#0F172A] shadow-[0_8px_20px_rgba(15,23,42,0.06)]`;
|
||||
}
|
||||
|
||||
return isDark
|
||||
? `flex w-full items-center gap-3 ${RADIUS} ${alignment} py-[14px] text-left text-[15px] font-semibold text-slate-400 transition hover:bg-white/[0.04] hover:text-white`
|
||||
: `flex w-full items-center gap-3 ${RADIUS} ${alignment} py-[14px] text-left text-[15px] font-semibold text-slate-600 transition hover:bg-white hover:text-[#0F172A] hover:shadow-sm`;
|
||||
}
|
||||
|
||||
function navIconClass(isDark: boolean, active: boolean) {
|
||||
if (active) {
|
||||
return isDark
|
||||
? "h-[22px] w-[22px] shrink-0 text-emerald-300"
|
||||
: "h-[22px] w-[22px] shrink-0 text-[#0F766E]";
|
||||
}
|
||||
|
||||
return isDark
|
||||
? "h-[22px] w-[22px] shrink-0 text-slate-500"
|
||||
: "h-[22px] w-[22px] shrink-0 text-slate-500";
|
||||
}
|
||||
@@ -1,512 +1,323 @@
|
||||
import {
|
||||
Activity,
|
||||
CloudRain,
|
||||
Droplets,
|
||||
Fan,
|
||||
Lightbulb,
|
||||
ArrowRight,
|
||||
Cloud,
|
||||
Leaf,
|
||||
MapPin,
|
||||
ShieldCheck,
|
||||
Sprout,
|
||||
Sun,
|
||||
Thermometer,
|
||||
Wind,
|
||||
Zap,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { MetricCard } from "../../../components/cards/MetricCard";
|
||||
import { DashboardTrendChart } from "../components/DashboardTrendChart";
|
||||
import { useDashboardOverviewStream } from "../hooks/useDashboardOverviewStream";
|
||||
import { useHistorianDashboard } from "../hooks/useHistorianDashboard";
|
||||
import backgroundImage from "../../../assets/background.png";
|
||||
import farmdrawImage from "../../../assets/farmdraw.png";
|
||||
import farmdrawWhiteImage from "../../../assets/farm-draw.png";
|
||||
import { WeatherForecastCard } from "../../meteo/components/WeatherForecastCard";
|
||||
import { useWeatherForecast } from "../../meteo/hooks/useWeatherForecast";
|
||||
|
||||
type DashboardPageProps = {
|
||||
theme: "dark" | "light";
|
||||
onOpenMeteo: () => void;
|
||||
};
|
||||
|
||||
const historianKeys = [
|
||||
"meteo.exterior_temperature",
|
||||
"meteo.exterior_humidity",
|
||||
"meteo.radiation",
|
||||
"climate.zone_1.temperature",
|
||||
"climate.zone_1.humidity",
|
||||
];
|
||||
|
||||
function formatNumber(value: number | null | undefined, decimals = 1) {
|
||||
if (value === null || value === undefined) return "--";
|
||||
return value.toFixed(decimals);
|
||||
}
|
||||
|
||||
function formatBoolean(value: boolean | null | undefined) {
|
||||
if (value === null || value === undefined) return "--";
|
||||
return value ? "Sim" : "Não";
|
||||
}
|
||||
|
||||
export function DashboardPage({ theme }: DashboardPageProps) {
|
||||
const { overview, connected } = useDashboardOverviewStream();
|
||||
|
||||
const { data: historianData } = useHistorianDashboard({
|
||||
keys: historianKeys,
|
||||
minutesBack: 30,
|
||||
refreshIntervalMs: 15000,
|
||||
});
|
||||
|
||||
const meteo = overview?.meteo;
|
||||
const zones = overview?.climate.zones ?? [];
|
||||
const zoneOne = zones[0];
|
||||
const RADIUS = "rounded-[5px]";
|
||||
|
||||
export function DashboardPage({ theme, onOpenMeteo }: DashboardPageProps) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const onlineZoneCount = zones.filter(
|
||||
(zone) => zone.temperature !== null || zone.humidity !== null,
|
||||
).length;
|
||||
|
||||
const validZoneTemperatures = zones
|
||||
.map((zone) => zone.temperature)
|
||||
.filter((value): value is number => value !== null);
|
||||
|
||||
const averageZoneTemperature =
|
||||
validZoneTemperatures.length > 0
|
||||
? validZoneTemperatures.reduce((sum, value) => sum + value, 0) /
|
||||
validZoneTemperatures.length
|
||||
: null;
|
||||
const weather = useWeatherForecast();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-5">
|
||||
<MetricCard
|
||||
title="Temperatura Exterior"
|
||||
value={formatNumber(meteo?.exteriorTemperature)}
|
||||
unit="°C"
|
||||
icon={Thermometer}
|
||||
theme={theme}
|
||||
accent="red"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Humidade Exterior"
|
||||
value={formatNumber(meteo?.exteriorHumidity, 0)}
|
||||
unit="%"
|
||||
icon={Droplets}
|
||||
theme={theme}
|
||||
accent="cyan"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Radiação Solar"
|
||||
value={formatNumber(meteo?.radiation, 0)}
|
||||
unit="W/m²"
|
||||
icon={Sun}
|
||||
theme={theme}
|
||||
accent="yellow"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Velocidade do Vento"
|
||||
value={formatNumber(meteo?.windSpeed, 0)}
|
||||
unit="Km/h"
|
||||
icon={Wind}
|
||||
theme={theme}
|
||||
accent="blue"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Chuva"
|
||||
value={formatBoolean(meteo?.raining)}
|
||||
unit=""
|
||||
icon={CloudRain}
|
||||
theme={theme}
|
||||
accent="green"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-5 lg:grid-cols-4">
|
||||
<DashboardModuleCard
|
||||
theme={theme}
|
||||
title="Clima"
|
||||
icon={Fan}
|
||||
main={`${onlineZoneCount} / ${zones.length}`}
|
||||
label="zonas com dados"
|
||||
accent="cyan"
|
||||
items={[
|
||||
["Temp. média", `${formatNumber(averageZoneTemperature)} °C`],
|
||||
["CO₂ Zona 1", `${formatNumber(zoneOne?.co2, 0)} ppm`],
|
||||
[
|
||||
"Zenitais Zona 1",
|
||||
`${formatNumber(zoneOne?.zenitalLeftPercent, 0)}% / ${formatNumber(
|
||||
zoneOne?.zenitalRightPercent,
|
||||
0,
|
||||
)}%`,
|
||||
],
|
||||
]}
|
||||
/>
|
||||
|
||||
<DashboardModuleCard
|
||||
theme={theme}
|
||||
title="Rega"
|
||||
icon={Droplets}
|
||||
main={`${overview?.irrigation.activeValveCount ?? 0}`}
|
||||
label={`válvulas ativas / ${overview?.irrigation.controllerCount ?? 0
|
||||
} controladores`}
|
||||
accent="green"
|
||||
items={[
|
||||
[
|
||||
"Bombas ativas",
|
||||
`${overview?.irrigation.activePumpCount ?? 0}`,
|
||||
],
|
||||
[
|
||||
"Estado",
|
||||
overview?.irrigation.activeValveCount ? "A regar" : "Parada",
|
||||
],
|
||||
]}
|
||||
/>
|
||||
|
||||
<DashboardModuleCard
|
||||
theme={theme}
|
||||
title="Iluminação"
|
||||
icon={Lightbulb}
|
||||
main={`${overview?.lighting.activeSectorCount ?? 0}`}
|
||||
label={`setores ativos / ${overview?.lighting.sectorCount ?? 0
|
||||
} setores`}
|
||||
accent="yellow"
|
||||
items={[
|
||||
[
|
||||
"Estado",
|
||||
overview?.lighting.activeSectorCount ? "Ligada" : "Desligada",
|
||||
],
|
||||
]}
|
||||
/>
|
||||
|
||||
<DashboardModuleCard
|
||||
theme={theme}
|
||||
title="Sistema"
|
||||
icon={Zap}
|
||||
main={connected ? "Online" : "Offline"}
|
||||
label="ligação em tempo real"
|
||||
accent="blue"
|
||||
items={[
|
||||
["PLC", connected ? "Comunicando" : "Sem ligação"],
|
||||
["Historiador", historianData ? "Ativo" : "A carregar"],
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-5 xl:grid-cols-2">
|
||||
<DashboardTrendChart
|
||||
title="Meteorologia em Tempo Real"
|
||||
subtitle="Temperatura exterior, humidade e radiação nos últimos 30 minutos."
|
||||
theme={theme}
|
||||
data={historianData}
|
||||
series={[
|
||||
{
|
||||
key: "meteo.exterior_temperature",
|
||||
label: "Temp. Exterior",
|
||||
color: "#fb7185",
|
||||
},
|
||||
{
|
||||
key: "meteo.exterior_humidity",
|
||||
label: "Humidade",
|
||||
color: "#22d3ee",
|
||||
},
|
||||
{
|
||||
key: "meteo.radiation",
|
||||
label: "Radiação",
|
||||
color: "#facc15",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<DashboardTrendChart
|
||||
title="Clima — Zona 1"
|
||||
subtitle="Temperatura e humidade da zona principal nos últimos 30 minutos."
|
||||
theme={theme}
|
||||
data={historianData}
|
||||
series={[
|
||||
{
|
||||
key: "climate.zone_1.temperature",
|
||||
label: "Temp. Zona 1",
|
||||
color: "#38bdf8",
|
||||
},
|
||||
{
|
||||
key: "climate.zone_1.humidity",
|
||||
label: "Humidade Zona 1",
|
||||
color: "#2dd4bf",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-5 xl:grid-cols-12">
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "xl:col-span-4 rounded-2xl border border-white/10 bg-[#142230] p-5 shadow-[0_16px_40px_rgba(0,0,0,0.22)]"
|
||||
: "xl:col-span-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"
|
||||
? "relative h-full overflow-hidden text-slate-100"
|
||||
: "relative h-full overflow-hidden text-slate-950"
|
||||
}
|
||||
>
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<img
|
||||
src={backgroundImage}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "absolute inset-0 bg-[#0B1220]/34"
|
||||
: "absolute inset-0 bg-white/8"
|
||||
}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "absolute inset-0 bg-[linear-gradient(90deg,#0B1220_0%,#0B1220_16%,rgba(11,18,32,0.94)_26%,rgba(11,18,32,0.62)_44%,rgba(11,18,32,0.20)_70%,rgba(11,18,32,0.02)_100%)]"
|
||||
: "absolute inset-0 bg-[linear-gradient(90deg,#ffffff_0%,rgba(255,255,255,0.98)_12%,rgba(255,255,255,0.78)_28%,rgba(255,255,255,0.34)_48%,rgba(255,255,255,0.08)_70%,rgba(255,255,255,0)_100%)]"
|
||||
}
|
||||
/>
|
||||
|
||||
<main className="relative z-10 grid h-full w-full grid-rows-[minmax(330px,1.25fr)_118px_minmax(310px,1fr)_22px] gap-5 px-14 pb-3 pt-10">
|
||||
<section className="relative min-h-0 pb-10">
|
||||
<div className="max-w-[620px] pt-5">
|
||||
<h1
|
||||
className={
|
||||
isDark
|
||||
? "text-[52px] font-black leading-[1.03] tracking-[-0.06em] text-white"
|
||||
: "text-[52px] font-black leading-[1.03] tracking-[-0.06em] text-slate-950"
|
||||
}
|
||||
>
|
||||
Bem-vindo ao
|
||||
<br />
|
||||
<span className="text-emerald-400">Litoral Central</span>
|
||||
</h1>
|
||||
|
||||
<div className="mt-7 h-[3px] w-12 bg-emerald-400" />
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-7 max-w-[500px] text-[15px] leading-7 text-slate-300"
|
||||
: "mt-7 max-w-[500px] text-[15px] leading-7 text-slate-700"
|
||||
}
|
||||
>
|
||||
A sua plataforma inteligente para gestão de operações agrícolas.
|
||||
Acompanhe as condições, controle os sistemas e maximize a
|
||||
eficiência no campo.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenMeteo}
|
||||
className={`mt-8 inline-flex h-13 items-center gap-8 ${RADIUS} border border-emerald-400/45 bg-emerald-500/10 px-7 text-sm font-bold shadow-[0_12px_30px_rgba(16,185,129,0.12)] transition hover:bg-emerald-500/15 ${isDark ? "text-white" : "text-emerald-700"
|
||||
}`}
|
||||
>
|
||||
Explorar plataforma
|
||||
<ArrowRight className="h-5 w-5 text-emerald-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<WeatherForecastCard
|
||||
compact
|
||||
theme={theme}
|
||||
forecast={weather.forecast}
|
||||
loading={weather.loading}
|
||||
error={weather.error}
|
||||
onOpenMeteo={onOpenMeteo}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid min-h-0 grid-cols-3 gap-5">
|
||||
<InfoCard
|
||||
theme={theme}
|
||||
icon={<Sprout className="h-7 w-7" />}
|
||||
iconClass="bg-emerald-500/10 text-emerald-400"
|
||||
title="Gestão inteligente"
|
||||
text="Monitorize e controle todos os seus sistemas de forma centralizada."
|
||||
/>
|
||||
|
||||
<InfoCard
|
||||
theme={theme}
|
||||
icon={<Leaf className="h-7 w-7" />}
|
||||
iconClass="bg-sky-500/10 text-sky-400"
|
||||
title="Eficiência hídrica"
|
||||
text="Otimize o uso de água e promova uma agricultura sustentável."
|
||||
/>
|
||||
|
||||
<InfoCard
|
||||
theme={theme}
|
||||
icon={<Sun className="h-7 w-7" />}
|
||||
iconClass="bg-yellow-400/10 text-yellow-300"
|
||||
title="Decisões informadas"
|
||||
text="Dados meteorológicos e insights para melhores decisões no campo."
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={
|
||||
isDark
|
||||
? `min-h-0 overflow-visible ${RADIUS} border border-white/10 bg-[#07111f]/78 p-8 shadow-[0_18px_50px_rgba(0,0,0,0.28)] backdrop-blur-md`
|
||||
: `min-h-0 overflow-visible ${RADIUS} border border-slate-200/90 bg-white/94 p-8 shadow-[0_14px_40px_rgba(15,23,42,0.06)] backdrop-blur-md`
|
||||
}
|
||||
>
|
||||
<div className="grid h-full grid-cols-[minmax(0,0.9fr)_minmax(620px,1.1fr)] items-center gap-2">
|
||||
<div>
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "text-xs font-bold uppercase tracking-[0.28em] text-slate-400"
|
||||
: "text-xs font-bold uppercase tracking-[0.28em] text-emerald-700"
|
||||
}
|
||||
>
|
||||
A Nossa Missão
|
||||
</p>
|
||||
|
||||
<h2
|
||||
className={
|
||||
isDark
|
||||
? "text-base font-semibold text-white"
|
||||
: "text-base font-semibold text-slate-950"
|
||||
? "mt-4 max-w-[640px] text-[30px] font-black leading-tight tracking-[-0.045em] text-white"
|
||||
: "mt-4 max-w-[640px] text-[30px] font-black leading-tight tracking-[-0.045em] text-slate-950"
|
||||
}
|
||||
>
|
||||
Estado Operacional
|
||||
Soluções inovadoras para
|
||||
<br />
|
||||
uma{" "}
|
||||
<span className="text-emerald-400">
|
||||
agricultura sustentável
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark ? "text-sm text-slate-400" : "text-sm text-slate-500"
|
||||
isDark
|
||||
? "mt-4 max-w-[560px] text-sm leading-6 text-slate-400"
|
||||
: "mt-4 max-w-[560px] text-sm leading-6 text-slate-600"
|
||||
}
|
||||
>
|
||||
Resumo rápido da instalação.
|
||||
Tecnologia, conhecimento e proximidade para impulsionar o futuro
|
||||
do setor agrícola em Portugal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Activity
|
||||
className={isDark ? "h-5 w-5 text-slate-400" : "h-5 w-5 text-slate-500"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<StatusRow
|
||||
theme={theme}
|
||||
label="Aquisição"
|
||||
value={connected ? "Ativa" : "Inativa"}
|
||||
good={connected}
|
||||
/>
|
||||
<StatusRow
|
||||
theme={theme}
|
||||
label="Rega"
|
||||
value={overview?.irrigation.activeValveCount ? "Em curso" : "Parada"}
|
||||
good={!overview?.irrigation.activeValveCount}
|
||||
/>
|
||||
<StatusRow
|
||||
theme={theme}
|
||||
label="Chuva"
|
||||
value={formatBoolean(meteo?.raining)}
|
||||
good={!meteo?.raining}
|
||||
/>
|
||||
<StatusRow
|
||||
theme={theme}
|
||||
label="Zenitais Zona 1"
|
||||
value={`${formatNumber(zoneOne?.zenitalLeftPercent, 0)}%`}
|
||||
good
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "xl:col-span-8 rounded-2xl border border-white/10 bg-[#142230] p-5 shadow-[0_16px_40px_rgba(0,0,0,0.22)]"
|
||||
: "xl:col-span-8 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"
|
||||
? "mt-6 grid grid-cols-3 gap-5 border-t border-white/10 pt-6"
|
||||
: "mt-6 grid grid-cols-3 gap-5 border-t border-slate-200 pt-6"
|
||||
}
|
||||
>
|
||||
<div className="mb-5">
|
||||
<h2
|
||||
className={
|
||||
isDark
|
||||
? "text-base font-semibold text-white"
|
||||
: "text-base font-semibold text-slate-950"
|
||||
}
|
||||
>
|
||||
Próximos Desenvolvimentos
|
||||
</h2>
|
||||
<p
|
||||
className={
|
||||
isDark ? "text-sm text-slate-400" : "text-sm text-slate-500"
|
||||
}
|
||||
>
|
||||
Espaço preparado para eventos, alarmes, programas e depósitos.
|
||||
</p>
|
||||
<MissionItem
|
||||
theme={theme}
|
||||
icon={<Sprout />}
|
||||
title="Sustentabilidade"
|
||||
text="Compromisso com o futuro"
|
||||
/>
|
||||
<MissionItem
|
||||
theme={theme}
|
||||
icon={<ShieldCheck />}
|
||||
title="Confiabilidade"
|
||||
text="Tecnologia robusta e segura"
|
||||
/>
|
||||
<MissionItem
|
||||
theme={theme}
|
||||
icon={<Users />}
|
||||
title="Apoio próximo"
|
||||
text="Sempre ao seu lado"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<PlaceholderPanel
|
||||
theme={theme}
|
||||
title="Eventos"
|
||||
text="Últimos eventos do sistema"
|
||||
/>
|
||||
<PlaceholderPanel
|
||||
theme={theme}
|
||||
title="Alarmes"
|
||||
text="Alarmes ativos e recentes"
|
||||
/>
|
||||
<PlaceholderPanel
|
||||
theme={theme}
|
||||
title="Depósitos"
|
||||
text="Níveis, pH e CE"
|
||||
/>
|
||||
</div>
|
||||
<FarmIllustration theme={theme} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="flex min-h-0 items-center justify-between px-56 text-xs text-slate-500">
|
||||
<span>© 2026 Litoral Central. Todos os direitos reservados.</span>
|
||||
<span>
|
||||
Feito em Portugal <span className="ml-2">🇵🇹</span>
|
||||
</span>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DashboardModuleCardProps = {
|
||||
theme: "dark" | "light";
|
||||
title: string;
|
||||
icon: React.ElementType;
|
||||
main: string;
|
||||
label: string;
|
||||
accent: "blue" | "green" | "yellow" | "cyan";
|
||||
items: [string, string][];
|
||||
};
|
||||
|
||||
const moduleAccentClasses = {
|
||||
blue: {
|
||||
icon: "text-sky-400",
|
||||
bg: "bg-sky-500/10",
|
||||
},
|
||||
green: {
|
||||
icon: "text-emerald-400",
|
||||
bg: "bg-emerald-500/10",
|
||||
},
|
||||
yellow: {
|
||||
icon: "text-yellow-400",
|
||||
bg: "bg-yellow-500/10",
|
||||
},
|
||||
cyan: {
|
||||
icon: "text-cyan-400",
|
||||
bg: "bg-cyan-500/10",
|
||||
},
|
||||
};
|
||||
|
||||
function DashboardModuleCard({
|
||||
function InfoCard({
|
||||
theme,
|
||||
icon,
|
||||
iconClass,
|
||||
title,
|
||||
icon: Icon,
|
||||
main,
|
||||
label,
|
||||
accent,
|
||||
items,
|
||||
}: DashboardModuleCardProps) {
|
||||
const isDark = theme === "dark";
|
||||
const accentClass = moduleAccentClasses[accent];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "rounded-2xl border border-white/10 bg-[#142230] p-5 shadow-[0_16px_40px_rgba(0,0,0,0.22)]"
|
||||
: "rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"
|
||||
}
|
||||
>
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h2
|
||||
className={
|
||||
isDark
|
||||
? "text-sm font-medium text-slate-300"
|
||||
: "text-sm font-medium text-slate-600"
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-xl ${accentClass.bg} ${accentClass.icon}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "text-3xl font-bold tracking-tight text-white"
|
||||
: "text-3xl font-bold tracking-tight text-slate-950"
|
||||
}
|
||||
>
|
||||
{main}
|
||||
</p>
|
||||
|
||||
<p className={isDark ? "mt-1 text-sm text-slate-400" : "mt-1 text-sm text-slate-500"}>
|
||||
{label}
|
||||
</p>
|
||||
|
||||
<div className="mt-5 space-y-2">
|
||||
{items.map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between gap-4 text-sm">
|
||||
<span className={isDark ? "text-slate-400" : "text-slate-500"}>
|
||||
{key}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
isDark
|
||||
? "font-medium text-slate-100"
|
||||
: "font-medium text-slate-800"
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StatusRowProps = {
|
||||
theme: "dark" | "light";
|
||||
label: string;
|
||||
value: string;
|
||||
good: boolean;
|
||||
};
|
||||
|
||||
function StatusRow({ theme, label, value, good }: StatusRowProps) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "flex items-center justify-between rounded-xl bg-white/5 px-4 py-3"
|
||||
: "flex items-center justify-between rounded-xl bg-slate-50 px-4 py-3"
|
||||
}
|
||||
>
|
||||
<span className={isDark ? "text-sm text-slate-300" : "text-sm text-slate-600"}>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={
|
||||
good
|
||||
? "rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-500"
|
||||
: "rounded-full bg-red-500/10 px-3 py-1 text-xs font-semibold text-red-500"
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PlaceholderPanelProps = {
|
||||
text,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
icon: React.ReactNode;
|
||||
iconClass: string;
|
||||
title: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
function PlaceholderPanel({ theme, title, text }: PlaceholderPanelProps) {
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div
|
||||
<article
|
||||
className={
|
||||
isDark
|
||||
? "rounded-xl border border-white/10 bg-white/5 p-4"
|
||||
: "rounded-xl border border-slate-200 bg-slate-50 p-4"
|
||||
? `${RADIUS} flex h-full items-center justify-between border border-white/10 bg-[#07111f]/78 p-5 shadow-[0_18px_50px_rgba(0,0,0,0.24)] backdrop-blur-md`
|
||||
: `${RADIUS} flex h-full items-center justify-between border border-slate-200/90 bg-white/95 p-5 shadow-[0_8px_24px_rgba(15,23,42,0.05)] backdrop-blur-md`
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-5">
|
||||
<div
|
||||
className={`grid h-[62px] w-[62px] place-items-center ${RADIUS} ${iconClass}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-base font-black">{title}</h3>
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "text-sm font-semibold text-white"
|
||||
: "text-sm font-semibold text-slate-950"
|
||||
? "mt-2 max-w-[280px] text-sm leading-5 text-slate-400"
|
||||
: "mt-2 max-w-[280px] text-sm leading-5 text-slate-600"
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p className={isDark ? "mt-1 text-sm text-slate-400" : "mt-1 text-sm text-slate-500"}>
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArrowRight
|
||||
className={isDark ? "h-5 w-5 text-slate-400" : "h-5 w-5 text-emerald-500"}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function MissionItem({
|
||||
theme,
|
||||
icon,
|
||||
title,
|
||||
text,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
text: string;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-7 w-7 text-emerald-400">{icon}</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-black">{title}</h4>
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-1 text-xs text-slate-400"
|
||||
: "mt-1 text-xs text-slate-600"
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FarmIllustration({ theme }: { theme: "dark" | "light" }) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-[270px] w-full overflow-visible">
|
||||
<img
|
||||
src={isDark ? farmdrawImage : farmdrawWhiteImage}
|
||||
alt=""
|
||||
className={
|
||||
isDark
|
||||
? "absolute right-[-70px] top-1/2 h-[380px] max-w-none -translate-y-1/2 opacity-95"
|
||||
: "absolute right-[-70px] top-1/2 h-[380px] max-w-none -translate-y-1/2 opacity-100"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -26,6 +26,8 @@ type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const RADIUS = "rounded-[5px]";
|
||||
|
||||
const RANGE_OPTIONS: Array<{ label: string; value: AccumulatedRange }> = [
|
||||
{ label: "7D", value: "7d" },
|
||||
{ label: "30D", value: "30d" },
|
||||
@@ -44,6 +46,7 @@ export function AccumulatedHistoryModal({
|
||||
onClose,
|
||||
}: Props) {
|
||||
const isDark = theme === "dark";
|
||||
const palette = accumulatedPalette(isDark);
|
||||
const [mode, setMode] = useState<"chart" | "table">("chart");
|
||||
|
||||
const stats = useMemo(() => {
|
||||
@@ -62,26 +65,34 @@ export function AccumulatedHistoryModal({
|
||||
|
||||
if (!sensor) return null;
|
||||
|
||||
const unit = sensor.unit ?? buckets[0]?.unit ?? "";
|
||||
const unit = buckets[0]?.unit ?? sensor.unit ?? "";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-md">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "flex h-[82vh] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-[#24384d] bg-[#071120] text-white shadow-2xl"
|
||||
: "flex h-[82vh] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white text-slate-950 shadow-2xl"
|
||||
? `${RADIUS} flex h-[82vh] w-full max-w-6xl flex-col overflow-hidden border border-white/10 bg-[#111827] text-slate-100 shadow-2xl`
|
||||
: `${RADIUS} flex h-[82vh] w-full max-w-6xl flex-col overflow-hidden border border-slate-200 bg-white text-slate-950 shadow-[0_24px_70px_rgba(15,23,42,0.18)]`
|
||||
}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-5 px-6 py-4">
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] font-black uppercase tracking-[0.42em] text-cyan-400">
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mb-1 text-[11px] font-bold uppercase tracking-[0.22em] text-slate-500"
|
||||
: "mb-1 text-[11px] font-bold uppercase tracking-[0.22em] text-slate-400"
|
||||
}
|
||||
>
|
||||
Acumulado
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-black">{title}</h2>
|
||||
<h2 className="text-xl font-black tracking-[-0.03em]">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Chave: meteo.{sensor.key}
|
||||
</p>
|
||||
</div>
|
||||
@@ -89,7 +100,11 @@ export function AccumulatedHistoryModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-2 text-slate-400 hover:text-white"
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} p-2 text-slate-400 transition hover:bg-white/5 hover:text-white`
|
||||
: `${RADIUS} p-2 text-slate-400 transition hover:bg-slate-100 hover:text-slate-900`
|
||||
}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -99,8 +114,8 @@ export function AccumulatedHistoryModal({
|
||||
<section
|
||||
className={
|
||||
isDark
|
||||
? "flex min-h-0 flex-1 flex-col rounded-xl border border-[#24384d] bg-[#0a1728] p-4"
|
||||
: "flex min-h-0 flex-1 flex-col rounded-xl border border-slate-200 bg-slate-50 p-4"
|
||||
? `${RADIUS} flex min-h-0 flex-1 flex-col border border-white/10 bg-[#0b1220] p-4`
|
||||
: `${RADIUS} flex min-h-0 flex-1 flex-col border border-slate-200 bg-slate-50 p-4`
|
||||
}
|
||||
>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
@@ -111,10 +126,8 @@ export function AccumulatedHistoryModal({
|
||||
onClick={() => onRangeChange(option.value)}
|
||||
className={
|
||||
range === option.value
|
||||
? "rounded-lg bg-cyan-400 px-4 py-2 text-xs font-black text-slate-950 shadow-lg shadow-cyan-400/20"
|
||||
: isDark
|
||||
? "rounded-lg bg-slate-900/70 px-4 py-2 text-xs font-bold text-slate-300 hover:bg-slate-800"
|
||||
: "rounded-lg bg-white px-4 py-2 text-xs font-bold text-slate-600 hover:bg-slate-100"
|
||||
? activeButtonClass(isDark)
|
||||
: buttonClass(isDark)
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
@@ -142,7 +155,7 @@ export function AccumulatedHistoryModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-3 gap-3">
|
||||
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<StatCard theme={theme} label="Total" value={formatValue(stats.total, unit)} />
|
||||
<StatCard theme={theme} label="Média" value={formatValue(stats.average, unit)} />
|
||||
<StatCard theme={theme} label="Máximo" value={formatValue(stats.max, unit)} />
|
||||
@@ -155,35 +168,41 @@ export function AccumulatedHistoryModal({
|
||||
<EmptyState>Sem dados acumulados para este período.</EmptyState>
|
||||
) : mode === "chart" ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={buckets}>
|
||||
<BarChart
|
||||
data={buckets}
|
||||
margin={{ top: 16, right: 18, bottom: 8, left: 0 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
stroke="#1f3348"
|
||||
strokeDasharray="3 3"
|
||||
stroke={palette.grid}
|
||||
strokeDasharray="4 6"
|
||||
vertical={false}
|
||||
/>
|
||||
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
||||
tick={{ fill: palette.axis, fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#334155" }}
|
||||
axisLine={{ stroke: palette.axisLine }}
|
||||
minTickGap={20}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
||||
tick={{ fill: palette.axis, fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#334155" }}
|
||||
width={50}
|
||||
axisLine={{ stroke: palette.axisLine }}
|
||||
width={56}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(34, 211, 238, 0.08)" }}
|
||||
cursor={{ fill: palette.cursor }}
|
||||
contentStyle={{
|
||||
background: "#111827",
|
||||
border: "1px solid #334155",
|
||||
borderRadius: "10px",
|
||||
color: "#fff",
|
||||
background: palette.tooltipBg,
|
||||
border: `1px solid ${palette.tooltipBorder}`,
|
||||
borderRadius: "5px",
|
||||
color: palette.tooltipText,
|
||||
boxShadow: isDark
|
||||
? "0 18px 45px rgba(0,0,0,0.30)"
|
||||
: "0 18px 45px rgba(15,23,42,0.14)",
|
||||
}}
|
||||
formatter={(value) => [
|
||||
formatValue(Number(value), unit),
|
||||
@@ -193,16 +212,28 @@ export function AccumulatedHistoryModal({
|
||||
|
||||
<Bar
|
||||
dataKey="total"
|
||||
fill="#22d3ee"
|
||||
radius={[6, 6, 0, 0]}
|
||||
fill={palette.bar}
|
||||
radius={[3, 3, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full overflow-auto rounded-xl border border-slate-700/60">
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} h-full overflow-auto border border-white/10 bg-[#111827]`
|
||||
: `${RADIUS} h-full overflow-auto border border-slate-200 bg-white`
|
||||
}
|
||||
>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="sticky top-0 bg-[#0b1828] text-xs uppercase tracking-[0.16em] text-slate-400">
|
||||
<thead
|
||||
className={
|
||||
isDark
|
||||
? "sticky top-0 bg-[#111827] text-xs uppercase tracking-[0.14em] text-slate-500"
|
||||
: "sticky top-0 bg-slate-50 text-xs uppercase tracking-[0.14em] text-slate-500"
|
||||
}
|
||||
>
|
||||
<tr>
|
||||
<th className="px-4 py-3">Período</th>
|
||||
<th className="px-4 py-3">Início</th>
|
||||
@@ -215,18 +246,31 @@ export function AccumulatedHistoryModal({
|
||||
{buckets.map((bucket) => (
|
||||
<tr
|
||||
key={`${bucket.from}-${bucket.to}`}
|
||||
className="border-t border-slate-700/60 text-slate-200"
|
||||
className={
|
||||
isDark
|
||||
? "border-t border-white/10 text-slate-300"
|
||||
: "border-t border-slate-200 text-slate-700"
|
||||
}
|
||||
>
|
||||
<td className="px-4 py-3 font-semibold">
|
||||
{bucket.label}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-400">
|
||||
|
||||
<td className="px-4 py-3 text-slate-500">
|
||||
{formatDate(bucket.from)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-400">
|
||||
|
||||
<td className="px-4 py-3 text-slate-500">
|
||||
{formatDate(bucket.to)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-black text-cyan-300">
|
||||
|
||||
<td
|
||||
className={
|
||||
isDark
|
||||
? "px-4 py-3 text-right font-black text-slate-100"
|
||||
: "px-4 py-3 text-right font-black text-slate-950"
|
||||
}
|
||||
>
|
||||
{formatValue(bucket.total, unit)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -243,6 +287,30 @@ export function AccumulatedHistoryModal({
|
||||
);
|
||||
}
|
||||
|
||||
function accumulatedPalette(isDark: boolean) {
|
||||
return isDark
|
||||
? {
|
||||
bar: "#94a3b8",
|
||||
grid: "rgba(148,163,184,0.16)",
|
||||
axis: "#64748b",
|
||||
axisLine: "rgba(148,163,184,0.18)",
|
||||
cursor: "rgba(148,163,184,0.08)",
|
||||
tooltipBg: "#111827",
|
||||
tooltipBorder: "rgba(255,255,255,0.10)",
|
||||
tooltipText: "#e5e7eb",
|
||||
}
|
||||
: {
|
||||
bar: "#475569",
|
||||
grid: "#e2e8f0",
|
||||
axis: "#64748b",
|
||||
axisLine: "#cbd5e1",
|
||||
cursor: "rgba(148,163,184,0.12)",
|
||||
tooltipBg: "#ffffff",
|
||||
tooltipBorder: "#e2e8f0",
|
||||
tooltipText: "#0f172a",
|
||||
};
|
||||
}
|
||||
|
||||
function EmptyState({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-slate-400">
|
||||
@@ -266,27 +334,47 @@ function StatCard({
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "rounded-xl border border-slate-700/60 bg-[#071120] p-3"
|
||||
: "rounded-xl border border-slate-200 bg-white p-3"
|
||||
? `${RADIUS} border border-white/10 bg-[#111827] p-3`
|
||||
: `${RADIUS} border border-slate-200 bg-white p-3`
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-slate-400">{label}</p>
|
||||
<p className="mt-1 text-xl font-black">{value}</p>
|
||||
<p className="text-xs text-slate-500">{label}</p>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-1 text-xl font-black text-slate-100"
|
||||
: "mt-1 text-xl font-black text-slate-950"
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toggleButtonClass(isDark: boolean, active: boolean) {
|
||||
if (active) {
|
||||
return "rounded-lg border border-cyan-400/40 bg-cyan-400/10 px-3 py-2 text-xs font-semibold text-cyan-300";
|
||||
}
|
||||
|
||||
function buttonClass(isDark: boolean) {
|
||||
return isDark
|
||||
? "rounded-lg border border-slate-700 px-3 py-2 text-xs font-semibold text-slate-200 hover:bg-slate-800"
|
||||
: "rounded-lg border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-100";
|
||||
? `${RADIUS} border border-white/10 bg-white/[0.03] px-3 py-2 text-xs font-semibold text-slate-300 transition hover:bg-white/[0.06] hover:text-slate-100`
|
||||
: `${RADIUS} border border-slate-200 bg-white px-3 py-2 text-xs font-semibold text-slate-600 transition hover:bg-slate-100 hover:text-slate-950`;
|
||||
}
|
||||
|
||||
function activeButtonClass(isDark: boolean) {
|
||||
return isDark
|
||||
? `${RADIUS} border border-white/10 bg-slate-200 px-3 py-2 text-xs font-black text-slate-950`
|
||||
: `${RADIUS} border border-slate-300 bg-slate-900 px-3 py-2 text-xs font-black text-white`;
|
||||
}
|
||||
|
||||
function toggleButtonClass(isDark: boolean, active: boolean) {
|
||||
if (active) return activeButtonClass(isDark);
|
||||
return buttonClass(isDark);
|
||||
}
|
||||
|
||||
function formatValue(value: number, unit: string) {
|
||||
if (unit === "Wh/m²" && value >= 1000) {
|
||||
return `${(value / 1000).toFixed(2)} kWh/m²`;
|
||||
}
|
||||
|
||||
return `${value.toFixed(1)}${unit ? ` ${unit}` : ""}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { ModuleSensorResponse } from "../../../types/meteo";
|
||||
|
||||
@@ -39,6 +39,8 @@ type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const RADIUS = "rounded-[5px]";
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
{ label: "15M", hours: 0.25 },
|
||||
{ label: "30M", hours: 0.5 },
|
||||
@@ -64,6 +66,7 @@ export function MeteoHistoryModal({
|
||||
onClose,
|
||||
}: Props) {
|
||||
const isDark = theme === "dark";
|
||||
const palette = chartPalette(isDark);
|
||||
|
||||
const [intervalMinutes, setIntervalMinutes] = useState(5);
|
||||
const [showIndicators, setShowIndicators] = useState(true);
|
||||
@@ -71,7 +74,6 @@ export function MeteoHistoryModal({
|
||||
const [chartMode, setChartMode] = useState<"area" | "line">("area");
|
||||
const [zeroBaseline, setZeroBaseline] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const [intervalOpen, setIntervalOpen] = useState(false);
|
||||
|
||||
const rawData = useMemo(
|
||||
@@ -80,11 +82,7 @@ export function MeteoHistoryModal({
|
||||
.map((point) => {
|
||||
const value =
|
||||
point.numericValue ??
|
||||
(point.booleanValue === null
|
||||
? null
|
||||
: point.booleanValue
|
||||
? 1
|
||||
: 0);
|
||||
(point.booleanValue === null ? null : point.booleanValue ? 1 : 0);
|
||||
|
||||
return {
|
||||
timestamp: point.timestamp,
|
||||
@@ -92,8 +90,14 @@ export function MeteoHistoryModal({
|
||||
value,
|
||||
};
|
||||
})
|
||||
.filter((point): point is { timestamp: string; date: Date; value: number } =>
|
||||
typeof point.value === "number",
|
||||
.filter(
|
||||
(
|
||||
point,
|
||||
): point is {
|
||||
timestamp: string;
|
||||
date: Date;
|
||||
value: number;
|
||||
} => typeof point.value === "number",
|
||||
),
|
||||
[points],
|
||||
);
|
||||
@@ -101,7 +105,11 @@ export function MeteoHistoryModal({
|
||||
const chartData = useMemo(() => {
|
||||
if (rawData.length === 0) return [];
|
||||
|
||||
const buckets = new Map<number, { total: number; count: number; timestamp: string }>();
|
||||
const buckets = new Map<
|
||||
number,
|
||||
{ total: number; count: number; timestamp: string }
|
||||
>();
|
||||
|
||||
const intervalMs = intervalMinutes * 60 * 1000;
|
||||
|
||||
for (const point of rawData) {
|
||||
@@ -177,43 +185,63 @@ export function MeteoHistoryModal({
|
||||
if (!sensor) return null;
|
||||
|
||||
const unit = sensor.unit ?? "";
|
||||
const yDomain: [number | "auto", number | "auto"] = zeroBaseline ? [0, "auto"] : ["auto", "auto"];
|
||||
const yDomain: [number | "auto", number | "auto"] = zeroBaseline
|
||||
? [0, "auto"]
|
||||
: ["auto", "auto"];
|
||||
|
||||
const Chart = chartMode === "area" ? AreaChart : LineChart;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-md">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${expanded ? "h-[88vh] max-w-[92vw]" : "max-w-6xl"} flex w-full flex-col overflow-hidden rounded-2xl border border-[#24384d] bg-[#071120] text-white shadow-2xl`
|
||||
: `${expanded ? "h-[88vh] max-w-[92vw]" : "max-w-6xl"} flex w-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white text-slate-950 shadow-2xl`
|
||||
? `${expanded ? "h-[88vh] max-w-[92vw]" : "max-w-6xl"} ${RADIUS} flex w-full flex-col overflow-hidden border border-white/10 bg-[#111827] text-slate-100 shadow-2xl`
|
||||
: `${expanded ? "h-[88vh] max-w-[92vw]" : "max-w-6xl"} ${RADIUS} flex w-full flex-col overflow-hidden border border-slate-200 bg-white text-slate-950 shadow-[0_24px_70px_rgba(15,23,42,0.18)]`
|
||||
}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-5 px-6 py-4">
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] font-black uppercase tracking-[0.42em] text-cyan-400">
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mb-1 text-[11px] font-bold uppercase tracking-[0.22em] text-slate-500"
|
||||
: "mb-1 text-[11px] font-bold uppercase tracking-[0.22em] text-slate-400"
|
||||
}
|
||||
>
|
||||
Histórico
|
||||
</p>
|
||||
<h2 className="text-xl font-black">{sensor.name}</h2>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
|
||||
<h2 className="text-xl font-black tracking-[-0.03em]">
|
||||
{sensor.name}
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Chave: meteo.{sensor.key}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={onClose} className="p-2 text-slate-400 hover:text-white">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} p-2 text-slate-400 transition hover:bg-white/5 hover:text-white`
|
||||
: `${RADIUS} p-2 text-slate-400 transition hover:bg-slate-100 hover:text-slate-900`
|
||||
}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex min-h-0 flex-1 flex-col px-6 pb-5">
|
||||
<section className={
|
||||
<section
|
||||
className={
|
||||
isDark
|
||||
? "flex min-h-0 flex-1 flex-col rounded-xl border border-[#24384d] bg-[#0a1728] p-4"
|
||||
: "flex min-h-0 flex-1 flex-col rounded-xl border border-slate-200 bg-slate-50 p-4"
|
||||
}>
|
||||
? `${RADIUS} flex min-h-0 flex-1 flex-col border border-white/10 bg-[#0b1220] p-4`
|
||||
: `${RADIUS} flex min-h-0 flex-1 flex-col border border-slate-200 bg-slate-50 p-4`
|
||||
}
|
||||
>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
{RANGE_OPTIONS.map((range) => (
|
||||
<button
|
||||
@@ -222,10 +250,8 @@ export function MeteoHistoryModal({
|
||||
onClick={() => onHoursChange(range.hours)}
|
||||
className={
|
||||
hours === range.hours
|
||||
? "rounded-lg bg-cyan-400 px-4 py-2 text-xs font-black text-slate-950 shadow-lg shadow-cyan-400/20"
|
||||
: isDark
|
||||
? "rounded-lg bg-slate-900/70 px-4 py-2 text-xs font-bold text-slate-300 hover:bg-slate-800"
|
||||
: "rounded-lg bg-white px-4 py-2 text-xs font-bold text-slate-600 hover:bg-slate-100"
|
||||
? activeButtonClass(isDark)
|
||||
: buttonClass(isDark)
|
||||
}
|
||||
>
|
||||
{range.label}
|
||||
@@ -236,16 +262,12 @@ export function MeteoHistoryModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIntervalOpen((value) => !value)}
|
||||
className={
|
||||
isDark
|
||||
? "flex h-[36px] items-center gap-2 rounded-lg border border-slate-700 bg-[#0a1728] px-4 text-xs font-semibold text-slate-200 hover:bg-slate-800"
|
||||
: "flex h-[36px] items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 text-xs font-semibold text-slate-700 hover:bg-slate-100"
|
||||
}
|
||||
className={buttonClass(isDark)}
|
||||
>
|
||||
Intervalo: {intervalMinutes}m
|
||||
|
||||
<svg
|
||||
className={`h-3 w-3 transition ${intervalOpen ? "rotate-180" : ""}`}
|
||||
className={`ml-2 inline h-3 w-3 transition ${intervalOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
@@ -261,8 +283,8 @@ export function MeteoHistoryModal({
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "absolute left-0 top-[calc(100%+8px)] z-50 min-w-[160px] overflow-hidden rounded-xl border border-slate-700 bg-[#0b1828] shadow-2xl"
|
||||
: "absolute left-0 top-[calc(100%+8px)] z-50 min-w-[160px] overflow-hidden rounded-xl border border-slate-200 bg-white shadow-xl"
|
||||
? `absolute left-0 top-[calc(100%+8px)] z-50 min-w-[160px] overflow-hidden ${RADIUS} border border-white/10 bg-[#111827] shadow-2xl`
|
||||
: `absolute left-0 top-[calc(100%+8px)] z-50 min-w-[160px] overflow-hidden ${RADIUS} border border-slate-200 bg-white shadow-xl`
|
||||
}
|
||||
>
|
||||
{INTERVAL_OPTIONS.map((option) => (
|
||||
@@ -275,22 +297,30 @@ export function MeteoHistoryModal({
|
||||
}}
|
||||
className={
|
||||
intervalMinutes === option
|
||||
? "flex w-full items-center justify-between bg-cyan-400/10 px-4 py-3 text-left text-xs font-bold text-cyan-300"
|
||||
? isDark
|
||||
? "flex w-full items-center justify-between bg-white/[0.06] px-4 py-3 text-left text-xs font-bold text-slate-100"
|
||||
: "flex w-full items-center justify-between bg-slate-100 px-4 py-3 text-left text-xs font-bold text-slate-950"
|
||||
: isDark
|
||||
? "flex w-full items-center justify-between px-4 py-3 text-left text-xs font-semibold text-slate-300 hover:bg-slate-800"
|
||||
: "flex w-full items-center justify-between px-4 py-3 text-left text-xs font-semibold text-slate-700 hover:bg-slate-100"
|
||||
? "flex w-full items-center justify-between px-4 py-3 text-left text-xs font-semibold text-slate-300 hover:bg-white/[0.04]"
|
||||
: "flex w-full items-center justify-between px-4 py-3 text-left text-xs font-semibold text-slate-700 hover:bg-slate-50"
|
||||
}
|
||||
>
|
||||
<span>{option} minutos</span>
|
||||
|
||||
{intervalMinutes === option && (
|
||||
<div className="h-2 w-2 rounded-full bg-cyan-300" />
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "h-2 w-2 rounded-full bg-slate-300"
|
||||
: "h-2 w-2 rounded-full bg-slate-700"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCompareLine((value) => !value)}
|
||||
@@ -310,15 +340,21 @@ export function MeteoHistoryModal({
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center gap-6 text-xs">
|
||||
<span className="font-semibold">
|
||||
<span className="mr-2 inline-block h-2.5 w-2.5 rounded-full bg-cyan-400" />
|
||||
<span className={isDark ? "font-semibold text-slate-100" : "font-semibold text-slate-950"}>
|
||||
<span className={isDark ? "mr-2 inline-block h-2.5 w-2.5 rounded-full bg-slate-300" : "mr-2 inline-block h-2.5 w-2.5 rounded-full bg-slate-700"} />
|
||||
{sensor.name}
|
||||
{unit && ` (${unit})`}
|
||||
</span>
|
||||
|
||||
<span>Média: <b>{formatValue(stats.average, unit)}</b></span>
|
||||
<span>Máx: <b>{formatValue(stats.max, unit)}</b></span>
|
||||
<span>Mín: <b>{formatValue(stats.min, unit)}</b></span>
|
||||
<span className={isDark ? "text-slate-400" : "text-slate-500"}>
|
||||
Média: <b>{formatValue(stats.average, unit)}</b>
|
||||
</span>
|
||||
<span className={isDark ? "text-slate-400" : "text-slate-500"}>
|
||||
Máx: <b>{formatValue(stats.max, unit)}</b>
|
||||
</span>
|
||||
<span className={isDark ? "text-slate-400" : "text-slate-500"}>
|
||||
Mín: <b>{formatValue(stats.min, unit)}</b>
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex gap-2">
|
||||
<IconButton
|
||||
@@ -342,8 +378,7 @@ export function MeteoHistoryModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={expanded ? "min-h-0 flex-1" : "h-[300px]"}
|
||||
>
|
||||
<div className={expanded ? "min-h-0 flex-1" : "h-[300px]"}>
|
||||
{loading ? (
|
||||
<EmptyState>A carregar histórico...</EmptyState>
|
||||
) : chartData.length === 0 ? (
|
||||
@@ -353,32 +388,37 @@ export function MeteoHistoryModal({
|
||||
<Chart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="historyFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#22d3ee" stopOpacity={0.34} />
|
||||
<stop offset="95%" stopColor="#22d3ee" stopOpacity={0.03} />
|
||||
<stop offset="0%" stopColor={palette.line} stopOpacity={isDark ? 0.22 : 0.12} />
|
||||
<stop offset="70%" stopColor={palette.line} stopOpacity={isDark ? 0.08 : 0.04} />
|
||||
<stop offset="100%" stopColor={palette.line} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<CartesianGrid stroke="#1f3348" strokeDasharray="3 3" vertical={false} />
|
||||
<CartesianGrid
|
||||
stroke={palette.grid}
|
||||
strokeDasharray="4 6"
|
||||
vertical={false}
|
||||
/>
|
||||
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
||||
tick={{ fill: palette.axis, fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#334155" }}
|
||||
axisLine={{ stroke: palette.axisLine }}
|
||||
minTickGap={42}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
||||
tick={{ fill: palette.axis, fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#334155" }}
|
||||
axisLine={{ stroke: palette.axisLine }}
|
||||
width={44}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: "#94a3b8",
|
||||
stroke: palette.cursor,
|
||||
strokeWidth: 1,
|
||||
}}
|
||||
content={({ active, payload, label }) => {
|
||||
@@ -387,43 +427,52 @@ export function MeteoHistoryModal({
|
||||
const value = Number(payload[0].value);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-700 bg-[#111827] px-4 py-3 text-sm shadow-2xl">
|
||||
<p className="mb-2 text-xs text-slate-400">{label}</p>
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} border border-white/10 bg-[#111827] px-4 py-3 text-sm shadow-2xl`
|
||||
: `${RADIUS} border border-slate-200 bg-white px-4 py-3 text-sm text-slate-950 shadow-xl`
|
||||
}
|
||||
>
|
||||
<p className="mb-2 text-xs text-slate-500">
|
||||
{label}
|
||||
</p>
|
||||
|
||||
<p className="font-bold text-cyan-300">
|
||||
<p className={isDark ? "font-bold text-slate-100" : "font-bold text-slate-950"}>
|
||||
Atual: {formatValue(value, unit)}
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-slate-300">
|
||||
<p className={isDark ? "mt-1 text-slate-400" : "mt-1 text-slate-500"}>
|
||||
Média: {formatValue(stats.average, unit)}
|
||||
</p>
|
||||
|
||||
<p className="text-slate-300">
|
||||
<p className={isDark ? "text-slate-400" : "text-slate-500"}>
|
||||
Máx: {formatValue(stats.max, unit)}
|
||||
</p>
|
||||
|
||||
<p className="text-slate-300">
|
||||
<p className={isDark ? "text-slate-400" : "text-slate-500"}>
|
||||
Mín: {formatValue(stats.min, unit)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{showIndicators && stats.average !== null && (
|
||||
<ReferenceLine
|
||||
y={stats.average}
|
||||
stroke="#22d3ee"
|
||||
strokeDasharray="4 4"
|
||||
strokeOpacity={0.45}
|
||||
stroke={palette.reference}
|
||||
strokeDasharray="4 5"
|
||||
strokeOpacity={0.42}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCompareLine && stats.current !== null && (
|
||||
<ReferenceLine
|
||||
y={stats.current}
|
||||
stroke="#a78bfa"
|
||||
stroke={palette.compare}
|
||||
strokeDasharray="5 5"
|
||||
strokeOpacity={0.55}
|
||||
strokeOpacity={0.38}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -431,14 +480,14 @@ export function MeteoHistoryModal({
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#22d3ee"
|
||||
stroke={palette.line}
|
||||
strokeWidth={2}
|
||||
fill="url(#historyFill)"
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 5,
|
||||
fill: "#22d3ee",
|
||||
stroke: "#071120",
|
||||
fill: palette.line,
|
||||
stroke: isDark ? "#111827" : "#ffffff",
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
isAnimationActive={false}
|
||||
@@ -447,13 +496,13 @@ export function MeteoHistoryModal({
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#22d3ee"
|
||||
stroke={palette.line}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 5,
|
||||
fill: "#22d3ee",
|
||||
stroke: "#071120",
|
||||
fill: palette.line,
|
||||
stroke: isDark ? "#111827" : "#ffffff",
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
isAnimationActive={false}
|
||||
@@ -466,23 +515,31 @@ export function MeteoHistoryModal({
|
||||
</section>
|
||||
|
||||
{showIndicators && (
|
||||
<section
|
||||
className="mt-3 grid shrink-0 grid-cols-5 gap-3"
|
||||
>
|
||||
<section className="mt-3 grid shrink-0 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<MetricCard theme={theme} title="Valor atual" value={formatValue(stats.current, unit)} sub="Agora há pouco" />
|
||||
<MetricCard theme={theme} title="Variação" value={formatSignedValue(stats.change, unit)} sub="No período" positive={(stats.change ?? 0) >= 0} />
|
||||
<MetricCard theme={theme} title="Máxima" value={formatValue(stats.max, unit)} sub="No período" />
|
||||
<MetricCard theme={theme} title="Mínima" value={formatValue(stats.min, unit)} sub="No período" />
|
||||
|
||||
<div className={cardClass(isDark)}>
|
||||
<p className="text-xs text-slate-400">Volume de Dados</p>
|
||||
<h3 className="mt-1 text-xl font-black">{stats.count.toLocaleString("pt-PT")}</h3>
|
||||
<p className="text-xs text-slate-400">pontos</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
Volume de Dados
|
||||
</p>
|
||||
<h3 className="mt-1 text-xl font-black">
|
||||
{stats.count.toLocaleString("pt-PT")}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500">
|
||||
pontos
|
||||
</p>
|
||||
|
||||
<div className="mt-2 h-9">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={miniBars}>
|
||||
<Bar dataKey="value" fill="#4f46e5" radius={[3, 3, 0, 0]} />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill={isDark ? "#94a3b8" : "#64748b"}
|
||||
radius={[3, 3, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -495,8 +552,34 @@ export function MeteoHistoryModal({
|
||||
);
|
||||
}
|
||||
|
||||
function chartPalette(isDark: boolean) {
|
||||
return isDark
|
||||
? {
|
||||
line: "#cbd5e1",
|
||||
grid: "rgba(148,163,184,0.16)",
|
||||
axis: "#64748b",
|
||||
axisLine: "rgba(148,163,184,0.18)",
|
||||
cursor: "#64748b",
|
||||
reference: "#94a3b8",
|
||||
compare: "#64748b",
|
||||
}
|
||||
: {
|
||||
line: "#475569",
|
||||
grid: "#e2e8f0",
|
||||
axis: "#64748b",
|
||||
axisLine: "#cbd5e1",
|
||||
cursor: "#94a3b8",
|
||||
reference: "#64748b",
|
||||
compare: "#94a3b8",
|
||||
};
|
||||
}
|
||||
|
||||
function EmptyState({ children }: { children: string }) {
|
||||
return <div className="flex h-full items-center justify-center text-sm text-slate-400">{children}</div>;
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-slate-400">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
@@ -522,7 +605,6 @@ function MetricCard({
|
||||
title,
|
||||
value,
|
||||
sub,
|
||||
positive,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
title: string;
|
||||
@@ -534,36 +616,41 @@ function MetricCard({
|
||||
|
||||
return (
|
||||
<div className={cardClass(isDark)}>
|
||||
<p className="text-xs text-slate-400">{title}</p>
|
||||
<h3 className={`mt-1 text-xl font-black ${positive ? "text-emerald-400" : ""}`}>{value}</h3>
|
||||
<p className="text-xs text-slate-400">{sub}</p>
|
||||
<p className="text-xs text-slate-500">{title}</p>
|
||||
<h3 className="mt-1 text-xl font-black">{value}</h3>
|
||||
<p className="text-xs text-slate-500">{sub}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buttonClass(isDark: boolean) {
|
||||
return isDark
|
||||
? "rounded-lg border border-slate-700 px-3 py-2 text-xs font-semibold text-slate-200 hover:bg-slate-800"
|
||||
: "rounded-lg border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-100";
|
||||
? `${RADIUS} border border-white/10 bg-white/[0.03] px-3 py-2 text-xs font-semibold text-slate-300 transition hover:bg-white/[0.06] hover:text-slate-100`
|
||||
: `${RADIUS} border border-slate-200 bg-white px-3 py-2 text-xs font-semibold text-slate-600 transition hover:bg-slate-100 hover:text-slate-950`;
|
||||
}
|
||||
|
||||
function activeButtonClass(isDark: boolean) {
|
||||
return isDark
|
||||
? `${RADIUS} border border-white/10 bg-slate-200 px-3 py-2 text-xs font-black text-slate-950`
|
||||
: `${RADIUS} border border-slate-300 bg-slate-900 px-3 py-2 text-xs font-black text-white`;
|
||||
}
|
||||
|
||||
function iconButtonClass(isDark: boolean) {
|
||||
return isDark
|
||||
? "rounded-lg border border-slate-700 p-2 text-slate-300 hover:bg-slate-800"
|
||||
: "rounded-lg border border-slate-200 p-2 text-slate-600 hover:bg-slate-100";
|
||||
? `${RADIUS} border border-white/10 bg-white/[0.03] p-2 text-slate-400 transition hover:bg-white/[0.06] hover:text-slate-100`
|
||||
: `${RADIUS} border border-slate-200 bg-white p-2 text-slate-500 transition hover:bg-slate-100 hover:text-slate-950`;
|
||||
}
|
||||
|
||||
function toggleClass(isDark: boolean, active: boolean) {
|
||||
if (active) {
|
||||
return "rounded-lg border border-cyan-400/40 bg-cyan-400/10 px-3 py-2 text-xs font-semibold text-cyan-300";
|
||||
}
|
||||
|
||||
if (active) return activeButtonClass(isDark);
|
||||
return buttonClass(isDark);
|
||||
}
|
||||
|
||||
function toggleIconClass(isDark: boolean, active: boolean) {
|
||||
if (active) {
|
||||
return "rounded-lg border border-cyan-400/40 bg-cyan-400/10 p-2 text-cyan-300";
|
||||
return isDark
|
||||
? `${RADIUS} border border-white/10 bg-slate-200 p-2 text-slate-950`
|
||||
: `${RADIUS} border border-slate-300 bg-slate-900 p-2 text-white`;
|
||||
}
|
||||
|
||||
return iconButtonClass(isDark);
|
||||
@@ -571,8 +658,8 @@ function toggleIconClass(isDark: boolean, active: boolean) {
|
||||
|
||||
function cardClass(isDark: boolean) {
|
||||
return isDark
|
||||
? "rounded-xl border border-slate-700/60 bg-[#0a1728] p-3"
|
||||
: "rounded-xl border border-slate-200 bg-slate-50 p-3";
|
||||
? `${RADIUS} border border-white/10 bg-[#111827] p-3`
|
||||
: `${RADIUS} border border-slate-200 bg-white p-3`;
|
||||
}
|
||||
|
||||
function formatValue(value: number | null, unit: string) {
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
import {
|
||||
CloudRain,
|
||||
Droplets,
|
||||
MapPin,
|
||||
Navigation,
|
||||
Sun,
|
||||
Thermometer,
|
||||
Wind,
|
||||
ArrowRight,
|
||||
Cloud,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { WeatherForecastResponse } from "../../../types/weather";
|
||||
|
||||
type Props = {
|
||||
theme: "dark" | "light";
|
||||
forecast: WeatherForecastResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
compact?: boolean;
|
||||
onOpenMeteo?: () => void;
|
||||
};
|
||||
|
||||
const RADIUS = "rounded-[5px]";
|
||||
|
||||
export function WeatherForecastCard({
|
||||
theme,
|
||||
forecast,
|
||||
loading,
|
||||
error,
|
||||
compact = false,
|
||||
onOpenMeteo,
|
||||
}: Props) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<CompactWeatherCard
|
||||
theme={theme}
|
||||
forecast={forecast}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onOpenMeteo={onOpenMeteo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} border border-white/10 bg-[#111827] p-5 shadow-[0_12px_30px_rgba(0,0,0,0.22)]`
|
||||
: `${RADIUS} border border-slate-200 bg-white p-5 shadow-[0_10px_26px_rgba(15,23,42,0.06)]`
|
||||
}
|
||||
>
|
||||
<div className="mb-5 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className={isDark ? eyebrowDark : eyebrowLight}>
|
||||
Previsão meteorológica
|
||||
</p>
|
||||
|
||||
<h2 className={isDark ? titleDark : titleLight}>
|
||||
{forecast
|
||||
? `${forecast.location.name}, ${forecast.location.country}`
|
||||
: "Meteorologia externa"}
|
||||
</h2>
|
||||
|
||||
<p className={isDark ? subtitleDark : subtitleLight}>
|
||||
<MapPin className="h-4 w-4" />
|
||||
Dados externos de previsão
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{forecast?.current.condition?.icon && (
|
||||
<img
|
||||
src={forecast.current.condition.icon}
|
||||
alt={forecast.current.condition.text}
|
||||
className="h-12 w-12 opacity-90"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<StateMessage theme={theme}>A carregar previsão meteorológica...</StateMessage>
|
||||
) : error ? (
|
||||
<StateMessage theme={theme}>{error}</StateMessage>
|
||||
) : !forecast ? (
|
||||
<StateMessage theme={theme}>Sem previsão meteorológica disponível.</StateMessage>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<TodayForecastHero
|
||||
theme={theme}
|
||||
forecast={forecast}
|
||||
/>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{forecast.daily.slice(1, 7).map((day) => (
|
||||
<DailyForecastTile
|
||||
key={day.date}
|
||||
theme={theme}
|
||||
day={day}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TodayForecastHero({
|
||||
theme,
|
||||
forecast,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
forecast: WeatherForecastResponse;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const today = forecast.daily[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} border border-white/10 bg-white/[0.03] p-5`
|
||||
: `${RADIUS} border border-slate-200 bg-slate-50 p-5`
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-[320px_1fr]">
|
||||
<div className="flex items-center gap-4">
|
||||
{today.condition?.icon && (
|
||||
<img
|
||||
src={today.condition.icon}
|
||||
alt={today.condition.text}
|
||||
className="h-16 w-16 shrink-0 opacity-90"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "text-xs font-bold uppercase tracking-[0.16em] text-slate-500"
|
||||
: "text-xs font-bold uppercase tracking-[0.16em] text-slate-400"
|
||||
}
|
||||
>
|
||||
Hoje
|
||||
</p>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-1 text-sm font-semibold text-slate-400"
|
||||
: "mt-1 text-sm font-semibold text-slate-500"
|
||||
}
|
||||
>
|
||||
{formatDay(today.date)}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "mt-2 text-[46px] font-black leading-none tracking-[-0.06em] text-slate-100"
|
||||
: "mt-2 text-[46px] font-black leading-none tracking-[-0.06em] text-slate-950"
|
||||
}
|
||||
>
|
||||
{Math.round(today.maxTemperatureC)}°
|
||||
<span className="ml-1 text-lg tracking-normal text-slate-500">
|
||||
/ {Math.round(today.minTemperatureC)}°
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-2 text-sm font-semibold text-slate-300"
|
||||
: "mt-2 text-sm font-semibold text-slate-600"
|
||||
}
|
||||
>
|
||||
{today.condition?.text ?? "--"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<WeatherMiniStat
|
||||
theme={theme}
|
||||
icon={CloudRain}
|
||||
label="Prob. chuva"
|
||||
value={`${today.dailyRainChance}%`}
|
||||
/>
|
||||
|
||||
<WeatherMiniStat
|
||||
theme={theme}
|
||||
icon={Sun}
|
||||
label="UV"
|
||||
value={today.uv.toFixed(0)}
|
||||
/>
|
||||
|
||||
<WeatherMiniStat
|
||||
theme={theme}
|
||||
icon={Wind}
|
||||
label="Vento"
|
||||
value={`${forecast.current.windKph.toFixed(1)} km/h`}
|
||||
/>
|
||||
|
||||
<WeatherMiniStat
|
||||
theme={theme}
|
||||
icon={Droplets}
|
||||
label="Humidade"
|
||||
value={`${forecast.current.humidity}%`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DailyForecastTile({
|
||||
theme,
|
||||
day,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
day: WeatherForecastResponse["daily"][number];
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} border border-white/10 bg-white/[0.03] p-3`
|
||||
: `${RADIUS} border border-slate-200 bg-slate-50 p-3`
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "text-sm font-black text-slate-100"
|
||||
: "text-sm font-black text-slate-950"
|
||||
}
|
||||
>
|
||||
{formatDay(day.date)}
|
||||
</p>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-1 truncate text-xs text-slate-400"
|
||||
: "mt-1 truncate text-xs text-slate-500"
|
||||
}
|
||||
>
|
||||
{day.condition?.text ?? "--"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{day.condition?.icon && (
|
||||
<img
|
||||
src={day.condition.icon}
|
||||
alt={day.condition.text}
|
||||
className="h-9 w-9 shrink-0 opacity-90"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "text-2xl font-black text-slate-100"
|
||||
: "text-2xl font-black text-slate-950"
|
||||
}
|
||||
>
|
||||
{Math.round(day.maxTemperatureC)}°
|
||||
<span className="ml-1 text-sm font-bold text-slate-500">
|
||||
/ {Math.round(day.minTemperatureC)}°
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-2 text-xs">
|
||||
<span
|
||||
className={
|
||||
isDark
|
||||
? "flex items-center gap-1 font-semibold text-slate-400"
|
||||
: "flex items-center gap-1 font-semibold text-slate-500"
|
||||
}
|
||||
>
|
||||
<CloudRain className="h-3.5 w-3.5" />
|
||||
{day.dailyRainChance}%
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={
|
||||
isDark
|
||||
? "flex items-center gap-1 font-semibold text-slate-400"
|
||||
: "flex items-center gap-1 font-semibold text-slate-500"
|
||||
}
|
||||
>
|
||||
<Sun className="h-3.5 w-3.5" />
|
||||
UV {day.uv.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WeatherMiniStat({
|
||||
theme,
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} border border-white/10 bg-[#0b1220] px-3 py-2.5`
|
||||
: `${RADIUS} border border-slate-200 bg-white px-3 py-2.5`
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
className={
|
||||
isDark
|
||||
? "mb-1.5 h-4 w-4 text-slate-400"
|
||||
: "mb-1.5 h-4 w-4 text-slate-500"
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="text-[11px] text-slate-500">{label}</p>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-0.5 text-sm font-black text-slate-100"
|
||||
: "mt-0.5 text-sm font-black text-slate-950"
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StateMessage({
|
||||
theme,
|
||||
children,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
children: string;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} border border-white/10 bg-white/[0.03] p-5 text-sm text-slate-400`
|
||||
: `${RADIUS} border border-slate-200 bg-slate-50 p-5 text-sm text-slate-500`
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDay(date: string) {
|
||||
return new Date(date).toLocaleDateString("pt-PT", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
const eyebrowDark =
|
||||
"text-[11px] font-bold uppercase tracking-[0.22em] text-slate-500";
|
||||
const eyebrowLight =
|
||||
"text-[11px] font-bold uppercase tracking-[0.22em] text-slate-400";
|
||||
|
||||
const titleDark = "mt-2 text-xl font-black tracking-[-0.03em] text-slate-100";
|
||||
const titleLight = "mt-2 text-xl font-black tracking-[-0.03em] text-slate-950";
|
||||
|
||||
const subtitleDark = "mt-1 flex items-center gap-2 text-sm text-slate-400";
|
||||
const subtitleLight = "mt-1 flex items-center gap-2 text-sm text-slate-500";
|
||||
|
||||
function CompactWeatherCard({
|
||||
theme,
|
||||
forecast,
|
||||
loading,
|
||||
error,
|
||||
onOpenMeteo,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
forecast: WeatherForecastResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onOpenMeteo?: () => void;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const today = forecast?.daily?.[0];
|
||||
|
||||
return (
|
||||
<article
|
||||
className={
|
||||
isDark
|
||||
? "absolute right-0 top-10 w-[340px] rounded-[8px] border border-white/10 bg-[#07111f]/92 p-7 shadow-[0_22px_60px_rgba(0,0,0,0.42)] backdrop-blur-xl"
|
||||
: "absolute right-0 top-10 w-[340px] rounded-[8px] border border-slate-200/80 bg-white/96 p-7 shadow-[0_14px_40px_rgba(15,23,42,0.10)] backdrop-blur-xl"
|
||||
}
|
||||
>
|
||||
<p className="flex items-center gap-2 text-sm font-bold">
|
||||
<MapPin className="h-4 w-4" />
|
||||
|
||||
{forecast
|
||||
? `${forecast.location.name}, ${forecast.location.country}`
|
||||
: "Meteorologia"}
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-5 text-sm text-slate-400">
|
||||
A carregar previsão...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="mt-5 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
) : today ? (
|
||||
<>
|
||||
<div className="mt-6 flex items-start justify-between gap-5">
|
||||
<div>
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "text-xs font-bold uppercase tracking-[0.18em] text-slate-400"
|
||||
: "text-xs font-bold uppercase tracking-[0.18em] text-slate-500"
|
||||
}
|
||||
>
|
||||
Hoje
|
||||
</p>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-1 text-sm font-semibold text-slate-300"
|
||||
: "mt-1 text-sm font-semibold text-slate-600"
|
||||
}
|
||||
>
|
||||
{formatDay(today.date)}
|
||||
</p>
|
||||
|
||||
<div className="mt-5 flex items-end">
|
||||
<span className="text-[44px] font-black leading-none tracking-[-0.06em]">
|
||||
{Math.round(today.maxTemperatureC)}°
|
||||
</span>
|
||||
|
||||
<span className="mb-1 ml-2 text-base font-bold text-slate-500">
|
||||
/ {Math.round(today.minTemperatureC)}°
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-2 text-sm text-slate-300"
|
||||
: "mt-2 text-sm text-slate-700"
|
||||
}
|
||||
>
|
||||
{today.condition?.text ?? "--"}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "mt-4 flex flex-wrap gap-2 text-xs text-slate-400"
|
||||
: "mt-4 flex flex-wrap gap-2 text-xs text-slate-600"
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
isDark
|
||||
? "rounded-full border border-white/10 bg-white/5 px-2 py-1"
|
||||
: "rounded-full border border-slate-200 bg-slate-100 px-2 py-1"
|
||||
}
|
||||
>
|
||||
Chuva {today.dailyRainChance}%
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={
|
||||
isDark
|
||||
? "rounded-full border border-white/10 bg-white/5 px-2 py-1"
|
||||
: "rounded-full border border-slate-200 bg-slate-100 px-2 py-1"
|
||||
}
|
||||
>
|
||||
UV {today.uv.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{today.condition?.icon ? (
|
||||
<img
|
||||
src={today.condition.icon}
|
||||
alt={today.condition.text}
|
||||
className="h-16 w-16 shrink-0 opacity-90"
|
||||
/>
|
||||
) : (
|
||||
<Cloud
|
||||
className={
|
||||
isDark
|
||||
? "h-16 w-16 shrink-0 text-slate-200"
|
||||
: "h-16 w-16 shrink-0 text-slate-400"
|
||||
}
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "mt-6 border-t border-white/10 pt-4"
|
||||
: "mt-6 border-t border-slate-200 pt-4"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenMeteo}
|
||||
className="flex items-center gap-3 text-sm text-slate-500 transition hover:text-emerald-400"
|
||||
>
|
||||
Ver meteorologia
|
||||
<ArrowRight className="h-4 w-4 text-emerald-400" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-5 text-sm text-slate-400">
|
||||
Sem previsão diária disponível.
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { WeatherForecastResponse } from "../../../types/weather";
|
||||
|
||||
const BACKEND_URL = "http://localhost:18450";
|
||||
|
||||
type LocationState = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
export function useWeatherForecast() {
|
||||
const [forecast, setForecast] = useState<WeatherForecastResponse | null>(null);
|
||||
const [location, setLocation] = useState<LocationState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!navigator.geolocation) {
|
||||
setError("Geolocalização não suportada.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setLocation({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
// Fallback Leiria
|
||||
setLocation({
|
||||
latitude: 39.75,
|
||||
longitude: -8.8,
|
||||
});
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: false,
|
||||
timeout: 8000,
|
||||
maximumAge: 1000 * 60 * 60 * 12,
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!location) return;
|
||||
|
||||
const latitude = location.latitude;
|
||||
const longitude = location.longitude;
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
async function loadForecast() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
lat: String(latitude),
|
||||
lon: String(longitude),
|
||||
days: "7",
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/weather/forecast?${params.toString()}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load weather forecast");
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as WeatherForecastResponse;
|
||||
setForecast(payload);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
console.error("Failed to load weather forecast", error);
|
||||
setError("Não foi possível carregar a previsão meteorológica.");
|
||||
setForecast(null);
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadForecast();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [location]);
|
||||
|
||||
return {
|
||||
forecast,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Activity,
|
||||
ChartNoAxesColumnIncreasing,
|
||||
Table2,
|
||||
CloudRain,
|
||||
Droplets,
|
||||
MoreHorizontal,
|
||||
Radio,
|
||||
Sun,
|
||||
Table2,
|
||||
Thermometer,
|
||||
TrendingUp,
|
||||
Wind,
|
||||
Wifi,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { useMeteoModuleStream } from "../hooks/useMeteoModuleStream";
|
||||
import { MeteoHistoryModal } from "../components/MeteoHistoryModal";
|
||||
@@ -21,25 +20,36 @@ import {
|
||||
useAccumulatedHistory,
|
||||
type AccumulatedRange,
|
||||
} from "../hooks/useAccumulatedHistory";
|
||||
import { useWeatherForecast } from "../hooks/useWeatherForecast";
|
||||
import { WeatherForecastCard } from "../components/WeatherForecastCard";
|
||||
|
||||
type MeteoPageProps = {
|
||||
theme: "dark" | "light";
|
||||
};
|
||||
|
||||
type HistoryMap = Record<string, number[]>;
|
||||
type Accent = "amber" | "blue" | "cyan" | "emerald";
|
||||
|
||||
const MAX_HISTORY_POINTS = 34;
|
||||
const RADIUS = "rounded-[5px]";
|
||||
|
||||
export function MeteoPage({ theme }: MeteoPageProps) {
|
||||
const { sensors, sensorCount, connected, lastTimestamp } =
|
||||
useMeteoModuleStream();
|
||||
const { sensors } = useMeteoModuleStream();
|
||||
|
||||
const [selectedSensor, setSelectedSensor] =
|
||||
useState<ModuleSensorResponse | null>(null);
|
||||
|
||||
const [history, setHistory] = useState<HistoryMap>({});
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
|
||||
const [selectedAccumulated, setSelectedAccumulated] = useState<{
|
||||
title: string;
|
||||
sensor: ModuleSensorResponse | null;
|
||||
} | null>(null);
|
||||
|
||||
const [accumulatedRange, setAccumulatedRange] =
|
||||
useState<AccumulatedRange>("7d");
|
||||
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const temperature = findSensor(sensors, "temperatura.exterior");
|
||||
const humidity = findSensor(sensors, "humidade.exterior");
|
||||
@@ -59,26 +69,19 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
||||
findSensor(sensors, "chuva.instantanea") ??
|
||||
findSensor(sensors, "chuva.intensidade") ??
|
||||
maxSensor(rainSensors);
|
||||
|
||||
const rainValue = numericValue(rainSensor);
|
||||
const isRaining = rainValue !== null && rainValue > 0;
|
||||
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
|
||||
const meteoHistory = useMeteoHistory(selectedSensor);
|
||||
|
||||
const [selectedAccumulated, setSelectedAccumulated] = useState<{
|
||||
title: string;
|
||||
sensor: ModuleSensorResponse | null;
|
||||
} | null>(null);
|
||||
|
||||
const [accumulatedRange, setAccumulatedRange] =
|
||||
useState<AccumulatedRange>("7d");
|
||||
|
||||
const accumulatedHistory = useAccumulatedHistory(
|
||||
selectedAccumulated?.sensor ?? null,
|
||||
accumulatedRange,
|
||||
);
|
||||
|
||||
const weatherForecast = useWeatherForecast();
|
||||
|
||||
useEffect(() => {
|
||||
const samples: Array<[string, number | null]> = [
|
||||
["temperatura.exterior", numericValue(temperature)],
|
||||
@@ -113,17 +116,16 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<section
|
||||
className={
|
||||
isDark
|
||||
? "rounded-[30px] border border-white/10 bg-[#071827] p-4 shadow-[0_24px_70px_rgba(0,0,0,0.28)]"
|
||||
: "rounded-[30px] border border-slate-200 bg-slate-50 p-4 shadow-sm"
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-[1fr_372px_1fr]">
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-5 pb-6">
|
||||
<WeatherForecastCard
|
||||
theme={theme}
|
||||
forecast={weatherForecast.forecast}
|
||||
loading={weatherForecast.loading}
|
||||
error={weatherForecast.error}
|
||||
/>
|
||||
|
||||
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_420px_minmax(0,1fr)]">
|
||||
<div className="grid gap-5 md:grid-cols-2 2xl:grid-cols-1">
|
||||
<MetricTile
|
||||
theme={theme}
|
||||
title="Temperatura"
|
||||
@@ -134,7 +136,9 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
||||
status={temperatureBadge(temperature)}
|
||||
values={history["temperatura.exterior"]}
|
||||
menuOpen={openMenu === "temperature"}
|
||||
onMenuToggle={() => setOpenMenu(openMenu === "temperature" ? null : "temperature")}
|
||||
onMenuToggle={() =>
|
||||
setOpenMenu(openMenu === "temperature" ? null : "temperature")
|
||||
}
|
||||
actions={[
|
||||
{
|
||||
label: "Ver gráfico",
|
||||
@@ -202,7 +206,6 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
||||
title: "Precipitação acumulada",
|
||||
sensor: rainSensor ?? null,
|
||||
});
|
||||
|
||||
setOpenMenu(null);
|
||||
},
|
||||
},
|
||||
@@ -210,12 +213,9 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CompassPanel
|
||||
theme={theme}
|
||||
direction={numericValue(windDirection)}
|
||||
/>
|
||||
<CompassPanel theme={theme} direction={numericValue(windDirection)} />
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-1">
|
||||
<MetricTile
|
||||
theme={theme}
|
||||
title="Vento"
|
||||
@@ -271,23 +271,13 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
||||
title: "Radiação solar acumulada",
|
||||
sensor: radiation ?? null,
|
||||
});
|
||||
|
||||
setOpenMenu(null);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<StatusTile
|
||||
theme={theme}
|
||||
connected={connected}
|
||||
sensorCount={sensorCount}
|
||||
lastTimestamp={lastTimestamp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<MeteoHistoryModal
|
||||
sensor={selectedSensor}
|
||||
@@ -309,7 +299,7 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
||||
onRangeChange={setAccumulatedRange}
|
||||
onClose={() => setSelectedAccumulated(null)}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -335,7 +325,7 @@ function MetricTile({
|
||||
customValue?: string;
|
||||
customUnit?: string;
|
||||
icon: ReactNode;
|
||||
accent: "amber" | "blue" | "cyan" | "emerald";
|
||||
accent: Accent;
|
||||
status: string;
|
||||
values?: number[];
|
||||
menuOpen: boolean;
|
||||
@@ -347,25 +337,23 @@ function MetricTile({
|
||||
}>;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
const colors = accentColors(accent);
|
||||
const colors = accentColors(accent, isDark);
|
||||
const value = customValue ?? formatValue(sensor);
|
||||
const unit = customUnit ?? sensor?.unit;
|
||||
const trend = getTrend(values);
|
||||
|
||||
return (
|
||||
<div
|
||||
<article
|
||||
className={
|
||||
isDark
|
||||
? "group relative min-h-[178px] overflow-visible rounded-[22px] border border-[#24384d] bg-[linear-gradient(135deg,#10263b_0%,#0b1d31_48%,#081827_100%)] p-6 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.04),0_16px_34px_rgba(0,0,0,0.22)] transition hover:border-[#31506b]"
|
||||
: "group relative min-h-[178px] overflow-visible rounded-[22px] border border-slate-200 bg-white p-6 text-left shadow-sm transition hover:border-slate-300"
|
||||
? `${RADIUS} group relative min-h-[190px] overflow-hidden border border-white/10 bg-[#111827] p-5 text-left shadow-[0_12px_30px_rgba(0,0,0,0.22)] transition duration-200 hover:border-white/15 hover:bg-[#141d2b]`
|
||||
: `${RADIUS} group relative min-h-[190px] overflow-hidden border border-slate-200 bg-white p-5 text-left shadow-[0_10px_26px_rgba(15,23,42,0.06)] transition duration-200 hover:border-slate-300 hover:shadow-[0_14px_34px_rgba(15,23,42,0.08)]`
|
||||
}
|
||||
>
|
||||
<div className="relative z-20 flex items-start justify-between">
|
||||
<div className="relative z-20 flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`grid h-12 w-12 place-items-center rounded-2xl border ${isDark
|
||||
? "border-[#263e56] bg-[#132b43]"
|
||||
: "border-slate-200 bg-slate-50"
|
||||
} ${colors.text}`}
|
||||
className={`grid h-11 w-11 shrink-0 place-items-center ${RADIUS} border ${colors.iconBox} ${colors.icon}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
@@ -374,8 +362,8 @@ function MetricTile({
|
||||
<h2
|
||||
className={
|
||||
isDark
|
||||
? "text-[15px] font-bold text-white"
|
||||
: "text-[15px] font-bold text-slate-950"
|
||||
? "text-[15px] font-bold tracking-[-0.01em] text-slate-100"
|
||||
: "text-[15px] font-bold tracking-[-0.01em] text-slate-950"
|
||||
}
|
||||
>
|
||||
{title}
|
||||
@@ -392,7 +380,7 @@ function MetricTile({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
@@ -401,9 +389,10 @@ function MetricTile({
|
||||
}}
|
||||
className={
|
||||
isDark
|
||||
? "rounded-lg p-1 text-slate-300 transition hover:bg-white/10 hover:text-white"
|
||||
: "rounded-lg p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
|
||||
? `grid h-9 w-9 place-items-center ${RADIUS} text-slate-400 transition hover:bg-white/5 hover:text-slate-100`
|
||||
: `grid h-9 w-9 place-items-center ${RADIUS} text-slate-400 transition hover:bg-slate-100 hover:text-slate-700`
|
||||
}
|
||||
aria-label={`Abrir ações de ${title}`}
|
||||
>
|
||||
<MoreHorizontal className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -412,8 +401,8 @@ function MetricTile({
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "absolute right-0 top-8 z-50 w-44 rounded-xl border border-[#24384d] bg-[#0b2035] p-1 shadow-2xl"
|
||||
: "absolute right-0 top-8 z-50 w-44 rounded-xl border border-slate-200 bg-white p-1 shadow-xl"
|
||||
? `absolute right-0 top-11 z-50 w-48 ${RADIUS} border border-white/10 bg-[#0f172a] p-1.5 shadow-xl`
|
||||
: `absolute right-0 top-11 z-50 w-48 ${RADIUS} border border-slate-200 bg-white p-1.5 shadow-xl`
|
||||
}
|
||||
>
|
||||
{actions.map((action) => (
|
||||
@@ -426,8 +415,8 @@ function MetricTile({
|
||||
}}
|
||||
className={
|
||||
isDark
|
||||
? "flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-semibold text-slate-200 transition hover:bg-white/10 hover:text-white"
|
||||
: "flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-100 hover:text-slate-950"
|
||||
? `flex w-full items-center gap-3 ${RADIUS} px-3 py-2.5 text-sm font-semibold text-slate-300 transition hover:bg-white/5 hover:text-white`
|
||||
: `flex w-full items-center gap-3 ${RADIUS} px-3 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-100 hover:text-slate-950`
|
||||
}
|
||||
>
|
||||
{action.icon}
|
||||
@@ -439,18 +428,23 @@ function MetricTile({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-7 flex items-end justify-between gap-5">
|
||||
<div className="relative z-10 mt-8">
|
||||
<div className="flex items-end justify-between gap-5">
|
||||
<div>
|
||||
<div
|
||||
className={`text-[46px] font-black leading-none tracking-[-0.06em] ${colors.text}`}
|
||||
className={
|
||||
isDark
|
||||
? "flex items-end text-[50px] font-black leading-none tracking-[-0.07em] text-slate-100"
|
||||
: "flex items-end text-[50px] font-black leading-none tracking-[-0.07em] text-slate-950"
|
||||
}
|
||||
>
|
||||
{value}
|
||||
{unit && (
|
||||
<span
|
||||
className={
|
||||
isDark
|
||||
? "ml-2 text-sm font-black tracking-normal text-white"
|
||||
: "ml-2 text-sm font-black tracking-normal text-slate-800"
|
||||
? "mb-1 ml-2 text-base font-bold tracking-[-0.02em] text-slate-400"
|
||||
: "mb-1 ml-2 text-base font-bold tracking-[-0.02em] text-slate-500"
|
||||
}
|
||||
>
|
||||
{unit}
|
||||
@@ -458,24 +452,35 @@ function MetricTile({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`mt-5 inline-flex min-w-[96px] items-center justify-center rounded-xl border px-4 py-2 text-sm font-bold ${isDark
|
||||
? `${colors.badge} ${colors.text}`
|
||||
: "border-slate-200 bg-slate-50 text-slate-700"
|
||||
}`}
|
||||
className={`inline-flex items-center justify-center ${RADIUS} border px-3 py-1.5 text-xs font-bold ${colors.badge}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={
|
||||
isDark
|
||||
? `inline-flex items-center gap-1.5 ${RADIUS} border border-white/10 bg-white/[0.03] px-3 py-1.5 text-xs font-semibold text-slate-400`
|
||||
: `inline-flex items-center gap-1.5 ${RADIUS} border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs font-semibold text-slate-500`
|
||||
}
|
||||
>
|
||||
<TrendingUp className="h-3.5 w-3.5" />
|
||||
{trend}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sparkline
|
||||
values={values}
|
||||
className="absolute bottom-8 right-7 z-0 h-[56px] w-[210px]"
|
||||
className="absolute bottom-7 right-6 z-0 h-[62px] w-[220px]"
|
||||
strokeClassName={colors.stroke}
|
||||
glowClassName={colors.dot}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -492,48 +497,75 @@ function CompassPanel({
|
||||
const degrees = direction !== null ? Math.round(direction) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
<article
|
||||
className={
|
||||
isDark
|
||||
? "relative flex min-h-[560px] flex-col overflow-hidden rounded-[22px] border border-[#24384d] bg-[linear-gradient(180deg,#0d2236_0%,#081827_100%)] px-6 py-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.04),0_16px_34px_rgba(0,0,0,0.22)]"
|
||||
: "relative flex min-h-[560px] flex-col overflow-hidden rounded-[22px] border border-slate-200 bg-white px-6 py-5 shadow-sm"
|
||||
? `${RADIUS} relative flex min-h-[620px] flex-col overflow-hidden border border-white/10 bg-[#111827] p-6 shadow-[0_12px_30px_rgba(0,0,0,0.22)]`
|
||||
: `${RADIUS} relative flex min-h-[620px] flex-col overflow-hidden border border-slate-200 bg-white p-6 shadow-[0_10px_26px_rgba(15,23,42,0.06)]`
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<p className={isDark ? "text-[11px] font-bold uppercase tracking-[0.22em] text-slate-400" : "text-[11px] font-bold uppercase tracking-[0.22em] text-slate-500"}>
|
||||
<div className="relative z-10 flex flex-col items-center">
|
||||
<span
|
||||
className={
|
||||
isDark
|
||||
? `inline-flex items-center gap-2 ${RADIUS} border border-white/10 bg-white/[0.03] px-3 py-1 text-[11px] font-bold uppercase tracking-[0.16em] text-slate-400`
|
||||
: `inline-flex items-center gap-2 ${RADIUS} border border-slate-200 bg-slate-50 px-3 py-1 text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500`
|
||||
}
|
||||
>
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
Direção do vento
|
||||
</p>
|
||||
</span>
|
||||
|
||||
<h2 className={isDark ? "mt-3 text-[34px] font-black leading-none tracking-[-0.05em] text-white" : "mt-3 text-[34px] font-black leading-none tracking-[-0.05em] text-slate-950"}>
|
||||
<h2
|
||||
className={
|
||||
isDark
|
||||
? "mt-4 text-[42px] font-black leading-none tracking-[-0.08em] text-slate-100"
|
||||
: "mt-4 text-[42px] font-black leading-none tracking-[-0.08em] text-slate-950"
|
||||
}
|
||||
>
|
||||
{cardinal}
|
||||
</h2>
|
||||
|
||||
<div className="mt-5 grid w-full grid-cols-2 gap-3">
|
||||
<div className={isDark ? "rounded-[18px] border border-[#24384d] bg-[#0b2035] px-4 py-3" : "rounded-[18px] border border-slate-200 bg-slate-50 px-4 py-3"}>
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">
|
||||
Graus
|
||||
</p>
|
||||
<p className={isDark ? "mt-1 text-[20px] font-black text-white" : "mt-1 text-[20px] font-black text-slate-950"}>
|
||||
{degrees !== null ? `${degrees}°` : "--"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={isDark ? "rounded-[18px] border border-[#24384d] bg-[#0b2035] px-4 py-3" : "rounded-[18px] border border-slate-200 bg-slate-50 px-4 py-3"}>
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">
|
||||
Quadrante
|
||||
</p>
|
||||
<p className="mt-1 text-[20px] font-black text-cyan-300">
|
||||
{directionQuadrant(direction)}
|
||||
</p>
|
||||
</div>
|
||||
<CompassStat
|
||||
theme={theme}
|
||||
label="Graus"
|
||||
value={degrees !== null ? `${degrees}°` : "--"}
|
||||
/>
|
||||
<CompassStat
|
||||
theme={theme}
|
||||
label="Quadrante"
|
||||
value={directionQuadrant(direction)}
|
||||
highlighted
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center pt-2">
|
||||
<div className="relative h-[320px] w-[320px]">
|
||||
<div className={isDark ? "absolute inset-0 rounded-full border border-[#20364d] bg-[#071827]" : "absolute inset-0 rounded-full border border-slate-200 bg-slate-50"} />
|
||||
<div className={isDark ? "absolute inset-5 rounded-full border border-[#213b55]" : "absolute inset-5 rounded-full border border-slate-200"} />
|
||||
<div className={isDark ? "absolute inset-[74px] rounded-full border border-cyan-400/35 bg-[#0a1d30]" : "absolute inset-[74px] rounded-full border border-cyan-500/30 bg-white"} />
|
||||
<div className="relative z-10 flex flex-1 items-center justify-center pt-6">
|
||||
<div className="relative h-[340px] w-[340px] max-w-full">
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "absolute inset-0 rounded-full border border-white/10 bg-[#0b1220]"
|
||||
: "absolute inset-0 rounded-full border border-slate-200 bg-slate-50"
|
||||
}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "absolute inset-6 rounded-full border border-white/10"
|
||||
: "absolute inset-6 rounded-full border border-slate-200"
|
||||
}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "absolute inset-[78px] rounded-full border border-white/10 bg-white/[0.02]"
|
||||
: "absolute inset-[78px] rounded-full border border-slate-200 bg-white"
|
||||
}
|
||||
/>
|
||||
|
||||
{Array.from({ length: 72 }).map((_, index) => {
|
||||
const major = index % 6 === 0;
|
||||
@@ -541,58 +573,120 @@ function CompassPanel({
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className={`absolute left-1/2 top-1/2 w-px origin-[50%_149px] ${major ? "h-3" : "h-1.5"
|
||||
} ${isDark ? "bg-slate-500/50" : "bg-slate-400/60"}`}
|
||||
className={`absolute left-1/2 top-1/2 w-px origin-[50%_158px] ${major ? "h-4" : "h-2"
|
||||
} ${isDark ? "bg-slate-600/55" : "bg-slate-300"}`}
|
||||
style={{
|
||||
transform: `translate(-50%, -149px) rotate(${index * 5}deg)`,
|
||||
transform: `translate(-50%, -158px) rotate(${index * 5}deg)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<CompassLabel label="N" className="left-1/2 top-[18px] -translate-x-1/2" />
|
||||
<CompassLabel label="S" className="bottom-[18px] left-1/2 -translate-x-1/2" />
|
||||
<CompassLabel label="W" className="left-[22px] top-1/2 -translate-y-1/2" />
|
||||
<CompassLabel label="E" className="right-[22px] top-1/2 -translate-y-1/2" />
|
||||
<CompassLabel label="N" isDark={isDark} className="left-1/2 top-[18px] -translate-x-1/2" />
|
||||
<CompassLabel label="S" isDark={isDark} className="bottom-[18px] left-1/2 -translate-x-1/2" />
|
||||
<CompassLabel label="W" isDark={isDark} className="left-[22px] top-1/2 -translate-y-1/2" />
|
||||
<CompassLabel label="E" isDark={isDark} className="right-[22px] top-1/2 -translate-y-1/2" />
|
||||
|
||||
<div className={isDark ? "absolute inset-[108px] rounded-full bg-[#0b2035] shadow-[0_0_24px_rgba(34,211,238,0.08)]" : "absolute inset-[108px] rounded-full bg-white shadow-sm"} />
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "absolute inset-[116px] rounded-full border border-white/10 bg-[#111827]"
|
||||
: "absolute inset-[116px] rounded-full border border-slate-200 bg-white"
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="relative h-28 w-28">
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "relative h-24 w-24 rounded-full border border-cyan-400/20 bg-[#0b2035]"
|
||||
: "relative h-24 w-24 rounded-full border border-cyan-500/20 bg-white"
|
||||
? "absolute left-1/2 top-1/2 h-[126px] w-[10px] origin-bottom rounded-full bg-slate-300"
|
||||
: "absolute left-1/2 top-1/2 h-[126px] w-[10px] origin-bottom rounded-full bg-slate-700"
|
||||
}
|
||||
style={{
|
||||
clipPath: "polygon(50% 0%, 100% 100%, 50% 84%, 0% 100%)",
|
||||
transform: `translate(-50%, -100%) rotate(${angle}deg)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "absolute left-1/2 top-1/2 h-[118px] w-[2px] origin-bottom rounded-full bg-white/60"
|
||||
: "absolute left-1/2 top-1/2 h-[118px] w-[2px] origin-bottom rounded-full bg-white/90"
|
||||
}
|
||||
style={{
|
||||
transform: `translate(-50%, -100%) rotate(${angle}deg)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "absolute left-1/2 top-1/2 h-10 w-10 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/10 bg-[#0b1220]"
|
||||
: "absolute left-1/2 top-1/2 h-10 w-10 -translate-x-1/2 -translate-y-1/2 rounded-full border border-slate-200 bg-white"
|
||||
}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-slate-300"
|
||||
: "absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-slate-700"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function CompassStat({
|
||||
theme,
|
||||
label,
|
||||
value,
|
||||
highlighted,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
label: string;
|
||||
value: string;
|
||||
highlighted?: boolean;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} border border-white/10 bg-white/[0.03] px-4 py-3`
|
||||
: `${RADIUS} border border-slate-200 bg-slate-50 px-4 py-3`
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 h-[124px] w-[14px] origin-bottom rounded-full bg-cyan-300/90 shadow-[0_0_8px_rgba(34,211,238,0.28)]"
|
||||
style={{
|
||||
clipPath: "polygon(50% 0%, 100% 100%, 50% 88%, 0% 100%)",
|
||||
transform: `translate(-50%, -100%) rotate(${angle}deg)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 h-[116px] w-[5px] origin-bottom rounded-full bg-white/55"
|
||||
style={{
|
||||
transform: `translate(-50%, -100%) rotate(${angle}deg)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "absolute left-1/2 top-1/2 h-8 w-8 -translate-x-1/2 -translate-y-1/2 rounded-full border border-cyan-300/30 bg-[#0d263d]"
|
||||
: "absolute left-1/2 top-1/2 h-8 w-8 -translate-x-1/2 -translate-y-1/2 rounded-full border border-cyan-500/30 bg-white"
|
||||
? "text-[10px] font-bold uppercase tracking-[0.16em] text-slate-500"
|
||||
: "text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400"
|
||||
}
|
||||
/>
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
|
||||
<div className="absolute left-1/2 top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-cyan-300" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className={
|
||||
highlighted
|
||||
? isDark
|
||||
? "mt-1 text-xl font-black tracking-[-0.04em] text-slate-200"
|
||||
: "mt-1 text-xl font-black tracking-[-0.04em] text-slate-800"
|
||||
: isDark
|
||||
? "mt-1 text-xl font-black tracking-[-0.04em] text-slate-100"
|
||||
: "mt-1 text-xl font-black tracking-[-0.04em] text-slate-950"
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -600,105 +694,24 @@ function CompassPanel({
|
||||
function CompassLabel({
|
||||
label,
|
||||
className,
|
||||
isDark,
|
||||
}: {
|
||||
label: string;
|
||||
className: string;
|
||||
isDark: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`absolute flex h-5 w-5 items-center justify-center text-[13px] font-black leading-none tracking-[0.02em] text-slate-200 ${className}`}
|
||||
className={`absolute flex h-6 w-6 items-center justify-center rounded-full text-[13px] font-black leading-none tracking-[0.02em] ${isDark
|
||||
? "bg-white/[0.03] text-slate-300"
|
||||
: "bg-white text-slate-500 shadow-sm"
|
||||
} ${className}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function directionQuadrant(direction: number | null) {
|
||||
if (direction === null) return "--";
|
||||
if (direction >= 315 || direction < 45) return "Norte";
|
||||
if (direction >= 45 && direction < 135) return "Este";
|
||||
if (direction >= 135 && direction < 225) return "Sul";
|
||||
return "Oeste";
|
||||
}
|
||||
|
||||
function StatusTile({
|
||||
theme,
|
||||
connected,
|
||||
sensorCount,
|
||||
lastTimestamp,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
connected: boolean;
|
||||
sensorCount: number;
|
||||
lastTimestamp: string | null;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "min-h-[178px] rounded-[22px] border border-[#24384d] bg-[linear-gradient(135deg,#10263b_0%,#0b1d31_48%,#081827_100%)] p-6 shadow-[inset_0_1px_0_rgba(255,255,255,0.04),0_16px_34px_rgba(0,0,0,0.22)]"
|
||||
: "min-h-[178px] rounded-[22px] border border-slate-200 bg-white p-6 shadow-sm"
|
||||
}
|
||||
>
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<Radio className="h-5 w-5 text-emerald-300" />
|
||||
<h2 className={isDark ? "text-[15px] font-bold text-white" : "text-[15px] font-bold text-slate-950"}>
|
||||
Estado do módulo
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<StatusRow
|
||||
theme={theme}
|
||||
label="Ligação"
|
||||
value={connected ? "Online" : "Offline"}
|
||||
online={connected}
|
||||
/>
|
||||
<StatusRow theme={theme} label="Sensores" value={String(sensorCount)} />
|
||||
<StatusRow
|
||||
theme={theme}
|
||||
label="Atualização"
|
||||
value={lastTimestamp ? formatTime(lastTimestamp) : "--"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusRow({
|
||||
theme,
|
||||
label,
|
||||
value,
|
||||
online,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
label: string;
|
||||
value: string;
|
||||
online?: boolean;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className={isDark ? "text-sm text-slate-400" : "text-sm text-slate-500"}>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<span className="flex items-center gap-3">
|
||||
{online !== undefined && (
|
||||
<Wifi className={online ? "h-4 w-4 text-emerald-300" : "h-4 w-4 text-red-300"} />
|
||||
)}
|
||||
<span className={online ? "text-sm font-semibold text-emerald-300" : isDark ? "text-sm font-semibold text-white" : "text-sm font-semibold text-slate-950"}>
|
||||
{value}
|
||||
</span>
|
||||
<ChevronRight className={isDark ? "h-4 w-4 text-slate-500" : "h-4 w-4 text-slate-400"} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Sparkline({
|
||||
values,
|
||||
className,
|
||||
@@ -712,8 +725,8 @@ function Sparkline({
|
||||
}) {
|
||||
if (!values || values.length < 2) return null;
|
||||
|
||||
const width = 210;
|
||||
const height = 56;
|
||||
const width = 220;
|
||||
const height = 62;
|
||||
const padding = 8;
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
@@ -728,7 +741,12 @@ function Sparkline({
|
||||
const last = points[points.length - 1].split(",").map(Number);
|
||||
|
||||
return (
|
||||
<svg className={`pointer-events-none opacity-75 ${className}`} viewBox={`0 0 ${width} ${height}`} fill="none">
|
||||
<svg
|
||||
className={`pointer-events-none opacity-45 ${className}`}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline
|
||||
points={points.join(" ")}
|
||||
className={strokeClassName}
|
||||
@@ -737,8 +755,7 @@ function Sparkline({
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx={last[0]} cy={last[1]} r="4" className={glowClassName} />
|
||||
<circle cx={last[0]} cy={last[1]} r="7" className={`${glowClassName} opacity-20`} />
|
||||
<circle cx={last[0]} cy={last[1]} r="3.5" className={glowClassName} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -786,6 +803,26 @@ function directionName(direction: number | null) {
|
||||
return labels[index];
|
||||
}
|
||||
|
||||
function directionQuadrant(direction: number | null) {
|
||||
if (direction === null) return "--";
|
||||
if (direction >= 315 || direction < 45) return "Norte";
|
||||
if (direction >= 45 && direction < 135) return "Este";
|
||||
if (direction >= 135 && direction < 225) return "Sul";
|
||||
return "Oeste";
|
||||
}
|
||||
|
||||
function getTrend(values?: number[]) {
|
||||
if (!values || values.length < 2) return "Sem tendência";
|
||||
|
||||
const first = values[0];
|
||||
const last = values[values.length - 1];
|
||||
const delta = last - first;
|
||||
|
||||
if (Math.abs(delta) < 0.1) return "Estável";
|
||||
if (delta > 0) return "A subir";
|
||||
return "A descer";
|
||||
}
|
||||
|
||||
function temperatureBadge(sensor?: ModuleSensorResponse) {
|
||||
const value = numericValue(sensor);
|
||||
if (value === null) return "Sem dados";
|
||||
@@ -818,42 +855,74 @@ function radiationBadge(sensor?: ModuleSensorResponse) {
|
||||
return "Baixa";
|
||||
}
|
||||
|
||||
function accentColors(accent: "amber" | "blue" | "cyan" | "emerald") {
|
||||
function accentColors(accent: Accent, isDark: boolean) {
|
||||
switch (accent) {
|
||||
case "amber":
|
||||
return {
|
||||
text: "text-amber-300",
|
||||
stroke: "stroke-amber-400",
|
||||
dot: "fill-amber-400",
|
||||
badge: "border-amber-300/20 bg-amber-400/10",
|
||||
return isDark
|
||||
? {
|
||||
icon: "text-amber-200",
|
||||
stroke: "stroke-amber-200",
|
||||
dot: "fill-amber-200",
|
||||
iconBox: "border-white/10 bg-white/[0.03]",
|
||||
badge: "border-white/10 bg-white/[0.03] text-slate-300",
|
||||
}
|
||||
: {
|
||||
icon: "text-amber-700",
|
||||
stroke: "stroke-amber-500",
|
||||
dot: "fill-amber-500",
|
||||
iconBox: "border-slate-200 bg-slate-50",
|
||||
badge: "border-slate-200 bg-slate-50 text-slate-600",
|
||||
};
|
||||
|
||||
case "blue":
|
||||
return {
|
||||
text: "text-sky-300",
|
||||
stroke: "stroke-sky-400",
|
||||
dot: "fill-sky-400",
|
||||
badge: "border-sky-300/20 bg-sky-400/10",
|
||||
return isDark
|
||||
? {
|
||||
icon: "text-sky-200",
|
||||
stroke: "stroke-sky-200",
|
||||
dot: "fill-sky-200",
|
||||
iconBox: "border-white/10 bg-white/[0.03]",
|
||||
badge: "border-white/10 bg-white/[0.03] text-slate-300",
|
||||
}
|
||||
: {
|
||||
icon: "text-sky-700",
|
||||
stroke: "stroke-sky-500",
|
||||
dot: "fill-sky-500",
|
||||
iconBox: "border-slate-200 bg-slate-50",
|
||||
badge: "border-slate-200 bg-slate-50 text-slate-600",
|
||||
};
|
||||
|
||||
case "cyan":
|
||||
return {
|
||||
text: "text-cyan-300",
|
||||
stroke: "stroke-cyan-400",
|
||||
dot: "fill-cyan-400",
|
||||
badge: "border-cyan-300/20 bg-cyan-400/10",
|
||||
return isDark
|
||||
? {
|
||||
icon: "text-cyan-200",
|
||||
stroke: "stroke-cyan-200",
|
||||
dot: "fill-cyan-200",
|
||||
iconBox: "border-white/10 bg-white/[0.03]",
|
||||
badge: "border-white/10 bg-white/[0.03] text-slate-300",
|
||||
}
|
||||
: {
|
||||
icon: "text-cyan-700",
|
||||
stroke: "stroke-cyan-500",
|
||||
dot: "fill-cyan-500",
|
||||
iconBox: "border-slate-200 bg-slate-50",
|
||||
badge: "border-slate-200 bg-slate-50 text-slate-600",
|
||||
};
|
||||
|
||||
case "emerald":
|
||||
return {
|
||||
text: "text-emerald-300",
|
||||
stroke: "stroke-emerald-400",
|
||||
dot: "fill-emerald-400",
|
||||
badge: "border-emerald-300/20 bg-emerald-400/10",
|
||||
return isDark
|
||||
? {
|
||||
icon: "text-emerald-200",
|
||||
stroke: "stroke-emerald-200",
|
||||
dot: "fill-emerald-200",
|
||||
iconBox: "border-white/10 bg-white/[0.03]",
|
||||
badge: "border-white/10 bg-white/[0.03] text-slate-300",
|
||||
}
|
||||
: {
|
||||
icon: "text-emerald-700",
|
||||
stroke: "stroke-emerald-500",
|
||||
dot: "fill-emerald-500",
|
||||
iconBox: "border-slate-200 bg-slate-50",
|
||||
badge: "border-slate-200 bg-slate-50 text-slate-600",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string) {
|
||||
return new Date(timestamp).toLocaleTimeString("pt-PT", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
+18
-4
@@ -4,8 +4,14 @@ html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: #0f1720;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #0b1220;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -28,11 +34,19 @@ body {
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(56, 189, 248, 0.7), rgba(14, 165, 233, 0.35));
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(56, 189, 248, 0.7),
|
||||
rgba(14, 165, 233, 0.35)
|
||||
);
|
||||
border: 2px solid rgba(15, 23, 42, 0.9);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgba(56, 189, 248, 0.95), rgba(14, 165, 233, 0.55));
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(56, 189, 248, 0.95),
|
||||
rgba(14, 165, 233, 0.55)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export type WeatherForecastResponse = {
|
||||
location: WeatherLocation;
|
||||
current: WeatherCurrent;
|
||||
daily: WeatherDaily[];
|
||||
};
|
||||
|
||||
export type WeatherLocation = {
|
||||
name: string;
|
||||
region: string;
|
||||
country: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
localTime: string;
|
||||
};
|
||||
|
||||
export type WeatherCondition = {
|
||||
text: string;
|
||||
icon: string;
|
||||
code: number;
|
||||
};
|
||||
|
||||
export type WeatherCurrent = {
|
||||
temperatureC: number;
|
||||
feelsLikeC: number;
|
||||
humidity: number;
|
||||
precipitationMm: number;
|
||||
windKph: number;
|
||||
windDegree: number;
|
||||
windDirection: string;
|
||||
pressureMb: number;
|
||||
uv: number;
|
||||
condition: WeatherCondition;
|
||||
};
|
||||
|
||||
export type WeatherDaily = {
|
||||
date: string;
|
||||
maxTemperatureC: number;
|
||||
minTemperatureC: number;
|
||||
averageTemperatureC: number;
|
||||
totalPrecipitationMm: number;
|
||||
dailyRainChance: number;
|
||||
maxWindKph: number;
|
||||
uv: number;
|
||||
sunrise: string;
|
||||
sunset: string;
|
||||
condition: WeatherCondition;
|
||||
};
|
||||
Reference in New Issue
Block a user