finished meteorologia and main dashboard
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "litoralregas-frontend",
|
"title": "Litoral Central",
|
||||||
"width": 800,
|
"width": 800,
|
||||||
"height": 600
|
"height": 600
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -14,7 +14,10 @@ function App() {
|
|||||||
activePage === "meteo" ? (
|
activePage === "meteo" ? (
|
||||||
<MeteoPage theme={theme} />
|
<MeteoPage theme={theme} />
|
||||||
) : (
|
) : (
|
||||||
<DashboardPage theme={theme} />
|
<DashboardPage
|
||||||
|
theme={theme}
|
||||||
|
onOpenMeteo={() => setActivePage("meteo")}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</AppShell>
|
</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 { Sidebar } from "../navigation/Sidebar";
|
||||||
import { TopBar } from "./TopBar";
|
import { TopBar } from "../layout/TopBar";
|
||||||
import { BottomStatusBar } from "./BottomStatusBar";
|
|
||||||
import { useTelemetryStream } from "../../features/telemetry/hooks/useTelemetryStream";
|
import { useTelemetryStream } from "../../features/telemetry/hooks/useTelemetryStream";
|
||||||
import { useNotifications } from "../../features/notifications/hooks/useNotifications";
|
import { useNotifications } from "../../features/notifications/hooks/useNotifications";
|
||||||
import { useCurrentUser } from "../../features/auth/hooks/useCurrentUser";
|
import { useCurrentUser } from "../../features/auth/hooks/useCurrentUser";
|
||||||
import { useRuntimeConfig } from "../../features/system/hooks/useRuntimeConfig";
|
|
||||||
import type { TelemetrySnapshot } from "../../types/telemetry";
|
import type { TelemetrySnapshot } from "../../types/telemetry";
|
||||||
import type { AppPage } from "../../app/App";
|
import type { AppPage } from "../../app/App";
|
||||||
|
|
||||||
|
type Theme = "dark" | "light";
|
||||||
|
|
||||||
type AppShellRenderProps = {
|
type AppShellRenderProps = {
|
||||||
theme: "dark" | "light";
|
theme: Theme;
|
||||||
snapshots: TelemetrySnapshot[];
|
snapshots: TelemetrySnapshot[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -19,31 +19,71 @@ type AppShellProps = {
|
|||||||
onNavigate: (page: AppPage) => void;
|
onNavigate: (page: AppPage) => void;
|
||||||
children: (props: AppShellRenderProps) => ReactNode;
|
children: (props: AppShellRenderProps) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const THEME_STORAGE_KEY = "app-theme";
|
||||||
|
|
||||||
export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
|
export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
|
||||||
const telemetry = useTelemetryStream();
|
const telemetry = useTelemetryStream();
|
||||||
const notifications = useNotifications();
|
const notifications = useNotifications();
|
||||||
const currentUser = useCurrentUser();
|
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 = () => {
|
const toggleTheme = () => {
|
||||||
setTheme((current) => (current === "dark" ? "light" : "dark"));
|
setTheme((current) => (current === "dark" ? "light" : "dark"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDark = theme === "dark";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "h-screen overflow-hidden bg-[#0E1A24] text-[#EAF2FA]"
|
? "fixed inset-0 overflow-hidden bg-[#0b1220] text-slate-100"
|
||||||
: "h-screen overflow-hidden bg-[#F4F7FA] text-[#102030]"
|
: "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="flex h-full overflow-hidden">
|
||||||
<div className="relative h-full shrink-0 overflow-hidden">
|
<aside className="relative h-full shrink-0 overflow-hidden">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
theme={theme}
|
theme={theme}
|
||||||
activePage={activePage}
|
activePage={activePage}
|
||||||
@@ -51,8 +91,15 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
|
|||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
|
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">
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
<TopBar
|
<TopBar
|
||||||
@@ -68,23 +115,23 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
|
|||||||
<main
|
<main
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "custom-scrollbar min-h-0 flex-1 overflow-y-auto bg-[#0E1A24] px-6 py-5"
|
? `app-scrollbar min-h-0 flex-1 border-t border-white/10 bg-[#0b1220] ${activePage === "dashboard"
|
||||||
: "custom-scrollbar min-h-0 flex-1 overflow-y-auto bg-[#F4F7FA] px-6 py-5"
|
? "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({
|
{children({
|
||||||
theme,
|
theme,
|
||||||
snapshots: telemetry.snapshots,
|
snapshots: telemetry.snapshots,
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<BottomStatusBar
|
|
||||||
theme={theme}
|
|
||||||
backendPort={runtime.runtimeConfig?.backendPort.toString()}
|
|
||||||
mode={runtime.runtimeConfig?.mode}
|
|
||||||
controllerName={runtime.runtimeConfig?.controllerName}
|
|
||||||
controllerIp={runtime.runtimeConfig?.controllerIp}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ type TopBarProps = {
|
|||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RADIUS = "rounded-[5px]";
|
||||||
|
|
||||||
export function TopBar({
|
export function TopBar({
|
||||||
connected,
|
connected,
|
||||||
lastTimestamp,
|
lastTimestamp,
|
||||||
@@ -40,9 +42,7 @@ export function TopBar({
|
|||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
const ThemeIcon = isDark ? Moon : Sun;
|
const ThemeIcon = isDark ? Moon : Sun;
|
||||||
|
|
||||||
const systemDate = lastTimestamp
|
const systemDate = lastTimestamp ? new Date(lastTimestamp) : null;
|
||||||
? new Date(lastTimestamp)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const formattedTime = systemDate
|
const formattedTime = systemDate
|
||||||
? systemDate.toLocaleTimeString("pt-PT", {
|
? systemDate.toLocaleTimeString("pt-PT", {
|
||||||
@@ -61,39 +61,35 @@ export function TopBar({
|
|||||||
: "--/--/----";
|
: "--/--/----";
|
||||||
|
|
||||||
const dropdownClass = isDark
|
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 ${RADIUS} border border-white/[0.04] bg-[#111827] shadow-2xl`
|
||||||
: "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-slate-200 bg-white shadow-xl`;
|
||||||
|
|
||||||
const dropdownTitleClass = isDark
|
const dropdownTitleClass = isDark
|
||||||
? "text-sm font-semibold text-[#E4EDF6]"
|
? "text-sm font-bold text-slate-100"
|
||||||
: "text-sm font-semibold text-[#162434]";
|
: "text-sm font-bold text-slate-950";
|
||||||
|
|
||||||
const mutedTextClass = isDark
|
const mutedTextClass = "text-slate-500";
|
||||||
? "text-[#8FA3B8]"
|
|
||||||
: "text-[#607284]";
|
|
||||||
|
|
||||||
const menuItemClass = isDark
|
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 ${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 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-700 transition hover:bg-slate-100 hover:text-slate-950`;
|
||||||
|
|
||||||
const dividerClass = isDark
|
const dividerClass = isDark ? "my-2 h-px bg-white/10" : "my-2 h-px bg-slate-200";
|
||||||
? "my-2 h-px bg-[#24394A]"
|
|
||||||
: "my-2 h-px bg-[#D5DDE6]";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "flex h-16 items-center justify-between bg-[#0E1A24] px-6"
|
? "flex h-16 items-center justify-between bg-[#0b1220] px-6"
|
||||||
: "flex h-16 items-center justify-between bg-[#F4F7FA] px-6"
|
: "flex h-16 items-center justify-between border-b border-slate-200 bg-slate-50 px-6"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1
|
<h1
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "text-[28px] font-bold tracking-tight text-[#D4DEE8]"
|
? "text-2xl font-black tracking-[-0.04em] text-slate-100"
|
||||||
: "text-[28px] font-bold tracking-tight text-[#162434]"
|
: "text-2xl font-black tracking-[-0.04em] text-slate-950"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{pageTitle(activePage)}
|
{pageTitle(activePage)}
|
||||||
@@ -103,70 +99,57 @@ export function TopBar({
|
|||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "flex items-center gap-6 text-sm text-slate-300"
|
? "flex items-center gap-5 text-sm text-slate-400"
|
||||||
: "flex items-center gap-6 text-sm text-[#445569]"
|
: "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
|
<span
|
||||||
className={
|
className={
|
||||||
connected
|
connected
|
||||||
? "h-2.5 w-2.5 rounded-full bg-emerald-500"
|
? "h-2 w-2 rounded-full bg-emerald-500"
|
||||||
: "h-2.5 w-2.5 rounded-full bg-red-500"
|
: "h-2 w-2 rounded-full bg-red-500"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span>
|
<span className="font-medium">
|
||||||
{connected
|
{connected ? "Ligado ao sistema" : "Sistema desligado"}
|
||||||
? "Ligado ao sistema"
|
|
||||||
: "Sistema desligado"}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="hidden items-center gap-2 lg:flex">
|
||||||
<Clock
|
<Clock className="h-4 w-4 text-slate-500" />
|
||||||
className={
|
|
||||||
isDark
|
|
||||||
? "h-4 w-4 text-slate-400"
|
|
||||||
: "h-4 w-4 text-[#607284]"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>{formattedTime}</span>
|
<span>{formattedTime}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="hidden items-center gap-2 lg:flex">
|
||||||
<CalendarDays
|
<CalendarDays className="h-4 w-4 text-slate-500" />
|
||||||
className={
|
|
||||||
isDark
|
|
||||||
? "h-4 w-4 text-slate-400"
|
|
||||||
: "h-4 w-4 text-[#607284]"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>{formattedDate}</span>
|
<span>{formattedDate}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setNotificationsOpen(!notificationsOpen);
|
setNotificationsOpen(!notificationsOpen);
|
||||||
setUserMenuOpen(false);
|
setUserMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "relative flex h-9 w-9 items-center justify-center rounded-full transition-colors hover:bg-[#182531]"
|
? `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 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-slate-200 bg-white text-slate-500 transition hover:bg-slate-100 hover:text-slate-950`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Bell
|
<Bell className="h-5 w-5" />
|
||||||
className={
|
|
||||||
isDark
|
|
||||||
? "h-5 w-5 text-[#C8D3DF]"
|
|
||||||
: "h-5 w-5 text-[#445569]"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{notificationCount > 0 && (
|
{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}
|
{notificationCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -175,9 +158,7 @@ export function TopBar({
|
|||||||
{notificationsOpen && (
|
{notificationsOpen && (
|
||||||
<div className={`${dropdownClass} w-80 p-4`}>
|
<div className={`${dropdownClass} w-80 p-4`}>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<span className={dropdownTitleClass}>
|
<span className={dropdownTitleClass}>Notificações</span>
|
||||||
Notificações
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className={`text-xs ${mutedTextClass}`}>
|
<span className={`text-xs ${mutedTextClass}`}>
|
||||||
{notificationCount} novas
|
{notificationCount} novas
|
||||||
@@ -187,8 +168,8 @@ export function TopBar({
|
|||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "rounded-xl bg-[#132331] px-4 py-4 text-sm text-[#8FA3B8]"
|
? `${RADIUS} border border-white/[0.04] bg-white/[0.03] px-4 py-4 text-sm text-slate-400`
|
||||||
: "rounded-xl bg-[#EEF3F7] px-4 py-4 text-sm text-[#607284]"
|
: `${RADIUS} border border-slate-200 bg-slate-50 px-4 py-4 text-sm text-slate-500`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Sem notificações.
|
Sem notificações.
|
||||||
@@ -199,51 +180,48 @@ export function TopBar({
|
|||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUserMenuOpen(!userMenuOpen);
|
setUserMenuOpen(!userMenuOpen);
|
||||||
setNotificationsOpen(false);
|
setNotificationsOpen(false);
|
||||||
}}
|
}}
|
||||||
className={
|
className={
|
||||||
isDark
|
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 ${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 rounded-full bg-[#E2E8F0] py-1 pl-1 pr-3 transition-colors hover:bg-[#D5DDE6]"
|
: `flex items-center gap-2 ${RADIUS} border border-slate-200 bg-white py-1 pl-1 pr-3 transition hover:bg-slate-100`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
isDark
|
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-slate-200 text-xs font-black text-slate-950"
|
||||||
: "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-900 text-xs font-black text-white"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{userInitials}
|
{userInitials}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={`h-4 w-4 transition-transform ${isDark
|
className={`h-4 w-4 text-slate-500 transition-transform ${userMenuOpen ? "rotate-180" : ""
|
||||||
? "text-[#8FA3B8]"
|
}`}
|
||||||
: "text-[#607284]"
|
|
||||||
} ${userMenuOpen ? "rotate-180" : ""}`}
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{userMenuOpen && (
|
{userMenuOpen && (
|
||||||
<div className={`${dropdownClass} w-64 p-3`}>
|
<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
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
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-slate-200 text-sm font-black text-slate-950"
|
||||||
: "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-900 text-sm font-black text-white"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{userInitials}
|
{userInitials}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className={dropdownTitleClass}>
|
<div className={dropdownTitleClass}>admin</div>
|
||||||
admin
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`text-xs ${mutedTextClass}`}>
|
<div className={`text-xs ${mutedTextClass}`}>
|
||||||
Administrador
|
Administrador
|
||||||
@@ -253,22 +231,23 @@ export function TopBar({
|
|||||||
|
|
||||||
<div className={dividerClass} />
|
<div className={dividerClass} />
|
||||||
|
|
||||||
<button className={menuItemClass}>
|
<button type="button" className={menuItemClass}>
|
||||||
<User className={`h-4 w-4 ${mutedTextClass}`} />
|
<User className={`h-4 w-4 ${mutedTextClass}`} />
|
||||||
Perfil
|
Perfil
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button className={menuItemClass}>
|
<button type="button" className={menuItemClass}>
|
||||||
<SlidersHorizontal className={`h-4 w-4 ${mutedTextClass}`} />
|
<SlidersHorizontal className={`h-4 w-4 ${mutedTextClass}`} />
|
||||||
Preferências
|
Preferências
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onToggleTheme}
|
onClick={onToggleTheme}
|
||||||
className={
|
className={
|
||||||
isDark
|
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 ${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 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-700 transition hover:bg-slate-100 hover:text-slate-950`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-3">
|
<span className="flex items-center gap-3">
|
||||||
@@ -276,30 +255,31 @@ export function TopBar({
|
|||||||
Tema
|
Tema
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<ThemeIcon
|
<ThemeIcon className="h-4 w-4 text-slate-500" />
|
||||||
className={
|
|
||||||
isDark
|
|
||||||
? "h-4 w-4 text-[#8FA3B8]"
|
|
||||||
: "h-4 w-4 text-[#607284]"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className={dividerClass} />
|
<div className={dividerClass} />
|
||||||
|
|
||||||
<button className={menuItemClass}>
|
<button type="button" className={menuItemClass}>
|
||||||
<CircleHelp className={`h-4 w-4 ${mutedTextClass}`} />
|
<CircleHelp className={`h-4 w-4 ${mutedTextClass}`} />
|
||||||
Ajuda
|
Ajuda
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button className={menuItemClass}>
|
<button type="button" className={menuItemClass}>
|
||||||
<Info className={`h-4 w-4 ${mutedTextClass}`} />
|
<Info className={`h-4 w-4 ${mutedTextClass}`} />
|
||||||
Sobre
|
Sobre
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className={dividerClass} />
|
<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" />
|
<LogOut className="h-4 w-4" />
|
||||||
Terminar sessão
|
Terminar sessão
|
||||||
</button>
|
</button>
|
||||||
@@ -314,7 +294,7 @@ export function TopBar({
|
|||||||
function pageTitle(page: AppPage | null) {
|
function pageTitle(page: AppPage | null) {
|
||||||
switch (page) {
|
switch (page) {
|
||||||
case "dashboard":
|
case "dashboard":
|
||||||
return "Painel Principal";
|
return "";
|
||||||
|
|
||||||
case "meteo":
|
case "meteo":
|
||||||
return "Meteorologia";
|
return "Meteorologia";
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
BarChart3,
|
||||||
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CloudSun,
|
CloudSun,
|
||||||
Droplet,
|
Droplet,
|
||||||
|
Filter,
|
||||||
|
Gauge,
|
||||||
Home,
|
Home,
|
||||||
|
Lightbulb,
|
||||||
|
MonitorDot,
|
||||||
Settings,
|
Settings,
|
||||||
TabletSmartphone,
|
TabletSmartphone,
|
||||||
|
Waves,
|
||||||
Wind,
|
Wind,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -20,6 +28,8 @@ type SidebarProps = {
|
|||||||
onToggleCollapsed: () => void;
|
onToggleCollapsed: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RADIUS = "rounded-[10px]";
|
||||||
|
|
||||||
const navigationItems: {
|
const navigationItems: {
|
||||||
label: string;
|
label: string;
|
||||||
page: AppPage;
|
page: AppPage;
|
||||||
@@ -29,10 +39,24 @@ const navigationItems: {
|
|||||||
{ label: "Meteorologia", page: "meteo", icon: CloudSun },
|
{ 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: "Consola (VNC)", icon: TabletSmartphone },
|
||||||
{ label: "Rega", icon: Droplet },
|
|
||||||
{ label: "Clima", icon: Wind },
|
|
||||||
{ label: "Configurações", icon: Settings },
|
{ label: "Configurações", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -45,47 +69,72 @@ export function Sidebar({
|
|||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const isDark = theme === "dark";
|
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 (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={
|
className={
|
||||||
isDark
|
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-[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-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-[#D8DEE7] bg-[#F8FAFC] px-4 py-5 text-[#0F172A] shadow-[8px_0_28px_rgba(15,23,42,0.04)] transition-all duration-200`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
collapsed
|
collapsed
|
||||||
? "mb-10 flex items-center justify-center"
|
? "mb-12 flex items-center justify-center"
|
||||||
: "mb-10 flex items-center gap-3 px-2"
|
: "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
|
<img
|
||||||
src={logo}
|
src={logo}
|
||||||
alt="LitoralRegas"
|
alt="Litoral Central"
|
||||||
className="h-12 w-12 shrink-0 object-contain"
|
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>
|
</div>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "mt-0.5 text-[10px] uppercase tracking-[0.18em] text-[#8FA3B8]"
|
? "truncate text-[16px] font-black tracking-[-0.02em] text-white"
|
||||||
: "mt-0.5 text-[10px] uppercase tracking-[0.18em] text-[#607284]"
|
: "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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -94,63 +143,95 @@ export function Sidebar({
|
|||||||
<nav className="space-y-2">
|
<nav className="space-y-2">
|
||||||
{navigationItems.map((item) => {
|
{navigationItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const active = activePage === item.page;
|
const active = activePage === item.page && activeTreeItem === null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.label}
|
key={item.label}
|
||||||
onClick={() => onNavigate(item.page)}
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTreeItem(null);
|
||||||
|
onNavigate(item.page);
|
||||||
|
}}
|
||||||
title={collapsed ? item.label : undefined}
|
title={collapsed ? item.label : undefined}
|
||||||
className={
|
className={navButtonClass(isDark, active, collapsed)}
|
||||||
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]"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5 shrink-0" />
|
{active && <ActiveIndicator isDark={isDark} />}
|
||||||
{!collapsed && <span>{item.label}</span>}
|
|
||||||
|
<Icon className={navIconClass(isDark, active)} />
|
||||||
|
|
||||||
|
{!collapsed && <span className="truncate">{item.label}</span>}
|
||||||
</button>
|
</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 Icon = item.icon;
|
||||||
|
const key = `utility:${item.label}`;
|
||||||
|
const active = activeTreeItem === key;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.label}
|
key={item.label}
|
||||||
disabled
|
type="button"
|
||||||
|
onClick={() => handleTreeClick(key)}
|
||||||
title={collapsed ? item.label : undefined}
|
title={collapsed ? item.label : undefined}
|
||||||
className={
|
className={navButtonClass(isDark, active, collapsed)}
|
||||||
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"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5 shrink-0" />
|
{active && <ActiveIndicator isDark={isDark} />}
|
||||||
{!collapsed && <span>{item.label}</span>}
|
|
||||||
|
<Icon className={navIconClass(isDark, active)} />
|
||||||
|
|
||||||
|
{!collapsed && <span className="truncate">{item.label}</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onToggleCollapsed}
|
onClick={onToggleCollapsed}
|
||||||
className={
|
className={
|
||||||
isDark
|
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 ${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 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-[#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 ? (
|
{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>
|
<span>Recolher menu</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -158,3 +239,161 @@ export function Sidebar({
|
|||||||
</aside>
|
</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 {
|
import {
|
||||||
Activity,
|
ArrowRight,
|
||||||
CloudRain,
|
Cloud,
|
||||||
Droplets,
|
Leaf,
|
||||||
Fan,
|
MapPin,
|
||||||
Lightbulb,
|
ShieldCheck,
|
||||||
|
Sprout,
|
||||||
Sun,
|
Sun,
|
||||||
Thermometer,
|
Users,
|
||||||
Wind,
|
|
||||||
Zap,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { MetricCard } from "../../../components/cards/MetricCard";
|
import backgroundImage from "../../../assets/background.png";
|
||||||
import { DashboardTrendChart } from "../components/DashboardTrendChart";
|
import farmdrawImage from "../../../assets/farmdraw.png";
|
||||||
import { useDashboardOverviewStream } from "../hooks/useDashboardOverviewStream";
|
import farmdrawWhiteImage from "../../../assets/farm-draw.png";
|
||||||
import { useHistorianDashboard } from "../hooks/useHistorianDashboard";
|
import { WeatherForecastCard } from "../../meteo/components/WeatherForecastCard";
|
||||||
|
import { useWeatherForecast } from "../../meteo/hooks/useWeatherForecast";
|
||||||
|
|
||||||
type DashboardPageProps = {
|
type DashboardPageProps = {
|
||||||
theme: "dark" | "light";
|
theme: "dark" | "light";
|
||||||
|
onOpenMeteo: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const historianKeys = [
|
const RADIUS = "rounded-[5px]";
|
||||||
"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];
|
|
||||||
|
|
||||||
|
export function DashboardPage({ theme, onOpenMeteo }: DashboardPageProps) {
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
|
const weather = useWeatherForecast();
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
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)]"
|
? "relative h-full overflow-hidden text-slate-100"
|
||||||
: "xl:col-span-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"
|
: "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>
|
<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
|
<h2
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "text-base font-semibold text-white"
|
? "mt-4 max-w-[640px] text-[30px] font-black leading-tight tracking-[-0.045em] text-white"
|
||||||
: "text-base font-semibold text-slate-950"
|
: "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>
|
</h2>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
className={
|
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>
|
</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
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
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)]"
|
? "mt-6 grid grid-cols-3 gap-5 border-t border-white/10 pt-6"
|
||||||
: "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-slate-200 pt-6"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="mb-5">
|
<MissionItem
|
||||||
<h2
|
theme={theme}
|
||||||
className={
|
icon={<Sprout />}
|
||||||
isDark
|
title="Sustentabilidade"
|
||||||
? "text-base font-semibold text-white"
|
text="Compromisso com o futuro"
|
||||||
: "text-base font-semibold text-slate-950"
|
/>
|
||||||
}
|
<MissionItem
|
||||||
>
|
theme={theme}
|
||||||
Próximos Desenvolvimentos
|
icon={<ShieldCheck />}
|
||||||
</h2>
|
title="Confiabilidade"
|
||||||
<p
|
text="Tecnologia robusta e segura"
|
||||||
className={
|
/>
|
||||||
isDark ? "text-sm text-slate-400" : "text-sm text-slate-500"
|
<MissionItem
|
||||||
}
|
theme={theme}
|
||||||
>
|
icon={<Users />}
|
||||||
Espaço preparado para eventos, alarmes, programas e depósitos.
|
title="Apoio próximo"
|
||||||
</p>
|
text="Sempre ao seu lado"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<FarmIllustration theme={theme} />
|
||||||
<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>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type DashboardModuleCardProps = {
|
function InfoCard({
|
||||||
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({
|
|
||||||
theme,
|
theme,
|
||||||
|
icon,
|
||||||
|
iconClass,
|
||||||
title,
|
title,
|
||||||
icon: Icon,
|
text,
|
||||||
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 = {
|
|
||||||
theme: "dark" | "light";
|
theme: "dark" | "light";
|
||||||
|
icon: React.ReactNode;
|
||||||
|
iconClass: string;
|
||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text: string;
|
||||||
};
|
}) {
|
||||||
|
|
||||||
function PlaceholderPanel({ theme, title, text }: PlaceholderPanelProps) {
|
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<article
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "rounded-xl border border-white/10 bg-white/5 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`
|
||||||
: "rounded-xl border border-slate-200 bg-slate-50 p-4"
|
: `${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
|
<p
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "text-sm font-semibold text-white"
|
? "mt-2 max-w-[280px] text-sm leading-5 text-slate-400"
|
||||||
: "text-sm font-semibold text-slate-950"
|
: "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}
|
{text}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RADIUS = "rounded-[5px]";
|
||||||
|
|
||||||
const RANGE_OPTIONS: Array<{ label: string; value: AccumulatedRange }> = [
|
const RANGE_OPTIONS: Array<{ label: string; value: AccumulatedRange }> = [
|
||||||
{ label: "7D", value: "7d" },
|
{ label: "7D", value: "7d" },
|
||||||
{ label: "30D", value: "30d" },
|
{ label: "30D", value: "30d" },
|
||||||
@@ -44,6 +46,7 @@ export function AccumulatedHistoryModal({
|
|||||||
onClose,
|
onClose,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
|
const palette = accumulatedPalette(isDark);
|
||||||
const [mode, setMode] = useState<"chart" | "table">("chart");
|
const [mode, setMode] = useState<"chart" | "table">("chart");
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -62,26 +65,34 @@ export function AccumulatedHistoryModal({
|
|||||||
|
|
||||||
if (!sensor) return null;
|
if (!sensor) return null;
|
||||||
|
|
||||||
const unit = sensor.unit ?? buckets[0]?.unit ?? "";
|
const unit = buckets[0]?.unit ?? sensor.unit ?? "";
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "flex h-[82vh] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-[#24384d] bg-[#071120] text-white 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`
|
||||||
: "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-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">
|
<header className="flex items-start justify-between gap-5 px-6 py-4">
|
||||||
<div>
|
<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
|
Acumulado
|
||||||
</p>
|
</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}
|
Chave: meteo.{sensor.key}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,7 +100,11 @@ export function AccumulatedHistoryModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
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" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -99,8 +114,8 @@ export function AccumulatedHistoryModal({
|
|||||||
<section
|
<section
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "flex min-h-0 flex-1 flex-col rounded-xl border border-[#24384d] bg-[#0a1728] p-4"
|
? `${RADIUS} flex min-h-0 flex-1 flex-col border border-white/10 bg-[#0b1220] 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-slate-200 bg-slate-50 p-4`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
@@ -111,10 +126,8 @@ export function AccumulatedHistoryModal({
|
|||||||
onClick={() => onRangeChange(option.value)}
|
onClick={() => onRangeChange(option.value)}
|
||||||
className={
|
className={
|
||||||
range === option.value
|
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"
|
? activeButtonClass(isDark)
|
||||||
: isDark
|
: buttonClass(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"
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
@@ -142,7 +155,7 @@ export function AccumulatedHistoryModal({
|
|||||||
</div>
|
</div>
|
||||||
</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="Total" value={formatValue(stats.total, unit)} />
|
||||||
<StatCard theme={theme} label="Média" value={formatValue(stats.average, unit)} />
|
<StatCard theme={theme} label="Média" value={formatValue(stats.average, unit)} />
|
||||||
<StatCard theme={theme} label="Máximo" value={formatValue(stats.max, 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>
|
<EmptyState>Sem dados acumulados para este período.</EmptyState>
|
||||||
) : mode === "chart" ? (
|
) : mode === "chart" ? (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={buckets}>
|
<BarChart
|
||||||
|
data={buckets}
|
||||||
|
margin={{ top: 16, right: 18, bottom: 8, left: 0 }}
|
||||||
|
>
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
stroke="#1f3348"
|
stroke={palette.grid}
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="4 6"
|
||||||
vertical={false}
|
vertical={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
tick={{ fill: palette.axis, fontSize: 11 }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={{ stroke: "#334155" }}
|
axisLine={{ stroke: palette.axisLine }}
|
||||||
minTickGap={20}
|
minTickGap={20}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
tick={{ fill: palette.axis, fontSize: 11 }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={{ stroke: "#334155" }}
|
axisLine={{ stroke: palette.axisLine }}
|
||||||
width={50}
|
width={56}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ fill: "rgba(34, 211, 238, 0.08)" }}
|
cursor={{ fill: palette.cursor }}
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
background: "#111827",
|
background: palette.tooltipBg,
|
||||||
border: "1px solid #334155",
|
border: `1px solid ${palette.tooltipBorder}`,
|
||||||
borderRadius: "10px",
|
borderRadius: "5px",
|
||||||
color: "#fff",
|
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) => [
|
formatter={(value) => [
|
||||||
formatValue(Number(value), unit),
|
formatValue(Number(value), unit),
|
||||||
@@ -193,16 +212,28 @@ export function AccumulatedHistoryModal({
|
|||||||
|
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="total"
|
dataKey="total"
|
||||||
fill="#22d3ee"
|
fill={palette.bar}
|
||||||
radius={[6, 6, 0, 0]}
|
radius={[3, 3, 0, 0]}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</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">
|
<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>
|
<tr>
|
||||||
<th className="px-4 py-3">Período</th>
|
<th className="px-4 py-3">Período</th>
|
||||||
<th className="px-4 py-3">Início</th>
|
<th className="px-4 py-3">Início</th>
|
||||||
@@ -215,18 +246,31 @@ export function AccumulatedHistoryModal({
|
|||||||
{buckets.map((bucket) => (
|
{buckets.map((bucket) => (
|
||||||
<tr
|
<tr
|
||||||
key={`${bucket.from}-${bucket.to}`}
|
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">
|
<td className="px-4 py-3 font-semibold">
|
||||||
{bucket.label}
|
{bucket.label}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-slate-400">
|
|
||||||
|
<td className="px-4 py-3 text-slate-500">
|
||||||
{formatDate(bucket.from)}
|
{formatDate(bucket.from)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-slate-400">
|
|
||||||
|
<td className="px-4 py-3 text-slate-500">
|
||||||
{formatDate(bucket.to)}
|
{formatDate(bucket.to)}
|
||||||
</td>
|
</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)}
|
{formatValue(bucket.total, unit)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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 }) {
|
function EmptyState({ children }: { children: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-slate-400">
|
<div className="flex h-full items-center justify-center text-sm text-slate-400">
|
||||||
@@ -266,27 +334,47 @@ function StatCard({
|
|||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "rounded-xl border border-slate-700/60 bg-[#071120] p-3"
|
? `${RADIUS} border border-white/10 bg-[#111827] p-3`
|
||||||
: "rounded-xl border border-slate-200 bg-white p-3"
|
: `${RADIUS} border border-slate-200 bg-white p-3`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="text-xs text-slate-400">{label}</p>
|
<p className="text-xs text-slate-500">{label}</p>
|
||||||
<p className="mt-1 text-xl font-black">{value}</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleButtonClass(isDark: boolean, active: boolean) {
|
function buttonClass(isDark: boolean) {
|
||||||
if (active) {
|
return isDark
|
||||||
return "rounded-lg border border-cyan-400/40 bg-cyan-400/10 px-3 py-2 text-xs font-semibold text-cyan-300";
|
? `${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
|
return isDark
|
||||||
? "rounded-lg border border-slate-700 px-3 py-2 text-xs font-semibold text-slate-200 hover:bg-slate-800"
|
? `${RADIUS} border border-white/10 bg-slate-200 px-3 py-2 text-xs font-black text-slate-950`
|
||||||
: "rounded-lg border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-100";
|
: `${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) {
|
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}` : ""}`;
|
return `${value.toFixed(1)}${unit ? ` ${unit}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import type { ModuleSensorResponse } from "../../../types/meteo";
|
import type { ModuleSensorResponse } from "../../../types/meteo";
|
||||||
|
|
||||||
@@ -39,6 +39,8 @@ type Props = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RADIUS = "rounded-[5px]";
|
||||||
|
|
||||||
const RANGE_OPTIONS = [
|
const RANGE_OPTIONS = [
|
||||||
{ label: "15M", hours: 0.25 },
|
{ label: "15M", hours: 0.25 },
|
||||||
{ label: "30M", hours: 0.5 },
|
{ label: "30M", hours: 0.5 },
|
||||||
@@ -64,6 +66,7 @@ export function MeteoHistoryModal({
|
|||||||
onClose,
|
onClose,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
|
const palette = chartPalette(isDark);
|
||||||
|
|
||||||
const [intervalMinutes, setIntervalMinutes] = useState(5);
|
const [intervalMinutes, setIntervalMinutes] = useState(5);
|
||||||
const [showIndicators, setShowIndicators] = useState(true);
|
const [showIndicators, setShowIndicators] = useState(true);
|
||||||
@@ -71,7 +74,6 @@ export function MeteoHistoryModal({
|
|||||||
const [chartMode, setChartMode] = useState<"area" | "line">("area");
|
const [chartMode, setChartMode] = useState<"area" | "line">("area");
|
||||||
const [zeroBaseline, setZeroBaseline] = useState(false);
|
const [zeroBaseline, setZeroBaseline] = useState(false);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
const [intervalOpen, setIntervalOpen] = useState(false);
|
const [intervalOpen, setIntervalOpen] = useState(false);
|
||||||
|
|
||||||
const rawData = useMemo(
|
const rawData = useMemo(
|
||||||
@@ -80,11 +82,7 @@ export function MeteoHistoryModal({
|
|||||||
.map((point) => {
|
.map((point) => {
|
||||||
const value =
|
const value =
|
||||||
point.numericValue ??
|
point.numericValue ??
|
||||||
(point.booleanValue === null
|
(point.booleanValue === null ? null : point.booleanValue ? 1 : 0);
|
||||||
? null
|
|
||||||
: point.booleanValue
|
|
||||||
? 1
|
|
||||||
: 0);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
timestamp: point.timestamp,
|
timestamp: point.timestamp,
|
||||||
@@ -92,8 +90,14 @@ export function MeteoHistoryModal({
|
|||||||
value,
|
value,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((point): point is { timestamp: string; date: Date; value: number } =>
|
.filter(
|
||||||
typeof point.value === "number",
|
(
|
||||||
|
point,
|
||||||
|
): point is {
|
||||||
|
timestamp: string;
|
||||||
|
date: Date;
|
||||||
|
value: number;
|
||||||
|
} => typeof point.value === "number",
|
||||||
),
|
),
|
||||||
[points],
|
[points],
|
||||||
);
|
);
|
||||||
@@ -101,7 +105,11 @@ export function MeteoHistoryModal({
|
|||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
if (rawData.length === 0) return [];
|
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;
|
const intervalMs = intervalMinutes * 60 * 1000;
|
||||||
|
|
||||||
for (const point of rawData) {
|
for (const point of rawData) {
|
||||||
@@ -177,43 +185,63 @@ export function MeteoHistoryModal({
|
|||||||
if (!sensor) return null;
|
if (!sensor) return null;
|
||||||
|
|
||||||
const unit = sensor.unit ?? "";
|
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;
|
const Chart = chartMode === "area" ? AreaChart : LineChart;
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
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"} ${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"} 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-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">
|
<header className="flex items-start justify-between gap-5 px-6 py-4">
|
||||||
<div>
|
<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
|
Histórico
|
||||||
</p>
|
</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}
|
Chave: meteo.{sensor.key}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<button
|
||||||
<button type="button" onClick={onClose} className="p-2 text-slate-400 hover:text-white">
|
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" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex min-h-0 flex-1 flex-col px-6 pb-5">
|
<main className="flex min-h-0 flex-1 flex-col px-6 pb-5">
|
||||||
<section className={
|
<section
|
||||||
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "flex min-h-0 flex-1 flex-col rounded-xl border border-[#24384d] bg-[#0a1728] p-4"
|
? `${RADIUS} flex min-h-0 flex-1 flex-col border border-white/10 bg-[#0b1220] 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-slate-200 bg-slate-50 p-4`
|
||||||
}>
|
}
|
||||||
|
>
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
{RANGE_OPTIONS.map((range) => (
|
{RANGE_OPTIONS.map((range) => (
|
||||||
<button
|
<button
|
||||||
@@ -222,10 +250,8 @@ export function MeteoHistoryModal({
|
|||||||
onClick={() => onHoursChange(range.hours)}
|
onClick={() => onHoursChange(range.hours)}
|
||||||
className={
|
className={
|
||||||
hours === range.hours
|
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"
|
? activeButtonClass(isDark)
|
||||||
: isDark
|
: buttonClass(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"
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{range.label}
|
{range.label}
|
||||||
@@ -236,16 +262,12 @@ export function MeteoHistoryModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIntervalOpen((value) => !value)}
|
onClick={() => setIntervalOpen((value) => !value)}
|
||||||
className={
|
className={buttonClass(isDark)}
|
||||||
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"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Intervalo: {intervalMinutes}m
|
Intervalo: {intervalMinutes}m
|
||||||
|
|
||||||
<svg
|
<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"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
@@ -261,8 +283,8 @@ export function MeteoHistoryModal({
|
|||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
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 ${RADIUS} border border-white/10 bg-[#111827] 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-slate-200 bg-white shadow-xl`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{INTERVAL_OPTIONS.map((option) => (
|
{INTERVAL_OPTIONS.map((option) => (
|
||||||
@@ -275,22 +297,30 @@ export function MeteoHistoryModal({
|
|||||||
}}
|
}}
|
||||||
className={
|
className={
|
||||||
intervalMinutes === option
|
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
|
: 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-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-100"
|
: "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>
|
<span>{option} minutos</span>
|
||||||
|
|
||||||
{intervalMinutes === option && (
|
{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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowCompareLine((value) => !value)}
|
onClick={() => setShowCompareLine((value) => !value)}
|
||||||
@@ -310,15 +340,21 @@ export function MeteoHistoryModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-3 flex flex-wrap items-center gap-6 text-xs">
|
<div className="mb-3 flex flex-wrap items-center gap-6 text-xs">
|
||||||
<span className="font-semibold">
|
<span className={isDark ? "font-semibold text-slate-100" : "font-semibold text-slate-950"}>
|
||||||
<span className="mr-2 inline-block h-2.5 w-2.5 rounded-full bg-cyan-400" />
|
<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}
|
{sensor.name}
|
||||||
{unit && ` (${unit})`}
|
{unit && ` (${unit})`}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span>Média: <b>{formatValue(stats.average, unit)}</b></span>
|
<span className={isDark ? "text-slate-400" : "text-slate-500"}>
|
||||||
<span>Máx: <b>{formatValue(stats.max, unit)}</b></span>
|
Média: <b>{formatValue(stats.average, unit)}</b>
|
||||||
<span>Mín: <b>{formatValue(stats.min, unit)}</b></span>
|
</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">
|
<div className="ml-auto flex gap-2">
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -342,8 +378,7 @@ export function MeteoHistoryModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={expanded ? "min-h-0 flex-1" : "h-[300px]"}
|
<div className={expanded ? "min-h-0 flex-1" : "h-[300px]"}>
|
||||||
>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<EmptyState>A carregar histórico...</EmptyState>
|
<EmptyState>A carregar histórico...</EmptyState>
|
||||||
) : chartData.length === 0 ? (
|
) : chartData.length === 0 ? (
|
||||||
@@ -353,32 +388,37 @@ export function MeteoHistoryModal({
|
|||||||
<Chart data={chartData}>
|
<Chart data={chartData}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="historyFill" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="historyFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#22d3ee" stopOpacity={0.34} />
|
<stop offset="0%" stopColor={palette.line} stopOpacity={isDark ? 0.22 : 0.12} />
|
||||||
<stop offset="95%" stopColor="#22d3ee" stopOpacity={0.03} />
|
<stop offset="70%" stopColor={palette.line} stopOpacity={isDark ? 0.08 : 0.04} />
|
||||||
|
<stop offset="100%" stopColor={palette.line} stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<CartesianGrid stroke="#1f3348" strokeDasharray="3 3" vertical={false} />
|
<CartesianGrid
|
||||||
|
stroke={palette.grid}
|
||||||
|
strokeDasharray="4 6"
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="time"
|
dataKey="time"
|
||||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
tick={{ fill: palette.axis, fontSize: 11 }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={{ stroke: "#334155" }}
|
axisLine={{ stroke: palette.axisLine }}
|
||||||
minTickGap={42}
|
minTickGap={42}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<YAxis
|
<YAxis
|
||||||
domain={yDomain}
|
domain={yDomain}
|
||||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
tick={{ fill: palette.axis, fontSize: 11 }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={{ stroke: "#334155" }}
|
axisLine={{ stroke: palette.axisLine }}
|
||||||
width={44}
|
width={44}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{
|
cursor={{
|
||||||
stroke: "#94a3b8",
|
stroke: palette.cursor,
|
||||||
strokeWidth: 1,
|
strokeWidth: 1,
|
||||||
}}
|
}}
|
||||||
content={({ active, payload, label }) => {
|
content={({ active, payload, label }) => {
|
||||||
@@ -387,43 +427,52 @@ export function MeteoHistoryModal({
|
|||||||
const value = Number(payload[0].value);
|
const value = Number(payload[0].value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-700 bg-[#111827] px-4 py-3 text-sm shadow-2xl">
|
<div
|
||||||
<p className="mb-2 text-xs text-slate-400">{label}</p>
|
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)}
|
Atual: {formatValue(value, unit)}
|
||||||
</p>
|
</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)}
|
Média: {formatValue(stats.average, unit)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-slate-300">
|
<p className={isDark ? "text-slate-400" : "text-slate-500"}>
|
||||||
Máx: {formatValue(stats.max, unit)}
|
Máx: {formatValue(stats.max, unit)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-slate-300">
|
<p className={isDark ? "text-slate-400" : "text-slate-500"}>
|
||||||
Mín: {formatValue(stats.min, unit)}
|
Mín: {formatValue(stats.min, unit)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showIndicators && stats.average !== null && (
|
{showIndicators && stats.average !== null && (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
y={stats.average}
|
y={stats.average}
|
||||||
stroke="#22d3ee"
|
stroke={palette.reference}
|
||||||
strokeDasharray="4 4"
|
strokeDasharray="4 5"
|
||||||
strokeOpacity={0.45}
|
strokeOpacity={0.42}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showCompareLine && stats.current !== null && (
|
{showCompareLine && stats.current !== null && (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
y={stats.current}
|
y={stats.current}
|
||||||
stroke="#a78bfa"
|
stroke={palette.compare}
|
||||||
strokeDasharray="5 5"
|
strokeDasharray="5 5"
|
||||||
strokeOpacity={0.55}
|
strokeOpacity={0.38}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -431,14 +480,14 @@ export function MeteoHistoryModal({
|
|||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
stroke="#22d3ee"
|
stroke={palette.line}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="url(#historyFill)"
|
fill="url(#historyFill)"
|
||||||
dot={false}
|
dot={false}
|
||||||
activeDot={{
|
activeDot={{
|
||||||
r: 5,
|
r: 5,
|
||||||
fill: "#22d3ee",
|
fill: palette.line,
|
||||||
stroke: "#071120",
|
stroke: isDark ? "#111827" : "#ffffff",
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
}}
|
}}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
@@ -447,13 +496,13 @@ export function MeteoHistoryModal({
|
|||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
stroke="#22d3ee"
|
stroke={palette.line}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={false}
|
||||||
activeDot={{
|
activeDot={{
|
||||||
r: 5,
|
r: 5,
|
||||||
fill: "#22d3ee",
|
fill: palette.line,
|
||||||
stroke: "#071120",
|
stroke: isDark ? "#111827" : "#ffffff",
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
}}
|
}}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
@@ -466,23 +515,31 @@ export function MeteoHistoryModal({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{showIndicators && (
|
{showIndicators && (
|
||||||
<section
|
<section className="mt-3 grid shrink-0 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
className="mt-3 grid shrink-0 grid-cols-5 gap-3"
|
|
||||||
>
|
|
||||||
<MetricCard theme={theme} title="Valor atual" value={formatValue(stats.current, unit)} sub="Agora há pouco" />
|
<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="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á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" />
|
<MetricCard theme={theme} title="Mínima" value={formatValue(stats.min, unit)} sub="No período" />
|
||||||
|
|
||||||
<div className={cardClass(isDark)}>
|
<div className={cardClass(isDark)}>
|
||||||
<p className="text-xs text-slate-400">Volume de Dados</p>
|
<p className="text-xs text-slate-500">
|
||||||
<h3 className="mt-1 text-xl font-black">{stats.count.toLocaleString("pt-PT")}</h3>
|
Volume de Dados
|
||||||
<p className="text-xs text-slate-400">pontos</p>
|
</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">
|
<div className="mt-2 h-9">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={miniBars}>
|
<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>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</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 }) {
|
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({
|
function IconButton({
|
||||||
@@ -522,7 +605,6 @@ function MetricCard({
|
|||||||
title,
|
title,
|
||||||
value,
|
value,
|
||||||
sub,
|
sub,
|
||||||
positive,
|
|
||||||
}: {
|
}: {
|
||||||
theme: "dark" | "light";
|
theme: "dark" | "light";
|
||||||
title: string;
|
title: string;
|
||||||
@@ -534,36 +616,41 @@ function MetricCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cardClass(isDark)}>
|
<div className={cardClass(isDark)}>
|
||||||
<p className="text-xs text-slate-400">{title}</p>
|
<p className="text-xs text-slate-500">{title}</p>
|
||||||
<h3 className={`mt-1 text-xl font-black ${positive ? "text-emerald-400" : ""}`}>{value}</h3>
|
<h3 className="mt-1 text-xl font-black">{value}</h3>
|
||||||
<p className="text-xs text-slate-400">{sub}</p>
|
<p className="text-xs text-slate-500">{sub}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buttonClass(isDark: boolean) {
|
function buttonClass(isDark: boolean) {
|
||||||
return isDark
|
return isDark
|
||||||
? "rounded-lg border border-slate-700 px-3 py-2 text-xs font-semibold text-slate-200 hover:bg-slate-800"
|
? `${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`
|
||||||
: "rounded-lg border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-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) {
|
function iconButtonClass(isDark: boolean) {
|
||||||
return isDark
|
return isDark
|
||||||
? "rounded-lg border border-slate-700 p-2 text-slate-300 hover:bg-slate-800"
|
? `${RADIUS} border border-white/10 bg-white/[0.03] p-2 text-slate-400 transition hover:bg-white/[0.06] hover:text-slate-100`
|
||||||
: "rounded-lg border border-slate-200 p-2 text-slate-600 hover:bg-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) {
|
function toggleClass(isDark: boolean, active: boolean) {
|
||||||
if (active) {
|
if (active) return activeButtonClass(isDark);
|
||||||
return "rounded-lg border border-cyan-400/40 bg-cyan-400/10 px-3 py-2 text-xs font-semibold text-cyan-300";
|
|
||||||
}
|
|
||||||
|
|
||||||
return buttonClass(isDark);
|
return buttonClass(isDark);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleIconClass(isDark: boolean, active: boolean) {
|
function toggleIconClass(isDark: boolean, active: boolean) {
|
||||||
if (active) {
|
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);
|
return iconButtonClass(isDark);
|
||||||
@@ -571,8 +658,8 @@ function toggleIconClass(isDark: boolean, active: boolean) {
|
|||||||
|
|
||||||
function cardClass(isDark: boolean) {
|
function cardClass(isDark: boolean) {
|
||||||
return isDark
|
return isDark
|
||||||
? "rounded-xl border border-slate-700/60 bg-[#0a1728] p-3"
|
? `${RADIUS} border border-white/10 bg-[#111827] p-3`
|
||||||
: "rounded-xl border border-slate-200 bg-slate-50 p-3";
|
: `${RADIUS} border border-slate-200 bg-white p-3`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatValue(value: number | null, unit: string) {
|
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 { useEffect, useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
|
Activity,
|
||||||
ChartNoAxesColumnIncreasing,
|
ChartNoAxesColumnIncreasing,
|
||||||
Table2,
|
|
||||||
CloudRain,
|
CloudRain,
|
||||||
Droplets,
|
Droplets,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Radio,
|
|
||||||
Sun,
|
Sun,
|
||||||
|
Table2,
|
||||||
Thermometer,
|
Thermometer,
|
||||||
|
TrendingUp,
|
||||||
Wind,
|
Wind,
|
||||||
Wifi,
|
|
||||||
ChevronRight,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMeteoModuleStream } from "../hooks/useMeteoModuleStream";
|
import { useMeteoModuleStream } from "../hooks/useMeteoModuleStream";
|
||||||
import { MeteoHistoryModal } from "../components/MeteoHistoryModal";
|
import { MeteoHistoryModal } from "../components/MeteoHistoryModal";
|
||||||
@@ -21,25 +20,36 @@ import {
|
|||||||
useAccumulatedHistory,
|
useAccumulatedHistory,
|
||||||
type AccumulatedRange,
|
type AccumulatedRange,
|
||||||
} from "../hooks/useAccumulatedHistory";
|
} from "../hooks/useAccumulatedHistory";
|
||||||
|
import { useWeatherForecast } from "../hooks/useWeatherForecast";
|
||||||
|
import { WeatherForecastCard } from "../components/WeatherForecastCard";
|
||||||
|
|
||||||
type MeteoPageProps = {
|
type MeteoPageProps = {
|
||||||
theme: "dark" | "light";
|
theme: "dark" | "light";
|
||||||
};
|
};
|
||||||
|
|
||||||
type HistoryMap = Record<string, number[]>;
|
type HistoryMap = Record<string, number[]>;
|
||||||
|
type Accent = "amber" | "blue" | "cyan" | "emerald";
|
||||||
|
|
||||||
const MAX_HISTORY_POINTS = 34;
|
const MAX_HISTORY_POINTS = 34;
|
||||||
|
const RADIUS = "rounded-[5px]";
|
||||||
|
|
||||||
export function MeteoPage({ theme }: MeteoPageProps) {
|
export function MeteoPage({ theme }: MeteoPageProps) {
|
||||||
const { sensors, sensorCount, connected, lastTimestamp } =
|
const { sensors } = useMeteoModuleStream();
|
||||||
useMeteoModuleStream();
|
|
||||||
|
|
||||||
const [selectedSensor, setSelectedSensor] =
|
const [selectedSensor, setSelectedSensor] =
|
||||||
useState<ModuleSensorResponse | null>(null);
|
useState<ModuleSensorResponse | null>(null);
|
||||||
|
|
||||||
const [history, setHistory] = useState<HistoryMap>({});
|
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 temperature = findSensor(sensors, "temperatura.exterior");
|
||||||
const humidity = findSensor(sensors, "humidade.exterior");
|
const humidity = findSensor(sensors, "humidade.exterior");
|
||||||
@@ -59,26 +69,19 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
|||||||
findSensor(sensors, "chuva.instantanea") ??
|
findSensor(sensors, "chuva.instantanea") ??
|
||||||
findSensor(sensors, "chuva.intensidade") ??
|
findSensor(sensors, "chuva.intensidade") ??
|
||||||
maxSensor(rainSensors);
|
maxSensor(rainSensors);
|
||||||
|
|
||||||
const rainValue = numericValue(rainSensor);
|
const rainValue = numericValue(rainSensor);
|
||||||
const isRaining = rainValue !== null && rainValue > 0;
|
const isRaining = rainValue !== null && rainValue > 0;
|
||||||
|
|
||||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const meteoHistory = useMeteoHistory(selectedSensor);
|
const meteoHistory = useMeteoHistory(selectedSensor);
|
||||||
|
|
||||||
const [selectedAccumulated, setSelectedAccumulated] = useState<{
|
|
||||||
title: string;
|
|
||||||
sensor: ModuleSensorResponse | null;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const [accumulatedRange, setAccumulatedRange] =
|
|
||||||
useState<AccumulatedRange>("7d");
|
|
||||||
|
|
||||||
const accumulatedHistory = useAccumulatedHistory(
|
const accumulatedHistory = useAccumulatedHistory(
|
||||||
selectedAccumulated?.sensor ?? null,
|
selectedAccumulated?.sensor ?? null,
|
||||||
accumulatedRange,
|
accumulatedRange,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const weatherForecast = useWeatherForecast();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const samples: Array<[string, number | null]> = [
|
const samples: Array<[string, number | null]> = [
|
||||||
["temperatura.exterior", numericValue(temperature)],
|
["temperatura.exterior", numericValue(temperature)],
|
||||||
@@ -113,17 +116,16 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-5 pb-6">
|
||||||
<div>
|
<WeatherForecastCard
|
||||||
<section
|
theme={theme}
|
||||||
className={
|
forecast={weatherForecast.forecast}
|
||||||
isDark
|
loading={weatherForecast.loading}
|
||||||
? "rounded-[30px] border border-white/10 bg-[#071827] p-4 shadow-[0_24px_70px_rgba(0,0,0,0.28)]"
|
error={weatherForecast.error}
|
||||||
: "rounded-[30px] border border-slate-200 bg-slate-50 p-4 shadow-sm"
|
/>
|
||||||
}
|
|
||||||
>
|
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_420px_minmax(0,1fr)]">
|
||||||
<div className="grid gap-4 xl:grid-cols-[1fr_372px_1fr]">
|
<div className="grid gap-5 md:grid-cols-2 2xl:grid-cols-1">
|
||||||
<div className="grid gap-4">
|
|
||||||
<MetricTile
|
<MetricTile
|
||||||
theme={theme}
|
theme={theme}
|
||||||
title="Temperatura"
|
title="Temperatura"
|
||||||
@@ -134,7 +136,9 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
|||||||
status={temperatureBadge(temperature)}
|
status={temperatureBadge(temperature)}
|
||||||
values={history["temperatura.exterior"]}
|
values={history["temperatura.exterior"]}
|
||||||
menuOpen={openMenu === "temperature"}
|
menuOpen={openMenu === "temperature"}
|
||||||
onMenuToggle={() => setOpenMenu(openMenu === "temperature" ? null : "temperature")}
|
onMenuToggle={() =>
|
||||||
|
setOpenMenu(openMenu === "temperature" ? null : "temperature")
|
||||||
|
}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
label: "Ver gráfico",
|
label: "Ver gráfico",
|
||||||
@@ -202,7 +206,6 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
|||||||
title: "Precipitação acumulada",
|
title: "Precipitação acumulada",
|
||||||
sensor: rainSensor ?? null,
|
sensor: rainSensor ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -210,12 +213,9 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CompassPanel
|
<CompassPanel theme={theme} direction={numericValue(windDirection)} />
|
||||||
theme={theme}
|
|
||||||
direction={numericValue(windDirection)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-1">
|
||||||
<MetricTile
|
<MetricTile
|
||||||
theme={theme}
|
theme={theme}
|
||||||
title="Vento"
|
title="Vento"
|
||||||
@@ -271,23 +271,13 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
|||||||
title: "Radiação solar acumulada",
|
title: "Radiação solar acumulada",
|
||||||
sensor: radiation ?? null,
|
sensor: radiation ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatusTile
|
|
||||||
theme={theme}
|
|
||||||
connected={connected}
|
|
||||||
sensorCount={sensorCount}
|
|
||||||
lastTimestamp={lastTimestamp}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MeteoHistoryModal
|
<MeteoHistoryModal
|
||||||
sensor={selectedSensor}
|
sensor={selectedSensor}
|
||||||
@@ -309,7 +299,7 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
|||||||
onRangeChange={setAccumulatedRange}
|
onRangeChange={setAccumulatedRange}
|
||||||
onClose={() => setSelectedAccumulated(null)}
|
onClose={() => setSelectedAccumulated(null)}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,7 +325,7 @@ function MetricTile({
|
|||||||
customValue?: string;
|
customValue?: string;
|
||||||
customUnit?: string;
|
customUnit?: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
accent: "amber" | "blue" | "cyan" | "emerald";
|
accent: Accent;
|
||||||
status: string;
|
status: string;
|
||||||
values?: number[];
|
values?: number[];
|
||||||
menuOpen: boolean;
|
menuOpen: boolean;
|
||||||
@@ -347,25 +337,23 @@ function MetricTile({
|
|||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
const colors = accentColors(accent);
|
const colors = accentColors(accent, isDark);
|
||||||
const value = customValue ?? formatValue(sensor);
|
const value = customValue ?? formatValue(sensor);
|
||||||
const unit = customUnit ?? sensor?.unit;
|
const unit = customUnit ?? sensor?.unit;
|
||||||
|
const trend = getTrend(values);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<article
|
||||||
className={
|
className={
|
||||||
isDark
|
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]"
|
? `${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]`
|
||||||
: "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-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="flex items-start gap-4">
|
||||||
<div
|
<div
|
||||||
className={`grid h-12 w-12 place-items-center rounded-2xl border ${isDark
|
className={`grid h-11 w-11 shrink-0 place-items-center ${RADIUS} border ${colors.iconBox} ${colors.icon}`}
|
||||||
? "border-[#263e56] bg-[#132b43]"
|
|
||||||
: "border-slate-200 bg-slate-50"
|
|
||||||
} ${colors.text}`}
|
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
@@ -374,8 +362,8 @@ function MetricTile({
|
|||||||
<h2
|
<h2
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "text-[15px] font-bold text-white"
|
? "text-[15px] font-bold tracking-[-0.01em] text-slate-100"
|
||||||
: "text-[15px] font-bold text-slate-950"
|
: "text-[15px] font-bold tracking-[-0.01em] text-slate-950"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -392,7 +380,7 @@ function MetricTile({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
@@ -401,9 +389,10 @@ function MetricTile({
|
|||||||
}}
|
}}
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "rounded-lg p-1 text-slate-300 transition hover:bg-white/10 hover:text-white"
|
? `grid h-9 w-9 place-items-center ${RADIUS} text-slate-400 transition hover:bg-white/5 hover:text-slate-100`
|
||||||
: "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-slate-100 hover:text-slate-700`
|
||||||
}
|
}
|
||||||
|
aria-label={`Abrir ações de ${title}`}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-5 w-5" />
|
<MoreHorizontal className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -412,8 +401,8 @@ function MetricTile({
|
|||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
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-11 z-50 w-48 ${RADIUS} border border-white/10 bg-[#0f172a] p-1.5 shadow-xl`
|
||||||
: "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-slate-200 bg-white p-1.5 shadow-xl`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{actions.map((action) => (
|
{actions.map((action) => (
|
||||||
@@ -426,8 +415,8 @@ function MetricTile({
|
|||||||
}}
|
}}
|
||||||
className={
|
className={
|
||||||
isDark
|
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 ${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 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-700 transition hover:bg-slate-100 hover:text-slate-950`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{action.icon}
|
{action.icon}
|
||||||
@@ -439,18 +428,23 @@ function MetricTile({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<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}
|
{value}
|
||||||
{unit && (
|
{unit && (
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "ml-2 text-sm font-black tracking-normal text-white"
|
? "mb-1 ml-2 text-base font-bold tracking-[-0.02em] text-slate-400"
|
||||||
: "ml-2 text-sm font-black tracking-normal text-slate-800"
|
: "mb-1 ml-2 text-base font-bold tracking-[-0.02em] text-slate-500"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{unit}
|
{unit}
|
||||||
@@ -458,24 +452,35 @@ function MetricTile({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-wrap items-center gap-2">
|
||||||
<span
|
<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
|
className={`inline-flex items-center justify-center ${RADIUS} border px-3 py-1.5 text-xs font-bold ${colors.badge}`}
|
||||||
? `${colors.badge} ${colors.text}`
|
|
||||||
: "border-slate-200 bg-slate-50 text-slate-700"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{status}
|
{status}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Sparkline
|
<Sparkline
|
||||||
values={values}
|
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}
|
strokeClassName={colors.stroke}
|
||||||
glowClassName={colors.dot}
|
glowClassName={colors.dot}
|
||||||
/>
|
/>
|
||||||
</div>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,48 +497,75 @@ function CompassPanel({
|
|||||||
const degrees = direction !== null ? Math.round(direction) : null;
|
const degrees = direction !== null ? Math.round(direction) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<article
|
||||||
className={
|
className={
|
||||||
isDark
|
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)]"
|
? `${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)]`
|
||||||
: "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-slate-200 bg-white p-6 shadow-[0_10px_26px_rgba(15,23,42,0.06)]`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center">
|
<div className="relative z-10 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"}>
|
<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
|
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}
|
{cardinal}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="mt-5 grid w-full grid-cols-2 gap-3">
|
<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"}>
|
<CompassStat
|
||||||
<p className="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">
|
theme={theme}
|
||||||
Graus
|
label="Graus"
|
||||||
</p>
|
value={degrees !== null ? `${degrees}°` : "--"}
|
||||||
<p className={isDark ? "mt-1 text-[20px] font-black text-white" : "mt-1 text-[20px] font-black text-slate-950"}>
|
/>
|
||||||
{degrees !== null ? `${degrees}°` : "--"}
|
<CompassStat
|
||||||
</p>
|
theme={theme}
|
||||||
</div>
|
label="Quadrante"
|
||||||
|
value={directionQuadrant(direction)}
|
||||||
<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"}>
|
highlighted
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 items-center justify-center pt-2">
|
<div className="relative z-10 flex flex-1 items-center justify-center pt-6">
|
||||||
<div className="relative h-[320px] w-[320px]">
|
<div className="relative h-[340px] w-[340px] max-w-full">
|
||||||
<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
|
||||||
<div className={isDark ? "absolute inset-5 rounded-full border border-[#213b55]" : "absolute inset-5 rounded-full border border-slate-200"} />
|
className={
|
||||||
<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"} />
|
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) => {
|
{Array.from({ length: 72 }).map((_, index) => {
|
||||||
const major = index % 6 === 0;
|
const major = index % 6 === 0;
|
||||||
@@ -541,58 +573,120 @@ function CompassPanel({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className={`absolute left-1/2 top-1/2 w-px origin-[50%_149px] ${major ? "h-3" : "h-1.5"
|
className={`absolute left-1/2 top-1/2 w-px origin-[50%_158px] ${major ? "h-4" : "h-2"
|
||||||
} ${isDark ? "bg-slate-500/50" : "bg-slate-400/60"}`}
|
} ${isDark ? "bg-slate-600/55" : "bg-slate-300"}`}
|
||||||
style={{
|
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="N" isDark={isDark} 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="S" isDark={isDark} 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="W" isDark={isDark} 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="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="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="relative h-28 w-28">
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "relative h-24 w-24 rounded-full border border-cyan-400/20 bg-[#0b2035]"
|
? "absolute left-1/2 top-1/2 h-[126px] w-[10px] origin-bottom rounded-full bg-slate-300"
|
||||||
: "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-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
|
<p
|
||||||
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
|
|
||||||
className={
|
className={
|
||||||
isDark
|
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]"
|
? "text-[10px] font-bold uppercase tracking-[0.16em] text-slate-500"
|
||||||
: "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-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" />
|
<p
|
||||||
</div>
|
className={
|
||||||
</div>
|
highlighted
|
||||||
</div>
|
? isDark
|
||||||
</div>
|
? "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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -600,105 +694,24 @@ function CompassPanel({
|
|||||||
function CompassLabel({
|
function CompassLabel({
|
||||||
label,
|
label,
|
||||||
className,
|
className,
|
||||||
|
isDark,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
className: string;
|
className: string;
|
||||||
|
isDark: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<span
|
<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}
|
{label}
|
||||||
</span>
|
</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({
|
function Sparkline({
|
||||||
values,
|
values,
|
||||||
className,
|
className,
|
||||||
@@ -712,8 +725,8 @@ function Sparkline({
|
|||||||
}) {
|
}) {
|
||||||
if (!values || values.length < 2) return null;
|
if (!values || values.length < 2) return null;
|
||||||
|
|
||||||
const width = 210;
|
const width = 220;
|
||||||
const height = 56;
|
const height = 62;
|
||||||
const padding = 8;
|
const padding = 8;
|
||||||
const min = Math.min(...values);
|
const min = Math.min(...values);
|
||||||
const max = Math.max(...values);
|
const max = Math.max(...values);
|
||||||
@@ -728,7 +741,12 @@ function Sparkline({
|
|||||||
const last = points[points.length - 1].split(",").map(Number);
|
const last = points[points.length - 1].split(",").map(Number);
|
||||||
|
|
||||||
return (
|
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
|
<polyline
|
||||||
points={points.join(" ")}
|
points={points.join(" ")}
|
||||||
className={strokeClassName}
|
className={strokeClassName}
|
||||||
@@ -737,8 +755,7 @@ function Sparkline({
|
|||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>
|
||||||
<circle cx={last[0]} cy={last[1]} r="4" className={glowClassName} />
|
<circle cx={last[0]} cy={last[1]} r="3.5" className={glowClassName} />
|
||||||
<circle cx={last[0]} cy={last[1]} r="7" className={`${glowClassName} opacity-20`} />
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -786,6 +803,26 @@ function directionName(direction: number | null) {
|
|||||||
return labels[index];
|
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) {
|
function temperatureBadge(sensor?: ModuleSensorResponse) {
|
||||||
const value = numericValue(sensor);
|
const value = numericValue(sensor);
|
||||||
if (value === null) return "Sem dados";
|
if (value === null) return "Sem dados";
|
||||||
@@ -818,42 +855,74 @@ function radiationBadge(sensor?: ModuleSensorResponse) {
|
|||||||
return "Baixa";
|
return "Baixa";
|
||||||
}
|
}
|
||||||
|
|
||||||
function accentColors(accent: "amber" | "blue" | "cyan" | "emerald") {
|
function accentColors(accent: Accent, isDark: boolean) {
|
||||||
switch (accent) {
|
switch (accent) {
|
||||||
case "amber":
|
case "amber":
|
||||||
return {
|
return isDark
|
||||||
text: "text-amber-300",
|
? {
|
||||||
stroke: "stroke-amber-400",
|
icon: "text-amber-200",
|
||||||
dot: "fill-amber-400",
|
stroke: "stroke-amber-200",
|
||||||
badge: "border-amber-300/20 bg-amber-400/10",
|
dot: "fill-amber-200",
|
||||||
};
|
iconBox: "border-white/10 bg-white/[0.03]",
|
||||||
case "blue":
|
badge: "border-white/10 bg-white/[0.03] text-slate-300",
|
||||||
return {
|
|
||||||
text: "text-sky-300",
|
|
||||||
stroke: "stroke-sky-400",
|
|
||||||
dot: "fill-sky-400",
|
|
||||||
badge: "border-sky-300/20 bg-sky-400/10",
|
|
||||||
};
|
|
||||||
case "cyan":
|
|
||||||
return {
|
|
||||||
text: "text-cyan-300",
|
|
||||||
stroke: "stroke-cyan-400",
|
|
||||||
dot: "fill-cyan-400",
|
|
||||||
badge: "border-cyan-300/20 bg-cyan-400/10",
|
|
||||||
};
|
|
||||||
case "emerald":
|
|
||||||
return {
|
|
||||||
text: "text-emerald-300",
|
|
||||||
stroke: "stroke-emerald-400",
|
|
||||||
dot: "fill-emerald-400",
|
|
||||||
badge: "border-emerald-300/20 bg-emerald-400/10",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
: {
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
function formatTime(timestamp: string) {
|
case "blue":
|
||||||
return new Date(timestamp).toLocaleTimeString("pt-PT", {
|
return isDark
|
||||||
hour: "2-digit",
|
? {
|
||||||
minute: "2-digit",
|
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 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 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+18
-4
@@ -4,8 +4,14 @@ html,
|
|||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100%;
|
width: 100%;
|
||||||
background: #0f1720;
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0b1220;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -28,11 +34,19 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
.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: 2px solid rgba(15, 23, 42, 0.9);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
.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