From 90b863b6cf3eb1f08848570a42b70a32b6aaa430 Mon Sep 17 00:00:00 2001 From: John Costa Date: Sat, 10 May 2025 16:43:30 +0100 Subject: [PATCH] feat: frontend notification manager --- frontend/src/ProtectedRoute.tsx | 34 ++++++++++-- frontend/src/Search.tsx | 4 ++ frontend/src/network/index.ts | 2 +- frontend/src/notifications/index.ts | 85 +++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 frontend/src/notifications/index.ts diff --git a/frontend/src/ProtectedRoute.tsx b/frontend/src/ProtectedRoute.tsx index 66226cb..dbed383 100644 --- a/frontend/src/ProtectedRoute.tsx +++ b/frontend/src/ProtectedRoute.tsx @@ -1,8 +1,15 @@ -import { type Component, type JSX, Show } from "solid-js"; +import { + type Component, + type JSX, + ParentProps, + Show, + useContext, +} from "solid-js"; import { jwtDecode } from "jwt-decode"; import { Navigate } from "@solidjs/router"; import { save_token } from "tauri-plugin-ios-shared-token-api"; import { platform } from "@tauri-apps/plugin-os"; +import { Notifications, NotificationsContext } from "./notifications"; export const isTokenValid = (): boolean => { const token = localStorage.getItem("access"); @@ -19,9 +26,26 @@ export const isTokenValid = (): boolean => { } }; -export const ProtectedRoute: Component<{ children?: JSX.Element }> = ( - props, -) => { +const WithNotifications: Component = (props) => { + const notifications = Notifications(); + + return ( + + {props.children} + + ); +}; + +export const useNotifications = () => { + const notifications = useContext(NotificationsContext); + if (notifications == null) { + throw new Error("Notifications must be defined when using this hook"); + } + + return notifications; +}; + +export const ProtectedRoute: Component = (props) => { const isValid = isTokenValid(); if (isValid) { @@ -42,7 +66,7 @@ export const ProtectedRoute: Component<{ children?: JSX.Element }> = ( return ( }> - {props.children} + {props.children} ); }; diff --git a/frontend/src/Search.tsx b/frontend/src/Search.tsx index 3ddf00f..d21ef23 100644 --- a/frontend/src/Search.tsx +++ b/frontend/src/Search.tsx @@ -17,12 +17,16 @@ import { base, type UserImage } from "./network"; import { useSearchImageContext } from "./contexts/SearchImageContext"; import { A } from "@solidjs/router"; import { useSetEntity } from "./WithEntityDialog"; +import { useNotifications } from "./ProtectedRoute"; export const Search = () => { const [searchResults, setSearchResults] = createSignal([]); const [searchQuery, setSearchQuery] = createSignal(""); const setEntity = useSetEntity(); + const notifications = useNotifications(); + console.log(notifications); + const { images, imagesWithProperties, onRefetchImages } = useSearchImageContext(); diff --git a/frontend/src/network/index.ts b/frontend/src/network/index.ts index 5b9066a..cb57522 100644 --- a/frontend/src/network/index.ts +++ b/frontend/src/network/index.ts @@ -20,7 +20,7 @@ type BaseRequestParams = Partial<{ method: "GET" | "POST"; }>; -export const base = "https://haystack.johncosta.tech"; +export const base = "http://localhost:3040"; const getBaseRequest = ({ path, body, method }: BaseRequestParams): Request => { return new Request(`${base}/${path}`, { diff --git a/frontend/src/notifications/index.ts b/frontend/src/notifications/index.ts new file mode 100644 index 0000000..7002552 --- /dev/null +++ b/frontend/src/notifications/index.ts @@ -0,0 +1,85 @@ +import { createStore } from "solid-js/store"; +import { + type InferOutput, + literal, + pipe, + safeParse, + strictObject, + string, + union, + uuid, +} from "valibot"; +import { base } from "../network"; +import { createContext } from "solid-js"; + +const processingImagesValidator = strictObject({ + ImageID: pipe(string(), uuid()), + Status: union([literal("in-process"), literal("complete")]), +}); + +type NotificationState = { + ProcessingImages: Record< + string, + InferOutput["Status"] + >; +}; + +export const Notifications = () => { + const [state, setState] = createStore({ + ProcessingImages: {}, + }); + + const access = localStorage.getItem("access"); + if (access == null) { + throw new Error("Access token not defined"); + } + + const dataEventListener = (e: MessageEvent) => { + const processingImage = safeParse(processingImagesValidator, e.data); + if (!processingImage.success) { + console.error("Processing image could not be parsed.", e.data); + return; + } + + const { ImageID, Status } = processingImage.output; + + if (ImageID in state.ProcessingImages) { + const localImageStatus = state.ProcessingImages[ImageID]; + if (localImageStatus !== "in-process" || Status !== "complete") { + console.error( + "Invalid state, an image present must always be in process", + ); + return; + } + + setState("ProcessingImages", (images) => { + delete images[ImageID]; + + // TODO: this might not work because it's not a new object. + return images; + }); + } else { + if (Status !== "in-process") { + console.error( + "Invalid state, at this point a new image that isn't present should be in-progress", + ); + return; + } + + setState("ProcessingImages", { + [ImageID]: Status, + }); + } + }; + + const events = new EventSource(`${base}/notifications?token=${access}`); + + events.addEventListener("data", dataEventListener); + + return { + state, + }; +}; + +export const NotificationsContext = + createContext>();