feat: updating notifications system based on incoming request

This is making the frontend data logic a little complex
This commit is contained in:
2025-07-02 13:28:28 +01:00
parent 5cf0b66688
commit c62378c20a
3 changed files with 174 additions and 136 deletions

View File

@ -1,106 +1,113 @@
import { import {
type Accessor, type Accessor,
type Component, type Component,
type ParentProps, type ParentProps,
createContext, createContext,
createMemo, createMemo,
createResource, createResource,
useContext, useContext,
} from "solid-js"; } from "solid-js";
import { getUserImages } from "../network"; import { getUserImages } from "../network";
import { groupPropertiesWithImage } from "../utils/groupPropertiesWithImage"; import { groupPropertiesWithImage } from "../utils/groupPropertiesWithImage";
export type ImageWithRawData = Awaited< export type ImageWithRawData = Awaited<
ReturnType<typeof getUserImages> ReturnType<typeof getUserImages>
>["ImageProperties"][number] & { >["ImageProperties"][number] & {
rawData: string[]; rawData: string[];
}; };
type SearchImageStore = { type SearchImageStore = {
images: Accessor<ImageWithRawData[]>; images: Accessor<ImageWithRawData[]>;
imagesWithProperties: Accessor<ReturnType<typeof groupPropertiesWithImage>>; imagesWithProperties: Accessor<ReturnType<typeof groupPropertiesWithImage>>;
onRefetchImages: () => void; processingImages: Accessor<
Awaited<ReturnType<typeof getUserImages>>["ProcessingImages"] | undefined
>;
onRefetchImages: () => void;
}; };
// How wonderfully functional // How wonderfully functional
const getAllValues = (object: object): Array<string> => { const getAllValues = (object: object): Array<string> => {
const loop = (acc: Array<string>, next: object): Array<string> => { const loop = (acc: Array<string>, next: object): Array<string> => {
for (const [key, _value] of Object.entries(next)) { for (const [key, _value] of Object.entries(next)) {
if (key === "ID") { if (key === "ID") {
continue; continue;
} }
const value: unknown = _value; const value: unknown = _value;
switch (typeof value) { switch (typeof value) {
case "object": case "object":
if (value != null) { if (value != null) {
acc.push(...loop(acc, value)); acc.push(...loop(acc, value));
} }
break; break;
case "string": case "string":
case "number": case "number":
case "boolean": case "boolean":
acc.push(value.toString()); acc.push(value.toString());
break; break;
default: default:
break; break;
} }
} }
return acc; return acc;
}; };
return loop([], object); return loop([], object);
}; };
const SearchImageContext = createContext<SearchImageStore>(); const SearchImageContext = createContext<SearchImageStore>();
export const SearchImageContextProvider: Component<ParentProps> = (props) => { export const SearchImageContextProvider: Component<ParentProps> = (props) => {
const [data, { refetch }] = createResource(getUserImages); const [data, { refetch }] = createResource(getUserImages);
const imageData = createMemo<ImageWithRawData[]>(() => { const imageData = createMemo<ImageWithRawData[]>(() => {
const d = data(); const d = data();
if (d == null) { if (d == null) {
return []; return [];
} }
return d.ImageProperties.map((d) => ({ return d.ImageProperties.map((d) => ({
...d, ...d,
rawData: getAllValues(d), rawData: getAllValues(d),
})); }));
}); });
const imagesWithProperties = createMemo< const processingImages = () => data()?.ProcessingImages ?? [];
ReturnType<typeof groupPropertiesWithImage>
>(() => {
const d = data();
if (d == null) {
return {};
}
return groupPropertiesWithImage(d); const imagesWithProperties = createMemo<
}); ReturnType<typeof groupPropertiesWithImage>
>(() => {
const d = data();
if (d == null) {
return {};
}
return ( return groupPropertiesWithImage(d);
<SearchImageContext.Provider });
value={{
images: imageData, return (
imagesWithProperties: imagesWithProperties, <SearchImageContext.Provider
onRefetchImages: refetch, value={{
}} images: imageData,
> imagesWithProperties: imagesWithProperties,
{props.children} processingImages,
</SearchImageContext.Provider> onRefetchImages: refetch,
); }}
>
{props.children}
</SearchImageContext.Provider>
);
}; };
export const useSearchImageContext = () => { export const useSearchImageContext = () => {
const context = useContext(SearchImageContext); const context = useContext(SearchImageContext);
if (context == null) { if (context == null) {
throw new Error( throw new Error(
"Unreachable: We should always have a mounted context and no undefined values", "Unreachable: We should always have a mounted context and no undefined values",
); );
} }
return context; return context;
}; };

