diff --git a/package.json b/package.json index f253e7d..f276bbb 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,12 @@ "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.12", "@tanstack/react-router": "^1.141.6", + "clsx": "^2.1.1", + "country-flag-icons": "^1.6.4", + "konva": "^10.0.12", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-konva": "^19.2.1", "tailwindcss": "^4.1.18" }, "devDependencies": { diff --git a/src/components/CardHeader.tsx b/src/components/CardHeader.tsx new file mode 100644 index 0000000..c8c2aba --- /dev/null +++ b/src/components/CardHeader.tsx @@ -0,0 +1,21 @@ +import clsx from "clsx"; + +type CardHeaderProps = { + title: string; +}; + +const CardHeader = ({ title }: CardHeaderProps) => { + return ( +
+
+

{title}

+
+
+ ); +}; + +export default CardHeader; diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx new file mode 100644 index 0000000..2b814a9 --- /dev/null +++ b/src/components/ui/Card.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import clsx from "clsx"; + +type CardProps = { + children: React.ReactNode; + className?: string; +}; + +const Card = ({ children, className }: CardProps) => { + return ( +
+ {children} +
+ ); +}; + +export default Card; diff --git a/src/features/dashboard/Dashboard.tsx b/src/features/dashboard/Dashboard.tsx index fb1efb2..f244267 100644 --- a/src/features/dashboard/Dashboard.tsx +++ b/src/features/dashboard/Dashboard.tsx @@ -1,10 +1,15 @@ +import SightingStack from "./components/sightingStack/SightingStack"; import VideoFeed from "./components/videoFeed/VideoFeed"; +import { useSightingList } from "./hooks/useSightingList"; const Dashboard = () => { + const { sightingList, isLoading } = useSightingList(); + const mostRecent = sightingList[0]; + return ( -
- Dashboard - +
+ +
); }; diff --git a/src/features/dashboard/components/platePatch/NumberPlate.tsx b/src/features/dashboard/components/platePatch/NumberPlate.tsx new file mode 100644 index 0000000..2f986cb --- /dev/null +++ b/src/features/dashboard/components/platePatch/NumberPlate.tsx @@ -0,0 +1,57 @@ +import { GB } from "country-flag-icons/react/3x2"; +import { formatNumberPlate } from "../../../../utils/utils"; + +type NumberPlateProps = { + vrm?: string | undefined; + motion?: boolean; + size?: "xs" | "sm" | "md" | "lg"; +}; + +const NumberPlate = ({ vrm, motion, size }: NumberPlateProps) => { + let options = { + plateWidth: "w-[14rem]", + textSize: "text-2xl", + borderWidth: "border-6", + }; + + switch (size) { + case "xs": + options = { + plateWidth: "w-[8rem]", + textSize: "text-md", + borderWidth: "border-4", + }; + break; + case "sm": + options = { + plateWidth: "w-[10rem]", + textSize: "text-lg", + borderWidth: "border-4", + }; + break; + case "lg": + options = { + plateWidth: "w-[16rem]", + textSize: "text-3xl", + borderWidth: "border-6", + }; + break; + } + + return ( +
+
+
+ +
+

{vrm && formatNumberPlate(vrm)}

+
+
+ ); +}; + +export default NumberPlate; diff --git a/src/features/dashboard/components/sightingStack/SightingItem.tsx b/src/features/dashboard/components/sightingStack/SightingItem.tsx new file mode 100644 index 0000000..4009b44 --- /dev/null +++ b/src/features/dashboard/components/sightingStack/SightingItem.tsx @@ -0,0 +1,23 @@ +import type { SightingType } from "../../../../utils/types"; +import NumberPlate from "../platePatch/NumberPlate"; + +type SightingItemProps = { + sighting: SightingType; +}; + +const SightingItem = ({ sighting }: SightingItemProps) => { + console.log(sighting); + const motion = sighting.motion.toLowerCase() === "away" ? true : false; + return ( +
+
+
Ref: {sighting.ref}
+
vrm: {sighting.vrm}
+
+ + +
+ ); +}; + +export default SightingItem; diff --git a/src/features/dashboard/components/sightingStack/SightingStack.tsx b/src/features/dashboard/components/sightingStack/SightingStack.tsx new file mode 100644 index 0000000..69917e3 --- /dev/null +++ b/src/features/dashboard/components/sightingStack/SightingStack.tsx @@ -0,0 +1,20 @@ +import CardHeader from "../../../../components/CardHeader"; +import Card from "../../../../components/ui/Card"; +import type { SightingType } from "../../../../utils/types"; +import SightingItem from "./SightingItem"; + +type SightingStackProps = { + sightings: SightingType[]; +}; +const SightingStack = ({ sightings }: SightingStackProps) => { + return ( + + + {sightings.map((sighting) => ( + + ))} + + ); +}; + +export default SightingStack; diff --git a/src/features/dashboard/components/videoFeed/VideoFeed.tsx b/src/features/dashboard/components/videoFeed/VideoFeed.tsx index e028805..44ec195 100644 --- a/src/features/dashboard/components/videoFeed/VideoFeed.tsx +++ b/src/features/dashboard/components/videoFeed/VideoFeed.tsx @@ -1,16 +1,86 @@ -import { useSightingList } from "../../hooks/useSightingList"; +import { Stage, Layer, Image, Rect } from "react-konva"; +import type { SightingType } from "../../../../utils/types"; +import { useCreateVideoSnapshot } from "../../hooks/useCreateVideoSnapshot"; +import { useEffect, useState } from "react"; + +type VideoFeedProps = { + mostRecentSighting: SightingType; + isLoading: boolean; +}; + +const VideoFeed = ({ mostRecentSighting, isLoading }: VideoFeedProps) => { + const [size, setSize] = useState<{ width: number; height: number }>({ width: 1280, height: 960 }); + const [mode, setMode] = useState(0); + const { image, plateRect, plateTrack } = useCreateVideoSnapshot(mostRecentSighting); + + const handleModeChange = (newMode: number) => { + if (newMode > 2) setMode(0); + else setMode(newMode); + }; + + useEffect(() => { + const updateSize = () => { + const width = window.innerWidth * 0.5; + const height = (width * 3) / 4; + setSize({ width, height }); + }; + updateSize(); + window.addEventListener("resize", updateSize); + return () => window.removeEventListener("resize", updateSize); + }, []); + + if (isLoading) return <>Loading...; -const VideoFeed = () => { - const { sightingList } = useSightingList(); - console.log(sightingList); return ( -
- {sightingList.map((sighting) => ( -
-
Ref: {sighting.ref}
-
vrm: {sighting.vrm}
-
- ))} +
+ handleModeChange(mode + 1)}> + + { + const container = e.target.getStage()?.container(); + if (container) container.style.cursor = "pointer"; + }} + onMouseLeave={(e) => { + const container = e.target.getStage()?.container(); + if (container) container.style.cursor = "default"; + }} + cornerRadius={10} + /> + + {plateRect && mode === 1 && ( + + + + )} + + {plateTrack && mode === 2 && ( + + {plateTrack.map((rect, index) => ( + + ))} + + )} +
); }; diff --git a/src/features/dashboard/hooks/useCreateVideoSnapshot.ts b/src/features/dashboard/hooks/useCreateVideoSnapshot.ts new file mode 100644 index 0000000..f682e12 --- /dev/null +++ b/src/features/dashboard/hooks/useCreateVideoSnapshot.ts @@ -0,0 +1,19 @@ +import { useRef } from "react"; +import type { SightingType } from "../../../utils/types"; + +export const useCreateVideoSnapshot = (mostRecentSighting: SightingType) => { + const latestBitMapRef = useRef(null); + const snapshotUrl = mostRecentSighting?.overviewUrl; + + const image = new Image(); + + image.src = snapshotUrl; + + console.log(mostRecentSighting); + + const plateRect = mostRecentSighting?.overviewPlateRect; + + const plateTrack = mostRecentSighting?.plateTrack; + + return { snapshotUrl, latestBitMapRef, image, plateRect, plateTrack }; +}; diff --git a/src/features/dashboard/hooks/useSightingList.ts b/src/features/dashboard/hooks/useSightingList.ts index f6b3176..7c5900b 100644 --- a/src/features/dashboard/hooks/useSightingList.ts +++ b/src/features/dashboard/hooks/useSightingList.ts @@ -7,6 +7,7 @@ export const useSightingList = () => { const { videoFeedQuery } = useVideoFeed(); const latestSighting = videoFeedQuery?.data; const lastProcessedRef = useRef(-1); + const isLoading = videoFeedQuery?.isPending; useEffect(() => { if (!latestSighting || latestSighting.ref === undefined || latestSighting.ref === -1) return; @@ -22,5 +23,5 @@ export const useSightingList = () => { }); } }, [latestSighting, latestSighting?.ref]); - return { sightingList }; + return { sightingList, isLoading }; }; diff --git a/src/utils/types.tsx b/src/utils/types.ts similarity index 100% rename from src/utils/types.tsx rename to src/utils/types.ts diff --git a/src/utils/utils.ts b/src/utils/utils.ts new file mode 100644 index 0000000..0e208a4 --- /dev/null +++ b/src/utils/utils.ts @@ -0,0 +1,6 @@ +export const formatNumberPlate = (plate: string) => { + const splittedPlate = plate?.split(""); + splittedPlate?.splice(4, 0, " "); + const formattedPlate = splittedPlate?.join(""); + return formattedPlate; +}; diff --git a/yarn.lock b/yarn.lock index 5c9f541..d14dca0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -965,6 +965,16 @@ resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c" integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== +"@types/react-reconciler@^0.28.9": + version "0.28.9" + resolved "https://registry.yarnpkg.com/@types/react-reconciler/-/react-reconciler-0.28.9.tgz#d24b4864c384e770c83275b3fe73fba00269c83b" + integrity sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg== + +"@types/react-reconciler@^0.32.3": + version "0.32.3" + resolved "https://registry.yarnpkg.com/@types/react-reconciler/-/react-reconciler-0.32.3.tgz#eb4b346f367f29f07628032934d30a4f3f9eaba7" + integrity sha512-cMi5ZrLG7UtbL7LTK6hq9w/EZIRk4Mf1Z5qHoI+qBh7/WkYkFXQ7gOto2yfUvPzF5ERMAhaXS5eTQ2SAnHjLzA== + "@types/react@^19.2.5": version "19.2.7" resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.7.tgz#84e62c0f23e8e4e5ac2cadcea1ffeacccae7f62f" @@ -1223,6 +1233,11 @@ chokidar@^3.6.0: optionalDependencies: fsevents "~2.3.2" +clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + color-convert@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" @@ -1250,6 +1265,11 @@ cookie-es@^2.0.0: resolved "https://registry.yarnpkg.com/cookie-es/-/cookie-es-2.0.0.tgz#ca6163d7ef8686ea6bbdd551f1de575569c1ed69" integrity sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg== +country-flag-icons@^1.6.4: + version "1.6.4" + resolved "https://registry.yarnpkg.com/country-flag-icons/-/country-flag-icons-1.6.4.tgz#ddf9d07465678ab0a5b04b0d2f60717c2f46cd6f" + integrity sha512-Z3Zi419FI889tlElMsVhCIS5eRkiLDWixr576J5DPiTe5RGxpbRi+enMpHdYVp5iK5WFjr8P/RgyIFAGhFsiFg== + cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" @@ -1628,6 +1648,13 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== +its-fine@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/its-fine/-/its-fine-2.0.0.tgz#a90b18a3ee4c211a1fb6faac2abcc2b682ce1f21" + integrity sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng== + dependencies: + "@types/react-reconciler" "^0.28.9" + jiti@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.6.1.tgz#178ef2fc9a1a594248c20627cd820187a4d78d92" @@ -1677,6 +1704,11 @@ keyv@^4.5.4: dependencies: json-buffer "3.0.1" +konva@^10.0.12: + version "10.0.12" + resolved "https://registry.yarnpkg.com/konva/-/konva-10.0.12.tgz#4d0c8d94b007c67b73630ea6369bc272f86d7e29" + integrity sha512-DHmkeG5FbW6tLCkbMQTi1ihWycfzljrn0V7umUUuewxx7aoINcI71ksgBX9fTPNXhlsK4/JoMgKwI/iCde+BRw== + levn@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" @@ -1918,6 +1950,23 @@ react-dom@^19.2.0: dependencies: scheduler "^0.27.0" +react-konva@^19.2.1: + version "19.2.1" + resolved "https://registry.yarnpkg.com/react-konva/-/react-konva-19.2.1.tgz#a8009c884d29ad5806adcc99ca83804e13f5ef76" + integrity sha512-sqZWCzQGpdMrU5aeunR0oxUY8UeCPbU8gnAYxMtAn6BT4coeSpiATKOctsoxRu6F56TAcF+s0c6Lul9ansNqQA== + dependencies: + "@types/react-reconciler" "^0.32.3" + its-fine "^2.0.0" + react-reconciler "0.33.0" + scheduler "0.27.0" + +react-reconciler@0.33.0: + version "0.33.0" + resolved "https://registry.yarnpkg.com/react-reconciler/-/react-reconciler-0.33.0.tgz#9dd20208d45baa5b0b4701781f858236657f15e1" + integrity sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA== + dependencies: + scheduler "^0.27.0" + react-refresh@^0.18.0: version "0.18.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.18.0.tgz#2dce97f4fe932a4d8142fa1630e475c1729c8062" @@ -1987,7 +2036,7 @@ rollup@^4.43.0: "@rollup/rollup-win32-x64-msvc" "4.53.5" fsevents "~2.3.2" -scheduler@^0.27.0: +scheduler@0.27.0, scheduler@^0.27.0: version "0.27.0" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd" integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==