diff --git a/src/App.css b/src/App.css index a461c50..a604831 100644 --- a/src/App.css +++ b/src/App.css @@ -1 +1,5 @@ -@import "tailwindcss"; \ No newline at end of file +@import "tailwindcss"; + +body { + background: red; +} \ No newline at end of file diff --git a/src/app/App.tsx b/src/app/App.tsx index 8f83e2b..913ce06 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,10 +1,10 @@ -import "../App.css"; +import { AppShell } from "../components/layout/AppShell"; function App() { return ( -
- LitoralRegas Frontend -
+ +
+ ); } diff --git a/src/assets/logo.png b/src/assets/logo.png new file mode 100644 index 0000000..798eaa9 Binary files /dev/null and b/src/assets/logo.png differ diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..ed1355a --- /dev/null +++ b/src/components/layout/AppShell.tsx @@ -0,0 +1,66 @@ +import { Sidebar } from "../navigation/Sidebar"; +import { TopBar } from "./TopBar"; +import { BottomStatusBar } from "./BottomStatusBar"; +import { useTelemetryStream } from "../../features/telemetry/hooks/useTelemetryStream"; +import { useNotifications } from "../../features/notifications/hooks/useNotifications"; +import { useCurrentUser } from "../../features/auth/hooks/useCurrentUser"; +import { useRuntimeConfig } from "../../features/system/hooks/useRuntimeConfig"; + +import { useState } from "react"; + +type AppShellProps = { + children: React.ReactNode; +}; + +export function AppShell({ children }: AppShellProps) { + const telemetry = useTelemetryStream(); + const notifications = useNotifications(); + const currentUser = useCurrentUser(); + const [theme, setTheme] = useState<"dark" | "light">("dark"); + const runtime = useRuntimeConfig(); + + const toggleTheme = () => { + setTheme((current) => (current === "dark" ? "light" : "dark")); + }; + + const isDark = theme === "dark"; + return ( +
+
+
+ +
+
+ +
+ + +
+ {children} +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/layout/BottomStatusBar.tsx b/src/components/layout/BottomStatusBar.tsx new file mode 100644 index 0000000..6ca1cc2 --- /dev/null +++ b/src/components/layout/BottomStatusBar.tsx @@ -0,0 +1,47 @@ +type BottomStatusBarProps = { + theme: "dark" | "light"; + backendPort?: string; + mode?: string; + controllerName?: string; + controllerIp?: string; +}; + +export function BottomStatusBar({ + theme, + backendPort = "18450", + mode = "Local", + controllerName = "PLC_Principal", + controllerIp = "198.19.0.176", +}: BottomStatusBarProps) { + const isDark = theme === "dark"; + + return ( +
+
+ + Porto Backend: {backendPort} +
+ +
+ + Modo: {mode} +
+ +
+ + Controlador: {controllerName} +
+ +
+ + IP Controlador: {controllerIp} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx new file mode 100644 index 0000000..5bff8b5 --- /dev/null +++ b/src/components/layout/TopBar.tsx @@ -0,0 +1,309 @@ +import { + Bell, + CalendarDays, + ChevronDown, + CircleHelp, + Clock, + Info, + LogOut, + Moon, + Settings2, + SlidersHorizontal, + Sun, + User, +} from "lucide-react"; +import { useState } from "react"; + +type TopBarProps = { + connected: boolean; + lastTimestamp: string | null; + notificationCount: number; + userInitials: string; + theme: "dark" | "light"; + onToggleTheme: () => void; +}; + +export function TopBar({ + connected, + lastTimestamp, + notificationCount, + userInitials, + theme, + onToggleTheme, +}: TopBarProps) { + const [notificationsOpen, setNotificationsOpen] = useState(false); + const [userMenuOpen, setUserMenuOpen] = useState(false); + + const isDark = theme === "dark"; + const ThemeIcon = isDark ? Moon : Sun; + + const systemDate = lastTimestamp + ? new Date(lastTimestamp) + : null; + + const formattedTime = systemDate + ? systemDate.toLocaleTimeString("pt-PT", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + : "--:--:--"; + + const formattedDate = systemDate + ? systemDate.toLocaleDateString("pt-PT", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }) + : "--/--/----"; + + const dropdownClass = isDark + ? "absolute right-0 top-12 z-50 rounded-2xl border border-[#24394A] bg-[#0F1D29] shadow-2xl shadow-black/40" + : "absolute right-0 top-12 z-50 rounded-2xl border border-[#D5DDE6] bg-white shadow-2xl shadow-slate-300/40"; + + const dropdownTitleClass = isDark + ? "text-sm font-semibold text-[#E4EDF6]" + : "text-sm font-semibold text-[#162434]"; + + const mutedTextClass = isDark + ? "text-[#8FA3B8]" + : "text-[#607284]"; + + const menuItemClass = isDark + ? "flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-[#D4DEE8] transition-colors hover:bg-[#182B3B]" + : "flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-[#162434] transition-colors hover:bg-[#EEF3F7]"; + + const dividerClass = isDark + ? "my-2 h-px bg-[#24394A]" + : "my-2 h-px bg-[#D5DDE6]"; + + return ( +
+
+

+ Painel Principal +

+
+ +
+
+ + + + {connected + ? "Ligado ao sistema" + : "Sistema desligado"} + +
+ +
+ + {formattedTime} +
+ +
+ + {formattedDate} +
+ +
+ + + {notificationsOpen && ( +
+
+ + Notificações + + + + {notificationCount} novas + +
+ +
+ Sem notificações. +
+
+ )} +
+ +
+ + + {userMenuOpen && ( +
+
+
+ {userInitials} +
+ +
+
+ admin +
+ +
+ Administrador +
+
+
+ +
+ + + + + + + +
+ + + + + +
+ + +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/navigation/Sidebar.tsx b/src/components/navigation/Sidebar.tsx new file mode 100644 index 0000000..ffe9994 --- /dev/null +++ b/src/components/navigation/Sidebar.tsx @@ -0,0 +1,101 @@ +import { + CloudSun, + Droplet, + Home, + Settings, + TabletSmartphone, + Wind, +} from "lucide-react"; + +import logo from "../../assets/logo.png"; + +type SidebarProps = { + theme: "dark" | "light"; +}; + +const navigationItems = [ + { label: "Painel Principal", icon: Home, active: true }, + { label: "Meteorologia", icon: CloudSun }, + { label: "Consola (VNC)", icon: TabletSmartphone }, + { label: "Rega", icon: Droplet }, + { label: "Clima", icon: Wind }, + { label: "Configurações", icon: Settings }, +]; + +export function Sidebar({ theme }: SidebarProps) { + const isDark = theme === "dark"; + + return ( + + ); +} \ No newline at end of file diff --git a/src/features/auth/hooks/useCurrentUser.ts b/src/features/auth/hooks/useCurrentUser.ts new file mode 100644 index 0000000..390c0a3 --- /dev/null +++ b/src/features/auth/hooks/useCurrentUser.ts @@ -0,0 +1,6 @@ +export function useCurrentUser() { + return { + name: "Administrador", + initials: "AD", + }; +} \ No newline at end of file diff --git a/src/features/notifications/hooks/useNotifications.ts b/src/features/notifications/hooks/useNotifications.ts new file mode 100644 index 0000000..d88fa1d --- /dev/null +++ b/src/features/notifications/hooks/useNotifications.ts @@ -0,0 +1,5 @@ +export function useNotifications() { + return { + unreadCount: 0, + }; +} \ No newline at end of file diff --git a/src/features/system/hooks/useRuntimeConfig.ts b/src/features/system/hooks/useRuntimeConfig.ts new file mode 100644 index 0000000..f232fbe --- /dev/null +++ b/src/features/system/hooks/useRuntimeConfig.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from "react"; +import { fetchRuntimeConfig } from "../../../lib/api/systemApi"; +import type { RuntimeConfig } from "../../../types/system"; + +export function useRuntimeConfig() { + const [runtimeConfig, setRuntimeConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetchRuntimeConfig() + .then((data) => { + setRuntimeConfig(data); + setError(null); + }) + .catch((exception: unknown) => { + setError( + exception instanceof Error + ? exception.message + : "Failed to fetch runtime config.", + ); + }) + .finally(() => { + setLoading(false); + }); + }, []); + + return { + runtimeConfig, + loading, + error, + }; +} \ No newline at end of file diff --git a/src/features/telemetry/hooks/useTelemetryStream.ts b/src/features/telemetry/hooks/useTelemetryStream.ts new file mode 100644 index 0000000..7ee4e9a --- /dev/null +++ b/src/features/telemetry/hooks/useTelemetryStream.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react"; +import { Client } from "@stomp/stompjs"; +import type { TelemetryBroadcastMessage } from "../../../types/telemetry"; + +export function useTelemetryStream() { + const [message, setMessage] = useState(null); + const [connected, setConnected] = useState(false); + + useEffect(() => { + const client = new Client({ + brokerURL: "ws://localhost:18450/ws", + reconnectDelay: 3000, + + onConnect: () => { + setConnected(true); + + client.subscribe("/topic/telemetry/latest", (frame) => { + const payload = JSON.parse(frame.body) as TelemetryBroadcastMessage; + setMessage(payload); + }); + }, + + onWebSocketClose: () => { + setConnected(false); + }, + + onStompError: () => { + setConnected(false); + }, + }); + + client.activate(); + + return () => { + client.deactivate(); + }; + }, []); + + return { + connected, + message, + lastTimestamp: message?.timestamp ?? null, + snapshots: message?.snapshots ?? [], + sensorCount: message?.sensorCount ?? 0, + }; +} \ No newline at end of file diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..c777870 --- /dev/null +++ b/src/index.css @@ -0,0 +1,13 @@ +@import "tailwindcss"; + +html, +body, +#root { + margin: 0; + min-height: 100%; + background: #0f1720; +} + +body { + font-family: Inter, system-ui, sans-serif; +} \ No newline at end of file diff --git a/src/lib/api/systemApi.ts b/src/lib/api/systemApi.ts new file mode 100644 index 0000000..d0fbefc --- /dev/null +++ b/src/lib/api/systemApi.ts @@ -0,0 +1,13 @@ +import type { RuntimeConfig } from "../../types/system"; + +const API_BASE_URL = "http://localhost:18450"; + +export async function fetchRuntimeConfig(): Promise { + const response = await fetch(`${API_BASE_URL}/api/system/runtime-config`); + + if (!response.ok) { + throw new Error("Failed to fetch runtime config."); + } + + return response.json() as Promise; +} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index f7b6b90..030217b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,9 +1,10 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./app/App"; +import "./index.css"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( , -); +); \ No newline at end of file diff --git a/src/types/system.ts b/src/types/system.ts new file mode 100644 index 0000000..c34e7d6 --- /dev/null +++ b/src/types/system.ts @@ -0,0 +1,6 @@ +export type RuntimeConfig = { + mode: string; + controllerName: string; + controllerIp: string; + backendPort: number; +}; \ No newline at end of file diff --git a/src/types/telemetry.ts b/src/types/telemetry.ts index e69de29..ce7ceb3 100644 --- a/src/types/telemetry.ts +++ b/src/types/telemetry.ts @@ -0,0 +1,16 @@ +export type TelemetrySnapshot = { + sensorId: number; + name: string; + modbusAddress: number; + bitOffset: number | null; + rawValue: number; + value: number | boolean | string | null; + unit: string | null; + timestamp: string; +}; + +export type TelemetryBroadcastMessage = { + timestamp: string; + sensorCount: number; + snapshots: TelemetrySnapshot[]; +}; \ No newline at end of file