ui update removing topbar, responsiveness work, console behavior, custom titlebar
This commit is contained in:
@@ -13,8 +13,12 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "Central LRX",
|
"title": "Central LRX",
|
||||||
"width": 800,
|
"decorations": false,
|
||||||
"height": 600
|
"width": 1600,
|
||||||
|
"height": 900,
|
||||||
|
"minWidth": 1280,
|
||||||
|
"minHeight": 720,
|
||||||
|
"maximized": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
+44
-39
@@ -11,6 +11,7 @@ import { ChartWindowPage } from "../features/chartworkspace/pages/ChartWindowPag
|
|||||||
import { SettingsPage } from "../features/settings/pages/SettingsPage";
|
import { SettingsPage } from "../features/settings/pages/SettingsPage";
|
||||||
import SynopticPage from "../features/synoptic/pages/SynopticPage";
|
import SynopticPage from "../features/synoptic/pages/SynopticPage";
|
||||||
import MeteoChartsPage from "../features/meteo/pages/MeteoChartsPage";
|
import MeteoChartsPage from "../features/meteo/pages/MeteoChartsPage";
|
||||||
|
import { TitleBar } from "../components/window/TitleBar";
|
||||||
|
|
||||||
export type AppPage =
|
export type AppPage =
|
||||||
| "dashboard"
|
| "dashboard"
|
||||||
@@ -43,52 +44,56 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell activePage={activePage} onNavigate={setActivePage}>
|
<div className="h-screen overflow-hidden bg-[#071421]">
|
||||||
{({ theme }) => {
|
<TitleBar />
|
||||||
if (activePage === "meteo") {
|
|
||||||
return (
|
|
||||||
<MeteoPage
|
|
||||||
theme={theme}
|
|
||||||
onOpenMeteoCharts={() => setActivePage("meteoCharts")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activePage === "meteoCharts") {
|
<div className="pt-9">
|
||||||
return (
|
<div className="h-[calc(100vh-36px)] overflow-hidden">
|
||||||
<MeteoChartsPage
|
<AppShell activePage={activePage} onNavigate={setActivePage}>
|
||||||
theme={theme}
|
{({ theme }) => {
|
||||||
/>
|
if (activePage === "meteo") {
|
||||||
);
|
return (
|
||||||
}
|
<MeteoPage
|
||||||
|
theme={theme}
|
||||||
|
onOpenMeteoCharts={() => setActivePage("meteoCharts")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (activePage === "climateCharts") {
|
if (activePage === "meteoCharts") {
|
||||||
return <ClimateChartsPage theme={theme} />;
|
return <MeteoChartsPage theme={theme} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activePage === "console") return <ConsolePage theme={theme} />;
|
if (activePage === "climateCharts") {
|
||||||
|
return <ClimateChartsPage theme={theme} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (activePage === "maincharts") {
|
if (activePage === "console") return <ConsolePage theme={theme} />;
|
||||||
return <MainChartsPage theme={theme} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activePage === "settings") {
|
if (activePage === "maincharts") {
|
||||||
return <SettingsPage theme={theme} />;
|
return <MainChartsPage theme={theme} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activePage === "synoptic") {
|
if (activePage === "settings") {
|
||||||
return <SynopticPage theme={theme} />;
|
return <SettingsPage theme={theme} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (activePage === "synoptic") {
|
||||||
<DashboardPage
|
return <SynopticPage theme={theme} />;
|
||||||
theme={theme}
|
}
|
||||||
onOpenMeteo={() => setActivePage("meteo")}
|
|
||||||
onNavigate={setActivePage}
|
return (
|
||||||
/>
|
<DashboardPage
|
||||||
);
|
theme={theme}
|
||||||
}}
|
onOpenMeteo={() => setActivePage("meteo")}
|
||||||
</AppShell>
|
onNavigate={setActivePage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</AppShell>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
@@ -64,6 +64,7 @@ type Props = {
|
|||||||
theme: "dark" | "light";
|
theme: "dark" | "light";
|
||||||
chart: WorkspaceChartConfig;
|
chart: WorkspaceChartConfig;
|
||||||
detached?: boolean;
|
detached?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
dragHandle?: React.ReactNode;
|
dragHandle?: React.ReactNode;
|
||||||
onHeaderPointerDown?: (event: React.PointerEvent) => void;
|
onHeaderPointerDown?: (event: React.PointerEvent) => void;
|
||||||
onModeChange: (mode: WorkspaceChartMode) => void;
|
onModeChange: (mode: WorkspaceChartMode) => void;
|
||||||
@@ -93,6 +94,8 @@ type YAxisConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RADIUS = "rounded-[8px]";
|
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 }[] = [
|
const RANGE_OPTIONS: { value: WorkspaceChartTimeRange; label: string }[] = [
|
||||||
{ value: "15m", label: "15M" },
|
{ value: "15m", label: "15M" },
|
||||||
@@ -115,6 +118,7 @@ export function WorkspaceChart({
|
|||||||
chart,
|
chart,
|
||||||
loading = false,
|
loading = false,
|
||||||
detached = false,
|
detached = false,
|
||||||
|
compact = false,
|
||||||
dragHandle,
|
dragHandle,
|
||||||
onHeaderPointerDown,
|
onHeaderPointerDown,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
@@ -267,9 +271,7 @@ export function WorkspaceChart({
|
|||||||
};
|
};
|
||||||
}, [primaryVariable]);
|
}, [primaryVariable]);
|
||||||
|
|
||||||
const yDomain: [number | "auto", number | "auto"] = zeroBaseline
|
const yDomain = useMemo(() => buildPaddedYDomain(zeroBaseline), [zeroBaseline]);
|
||||||
? [0, "auto"]
|
|
||||||
: ["auto", "auto"];
|
|
||||||
|
|
||||||
const allowedIntervalOptions = useMemo(() => {
|
const allowedIntervalOptions = useMemo(() => {
|
||||||
if (chart.mode === "bar") {
|
if (chart.mode === "bar") {
|
||||||
@@ -286,16 +288,20 @@ export function WorkspaceChart({
|
|||||||
className={
|
className={
|
||||||
detached
|
detached
|
||||||
? isDark
|
? 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-visible 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-white text-slate-950`
|
||||||
: isDark
|
: 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-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-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-slate-200 bg-white text-slate-950 shadow-[0_14px_34px_rgba(15,23,42,0.08)]`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<header
|
<header
|
||||||
onPointerDown={onHeaderPointerDown}
|
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">
|
<div className="flex min-w-0 flex-1 items-start gap-3">
|
||||||
{dragHandle && <div className="mt-1 shrink-0">{dragHandle}</div>}
|
{dragHandle && <div className="mt-1 shrink-0">{dragHandle}</div>}
|
||||||
@@ -304,7 +310,11 @@ export function WorkspaceChart({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{chart.title ? (
|
{chart.title ? (
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<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}
|
{chart.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -318,7 +328,7 @@ export function WorkspaceChart({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{chart.subtitle ? (
|
{chart.subtitle && !compact ? (
|
||||||
<p className="mt-1 truncate text-xs text-[#8290A6]">
|
<p className="mt-1 truncate text-xs text-[#8290A6]">
|
||||||
{chart.subtitle}
|
{chart.subtitle}
|
||||||
</p>
|
</p>
|
||||||
@@ -363,22 +373,34 @@ export function WorkspaceChart({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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
|
<section
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? `${RADIUS} flex min-h-0 flex-1 flex-col bg-[#09111F] p-2 sm:p-3`
|
? compact
|
||||||
: `${RADIUS} flex min-h-0 flex-1 flex-col bg-slate-50 p-2 sm:p-3`
|
? `${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">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setRangeMenuOpen((value) => !value)}
|
onClick={() => setRangeMenuOpen((value) => !value)}
|
||||||
className={buttonClass(isDark)}
|
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" />
|
<ChevronDown className="ml-1.5 inline h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -415,7 +437,7 @@ export function WorkspaceChart({
|
|||||||
onClick={() => setIntervalMenuOpen((value) => !value)}
|
onClick={() => setIntervalMenuOpen((value) => !value)}
|
||||||
className={buttonClass(isDark)}
|
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" />
|
<ChevronDown className="ml-1.5 inline h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -451,7 +473,7 @@ export function WorkspaceChart({
|
|||||||
onClick={() => setModeMenuOpen((value) => !value)}
|
onClick={() => setModeMenuOpen((value) => !value)}
|
||||||
className={buttonClass(isDark)}
|
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" />
|
<ChevronDown className="ml-1.5 inline h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -492,8 +514,8 @@ export function WorkspaceChart({
|
|||||||
: buttonClass(isDark)
|
: buttonClass(isDark)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Activity className="mr-1.5 inline h-3.5 w-3.5" />
|
<Activity className={compact ? "inline h-3.5 w-3.5" : "mr-1.5 inline h-3.5 w-3.5"} />
|
||||||
Indicadores
|
{!compact && "Indicadores"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -515,7 +537,7 @@ export function WorkspaceChart({
|
|||||||
Variáveis registadas
|
Variáveis registadas
|
||||||
</p>
|
</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) => (
|
{chart.variables.map((variable) => (
|
||||||
<button
|
<button
|
||||||
key={variable.key}
|
key={variable.key}
|
||||||
@@ -569,8 +591,12 @@ export function WorkspaceChart({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-2 flex shrink-0 items-center justify-between gap-3 overflow-x-auto pb-1">
|
<div className={
|
||||||
<div className="flex shrink-0 items-center gap-4 text-[11px]">
|
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) => (
|
{visibleVariables.map((variable) => (
|
||||||
<span
|
<span
|
||||||
key={variable.key}
|
key={variable.key}
|
||||||
@@ -596,19 +622,23 @@ export function WorkspaceChart({
|
|||||||
Média:{" "}
|
Média:{" "}
|
||||||
<b>{formatValue(stats.average, displayUnit(primaryVariable.unit))}</b>
|
<b>{formatValue(stats.average, displayUnit(primaryVariable.unit))}</b>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
{!compact && (
|
||||||
Máx:{" "}
|
<>
|
||||||
<b>{formatValue(stats.max, displayUnit(primaryVariable.unit))}</b>
|
<span>
|
||||||
</span>
|
Máx:{" "}
|
||||||
<span>
|
<b>{formatValue(stats.max, displayUnit(primaryVariable.unit))}</b>
|
||||||
Mín:{" "}
|
</span>
|
||||||
<b>{formatValue(stats.min, displayUnit(primaryVariable.unit))}</b>
|
<span>
|
||||||
</span>
|
Mín:{" "}
|
||||||
|
<b>{formatValue(stats.min, displayUnit(primaryVariable.unit))}</b>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 ? (
|
{visibleVariables.length === 0 && !shouldShowLoading ? (
|
||||||
<EmptyChartMessage message="Escolha pelo menos uma variável." />
|
<EmptyChartMessage message="Escolha pelo menos uma variável." />
|
||||||
) : data.length === 0 && !shouldShowLoading ? (
|
) : data.length === 0 && !shouldShowLoading ? (
|
||||||
@@ -616,12 +646,15 @@ export function WorkspaceChart({
|
|||||||
) : data.length > 0 ? (
|
) : data.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
{chart.mode === "bar" ? (
|
{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
|
<ChartScaffold
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
yDomain={yDomain}
|
yDomain={yDomain}
|
||||||
yAxes={yAxes}
|
yAxes={yAxes}
|
||||||
chartTimeRange={chart.timeRange}
|
chartTimeRange={chart.timeRange}
|
||||||
|
chartInterval={chart.interval}
|
||||||
|
chartMode={chart.mode}
|
||||||
|
compact={compact}
|
||||||
/>
|
/>
|
||||||
{visibleVariables.map((variable) => (
|
{visibleVariables.map((variable) => (
|
||||||
<Bar
|
<Bar
|
||||||
@@ -634,22 +667,27 @@ export function WorkspaceChart({
|
|||||||
radius={[3, 3, 0, 0]}
|
radius={[3, 3, 0, 0]}
|
||||||
opacity={0.78}
|
opacity={0.78}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
maxBarSize={42}
|
maxBarSize={compact ? 34 : 42}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</BarChart>
|
</BarChart>
|
||||||
) : chart.mode === "area" ? (
|
) : chart.mode === "area" ? (
|
||||||
<AreaChart data={displayData}>
|
<AreaChart data={displayData} margin={chartMargin(compact, yAxes.length)}>
|
||||||
<ChartScaffold
|
<ChartScaffold
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
yDomain={yDomain}
|
yDomain={yDomain}
|
||||||
yAxes={yAxes}
|
yAxes={yAxes}
|
||||||
chartTimeRange={chart.timeRange}
|
chartTimeRange={chart.timeRange}
|
||||||
|
chartInterval={chart.interval}
|
||||||
|
chartMode={chart.mode}
|
||||||
|
compact={compact}
|
||||||
/>
|
/>
|
||||||
{renderReferenceLines(
|
{renderReferenceLines(
|
||||||
visibleVariables,
|
visibleVariables,
|
||||||
showIndicators,
|
showIndicators,
|
||||||
stats.average,
|
stats.average,
|
||||||
|
yAxes,
|
||||||
|
primaryVariable,
|
||||||
)}
|
)}
|
||||||
{visibleVariables.map((variable) => (
|
{visibleVariables.map((variable) => (
|
||||||
<Area
|
<Area
|
||||||
@@ -662,26 +700,31 @@ export function WorkspaceChart({
|
|||||||
stroke={variable.color}
|
stroke={variable.color}
|
||||||
fill={variable.color}
|
fill={variable.color}
|
||||||
fillOpacity={0.08}
|
fillOpacity={0.08}
|
||||||
strokeWidth={2}
|
strokeWidth={compact ? 1.75 : 2}
|
||||||
dot={false}
|
dot={false}
|
||||||
activeDot={{ r: 4 }}
|
activeDot={{ r: compact ? 3 : 4 }}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
connectNulls={false}
|
connectNulls={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
) : (
|
) : (
|
||||||
<LineChart data={displayData}>
|
<LineChart data={displayData} margin={chartMargin(compact, yAxes.length)}>
|
||||||
<ChartScaffold
|
<ChartScaffold
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
yDomain={yDomain}
|
yDomain={yDomain}
|
||||||
yAxes={yAxes}
|
yAxes={yAxes}
|
||||||
chartTimeRange={chart.timeRange}
|
chartTimeRange={chart.timeRange}
|
||||||
|
chartInterval={chart.interval}
|
||||||
|
chartMode={chart.mode}
|
||||||
|
compact={compact}
|
||||||
/>
|
/>
|
||||||
{renderReferenceLines(
|
{renderReferenceLines(
|
||||||
visibleVariables,
|
visibleVariables,
|
||||||
showIndicators,
|
showIndicators,
|
||||||
stats.average,
|
stats.average,
|
||||||
|
yAxes,
|
||||||
|
primaryVariable,
|
||||||
)}
|
)}
|
||||||
{visibleVariables.map((variable) => (
|
{visibleVariables.map((variable) => (
|
||||||
<Line
|
<Line
|
||||||
@@ -692,9 +735,9 @@ export function WorkspaceChart({
|
|||||||
dataKey={variable.key}
|
dataKey={variable.key}
|
||||||
yAxisId={getYAxisId(variable, yAxes)}
|
yAxisId={getYAxisId(variable, yAxes)}
|
||||||
stroke={variable.color}
|
stroke={variable.color}
|
||||||
strokeWidth={2}
|
strokeWidth={compact ? 1.75 : 2}
|
||||||
dot={false}
|
dot={false}
|
||||||
activeDot={{ r: 4 }}
|
activeDot={{ r: compact ? 3 : 4 }}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
connectNulls={false}
|
connectNulls={false}
|
||||||
/>
|
/>
|
||||||
@@ -714,8 +757,8 @@ export function WorkspaceChart({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showIndicators && primaryVariable && (
|
{showIndicators && primaryVariable && !compact && (
|
||||||
<div className="mt-2 shrink-0 overflow-x-auto border-t border-white/[0.06] pt-2">
|
<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">
|
<div className="flex min-w-max items-center gap-5 text-xs">
|
||||||
<InlineMetric
|
<InlineMetric
|
||||||
label="Atual"
|
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({
|
function ChartScaffold({
|
||||||
isDark,
|
isDark,
|
||||||
yDomain,
|
yDomain,
|
||||||
yAxes,
|
yAxes,
|
||||||
chartTimeRange
|
chartTimeRange,
|
||||||
|
chartInterval,
|
||||||
|
chartMode,
|
||||||
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
isDark: boolean;
|
isDark: boolean;
|
||||||
yDomain: [number | "auto", number | "auto"];
|
yDomain: [(dataMin: number) => number, (dataMax: number) => number];
|
||||||
yAxes: YAxisConfig[];
|
yAxes: YAxisConfig[];
|
||||||
chartTimeRange: WorkspaceChartTimeRange;
|
chartTimeRange: WorkspaceChartTimeRange;
|
||||||
|
chartInterval: WorkspaceChartInterval;
|
||||||
|
chartMode: WorkspaceChartMode;
|
||||||
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const xPadMs = getXAxisPaddingMs(chartInterval, chartMode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
@@ -780,14 +877,18 @@ function ChartScaffold({
|
|||||||
dataKey="timestampMs"
|
dataKey="timestampMs"
|
||||||
type="number"
|
type="number"
|
||||||
scale="time"
|
scale="time"
|
||||||
domain={["dataMin", "dataMax"]}
|
domain={[
|
||||||
padding={{ left: 24, right: 24 }}
|
(dataMin: number) => dataMin - xPadMs,
|
||||||
tick={{ fill: "#64748b", fontSize: 10 }}
|
(dataMax: number) => dataMax + xPadMs,
|
||||||
|
]}
|
||||||
|
padding={{ left: 0, right: 0 }}
|
||||||
|
allowDataOverflow={false}
|
||||||
|
tick={{ fill: "#64748b", fontSize: compact ? 9 : 10 }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={{
|
axisLine={{
|
||||||
stroke: isDark ? "rgba(148,163,184,0.12)" : "#cbd5e1",
|
stroke: isDark ? "rgba(148,163,184,0.12)" : "#cbd5e1",
|
||||||
}}
|
}}
|
||||||
minTickGap={34}
|
minTickGap={compact ? 20 : 34}
|
||||||
tickFormatter={(value) =>
|
tickFormatter={(value) =>
|
||||||
formatAxisTime(new Date(Number(value)).toISOString(), chartTimeRange)
|
formatAxisTime(new Date(Number(value)).toISOString(), chartTimeRange)
|
||||||
}
|
}
|
||||||
@@ -798,12 +899,15 @@ function ChartScaffold({
|
|||||||
yAxisId={axis.id}
|
yAxisId={axis.id}
|
||||||
orientation={axis.orientation}
|
orientation={axis.orientation}
|
||||||
domain={yDomain}
|
domain={yDomain}
|
||||||
tick={{ fill: axis.color, fontSize: 10 }}
|
allowDataOverflow={false}
|
||||||
|
tick={{ fill: axis.color, fontSize: compact ? 9 : 10 }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
|
tickMargin={compact ? 4 : 6}
|
||||||
|
tickFormatter={(value) => formatDecimal(Number(value), 1)}
|
||||||
axisLine={{
|
axisLine={{
|
||||||
stroke: isDark ? "rgba(148,163,184,0.18)" : "#cbd5e1",
|
stroke: isDark ? "rgba(148,163,184,0.18)" : "#cbd5e1",
|
||||||
}}
|
}}
|
||||||
width={52}
|
width={compact ? 44 : 52}
|
||||||
label={
|
label={
|
||||||
axis.unit
|
axis.unit
|
||||||
? {
|
? {
|
||||||
@@ -814,7 +918,7 @@ function ChartScaffold({
|
|||||||
? "insideLeft"
|
? "insideLeft"
|
||||||
: "insideRight",
|
: "insideRight",
|
||||||
fill: axis.color,
|
fill: axis.color,
|
||||||
fontSize: 10,
|
fontSize: compact ? 9 : 10,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -822,6 +926,7 @@ function ChartScaffold({
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
wrapperStyle={{ zIndex: 80, outline: "none" }}
|
||||||
cursor={{
|
cursor={{
|
||||||
fill: "rgba(255,255,255,0.04)",
|
fill: "rgba(255,255,255,0.04)",
|
||||||
stroke: "rgba(79,209,197,0.12)",
|
stroke: "rgba(79,209,197,0.12)",
|
||||||
@@ -831,6 +936,14 @@ function ChartScaffold({
|
|||||||
const timestamp = payload?.[0]?.payload?.timestamp;
|
const timestamp = payload?.[0]?.payload?.timestamp;
|
||||||
return timestamp ? formatTooltipDate(timestamp) : "";
|
return timestamp ? formatTooltipDate(timestamp) : "";
|
||||||
}}
|
}}
|
||||||
|
formatter={(value, name) => {
|
||||||
|
const numericValue = typeof value === "number" ? value : Number(value);
|
||||||
|
|
||||||
|
return [
|
||||||
|
formatDecimal(numericValue, 2),
|
||||||
|
String(name ?? ""),
|
||||||
|
];
|
||||||
|
}}
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
background: isDark ? "#111827" : "#ffffff",
|
background: isDark ? "#111827" : "#ffffff",
|
||||||
border: isDark
|
border: isDark
|
||||||
@@ -858,8 +971,8 @@ function DropdownPanel({
|
|||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? `absolute z-50 gap-1 ${RADIUS} border border-[#263247] bg-[#111827] p-2 shadow-2xl ${className}`
|
? `absolute z-[1000] 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-slate-200 bg-white p-2 shadow-xl ${className}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -879,15 +992,19 @@ function renderReferenceLines(
|
|||||||
visibleVariables: WorkspaceChartVariable[],
|
visibleVariables: WorkspaceChartVariable[],
|
||||||
showIndicators: boolean,
|
showIndicators: boolean,
|
||||||
average: number | null,
|
average: number | null,
|
||||||
|
yAxes: YAxisConfig[],
|
||||||
|
primaryVariable: WorkspaceChartVariable | null,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showIndicators && average !== null && (
|
{showIndicators && average !== null && primaryVariable && (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
|
yAxisId={getYAxisId(primaryVariable, yAxes)}
|
||||||
y={average}
|
y={average}
|
||||||
stroke="#94a3b8"
|
stroke="#94a3b8"
|
||||||
strokeDasharray="4 5"
|
strokeDasharray="4 5"
|
||||||
strokeOpacity={0.28}
|
strokeOpacity={0.28}
|
||||||
|
ifOverflow="extendDomain"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -895,10 +1012,12 @@ function renderReferenceLines(
|
|||||||
variable.limit !== undefined ? (
|
variable.limit !== undefined ? (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
key={`${variable.key}-limit`}
|
key={`${variable.key}-limit`}
|
||||||
|
yAxisId={getYAxisId(variable, yAxes)}
|
||||||
y={variable.limit}
|
y={variable.limit}
|
||||||
stroke="#ef4444"
|
stroke="#ef4444"
|
||||||
strokeDasharray="6 6"
|
strokeDasharray="6 6"
|
||||||
strokeOpacity={0.55}
|
strokeOpacity={0.55}
|
||||||
|
ifOverflow="extendDomain"
|
||||||
/>
|
/>
|
||||||
) : null,
|
) : null,
|
||||||
)}
|
)}
|
||||||
@@ -955,21 +1074,26 @@ function windowButtonClass(isDark: boolean) {
|
|||||||
: `${RADIUS} p-2 text-slate-400 transition hover:bg-slate-100 hover:text-slate-900`;
|
: `${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) {
|
function formatValue(value: number | null, unit?: string) {
|
||||||
if (value === null || Number.isNaN(value)) return "--";
|
|
||||||
|
|
||||||
const normalizedUnit = displayUnit(unit);
|
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) {
|
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 prefix = value >= 0 ? "+" : "";
|
||||||
const normalizedUnit = displayUnit(unit);
|
const normalizedUnit = displayUnit(unit);
|
||||||
|
|
||||||
return `${prefix}${value.toFixed(1)}${normalizedUnit ? ` ${normalizedUnit}` : ""}`;
|
return `${prefix}${formatDecimal(value, 2)}${normalizedUnit ? ` ${normalizedUnit}` : ""}`;
|
||||||
}
|
}
|
||||||
function formatRangeLabel(range: WorkspaceChartTimeRange) {
|
function formatRangeLabel(range: WorkspaceChartTimeRange) {
|
||||||
return range.toUpperCase();
|
return range.toUpperCase();
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { type ReactNode, useEffect, useState } from "react";
|
import { type ReactNode, useEffect, useState } from "react";
|
||||||
import { Sidebar } from "../navigation/Sidebar";
|
import { Sidebar } from "../navigation/Sidebar";
|
||||||
import { TopBar } from "../layout/TopBar";
|
|
||||||
import { useTelemetryStream } from "../../features/telemetry/hooks/useTelemetryStream";
|
import { useTelemetryStream } from "../../features/telemetry/hooks/useTelemetryStream";
|
||||||
import { useNotifications } from "../../features/notifications/hooks/useNotifications";
|
|
||||||
import { useCurrentUser } from "../../features/auth/hooks/useCurrentUser";
|
import { useCurrentUser } from "../../features/auth/hooks/useCurrentUser";
|
||||||
import type { TelemetrySnapshot } from "../../types/telemetry";
|
import type { TelemetrySnapshot } from "../../types/telemetry";
|
||||||
import type { AppPage } from "../../app/App";
|
import type { AppPage } from "../../app/App";
|
||||||
@@ -24,10 +22,11 @@ const THEME_STORAGE_KEY = "app-theme";
|
|||||||
|
|
||||||
export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
|
export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
|
||||||
const telemetry = useTelemetryStream();
|
const telemetry = useTelemetryStream();
|
||||||
const notifications = useNotifications();
|
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
|
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
const [autoCompact, setAutoCompact] = useState(false);
|
||||||
|
|
||||||
const [theme, setTheme] = useState<Theme>(() => {
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
return stored === "light" || stored === "dark" ? stored : "dark";
|
return stored === "light" || stored === "dark" ? stored : "dark";
|
||||||
@@ -44,12 +43,28 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
|
|||||||
setTheme((current) => (current === "dark" ? "light" : "dark"));
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "fixed inset-0 overflow-hidden bg-[#07101B] text-slate-100"
|
? "relative h-full overflow-hidden bg-[#07101B] text-slate-100"
|
||||||
: "fixed inset-0 overflow-hidden bg-white text-slate-950"
|
: "relative h-full overflow-hidden bg-white text-slate-950"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<style>{`
|
<style>{`
|
||||||
@@ -89,8 +104,10 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
|
|||||||
theme={theme}
|
theme={theme}
|
||||||
activePage={activePage}
|
activePage={activePage}
|
||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
|
userInitials={currentUser.initials}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
|
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
|
||||||
|
onToggleTheme={toggleTheme}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -103,26 +120,16 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
<TopBar
|
|
||||||
connected={telemetry.connected}
|
|
||||||
lastTimestamp={telemetry.lastTimestamp}
|
|
||||||
notificationCount={notifications.unreadCount}
|
|
||||||
userInitials={currentUser.initials}
|
|
||||||
theme={theme}
|
|
||||||
activePage={activePage}
|
|
||||||
onToggleTheme={toggleTheme}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main
|
<main
|
||||||
className={
|
className={
|
||||||
isDark
|
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-[#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-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 {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronLeft,
|
PanelLeft,
|
||||||
ChevronRight,
|
CircleHelp,
|
||||||
CloudSun,
|
CloudSun,
|
||||||
Droplet,
|
Droplet,
|
||||||
Filter,
|
Filter,
|
||||||
Gauge,
|
Gauge,
|
||||||
Home,
|
Home,
|
||||||
|
Info,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
|
LogOut,
|
||||||
MonitorDot,
|
MonitorDot,
|
||||||
|
Moon,
|
||||||
Settings,
|
Settings,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Sun,
|
||||||
TabletSmartphone,
|
TabletSmartphone,
|
||||||
|
User,
|
||||||
Waves,
|
Waves,
|
||||||
Wind,
|
Wind,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -24,17 +30,16 @@ type SidebarProps = {
|
|||||||
theme: "dark" | "light";
|
theme: "dark" | "light";
|
||||||
activePage: AppPage;
|
activePage: AppPage;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
|
userInitials: string;
|
||||||
onNavigate: (page: AppPage) => void;
|
onNavigate: (page: AppPage) => void;
|
||||||
onToggleCollapsed: () => void;
|
onToggleCollapsed: () => void;
|
||||||
|
onToggleTheme: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const RADIUS = "rounded-[6px]";
|
const RADIUS = "rounded-[6px]";
|
||||||
|
|
||||||
const meteoItems: {
|
const meteoItems: { label: string; page: AppPage; icon: React.ElementType }[] =
|
||||||
label: string;
|
[
|
||||||
page: AppPage;
|
|
||||||
icon: React.ElementType;
|
|
||||||
}[] = [
|
|
||||||
{ label: "Previsões", page: "meteo", icon: CloudSun },
|
{ label: "Previsões", page: "meteo", icon: CloudSun },
|
||||||
{ label: "Gráficos", page: "meteoCharts", icon: BarChart3 },
|
{ label: "Gráficos", page: "meteoCharts", icon: BarChart3 },
|
||||||
];
|
];
|
||||||
@@ -65,24 +70,54 @@ const utilityItems: {
|
|||||||
label: string;
|
label: string;
|
||||||
page: AppPage;
|
page: AppPage;
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
}[] = [
|
}[] = [{ label: "Configurações", page: "settings", icon: Settings }];
|
||||||
{ label: "Consola (VNC)", page: "console", icon: TabletSmartphone },
|
|
||||||
{ label: "Configurações", page: "settings", icon: Settings },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
theme,
|
theme,
|
||||||
activePage,
|
activePage,
|
||||||
collapsed,
|
collapsed,
|
||||||
|
userInitials,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
onToggleCollapsed,
|
onToggleCollapsed,
|
||||||
|
onToggleTheme,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
|
const ThemeIcon = isDark ? Moon : Sun;
|
||||||
|
|
||||||
const [meteoOpen, setMeteoOpen] = useState(true);
|
const [meteoOpen, setMeteoOpen] = useState(true);
|
||||||
const [climateOpen, setClimateOpen] = useState(false);
|
const [climateOpen, setClimateOpen] = useState(false);
|
||||||
const [irrigationOpen, setIrrigationOpen] = useState(false);
|
const [irrigationOpen, setIrrigationOpen] = useState(false);
|
||||||
const [activeTreeItem, setActiveTreeItem] = useState<string | null>(null);
|
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) => {
|
const handleTreeClick = (key: string, page?: AppPage) => {
|
||||||
setActiveTreeItem(key);
|
setActiveTreeItem(key);
|
||||||
@@ -92,232 +127,452 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTreeToggle = (section: "meteo" | "climate" | "irrigation") => {
|
const handleTreeToggle = (
|
||||||
|
section: "meteo" | "climate" | "irrigation"
|
||||||
|
) => {
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
onToggleCollapsed();
|
onToggleCollapsed();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (section === "meteo") {
|
const sectionItems = {
|
||||||
setMeteoOpen((current) => !current);
|
meteo: meteoItems,
|
||||||
setClimateOpen(false);
|
climate: climateItems,
|
||||||
setIrrigationOpen(false);
|
irrigation: irrigationItems,
|
||||||
return;
|
};
|
||||||
|
|
||||||
|
const firstItem = sectionItems[section][0];
|
||||||
|
|
||||||
|
setActiveTreeItem(`${section}:${firstItem.label}`);
|
||||||
|
|
||||||
|
if (firstItem.page) {
|
||||||
|
onNavigate(firstItem.page);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (section === "climate") {
|
setMeteoOpen(section === "meteo");
|
||||||
setClimateOpen((current) => !current);
|
setClimateOpen(section === "climate");
|
||||||
setMeteoOpen(false);
|
setIrrigationOpen(section === "irrigation");
|
||||||
setIrrigationOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIrrigationOpen((current) => !current);
|
|
||||||
setMeteoOpen(false);
|
|
||||||
setClimateOpen(false);
|
|
||||||
};
|
};
|
||||||
|
const menuPositionClass = collapsed
|
||||||
|
? "left-[64px] bottom-[62px]"
|
||||||
|
: "left-[268px] bottom-[62px]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<>
|
||||||
className={
|
<aside
|
||||||
isDark
|
onMouseEnter={() => setSidebarHovered(true)}
|
||||||
? `${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`
|
onMouseLeave={() => setSidebarHovered(false)}
|
||||||
: `${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`
|
className={
|
||||||
}
|
isDark
|
||||||
>
|
? `${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`
|
||||||
<div className={collapsed ? "shrink-0 px-3 pt-5" : "shrink-0 px-4 pt-5"}>
|
: `${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
|
{collapsed && (
|
||||||
? "mb-7 flex justify-center"
|
<button
|
||||||
: isDark
|
type="button"
|
||||||
? `relative mb-8 overflow-hidden ${RADIUS} border border-[#22314A] bg-[#101A2C] px-3.5 py-3.5`
|
aria-label="Abrir menu"
|
||||||
: `relative mb-8 overflow-hidden ${RADIUS} border border-slate-200 bg-white px-3.5 py-3.5 shadow-sm`
|
onClick={onToggleCollapsed}
|
||||||
}
|
className="absolute inset-y-0 right-0 z-20 w-2 cursor-e-resize"
|
||||||
>
|
/>
|
||||||
{!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 ? "shrink-0 px-2 py-3" : "shrink-0 px-3 py-3"}>
|
||||||
)}
|
<div
|
||||||
|
className={
|
||||||
<div className={collapsed ? "relative flex justify-center" : "relative flex items-center gap-3"}>
|
collapsed
|
||||||
<div
|
? "flex items-center justify-center"
|
||||||
className={
|
: "flex items-center justify-between gap-2"
|
||||||
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"
|
{collapsed ? (
|
||||||
}
|
<button
|
||||||
>
|
type="button"
|
||||||
<img
|
onClick={onToggleCollapsed}
|
||||||
src={logo}
|
title="Abrir menu"
|
||||||
alt="Litoral Regas"
|
className={
|
||||||
className="h-14 w-14 object-contain"
|
isDark
|
||||||
/>
|
? "grid h-10 w-10 shrink-0 cursor-pointer place-items-center rounded-lg text-[#8EA0BA] transition hover:bg-[#111A2B] hover:text-white"
|
||||||
</div>
|
: "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"
|
||||||
|
}
|
||||||
{!collapsed && (
|
>
|
||||||
<div className="min-w-0 flex-1">
|
{sidebarHovered ? (
|
||||||
<div
|
<PanelLeft className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={logo}
|
||||||
|
alt="Litoral Regas"
|
||||||
|
className="h-7 w-7 object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate("dashboard")}
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "truncate text-[18px] font-black leading-none tracking-[-0.04em] text-white"
|
? "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]"
|
||||||
: "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-900 transition hover:bg-white"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Central LRX
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "truncate text-[11px] font-semibold text-[#8FA3BF]"
|
? "truncate text-[19px] font-semibold tracking-[0.02em] text-slate-100"
|
||||||
: "truncate text-[11px] font-semibold text-slate-500"
|
: "truncate text-[19px] font-semibold tracking-[0.02em] text-slate-950"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
CENTRO DE OPERAÇÕES
|
Central LRX
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
|
||||||
|
<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>
|
</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
|
||||||
<NavItem
|
|
||||||
theme={theme}
|
|
||||||
collapsed={collapsed}
|
|
||||||
label="Painel Principal"
|
|
||||||
page="dashboard"
|
|
||||||
icon={Home}
|
|
||||||
activePage={activePage}
|
|
||||||
activeTreeItem={activeTreeItem}
|
|
||||||
onNavigate={(page) => {
|
|
||||||
setActiveTreeItem(null);
|
|
||||||
onNavigate(page);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TreeSection
|
|
||||||
theme={theme}
|
|
||||||
collapsed={collapsed}
|
|
||||||
label="Meteorologia"
|
|
||||||
icon={CloudSun}
|
|
||||||
open={meteoOpen}
|
|
||||||
onToggle={() => handleTreeToggle("meteo")}
|
|
||||||
items={meteoItems}
|
|
||||||
sectionKey="meteo"
|
|
||||||
activeTreeItem={activeTreeItem}
|
|
||||||
activePage={activePage}
|
|
||||||
onItemClick={handleTreeClick}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<NavItem
|
|
||||||
theme={theme}
|
|
||||||
collapsed={collapsed}
|
|
||||||
label="Gráficos Gerais"
|
|
||||||
page="maincharts"
|
|
||||||
icon={BarChart3}
|
|
||||||
activePage={activePage}
|
|
||||||
activeTreeItem={activeTreeItem}
|
|
||||||
onNavigate={(page) => {
|
|
||||||
setActiveTreeItem(null);
|
|
||||||
onNavigate(page);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<NavItem
|
|
||||||
theme={theme}
|
|
||||||
collapsed={collapsed}
|
|
||||||
label="Sinótico"
|
|
||||||
page="synoptic"
|
|
||||||
icon={MonitorDot}
|
|
||||||
activePage={activePage}
|
|
||||||
activeTreeItem={activeTreeItem}
|
|
||||||
onNavigate={(page) => {
|
|
||||||
setActiveTreeItem(null);
|
|
||||||
onNavigate(page);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SectionLabel collapsed={collapsed} label="Operação" />
|
|
||||||
|
|
||||||
<TreeSection
|
|
||||||
theme={theme}
|
|
||||||
collapsed={collapsed}
|
|
||||||
label="Clima"
|
|
||||||
icon={Wind}
|
|
||||||
open={climateOpen}
|
|
||||||
onToggle={() => handleTreeToggle("climate")}
|
|
||||||
items={climateItems}
|
|
||||||
sectionKey="climate"
|
|
||||||
activeTreeItem={activeTreeItem}
|
|
||||||
activePage={activePage}
|
|
||||||
onItemClick={handleTreeClick}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TreeSection
|
|
||||||
theme={theme}
|
|
||||||
collapsed={collapsed}
|
|
||||||
label="Rega"
|
|
||||||
icon={Droplet}
|
|
||||||
open={irrigationOpen}
|
|
||||||
onToggle={() => handleTreeToggle("irrigation")}
|
|
||||||
items={irrigationItems}
|
|
||||||
sectionKey="irrigation"
|
|
||||||
activeTreeItem={activeTreeItem}
|
|
||||||
activePage={activePage}
|
|
||||||
onItemClick={handleTreeClick}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SectionLabel collapsed={collapsed} label="Sistema" />
|
|
||||||
|
|
||||||
{utilityItems.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const active = activePage === item.page && activeTreeItem === null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.label}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setActiveTreeItem(null);
|
|
||||||
onNavigate(item.page);
|
|
||||||
}}
|
|
||||||
title={collapsed ? item.label : undefined}
|
|
||||||
className={navButtonClass(isDark, active, collapsed)}
|
|
||||||
>
|
|
||||||
{active && <ActiveIndicator isDark={isDark} />}
|
|
||||||
<Icon className={navIconClass(isDark, active)} />
|
|
||||||
{!collapsed && <span className="truncate">{item.label}</span>}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="shrink-0 px-4 pb-5 pt-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onToggleCollapsed}
|
|
||||||
className={
|
className={
|
||||||
isDark
|
collapsed
|
||||||
? `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`
|
? "app-scrollbar min-h-0 flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-2 pb-4"
|
||||||
: `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]`
|
: "app-scrollbar min-h-0 flex-1 space-y-1.5 overflow-y-auto overflow-x-hidden px-4 pb-4"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{collapsed ? (
|
<NavItem
|
||||||
<ChevronRight className="h-5 w-5" />
|
theme={theme}
|
||||||
) : (
|
collapsed={collapsed}
|
||||||
<>
|
label="Painel Principal"
|
||||||
<ChevronLeft className="h-5 w-5" />
|
page="dashboard"
|
||||||
<span>Recolher menu</span>
|
icon={Home}
|
||||||
</>
|
activePage={activePage}
|
||||||
)}
|
activeTreeItem={activeTreeItem}
|
||||||
</button>
|
onNavigate={(page) => {
|
||||||
</div>
|
setActiveTreeItem(null);
|
||||||
</aside>
|
onNavigate(page);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NavItem
|
||||||
|
theme={theme}
|
||||||
|
collapsed={collapsed}
|
||||||
|
label="Consola VNC"
|
||||||
|
page="console"
|
||||||
|
icon={TabletSmartphone}
|
||||||
|
activePage={activePage}
|
||||||
|
activeTreeItem={activeTreeItem}
|
||||||
|
onNavigate={(page) => {
|
||||||
|
setActiveTreeItem(null);
|
||||||
|
onNavigate(page);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NavItem
|
||||||
|
theme={theme}
|
||||||
|
collapsed={collapsed}
|
||||||
|
label="Gráficos Gerais"
|
||||||
|
page="maincharts"
|
||||||
|
icon={BarChart3}
|
||||||
|
activePage={activePage}
|
||||||
|
activeTreeItem={activeTreeItem}
|
||||||
|
onNavigate={(page) => {
|
||||||
|
setActiveTreeItem(null);
|
||||||
|
onNavigate(page);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NavItem
|
||||||
|
theme={theme}
|
||||||
|
collapsed={collapsed}
|
||||||
|
label="Sinótico"
|
||||||
|
page="synoptic"
|
||||||
|
icon={MonitorDot}
|
||||||
|
activePage={activePage}
|
||||||
|
activeTreeItem={activeTreeItem}
|
||||||
|
onNavigate={(page) => {
|
||||||
|
setActiveTreeItem(null);
|
||||||
|
onNavigate(page);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
collapsed={collapsed}
|
||||||
|
label="Clima"
|
||||||
|
icon={Wind}
|
||||||
|
open={climateOpen}
|
||||||
|
onToggle={() => handleTreeToggle("climate")}
|
||||||
|
items={climateItems}
|
||||||
|
sectionKey="climate"
|
||||||
|
activeTreeItem={activeTreeItem}
|
||||||
|
activePage={activePage}
|
||||||
|
onItemClick={handleTreeClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TreeSection
|
||||||
|
theme={theme}
|
||||||
|
collapsed={collapsed}
|
||||||
|
label="Rega"
|
||||||
|
icon={Droplet}
|
||||||
|
open={irrigationOpen}
|
||||||
|
onToggle={() => handleTreeToggle("irrigation")}
|
||||||
|
items={irrigationItems}
|
||||||
|
sectionKey="irrigation"
|
||||||
|
activeTreeItem={activeTreeItem}
|
||||||
|
activePage={activePage}
|
||||||
|
onItemClick={handleTreeClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SectionLabel collapsed={collapsed} label="Sistema" />
|
||||||
|
|
||||||
|
{utilityItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = activePage === item.page && activeTreeItem === null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsedTooltipWrapper
|
||||||
|
key={item.label}
|
||||||
|
collapsed={collapsed}
|
||||||
|
label={item.label}
|
||||||
|
isDark={isDark}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTreeItem(null);
|
||||||
|
onNavigate(item.page);
|
||||||
|
}}
|
||||||
|
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={
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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 && (
|
||||||
|
<>
|
||||||
|
<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({
|
function TreeSection({
|
||||||
theme,
|
theme,
|
||||||
collapsed,
|
collapsed,
|
||||||
@@ -346,32 +601,39 @@ function TreeSection({
|
|||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
const hasActiveChild = items.some((item) => {
|
const hasActiveChild = items.some((item) => {
|
||||||
const key = `${sectionKey}:${item.label}`;
|
const key = `${sectionKey}:${item.label}`;
|
||||||
|
return (
|
||||||
return activeTreeItem === key || Boolean(item.page && activePage === item.page);
|
activeTreeItem === key || Boolean(item.page && activePage === item.page)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<CollapsedTooltipWrapper
|
||||||
type="button"
|
collapsed={collapsed}
|
||||||
onClick={onToggle}
|
label={label}
|
||||||
title={collapsed ? label : undefined}
|
isDark={isDark}
|
||||||
className={navButtonClass(isDark, hasActiveChild, collapsed)}
|
|
||||||
>
|
>
|
||||||
{hasActiveChild && <ActiveIndicator isDark={isDark} />}
|
<button
|
||||||
<Icon className={navIconClass(isDark, hasActiveChild)} />
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
title={undefined}
|
||||||
|
className={navButtonClass(isDark, hasActiveChild, collapsed)}
|
||||||
|
>
|
||||||
|
{hasActiveChild && <ActiveIndicator isDark={isDark} />}
|
||||||
|
<Icon className={navIconClass(isDark, hasActiveChild)} />
|
||||||
|
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<>
|
<>
|
||||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||||
|
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={`h-4 w-4 shrink-0 text-[#6F819B] transition-transform duration-200 ${open ? "rotate-180" : ""
|
className={`h-4 w-4 shrink-0 text-[#6F819B] transition-transform duration-200 ${open ? "rotate-180" : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
</CollapsedTooltipWrapper>
|
||||||
|
|
||||||
{!collapsed && open && (
|
{!collapsed && open && (
|
||||||
<div
|
<div
|
||||||
@@ -385,7 +647,8 @@ function TreeSection({
|
|||||||
const SubIcon = item.icon;
|
const SubIcon = item.icon;
|
||||||
const key = `${sectionKey}:${item.label}`;
|
const key = `${sectionKey}:${item.label}`;
|
||||||
const active =
|
const active =
|
||||||
activeTreeItem === key || Boolean(item.page && activePage === item.page);
|
activeTreeItem === key ||
|
||||||
|
Boolean(item.page && activePage === item.page);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -395,11 +658,11 @@ function TreeSection({
|
|||||||
className={
|
className={
|
||||||
active
|
active
|
||||||
? isDark
|
? 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 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 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-[#CBD5E1] bg-white px-3 py-2.5 text-left text-[13px] font-bold text-[#0F172A]"
|
||||||
: isDark
|
: 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 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 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-slate-500 transition hover:bg-white hover:text-[#0F172A]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SubIcon
|
<SubIcon
|
||||||
@@ -430,7 +693,7 @@ function SectionLabel({
|
|||||||
label: string;
|
label: string;
|
||||||
}) {
|
}) {
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return <div className="py-2" />;
|
return <div className="h-2" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -455,23 +718,23 @@ function ActiveIndicator({ isDark }: { isDark: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function navButtonClass(isDark: boolean, active: boolean, collapsed: 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) {
|
if (active) {
|
||||||
return isDark
|
return isDark
|
||||||
? `relative flex w-full items-center gap-3 ${RADIUS}
|
? `relative flex w-full cursor-pointer items-center gap-3 ${RADIUS} px-4 py-3.5 text-left text-[14px] font-bold text-white`
|
||||||
${alignment}
|
: `relative flex w-full cursor-pointer items-center gap-3 ${RADIUS} px-4 py-3.5 text-left text-[14px] font-bold text-[#0F172A]`;
|
||||||
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]`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return isDark
|
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 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 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-slate-600 transition hover:bg-white hover:text-[#0F172A]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function navIconClass(isDark: boolean, active: boolean) {
|
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";
|
: "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({
|
function NavItem({
|
||||||
theme,
|
theme,
|
||||||
collapsed,
|
collapsed,
|
||||||
@@ -509,15 +804,21 @@ function NavItem({
|
|||||||
const active = activePage === page && activeTreeItem === null;
|
const active = activePage === page && activeTreeItem === null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<CollapsedTooltipWrapper
|
||||||
type="button"
|
collapsed={collapsed}
|
||||||
onClick={() => onNavigate(page)}
|
label={label}
|
||||||
title={collapsed ? label : undefined}
|
isDark={isDark}
|
||||||
className={navButtonClass(isDark, active, collapsed)}
|
|
||||||
>
|
>
|
||||||
{active && <ActiveIndicator isDark={isDark} />}
|
<button
|
||||||
<Icon className={navIconClass(isDark, active)} />
|
type="button"
|
||||||
{!collapsed && <span className="truncate">{label}</span>}
|
onClick={() => onNavigate(page)}
|
||||||
</button>
|
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 latestChartRef = useRef<ChartWorkspaceItem | null>(null);
|
||||||
|
|
||||||
const titleBarClass = isDark
|
const titleBarClass = isDark
|
||||||
? "flex h-9 shrink-0 items-center bg-[#1F232A] text-[#D7DEE8]"
|
? "flex h-10 shrink-0 items-center border-b border-white/10 bg-[#071421] text-slate-100"
|
||||||
: "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-slate-200 bg-white text-slate-950";
|
||||||
|
|
||||||
const titleButtonClass = isDark
|
const titleButtonClass = isDark
|
||||||
? "grid h-9 w-11 place-items-center text-[#A8B3C7] transition hover:bg-white/10 hover:text-white"
|
? "grid h-10 w-11 place-items-center text-slate-400 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-500 transition hover:bg-slate-100 hover:text-slate-950";
|
||||||
|
|
||||||
const closeButtonClass = isDark
|
const closeButtonClass = isDark
|
||||||
? "grid h-9 w-11 place-items-center text-[#A8B3C7] 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-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-500 transition hover:bg-red-500 hover:text-white";
|
||||||
|
|
||||||
useChartWorkspacePersistence({
|
useChartWorkspacePersistence({
|
||||||
scope,
|
scope,
|
||||||
@@ -215,7 +215,7 @@ export function ChartWindowPage({ theme }: ChartWindowPageProps) {
|
|||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
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"
|
: "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
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "flex h-screen flex-col overflow-hidden bg-[#07101B] text-white"
|
? "flex h-screen flex-col overflow-hidden bg-[#071421] text-slate-100"
|
||||||
: "flex h-screen flex-col overflow-hidden bg-white text-[#0F172A]"
|
: "flex h-screen flex-col overflow-hidden bg-white text-slate-950"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<header className={titleBarClass}>
|
<header className={titleBarClass}>
|
||||||
@@ -296,8 +296,8 @@ export function ChartWindowPage({ theme }: ChartWindowPageProps) {
|
|||||||
<main
|
<main
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "min-h-0 flex-1 bg-[#07101B] [&>section]:h-full [&>section]:rounded-none [&>section]:border-0"
|
? "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]:shadow-none"
|
: "min-h-0 flex-1 bg-white [&>section]:h-full [&>section]:rounded-none [&>section]:border-0 [&>section]:bg-white [&>section]:shadow-none"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<WorkspaceChart
|
<WorkspaceChart
|
||||||
|
|||||||
@@ -537,7 +537,7 @@ export function ClimateChartsPage({ theme }: ClimateChartsPageProps) {
|
|||||||
}, [layoutMode]);
|
}, [layoutMode]);
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
@@ -664,6 +664,7 @@ export function ClimateChartsPage({ theme }: ClimateChartsPageProps) {
|
|||||||
<WorkspaceChartContainer
|
<WorkspaceChartContainer
|
||||||
key={chartItem.id}
|
key={chartItem.id}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
layoutMode={layoutMode}
|
||||||
chartItem={chartItem}
|
chartItem={chartItem}
|
||||||
chartableVariables={chartableVariables}
|
chartableVariables={chartableVariables}
|
||||||
connected={connected}
|
connected={connected}
|
||||||
@@ -760,6 +761,7 @@ export function ClimateChartsPage({ theme }: ClimateChartsPageProps) {
|
|||||||
|
|
||||||
function WorkspaceChartContainer({
|
function WorkspaceChartContainer({
|
||||||
theme,
|
theme,
|
||||||
|
layoutMode,
|
||||||
chartItem,
|
chartItem,
|
||||||
chartableVariables,
|
chartableVariables,
|
||||||
connected,
|
connected,
|
||||||
@@ -779,6 +781,7 @@ function WorkspaceChartContainer({
|
|||||||
moveDetachedChart
|
moveDetachedChart
|
||||||
}: {
|
}: {
|
||||||
theme: "dark" | "light";
|
theme: "dark" | "light";
|
||||||
|
layoutMode: ChartLayoutMode;
|
||||||
chartItem: ChartWorkspaceItem;
|
chartItem: ChartWorkspaceItem;
|
||||||
chartableVariables: ChartVariable[];
|
chartableVariables: ChartVariable[];
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
@@ -864,7 +867,7 @@ function WorkspaceChartContainer({
|
|||||||
: "relative min-h-0 overflow-hidden rounded-[7px]"
|
: "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)}>
|
<button type="button" title="Configurar" className={floatingIconClass(theme)} onClick={() => setConfigChartId(chartItem.id)}>
|
||||||
<Cog className="h-4 w-4" />
|
<Cog className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -921,6 +924,7 @@ function WorkspaceChartContainer({
|
|||||||
|
|
||||||
<WorkspaceChart
|
<WorkspaceChart
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
compact={layoutMode === "fourGrid"}
|
||||||
chart={chartConfig}
|
chart={chartConfig}
|
||||||
configuredVariableCount={chartItem.selectedSensorKeys.length}
|
configuredVariableCount={chartItem.selectedSensorKeys.length}
|
||||||
loading={loading || variablesStillResolving}
|
loading={loading || variablesStillResolving}
|
||||||
@@ -1012,8 +1016,8 @@ function WorkspaceChartContainer({
|
|||||||
|
|
||||||
function floatingIconClass(theme: "dark" | "light") {
|
function floatingIconClass(theme: "dark" | "light") {
|
||||||
return theme === "dark"
|
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-7 w-7 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-[#D7DEE8] bg-white text-slate-500 transition hover:text-[#0F172A]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SavedChartsDropdown({
|
function SavedChartsDropdown({
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
|||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
|
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
|
||||||
const rgbaRef = useRef<Uint8ClampedArray | null>(null);
|
const rgbaRef = useRef<Uint8ClampedArray | null>(null);
|
||||||
|
|
||||||
const framebufferRef = useRef({
|
const framebufferRef = useRef({
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
@@ -45,28 +46,19 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
|||||||
const [frameSize, setFrameSize] = useState({ width: 0, height: 0 });
|
const [frameSize, setFrameSize] = useState({ width: 0, height: 0 });
|
||||||
const [lastFrameAt, setLastFrameAt] = useState<string | null>(null);
|
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 clearFrame = useCallback(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const ctx = ctxRef.current;
|
const ctx = ctxRef.current;
|
||||||
|
|
||||||
if (canvas && ctx) {
|
if (canvas && ctx) {
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
canvas.width = 0;
|
||||||
|
canvas.height = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctxRef.current = null;
|
||||||
rgbaRef.current = null;
|
rgbaRef.current = null;
|
||||||
|
|
||||||
framebufferRef.current = {
|
framebufferRef.current = {
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
@@ -76,16 +68,44 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
|||||||
setLastFrameAt(null);
|
setLastFrameAt(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const closeSocket = useCallback(() => {
|
||||||
|
const ws = wsRef.current;
|
||||||
|
wsRef.current = null;
|
||||||
|
|
||||||
|
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 drawFrame = useCallback((buffer: ArrayBuffer) => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
if (!canvas) {
|
if (buffer.byteLength <= 8) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const view = new DataView(buffer);
|
const view = new DataView(buffer);
|
||||||
const width = view.getInt32(0);
|
const width = view.getInt32(0);
|
||||||
const height = view.getInt32(4);
|
const height = view.getInt32(4);
|
||||||
|
|
||||||
|
if (!width || !height || width <= 0 || height <= 0) return;
|
||||||
|
|
||||||
const pixels = new Uint8ClampedArray(buffer, 8);
|
const pixels = new Uint8ClampedArray(buffer, 8);
|
||||||
|
|
||||||
framebufferRef.current = {
|
framebufferRef.current = {
|
||||||
@@ -101,10 +121,7 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ctx = ctxRef.current;
|
const ctx = ctxRef.current;
|
||||||
|
if (!ctx) return;
|
||||||
if (!ctx) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canvas.width !== width || canvas.height !== height) {
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
@@ -125,6 +142,7 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
|||||||
rgba[index + 2] = pixels[index];
|
rgba[index + 2] = pixels[index];
|
||||||
rgba[index + 3] = 255;
|
rgba[index + 3] = 255;
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageData = ctx.createImageData(width, height);
|
const imageData = ctx.createImageData(width, height);
|
||||||
imageData.data.set(rgba);
|
imageData.data.set(rgba);
|
||||||
|
|
||||||
@@ -144,89 +162,119 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
closeSocket();
|
||||||
|
clearFrame();
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setState("CONNECTING_WS");
|
setState("CONNECTING_WS");
|
||||||
|
|
||||||
closeSocket();
|
window.setTimeout(() => {
|
||||||
|
const socket = new WebSocket(websocketUrl);
|
||||||
|
|
||||||
const socket = new WebSocket(websocketUrl);
|
wsRef.current = socket;
|
||||||
wsRef.current = socket;
|
socket.binaryType = "arraybuffer";
|
||||||
socket.binaryType = "arraybuffer";
|
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
setState("CONNECTING_VNC");
|
if (wsRef.current !== socket) return;
|
||||||
|
|
||||||
socket.send(
|
setState("CONNECTING_VNC");
|
||||||
JSON.stringify({
|
|
||||||
type: "connect",
|
|
||||||
host: nextHost,
|
|
||||||
port: nextPort,
|
|
||||||
password: nextPassword,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onmessage = (event) => {
|
socket.send(
|
||||||
if (typeof event.data === "string") {
|
JSON.stringify({
|
||||||
const message = JSON.parse(event.data) as {
|
type: "connect",
|
||||||
type?: string;
|
host: nextHost,
|
||||||
state?: string;
|
port: nextPort,
|
||||||
message?: string;
|
password: nextPassword,
|
||||||
};
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (message.type === "state") {
|
socket.onmessage = (event) => {
|
||||||
if (message.state === "CONNECTING") {
|
if (wsRef.current !== socket) return;
|
||||||
setState("CONNECTING_VNC");
|
|
||||||
|
if (typeof event.data === "string") {
|
||||||
|
let message: {
|
||||||
|
type?: string;
|
||||||
|
state?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
message = JSON.parse(event.data);
|
||||||
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.state === "CONNECTED") {
|
if (message.type === "state") {
|
||||||
setState("CONNECTED");
|
if (message.state === "CONNECTING") {
|
||||||
return;
|
setState("CONNECTING_VNC");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.state === "CONNECTED") {
|
||||||
|
setState("CONNECTED");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.state === "FIRST_FRAME") {
|
||||||
|
setState("FIRST_FRAME");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.state === "DISCONNECTED") {
|
||||||
|
clearFrame();
|
||||||
|
setState("DISCONNECTED");
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.state === "FIRST_FRAME") {
|
if (message.type === "error") {
|
||||||
setState("FIRST_FRAME");
|
clearFrame();
|
||||||
return;
|
setError(message.message ?? "Erro VNC desconhecido.");
|
||||||
|
setState("ERROR");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.state === "DISCONNECTED") {
|
return;
|
||||||
setState("DISCONNECTED");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === "error") {
|
drawFrame(event.data);
|
||||||
setError(message.message ?? "Erro VNC desconhecido.");
|
};
|
||||||
setState("ERROR");
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
socket.onerror = () => {
|
||||||
}
|
if (wsRef.current !== socket) return;
|
||||||
|
|
||||||
drawFrame(event.data);
|
clearFrame();
|
||||||
};
|
setError("Erro na ligação WebSocket da consola VNC.");
|
||||||
|
setState("ERROR");
|
||||||
|
};
|
||||||
|
|
||||||
socket.onerror = () => {
|
socket.onclose = () => {
|
||||||
setError("Erro na ligação WebSocket da consola VNC.");
|
if (wsRef.current !== socket) return;
|
||||||
setState("ERROR");
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onclose = () => {
|
wsRef.current = null;
|
||||||
wsRef.current = null;
|
clearFrame();
|
||||||
clearFrame();
|
|
||||||
|
|
||||||
setState((current) =>
|
setState((current) =>
|
||||||
current === "ERROR" ? "ERROR" : "DISCONNECTED",
|
current === "ERROR" ? "ERROR" : "DISCONNECTED",
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
}, 100);
|
||||||
},
|
},
|
||||||
[closeSocket, drawFrame, host, password, port, websocketUrl],
|
[
|
||||||
|
clearFrame,
|
||||||
|
closeSocket,
|
||||||
|
drawFrame,
|
||||||
|
host,
|
||||||
|
password,
|
||||||
|
port,
|
||||||
|
websocketUrl,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const disconnect = useCallback(() => {
|
const disconnect = useCallback(() => {
|
||||||
closeSocket();
|
closeSocket();
|
||||||
clearFrame();
|
clearFrame();
|
||||||
|
setError(null);
|
||||||
setState("DISCONNECTED");
|
setState("DISCONNECTED");
|
||||||
}, [clearFrame, closeSocket]);
|
}, [clearFrame, closeSocket]);
|
||||||
|
|
||||||
@@ -235,13 +283,8 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
|||||||
const ws = wsRef.current;
|
const ws = wsRef.current;
|
||||||
const framebuffer = framebufferRef.current;
|
const framebuffer = framebufferRef.current;
|
||||||
|
|
||||||
if (!canvas || !ws || ws.readyState !== WebSocket.OPEN) {
|
if (!canvas || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
return;
|
if (!framebuffer.width || !framebuffer.height) return;
|
||||||
}
|
|
||||||
|
|
||||||
if (!framebuffer.width || !framebuffer.height) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
|
||||||
@@ -278,8 +321,9 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
closeSocket();
|
closeSocket();
|
||||||
|
clearFrame();
|
||||||
};
|
};
|
||||||
}, [closeSocket]);
|
}, [clearFrame, closeSocket]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canvasRef,
|
canvasRef,
|
||||||
@@ -299,4 +343,4 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
|||||||
connected: state === "CONNECTED" || state === "FIRST_FRAME",
|
connected: state === "CONNECTED" || state === "FIRST_FRAME",
|
||||||
connecting: state === "CONNECTING_WS" || state === "CONNECTING_VNC",
|
connecting: state === "CONNECTING_WS" || state === "CONNECTING_VNC",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ export function ConsolePage({ theme }: ConsolePageProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={vnc.connecting}
|
disabled={vnc.connecting}
|
||||||
onClick={() => (vnc.connected ? vnc.disconnect() : vnc.connect())}
|
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" />
|
<Plug className="h-4 w-4" />
|
||||||
{vnc.connected ? "Desligar sessão" : vnc.connecting ? "A ligar..." : "Iniciar sessão"}
|
{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 [movingChartId, setMovingChartId] = useState<string | null>(null);
|
||||||
const [newChartOpen, setNewChartOpen] = useState(false);
|
const [newChartOpen, setNewChartOpen] = useState(false);
|
||||||
const [placingChartId, setPlacingChartId] = useState<string | null>(null);
|
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 changeLayoutMode = (nextLayoutMode: ChartLayoutMode) => {
|
||||||
const nextVisibleCount = getVisibleSlotCount(nextLayoutMode);
|
const nextVisibleCount = getVisibleSlotCount(nextLayoutMode);
|
||||||
@@ -536,7 +555,7 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
|
|||||||
};
|
};
|
||||||
}, [layoutMode]);
|
}, [layoutMode]);
|
||||||
return (
|
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
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
@@ -680,6 +699,8 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
|
|||||||
detachChart={detachChart}
|
detachChart={detachChart}
|
||||||
attachChart={attachChart}
|
attachChart={attachChart}
|
||||||
moveDetachedChart={moveDetachedChart}
|
moveDetachedChart={moveDetachedChart}
|
||||||
|
viewportCompact={viewportCompact}
|
||||||
|
layoutMode={layoutMode}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -775,7 +796,9 @@ function WorkspaceChartContainer({
|
|||||||
placeChartHere,
|
placeChartHere,
|
||||||
detachChart,
|
detachChart,
|
||||||
attachChart,
|
attachChart,
|
||||||
moveDetachedChart
|
moveDetachedChart,
|
||||||
|
viewportCompact,
|
||||||
|
layoutMode,
|
||||||
}: {
|
}: {
|
||||||
theme: "dark" | "light";
|
theme: "dark" | "light";
|
||||||
chartItem: ChartWorkspaceItem;
|
chartItem: ChartWorkspaceItem;
|
||||||
@@ -795,9 +818,17 @@ function WorkspaceChartContainer({
|
|||||||
detachChart: (chartId: string) => void;
|
detachChart: (chartId: string) => void;
|
||||||
attachChart: (chartId: string) => void | Promise<void>;
|
attachChart: (chartId: string) => void | Promise<void>;
|
||||||
moveDetachedChart: (chartId: string, x: number, y: number) => void;
|
moveDetachedChart: (chartId: string, x: number, y: number) => void;
|
||||||
|
viewportCompact: boolean;
|
||||||
|
layoutMode: ChartLayoutMode;
|
||||||
}) {
|
}) {
|
||||||
const isMoving = movingChartId === chartItem.id;
|
const isMoving = movingChartId === chartItem.id;
|
||||||
|
|
||||||
|
const shouldUseCompactMode =
|
||||||
|
!chartItem.detached &&
|
||||||
|
(layoutMode === "fourGrid" ||
|
||||||
|
layoutMode === "twoRows" ||
|
||||||
|
(viewportCompact && layoutMode === "twoColumns"));
|
||||||
|
|
||||||
const canReceiveMove =
|
const canReceiveMove =
|
||||||
(movingChartId !== null && movingChartId !== chartItem.id) ||
|
(movingChartId !== null && movingChartId !== chartItem.id) ||
|
||||||
(placingChartId !== null && placingChartId !== chartItem.id);
|
(placingChartId !== null && placingChartId !== chartItem.id);
|
||||||
@@ -924,6 +955,7 @@ function WorkspaceChartContainer({
|
|||||||
configuredVariableCount={chartItem.selectedSensorKeys.length}
|
configuredVariableCount={chartItem.selectedSensorKeys.length}
|
||||||
loading={loading || variablesStillResolving}
|
loading={loading || variablesStillResolving}
|
||||||
detached={chartItem.detached}
|
detached={chartItem.detached}
|
||||||
|
compact={shouldUseCompactMode}
|
||||||
onDetach={() => detachChart(chartItem.id)}
|
onDetach={() => detachChart(chartItem.id)}
|
||||||
onAttach={() => attachChart(chartItem.id)}
|
onAttach={() => attachChart(chartItem.id)}
|
||||||
onTimeRangeChange={(range) =>
|
onTimeRangeChange={(range) =>
|
||||||
|
|||||||
@@ -539,7 +539,7 @@ export function MeteoChartsPage({
|
|||||||
}, [layoutMode]);
|
}, [layoutMode]);
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
@@ -666,6 +666,7 @@ export function MeteoChartsPage({
|
|||||||
<WorkspaceChartContainer
|
<WorkspaceChartContainer
|
||||||
key={chartItem.id}
|
key={chartItem.id}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
layoutMode={layoutMode}
|
||||||
chartItem={chartItem}
|
chartItem={chartItem}
|
||||||
chartableVariables={chartableVariables}
|
chartableVariables={chartableVariables}
|
||||||
connected={connected}
|
connected={connected}
|
||||||
@@ -773,6 +774,7 @@ export function MeteoChartsPage({
|
|||||||
|
|
||||||
function WorkspaceChartContainer({
|
function WorkspaceChartContainer({
|
||||||
theme,
|
theme,
|
||||||
|
layoutMode,
|
||||||
chartItem,
|
chartItem,
|
||||||
chartableVariables,
|
chartableVariables,
|
||||||
connected,
|
connected,
|
||||||
@@ -792,6 +794,7 @@ function WorkspaceChartContainer({
|
|||||||
moveDetachedChart,
|
moveDetachedChart,
|
||||||
}: {
|
}: {
|
||||||
theme: "dark" | "light";
|
theme: "dark" | "light";
|
||||||
|
layoutMode: ChartLayoutMode;
|
||||||
chartItem: ChartWorkspaceItem;
|
chartItem: ChartWorkspaceItem;
|
||||||
chartableVariables: ChartVariable[];
|
chartableVariables: ChartVariable[];
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
@@ -875,7 +878,7 @@ function WorkspaceChartContainer({
|
|||||||
: "relative min-h-0 overflow-hidden rounded-[7px]"
|
: "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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="Configurar"
|
title="Configurar"
|
||||||
@@ -942,6 +945,7 @@ function WorkspaceChartContainer({
|
|||||||
|
|
||||||
<WorkspaceChart
|
<WorkspaceChart
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
compact={layoutMode === "fourGrid"}
|
||||||
chart={chartConfig}
|
chart={chartConfig}
|
||||||
configuredVariableCount={chartItem.selectedSensorKeys.length}
|
configuredVariableCount={chartItem.selectedSensorKeys.length}
|
||||||
loading={loading || variablesStillResolving}
|
loading={loading || variablesStillResolving}
|
||||||
@@ -1158,8 +1162,8 @@ function timeRangeToMilliseconds(timeRange: WorkspaceChartTimeRange): number {
|
|||||||
|
|
||||||
function floatingIconClass(theme: "dark" | "light") {
|
function floatingIconClass(theme: "dark" | "light") {
|
||||||
return theme === "dark"
|
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-7 w-7 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-[#D7DEE8] bg-white text-slate-500 transition hover:text-[#0F172A]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SavedChartsDropdown({
|
function SavedChartsDropdown({
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export function useTelemetryChartSeries(
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
async function loadHistory(showLoading: boolean) {
|
async function loadHistory(showLoading: boolean) {
|
||||||
const startedAt = performance.now();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const to = new Date();
|
const to = new Date();
|
||||||
@@ -49,14 +48,6 @@ export function useTelemetryChartSeries(
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[TelemetryChartSeries REQUEST]", {
|
|
||||||
sensorKeys,
|
|
||||||
timeRange,
|
|
||||||
interval,
|
|
||||||
from: from.toISOString(),
|
|
||||||
to: to.toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const entries = await Promise.all(
|
const entries = await Promise.all(
|
||||||
sensorKeys.map(async (key) => {
|
sensorKeys.map(async (key) => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -96,10 +87,6 @@ export function useTelemetryChartSeries(
|
|||||||
setSeriesByKey(Object.fromEntries(entries));
|
setSeriesByKey(Object.fromEntries(entries));
|
||||||
initializedRef.current = true;
|
initializedRef.current = true;
|
||||||
|
|
||||||
console.log("[TelemetryChartSeries DONE]", {
|
|
||||||
sensorCount: sensorKeys.length,
|
|
||||||
durationMs: Math.round(performance.now() - startedAt),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ export function useTelemetryStream() {
|
|||||||
|
|
||||||
const payload = (await response.json()) as TelemetryBroadcastMessage;
|
const payload = (await response.json()) as TelemetryBroadcastMessage;
|
||||||
|
|
||||||
console.log("[TelemetryStream INITIAL]", payload);
|
|
||||||
|
|
||||||
setMessage(payload);
|
setMessage(payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
@@ -49,14 +47,11 @@ export function useTelemetryStream() {
|
|||||||
reconnectDelay: 3000,
|
reconnectDelay: 3000,
|
||||||
|
|
||||||
onConnect: () => {
|
onConnect: () => {
|
||||||
console.log("[TelemetryStream WS CONNECTED]");
|
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
|
|
||||||
client.subscribe("/topic/telemetry/latest", (frame) => {
|
client.subscribe("/topic/telemetry/latest", (frame) => {
|
||||||
const payload = JSON.parse(frame.body) as TelemetryBroadcastMessage;
|
const payload = JSON.parse(frame.body) as TelemetryBroadcastMessage;
|
||||||
|
|
||||||
console.log("[TelemetryStream WS MESSAGE]", payload);
|
|
||||||
|
|
||||||
setMessage(payload);
|
setMessage(payload);
|
||||||
setInitialLoading(false);
|
setInitialLoading(false);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user