View File

@ -175,7 +175,11 @@ const userProcessingImageValidator = strictObject({
ImageName: string(), ImageName: string(),
Image: null_(), Image: null_(),
}), }),
Status: union([literal("not-started"), literal("in-progress")]), Status: union([
literal("not-started"),
literal("in-progress"),
literal("complete"),
]),
}); });
export type UserImage = InferOutput<typeof dataTypeValidator>; export type UserImage = InferOutput<typeof dataTypeValidator>;

View File

@ -1,88 +1,115 @@
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
import { import {
type InferOutput, type InferOutput,
literal, literal,
pipe, pipe,
safeParse, safeParse,
strictObject, strictObject,
string, string,
union, union,
uuid, uuid,
} from "valibot"; } from "valibot";
import { base } from "../network"; import { base } from "../network";
import { createContext, onCleanup } from "solid-js"; import { createContext, createEffect, onCleanup } from "solid-js";
import { useSearchImageContext } from "../contexts/SearchImageContext";
const processingImagesValidator = strictObject({ const processingImagesValidator = strictObject({
ImageID: pipe(string(), uuid()), ImageID: pipe(string(), uuid()),
Status: union([literal("in-progress"), literal("complete")]), Status: union([
literal("not-started"),
literal("in-progress"),
literal("complete"),
]),
}); });
type NotificationState = { type NotificationState = {
ProcessingImages: Record< ProcessingImages: Record<
string, string,
InferOutput<typeof processingImagesValidator>["Status"] | undefined InferOutput<typeof processingImagesValidator>["Status"] | undefined
>; >;
}; };
export const Notifications = (onCompleteImage: () => void) => { export const Notifications = (onCompleteImage: () => void) => {
const [state, setState] = createStore<NotificationState>({ const [state, setState] = createStore<NotificationState>({
ProcessingImages: {}, ProcessingImages: {},
}); });
const access = localStorage.getItem("access"); const { processingImages } = useSearchImageContext();
if (access == null) {
throw new Error("Access token not defined");
}
const dataEventListener = (e: MessageEvent<unknown>) => { const access = localStorage.getItem("access");
if (typeof e.data !== "string") { if (access == null) {
console.error("Error type is not string"); throw new Error("Access token not defined");
return; }
}
let jsonData: object = {}; const dataEventListener = (e: MessageEvent<unknown>) => {
try { if (typeof e.data !== "string") {
jsonData = JSON.parse(e.data); console.error("Error type is not string");
} catch (e) { return;
console.error(e); }
return;
}
const processingImage = safeParse(processingImagesValidator, jsonData); let jsonData: object = {};
if (!processingImage.success) { try {
console.error("Processing image could not be parsed.", e.data); jsonData = JSON.parse(e.data);
return; } catch (e) {
} console.error(e);
return;
}
console.log("SSE: ", processingImage); const processingImage = safeParse(processingImagesValidator, jsonData);
if (!processingImage.success) {
console.error("Processing image could not be parsed.", e.data);
return;
}
const { ImageID, Status } = processingImage.output; console.log("SSE: ", processingImage);
if (Status === "complete") { const { ImageID, Status } = processingImage.output;
setState("ProcessingImages", ImageID, undefined);
onCompleteImage();
} else {
setState("ProcessingImages", ImageID, Status);
}
};
const events = new EventSource(`${base}/notifications?token=${access}`); if (Status === "complete") {
setState("ProcessingImages", ImageID, undefined);
onCompleteImage();
} else {
setState("ProcessingImages", ImageID, Status);
}
};
events.addEventListener("data", dataEventListener); const upsertImageProcessing = (
images: NotificationState["ProcessingImages"],
) => {
setState("ProcessingImages", (currentImages) => ({
...currentImages,
...images,
}));
};
events.onerror = (e) => { createEffect(() => {
console.error(e); const images = processingImages();
}; if (images == null) {
return;
}
onCleanup(() => { upsertImageProcessing(
events.removeEventListener("data", dataEventListener); Object.fromEntries(images.map((i) => [i.ImageID, i.Status])),
events.close(); );
}); });
return { const events = new EventSource(`${base}/notifications?token=${access}`);
state,
}; events.addEventListener("data", dataEventListener);
events.onerror = (e) => {
console.error(e);
};
onCleanup(() => {
events.removeEventListener("data", dataEventListener);
events.close();
});
return {
state,
};
}; };
export const NotificationsContext = export const NotificationsContext =
createContext<ReturnType<typeof Notifications>>(); createContext<ReturnType<typeof Notifications>>();