Updated loading states and error states accross app
This commit is contained in:
@@ -3,6 +3,8 @@ import type { ZoomInOptions } from "../../types/types";
|
||||
import NavigationArrow from "../UI/NavigationArrow";
|
||||
import { useCameraZoom } from "../../hooks/useCameraZoom";
|
||||
import { useEffect } from "react";
|
||||
import Loading from "../UI/Loading";
|
||||
import ErrorState from "../UI/ErrorState";
|
||||
|
||||
type SnapshotContainerProps = {
|
||||
side: string;
|
||||
@@ -42,13 +44,16 @@ export const SnapshotContainer = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [zoomLevel]);
|
||||
|
||||
if (isError) return <p className="h-100">An error occurred</p>;
|
||||
if (isPending) return <p className="h-100">Loading...</p>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<NavigationArrow side={side} settingsPage={settingsPage} />
|
||||
<div className="w-full">
|
||||
{isError && <ErrorState />}
|
||||
{isPending && (
|
||||
<div className="my-50 h-[50%]">
|
||||
<Loading message="Camera Preview" />
|
||||
</div>
|
||||
)}
|
||||
<canvas
|
||||
onClick={handleZoomClick}
|
||||
ref={canvasRef}
|
||||
|
||||
@@ -17,6 +17,7 @@ type CameraSettingsProps = {
|
||||
updateCameraConfig: (values: CameraSettingValues) => Promise<void> | void;
|
||||
zoomLevel?: number;
|
||||
onZoomLevelChange?: (level: number) => void;
|
||||
updateCameraConfigError: null | Error;
|
||||
};
|
||||
|
||||
const CameraSettingFields = ({
|
||||
@@ -24,6 +25,7 @@ const CameraSettingFields = ({
|
||||
updateCameraConfig,
|
||||
zoomLevel,
|
||||
onZoomLevelChange,
|
||||
updateCameraConfigError,
|
||||
}: CameraSettingsProps) => {
|
||||
const [showPwd, setShowPwd] = useState(false);
|
||||
const cameraControllerSide =
|
||||
@@ -43,23 +45,20 @@ const CameraSettingFields = ({
|
||||
switch (levelstring) {
|
||||
case "1x":
|
||||
return 1;
|
||||
break;
|
||||
|
||||
case "2x":
|
||||
return 2;
|
||||
break;
|
||||
|
||||
case "4x":
|
||||
return 4;
|
||||
break;
|
||||
|
||||
case "8x":
|
||||
return 8;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
const level = getZoomLevel(query.data);
|
||||
|
||||
console.log("level from get", level);
|
||||
console.log("zoomLevel state", zoomLevel);
|
||||
const initialValues = useMemo<CameraSettingValues>(
|
||||
() => ({
|
||||
friendlyName: initialData?.id ?? "",
|
||||
@@ -70,6 +69,7 @@ const CameraSettingFields = ({
|
||||
|
||||
zoom: zoomLevel,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[initialData?.id, initialData?.propURI?.value, zoomLevel]
|
||||
);
|
||||
|
||||
@@ -96,7 +96,6 @@ const CameraSettingFields = ({
|
||||
mutation.mutate(zoomInOptions);
|
||||
};
|
||||
const selectedZoom = zoomLevel ?? 1;
|
||||
console.log(selectedZoom);
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
@@ -105,7 +104,7 @@ const CameraSettingFields = ({
|
||||
validateOnChange={false}
|
||||
enableReinitialize
|
||||
>
|
||||
{({ errors, touched }) => (
|
||||
{({ errors, touched, isSubmitting }) => (
|
||||
<Form className="flex flex-col space-y-6 p-2">
|
||||
<div className="flex flex-col space-y-2 relative">
|
||||
<label htmlFor="friendlyName">Name</label>
|
||||
@@ -184,7 +183,7 @@ const CameraSettingFields = ({
|
||||
<CardHeader title="Zoom settings" />
|
||||
<div className="mx-auto grid grid-cols-4 items-center">
|
||||
{zoomOptions.map((zoom) => (
|
||||
<div key={zoom}>
|
||||
<div key={zoom} className="my-3">
|
||||
<Field
|
||||
type="radio"
|
||||
name="zoom"
|
||||
@@ -206,12 +205,20 @@ const CameraSettingFields = ({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-[#26B170] text-white rounded-lg p-2 mx-auto h-[100%] w-full"
|
||||
>
|
||||
Save settings
|
||||
</button>
|
||||
<div className="mt-3">
|
||||
{updateCameraConfigError ? (
|
||||
<button className="bg-red-500 text-white rounded-lg p-2 mx-auto h-[100%] w-full">
|
||||
Retry
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-[#26B170] text-white rounded-lg p-2 mx-auto h-[100%] w-full"
|
||||
>
|
||||
{isSubmitting ? "Saving" : "Save settings"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useFetchCameraConfig } from "../../hooks/useCameraConfig";
|
||||
|
||||
import Card from "../UI/Card";
|
||||
import CardHeader from "../UI/CardHeader";
|
||||
import CameraSettingFields from "./CameraSettingFields";
|
||||
@@ -16,22 +15,23 @@ const CameraSettings = ({
|
||||
zoomLevel?: number;
|
||||
onZoomLevelChange?: (level: number) => void;
|
||||
}) => {
|
||||
const { data, isError, isPending, updateCameraConfig } =
|
||||
const { data, updateCameraConfig, updateCameraConfigError } =
|
||||
useFetchCameraConfig(side);
|
||||
console.log(updateCameraConfigError);
|
||||
return (
|
||||
<Card className="overflow-hidden min-h-[40vh] md:min-h-[60vh] max-h-[80vh] lg:w-[40%] p-4">
|
||||
{isPending && <>Loading camera config</>}
|
||||
{isError && <>Error fetching camera config</>}
|
||||
<div className="relative flex flex-col space-y-3">
|
||||
<CardHeader title={title} icon={faWrench} />
|
||||
{!isPending && (
|
||||
|
||||
{
|
||||
<CameraSettingFields
|
||||
initialData={data}
|
||||
updateCameraConfig={updateCameraConfig}
|
||||
zoomLevel={zoomLevel}
|
||||
onZoomLevelChange={onZoomLevelChange}
|
||||
updateCameraConfigError={updateCameraConfigError}
|
||||
/>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ type AlertItemProps = {
|
||||
|
||||
const AlertItem = ({ item }: AlertItemProps) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const { dispatch } = useAlertHitContext();
|
||||
const { dispatch, isError } = useAlertHitContext();
|
||||
|
||||
// const {d} = useCameraBlackboard();
|
||||
const motionAway = (item?.motion ?? "").toUpperCase() === "AWAY";
|
||||
@@ -24,6 +24,7 @@ const AlertItem = ({ item }: AlertItemProps) => {
|
||||
const isNPEDHitB = item?.metadata?.npedJSON?.["NPED CATEGORY"] === "B";
|
||||
const isNPEDHitC = item?.metadata?.npedJSON?.["NPED CATEGORY"] === "C";
|
||||
|
||||
console.log(isError);
|
||||
const handleClick = () => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -32,7 +32,6 @@ const NPEDFields = () => {
|
||||
...values,
|
||||
};
|
||||
signIn(valuesToSend);
|
||||
toast.success("Signed into NPED Successfully");
|
||||
};
|
||||
|
||||
const validateValues = (values: NPEDFieldType) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { toast } from "sonner";
|
||||
import type { SystemValues } from "../../../types/types";
|
||||
import { CAM_BASE } from "../../../utils/config";
|
||||
|
||||
@@ -35,7 +36,13 @@ export async function handleSystemSave(values: SystemValues) {
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (err instanceof Error) {
|
||||
toast.error(`Failed to save system settings: ${err.message}`);
|
||||
console.error(err);
|
||||
} else {
|
||||
toast.error("An unexpected error occurred while saving.");
|
||||
console.error("Unknown error:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +86,12 @@ export async function handleSystemRecall() {
|
||||
|
||||
return { deviceName, sntpServer, sntpInterval, timeZone };
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (err instanceof Error) {
|
||||
toast.error(`Error: ${err.message}`);
|
||||
} else {
|
||||
toast.error("An unexpected error occurred");
|
||||
}
|
||||
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { SystemValues, SystemValuesErrors } from "../../../types/types";
|
||||
import { useSystemConfig } from "../../../hooks/useSystemConfig";
|
||||
|
||||
const SystemConfigFields = () => {
|
||||
const { saveSystemSettings, systemSettingsData } = useSystemConfig();
|
||||
const { saveSystemSettings, systemSettingsData, saveSystemSettingsLoading } =
|
||||
useSystemConfig();
|
||||
const initialvalues: SystemValues = {
|
||||
deviceName: systemSettingsData?.deviceName ?? "",
|
||||
timeZone: systemSettingsData?.timeZone ?? "",
|
||||
@@ -37,7 +38,7 @@ const SystemConfigFields = () => {
|
||||
validateOnChange
|
||||
validateOnBlur
|
||||
>
|
||||
{({ values, errors, touched }) => (
|
||||
{({ values, errors, touched, isSubmitting }) => (
|
||||
<Form className="flex flex-col space-y-5 px-2">
|
||||
<FormGroup>
|
||||
<label
|
||||
@@ -131,8 +132,9 @@ const SystemConfigFields = () => {
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full md:w-[50%]"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Save System Settings
|
||||
{saveSystemSettingsLoading ? "Saving..." : "Save System Settings"}
|
||||
</button>
|
||||
<SystemFileUpload
|
||||
name={"softwareUpdate"}
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { ModemSettingsType } from "../../../types/types";
|
||||
import { useWifiAndModem } from "../../../hooks/useCameraWifiandModem";
|
||||
import { useEffect, useState } from "react";
|
||||
import ModemToggle from "./ModemToggle";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const ModemSettings = () => {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
@@ -50,11 +49,6 @@ const ModemSettings = () => {
|
||||
],
|
||||
};
|
||||
modemMutation.mutate(modemConfig);
|
||||
if (modemMutation.error) {
|
||||
toast.error("Failed to update modem settings");
|
||||
return;
|
||||
}
|
||||
toast.success("Modem settings updated");
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Field, Form, Formik } from "formik";
|
||||
import FormGroup from "../components/FormGroup";
|
||||
import type { WifiSettingValues } from "../../../types/types";
|
||||
import { useWifiAndModem } from "../../../hooks/useCameraWifiandModem";
|
||||
import { toast } from "sonner";
|
||||
import { useState } from "react";
|
||||
import { faEyeSlash, faEye } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -36,12 +35,6 @@ const WiFiSettingsForm = () => {
|
||||
};
|
||||
|
||||
wifiMutation.mutate(wifiConfig);
|
||||
|
||||
if (wifiMutation.error) {
|
||||
toast.error("Failed to update WiFi settings");
|
||||
return;
|
||||
}
|
||||
toast.success("WiFi settings updated");
|
||||
};
|
||||
return (
|
||||
<Formik
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSightingFeedContext } from "../../context/SightingFeedContext";
|
||||
import { useHiDPICanvas } from "../../hooks/useHiDPICanvas";
|
||||
import NavigationArrow from "../UI/NavigationArrow";
|
||||
import NumberPlate from "../PlateStack/NumberPlate";
|
||||
import Loading from "../UI/Loading";
|
||||
|
||||
const SightingOverview = () => {
|
||||
const [overlayMode, setOverlayMode] = useState<0 | 1 | 2>(0);
|
||||
@@ -22,18 +23,11 @@ const SightingOverview = () => {
|
||||
|
||||
const { sync } = useHiDPICanvas(imgRef, canvasRef);
|
||||
|
||||
if (!mostRecent)
|
||||
return (
|
||||
<div className="h-150 flex items-center justify-center text-3xl animate-pulse">
|
||||
<NavigationArrow side={side} />
|
||||
<p>No Recent Sightings</p>
|
||||
</div>
|
||||
);
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="h-150 flex items-center justify-center text-3xl animate-pulse">
|
||||
<div className="h-150 flex items-center justify-center">
|
||||
<NavigationArrow side={side} />
|
||||
<p>Loading</p>
|
||||
<Loading message="Loading" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -42,6 +36,14 @@ const SightingOverview = () => {
|
||||
An error occurred. Cannot display footage.
|
||||
</div>;
|
||||
|
||||
if (!mostRecent)
|
||||
return (
|
||||
<div className="h-150 flex items-center justify-center text-3xl animate-pulse">
|
||||
<NavigationArrow side={side} />
|
||||
<Loading message="No Recent Sightings" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<NavigationArrow side={side} />
|
||||
|
||||
@@ -16,6 +16,7 @@ import popup from "../../assets/sounds/ui/popup_open.mp3";
|
||||
import { useSound } from "react-sounds";
|
||||
import { useNPEDContext } from "../../context/NPEDUserContext";
|
||||
import { useSoundContext } from "../../context/SoundContext";
|
||||
import Loading from "../UI/Loading";
|
||||
|
||||
function useNow(tickMs = 1000) {
|
||||
const [, setNow] = useState(() => Date.now());
|
||||
@@ -54,6 +55,7 @@ export default function SightingHistoryWidget({
|
||||
isSightingModalOpen,
|
||||
selectedSighting,
|
||||
mostRecent,
|
||||
isLoading,
|
||||
} = useSightingFeedContext();
|
||||
|
||||
const { dispatch } = useAlertHitContext();
|
||||
@@ -64,6 +66,7 @@ export default function SightingHistoryWidget({
|
||||
if (!mostRecent) return;
|
||||
setSessionList([...sessionList, mostRecent]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mostRecent, sessionStarted, setSessionList]);
|
||||
|
||||
const hasAutoOpenedRef = useRef(false);
|
||||
@@ -127,6 +130,11 @@ export default function SightingHistoryWidget({
|
||||
>
|
||||
<CardHeader title={title} />
|
||||
<div className="flex flex-col gap-3 ">
|
||||
{isLoading && (
|
||||
<div className="my-50 h-[50%]">
|
||||
<Loading message="Loading Sightings" />
|
||||
</div>
|
||||
)}
|
||||
{/* Rows */}
|
||||
<div className="flex flex-col">
|
||||
{rows?.map((obj) => {
|
||||
|
||||
162
src/components/UI/ErrorState.tsx
Normal file
162
src/components/UI/ErrorState.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faTriangleExclamation,
|
||||
faRotateRight,
|
||||
faChevronDown,
|
||||
faChevronUp,
|
||||
faClipboard,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { useState, type FC } from "react";
|
||||
|
||||
type Variant = "inline" | "card" | "banner";
|
||||
|
||||
export type ErrorStateProps = {
|
||||
/** Main heading shown to the user */
|
||||
title?: string;
|
||||
/** Friendly message for the user */
|
||||
message?: string;
|
||||
/** Raw error to help devs (object, string, whatever) */
|
||||
error?: unknown;
|
||||
/** Called when user clicks Retry */
|
||||
onRetry?: () => Promise<void> | void;
|
||||
/** Show a Retry button */
|
||||
showRetry?: boolean;
|
||||
/** Optional custom icon */
|
||||
icon?: IconDefinition;
|
||||
/** Visual style */
|
||||
variant?: Variant;
|
||||
/** Additional actions (e.g. “Report”) */
|
||||
actions?: React.ReactNode;
|
||||
/** Test id for testing */
|
||||
"data-testid"?: string;
|
||||
/** ClassName passthrough */
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function formatError(err: unknown) {
|
||||
if (!err) return "";
|
||||
if (typeof err === "string") return err;
|
||||
if (err instanceof Error) return err.stack || err.message;
|
||||
try {
|
||||
return JSON.stringify(err, null, 2);
|
||||
} catch {
|
||||
return String(err);
|
||||
}
|
||||
}
|
||||
|
||||
const baseStyles = "w-full text-left flex items-start gap-3 rounded-md border";
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
inline: "p-3 border-red-200 bg-red-50 text-red-800",
|
||||
card: "p-4 border-red-200 bg-red-50 text-red-800 shadow-sm",
|
||||
banner: "p-3 border-red-200 bg-red-50 text-red-800 rounded-none border-x-0",
|
||||
};
|
||||
|
||||
export const ErrorState: FC<ErrorStateProps> = ({
|
||||
title = "Something went wrong",
|
||||
message = "Please try again or contact support if the problem persists.",
|
||||
error,
|
||||
onRetry,
|
||||
showRetry = !!onRetry,
|
||||
icon = faTriangleExclamation,
|
||||
variant = "inline",
|
||||
actions,
|
||||
className = "",
|
||||
...rest
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
|
||||
const details = formatError(error);
|
||||
|
||||
async function handleRetry() {
|
||||
if (!onRetry) return;
|
||||
try {
|
||||
setRetrying(true);
|
||||
await onRetry();
|
||||
} finally {
|
||||
setRetrying(false);
|
||||
}
|
||||
}
|
||||
|
||||
function copyDetails() {
|
||||
if (!details) return;
|
||||
navigator.clipboard?.writeText(details).catch(() => {});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className={`${baseStyles} ${variants[variant]} ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
<div className="mt-0.5">
|
||||
<FontAwesomeIcon icon={icon} className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1">
|
||||
<h3 className="font-medium">{title}</h3>
|
||||
{message && <p className="text-sm opacity-90">{message}</p>}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
{showRetry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
disabled={retrying}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-red-600 text-white text-sm hover:bg-red-700 disabled:opacity-60"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotateRight} className="h-4 w-4" />
|
||||
{retrying ? "Retrying…" : "Retry"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{details && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md border text-sm hover:bg-white/50"
|
||||
aria-expanded={expanded}
|
||||
aria-controls="error-details"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={expanded ? faChevronUp : faChevronDown}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
{expanded ? "Hide details" : "Show details"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyDetails}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md border text-sm hover:bg-white/50"
|
||||
aria-label="Copy error details"
|
||||
>
|
||||
<FontAwesomeIcon icon={faClipboard} className="h-4 w-4" />
|
||||
Copy details
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{actions}
|
||||
</div>
|
||||
|
||||
{/* Dev details (collapsible) */}
|
||||
{expanded && details && (
|
||||
<pre
|
||||
id="error-details"
|
||||
className="mt-2 max-h-64 overflow-auto text-xs leading-relaxed bg-white/60 text-red-900 border rounded p-3"
|
||||
>
|
||||
{details}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorState;
|
||||
14
src/components/UI/Loading.tsx
Normal file
14
src/components/UI/Loading.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
type LoadingProps = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const Loading = ({ message }: LoadingProps) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-6">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-gray-500 mb-2"></div>
|
||||
{message && <p className="text-lg text-gray-500">{message}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
Reference in New Issue
Block a user