Adds proper tree items to the sidebar for the workspace items, fixes some minor bugs related to the window detachment logic, etc

This commit is contained in:
litoral05
2026-06-09 17:24:13 +01:00
parent 8f3c9d6da8
commit 1ef2fcb97c
14 changed files with 891 additions and 266 deletions
+58 -4
View File
@@ -15,6 +15,7 @@ import { useAuth } from "../features/auth/AuthContext";
import { LoginPage } from "../features/auth/pages/LoginPage";
import { RuntimeConfigProvider } from "../features/system/RuntimeConfigProvider";
import { TitleBar } from "../components/window/TitleBar";
import type { ChartWorkspaceScope } from "../features/chartworkspace/types";
export type AppPage =
| "dashboard"
@@ -37,6 +38,30 @@ export type AppPage =
function App() {
const { authenticated } = useAuth();
const [activePage, setActivePage] = useState<AppPage>("dashboard");
const [selectedWorkspaceByScope, setSelectedWorkspaceByScope] = useState<
Partial<Record<ChartWorkspaceScope, number | null>>
>({});
const openWorkspace = (scope: ChartWorkspaceScope, workspaceId: number) => {
setSelectedWorkspaceByScope((current) => ({
...current,
[scope]: workspaceId,
}));
if (scope === "GLOBAL") setActivePage("maincharts");
if (scope === "METEO") setActivePage("meteoCharts");
if (scope === "CLIMATE") setActivePage("climateCharts");
};
const updateSelectedWorkspace = (
scope: ChartWorkspaceScope,
workspaceId: number | null,
) => {
setSelectedWorkspaceByScope((current) => ({
...current,
[scope]: workspaceId,
}));
};
const isWorkspaceWindow = window.location.pathname.startsWith("/workspace-window/");
@@ -72,7 +97,12 @@ function App() {
<div className="pt-9">
<div className="h-[calc(100vh-36px)] overflow-hidden">
<RuntimeConfigProvider>
<AppShell activePage={activePage} onNavigate={setActivePage}>
<AppShell
activePage={activePage}
selectedWorkspaceByScope={selectedWorkspaceByScope}
onNavigate={setActivePage}
onOpenWorkspace={openWorkspace}
>
{({ theme }) => {
if (activePage === "meteo") {
return (
@@ -84,17 +114,41 @@ function App() {
}
if (activePage === "meteoCharts") {
return <MeteoChartsPage theme={theme} />;
return (
<MeteoChartsPage
theme={theme}
workspaceId={selectedWorkspaceByScope.METEO}
onWorkspaceChange={(workspaceId) =>
updateSelectedWorkspace("METEO", workspaceId)
}
/>
);
}
if (activePage === "climateCharts") {
return <ClimateChartsPage theme={theme} />;
return (
<ClimateChartsPage
theme={theme}
workspaceId={selectedWorkspaceByScope.CLIMATE}
onWorkspaceChange={(workspaceId) =>
updateSelectedWorkspace("CLIMATE", workspaceId)
}
/>
);
}
if (activePage === "console") return <ConsolePage theme={theme} />;
if (activePage === "maincharts") {
return <MainChartsPage theme={theme} />;
return (
<MainChartsPage
theme={theme}
workspaceId={selectedWorkspaceByScope.GLOBAL}
onWorkspaceChange={(workspaceId) =>
updateSelectedWorkspace("GLOBAL", workspaceId)
}
/>
);
}
if (activePage === "settings") {
+12 -1
View File
@@ -4,6 +4,7 @@ import { useTelemetryStream } from "../../features/telemetry/hooks/useTelemetryS
import { useCurrentUser } from "../../features/auth/hooks/useCurrentUser";
import type { TelemetrySnapshot } from "../../types/telemetry";
import type { AppPage } from "../../app/App";
import type { ChartWorkspaceScope } from "../../features/chartworkspace/types";
type Theme = "dark" | "light";
@@ -14,13 +15,21 @@ type AppShellRenderProps = {
type AppShellProps = {
activePage: AppPage;
selectedWorkspaceByScope: Partial<Record<ChartWorkspaceScope, number | null>>;
onNavigate: (page: AppPage) => void;
onOpenWorkspace: (scope: ChartWorkspaceScope, workspaceId: number) => void;
children: (props: AppShellRenderProps) => ReactNode;
};
const THEME_STORAGE_KEY = "app-theme";
export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
export function AppShell({
activePage,
selectedWorkspaceByScope,
onNavigate,
onOpenWorkspace,
children,
}: AppShellProps) {
const telemetry = useTelemetryStream();
const currentUser = useCurrentUser();
@@ -107,7 +116,9 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
userInitials={currentUser.initials}
userName={currentUser.name}
userRole={currentUser.role}
selectedWorkspaceByScope={selectedWorkspaceByScope}
onNavigate={onNavigate}
onOpenWorkspace={onOpenWorkspace}
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
onToggleTheme={toggleTheme}
/>
+343 -38
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
BarChart3,
ChevronDown,
@@ -26,6 +26,11 @@ import {
import logo from "../../assets/logo5.png";
import type { AppPage } from "../../app/App";
import { useAuth } from "../../features/auth/AuthContext";
import {
listChartWorkspaces,
type ChartWorkspaceResponse,
} from "../../features/chartworkspace/api/chartWorkspaceApi";
import type { ChartWorkspaceScope } from "../../features/chartworkspace/types";
type SidebarProps = {
theme: "dark" | "light";
@@ -34,7 +39,9 @@ type SidebarProps = {
userInitials: string;
userName: string;
userRole: string;
selectedWorkspaceByScope: Partial<Record<ChartWorkspaceScope, number | null>>;
onNavigate: (page: AppPage) => void;
onOpenWorkspace: (scope: ChartWorkspaceScope, workspaceId: number) => void;
onToggleCollapsed: () => void;
onToggleTheme: () => void;
};
@@ -82,7 +89,9 @@ export function Sidebar({
userInitials,
userName,
userRole,
selectedWorkspaceByScope,
onNavigate,
onOpenWorkspace,
onToggleCollapsed,
onToggleTheme,
}: SidebarProps) {
@@ -90,16 +99,64 @@ export function Sidebar({
const isDark = theme === "dark";
const ThemeIcon = isDark ? Moon : Sun;
const [generalOpen, setGeneralOpen] = useState(true);
const [meteoOpen, setMeteoOpen] = useState(true);
const [climateOpen, setClimateOpen] = useState(false);
const [irrigationOpen, setIrrigationOpen] = useState(false);
const [activeTreeItem, setActiveTreeItem] = useState<string | null>(null);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [sidebarHovered, setSidebarHovered] = useState(false);
const [workspaceLists, setWorkspaceLists] = useState<
Partial<Record<ChartWorkspaceScope, ChartWorkspaceResponse[]>>
>({});
const userButtonRef = useRef<HTMLButtonElement | null>(null);
const userMenuRef = useRef<HTMLDivElement | null>(null);
const refreshWorkspaces = useCallback(async (scope?: ChartWorkspaceScope) => {
const scopes: ChartWorkspaceScope[] = scope
? [scope]
: ["GLOBAL", "METEO", "CLIMATE"];
const loadedEntries = await Promise.all(
scopes.map(async (workspaceScope) => {
try {
return [workspaceScope, await listChartWorkspaces(workspaceScope)] as const;
} catch (error) {
console.error(`Failed to load sidebar workspaces for ${workspaceScope}`, error);
return [workspaceScope, [] as ChartWorkspaceResponse[]] as const;
}
}),
);
setWorkspaceLists((current) => {
const next = { ...current };
for (const [workspaceScope, workspaces] of loadedEntries) {
next[workspaceScope] = workspaces;
}
return next;
});
}, []);
useEffect(() => {
void refreshWorkspaces();
}, [refreshWorkspaces]);
useEffect(() => {
const handleWorkspaceChange = (event: Event) => {
const detail = (event as CustomEvent<{ scope?: ChartWorkspaceScope }>).detail;
void refreshWorkspaces(detail?.scope);
};
window.addEventListener("chart-workspaces:changed", handleWorkspaceChange);
return () => {
window.removeEventListener("chart-workspaces:changed", handleWorkspaceChange);
};
}, [refreshWorkspaces]);
useEffect(() => {
if (!userMenuOpen) return;
@@ -149,6 +206,7 @@ export function Sidebar({
const firstItem = sectionItems[section][0];
setActiveTreeItem(`${section}:${firstItem.label}`);
setGeneralOpen(false);
if (firstItem.page) {
onNavigate(firstItem.page);
@@ -288,13 +346,30 @@ export function Sidebar({
theme={theme}
collapsed={collapsed}
label="Gráficos Gerais"
page="maincharts"
icon={BarChart3}
activePage={activePage}
activeTreeItem={activeTreeItem}
onNavigate={(page) => {
open={generalOpen}
workspaces={workspaceLists.GLOBAL ?? []}
active={activePage === "maincharts"}
selectedWorkspaceId={selectedWorkspaceByScope.GLOBAL ?? null}
onToggle={() => {
if (collapsed) {
onToggleCollapsed();
}
setActiveTreeItem(null);
onNavigate(page);
setGeneralOpen((current) => !current);
setMeteoOpen(false);
setClimateOpen(false);
setIrrigationOpen(false);
onNavigate("maincharts");
}}
onOpenWorkspace={(workspaceId) => {
setActiveTreeItem(null);
setGeneralOpen(true);
setMeteoOpen(false);
setClimateOpen(false);
setIrrigationOpen(false);
onOpenWorkspace("GLOBAL", workspaceId);
}}
/>
@@ -325,7 +400,21 @@ export function Sidebar({
sectionKey="meteo"
activeTreeItem={activeTreeItem}
activePage={activePage}
workspaceGroup={{
scope: "METEO",
page: "meteoCharts",
workspaces: workspaceLists.METEO ?? [],
selectedWorkspaceId: selectedWorkspaceByScope.METEO ?? null,
}}
onItemClick={handleTreeClick}
onWorkspaceClick={(scope, workspaceId) => {
setActiveTreeItem(null);
setGeneralOpen(false);
setMeteoOpen(true);
setClimateOpen(false);
setIrrigationOpen(false);
onOpenWorkspace(scope, workspaceId);
}}
/>
<TreeSection
@@ -339,7 +428,21 @@ export function Sidebar({
sectionKey="climate"
activeTreeItem={activeTreeItem}
activePage={activePage}
workspaceGroup={{
scope: "CLIMATE",
page: "climateCharts",
workspaces: workspaceLists.CLIMATE ?? [],
selectedWorkspaceId: selectedWorkspaceByScope.CLIMATE ?? null,
}}
onItemClick={handleTreeClick}
onWorkspaceClick={(scope, workspaceId) => {
setActiveTreeItem(null);
setGeneralOpen(false);
setMeteoOpen(false);
setClimateOpen(true);
setIrrigationOpen(false);
onOpenWorkspace(scope, workspaceId);
}}
/>
<TreeSection
@@ -594,7 +697,9 @@ function TreeSection({
sectionKey,
activeTreeItem,
activePage,
workspaceGroup,
onItemClick,
onWorkspaceClick,
}: {
theme: "dark" | "light";
collapsed: boolean;
@@ -606,7 +711,14 @@ function TreeSection({
sectionKey: string;
activeTreeItem: string | null;
activePage: AppPage;
workspaceGroup?: {
scope: ChartWorkspaceScope;
page: AppPage;
workspaces: ChartWorkspaceResponse[];
selectedWorkspaceId: number | null;
};
onItemClick: (key: string, page?: AppPage) => void;
onWorkspaceClick?: (scope: ChartWorkspaceScope, workspaceId: number) => void;
}) {
const isDark = theme === "dark";
const hasActiveChild = items.some((item) => {
@@ -659,34 +771,52 @@ function TreeSection({
const active =
activeTreeItem === key ||
Boolean(item.page && activePage === item.page);
const showWorkspaces =
workspaceGroup &&
item.page === workspaceGroup.page &&
workspaceGroup.workspaces.length > 0;
return (
<button
key={item.label}
type="button"
onClick={() => onItemClick(key, item.page)}
className={
active
? isDark
? "flex w-full cursor-pointer items-center gap-2 rounded-[5px] border border-[#2C3D56] bg-[#131D2F] px-3 py-2.5 text-left text-[13px] font-bold text-white"
: "flex w-full cursor-pointer items-center gap-2 rounded-[5px] border border-[#CBD5E1] bg-white px-3 py-2.5 text-left text-[13px] font-bold text-[#0F172A]"
: isDark
? "flex w-full cursor-pointer items-center gap-2 rounded-[5px] px-3 py-2.5 text-left text-[13px] font-semibold text-[#7D8EA8] transition hover:bg-[#111A2B] hover:text-slate-200"
: "flex w-full cursor-pointer items-center gap-2 rounded-[5px] px-3 py-2.5 text-left text-[13px] font-semibold text-slate-500 transition hover:bg-white hover:text-[#0F172A]"
}
>
<SubIcon
<div key={item.label}>
<button
type="button"
onClick={() => onItemClick(key, item.page)}
className={
active
? isDark
? "h-4 w-4 shrink-0 text-[#4FD1C5]"
: "h-4 w-4 shrink-0 text-[#0F766E]"
: "h-4 w-4 shrink-0"
? "flex w-full cursor-pointer items-center gap-2 rounded-[5px] border border-[#2C3D56] bg-[#131D2F] px-3 py-2.5 text-left text-[13px] font-bold text-white"
: "flex w-full cursor-pointer items-center gap-2 rounded-[5px] border border-[#CBD5E1] bg-white px-3 py-2.5 text-left text-[13px] font-bold text-[#0F172A]"
: isDark
? "flex w-full cursor-pointer items-center gap-2 rounded-[5px] px-3 py-2.5 text-left text-[13px] font-semibold text-[#7D8EA8] transition hover:bg-[#111A2B] hover:text-slate-200"
: "flex w-full cursor-pointer items-center gap-2 rounded-[5px] px-3 py-2.5 text-left text-[13px] font-semibold text-slate-500 transition hover:bg-white hover:text-[#0F172A]"
}
/>
>
<SubIcon
className={
active
? isDark
? "h-4 w-4 shrink-0 text-[#4FD1C5]"
: "h-4 w-4 shrink-0 text-[#0F766E]"
: "h-4 w-4 shrink-0"
}
/>
<span className="truncate">{item.label}</span>
</button>
<span className="truncate">{item.label}</span>
</button>
{showWorkspaces && (
<WorkspaceTreeList
theme={theme}
workspaces={workspaceGroup.workspaces}
activePage={activePage}
page={workspaceGroup.page}
selectedWorkspaceId={workspaceGroup.selectedWorkspaceId}
onOpenWorkspace={(workspaceId) =>
onWorkspaceClick?.(workspaceGroup.scope, workspaceId)
}
/>
)}
</div>
);
})}
</div>
@@ -715,6 +845,160 @@ function SectionLabel({
);
}
function WorkspaceNavSection({
theme,
collapsed,
label,
icon: Icon,
open,
workspaces,
active,
selectedWorkspaceId,
onToggle,
onOpenWorkspace,
}: {
theme: "dark" | "light";
collapsed: boolean;
label: string;
icon: React.ElementType;
open: boolean;
workspaces: ChartWorkspaceResponse[];
active: boolean;
selectedWorkspaceId: number | null;
onToggle: () => void;
onOpenWorkspace: (workspaceId: number) => void;
}) {
const isDark = theme === "dark";
return (
<div>
<CollapsedTooltipWrapper
collapsed={collapsed}
label={label}
isDark={isDark}
>
<button
type="button"
onClick={onToggle}
title={undefined}
className={navButtonClass(isDark, active, collapsed)}
>
{active && <ActiveIndicator isDark={isDark} />}
<Icon className={navIconClass(isDark, active)} />
{!collapsed && (
<>
<span className="min-w-0 flex-1 truncate">{label}</span>
<ChevronDown
className={`h-4 w-4 shrink-0 text-[#6F819B] transition-transform duration-200 ${open ? "rotate-180" : ""
}`}
/>
</>
)}
</button>
</CollapsedTooltipWrapper>
{!collapsed && open && (
<WorkspaceTreeList
theme={theme}
workspaces={workspaces}
activePage={active ? "maincharts" : "dashboard"}
page="maincharts"
selectedWorkspaceId={selectedWorkspaceId}
onOpenWorkspace={onOpenWorkspace}
/>
)}
</div>
);
}
function WorkspaceTreeList({
theme,
workspaces,
activePage,
page,
selectedWorkspaceId,
onOpenWorkspace,
}: {
theme: "dark" | "light";
workspaces: ChartWorkspaceResponse[];
activePage: AppPage;
page: AppPage;
selectedWorkspaceId: number | null;
onOpenWorkspace: (workspaceId: number) => void;
}) {
const isDark = theme === "dark";
return (
<div
className={
isDark
? "ml-[18px] mt-1 space-y-1 border-l border-[#263247] pl-3"
: "ml-[18px] mt-1 space-y-1 border-l border-[#CBD5E1] pl-3"
}
>
{workspaces.map((workspace) => {
const active =
activePage === page && workspace.id === selectedWorkspaceId;
const chartCount = getWorkspaceChartCount(workspace);
return (
<button
key={workspace.id}
type="button"
onClick={() => onOpenWorkspace(workspace.id)}
className={
active
? isDark
? "group flex w-full cursor-pointer items-center gap-2 rounded-[5px] border border-[#2C3D56] bg-[#101D30] px-3 py-2 text-left text-[12px] font-black text-white"
: "group flex w-full cursor-pointer items-center gap-2 rounded-[5px] border border-[#CBD5E1] bg-white px-3 py-2 text-left text-[12px] font-black text-[#0F172A]"
: isDark
? "group flex w-full cursor-pointer items-center gap-2 rounded-[5px] px-3 py-2 text-left text-[12px] font-bold text-[#7D8EA8] transition hover:bg-[#111A2B] hover:text-slate-200"
: "group flex w-full cursor-pointer items-center gap-2 rounded-[5px] px-3 py-2 text-left text-[12px] font-bold text-slate-500 transition hover:bg-white hover:text-[#0F172A]"
}
>
<span
className={
active
? "h-2 w-2 shrink-0 rounded-full bg-[#4FD1C5]"
: "h-2 w-2 shrink-0 rounded-full bg-[#61738C] group-hover:bg-[#4FD1C5]"
}
/>
<span className="min-w-0 flex-1 truncate">{workspace.name}</span>
<span className="shrink-0 text-[10px] font-black text-[#61738C]">
{chartCount}/10
</span>
</button>
);
})}
{workspaces.length === 0 && (
<div
className={
isDark
? "rounded-[5px] px-3 py-2 text-[12px] font-bold text-[#61738C]"
: "rounded-[5px] px-3 py-2 text-[12px] font-bold text-slate-400"
}
>
Sem workspaces.
</div>
)}
</div>
);
}
function getWorkspaceChartCount(workspace: ChartWorkspaceResponse) {
try {
const charts = JSON.parse(workspace.chartsJson) as unknown[];
return Array.isArray(charts) ? charts.length : 0;
} catch {
return 0;
}
}
function ActiveIndicator({ isDark }: { isDark: boolean }) {
return (
<span
@@ -791,16 +1075,7 @@ function Divider({ isDark }: { isDark: boolean }) {
);
}
function NavItem({
theme,
collapsed,
label,
page,
icon: Icon,
activePage,
activeTreeItem,
onNavigate,
}: {
type StandardNavItemProps = {
theme: "dark" | "light";
collapsed: boolean;
label: string;
@@ -809,7 +1084,37 @@ function NavItem({
activePage: AppPage;
activeTreeItem: string | null;
onNavigate: (page: AppPage) => void;
}) {
};
type WorkspaceNavItemProps = {
theme: "dark" | "light";
collapsed: boolean;
label: string;
icon: React.ElementType;
open: boolean;
workspaces: ChartWorkspaceResponse[];
active: boolean;
selectedWorkspaceId: number | null;
onToggle: () => void;
onOpenWorkspace: (workspaceId: number) => void;
};
function NavItem(props: StandardNavItemProps | WorkspaceNavItemProps) {
if ("open" in props) {
return <WorkspaceNavSection {...props} />;
}
const {
theme,
collapsed,
label,
page,
icon: Icon,
activePage,
activeTreeItem,
onNavigate,
} = props;
const isDark = theme === "dark";
const active = activePage === page && activeTreeItem === null;
+2 -2
View File
@@ -43,9 +43,9 @@ export function LoginPage() {
/>
<div>
<p className="text-xs font-black uppercase tracking-[0.22em] text-[#4FD1C5]">
Litoral Regas
Central LRX
</p>
<h1 className="mt-1 text-2xl font-black">Iniciar sessao</h1>
<h1 className="mt-1 text-2xl font-black">Iniciar sessão</h1>
</div>
</div>
@@ -20,8 +20,8 @@ export type ChartWorkspaceSaveRequest = {
name?: string;
sortOrder?: number;
defaultWorkspace?: boolean;
layoutMode: ChartLayoutMode;
chartsJson: string;
layoutMode?: ChartLayoutMode;
chartsJson?: string;
};
function workspaceUrl(workspaceId: number | null, scope: ChartWorkspaceScope) {
@@ -80,6 +80,7 @@ export function EmptyWorkspace({
description = "Abra um gráfico guardado ou crie um novo gráfico para continuar.",
addLabel = "Novo Gráfico",
savedLabel = "Abrir Guardado",
showSavedButton = true,
minHeightClass = "min-h-0 flex-1",
onAddChart,
onOpenSaved,
@@ -90,6 +91,7 @@ export function EmptyWorkspace({
description?: string;
addLabel?: string;
savedLabel?: string;
showSavedButton?: boolean;
minHeightClass?: string;
onAddChart: () => void;
onOpenSaved: () => void;
@@ -150,18 +152,20 @@ export function EmptyWorkspace({
{addLabel}
</button>
<button
type="button"
onClick={onOpenSaved}
className={
isDark
? `inline-flex h-11 items-center gap-3 ${RADIUS} border border-[#263247] bg-[#111A2B] px-5 text-sm font-black text-white`
: `inline-flex h-11 items-center gap-3 ${RADIUS} border border-[#D7DEE8] bg-[#F8FAFC] px-5 text-sm font-black text-[#0F172A]`
}
>
<Search className="h-5 w-5" />
{savedLabel}
</button>
{showSavedButton && (
<button
type="button"
onClick={onOpenSaved}
className={
isDark
? `inline-flex h-11 items-center gap-3 ${RADIUS} border border-[#263247] bg-[#111A2B] px-5 text-sm font-black text-white`
: `inline-flex h-11 items-center gap-3 ${RADIUS} border border-[#D7DEE8] bg-[#F8FAFC] px-5 text-sm font-black text-[#0F172A]`
}
>
<Search className="h-5 w-5" />
{savedLabel}
</button>
)}
</div>
</div>
</section>
@@ -189,7 +193,7 @@ export function WorkspaceSelector({
creating: boolean;
canCreateWorkspace: boolean;
onSelectWorkspace: (workspaceId: number) => void;
onCreateWorkspace: (name: string) => void;
onCreateWorkspace: (name: string) => void | Promise<void>;
onRenameWorkspace: (workspaceId: number, name: string) => void | Promise<void>;
onDeleteWorkspace: (workspaceId: number) => void | Promise<void>;
}) {
@@ -208,7 +212,7 @@ export function WorkspaceSelector({
);
const createWorkspace = (name: string) => {
onCreateWorkspace(name);
void onCreateWorkspace(name);
setCreateOpen(false);
setOpen(false);
};
@@ -514,7 +518,7 @@ function WorkspaceNameModal({
);
}
function NewWorkspaceModal({
export function NewWorkspaceModal({
theme,
creating,
onClose,
@@ -44,7 +44,7 @@ type UseChartWorkspacePersistenceParams = {
export function useChartWorkspacePersistence({
scope,
workspaceId = null,
workspaceId,
layoutMode,
charts,
onLoaded,
@@ -58,7 +58,11 @@ export function useChartWorkspacePersistence({
const loadedWorkspaceKeyRef = useRef<string | null>(null);
const workspaceKey =
workspaceId === null ? `scope:${scope}` : `id:${workspaceId}`;
workspaceId === undefined
? `scope:${scope}`
: workspaceId === null
? `none:${scope}`
: `id:${workspaceId}`;
useEffect(() => {
let cancelled = false;
@@ -70,8 +74,17 @@ export function useChartWorkspacePersistence({
loadedWorkspaceKeyRef.current = null;
try {
if (workspaceId === null) {
if (!cancelled) {
loadedWorkspaceKeyRef.current = workspaceKey;
setLoaded(true);
}
return;
}
const payload =
await loadChartWorkspace(scope, workspaceId);
await loadChartWorkspace(scope, workspaceId ?? null);
if (!payload) {
return;
@@ -110,6 +123,7 @@ export function useChartWorkspacePersistence({
useEffect(() => {
if (!loaded || !saveEnabled) return;
if (workspaceId === null) return;
if (loadedWorkspaceKeyRef.current !== workspaceKey) return;
if (saveTimeoutRef.current !== null) {
@@ -127,7 +141,13 @@ export function useChartWorkspacePersistence({
layoutMode,
chartsJson: JSON.stringify(charts),
},
workspaceId,
workspaceId ?? null,
);
window.dispatchEvent(
new CustomEvent("chart-workspaces:changed", {
detail: { scope, workspaceId },
}),
);
setError(null);
@@ -74,11 +74,7 @@ export function useChartWorkspaceSelection({
await saveChartWorkspace(
scope,
{
name: workspace.name,
sortOrder: workspace.sortOrder,
defaultWorkspace: true,
layoutMode: workspace.layoutMode,
chartsJson: workspace.chartsJson,
},
workspace.id,
);
@@ -97,7 +93,7 @@ export function useChartWorkspaceSelection({
}, [scope, workspaces]);
const createWorkspace = useCallback(async (name: string) => {
if (workspaces.length >= 10 || creating) return;
if (workspaces.length >= 10 || creating) return null;
try {
setCreating(true);
@@ -120,9 +116,11 @@ export function useChartWorkspaceSelection({
]);
setActiveWorkspaceId(createdWorkspace.id);
setError(null);
return createdWorkspace;
} catch (exception) {
console.error("Failed to create chart workspace", exception);
setError("Nao foi possivel criar o workspace.");
return null;
} finally {
setCreating(false);
}
@@ -142,26 +140,7 @@ export function useChartWorkspaceSelection({
setWorkspaces(remaining);
if (activeWorkspaceId === workspaceId) {
const nextWorkspace =
remaining.find((item) => item.defaultWorkspace) ??
remaining[0] ??
null;
setActiveWorkspaceId(nextWorkspace?.id ?? null);
if (nextWorkspace) {
await saveChartWorkspace(
scope,
{
name: nextWorkspace.name,
sortOrder: nextWorkspace.sortOrder,
defaultWorkspace: true,
layoutMode: nextWorkspace.layoutMode,
chartsJson: nextWorkspace.chartsJson,
},
nextWorkspace.id,
);
}
setActiveWorkspaceId(null);
}
setError(null);
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { emit, listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Minus, Square, X } from "lucide-react";
@@ -30,6 +30,21 @@ export function WorkspaceWindowPage({ theme }: WorkspaceWindowPageProps) {
const currentWindow = getCurrentWindow();
const { scope, workspaceId, title } = parseWorkspaceRoute();
const [windowTitle, setWindowTitle] = useState(title);
const closingRef = useRef(false);
const closeWorkspaceWindow = useCallback(async () => {
if (closingRef.current) return;
closingRef.current = true;
if (workspaceId) {
await emit("workspace-window://closed", {
scope,
workspaceId,
});
}
await currentWindow.destroy();
}, [currentWindow, scope, workspaceId]);
useEffect(() => {
void currentWindow.setTitle(windowTitle);
@@ -57,19 +72,15 @@ export function WorkspaceWindowPage({ theme }: WorkspaceWindowPageProps) {
}, [scope, workspaceId]);
useEffect(() => {
const unlistenPromise = currentWindow.onCloseRequested(() => {
if (!workspaceId) return;
void emit("workspace-window://closed", {
scope,
workspaceId,
});
const unlistenPromise = currentWindow.onCloseRequested((event) => {
event.preventDefault();
void closeWorkspaceWindow();
});
return () => {
void unlistenPromise.then((unlisten) => unlisten());
};
}, [currentWindow, scope, workspaceId]);
}, [closeWorkspaceWindow, currentWindow]);
const titleBarClass = isDark
? "flex h-10 shrink-0 items-center border-b border-white/10 bg-[#071421] text-slate-100"
@@ -151,7 +162,7 @@ export function WorkspaceWindowPage({ theme }: WorkspaceWindowPageProps) {
<button
type="button"
title="Fechar"
onClick={() => currentWindow.close()}
onClick={() => void closeWorkspaceWindow()}
className={closeButtonClass}
>
<X className="h-4 w-4" />
+124 -30
View File
@@ -5,6 +5,7 @@ import {
EmptyWorkspace,
LayoutButton,
ModeButton,
NewWorkspaceModal,
WorkspaceSelector,
} from "../../chartworkspace/components/ChartWorkspaceControls";
import { openWorkspaceWindow } from "../../chartworkspace/utils/openWorkspaceWindow";
@@ -57,6 +58,7 @@ type ClimateChartsPageProps = {
theme: "dark" | "light";
workspaceId?: number | null;
workspaceWindow?: boolean;
onWorkspaceChange?: (workspaceId: number | null) => void;
};
const RADIUS = "rounded-[6px]";
@@ -65,8 +67,9 @@ const INITIAL_CHARTS: ChartWorkspaceItem[] = [];
export function ClimateChartsPage({
theme,
workspaceId = null,
workspaceId,
workspaceWindow = false,
onWorkspaceChange,
}: ClimateChartsPageProps) {
const isDark = theme === "dark";
@@ -79,6 +82,7 @@ export function ClimateChartsPage({
const [savedOpen, setSavedOpen] = useState(false);
const [movingChartId, setMovingChartId] = useState<string | null>(null);
const [newChartOpen, setNewChartOpen] = useState(false);
const [newWorkspaceOpen, setNewWorkspaceOpen] = useState(false);
const [placingChartId, setPlacingChartId] = useState<string | null>(null);
const [detachedWorkspaceIds, setDetachedWorkspaceIds] = useState<Set<number>>(() => new Set());
@@ -87,7 +91,8 @@ export function ClimateChartsPage({
defaultName: "Workspace Clima",
});
const activeWorkspaceId = workspaceId ?? workspaceSelection.activeWorkspaceId;
const activeWorkspaceId =
workspaceId !== undefined ? workspaceId : workspaceSelection.activeWorkspaceId;
const activeWorkspace =
workspaceSelection.workspaces.find((workspace) => workspace.id === activeWorkspaceId) ??
workspaceSelection.activeWorkspace;
@@ -96,26 +101,38 @@ export function ClimateChartsPage({
const workspaceWindowLabel = (workspaceId: number) =>
`workspace-climate-${workspaceId}`;
const renameWorkspace = async (workspaceId: number, name: string) => {
const workspace = workspaceSelection.workspaces.find(
(item) => item.id === workspaceId,
const notifyWorkspaceListChanged = () => {
window.dispatchEvent(
new CustomEvent("chart-workspaces:changed", {
detail: { scope: "CLIMATE" },
}),
);
};
if (!workspace) return;
const selectWorkspace = async (workspaceId: number) => {
await workspaceSelection.selectWorkspace(workspaceId);
onWorkspaceChange?.(workspaceId);
};
const createWorkspace = async (name: string) => {
const createdWorkspace = await workspaceSelection.createWorkspace(name);
if (createdWorkspace) {
onWorkspaceChange?.(createdWorkspace.id);
notifyWorkspaceListChanged();
}
};
const renameWorkspace = async (workspaceId: number, name: string) => {
await saveChartWorkspace(
"CLIMATE",
{
name,
sortOrder: workspace.sortOrder,
defaultWorkspace: workspace.defaultWorkspace,
layoutMode: workspace.layoutMode,
chartsJson: workspace.chartsJson,
},
{ name },
workspaceId,
);
workspaceSelection.updateWorkspaceName(workspaceId, name);
notifyWorkspaceListChanged();
const existing = await WebviewWindow.getByLabel(
workspaceWindowLabel(workspaceId),
@@ -141,6 +158,13 @@ export function ClimateChartsPage({
});
await workspaceSelection.deleteWorkspace(workspaceId);
if (activeWorkspaceId === workspaceId) {
workspaceSelection.setActiveWorkspace(null);
onWorkspaceChange?.(null);
}
notifyWorkspaceListChanged();
};
const detachWorkspace = () => {
@@ -148,17 +172,8 @@ export function ClimateChartsPage({
setDetachedWorkspaceIds((current) => new Set(current).add(activeWorkspaceId));
const nextWorkspace = workspaceSelection.workspaces.find(
(workspace) =>
workspace.id !== activeWorkspaceId &&
!detachedWorkspaceIds.has(workspace.id),
);
if (nextWorkspace) {
void workspaceSelection.selectWorkspace(nextWorkspace.id);
} else {
workspaceSelection.setActiveWorkspace(null);
}
workspaceSelection.setActiveWorkspace(null);
onWorkspaceChange?.(null);
void openWorkspaceWindow(
activeWorkspaceId,
@@ -187,6 +202,54 @@ export function ClimateChartsPage({
};
}, []);
useEffect(() => {
if (workspaceWindow || workspaceSelection.loading) return;
let cancelled = false;
async function syncDetachedWorkspaceWindows() {
const nextDetachedWorkspaceIds = new Set<number>();
for (const workspace of workspaceSelection.workspaces) {
const existing = await WebviewWindow.getByLabel(
workspaceWindowLabel(workspace.id),
);
if (existing) {
nextDetachedWorkspaceIds.add(workspace.id);
}
}
if (cancelled) return;
setDetachedWorkspaceIds(nextDetachedWorkspaceIds);
const selectedWorkspaceId = activeWorkspaceId;
if (
selectedWorkspaceId !== null &&
nextDetachedWorkspaceIds.has(selectedWorkspaceId)
) {
workspaceSelection.setActiveWorkspace(null);
onWorkspaceChange?.(null);
}
}
void syncDetachedWorkspaceWindows();
return () => {
cancelled = true;
};
}, [
workspaceSelection.workspaces,
activeWorkspaceId,
workspaceSelection.loading,
workspaceSelection.selectWorkspace,
workspaceSelection.setActiveWorkspace,
workspaceWindow,
onWorkspaceChange,
]);
useEffect(() => {
setCharts([]);
setConfigChartId(null);
@@ -231,7 +294,7 @@ export function ClimateChartsPage({
},
});
const canAddMoreCharts = charts.length < MAX_CHARTS;
const canAddMoreCharts = activeWorkspaceId !== null && charts.length < MAX_CHARTS;
const visibleCharts = useMemo(() => {
const openCharts = charts.filter(
@@ -566,13 +629,13 @@ export function ClimateChartsPage({
<WorkspaceSelector
theme={theme}
workspaces={workspaceSelection.workspaces}
activeWorkspaceId={workspaceSelection.activeWorkspaceId}
activeWorkspaceId={activeWorkspaceId}
detachedWorkspaceIds={detachedWorkspaceIds}
loading={workspaceSelection.loading}
creating={workspaceSelection.creating}
canCreateWorkspace={workspaceSelection.canCreateWorkspace}
onSelectWorkspace={workspaceSelection.selectWorkspace}
onCreateWorkspace={(name) => void workspaceSelection.createWorkspace(name)}
onSelectWorkspace={(workspaceId) => void selectWorkspace(workspaceId)}
onCreateWorkspace={createWorkspace}
onRenameWorkspace={(workspaceId, name) => void renameWorkspace(workspaceId, name)}
onDeleteWorkspace={(workspaceId) => void deleteWorkspace(workspaceId)}
/>
@@ -705,11 +768,31 @@ export function ClimateChartsPage({
{visibleCharts.length === 0 ? (
<EmptyWorkspace
theme={theme}
canAddMoreCharts={canAddMoreCharts}
canAddMoreCharts={activeWorkspaceId === null || canAddMoreCharts}
title={activeWorkspaceId === null ? "Nenhum workspace aberto" : undefined}
description={
activeWorkspaceId === null
? "Crie um workspace novo ou abra um workspace guardado para continuar."
: undefined
}
addLabel={activeWorkspaceId === null ? "Novo Workspace" : "Novo Gráfico"}
savedLabel={activeWorkspaceId === null ? "Abrir Workspace" : "Abrir Guardado"}
showSavedButton={activeWorkspaceId !== null}
onAddChart={() => {
if (activeWorkspaceId === null) {
setNewWorkspaceOpen(true);
return;
}
if (canAddMoreCharts) setNewChartOpen(true);
}}
onOpenSaved={() => setSavedOpen(true)}
onOpenSaved={() => {
if (activeWorkspaceId === null) {
return;
}
setSavedOpen(true);
}}
/>
) : (
<section className={layoutGridClass(layoutMode)}>
@@ -785,6 +868,17 @@ export function ClimateChartsPage({
/>
)}
{newWorkspaceOpen && (
<NewWorkspaceModal
theme={theme}
creating={workspaceSelection.creating}
onClose={() => setNewWorkspaceOpen(false)}
onCreate={(name) => {
void createWorkspace(name).then(() => setNewWorkspaceOpen(false));
}}
/>
)}
{configChart && (
<ChartConfigModal
theme={theme}
+124 -63
View File
@@ -5,6 +5,7 @@ import {
EmptyWorkspace,
LayoutButton,
ModeButton,
NewWorkspaceModal,
WorkspaceSelector,
} from "../../chartworkspace/components/ChartWorkspaceControls";
import { openWorkspaceWindow } from "../../chartworkspace/utils/openWorkspaceWindow";
@@ -13,7 +14,6 @@ import { saveChartWorkspace } from "../../chartworkspace/api/chartWorkspaceApi";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import {
Cog,
Copy,
Maximize2,
AreaChart,
BarChart3,
@@ -57,6 +57,7 @@ type MainChartsPageProps = {
theme: "dark" | "light";
workspaceId?: number | null;
workspaceWindow?: boolean;
onWorkspaceChange?: (workspaceId: number | null) => void;
};
const RADIUS = "rounded-[6px]";
@@ -65,8 +66,9 @@ const INITIAL_CHARTS: ChartWorkspaceItem[] = [];
export function MainChartsPage({
theme,
workspaceId = null,
workspaceId,
workspaceWindow = false,
onWorkspaceChange,
}: MainChartsPageProps) {
const isDark = theme === "dark";
@@ -79,6 +81,7 @@ export function MainChartsPage({
const [savedOpen, setSavedOpen] = useState(false);
const [movingChartId, setMovingChartId] = useState<string | null>(null);
const [newChartOpen, setNewChartOpen] = useState(false);
const [newWorkspaceOpen, setNewWorkspaceOpen] = useState(false);
const [placingChartId, setPlacingChartId] = useState<string | null>(null);
const [detachedWorkspaceIds, setDetachedWorkspaceIds] = useState<Set<number>>(() => new Set());
@@ -87,7 +90,8 @@ export function MainChartsPage({
defaultName: "Workspace Geral",
});
const activeWorkspaceId = workspaceId ?? workspaceSelection.activeWorkspaceId;
const activeWorkspaceId =
workspaceId !== undefined ? workspaceId : workspaceSelection.activeWorkspaceId;
const activeWorkspace =
workspaceSelection.workspaces.find((workspace) => workspace.id === activeWorkspaceId) ??
workspaceSelection.activeWorkspace;
@@ -96,26 +100,38 @@ export function MainChartsPage({
const workspaceWindowLabel = (workspaceId: number) =>
`workspace-global-${workspaceId}`;
const renameWorkspace = async (workspaceId: number, name: string) => {
const workspace = workspaceSelection.workspaces.find(
(item) => item.id === workspaceId,
const notifyWorkspaceListChanged = () => {
window.dispatchEvent(
new CustomEvent("chart-workspaces:changed", {
detail: { scope: "GLOBAL" },
}),
);
};
if (!workspace) return;
const selectWorkspace = async (workspaceId: number) => {
await workspaceSelection.selectWorkspace(workspaceId);
onWorkspaceChange?.(workspaceId);
};
const createWorkspace = async (name: string) => {
const createdWorkspace = await workspaceSelection.createWorkspace(name);
if (createdWorkspace) {
onWorkspaceChange?.(createdWorkspace.id);
notifyWorkspaceListChanged();
}
};
const renameWorkspace = async (workspaceId: number, name: string) => {
await saveChartWorkspace(
"GLOBAL",
{
name,
sortOrder: workspace.sortOrder,
defaultWorkspace: workspace.defaultWorkspace,
layoutMode: workspace.layoutMode,
chartsJson: workspace.chartsJson,
},
{ name },
workspaceId,
);
workspaceSelection.updateWorkspaceName(workspaceId, name);
notifyWorkspaceListChanged();
const existing = await WebviewWindow.getByLabel(
workspaceWindowLabel(workspaceId),
@@ -141,6 +157,13 @@ export function MainChartsPage({
});
await workspaceSelection.deleteWorkspace(workspaceId);
if (activeWorkspaceId === workspaceId) {
workspaceSelection.setActiveWorkspace(null);
onWorkspaceChange?.(null);
}
notifyWorkspaceListChanged();
};
const detachWorkspace = () => {
@@ -148,17 +171,8 @@ export function MainChartsPage({
setDetachedWorkspaceIds((current) => new Set(current).add(activeWorkspaceId));
const nextWorkspace = workspaceSelection.workspaces.find(
(workspace) =>
workspace.id !== activeWorkspaceId &&
!detachedWorkspaceIds.has(workspace.id),
);
if (nextWorkspace) {
void workspaceSelection.selectWorkspace(nextWorkspace.id);
} else {
workspaceSelection.setActiveWorkspace(null);
}
workspaceSelection.setActiveWorkspace(null);
onWorkspaceChange?.(null);
void openWorkspaceWindow(
activeWorkspaceId,
@@ -209,6 +223,54 @@ export function MainChartsPage({
};
}, []);
useEffect(() => {
if (workspaceWindow || workspaceSelection.loading) return;
let cancelled = false;
async function syncDetachedWorkspaceWindows() {
const nextDetachedWorkspaceIds = new Set<number>();
for (const workspace of workspaceSelection.workspaces) {
const existing = await WebviewWindow.getByLabel(
workspaceWindowLabel(workspace.id),
);
if (existing) {
nextDetachedWorkspaceIds.add(workspace.id);
}
}
if (cancelled) return;
setDetachedWorkspaceIds(nextDetachedWorkspaceIds);
const selectedWorkspaceId = activeWorkspaceId;
if (
selectedWorkspaceId !== null &&
nextDetachedWorkspaceIds.has(selectedWorkspaceId)
) {
workspaceSelection.setActiveWorkspace(null);
onWorkspaceChange?.(null);
}
}
void syncDetachedWorkspaceWindows();
return () => {
cancelled = true;
};
}, [
workspaceSelection.workspaces,
activeWorkspaceId,
workspaceSelection.loading,
workspaceSelection.selectWorkspace,
workspaceSelection.setActiveWorkspace,
workspaceWindow,
onWorkspaceChange,
]);
useEffect(() => {
setCharts([]);
setConfigChartId(null);
@@ -253,7 +315,7 @@ export function MainChartsPage({
},
});
const canAddMoreCharts = charts.length < MAX_CHARTS;
const canAddMoreCharts = activeWorkspaceId !== null && charts.length < MAX_CHARTS;
const visibleCharts = useMemo(() => {
const openCharts = charts.filter(
@@ -343,31 +405,6 @@ export function MainChartsPage({
setNewChartOpen(false);
};
const duplicateChart = (chartId: string) => {
if (!canAddMoreCharts) return;
const chart = charts.find((item) => item.id === chartId);
if (!chart) return;
const copy: ChartWorkspaceItem = {
...chart,
id: `chart-${Date.now()}`,
title: `${chart.title} cópia`,
};
setCharts((current) => {
const next = [...current, copy];
if (next.length >= 3) {
setLayoutMode("fourGrid");
} else if (next.length === 2) {
setLayoutMode("twoColumns");
}
return next;
});
};
const setChartMode = (chartId: string, mode: WorkspaceChartMode) => {
setCharts((current) =>
current.map((chart) =>
@@ -641,13 +678,13 @@ export function MainChartsPage({
<WorkspaceSelector
theme={theme}
workspaces={workspaceSelection.workspaces}
activeWorkspaceId={workspaceSelection.activeWorkspaceId}
activeWorkspaceId={activeWorkspaceId}
detachedWorkspaceIds={detachedWorkspaceIds}
loading={workspaceSelection.loading}
creating={workspaceSelection.creating}
canCreateWorkspace={workspaceSelection.canCreateWorkspace}
onSelectWorkspace={workspaceSelection.selectWorkspace}
onCreateWorkspace={(name) => void workspaceSelection.createWorkspace(name)}
onSelectWorkspace={(workspaceId) => void selectWorkspace(workspaceId)}
onCreateWorkspace={createWorkspace}
onRenameWorkspace={(workspaceId, name) => void renameWorkspace(workspaceId, name)}
onDeleteWorkspace={(workspaceId) => void deleteWorkspace(workspaceId)}
/>
@@ -780,12 +817,32 @@ export function MainChartsPage({
{visibleCharts.length === 0 ? (
<EmptyWorkspace
theme={theme}
canAddMoreCharts={canAddMoreCharts}
canAddMoreCharts={activeWorkspaceId === null || canAddMoreCharts}
title={activeWorkspaceId === null ? "Nenhum workspace aberto" : undefined}
description={
activeWorkspaceId === null
? "Crie um workspace novo ou abra um workspace guardado para continuar."
: undefined
}
addLabel={activeWorkspaceId === null ? "Novo Workspace" : "Novo Gráfico"}
savedLabel={activeWorkspaceId === null ? "Abrir Workspace" : "Abrir Guardado"}
showSavedButton={activeWorkspaceId !== null}
minHeightClass="min-h-[520px]"
onAddChart={() => {
if (activeWorkspaceId === null) {
setNewWorkspaceOpen(true);
return;
}
if (canAddMoreCharts) setNewChartOpen(true);
}}
onOpenSaved={() => setSavedOpen(true)}
onOpenSaved={() => {
if (activeWorkspaceId === null) {
return;
}
setSavedOpen(true);
}}
/>
) : (
<section className={layoutGridClass(layoutMode)}>
@@ -799,7 +856,6 @@ export function MainChartsPage({
movingChartId={movingChartId}
setMovingChartId={setMovingChartId}
swapCharts={swapCharts}
duplicateChart={duplicateChart}
closeChart={closeChart}
setConfigChartId={setConfigChartId}
setChartMode={setChartMode}
@@ -864,6 +920,17 @@ export function MainChartsPage({
/>
)}
{newWorkspaceOpen && (
<NewWorkspaceModal
theme={theme}
creating={workspaceSelection.creating}
onClose={() => setNewWorkspaceOpen(false)}
onCreate={(name) => {
void createWorkspace(name).then(() => setNewWorkspaceOpen(false));
}}
/>
)}
{configChart && (
<ChartConfigModal
theme={theme}
@@ -895,7 +962,6 @@ function WorkspaceChartContainer({
movingChartId,
setMovingChartId,
swapCharts,
duplicateChart,
closeChart,
setConfigChartId,
setChartMode,
@@ -914,7 +980,6 @@ function WorkspaceChartContainer({
movingChartId: string | null;
setMovingChartId: React.Dispatch<React.SetStateAction<string | null>>;
swapCharts: (sourceId: string, targetId: string) => void;
duplicateChart: (chartId: string) => void;
closeChart: (chartId: string) => void | Promise<void>;
setConfigChartId: React.Dispatch<React.SetStateAction<string | null>>;
setChartMode: (chartId: string, mode: WorkspaceChartMode) => void;
@@ -1004,10 +1069,6 @@ function WorkspaceChartContainer({
<Cog className="h-4 w-4" />
</button>
<button type="button" title="Duplicar" className={floatingIconClass(theme)} onClick={() => duplicateChart(chartItem.id)}>
<Copy className="h-4 w-4" />
</button>
<button
type="button"
title="Fechar"
+124 -69
View File
@@ -7,7 +7,6 @@ import {
ChevronDown,
Cog,
Columns2,
Copy,
Grid2X2,
GripVertical,
LineChart,
@@ -29,6 +28,7 @@ import {
EmptyWorkspace,
LayoutButton,
ModeButton,
NewWorkspaceModal,
WorkspaceSelector,
} from "../../chartworkspace/components/ChartWorkspaceControls";
import { openWorkspaceWindow } from "../../chartworkspace/utils/openWorkspaceWindow";
@@ -62,6 +62,7 @@ type MeteoChartsPageProps = {
theme: "dark" | "light";
workspaceId?: number | null;
workspaceWindow?: boolean;
onWorkspaceChange?: (workspaceId: number | null) => void;
};
type HistorianPoint = {
@@ -78,8 +79,9 @@ const INITIAL_CHARTS: ChartWorkspaceItem[] = [];
export function MeteoChartsPage({
theme,
workspaceId = null,
workspaceId,
workspaceWindow = false,
onWorkspaceChange,
}: MeteoChartsPageProps) {
const isDark = theme === "dark";
@@ -96,6 +98,7 @@ export function MeteoChartsPage({
const [savedOpen, setSavedOpen] = useState(false);
const [movingChartId, setMovingChartId] = useState<string | null>(null);
const [newChartOpen, setNewChartOpen] = useState(false);
const [newWorkspaceOpen, setNewWorkspaceOpen] = useState(false);
const [placingChartId, setPlacingChartId] = useState<string | null>(null);
const [detachedWorkspaceIds, setDetachedWorkspaceIds] = useState<Set<number>>(() => new Set());
@@ -104,7 +107,8 @@ export function MeteoChartsPage({
defaultName: "Workspace Meteo",
});
const activeWorkspaceId = workspaceId ?? workspaceSelection.activeWorkspaceId;
const activeWorkspaceId =
workspaceId !== undefined ? workspaceId : workspaceSelection.activeWorkspaceId;
const activeWorkspace =
workspaceSelection.workspaces.find((workspace) => workspace.id === activeWorkspaceId) ??
workspaceSelection.activeWorkspace;
@@ -113,26 +117,38 @@ export function MeteoChartsPage({
const workspaceWindowLabel = (workspaceId: number) =>
`workspace-meteo-${workspaceId}`;
const renameWorkspace = async (workspaceId: number, name: string) => {
const workspace = workspaceSelection.workspaces.find(
(item) => item.id === workspaceId,
const notifyWorkspaceListChanged = () => {
window.dispatchEvent(
new CustomEvent("chart-workspaces:changed", {
detail: { scope: "METEO" },
}),
);
};
if (!workspace) return;
const selectWorkspace = async (workspaceId: number) => {
await workspaceSelection.selectWorkspace(workspaceId);
onWorkspaceChange?.(workspaceId);
};
const createWorkspace = async (name: string) => {
const createdWorkspace = await workspaceSelection.createWorkspace(name);
if (createdWorkspace) {
onWorkspaceChange?.(createdWorkspace.id);
notifyWorkspaceListChanged();
}
};
const renameWorkspace = async (workspaceId: number, name: string) => {
await saveChartWorkspace(
"METEO",
{
name,
sortOrder: workspace.sortOrder,
defaultWorkspace: workspace.defaultWorkspace,
layoutMode: workspace.layoutMode,
chartsJson: workspace.chartsJson,
},
{ name },
workspaceId,
);
workspaceSelection.updateWorkspaceName(workspaceId, name);
notifyWorkspaceListChanged();
const existing = await WebviewWindow.getByLabel(
workspaceWindowLabel(workspaceId),
@@ -158,6 +174,13 @@ export function MeteoChartsPage({
});
await workspaceSelection.deleteWorkspace(workspaceId);
if (activeWorkspaceId === workspaceId) {
workspaceSelection.setActiveWorkspace(null);
onWorkspaceChange?.(null);
}
notifyWorkspaceListChanged();
};
const detachWorkspace = () => {
@@ -165,17 +188,8 @@ export function MeteoChartsPage({
setDetachedWorkspaceIds((current) => new Set(current).add(activeWorkspaceId));
const nextWorkspace = workspaceSelection.workspaces.find(
(workspace) =>
workspace.id !== activeWorkspaceId &&
!detachedWorkspaceIds.has(workspace.id),
);
if (nextWorkspace) {
void workspaceSelection.selectWorkspace(nextWorkspace.id);
} else {
workspaceSelection.setActiveWorkspace(null);
}
workspaceSelection.setActiveWorkspace(null);
onWorkspaceChange?.(null);
void openWorkspaceWindow(
activeWorkspaceId,
@@ -207,6 +221,54 @@ export function MeteoChartsPage({
};
}, []);
useEffect(() => {
if (workspaceWindow || workspaceSelection.loading) return;
let cancelled = false;
async function syncDetachedWorkspaceWindows() {
const nextDetachedWorkspaceIds = new Set<number>();
for (const workspace of workspaceSelection.workspaces) {
const existing = await WebviewWindow.getByLabel(
workspaceWindowLabel(workspace.id),
);
if (existing) {
nextDetachedWorkspaceIds.add(workspace.id);
}
}
if (cancelled) return;
setDetachedWorkspaceIds(nextDetachedWorkspaceIds);
const selectedWorkspaceId = activeWorkspaceId;
if (
selectedWorkspaceId !== null &&
nextDetachedWorkspaceIds.has(selectedWorkspaceId)
) {
workspaceSelection.setActiveWorkspace(null);
onWorkspaceChange?.(null);
}
}
void syncDetachedWorkspaceWindows();
return () => {
cancelled = true;
};
}, [
workspaceSelection.workspaces,
activeWorkspaceId,
workspaceSelection.loading,
workspaceSelection.selectWorkspace,
workspaceSelection.setActiveWorkspace,
workspaceWindow,
onWorkspaceChange,
]);
useEffect(() => {
setCharts([]);
setConfigChartId(null);
@@ -249,7 +311,7 @@ export function MeteoChartsPage({
},
});
const canAddMoreCharts = charts.length < MAX_CHARTS;
const canAddMoreCharts = activeWorkspaceId !== null && charts.length < MAX_CHARTS;
const visibleCharts = useMemo(() => {
const openCharts = charts.filter(
@@ -337,31 +399,6 @@ export function MeteoChartsPage({
setNewChartOpen(false);
};
const duplicateChart = (chartId: string) => {
if (!canAddMoreCharts) return;
const chart = charts.find((item) => item.id === chartId);
if (!chart) return;
const copy: ChartWorkspaceItem = {
...chart,
id: `chart-${Date.now()}`,
title: `${chart.title} cópia`,
};
setCharts((current) => {
const next = [...current, copy];
if (next.length >= 3) {
setLayoutMode("fourGrid");
} else if (next.length === 2) {
setLayoutMode("twoColumns");
}
return next;
});
};
const setChartMode = (chartId: string, mode: WorkspaceChartMode) => {
setCharts((current) =>
current.map((chart) =>
@@ -624,13 +661,13 @@ export function MeteoChartsPage({
<WorkspaceSelector
theme={theme}
workspaces={workspaceSelection.workspaces}
activeWorkspaceId={workspaceSelection.activeWorkspaceId}
activeWorkspaceId={activeWorkspaceId}
detachedWorkspaceIds={detachedWorkspaceIds}
loading={workspaceSelection.loading}
creating={workspaceSelection.creating}
canCreateWorkspace={workspaceSelection.canCreateWorkspace}
onSelectWorkspace={workspaceSelection.selectWorkspace}
onCreateWorkspace={(name) => void workspaceSelection.createWorkspace(name)}
onSelectWorkspace={(workspaceId) => void selectWorkspace(workspaceId)}
onCreateWorkspace={createWorkspace}
onRenameWorkspace={(workspaceId, name) => void renameWorkspace(workspaceId, name)}
onDeleteWorkspace={(workspaceId) => void deleteWorkspace(workspaceId)}
/>
@@ -763,12 +800,31 @@ export function MeteoChartsPage({
{visibleCharts.length === 0 ? (
<EmptyWorkspace
theme={theme}
canAddMoreCharts={canAddMoreCharts}
description="Abra um gráfico guardado ou crie um novo gráfico meteorológico para continuar."
canAddMoreCharts={activeWorkspaceId === null || canAddMoreCharts}
title={activeWorkspaceId === null ? "Nenhum workspace aberto" : undefined}
description={
activeWorkspaceId === null
? "Crie um workspace novo ou abra um workspace guardado para continuar."
: "Abra um gráfico guardado ou crie um novo gráfico meteorológico para continuar."
}
addLabel={activeWorkspaceId === null ? "Novo Workspace" : "Novo Gráfico"}
savedLabel={activeWorkspaceId === null ? "Abrir Workspace" : "Abrir Guardado"}
showSavedButton={activeWorkspaceId !== null}
onAddChart={() => {
if (activeWorkspaceId === null) {
setNewWorkspaceOpen(true);
return;
}
if (canAddMoreCharts) setNewChartOpen(true);
}}
onOpenSaved={() => setSavedOpen(true)}
onOpenSaved={() => {
if (activeWorkspaceId === null) {
return;
}
setSavedOpen(true);
}}
/>
) : (
<section className={layoutGridClass(layoutMode)}>
@@ -783,7 +839,6 @@ export function MeteoChartsPage({
movingChartId={movingChartId}
setMovingChartId={setMovingChartId}
swapCharts={swapCharts}
duplicateChart={duplicateChart}
closeChart={closeChart}
setConfigChartId={setConfigChartId}
setChartMode={setChartMode}
@@ -857,6 +912,17 @@ export function MeteoChartsPage({
/>
)}
{newWorkspaceOpen && (
<NewWorkspaceModal
theme={theme}
creating={workspaceSelection.creating}
onClose={() => setNewWorkspaceOpen(false)}
onCreate={(name) => {
void createWorkspace(name).then(() => setNewWorkspaceOpen(false));
}}
/>
)}
{configChart && (
<ChartConfigModal
theme={theme}
@@ -889,7 +955,6 @@ function WorkspaceChartContainer({
movingChartId,
setMovingChartId,
swapCharts,
duplicateChart,
closeChart,
setConfigChartId,
setChartMode,
@@ -907,7 +972,6 @@ function WorkspaceChartContainer({
movingChartId: string | null;
setMovingChartId: React.Dispatch<React.SetStateAction<string | null>>;
swapCharts: (sourceId: string, targetId: string) => void;
duplicateChart: (chartId: string) => void;
closeChart: (chartId: string) => void | Promise<void>;
setConfigChartId: React.Dispatch<React.SetStateAction<string | null>>;
setChartMode: (chartId: string, mode: WorkspaceChartMode) => void;
@@ -992,15 +1056,6 @@ function WorkspaceChartContainer({
<Cog className="h-4 w-4" />
</button>
<button
type="button"
title="Duplicar"
className={floatingIconClass(theme)}
onClick={() => duplicateChart(chartItem.id)}
>
<Copy className="h-4 w-4" />
</button>
<button
type="button"
title="Fechar"
+13 -1
View File
@@ -8,10 +8,12 @@ import {
} from "react";
import { fetchRuntimeConfig } from "../../lib/api/systemApi";
import { ApiResponseError } from "../../lib/api/readJsonResponse";
import {
clearRuntimeConfig,
setRuntimeConfig as setRuntimeConfigSnapshot,
} from "../../lib/api/runtimeConfigStore";
import { useAuth } from "../auth/AuthContext";
import type { RuntimeConfig } from "../../types/system";
type RuntimeConfigContextValue = {
@@ -23,6 +25,7 @@ const RuntimeConfigContext = createContext<RuntimeConfigContextValue | null>(
);
export function RuntimeConfigProvider({ children }: { children: ReactNode }) {
const { logout } = useAuth();
const [runtimeConfig, setRuntimeConfig] = useState<RuntimeConfig | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -41,6 +44,15 @@ export function RuntimeConfigProvider({ children }: { children: ReactNode }) {
if (cancelled) return;
clearRuntimeConfig();
if (
exception instanceof ApiResponseError &&
(exception.status === 401 || exception.status === 403)
) {
logout();
return;
}
setError(
exception instanceof Error
? exception.message
@@ -52,7 +64,7 @@ export function RuntimeConfigProvider({ children }: { children: ReactNode }) {
cancelled = true;
clearRuntimeConfig();
};
}, []);
}, [logout]);
const value = useMemo<RuntimeConfigContextValue | null>(
() => (runtimeConfig ? { runtimeConfig } : null),
+21 -2
View File
@@ -1,3 +1,14 @@
export class ApiResponseError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly body: string,
) {
super(message);
this.name = "ApiResponseError";
}
}
export async function readJsonResponse<T>(
response: Response,
context: string,
@@ -5,7 +16,11 @@ export async function readJsonResponse<T>(
const text = await response.text();
if (!response.ok) {
throw new Error(`${context}: ${response.status} ${text}`.trim());
throw new ApiResponseError(
`${context}: ${response.status} ${text}`.trim(),
response.status,
text,
);
}
if (!text) {
@@ -23,7 +38,11 @@ export async function readOptionalJsonResponse<T>(
const text = await response.text();
if (!response.ok) {
throw new Error(`${context}: ${response.status} ${text}`.trim());
throw new ApiResponseError(
`${context}: ${response.status} ${text}`.trim(),
response.status,
text,
);
}
return text ? (JSON.parse(text) as T) : fallback;