ui update removing topbar, responsiveness work, console behavior, custom titlebar

This commit is contained in:
litoral05
2026-06-02 16:32:43 +01:00
parent bd8eef7f04
commit d6daac97c7
17 changed files with 1066 additions and 832 deletions
+6 -2
View File
@@ -13,8 +13,12 @@
"windows": [
{
"title": "Central LRX",
"width": 800,
"height": 600
"decorations": false,
"width": 1600,
"height": 900,
"minWidth": 1280,
"minHeight": 720,
"maximized": true
}
],
"security": {
+10 -5
View File
@@ -11,6 +11,7 @@ import { ChartWindowPage } from "../features/chartworkspace/pages/ChartWindowPag
import { SettingsPage } from "../features/settings/pages/SettingsPage";
import SynopticPage from "../features/synoptic/pages/SynopticPage";
import MeteoChartsPage from "../features/meteo/pages/MeteoChartsPage";
import { TitleBar } from "../components/window/TitleBar";
export type AppPage =
| "dashboard"
@@ -43,6 +44,11 @@ function App() {
}
return (
<div className="h-screen overflow-hidden bg-[#071421]">
<TitleBar />
<div className="pt-9">
<div className="h-[calc(100vh-36px)] overflow-hidden">
<AppShell activePage={activePage} onNavigate={setActivePage}>
{({ theme }) => {
if (activePage === "meteo") {
@@ -55,11 +61,7 @@ function App() {
}
if (activePage === "meteoCharts") {
return (
<MeteoChartsPage
theme={theme}
/>
);
return <MeteoChartsPage theme={theme} />;
}
if (activePage === "climateCharts") {
@@ -89,6 +91,9 @@ function App() {
);
}}
</AppShell>
</div>
</div>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

+174 -50
View File
@@ -64,6 +64,7 @@ type Props = {
theme: "dark" | "light";
chart: WorkspaceChartConfig;
detached?: boolean;
compact?: boolean;
dragHandle?: React.ReactNode;
onHeaderPointerDown?: (event: React.PointerEvent) => void;
onModeChange: (mode: WorkspaceChartMode) => void;
@@ -93,6 +94,8 @@ type YAxisConfig = {
};
const RADIUS = "rounded-[8px]";
const THIN_X_SCROLLBAR = "[scrollbar-width:thin] [scrollbar-color:#33445F_transparent] [&::-webkit-scrollbar]:h-1 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-[#33445F]";
const THIN_Y_SCROLLBAR = "[scrollbar-width:thin] [scrollbar-color:#33445F_transparent] [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-[#33445F]";
const RANGE_OPTIONS: { value: WorkspaceChartTimeRange; label: string }[] = [
{ value: "15m", label: "15M" },
@@ -115,6 +118,7 @@ export function WorkspaceChart({
chart,
loading = false,
detached = false,
compact = false,
dragHandle,
onHeaderPointerDown,
onModeChange,
@@ -267,9 +271,7 @@ export function WorkspaceChart({
};
}, [primaryVariable]);
const yDomain: [number | "auto", number | "auto"] = zeroBaseline
? [0, "auto"]
: ["auto", "auto"];
const yDomain = useMemo(() => buildPaddedYDomain(zeroBaseline), [zeroBaseline]);
const allowedIntervalOptions = useMemo(() => {
if (chart.mode === "bar") {
@@ -286,16 +288,20 @@ export function WorkspaceChart({
className={
detached
? isDark
? `${RADIUS} flex h-full min-h-0 flex-col overflow-hidden border-0 bg-[#0F1726] text-slate-100`
: `${RADIUS} flex h-full min-h-0 flex-col overflow-hidden border-0 bg-white text-slate-950`
? `${RADIUS} flex h-full min-h-0 flex-col overflow-visible border-0 bg-[#0F1726] text-slate-100`
: `${RADIUS} flex h-full min-h-0 flex-col overflow-visible border-0 bg-white text-slate-950`
: isDark
? `${RADIUS} flex h-full min-h-0 flex-col overflow-hidden border border-[#223049] bg-[#0F1726] text-slate-100 shadow-[0_18px_50px_rgba(0,0,0,0.24)]`
: `${RADIUS} flex h-full min-h-0 flex-col overflow-hidden border border-slate-200 bg-white text-slate-950 shadow-[0_14px_34px_rgba(15,23,42,0.08)]`
? `${RADIUS} flex h-full min-h-0 flex-col overflow-visible border border-[#223049] bg-[#0F1726] text-slate-100 shadow-[0_18px_50px_rgba(0,0,0,0.24)]`
: `${RADIUS} flex h-full min-h-0 flex-col overflow-visible border border-slate-200 bg-white text-slate-950 shadow-[0_14px_34px_rgba(15,23,42,0.08)]`
}
>
<header
onPointerDown={onHeaderPointerDown}
className="flex shrink-0 items-start justify-between gap-3 px-3 py-3 sm:px-4"
className={
compact
? "flex shrink-0 items-start justify-between gap-2 px-2 py-2"
: "flex shrink-0 items-start justify-between gap-3 px-3 py-3 sm:px-4"
}
>
<div className="flex min-w-0 flex-1 items-start gap-3">
{dragHandle && <div className="mt-1 shrink-0">{dragHandle}</div>}
@@ -304,7 +310,11 @@ export function WorkspaceChart({
<div className="min-w-0 flex-1">
{chart.title ? (
<div className="flex min-w-0 items-center gap-2">
<h2 className="truncate text-[19px] font-black tracking-[-0.035em]">
<h2 className={
compact
? "truncate text-[15px] font-black tracking-[-0.035em]"
: "truncate text-[19px] font-black tracking-[-0.035em]"
}>
{chart.title}
</h2>
@@ -318,7 +328,7 @@ export function WorkspaceChart({
</div>
) : null}
{chart.subtitle ? (
{chart.subtitle && !compact ? (
<p className="mt-1 truncate text-xs text-[#8290A6]">
{chart.subtitle}
</p>
@@ -363,22 +373,34 @@ export function WorkspaceChart({
</div>
</header>
<main className="flex min-h-0 flex-1 flex-col px-3 pb-3 sm:px-4 sm:pb-4">
<main className={
compact
? "flex min-h-0 flex-1 flex-col px-2 pb-2"
: "flex min-h-0 flex-1 flex-col px-3 pb-3 sm:px-4 sm:pb-4"
}>
<section
className={
isDark
? `${RADIUS} flex min-h-0 flex-1 flex-col bg-[#09111F] p-2 sm:p-3`
: `${RADIUS} flex min-h-0 flex-1 flex-col bg-slate-50 p-2 sm:p-3`
? compact
? `${RADIUS} relative isolate flex min-h-0 flex-1 flex-col overflow-visible bg-[#09111F] p-2`
: `${RADIUS} relative isolate flex min-h-0 flex-1 flex-col overflow-visible bg-[#09111F] p-2 sm:p-3`
: compact
? `${RADIUS} relative isolate flex min-h-0 flex-1 flex-col overflow-visible bg-slate-50 p-2`
: `${RADIUS} relative isolate flex min-h-0 flex-1 flex-col overflow-visible bg-slate-50 p-2 sm:p-3`
}
>
<div className="mb-2 flex shrink-0 items-center justify-between gap-2">
<div className={
compact
? "relative z-[90] mb-1 flex shrink-0 items-center justify-between gap-1 overflow-visible pb-1"
: "relative z-[90] mb-2 flex shrink-0 items-center justify-between gap-2 overflow-visible"
}>
<div className="relative">
<button
type="button"
onClick={() => setRangeMenuOpen((value) => !value)}
className={buttonClass(isDark)}
>
Período: {formatRangeLabel(chart.timeRange)}
{compact ? formatRangeLabel(chart.timeRange) : `Período: ${formatRangeLabel(chart.timeRange)}`}
<ChevronDown className="ml-1.5 inline h-3 w-3" />
</button>
@@ -415,7 +437,7 @@ export function WorkspaceChart({
onClick={() => setIntervalMenuOpen((value) => !value)}
className={buttonClass(isDark)}
>
Intervalo: {formatIntervalLabel(chart.interval)}
{compact ? formatIntervalLabel(chart.interval) : `Intervalo: ${formatIntervalLabel(chart.interval)}`}
<ChevronDown className="ml-1.5 inline h-3 w-3" />
</button>
@@ -451,7 +473,7 @@ export function WorkspaceChart({
onClick={() => setModeMenuOpen((value) => !value)}
className={buttonClass(isDark)}
>
Tipo: {formatModeLabel(chart.mode)}
{compact ? formatModeLabel(chart.mode) : `Tipo: ${formatModeLabel(chart.mode)}`}
<ChevronDown className="ml-1.5 inline h-3 w-3" />
</button>
@@ -492,8 +514,8 @@ export function WorkspaceChart({
: buttonClass(isDark)
}
>
<Activity className="mr-1.5 inline h-3.5 w-3.5" />
Indicadores
<Activity className={compact ? "inline h-3.5 w-3.5" : "mr-1.5 inline h-3.5 w-3.5"} />
{!compact && "Indicadores"}
</button>
<div className="relative">
@@ -515,7 +537,7 @@ export function WorkspaceChart({
Variáveis registadas
</p>
<div className="max-h-[260px] overflow-y-auto pr-1">
<div className={`max-h-[260px] overflow-y-auto pr-1 ${THIN_Y_SCROLLBAR}`}>
{chart.variables.map((variable) => (
<button
key={variable.key}
@@ -569,8 +591,12 @@ export function WorkspaceChart({
</div>
</div>
<div className="mb-2 flex shrink-0 items-center justify-between gap-3 overflow-x-auto pb-1">
<div className="flex shrink-0 items-center gap-4 text-[11px]">
<div className={
compact
? `relative z-30 mb-1 flex shrink-0 items-center justify-between gap-2 overflow-x-auto pb-1 ${THIN_X_SCROLLBAR}`
: `relative z-30 mb-2 flex shrink-0 items-center justify-between gap-3 overflow-x-auto pb-1 ${THIN_X_SCROLLBAR}`
}>
<div className={compact ? "flex shrink-0 items-center gap-3 text-[10px]" : "flex shrink-0 items-center gap-4 text-[11px]"}>
{visibleVariables.map((variable) => (
<span
key={variable.key}
@@ -596,6 +622,8 @@ export function WorkspaceChart({
Média:{" "}
<b>{formatValue(stats.average, displayUnit(primaryVariable.unit))}</b>
</span>
{!compact && (
<>
<span>
Máx:{" "}
<b>{formatValue(stats.max, displayUnit(primaryVariable.unit))}</b>
@@ -604,11 +632,13 @@ export function WorkspaceChart({
Mín:{" "}
<b>{formatValue(stats.min, displayUnit(primaryVariable.unit))}</b>
</span>
</>
)}
</div>
)}
</div>
<div className="relative min-h-[80px] flex-1">
<div className="relative z-0 min-h-[130px] flex-1 overflow-hidden rounded-[8px]">
{visibleVariables.length === 0 && !shouldShowLoading ? (
<EmptyChartMessage message="Escolha pelo menos uma variável." />
) : data.length === 0 && !shouldShowLoading ? (
@@ -616,12 +646,15 @@ export function WorkspaceChart({
) : data.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
{chart.mode === "bar" ? (
<BarChart data={displayData} barCategoryGap="25%" barGap={4}>
<BarChart data={displayData} margin={chartMargin(compact, yAxes.length)} barCategoryGap={compact ? "18%" : "25%"} barGap={compact ? 2 : 4}>
<ChartScaffold
isDark={isDark}
yDomain={yDomain}
yAxes={yAxes}
chartTimeRange={chart.timeRange}
chartInterval={chart.interval}
chartMode={chart.mode}
compact={compact}
/>
{visibleVariables.map((variable) => (
<Bar
@@ -634,22 +667,27 @@ export function WorkspaceChart({
radius={[3, 3, 0, 0]}
opacity={0.78}
isAnimationActive={false}
maxBarSize={42}
maxBarSize={compact ? 34 : 42}
/>
))}
</BarChart>
) : chart.mode === "area" ? (
<AreaChart data={displayData}>
<AreaChart data={displayData} margin={chartMargin(compact, yAxes.length)}>
<ChartScaffold
isDark={isDark}
yDomain={yDomain}
yAxes={yAxes}
chartTimeRange={chart.timeRange}
chartInterval={chart.interval}
chartMode={chart.mode}
compact={compact}
/>
{renderReferenceLines(
visibleVariables,
showIndicators,
stats.average,
yAxes,
primaryVariable,
)}
{visibleVariables.map((variable) => (
<Area
@@ -662,26 +700,31 @@ export function WorkspaceChart({
stroke={variable.color}
fill={variable.color}
fillOpacity={0.08}
strokeWidth={2}
strokeWidth={compact ? 1.75 : 2}
dot={false}
activeDot={{ r: 4 }}
activeDot={{ r: compact ? 3 : 4 }}
isAnimationActive={false}
connectNulls={false}
/>
))}
</AreaChart>
) : (
<LineChart data={displayData}>
<LineChart data={displayData} margin={chartMargin(compact, yAxes.length)}>
<ChartScaffold
isDark={isDark}
yDomain={yDomain}
yAxes={yAxes}
chartTimeRange={chart.timeRange}
chartInterval={chart.interval}
chartMode={chart.mode}
compact={compact}
/>
{renderReferenceLines(
visibleVariables,
showIndicators,
stats.average,
yAxes,
primaryVariable,
)}
{visibleVariables.map((variable) => (
<Line
@@ -692,9 +735,9 @@ export function WorkspaceChart({
dataKey={variable.key}
yAxisId={getYAxisId(variable, yAxes)}
stroke={variable.color}
strokeWidth={2}
strokeWidth={compact ? 1.75 : 2}
dot={false}
activeDot={{ r: 4 }}
activeDot={{ r: compact ? 3 : 4 }}
isAnimationActive={false}
connectNulls={false}
/>
@@ -714,8 +757,8 @@ export function WorkspaceChart({
)}
</div>
{showIndicators && primaryVariable && (
<div className="mt-2 shrink-0 overflow-x-auto border-t border-white/[0.06] pt-2">
{showIndicators && primaryVariable && !compact && (
<div className={`mt-2 shrink-0 overflow-x-auto border-t border-white/[0.06] pt-2 ${THIN_X_SCROLLBAR}`}>
<div className="flex min-w-max items-center gap-5 text-xs">
<InlineMetric
label="Atual"
@@ -757,17 +800,71 @@ export function WorkspaceChart({
);
}
function chartMargin(compact: boolean, axisCount: number) {
return compact
? { top: 8, right: axisCount > 1 ? 8 : 4, bottom: 2, left: 0 }
: { top: 18, right: axisCount > 1 ? 24 : 16, bottom: 10, left: 10 };
}
function buildPaddedYDomain(zeroBaseline: boolean): [(dataMin: number) => number, (dataMax: number) => number] {
return [
(dataMin: number) => {
if (!Number.isFinite(dataMin)) return zeroBaseline ? 0 : 0;
if (zeroBaseline) return 0;
return padYDomainEdge(dataMin, "min");
},
(dataMax: number) => {
if (!Number.isFinite(dataMax)) return 1;
return padYDomainEdge(dataMax, "max");
},
];
}
function padYDomainEdge(value: number, edge: "min" | "max") {
const magnitude = Math.max(Math.abs(value), 1);
const padding = magnitude * 0.08;
if (edge === "min") {
return value - padding;
}
return value + padding;
}
function getXAxisPaddingMs(
interval: WorkspaceChartInterval,
mode: WorkspaceChartMode,
) {
const intervalMs = intervalToMs(interval);
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
return 0;
}
return mode === "bar" ? intervalMs * 0.75 : intervalMs * 0.5;
}
function ChartScaffold({
isDark,
yDomain,
yAxes,
chartTimeRange
chartTimeRange,
chartInterval,
chartMode,
compact = false,
}: {
isDark: boolean;
yDomain: [number | "auto", number | "auto"];
yDomain: [(dataMin: number) => number, (dataMax: number) => number];
yAxes: YAxisConfig[];
chartTimeRange: WorkspaceChartTimeRange;
chartInterval: WorkspaceChartInterval;
chartMode: WorkspaceChartMode;
compact?: boolean;
}) {
const xPadMs = getXAxisPaddingMs(chartInterval, chartMode);
return (
<>
<CartesianGrid
@@ -780,14 +877,18 @@ function ChartScaffold({
dataKey="timestampMs"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
padding={{ left: 24, right: 24 }}
tick={{ fill: "#64748b", fontSize: 10 }}
domain={[
(dataMin: number) => dataMin - xPadMs,
(dataMax: number) => dataMax + xPadMs,
]}
padding={{ left: 0, right: 0 }}
allowDataOverflow={false}
tick={{ fill: "#64748b", fontSize: compact ? 9 : 10 }}
tickLine={false}
axisLine={{
stroke: isDark ? "rgba(148,163,184,0.12)" : "#cbd5e1",
}}
minTickGap={34}
minTickGap={compact ? 20 : 34}
tickFormatter={(value) =>
formatAxisTime(new Date(Number(value)).toISOString(), chartTimeRange)
}
@@ -798,12 +899,15 @@ function ChartScaffold({
yAxisId={axis.id}
orientation={axis.orientation}
domain={yDomain}
tick={{ fill: axis.color, fontSize: 10 }}
allowDataOverflow={false}
tick={{ fill: axis.color, fontSize: compact ? 9 : 10 }}
tickLine={false}
tickMargin={compact ? 4 : 6}
tickFormatter={(value) => formatDecimal(Number(value), 1)}
axisLine={{
stroke: isDark ? "rgba(148,163,184,0.18)" : "#cbd5e1",
}}
width={52}
width={compact ? 44 : 52}
label={
axis.unit
? {
@@ -814,7 +918,7 @@ function ChartScaffold({
? "insideLeft"
: "insideRight",
fill: axis.color,
fontSize: 10,
fontSize: compact ? 9 : 10,
}
: undefined
}
@@ -822,6 +926,7 @@ function ChartScaffold({
))}
<Tooltip
wrapperStyle={{ zIndex: 80, outline: "none" }}
cursor={{
fill: "rgba(255,255,255,0.04)",
stroke: "rgba(79,209,197,0.12)",
@@ -831,6 +936,14 @@ function ChartScaffold({
const timestamp = payload?.[0]?.payload?.timestamp;
return timestamp ? formatTooltipDate(timestamp) : "";
}}
formatter={(value, name) => {
const numericValue = typeof value === "number" ? value : Number(value);
return [
formatDecimal(numericValue, 2),
String(name ?? ""),
];
}}
contentStyle={{
background: isDark ? "#111827" : "#ffffff",
border: isDark
@@ -858,8 +971,8 @@ function DropdownPanel({
<div
className={
isDark
? `absolute z-50 gap-1 ${RADIUS} border border-[#263247] bg-[#111827] p-2 shadow-2xl ${className}`
: `absolute z-50 gap-1 ${RADIUS} border border-slate-200 bg-white p-2 shadow-xl ${className}`
? `absolute z-[1000] gap-1 ${RADIUS} border border-[#263247] bg-[#111827] p-2 shadow-2xl ${className}`
: `absolute z-[1000] gap-1 ${RADIUS} border border-slate-200 bg-white p-2 shadow-xl ${className}`
}
>
{children}
@@ -879,15 +992,19 @@ function renderReferenceLines(
visibleVariables: WorkspaceChartVariable[],
showIndicators: boolean,
average: number | null,
yAxes: YAxisConfig[],
primaryVariable: WorkspaceChartVariable | null,
) {
return (
<>
{showIndicators && average !== null && (
{showIndicators && average !== null && primaryVariable && (
<ReferenceLine
yAxisId={getYAxisId(primaryVariable, yAxes)}
y={average}
stroke="#94a3b8"
strokeDasharray="4 5"
strokeOpacity={0.28}
ifOverflow="extendDomain"
/>
)}
@@ -895,10 +1012,12 @@ function renderReferenceLines(
variable.limit !== undefined ? (
<ReferenceLine
key={`${variable.key}-limit`}
yAxisId={getYAxisId(variable, yAxes)}
y={variable.limit}
stroke="#ef4444"
strokeDasharray="6 6"
strokeOpacity={0.55}
ifOverflow="extendDomain"
/>
) : null,
)}
@@ -955,21 +1074,26 @@ function windowButtonClass(isDark: boolean) {
: `${RADIUS} p-2 text-slate-400 transition hover:bg-slate-100 hover:text-slate-900`;
}
function formatDecimal(value: number | null | undefined, decimals = 2) {
if (value === null || value === undefined || !Number.isFinite(value)) return "--";
return value.toFixed(decimals);
}
function formatValue(value: number | null, unit?: string) {
if (value === null || Number.isNaN(value)) return "--";
const normalizedUnit = displayUnit(unit);
const formattedValue = formatDecimal(value, 2);
return `${value.toFixed(1)}${normalizedUnit ? ` ${normalizedUnit}` : ""}`;
return `${formattedValue}${formattedValue !== "--" && normalizedUnit ? ` ${normalizedUnit}` : ""}`;
}
function formatSignedValue(value: number | null, unit?: string) {
if (value === null || Number.isNaN(value)) return "--";
if (value === null || !Number.isFinite(value)) return "--";
const prefix = value >= 0 ? "+" : "";
const normalizedUnit = displayUnit(unit);
return `${prefix}${value.toFixed(1)}${normalizedUnit ? ` ${normalizedUnit}` : ""}`;
return `${prefix}${formatDecimal(value, 2)}${normalizedUnit ? ` ${normalizedUnit}` : ""}`;
}
function formatRangeLabel(range: WorkspaceChartTimeRange) {
return range.toUpperCase();
+26 -19
View File
@@ -1,8 +1,6 @@
import { type ReactNode, useEffect, useState } from "react";
import { Sidebar } from "../navigation/Sidebar";
import { TopBar } from "../layout/TopBar";
import { useTelemetryStream } from "../../features/telemetry/hooks/useTelemetryStream";
import { useNotifications } from "../../features/notifications/hooks/useNotifications";
import { useCurrentUser } from "../../features/auth/hooks/useCurrentUser";
import type { TelemetrySnapshot } from "../../types/telemetry";
import type { AppPage } from "../../app/App";
@@ -24,10 +22,11 @@ const THEME_STORAGE_KEY = "app-theme";
export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
const telemetry = useTelemetryStream();
const notifications = useNotifications();
const currentUser = useCurrentUser();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [autoCompact, setAutoCompact] = useState(false);
const [theme, setTheme] = useState<Theme>(() => {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
return stored === "light" || stored === "dark" ? stored : "dark";
@@ -44,12 +43,28 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
setTheme((current) => (current === "dark" ? "light" : "dark"));
};
useEffect(() => {
const update = () => {
const compact = window.innerWidth <= 1366 || window.innerHeight <= 760;
setAutoCompact(compact);
if (compact) {
setSidebarCollapsed(true);
}
};
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []);
return (
<div
className={
isDark
? "fixed inset-0 overflow-hidden bg-[#07101B] text-slate-100"
: "fixed inset-0 overflow-hidden bg-white text-slate-950"
? "relative h-full overflow-hidden bg-[#07101B] text-slate-100"
: "relative h-full overflow-hidden bg-white text-slate-950"
}
>
<style>{`
@@ -89,8 +104,10 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
theme={theme}
activePage={activePage}
collapsed={sidebarCollapsed}
userInitials={currentUser.initials}
onNavigate={onNavigate}
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
onToggleTheme={toggleTheme}
/>
<div
@@ -103,26 +120,16 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
</aside>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<TopBar
connected={telemetry.connected}
lastTimestamp={telemetry.lastTimestamp}
notificationCount={notifications.unreadCount}
userInitials={currentUser.initials}
theme={theme}
activePage={activePage}
onToggleTheme={toggleTheme}
/>
<main
className={
isDark
? `app-scrollbar min-h-0 flex-1 border-t border-white/10 ${isDashboard
? `app-scrollbar min-h-0 flex-1 ${isDashboard
? "overflow-y-auto bg-[#07101B] p-0"
: "overflow-y-auto bg-[#0b1220] p-4"
: `${autoCompact ? "p-3" : "p-4"} overflow-y-auto bg-[#0b1220]`
}`
: `app-scrollbar min-h-0 flex-1 border-t border-slate-200 ${isDashboard
: `app-scrollbar min-h-0 flex-1 ${isDashboard
? "overflow-y-auto bg-white p-0"
: "overflow-y-auto bg-slate-100 p-4"
: `${autoCompact ? "p-3" : "p-4"} overflow-y-auto bg-slate-100`
}`
}
>
-336
View File
@@ -1,336 +0,0 @@
import {
Bell,
CalendarDays,
ChevronDown,
CircleHelp,
Clock,
Info,
LogOut,
Moon,
Settings2,
SlidersHorizontal,
Sun,
User,
} from "lucide-react";
import { useState } from "react";
import { AppPage } from "../../app/App";
type TopBarProps = {
connected: boolean;
lastTimestamp: string | null;
notificationCount: number;
userInitials: string;
theme: "dark" | "light";
activePage: AppPage | null;
onToggleTheme: () => void;
};
const RADIUS = "rounded-[5px]";
export function TopBar({
connected,
lastTimestamp,
notificationCount,
userInitials,
theme,
activePage,
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 ${RADIUS} border border-white/[0.04] bg-[#111827] shadow-2xl`
: `absolute right-0 top-12 z-50 ${RADIUS} border border-slate-200 bg-white shadow-xl`;
const dropdownTitleClass = isDark
? "text-sm font-bold text-slate-100"
: "text-sm font-bold text-slate-950";
const mutedTextClass = "text-slate-500";
const menuItemClass = isDark
? `flex w-full items-center gap-3 ${RADIUS} px-3 py-2.5 text-left text-sm font-medium text-slate-300 transition hover:bg-white/[0.05] hover:text-slate-100`
: `flex w-full items-center gap-3 ${RADIUS} px-3 py-2.5 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-100 hover:text-slate-950`;
const dividerClass = isDark ? "my-2 h-px bg-white/10" : "my-2 h-px bg-slate-200";
return (
<header
className={
isDark
? "flex h-16 items-center justify-between bg-[#0b1220] px-6"
: "flex h-16 items-center justify-between border-b border-slate-200 bg-slate-50 px-6"
}
>
<div>
<h1
className={
isDark
? "text-2xl font-black tracking-[-0.04em] text-slate-100"
: "text-2xl font-black tracking-[-0.04em] text-slate-950"
}
>
{pageTitle(activePage)}
</h1>
</div>
<div
className={
isDark
? "flex items-center gap-5 text-sm text-slate-400"
: "flex items-center gap-5 text-sm text-slate-500"
}
>
<div
className={
isDark
? `hidden items-center gap-2 ${RADIUS} border border-white/[0.04] bg-white/[0.03] px-3 py-1.5 md:flex`
: `hidden items-center gap-2 ${RADIUS} border border-slate-200 bg-white px-3 py-1.5 md:flex`
}
>
<span
className={
connected
? "h-2 w-2 rounded-full bg-emerald-500"
: "h-2 w-2 rounded-full bg-red-500"
}
/>
<span className="font-medium">
{connected ? "Ligado ao sistema" : "Sistema desligado"}
</span>
</div>
<div className="hidden items-center gap-2 lg:flex">
<Clock className="h-4 w-4 text-slate-500" />
<span>{formattedTime}</span>
</div>
<div className="hidden items-center gap-2 lg:flex">
<CalendarDays className="h-4 w-4 text-slate-500" />
<span>{formattedDate}</span>
</div>
<div className="relative">
<button
type="button"
onClick={() => {
setNotificationsOpen(!notificationsOpen);
setUserMenuOpen(false);
}}
className={
isDark
? `relative grid h-10 w-10 place-items-center ${RADIUS} border border-white/[0.04] bg-white/[0.03] text-slate-400 transition hover:bg-white/[0.06] hover:text-slate-100`
: `relative grid h-10 w-10 place-items-center ${RADIUS} border border-slate-200 bg-white text-slate-500 transition hover:bg-slate-100 hover:text-slate-950`
}
>
<Bell className="h-5 w-5" />
{notificationCount > 0 && (
<span className="absolute -right-1 -top-1 min-w-5 rounded-full bg-slate-900 px-1.5 text-[10px] font-bold leading-5 text-white">
{notificationCount}
</span>
)}
</button>
{notificationsOpen && (
<div className={`${dropdownClass} w-80 p-4`}>
<div className="mb-3 flex items-center justify-between">
<span className={dropdownTitleClass}>Notificações</span>
<span className={`text-xs ${mutedTextClass}`}>
{notificationCount} novas
</span>
</div>
<div
className={
isDark
? `${RADIUS} border border-white/[0.04] bg-white/[0.03] px-4 py-4 text-sm text-slate-400`
: `${RADIUS} border border-slate-200 bg-slate-50 px-4 py-4 text-sm text-slate-500`
}
>
Sem notificações.
</div>
</div>
)}
</div>
<div className="relative">
<button
type="button"
onClick={() => {
setUserMenuOpen(!userMenuOpen);
setNotificationsOpen(false);
}}
className={
isDark
? `flex items-center gap-2 ${RADIUS} border border-white/[0.04] bg-white/[0.03] py-1 pl-1 pr-3 transition hover:bg-white/[0.06]`
: `flex items-center gap-2 ${RADIUS} border border-slate-200 bg-white py-1 pl-1 pr-3 transition hover:bg-slate-100`
}
>
<span
className={
isDark
? "flex h-8 w-8 items-center justify-center rounded-full bg-slate-200 text-xs font-black text-slate-950"
: "flex h-8 w-8 items-center justify-center rounded-full bg-slate-900 text-xs font-black text-white"
}
>
{userInitials}
</span>
<ChevronDown
className={`h-4 w-4 text-slate-500 transition-transform ${userMenuOpen ? "rotate-180" : ""
}`}
/>
</button>
{userMenuOpen && (
<div className={`${dropdownClass} w-64 p-3`}>
<div className={`mb-3 flex items-center gap-3 ${RADIUS} px-2 py-2`}>
<div
className={
isDark
? "flex h-10 w-10 items-center justify-center rounded-full bg-slate-200 text-sm font-black text-slate-950"
: "flex h-10 w-10 items-center justify-center rounded-full bg-slate-900 text-sm font-black text-white"
}
>
{userInitials}
</div>
<div>
<div className={dropdownTitleClass}>admin</div>
<div className={`text-xs ${mutedTextClass}`}>
Administrador
</div>
</div>
</div>
<div className={dividerClass} />
<button type="button" className={menuItemClass}>
<User className={`h-4 w-4 ${mutedTextClass}`} />
Perfil
</button>
<button type="button" className={menuItemClass}>
<SlidersHorizontal className={`h-4 w-4 ${mutedTextClass}`} />
Preferências
</button>
<button
type="button"
onClick={onToggleTheme}
className={
isDark
? `flex w-full items-center justify-between ${RADIUS} px-3 py-2.5 text-left text-sm font-medium text-slate-300 transition hover:bg-white/[0.05] hover:text-slate-100`
: `flex w-full items-center justify-between ${RADIUS} px-3 py-2.5 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-100 hover:text-slate-950`
}
>
<span className="flex items-center gap-3">
<Settings2 className={`h-4 w-4 ${mutedTextClass}`} />
Tema
</span>
<ThemeIcon className="h-4 w-4 text-slate-500" />
</button>
<div className={dividerClass} />
<button type="button" className={menuItemClass}>
<CircleHelp className={`h-4 w-4 ${mutedTextClass}`} />
Ajuda
</button>
<button type="button" className={menuItemClass}>
<Info className={`h-4 w-4 ${mutedTextClass}`} />
Sobre
</button>
<div className={dividerClass} />
<button
type="button"
className={
isDark
? `flex w-full items-center gap-3 ${RADIUS} px-3 py-2.5 text-left text-sm font-medium text-red-300 transition hover:bg-red-500/10`
: `flex w-full items-center gap-3 ${RADIUS} px-3 py-2.5 text-left text-sm font-medium text-red-600 transition hover:bg-red-50`
}
>
<LogOut className="h-4 w-4" />
Terminar sessão
</button>
</div>
)}
</div>
</div>
</header>
);
}
function pageTitle(page: AppPage | null) {
switch (page) {
case "dashboard":
return "";
case "maincharts":
return "Gráficos Gerais";
case "meteo":
return "Previsões Meteorológicas";
case "meteoCharts":
return "Gráficos Meteorológicos";
case "synoptic":
return "Sinótico";
case "settings":
return "Configurações";
case "console":
return "Consola (VNC)";
// ALL CLIMATE PAGES
case "climate":
case "climateLighting":
case "climateVentilation":
return "Clima";
case "climateCharts":
return "Gráficos Climáticos";
// ALL IRRIGATION / REGA PAGES
case "irrigation":
case "irrigationCharts":
case "irrigationFilters":
case "irrigationConsumption":
case "irrigationDrainage":
return "Rega";
default:
return "Painel Principal";
}
}
+410 -109
View File
@@ -1,18 +1,24 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
BarChart3,
ChevronDown,
ChevronLeft,
ChevronRight,
PanelLeft,
CircleHelp,
CloudSun,
Droplet,
Filter,
Gauge,
Home,
Info,
Lightbulb,
LogOut,
MonitorDot,
Moon,
Settings,
SlidersHorizontal,
Sun,
TabletSmartphone,
User,
Waves,
Wind,
} from "lucide-react";
@@ -24,17 +30,16 @@ type SidebarProps = {
theme: "dark" | "light";
activePage: AppPage;
collapsed: boolean;
userInitials: string;
onNavigate: (page: AppPage) => void;
onToggleCollapsed: () => void;
onToggleTheme: () => void;
};
const RADIUS = "rounded-[6px]";
const meteoItems: {
label: string;
page: AppPage;
icon: React.ElementType;
}[] = [
const meteoItems: { label: string; page: AppPage; icon: React.ElementType }[] =
[
{ label: "Previsões", page: "meteo", icon: CloudSun },
{ label: "Gráficos", page: "meteoCharts", icon: BarChart3 },
];
@@ -65,24 +70,54 @@ const utilityItems: {
label: string;
page: AppPage;
icon: React.ElementType;
}[] = [
{ label: "Consola (VNC)", page: "console", icon: TabletSmartphone },
{ label: "Configurações", page: "settings", icon: Settings },
];
}[] = [{ label: "Configurações", page: "settings", icon: Settings }];
export function Sidebar({
theme,
activePage,
collapsed,
userInitials,
onNavigate,
onToggleCollapsed,
onToggleTheme,
}: SidebarProps) {
const isDark = theme === "dark";
const ThemeIcon = isDark ? Moon : Sun;
const [meteoOpen, setMeteoOpen] = useState(true);
const [climateOpen, setClimateOpen] = useState(false);
const [irrigationOpen, setIrrigationOpen] = useState(false);
const [activeTreeItem, setActiveTreeItem] = useState<string | null>(null);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [sidebarHovered, setSidebarHovered] = useState(false);
const userButtonRef = useRef<HTMLButtonElement | null>(null);
const userMenuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!userMenuOpen) return;
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node;
if (
userButtonRef.current?.contains(target) ||
userMenuRef.current?.contains(target)
) {
return;
}
setUserMenuOpen(false);
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("touchstart", handlePointerDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("touchstart", handlePointerDown);
};
}, [userMenuOpen]);
const handleTreeClick = (key: string, page?: AppPage) => {
setActiveTreeItem(key);
@@ -92,97 +127,129 @@ export function Sidebar({
}
};
const handleTreeToggle = (section: "meteo" | "climate" | "irrigation") => {
const handleTreeToggle = (
section: "meteo" | "climate" | "irrigation"
) => {
if (collapsed) {
onToggleCollapsed();
}
if (section === "meteo") {
setMeteoOpen((current) => !current);
setClimateOpen(false);
setIrrigationOpen(false);
return;
}
if (section === "climate") {
setClimateOpen((current) => !current);
setMeteoOpen(false);
setIrrigationOpen(false);
return;
}
setIrrigationOpen((current) => !current);
setMeteoOpen(false);
setClimateOpen(false);
const sectionItems = {
meteo: meteoItems,
climate: climateItems,
irrigation: irrigationItems,
};
const firstItem = sectionItems[section][0];
setActiveTreeItem(`${section}:${firstItem.label}`);
if (firstItem.page) {
onNavigate(firstItem.page);
}
setMeteoOpen(section === "meteo");
setClimateOpen(section === "climate");
setIrrigationOpen(section === "irrigation");
};
const menuPositionClass = collapsed
? "left-[64px] bottom-[62px]"
: "left-[268px] bottom-[62px]";
return (
<>
<aside
onMouseEnter={() => setSidebarHovered(true)}
onMouseLeave={() => setSidebarHovered(false)}
className={
isDark
? `${collapsed ? "w-20" : "w-[280px]"} flex h-full min-h-0 flex-col border-r border-[#263247] bg-[#0B1220] text-slate-100 transition-all duration-200`
: `${collapsed ? "w-20" : "w-[280px]"} flex h-full min-h-0 flex-col border-r border-[#D7DEE8] bg-[#F3F6FA] text-[#0F172A] transition-all duration-200`
? `${collapsed ? "w-[56px]" : "w-[260px]"} relative flex h-full min-h-0 flex-col border-r border-[#263247] bg-[#0B1220] text-slate-100 transition-all duration-200`
: `${collapsed ? "w-[56px]" : "w-[260px]"} relative flex h-full min-h-0 flex-col border-r border-[#D7DEE8] bg-[#F3F6FA] text-[#0F172A] transition-all duration-200`
}
>
<div className={collapsed ? "shrink-0 px-3 pt-5" : "shrink-0 px-4 pt-5"}>
{collapsed && (
<button
type="button"
aria-label="Abrir menu"
onClick={onToggleCollapsed}
className="absolute inset-y-0 right-0 z-20 w-2 cursor-e-resize"
/>
)}
<div className={collapsed ? "shrink-0 px-2 py-3" : "shrink-0 px-3 py-3"}>
<div
className={
collapsed
? "mb-7 flex justify-center"
: isDark
? `relative mb-8 overflow-hidden ${RADIUS} border border-[#22314A] bg-[#101A2C] px-3.5 py-3.5`
: `relative mb-8 overflow-hidden ${RADIUS} border border-slate-200 bg-white px-3.5 py-3.5 shadow-sm`
? "flex items-center justify-center"
: "flex items-center justify-between gap-2"
}
>
{!collapsed && (
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(79,209,197,0.18),transparent_42%)]" />
)}
<div className={collapsed ? "relative flex justify-center" : "relative flex items-center gap-3"}>
<div
{collapsed ? (
<button
type="button"
onClick={onToggleCollapsed}
title="Abrir menu"
className={
isDark
? "flex h-12 w-12 shrink-0 items-center justify-center rounded-[6px] bg-[#0B1220] ring-1 ring-white/10"
: "flex h-12 w-12 shrink-0 items-center justify-center rounded-[6px] bg-slate-50 ring-1 ring-slate-200"
? "grid h-10 w-10 shrink-0 cursor-pointer place-items-center rounded-lg text-[#8EA0BA] transition hover:bg-[#111A2B] hover:text-white"
: "grid h-10 w-10 shrink-0 cursor-pointer place-items-center rounded-lg text-slate-600 transition hover:bg-white hover:text-slate-950"
}
>
{sidebarHovered ? (
<PanelLeft className="h-5 w-5" />
) : (
<img
src={logo}
alt="Litoral Regas"
className="h-14 w-14 object-contain"
className="h-7 w-7 object-contain"
/>
</div>
{!collapsed && (
<div className="min-w-0 flex-1">
<div
)}
</button>
) : (
<>
<button
type="button"
onClick={() => onNavigate("dashboard")}
className={
isDark
? "truncate text-[18px] font-black leading-none tracking-[-0.04em] text-white"
: "truncate text-[18px] font-black leading-none tracking-[-0.04em] text-slate-950"
? "flex h-10 min-w-0 flex-1 items-center rounded-lg px-2 text-sm font-semibold text-slate-100 transition hover:bg-[#111A2B]"
: "flex h-10 min-w-0 flex-1 items-center rounded-lg px-2 text-sm font-semibold text-slate-900 transition hover:bg-white"
}
>
Central LRX
</div>
<div className="mt-2 flex items-center gap-2">
<span
className={
isDark
? "truncate text-[11px] font-semibold text-[#8FA3BF]"
: "truncate text-[11px] font-semibold text-slate-500"
? "truncate text-[19px] font-semibold tracking-[0.02em] text-slate-100"
: "truncate text-[19px] font-semibold tracking-[0.02em] text-slate-950"
}
>
CENTRO DE OPERAÇÕES
Central LRX
</span>
</div>
</div>
</button>
<button
type="button"
onClick={onToggleCollapsed}
title="Recolher menu"
className={
isDark
? "grid h-10 w-10 cursor-pointer shrink-0 place-items-center rounded-lg text-[#8EA0BA] transition hover:bg-[#111A2B] hover:text-white"
: "grid h-10 w-10 cursor-pointer shrink-0 place-items-center rounded-lg text-slate-600 transition hover:bg-white hover:text-slate-950"
}
>
<PanelLeft className="h-5 w-5" />
</button>
</>
)}
</div>
</div>
</div>
<nav className="app-scrollbar min-h-0 flex-1 space-y-1.5 overflow-y-auto overflow-x-hidden px-4 pb-4">
<nav
className={
collapsed
? "app-scrollbar min-h-0 flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-2 pb-4"
: "app-scrollbar min-h-0 flex-1 space-y-1.5 overflow-y-auto overflow-x-hidden px-4 pb-4"
}
>
<NavItem
theme={theme}
collapsed={collapsed}
@@ -197,18 +264,18 @@ export function Sidebar({
}}
/>
<TreeSection
<NavItem
theme={theme}
collapsed={collapsed}
label="Meteorologia"
icon={CloudSun}
open={meteoOpen}
onToggle={() => handleTreeToggle("meteo")}
items={meteoItems}
sectionKey="meteo"
activeTreeItem={activeTreeItem}
label="Consola VNC"
page="console"
icon={TabletSmartphone}
activePage={activePage}
onItemClick={handleTreeClick}
activeTreeItem={activeTreeItem}
onNavigate={(page) => {
setActiveTreeItem(null);
onNavigate(page);
}}
/>
<NavItem
@@ -239,7 +306,21 @@ export function Sidebar({
}}
/>
<SectionLabel collapsed={collapsed} label="Operação" />
<SectionLabel collapsed={collapsed} label="Monitorização" />
<TreeSection
theme={theme}
collapsed={collapsed}
label="Meteorologia"
icon={CloudSun}
open={meteoOpen}
onToggle={() => handleTreeToggle("meteo")}
items={meteoItems}
sectionKey="meteo"
activeTreeItem={activeTreeItem}
activePage={activePage}
onItemClick={handleTreeClick}
/>
<TreeSection
theme={theme}
@@ -276,48 +357,222 @@ export function Sidebar({
const active = activePage === item.page && activeTreeItem === null;
return (
<button
<CollapsedTooltipWrapper
key={item.label}
collapsed={collapsed}
label={item.label}
isDark={isDark}
>
<button
type="button"
onClick={() => {
setActiveTreeItem(null);
onNavigate(item.page);
}}
title={collapsed ? item.label : undefined}
title={undefined}
className={navButtonClass(isDark, active, collapsed)}
>
{active && <ActiveIndicator isDark={isDark} />}
<Icon className={navIconClass(isDark, active)} />
{!collapsed && <span className="truncate">{item.label}</span>}
</button>
</CollapsedTooltipWrapper>
);
})}
</nav>
<div className="shrink-0 px-4 pb-5 pt-3">
<button
type="button"
onClick={onToggleCollapsed}
<div
className={
isDark
? `flex w-full items-center justify-center gap-2 ${RADIUS} border border-[#263247] bg-[#111A2B] px-4 py-3 text-[14px] font-semibold text-[#9BAAC1] transition hover:border-[#33445F] hover:bg-[#162033] hover:text-white`
: `flex w-full items-center justify-center gap-2 ${RADIUS} border border-[#CBD5E1] bg-white px-4 py-3 text-[14px] font-semibold text-slate-600 transition hover:bg-[#EAF0F7] hover:text-[#0F172A]`
collapsed
? isDark
? "shrink-0 border-t border-[#263247] bg-[#081120] px-2 py-3"
: "shrink-0 border-t border-[#D7DEE8] bg-[#EAF0F7] px-2 py-3"
: isDark
? "shrink-0 border-t border-[#263247] bg-[#081120] px-3 py-3"
: "shrink-0 border-t border-[#D7DEE8] bg-[#EAF0F7] px-3 py-3"
}
>
{collapsed ? (
<ChevronRight className="h-5 w-5" />
) : (
<CollapsedTooltipWrapper
collapsed={collapsed}
label="admin"
isDark={isDark}
>
<button
ref={userButtonRef}
type="button"
onClick={() => setUserMenuOpen((current) => !current)}
className={
collapsed
? isDark
? "relative mx-auto flex h-10 w-10 cursor-pointer items-center justify-center rounded-lg text-[#8EA0BA] transition hover:bg-white/[0.06] hover:text-white"
: "relative mx-auto flex h-10 w-10 cursor-pointer items-center justify-center rounded-lg text-slate-500 transition hover:bg-slate-100 hover:text-slate-950"
: isDark
? "flex h-11 w-full cursor-pointer items-center gap-3 rounded-[8px] px-2 text-slate-200 transition hover:bg-[#111A2B]"
: "flex h-11 w-full cursor-pointer items-center gap-3 rounded-[8px] px-2 text-slate-900 transition hover:bg-white"
}
>
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-[#263247] bg-[#131D2F] text-xs font-bold text-slate-100">
{userInitials}
</span>
{!collapsed && (
<>
<ChevronLeft className="h-5 w-5" />
<span>Recolher menu</span>
<span className="min-w-0 flex-1 truncate text-left text-sm font-medium">
admin
</span>
<ChevronDown
className={`h-4 w-4 shrink-0 text-slate-500 transition-transform ${userMenuOpen ? "rotate-180" : ""
}`}
/>
</>
)}
</button>
</CollapsedTooltipWrapper>
</div>
</aside>
{userMenuOpen && (
<div
ref={userMenuRef}
className={
isDark
? `fixed ${menuPositionClass} z-50 w-72 rounded-[10px] border border-[#263247] bg-[#111A2B] p-3 text-sm text-slate-300 shadow-2xl`
: `fixed ${menuPositionClass} z-50 w-72 rounded-[10px] border border-[#CBD5E1] bg-white p-3 text-sm text-slate-700 shadow-xl`
}
>
<div className={`mb-3 flex items-center gap-3 ${RADIUS} px-2 py-2`}>
<div
className={
isDark
? "flex h-9 w-9 items-center justify-center rounded-full border border-[#263247] bg-[#131D2F] text-sm font-bold text-slate-100"
: "flex h-9 w-9 items-center justify-center rounded-full border border-[#CBD5E1] bg-white text-sm font-bold text-slate-900"
}
>
{userInitials}
</div>
<div>
<div
className={
isDark
? "font-bold text-slate-100"
: "font-bold text-slate-950"
}
>
admin
</div>
<div className="text-xs text-slate-500">Administrador</div>
</div>
</div>
<Divider isDark={isDark} />
<MenuButton isDark={isDark} icon={User} label="Perfil" />
<MenuButton
isDark={isDark}
icon={SlidersHorizontal}
label="Preferências"
/>
<button
type="button"
onClick={onToggleTheme}
className={
isDark
? `flex w-full cursor-pointer items-center justify-between ${RADIUS} px-3 py-2 text-left text-sm font-medium text-slate-300 transition hover:bg-white/[0.05] hover:text-slate-100`
: `flex w-full cursor-pointer items-center justify-between ${RADIUS} px-3 py-2 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">
<Settings className="h-4 w-4 text-slate-500" />
Tema
</span>
<ThemeIcon className="h-4 w-4 text-slate-500" />
</button>
<Divider isDark={isDark} />
<MenuButton isDark={isDark} icon={CircleHelp} label="Ajuda" />
<MenuButton isDark={isDark} icon={Info} label="Sobre" />
<Divider isDark={isDark} />
<button
type="button"
className={
isDark
? `flex w-full cursor-pointer items-center gap-3 ${RADIUS} px-3 py-2 text-left text-sm font-medium text-red-300 transition hover:bg-red-500/10`
: `flex w-full cursor-pointer items-center gap-3 ${RADIUS} px-3 py-2 text-left text-sm font-medium text-red-600 transition hover:bg-red-50`
}
>
<LogOut className="h-4 w-4" />
Terminar sessão
</button>
</div>
)}
</>
);
}
// ─── Tooltip wrapper for collapsed mode ──────────────────────────────────────
function CollapsedTooltipWrapper({
collapsed,
label,
isDark,
children,
}: {
collapsed: boolean;
label: string;
isDark: boolean;
children: React.ReactNode;
}) {
const [visible, setVisible] = useState(false);
if (!collapsed) return <>{children}</>;
return (
<div
className="relative cursor-pointer"
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
>
{children}
{visible && (
<div
className={
isDark
? "pointer-events-none absolute left-[calc(100%+10px)] top-1/2 z-50 -translate-y-1/2 whitespace-nowrap rounded-md border border-[#263247] bg-[#111A2B] px-2.5 py-1.5 text-xs font-semibold text-slate-200 shadow-xl"
: "pointer-events-none absolute left-[calc(100%+10px)] top-1/2 z-50 -translate-y-1/2 whitespace-nowrap rounded-md border border-[#CBD5E1] bg-white px-2.5 py-1.5 text-xs font-semibold text-slate-800 shadow-lg"
}
>
{/* Arrow */}
<span
className={
isDark
? "absolute -left-[5px] top-1/2 -translate-y-1/2 border-4 border-transparent border-r-[#263247]"
: "absolute -left-[5px] top-1/2 -translate-y-1/2 border-4 border-transparent border-r-[#CBD5E1]"
}
/>
<span
className={
isDark
? "absolute -left-[3px] top-1/2 -translate-y-1/2 border-4 border-transparent border-r-[#111A2B]"
: "absolute -left-[3px] top-1/2 -translate-y-1/2 border-4 border-transparent border-r-white"
}
/>
{label}
</div>
)}
</div>
);
}
// ─── Tree section ─────────────────────────────────────────────────────────────
function TreeSection({
theme,
collapsed,
@@ -346,16 +601,22 @@ function TreeSection({
const isDark = theme === "dark";
const hasActiveChild = items.some((item) => {
const key = `${sectionKey}:${item.label}`;
return activeTreeItem === key || Boolean(item.page && activePage === item.page);
return (
activeTreeItem === key || Boolean(item.page && activePage === item.page)
);
});
return (
<div>
<CollapsedTooltipWrapper
collapsed={collapsed}
label={label}
isDark={isDark}
>
<button
type="button"
onClick={onToggle}
title={collapsed ? label : undefined}
title={undefined}
className={navButtonClass(isDark, hasActiveChild, collapsed)}
>
{hasActiveChild && <ActiveIndicator isDark={isDark} />}
@@ -372,6 +633,7 @@ function TreeSection({
</>
)}
</button>
</CollapsedTooltipWrapper>
{!collapsed && open && (
<div
@@ -385,7 +647,8 @@ function TreeSection({
const SubIcon = item.icon;
const key = `${sectionKey}:${item.label}`;
const active =
activeTreeItem === key || Boolean(item.page && activePage === item.page);
activeTreeItem === key ||
Boolean(item.page && activePage === item.page);
return (
<button
@@ -395,11 +658,11 @@ function TreeSection({
className={
active
? isDark
? "flex w-full items-center gap-2 rounded-[5px] border border-[#2C3D56] bg-[#131D2F] px-3 py-2.5 text-left text-[13px] font-bold text-white"
: "flex w-full items-center gap-2 rounded-[5px] border border-[#CBD5E1] bg-white px-3 py-2.5 text-left text-[13px] font-bold text-[#0F172A]"
? "flex w-full cursor-pointer items-center gap-2 rounded-[5px] border border-[#2C3D56] bg-[#131D2F] px-3 py-2.5 text-left text-[13px] font-bold text-white"
: "flex w-full cursor-pointer items-center gap-2 rounded-[5px] border border-[#CBD5E1] bg-white px-3 py-2.5 text-left text-[13px] font-bold text-[#0F172A]"
: isDark
? "flex w-full items-center gap-2 rounded-[5px] px-3 py-2.5 text-left text-[13px] font-semibold text-[#7D8EA8] transition hover:bg-[#111A2B] hover:text-slate-200"
: "flex w-full items-center gap-2 rounded-[5px] px-3 py-2.5 text-left text-[13px] font-semibold text-slate-500 transition hover:bg-white hover:text-[#0F172A]"
? "flex w-full cursor-pointer items-center gap-2 rounded-[5px] px-3 py-2.5 text-left text-[13px] font-semibold text-[#7D8EA8] transition hover:bg-[#111A2B] hover:text-slate-200"
: "flex w-full cursor-pointer items-center gap-2 rounded-[5px] px-3 py-2.5 text-left text-[13px] font-semibold text-slate-500 transition hover:bg-white hover:text-[#0F172A]"
}
>
<SubIcon
@@ -430,7 +693,7 @@ function SectionLabel({
label: string;
}) {
if (collapsed) {
return <div className="py-2" />;
return <div className="h-2" />;
}
return (
@@ -455,23 +718,23 @@ function ActiveIndicator({ isDark }: { isDark: boolean }) {
}
function navButtonClass(isDark: boolean, active: boolean, collapsed: boolean) {
const alignment = collapsed ? "justify-center px-0" : "px-4";
if (collapsed) {
return isDark
? `relative mx-auto grid h-9 w-9 place-items-center rounded-lg text-[#8EA0BA] transition hover:bg-white/[0.06] hover:text-white cursor-pointer${active ? " bg-white/[0.08] text-white" : ""
}`
: `relative mx-auto grid h-9 w-9 place-items-center rounded-lg text-slate-500 transition hover:bg-slate-100 hover:text-slate-950 cursor-pointer${active ? " bg-slate-200 text-slate-950" : ""
}`;
}
if (active) {
return isDark
? `relative flex w-full items-center gap-3 ${RADIUS}
${alignment}
py-3.5 text-left text-[14px]
font-bold text-white`
: `relative flex w-full items-center gap-3 ${RADIUS}
${alignment}
py-3.5 text-left text-[14px]
font-bold text-[#0F172A]`;
? `relative flex w-full cursor-pointer items-center gap-3 ${RADIUS} px-4 py-3.5 text-left text-[14px] font-bold text-white`
: `relative flex w-full cursor-pointer items-center gap-3 ${RADIUS} px-4 py-3.5 text-left text-[14px] font-bold text-[#0F172A]`;
}
return isDark
? `relative flex w-full items-center gap-3 ${RADIUS} ${alignment} py-3.5 text-left text-[14px] font-semibold text-[#8EA0BA] transition hover:bg-[#111A2B] hover:text-white`
: `relative flex w-full items-center gap-3 ${RADIUS} ${alignment} py-3.5 text-left text-[14px] font-semibold text-slate-600 transition hover:bg-white hover:text-[#0F172A]`;
? `relative flex w-full cursor-pointer items-center gap-3 ${RADIUS} px-4 py-3.5 text-left text-[14px] font-semibold text-[#8EA0BA] transition hover:bg-[#111A2B] hover:text-white`
: `relative flex w-full cursor-pointer items-center gap-3 ${RADIUS} px-4 py-3.5 text-left text-[14px] font-semibold text-slate-600 transition hover:bg-white hover:text-[#0F172A]`;
}
function navIconClass(isDark: boolean, active: boolean) {
@@ -486,6 +749,38 @@ function navIconClass(isDark: boolean, active: boolean) {
: "h-5 w-5 shrink-0 text-slate-500";
}
function MenuButton({
isDark,
icon: Icon,
label,
}: {
isDark: boolean;
icon: React.ElementType;
label: string;
}) {
return (
<button
type="button"
className={
isDark
? `flex w-full cursor-pointer items-center gap-3 ${RADIUS} px-3 py-2 text-left text-sm font-medium text-slate-300 transition hover:bg-white/[0.05] hover:text-slate-100`
: `flex w-full cursor-pointer items-center gap-3 ${RADIUS} px-3 py-2 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-100 hover:text-slate-950`
}
>
<Icon className="h-4 w-4 text-slate-500" />
{label}
</button>
);
}
function Divider({ isDark }: { isDark: boolean }) {
return (
<div
className={isDark ? "my-2 h-px bg-white/10" : "my-2 h-px bg-slate-200"}
/>
);
}
function NavItem({
theme,
collapsed,
@@ -509,15 +804,21 @@ function NavItem({
const active = activePage === page && activeTreeItem === null;
return (
<CollapsedTooltipWrapper
collapsed={collapsed}
label={label}
isDark={isDark}
>
<button
type="button"
onClick={() => onNavigate(page)}
title={collapsed ? label : undefined}
title={undefined}
className={navButtonClass(isDark, active, collapsed)}
>
{active && <ActiveIndicator isDark={isDark} />}
<Icon className={navIconClass(isDark, active)} />
{!collapsed && <span className="truncate">{label}</span>}
</button>
</CollapsedTooltipWrapper>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { WindowControls } from "./WindowControls";
import logo from "../../assets/logo5.png";
export function TitleBar() {
return (
<header
data-tauri-drag-region
className="fixed left-0 right-0 top-0 z-[9999] flex h-9 items-center justify-between border-b border-white/10 bg-[#030814] pl-3"
>
<div
data-tauri-drag-region
className="flex items-center gap-2.5"
>
<img
src={logo}
alt="Central LRX"
draggable={false}
className="h-5 w-5 object-contain"
/>
<span className="text-sm font-semibold text-slate-100">
Central LRX
</span>
</div>
<WindowControls />
</header>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Minus, Square, X } from "lucide-react";
const appWindow = getCurrentWindow();
export function WindowControls() {
return (
<div className="flex h-10 items-center">
<button
type="button"
onClick={() => appWindow.minimize()}
className="grid h-9 w-10 place-items-center text-slate-400 hover:bg-white/10 hover:text-white"
>
<Minus className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => appWindow.toggleMaximize()}
className="grid h-9 w-10 place-items-center text-slate-400 hover:bg-white/10 hover:text-white"
>
<Square className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => appWindow.close()}
className="grid h-9 w-10 place-items-center text-slate-400 hover:bg-red-500 hover:text-white"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
@@ -61,16 +61,16 @@ export function ChartWindowPage({ theme }: ChartWindowPageProps) {
const latestChartRef = useRef<ChartWorkspaceItem | null>(null);
const titleBarClass = isDark
? "flex h-9 shrink-0 items-center bg-[#1F232A] text-[#D7DEE8]"
: "flex h-9 shrink-0 items-center border-b border-[#D7DEE8] bg-white text-[#0F172A]";
? "flex h-10 shrink-0 items-center border-b border-white/10 bg-[#071421] text-slate-100"
: "flex h-10 shrink-0 items-center border-b border-slate-200 bg-white text-slate-950";
const titleButtonClass = isDark
? "grid h-9 w-11 place-items-center text-[#A8B3C7] transition hover:bg-white/10 hover:text-white"
: "grid h-9 w-11 place-items-center text-slate-500 transition hover:bg-[#F8FAFC] hover:text-[#0F172A]";
? "grid h-10 w-11 place-items-center text-slate-400 transition hover:bg-white/10 hover:text-white"
: "grid h-10 w-11 place-items-center text-slate-500 transition hover:bg-slate-100 hover:text-slate-950";
const closeButtonClass = isDark
? "grid h-9 w-11 place-items-center text-[#A8B3C7] transition hover:bg-red-500 hover:text-white"
: "grid h-9 w-11 place-items-center text-slate-500 transition hover:bg-red-500 hover:text-white";
? "grid h-10 w-11 place-items-center text-slate-400 transition hover:bg-red-500 hover:text-white"
: "grid h-10 w-11 place-items-center text-slate-500 transition hover:bg-red-500 hover:text-white";
useChartWorkspacePersistence({
scope,
@@ -215,7 +215,7 @@ export function ChartWindowPage({ theme }: ChartWindowPageProps) {
<div
className={
isDark
? "flex h-screen items-center justify-center bg-[#07101B] text-sm font-bold text-[#7F8CA3]"
? "flex h-screen items-center justify-center bg-[#071421] text-sm font-bold text-slate-500"
: "flex h-screen items-center justify-center bg-white text-sm font-bold text-slate-500"
}
>
@@ -256,8 +256,8 @@ export function ChartWindowPage({ theme }: ChartWindowPageProps) {
<div
className={
isDark
? "flex h-screen flex-col overflow-hidden bg-[#07101B] text-white"
: "flex h-screen flex-col overflow-hidden bg-white text-[#0F172A]"
? "flex h-screen flex-col overflow-hidden bg-[#071421] text-slate-100"
: "flex h-screen flex-col overflow-hidden bg-white text-slate-950"
}
>
<header className={titleBarClass}>
@@ -296,8 +296,8 @@ export function ChartWindowPage({ theme }: ChartWindowPageProps) {
<main
className={
isDark
? "min-h-0 flex-1 bg-[#07101B] [&>section]:h-full [&>section]:rounded-none [&>section]:border-0"
: "min-h-0 flex-1 bg-white [&>section]:h-full [&>section]:rounded-none [&>section]:border-0 [&>section]:shadow-none"
? "min-h-0 flex-1 bg-[#071421] [&>section]:h-full [&>section]:rounded-none [&>section]:border-0 [&>section]:bg-[#071421]"
: "min-h-0 flex-1 bg-white [&>section]:h-full [&>section]:rounded-none [&>section]:border-0 [&>section]:bg-white [&>section]:shadow-none"
}
>
<WorkspaceChart
@@ -537,7 +537,7 @@ export function ClimateChartsPage({ theme }: ClimateChartsPageProps) {
}, [layoutMode]);
return (
<div className="flex h-[calc(100vh-88px)] min-h-0 flex-col gap-3 overflow-hidden pb-2">
<div className="flex h-full min-h-0 flex-col gap-2 overflow-hidden">
<div
className={
isDark
@@ -664,6 +664,7 @@ export function ClimateChartsPage({ theme }: ClimateChartsPageProps) {
<WorkspaceChartContainer
key={chartItem.id}
theme={theme}
layoutMode={layoutMode}
chartItem={chartItem}
chartableVariables={chartableVariables}
connected={connected}
@@ -760,6 +761,7 @@ export function ClimateChartsPage({ theme }: ClimateChartsPageProps) {
function WorkspaceChartContainer({
theme,
layoutMode,
chartItem,
chartableVariables,
connected,
@@ -779,6 +781,7 @@ function WorkspaceChartContainer({
moveDetachedChart
}: {
theme: "dark" | "light";
layoutMode: ChartLayoutMode;
chartItem: ChartWorkspaceItem;
chartableVariables: ChartVariable[];
connected: boolean;
@@ -864,7 +867,7 @@ function WorkspaceChartContainer({
: "relative min-h-0 overflow-hidden rounded-[7px]"
}
>
<div className="absolute right-4 top-4 z-20 flex items-center gap-1.5">
<div className="absolute right-3 top-3 z-20 flex items-center gap-1">
<button type="button" title="Configurar" className={floatingIconClass(theme)} onClick={() => setConfigChartId(chartItem.id)}>
<Cog className="h-4 w-4" />
</button>
@@ -921,6 +924,7 @@ function WorkspaceChartContainer({
<WorkspaceChart
theme={theme}
compact={layoutMode === "fourGrid"}
chart={chartConfig}
configuredVariableCount={chartItem.selectedSensorKeys.length}
loading={loading || variablesStillResolving}
@@ -1012,8 +1016,8 @@ function WorkspaceChartContainer({
function floatingIconClass(theme: "dark" | "light") {
return theme === "dark"
? `${RADIUS} grid h-8 w-8 place-items-center border border-[#263247] bg-[#111A2B] text-[#A8B3C7] transition hover:text-white`
: `${RADIUS} grid h-8 w-8 place-items-center border border-[#D7DEE8] bg-white text-slate-500 transition hover:text-[#0F172A]`;
? `${RADIUS} grid h-7 w-7 place-items-center border border-[#263247] bg-[#111A2B] text-[#A8B3C7] transition hover:text-white`
: `${RADIUS} grid h-7 w-7 place-items-center border border-[#D7DEE8] bg-white text-slate-500 transition hover:text-[#0F172A]`;
}
function SavedChartsDropdown({
+76 -32
View File
@@ -32,6 +32,7 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
const wsRef = useRef<WebSocket | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const rgbaRef = useRef<Uint8ClampedArray | null>(null);
const framebufferRef = useRef({
width: 0,
height: 0,
@@ -45,28 +46,19 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
const [frameSize, setFrameSize] = useState({ width: 0, height: 0 });
const [lastFrameAt, setLastFrameAt] = useState<string | null>(null);
const closeSocket = useCallback(() => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "disconnect" }));
ws.close();
} else if (ws) {
ws.close();
}
wsRef.current = null;
}, []);
const clearFrame = useCallback(() => {
const canvas = canvasRef.current;
const ctx = ctxRef.current;
if (canvas && ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.width = 0;
canvas.height = 0;
}
ctxRef.current = null;
rgbaRef.current = null;
framebufferRef.current = {
width: 0,
height: 0,
@@ -76,16 +68,44 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
setLastFrameAt(null);
}, []);
const drawFrame = useCallback((buffer: ArrayBuffer) => {
const canvas = canvasRef.current;
const closeSocket = useCallback(() => {
const ws = wsRef.current;
wsRef.current = null;
if (!canvas) {
if (!ws) return;
try {
ws.onopen = null;
ws.onmessage = null;
ws.onerror = null;
ws.onclose = null;
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "disconnect" }));
ws.close();
return;
}
if (ws.readyState === WebSocket.CONNECTING) {
ws.close();
}
} catch {
// Ignore cleanup errors.
}
}, []);
const drawFrame = useCallback((buffer: ArrayBuffer) => {
const canvas = canvasRef.current;
if (!canvas) return;
if (buffer.byteLength <= 8) return;
const view = new DataView(buffer);
const width = view.getInt32(0);
const height = view.getInt32(4);
if (!width || !height || width <= 0 || height <= 0) return;
const pixels = new Uint8ClampedArray(buffer, 8);
framebufferRef.current = {
@@ -101,10 +121,7 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
}
const ctx = ctxRef.current;
if (!ctx) {
return;
}
if (!ctx) return;
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
@@ -125,6 +142,7 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
rgba[index + 2] = pixels[index];
rgba[index + 3] = 255;
}
const imageData = ctx.createImageData(width, height);
imageData.data.set(rgba);
@@ -144,16 +162,21 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
return;
}
closeSocket();
clearFrame();
setError(null);
setState("CONNECTING_WS");
closeSocket();
window.setTimeout(() => {
const socket = new WebSocket(websocketUrl);
wsRef.current = socket;
socket.binaryType = "arraybuffer";
socket.onopen = () => {
if (wsRef.current !== socket) return;
setState("CONNECTING_VNC");
socket.send(
@@ -167,13 +190,21 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
};
socket.onmessage = (event) => {
if (wsRef.current !== socket) return;
if (typeof event.data === "string") {
const message = JSON.parse(event.data) as {
let message: {
type?: string;
state?: string;
message?: string;
};
try {
message = JSON.parse(event.data);
} catch {
return;
}
if (message.type === "state") {
if (message.state === "CONNECTING") {
setState("CONNECTING_VNC");
@@ -191,12 +222,14 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
}
if (message.state === "DISCONNECTED") {
clearFrame();
setState("DISCONNECTED");
return;
}
}
if (message.type === "error") {
clearFrame();
setError(message.message ?? "Erro VNC desconhecido.");
setState("ERROR");
}
@@ -208,11 +241,16 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
};
socket.onerror = () => {
if (wsRef.current !== socket) return;
clearFrame();
setError("Erro na ligação WebSocket da consola VNC.");
setState("ERROR");
};
socket.onclose = () => {
if (wsRef.current !== socket) return;
wsRef.current = null;
clearFrame();
@@ -220,13 +258,23 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
current === "ERROR" ? "ERROR" : "DISCONNECTED",
);
};
}, 100);
},
[closeSocket, drawFrame, host, password, port, websocketUrl],
[
clearFrame,
closeSocket,
drawFrame,
host,
password,
port,
websocketUrl,
],
);
const disconnect = useCallback(() => {
closeSocket();
clearFrame();
setError(null);
setState("DISCONNECTED");
}, [clearFrame, closeSocket]);
@@ -235,13 +283,8 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
const ws = wsRef.current;
const framebuffer = framebufferRef.current;
if (!canvas || !ws || ws.readyState !== WebSocket.OPEN) {
return;
}
if (!framebuffer.width || !framebuffer.height) {
return;
}
if (!canvas || !ws || ws.readyState !== WebSocket.OPEN) return;
if (!framebuffer.width || !framebuffer.height) return;
const rect = canvas.getBoundingClientRect();
@@ -278,8 +321,9 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
useEffect(() => {
return () => {
closeSocket();
clearFrame();
};
}, [closeSocket]);
}, [clearFrame, closeSocket]);
return {
canvasRef,
+1 -1
View File
@@ -109,7 +109,7 @@ export function ConsolePage({ theme }: ConsolePageProps) {
type="button"
disabled={vnc.connecting}
onClick={() => (vnc.connected ? vnc.disconnect() : vnc.connect())}
className="mt-5 flex h-11 w-full items-center justify-center gap-2 rounded-[5px] bg-[#4FD1C5] text-sm font-black text-[#031014] transition hover:bg-[#5FE1D5] disabled:cursor-not-allowed disabled:opacity-50"
className="mt-5 flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-[5px] bg-[#4FD1C5] text-sm font-black text-[#031014] transition hover:bg-[#5FE1D5] disabled:cursor-not-allowed disabled:opacity-50"
>
<Plug className="h-4 w-4" />
{vnc.connected ? "Desligar sessão" : vnc.connecting ? "A ligar..." : "Iniciar sessão"}
@@ -82,6 +82,25 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
const [movingChartId, setMovingChartId] = useState<string | null>(null);
const [newChartOpen, setNewChartOpen] = useState(false);
const [placingChartId, setPlacingChartId] = useState<string | null>(null);
const [viewportCompact, setViewportCompact] = useState(() =>
typeof window !== "undefined"
? window.innerWidth <= 1280 || window.innerHeight <= 720
: false,
);
useEffect(() => {
const updateViewportCompact = () => {
setViewportCompact(window.innerWidth <= 1280 || window.innerHeight <= 720);
};
updateViewportCompact();
window.addEventListener("resize", updateViewportCompact);
return () => {
window.removeEventListener("resize", updateViewportCompact);
};
}, []);
const changeLayoutMode = (nextLayoutMode: ChartLayoutMode) => {
const nextVisibleCount = getVisibleSlotCount(nextLayoutMode);
@@ -536,7 +555,7 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
};
}, [layoutMode]);
return (
<div className="flex h-[calc(100vh-88px)] min-h-0 flex-col gap-3 overflow-hidden pb-2">
<div className="flex h-full min-h-0 flex-col gap-2 overflow-hidden">
<div
className={
isDark
@@ -680,6 +699,8 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
detachChart={detachChart}
attachChart={attachChart}
moveDetachedChart={moveDetachedChart}
viewportCompact={viewportCompact}
layoutMode={layoutMode}
/>
))}
@@ -775,7 +796,9 @@ function WorkspaceChartContainer({
placeChartHere,
detachChart,
attachChart,
moveDetachedChart
moveDetachedChart,
viewportCompact,
layoutMode,
}: {
theme: "dark" | "light";
chartItem: ChartWorkspaceItem;
@@ -795,9 +818,17 @@ function WorkspaceChartContainer({
detachChart: (chartId: string) => void;
attachChart: (chartId: string) => void | Promise<void>;
moveDetachedChart: (chartId: string, x: number, y: number) => void;
viewportCompact: boolean;
layoutMode: ChartLayoutMode;
}) {
const isMoving = movingChartId === chartItem.id;
const shouldUseCompactMode =
!chartItem.detached &&
(layoutMode === "fourGrid" ||
layoutMode === "twoRows" ||
(viewportCompact && layoutMode === "twoColumns"));
const canReceiveMove =
(movingChartId !== null && movingChartId !== chartItem.id) ||
(placingChartId !== null && placingChartId !== chartItem.id);
@@ -924,6 +955,7 @@ function WorkspaceChartContainer({
configuredVariableCount={chartItem.selectedSensorKeys.length}
loading={loading || variablesStillResolving}
detached={chartItem.detached}
compact={shouldUseCompactMode}
onDetach={() => detachChart(chartItem.id)}
onAttach={() => attachChart(chartItem.id)}
onTimeRangeChange={(range) =>
+8 -4
View File
@@ -539,7 +539,7 @@ export function MeteoChartsPage({
}, [layoutMode]);
return (
<div className="flex h-[calc(100vh-88px)] min-h-0 flex-col gap-3 overflow-hidden pb-2">
<div className="flex h-full min-h-0 flex-col gap-2 overflow-hidden">
<div
className={
isDark
@@ -666,6 +666,7 @@ export function MeteoChartsPage({
<WorkspaceChartContainer
key={chartItem.id}
theme={theme}
layoutMode={layoutMode}
chartItem={chartItem}
chartableVariables={chartableVariables}
connected={connected}
@@ -773,6 +774,7 @@ export function MeteoChartsPage({
function WorkspaceChartContainer({
theme,
layoutMode,
chartItem,
chartableVariables,
connected,
@@ -792,6 +794,7 @@ function WorkspaceChartContainer({
moveDetachedChart,
}: {
theme: "dark" | "light";
layoutMode: ChartLayoutMode;
chartItem: ChartWorkspaceItem;
chartableVariables: ChartVariable[];
connected: boolean;
@@ -875,7 +878,7 @@ function WorkspaceChartContainer({
: "relative min-h-0 overflow-hidden rounded-[7px]"
}
>
<div className="absolute right-4 top-4 z-20 flex items-center gap-1.5">
<div className="absolute right-3 top-3 z-20 flex items-center gap-1">
<button
type="button"
title="Configurar"
@@ -942,6 +945,7 @@ function WorkspaceChartContainer({
<WorkspaceChart
theme={theme}
compact={layoutMode === "fourGrid"}
chart={chartConfig}
configuredVariableCount={chartItem.selectedSensorKeys.length}
loading={loading || variablesStillResolving}
@@ -1158,8 +1162,8 @@ function timeRangeToMilliseconds(timeRange: WorkspaceChartTimeRange): number {
function floatingIconClass(theme: "dark" | "light") {
return theme === "dark"
? `${RADIUS} grid h-8 w-8 place-items-center border border-[#263247] bg-[#111A2B] text-[#A8B3C7] transition hover:text-white`
: `${RADIUS} grid h-8 w-8 place-items-center border border-[#D7DEE8] bg-white text-slate-500 transition hover:text-[#0F172A]`;
? `${RADIUS} grid h-7 w-7 place-items-center border border-[#263247] bg-[#111A2B] text-[#A8B3C7] transition hover:text-white`
: `${RADIUS} grid h-7 w-7 place-items-center border border-[#D7DEE8] bg-white text-slate-500 transition hover:text-[#0F172A]`;
}
function SavedChartsDropdown({
@@ -39,7 +39,6 @@ export function useTelemetryChartSeries(
const controller = new AbortController();
async function loadHistory(showLoading: boolean) {
const startedAt = performance.now();
try {
const to = new Date();
@@ -49,14 +48,6 @@ export function useTelemetryChartSeries(
setLoading(true);
}
console.log("[TelemetryChartSeries REQUEST]", {
sensorKeys,
timeRange,
interval,
from: from.toISOString(),
to: to.toISOString(),
});
const entries = await Promise.all(
sensorKeys.map(async (key) => {
const params = new URLSearchParams({
@@ -96,10 +87,6 @@ export function useTelemetryChartSeries(
setSeriesByKey(Object.fromEntries(entries));
initializedRef.current = true;
console.log("[TelemetryChartSeries DONE]", {
sensorCount: sensorKeys.length,
durationMs: Math.round(performance.now() - startedAt),
});
} catch (error) {
if (controller.signal.aborted) return;
@@ -28,8 +28,6 @@ export function useTelemetryStream() {
const payload = (await response.json()) as TelemetryBroadcastMessage;
console.log("[TelemetryStream INITIAL]", payload);
setMessage(payload);
} catch (error) {
if (controller.signal.aborted) return;
@@ -49,14 +47,11 @@ export function useTelemetryStream() {
reconnectDelay: 3000,
onConnect: () => {
console.log("[TelemetryStream WS CONNECTED]");
setConnected(true);
client.subscribe("/topic/telemetry/latest", (frame) => {
const payload = JSON.parse(frame.body) as TelemetryBroadcastMessage;
console.log("[TelemetryStream WS MESSAGE]", payload);
setMessage(payload);
setInitialLoading(false);
});