From 276dcd26edad6a0f239e8267d1ec863f16df6825 Mon Sep 17 00:00:00 2001 From: Toba Ojo Date: Fri, 19 Dec 2025 16:04:06 +0000 Subject: [PATCH] Add video feed feature with related components and hooks - Implemented VideoFeed component to display sightings. - Created useSightingList and useVideoFeed hooks for data fetching and state management. - Added AppProviders for context management. - Updated Dashboard to include VideoFeed. - Introduced types for sightings in utils/types.tsx. - Added Header and Footer components for layout. - Configured React Query for data handling. --- .gitignore | 2 + .prettierrc | 3 ++ package.json | 1 + src/app/config.ts | 1 + src/app/providers/AppProviders.tsx | 8 +++ src/app/providers/QueryProvider.tsx | 7 +++ src/app/queryClient.ts | 10 ++++ src/{ => components}/ui/Footer.tsx | 0 src/{ => components}/ui/Header.tsx | 0 src/features/dashboard/Dashboard.tsx | 12 +++++ .../components/videoFeed/VideoFeed.tsx | 18 +++++++ .../dashboard/hooks/useSightingList.ts | 26 +++++++++ src/features/dashboard/hooks/useVideoFeed.ts | 32 +++++++++++ src/main.tsx | 7 ++- src/routes/__root.tsx | 4 +- src/routes/index.tsx | 3 +- src/utils/types.tsx | 53 +++++++++++++++++++ yarn.lock | 12 +++++ 18 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 .prettierrc create mode 100644 src/app/config.ts create mode 100644 src/app/providers/AppProviders.tsx create mode 100644 src/app/providers/QueryProvider.tsx create mode 100644 src/app/queryClient.ts rename src/{ => components}/ui/Footer.tsx (100%) rename src/{ => components}/ui/Header.tsx (100%) create mode 100644 src/features/dashboard/Dashboard.tsx create mode 100644 src/features/dashboard/components/videoFeed/VideoFeed.tsx create mode 100644 src/features/dashboard/hooks/useSightingList.ts create mode 100644 src/features/dashboard/hooks/useVideoFeed.ts create mode 100644 src/utils/types.tsx diff --git a/.gitignore b/.gitignore index a547bf3..50c8dda 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? + +.env diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..963354f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "printWidth": 120 +} diff --git a/package.json b/package.json index 76c2c42..f253e7d 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/react-fontawesome": "^3.1.1", "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-query": "^5.90.12", "@tanstack/react-router": "^1.141.6", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/src/app/config.ts b/src/app/config.ts new file mode 100644 index 0000000..c3da5a0 --- /dev/null +++ b/src/app/config.ts @@ -0,0 +1 @@ +export const cambase = import.meta.env.VITE_LOCAL_CAMBASE; diff --git a/src/app/providers/AppProviders.tsx b/src/app/providers/AppProviders.tsx new file mode 100644 index 0000000..212fdd3 --- /dev/null +++ b/src/app/providers/AppProviders.tsx @@ -0,0 +1,8 @@ +import { type PropsWithChildren } from "react"; +import { QueryProvider } from "./QueryProvider"; + +const AppProviders = ({ children }: PropsWithChildren) => { + return {children}; +}; + +export default AppProviders; diff --git a/src/app/providers/QueryProvider.tsx b/src/app/providers/QueryProvider.tsx new file mode 100644 index 0000000..33e98f2 --- /dev/null +++ b/src/app/providers/QueryProvider.tsx @@ -0,0 +1,7 @@ +import type { PropsWithChildren } from "react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { queryClient } from "../queryClient"; + +export function QueryProvider({ children }: PropsWithChildren) { + return {children}; +} diff --git a/src/app/queryClient.ts b/src/app/queryClient.ts new file mode 100644 index 0000000..ee9f895 --- /dev/null +++ b/src/app/queryClient.ts @@ -0,0 +1,10 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60_000, + refetchOnWindowFocus: false, + }, + }, +}); diff --git a/src/ui/Footer.tsx b/src/components/ui/Footer.tsx similarity index 100% rename from src/ui/Footer.tsx rename to src/components/ui/Footer.tsx diff --git a/src/ui/Header.tsx b/src/components/ui/Header.tsx similarity index 100% rename from src/ui/Header.tsx rename to src/components/ui/Header.tsx diff --git a/src/features/dashboard/Dashboard.tsx b/src/features/dashboard/Dashboard.tsx new file mode 100644 index 0000000..fb1efb2 --- /dev/null +++ b/src/features/dashboard/Dashboard.tsx @@ -0,0 +1,12 @@ +import VideoFeed from "./components/videoFeed/VideoFeed"; + +const Dashboard = () => { + return ( +
+ Dashboard + +
+ ); +}; + +export default Dashboard; diff --git a/src/features/dashboard/components/videoFeed/VideoFeed.tsx b/src/features/dashboard/components/videoFeed/VideoFeed.tsx new file mode 100644 index 0000000..e028805 --- /dev/null +++ b/src/features/dashboard/components/videoFeed/VideoFeed.tsx @@ -0,0 +1,18 @@ +import { useSightingList } from "../../hooks/useSightingList"; + +const VideoFeed = () => { + const { sightingList } = useSightingList(); + console.log(sightingList); + return ( +
+ {sightingList.map((sighting) => ( +
+
Ref: {sighting.ref}
+
vrm: {sighting.vrm}
+
+ ))} +
+ ); +}; + +export default VideoFeed; diff --git a/src/features/dashboard/hooks/useSightingList.ts b/src/features/dashboard/hooks/useSightingList.ts new file mode 100644 index 0000000..f6b3176 --- /dev/null +++ b/src/features/dashboard/hooks/useSightingList.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef, useState } from "react"; +import { useVideoFeed } from "./useVideoFeed"; +import type { SightingType } from "../../../utils/types"; + +export const useSightingList = () => { + const [sightingList, setSightingList] = useState([]); + const { videoFeedQuery } = useVideoFeed(); + const latestSighting = videoFeedQuery?.data; + const lastProcessedRef = useRef(-1); + + useEffect(() => { + if (!latestSighting || latestSighting.ref === undefined || latestSighting.ref === -1) return; + + if (latestSighting.ref !== lastProcessedRef.current) { + lastProcessedRef.current = latestSighting.ref; + + // eslint-disable-next-line react-hooks/set-state-in-effect + setSightingList((prevList) => { + if (prevList[0]?.ref === latestSighting.ref) return prevList; + const dedupPrev = prevList.filter((s) => s.ref !== latestSighting.ref); + return [latestSighting, ...dedupPrev].slice(0, 10); + }); + } + }, [latestSighting, latestSighting?.ref]); + return { sightingList }; +}; diff --git a/src/features/dashboard/hooks/useVideoFeed.ts b/src/features/dashboard/hooks/useVideoFeed.ts new file mode 100644 index 0000000..9ec65eb --- /dev/null +++ b/src/features/dashboard/hooks/useVideoFeed.ts @@ -0,0 +1,32 @@ +import { useQuery } from "@tanstack/react-query"; +import { cambase } from "../../../app/config"; +import { useEffect, useRef } from "react"; + +const fetchVideoFeed = async (refId: number) => { + const response = await fetch(`${cambase}/mergedHistory/sightingSummary?mostRecentRef=${refId}`); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); +}; + +export const useVideoFeed = () => { + const currentRefId = useRef(-1); + + const videoFeedQuery = useQuery({ + queryKey: ["videoFeed"], + queryFn: () => fetchVideoFeed(currentRefId.current), + refetchInterval: 1000, + refetchOnWindowFocus: false, + retry: false, + staleTime: 0, + }); + + useEffect(() => { + if (videoFeedQuery.data?.ref !== -1) { + currentRefId.current = videoFeedQuery?.data?.ref; + } + }, [videoFeedQuery.data]); + + return { videoFeedQuery }; +}; diff --git a/src/main.tsx b/src/main.tsx index 8aa86cf..8673bda 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import "./index.css"; import { RouterProvider, createRouter } from "@tanstack/react-router"; import { routeTree } from "./routeTree.gen.ts"; +import AppProviders from "./app/providers/AppProviders.tsx"; const router = createRouter({ routeTree, @@ -17,6 +18,8 @@ declare module "@tanstack/react-router" { createRoot(document.getElementById("root")!).render( - - + + + + , ); diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 0115855..e8e601b 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,6 +1,6 @@ import { createRootRoute, Outlet } from "@tanstack/react-router"; -import Header from "../ui/Header"; -import Footer from "../ui/Footer"; +import Header from "../components/ui/Header"; +import Footer from "../components/ui/Footer"; const RootLayout = () => { return ( diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 88639e6..eba55f3 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,9 +1,10 @@ import { createFileRoute } from "@tanstack/react-router"; +import Dashboard from "../features/dashboard/Dashboard"; export const Route = createFileRoute("/")({ component: RouteComponent, }); function RouteComponent() { - return
Hello "/"!
; + return ; } diff --git a/src/utils/types.tsx b/src/utils/types.tsx new file mode 100644 index 0000000..c518fa3 --- /dev/null +++ b/src/utils/types.tsx @@ -0,0 +1,53 @@ +export type SightingType = { + ref: number; + SaFID: string; + overviewUrl: string; + plateUrlInfrared: string; + plateUrlColour: string; + vrm: string; + vrmSecondary: string; + countryCode: string; + timeStamp: string; + detailsUrl: string; + overviewPlateRect?: [number, number, number, number]; + plateTrack?: [number, number, number, number][]; + make: string; + model: string; + color: string; + category: string; + charHeight: string; + seenCount: string; + timeStampMillis: number; + motion: string; + debug: string; + srcCam: number; + locationName: string; + laneID: string; + plateSize: string; + overviewSize: string; + radarSpeed: string; + trackSpeed: string; + metadata?: Metadata; +}; + +export type Metadata = { + npedJSON: NpedJSON; + "hotlist-matches": HotlistMatches; + hotlistMatches: HotlistMatches; +}; + +export type HotlistMatches = { + Hotlist0: boolean; + Hotlist1: boolean; + Hotlist2: boolean; +}; + +export type NpedJSON = { + status_code: number; + reason_phrase: string; + "NPED CATEGORY": "A" | "B" | "C" | "D"; + "MOT STATUS": boolean; + "TAX STATUS": boolean; + vrm: string; + "INSURANCE STATUS": string; +}; diff --git a/yarn.lock b/yarn.lock index 3b0bf49..5c9f541 100644 --- a/yarn.lock +++ b/yarn.lock @@ -793,6 +793,18 @@ resolved "https://registry.yarnpkg.com/@tanstack/history/-/history-1.141.0.tgz#6fa5119dd870f1617943903d30e799222a21f6c2" integrity sha512-LS54XNyxyTs5m/pl1lkwlg7uZM3lvsv2FIIV1rsJgnfwVCnI+n4ZGZ2CcjNT13BPu/3hPP+iHmliBSscJxW5FQ== +"@tanstack/query-core@5.90.12": + version "5.90.12" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.90.12.tgz#e1f5f47e72ef7d0fc794325936921c700352515e" + integrity sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg== + +"@tanstack/react-query@^5.90.12": + version "5.90.12" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.90.12.tgz#49536842eff6487a9e645a453fea2642d8f4f209" + integrity sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg== + dependencies: + "@tanstack/query-core" "5.90.12" + "@tanstack/react-router@^1.141.6": version "1.141.6" resolved "https://registry.yarnpkg.com/@tanstack/react-router/-/react-router-1.141.6.tgz#295a278c377a2d49f385f3f5a4cb53566caf53e2"