ui update removing topbar, responsiveness work, console behavior, custom titlebar
This commit is contained in:
@@ -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
@@ -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 |
@@ -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();
|
||||
|
||||
@@ -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`
|
||||
}`
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user