finished meteorologia and main dashboard

This commit is contained in:
litoral05
2026-05-25 16:37:00 +01:00
parent b1bcf44f0f
commit d7ef36fc53
16 changed files with 2202 additions and 1167 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "litoralregas-frontend",
"title": "Litoral Central",
"width": 800,
"height": 600
}
+4 -1
View File
@@ -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

+76 -29
View File
@@ -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"
}`
}
>
{children({
theme,
snapshots: telemetry.snapshots,
})}
<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>
+68 -88
View File
@@ -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="flex items-center gap-2">
<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`
}
>
<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";
+289 -50
View File
@@ -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"
}
>
<img
src={logo}
alt="LitoralRegas"
className="h-12 w-12 shrink-0 object-contain"
/>
<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="Litoral Central"
className="h-12 w-12 scale-[1.35] object-cover"
/>
</div>
{!collapsed && (
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div
className={
isDark
? "truncate text-[16px] font-bold tracking-wide text-white"
: "truncate text-[16px] font-bold tracking-wide text-[#162434]"
? "truncate text-[16px] font-black tracking-[-0.02em] text-white"
: "truncate text-[16px] font-black tracking-[-0.02em] text-[#0F172A]"
}
>
LITORAL CENTRAL
Litoral Central
</div>
<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]"
}
>
OPERAÇÕES AGRÍCOLAS
<div className="mt-1 whitespace-nowrap text-[10px] font-bold uppercase tracking-[0.1em] text-slate-500">
Operações agrícolas
</div>
</div>
)}
@@ -94,67 +143,257 @@ 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>
</>
)}
</button>
</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";
}
+262 -451
View File
@@ -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"
/>
<div
className={
isDark
? "relative h-full overflow-hidden text-slate-100"
: "relative h-full overflow-hidden text-slate-950"
}
>
<img
src={backgroundImage}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
<MetricCard
title="Humidade Exterior"
value={formatNumber(meteo?.exteriorHumidity, 0)}
unit="%"
icon={Droplets}
theme={theme}
accent="cyan"
/>
<div
className={
isDark
? "absolute inset-0 bg-[#0B1220]/34"
: "absolute inset-0 bg-white/8"
}
/>
<MetricCard
title="Radiação Solar"
value={formatNumber(meteo?.radiation, 0)}
unit="W/m²"
icon={Sun}
theme={theme}
accent="yellow"
/>
<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%)]"
}
/>
<MetricCard
title="Velocidade do Vento"
value={formatNumber(meteo?.windSpeed, 0)}
unit="Km/h"
icon={Wind}
theme={theme}
accent="blue"
/>
<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>
<MetricCard
title="Chuva"
value={formatBoolean(meteo?.raining)}
unit=""
icon={CloudRain}
theme={theme}
accent="green"
/>
</section>
<div className="mt-7 h-[3px] w-12 bg-emerald-400" />
<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,
)}%`,
],
]}
/>
<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>
<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",
],
]}
/>
<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>
<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",
],
]}
/>
<WeatherForecastCard
compact
theme={theme}
forecast={weather.forecast}
loading={weather.loading}
error={weather.error}
onOpenMeteo={onOpenMeteo}
/>
</section>
<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 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."
/>
<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",
},
]}
/>
<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."
/>
<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>
<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="grid grid-cols-1 gap-5 xl:grid-cols-12">
<div
<section
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"
? `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="mb-5 flex items-center justify-between">
<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
className={
isDark
? "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"
}
>
<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>
<Activity
className={isDark ? "h-5 w-5 text-slate-400" : "h-5 w-5 text-slate-500"}
/>
<FarmIllustration theme={theme} />
</div>
</section>
<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"
}
>
<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>
</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>
</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`
}
>
<p
className={
isDark
? "text-sm font-semibold text-white"
: "text-sm font-semibold text-slate-950"
}
>
{title}
</p>
<p className={isDark ? "mt-1 text-sm text-slate-400" : "mt-1 text-sm text-slate-500"}>
{text}
</p>
<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
? "mt-2 max-w-[280px] text-sm leading-5 text-slate-400"
: "mt-2 max-w-[280px] text-sm leading-5 text-slate-600"
}
>
{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">
<X className="h-5 w-5" />
</button>
</div>
<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>
</header>
<main className="flex min-h-0 flex-1 flex-col px-6 pb-5">
<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"
}>
<section
className={
isDark
? `${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,
};
}
File diff suppressed because it is too large Load Diff
+19 -5
View File
@@ -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)
);
}
+47
View File
@@ -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;
};