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 (
+
+ );
+}
\ 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 (
+
+ );
+}
\ 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