Files
NexaPG/frontend/src/state.jsx
nessi 839943d9fd Add navigation and smooth scrolling for alert toasts
This update enables opening specific alerts via toast buttons, utilizing `useNavigate` to redirect and auto-expand the corresponding alert on the Alerts page. Includes enhancements for toast dismissal with animations and adds new styles for smooth transitions and better user interaction.
2026-02-12 13:33:50 +01:00

207 lines
6.1 KiB
JavaScript

import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import { API_URL } from "./api";
const AuthCtx = createContext(null);
const UI_MODE_KEY = "nexapg_ui_mode";
function loadStorage() {
try {
return JSON.parse(localStorage.getItem("nexapg_auth") || "null");
} catch {
return null;
}
}
function loadUiMode() {
try {
const value = localStorage.getItem(UI_MODE_KEY);
if (value === "easy" || value === "dba") return value;
} catch {
// ignore storage errors
}
return "dba";
}
export function AuthProvider({ children }) {
const initial = loadStorage();
const [tokens, setTokens] = useState(initial?.tokens || null);
const [me, setMe] = useState(initial?.me || null);
const [uiMode, setUiModeState] = useState(loadUiMode);
const [alertStatus, setAlertStatus] = useState({ warnings: [], alerts: [], warning_count: 0, alert_count: 0 });
const [alertToasts, setAlertToasts] = useState([]);
const knownAlertKeysRef = useRef(new Set());
const hasAlertSnapshotRef = useRef(false);
const persist = (nextTokens, nextMe) => {
if (nextTokens && nextMe) {
localStorage.setItem("nexapg_auth", JSON.stringify({ tokens: nextTokens, me: nextMe }));
} else {
localStorage.removeItem("nexapg_auth");
}
};
const refresh = async () => {
if (!tokens?.refreshToken) return null;
const res = await fetch(`${API_URL}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: tokens.refreshToken }),
});
if (!res.ok) {
setTokens(null);
setMe(null);
persist(null, null);
return null;
}
const data = await res.json();
const nextTokens = { accessToken: data.access_token, refreshToken: data.refresh_token };
setTokens(nextTokens);
persist(nextTokens, me);
return nextTokens;
};
const login = async (email, password) => {
const res = await fetch(`${API_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error("Login failed");
const data = await res.json();
const nextTokens = { accessToken: data.access_token, refreshToken: data.refresh_token };
const meRes = await fetch(`${API_URL}/me`, {
headers: { Authorization: `Bearer ${nextTokens.accessToken}` },
});
if (!meRes.ok) throw new Error("Could not load user profile");
const profile = await meRes.json();
setTokens(nextTokens);
setMe(profile);
persist(nextTokens, profile);
};
const logout = async () => {
try {
if (tokens?.accessToken) {
await fetch(`${API_URL}/auth/logout`, {
method: "POST",
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
}
} finally {
setTokens(null);
setMe(null);
persist(null, null);
}
};
const dismissAlertToast = (toastId) => {
setAlertToasts((prev) => prev.map((t) => (t.id === toastId ? { ...t, closing: true } : t)));
setTimeout(() => {
setAlertToasts((prev) => prev.filter((t) => t.id !== toastId));
}, 220);
};
useEffect(() => {
if (!tokens?.accessToken) {
setAlertStatus({ warnings: [], alerts: [], warning_count: 0, alert_count: 0 });
setAlertToasts([]);
knownAlertKeysRef.current = new Set();
hasAlertSnapshotRef.current = false;
return;
}
let mounted = true;
const pushToastsForNewItems = (items) => {
if (!items.length) return;
const createdAt = Date.now();
const nextToasts = items.slice(0, 4).map((item, idx) => ({
id: `${createdAt}-${idx}-${item.alert_key}`,
alertKey: item.alert_key,
severity: item.severity,
title: item.name,
target: item.target_name,
message: item.message,
closing: false,
}));
setAlertToasts((prev) => [...nextToasts, ...prev].slice(0, 6));
for (const toast of nextToasts) {
setTimeout(() => {
if (!mounted) return;
dismissAlertToast(toast.id);
}, 8000);
}
};
const loadAlertStatus = async () => {
const request = async (accessToken) =>
fetch(`${API_URL}/alerts/status`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
let res = await request(tokens.accessToken);
if (res.status === 401 && tokens.refreshToken) {
const refreshed = await refresh();
if (refreshed?.accessToken) {
res = await request(refreshed.accessToken);
}
}
if (!res.ok) return;
const payload = await res.json();
if (!mounted) return;
setAlertStatus(payload);
const currentItems = [...(payload.warnings || []), ...(payload.alerts || [])];
const currentKeys = new Set(currentItems.map((item) => item.alert_key));
if (!hasAlertSnapshotRef.current) {
knownAlertKeysRef.current = currentKeys;
hasAlertSnapshotRef.current = true;
return;
}
const newItems = currentItems.filter((item) => !knownAlertKeysRef.current.has(item.alert_key));
knownAlertKeysRef.current = currentKeys;
pushToastsForNewItems(newItems);
};
loadAlertStatus().catch(() => {});
const timer = setInterval(() => {
loadAlertStatus().catch(() => {});
}, 8000);
return () => {
mounted = false;
clearInterval(timer);
};
}, [tokens?.accessToken, tokens?.refreshToken]);
const setUiMode = (nextMode) => {
const mode = nextMode === "easy" ? "easy" : "dba";
setUiModeState(mode);
localStorage.setItem(UI_MODE_KEY, mode);
};
const value = useMemo(
() => ({
tokens,
me,
login,
logout,
refresh,
uiMode,
setUiMode,
alertStatus,
alertToasts,
dismissAlertToast,
}),
[tokens, me, uiMode, alertStatus, alertToasts]
);
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthCtx);
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
return ctx;
}