Added NotificationProvider

This commit is contained in:
2026-07-25 01:03:16 -04:00
parent d0975354af
commit 2438a07eb7
4 changed files with 246 additions and 140 deletions

View File

@@ -0,0 +1,33 @@
import { Alert, Spinner } from "@heroui/react";
export type NotificationType = {
type: "success" | "default" | "accent" | "danger" | "warning";
loading: boolean;
title: string;
message: string;
};
export function Notification({
type,
loading,
title,
message,
}: NotificationType) {
return (
<div className="flex justify-center fixed top-12 w-dvw z-50">
<Alert className="w-fit h-fit border border-white" status={type}>
{loading ? (
<Alert.Indicator>
<Spinner size="sm" />
</Alert.Indicator>
) : (
<Alert.Indicator />
)}
<Alert.Content>
<Alert.Title>{title}</Alert.Title>
<Alert.Description>{message}</Alert.Description>
</Alert.Content>
</Alert>
</div>
);
}

View File

@@ -1,4 +1,3 @@
import { Alert, Spinner } from "@heroui/react";
import { import {
useEffect, useEffect,
useState, useState,
@@ -7,6 +6,7 @@ import {
type PropsWithChildren, type PropsWithChildren,
} from "react"; } from "react";
import { useLocation, useSearchParams } from "react-router"; import { useLocation, useSearchParams } from "react-router";
import { useNotification } from "./notification";
type User = { type User = {
id: string; id: string;
@@ -33,56 +33,96 @@ export function AuthProvider({ children }: PropsWithChildren) {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [searchParams, setSeachParams] = useSearchParams(); const [searchParams, setSeachParams] = useSearchParams();
const [popupShowed, setPopupShowed] = useState(false); const { notify } = useNotification();
const [error, setError] = useState("");
const location = useLocation(); const location = useLocation();
useEffect(() => {
if (user !== null && !loading) {
const timer = setTimeout(() => {
setPopupShowed(true);
}, 2000);
return () => clearTimeout(timer);
} else if (error !== "") {
const timer = setTimeout(() => {
setPopupShowed(true);
}, 10000);
return () => clearTimeout(timer);
}
else {
setPopupShowed(false);
}
}, [user, loading, error]);
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
try {
if (searchParams.get("openid.ns") !== null) { if (searchParams.get("openid.ns") !== null) {
setLoading(true); setLoading(true);
notify(
{
type: "accent",
loading: true,
title: "Logging in...",
message:
"Please wait while we log you in and sync your data. This may take a few moments.",
},
Infinity,
);
let query = searchParams.toString(); let query = searchParams.toString();
setSeachParams({}, { replace: true }); setSeachParams({}, { replace: true });
let response = await fetch( let response = await fetch(
"http://localhost:8080/login?" + query, `${import.meta.env.VITE_BASE_API_URL}/login?${query}`,
{ credentials: "include" }, { credentials: "include" },
).then((r) => r.text()); )
.then((r) => r.text())
.catch((e) => {
notify(
{
type: "danger",
loading: false,
title: "An error occured.",
message: `An error occured: ${e}`,
},
10000,
);
});
if (response === "nok") { if (response === "nok") {
setError("An error occured during the information exchange between Steam and Porning.") notify(
{
type: "danger",
loading: false,
title: "An error occured.",
message:
"An error occured during the information exchange between Steam and Porning.",
},
10000,
);
} }
} }
let data = await fetch("http://localhost:8080/me", { let data = await fetch(`${import.meta.env.VITE_BASE_API_URL}/me`, {
credentials: "include", credentials: "include",
}).then((r) => r.json()); })
.then((r) => r.json())
.catch((e) => {
notify(
{
type: "danger",
loading: false,
title: "An error occured.",
message: `An error occured: ${e}`,
},
10000,
);
});
if (data === null) { if (data === null) {
setError("An error occured while trying to fetch your user profile. Please contact an administrator.") notify(
{
type: "danger",
loading: false,
title: "An error occured.",
message:
"An error occured while trying to fetch your user profile. Please contact an administrator.",
},
10000,
);
} else if (data !== undefined) {
setUser(data);
notify(
{
type: "success",
loading: false,
title: "Login successful!",
message: "",
},
2000,
);
} }
setUser(data);
setLoading(false); setLoading(false);
} catch (_) {
setError("An error occured while communicating with the server.")
}
}; };
load(); load();
@@ -90,65 +130,32 @@ export function AuthProvider({ children }: PropsWithChildren) {
const login = () => { const login = () => {
window.location.replace( window.location.replace(
"https://steamcommunity.com/openid/login?openid.claimed_id=http://specs.openid.net/auth/2.0/identifier_select&openid.identity=http://specs.openid.net/auth/2.0/identifier_select&openid.mode=checkid_setup&openid.ns=http://specs.openid.net/auth/2.0&openid.realm=http://localhost:5173&openid.return_to=http://localhost:5173" + `https://steamcommunity.com/openid/login?openid.claimed_id=http://specs.openid.net/auth/2.0/identifier_select&openid.identity=http://specs.openid.net/auth/2.0/identifier_select&openid.mode=checkid_setup&openid.ns=http://specs.openid.net/auth/2.0&openid.realm=${import.meta.env.VITE_BASE_URL}&openid.return_to=${import.meta.env.VITE_BASE_URL}${location.pathname}`,
location.pathname,
); );
}; };
const logout = async () => { const logout = async () => {
await fetch("http://localhost:8080/logout", { credentials: "include" }); await fetch(import.meta.env.VITE_BASE_API_URL + "/logout", {
setUser(null); credentials: "include",
})
.then(() => {
setUser(null);
})
.catch((e) => {
notify(
{
type: "danger",
loading: false,
title: "An error occured.",
message: `An error occured: ${e}`,
},
10000,
);
});
}; };
return ( return (
<AuthContext.Provider value={{ user, loading, login, logout }}> <AuthContext.Provider value={{ user, loading, login, logout }}>
{loading ? (
<div className="flex justify-center fixed top-12 w-dvw z-50">
<Alert
className="w-fit h-fit border border-white"
status="accent"
>
<Alert.Indicator>
<Spinner size="sm" />
</Alert.Indicator>
<Alert.Content>
<Alert.Title>Logging in...</Alert.Title>
<Alert.Description>
Please wait while we log you in and sync your
data. This may take a few moments.
</Alert.Description>
</Alert.Content>
</Alert>
</div>
) : (
(!popupShowed &&
user !== null && (
<div className="flex justify-center fixed top-12 w-dvw z-50">
<Alert
className="w-fit h-fit border border-white"
status="success"
>
<Alert.Indicator />
<Alert.Content>
<Alert.Title>Login sucessful!</Alert.Title>
</Alert.Content>
</Alert>
</div>
)) || (!popupShowed && error !== "" && (
<div className="flex justify-center fixed top-12 w-dvw z-50">
<Alert
className="w-fit h-fit border border-white"
status="danger"
>
<Alert.Indicator />
<Alert.Content>
<Alert.Title>An error occured.</Alert.Title>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
</div>
))
)}
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

@@ -0,0 +1,62 @@
import {
createContext,
type PropsWithChildren,
useEffect,
useState,
useContext,
} from "react";
import { type NotificationType, Notification } from "~/components/notification";
type NotificationContextType = {
notify: (arg0: NotificationType, arg1: number) => void;
};
const NotificationContext = createContext<NotificationContextType>({
notify: (_arg0: NotificationType, _arg1: number) => {},
});
export function NotificationProvider({ children }: PropsWithChildren) {
const [notification, setNotification] = useState<NotificationType | null>(
null,
);
const [notificationTimeout, setNotificationTimeout] = useState(2000);
const [popupShowed, setPopupShowed] = useState(false);
const notify = (newNotification: NotificationType, timeout: number) => {
console.log("Changed notification");
setNotification(newNotification);
setNotificationTimeout(timeout);
};
useEffect(() => {
if (!popupShowed && notification !== null) {
console.log("setting timeout")
const timeout = setTimeout(() => {
console.log("timeout done")
setPopupShowed(true);
setNotification(null);
}, notificationTimeout);
return () => clearTimeout(timeout);
} else {
setPopupShowed(false);
}
}, [notification, notificationTimeout]);
return (
<NotificationContext.Provider value={{ notify }}>
{notification !== null && !popupShowed && (
<Notification
type={notification.type}
loading={notification.loading}
title={notification.title}
message={notification.message}
/>
)}
{children}
</NotificationContext.Provider>
);
}
export function useNotification() {
return useContext(NotificationContext);
}

View File

@@ -1,78 +1,82 @@
import { import {
isRouteErrorResponse, isRouteErrorResponse,
Links, Links,
Meta, Meta,
Outlet, Outlet,
Scripts, Scripts,
ScrollRestoration, ScrollRestoration,
} from "react-router"; } from "react-router";
import type { Route } from "./+types/root"; import type { Route } from "./+types/root";
import "./app.css"; import "./app.css";
import { AuthProvider } from "./hooks/auth"; import { AuthProvider } from "./hooks/auth";
import { NotificationProvider } from "./hooks/notification";
export const links: Route.LinksFunction = () => [ export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" }, { rel: "preconnect", href: "https://fonts.googleapis.com" },
{ {
rel: "preconnect", rel: "preconnect",
href: "https://fonts.gstatic.com", href: "https://fonts.gstatic.com",
crossOrigin: "anonymous", crossOrigin: "anonymous",
}, },
{ {
rel: "stylesheet", rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
}, },
]; ];
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<html className="dark" data-theme="dark" lang="en"> <html className="dark" data-theme="dark" lang="en">
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta
<Meta /> name="viewport"
<Links /> content="width=device-width, initial-scale=1"
</head> />
<body className="bg-background text-foreground"> <Meta />
<AuthProvider> <Links />
{children} </head>
</AuthProvider> <body className="bg-background text-foreground">
<ScrollRestoration /> <NotificationProvider>
<Scripts /> <AuthProvider>{children}</AuthProvider>
</body> </NotificationProvider>
</html> <ScrollRestoration />
); <Scripts />
</body>
</html>
);
} }
export default function App() { export default function App() {
return <Outlet />; return <Outlet />;
} }
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!"; let message = "Oops!";
let details = "An unexpected error occurred."; let details = "An unexpected error occurred.";
let stack: string | undefined; let stack: string | undefined;
if (isRouteErrorResponse(error)) { if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error"; message = error.status === 404 ? "404" : "Error";
details = details =
error.status === 404 error.status === 404
? "The requested page could not be found." ? "The requested page could not be found."
: error.statusText || details; : error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) { } else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message; details = error.message;
stack = error.stack; stack = error.stack;
} }
return ( return (
<main className="pt-16 p-4 container mx-auto"> <main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1> <h1>{message}</h1>
<p>{details}</p> <p>{details}</p>
{stack && ( {stack && (
<pre className="w-full p-4 overflow-x-auto"> <pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code> <code>{stack}</code>
</pre> </pre>
)} )}
</main> </main>
); );
} }