a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
77 | className
78 | )}
79 | {...props}
80 | />
81 | )
82 | }
83 |
84 | function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
85 | return (
86 |
94 | )
95 | }
96 |
97 | export {
98 | Empty,
99 | EmptyHeader,
100 | EmptyTitle,
101 | EmptyDescription,
102 | EmptyContent,
103 | EmptyMedia,
104 | }
105 |
--------------------------------------------------------------------------------
/src/components/MapCameraInfo/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import styled from 'styled-components';
3 | import { motion, AnimatePresence } from 'motion/react';
4 | import { Incident } from '@rdrnt/tps-calls-shared';
5 |
6 | import {
7 | Card,
8 | CardContent,
9 | CardDescription,
10 | CardFooter,
11 | CardHeader,
12 | CardTitle,
13 | } from '../ui/card';
14 |
15 | import { TorontoTrafficCamera } from '../../routes/TrafficCams';
16 | import {
17 | Accordion,
18 | AccordionContent,
19 | AccordionItem,
20 | AccordionTrigger,
21 | } from '../ui/accordion';
22 | import { Button } from '../ui/button';
23 |
24 | interface MapCameraInfoProps {
25 | camera?: TorontoTrafficCamera;
26 | drawerOpen: boolean;
27 | close: () => void;
28 | }
29 |
30 | const MapIncidentInfo: React.FunctionComponent
= ({
31 | camera,
32 | drawerOpen,
33 | close,
34 | }) => {
35 | const cameraDefault = camera?.cameras.find(
36 | cam => cam.direction === 'Default'
37 | );
38 | const otherCams =
39 | camera?.cameras.filter(cam => cam.direction !== 'Default') ?? [];
40 |
41 | return (
42 |
43 | {camera && !drawerOpen && (
44 |
45 |
46 | {camera.name}
47 |
48 | {camera.location.latitude.toFixed(4)},{' '}
49 | {camera.location.longitude.toFixed(4)}
50 |
51 |
52 |
53 | {cameraDefault && (
54 |
55 |
59 |
60 | )}
61 |
67 | {otherCams.map(cameraView => (
68 |
72 | {cameraView.direction}
73 |
74 |
75 |
76 |
77 | ))}
78 |
79 |
80 |
81 |
82 | Close
83 |
84 |
85 |
86 | )}
87 |
88 | );
89 | };
90 |
91 | export default MapIncidentInfo;
92 |
--------------------------------------------------------------------------------
/src/components/MapIncidentInfo/parts/CameraSection.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent } from 'react';
2 | import { Cctv } from 'lucide-react';
3 |
4 | import { Typography } from '../../Typography';
5 |
6 | import {
7 | Item,
8 | ItemContent,
9 | ItemGroup,
10 | ItemHeader,
11 | ItemTitle,
12 | } from '../../ui/item';
13 | import { Empty, EmptyHeader, EmptyMedia, EmptyTitle } from '../../ui/empty';
14 | import { cn } from '../../../lib/utils';
15 | import { Alert, AlertTitle } from '../../ui/alert';
16 | import { useAppSelector } from '../../../store';
17 |
18 | interface CameraSectionProps {
19 | nearbyCameraIds: string[];
20 | }
21 |
22 | const CameraSection: FunctionComponent = ({
23 | nearbyCameraIds,
24 | }) => {
25 | const nearbyCameras = useAppSelector(state => state.cameras.list).filter(
26 | camera => nearbyCameraIds.includes(camera.id)
27 | );
28 | if (nearbyCameras.length === 0) {
29 | return (
30 | <>
31 | {/* Mobile only */}
32 |
33 |
34 |
35 | No nearby cameras
36 |
37 |
38 | {/* Tablet / desktop only */}
39 |
40 |
41 |
42 |
43 |
44 |
45 | No nearby cameras
46 |
47 |
48 |
49 | >
50 | );
51 | }
52 |
53 | return (
54 |
55 |
Nearby Cameras
56 |
= 3 ? 'grid-cols-3' : 'grid-cols-2',
61 | // Desktop (md+): if 1 item → 1 col, else → 2 cols
62 | nearbyCameras.length === 1 ? 'md:grid-cols-1' : 'md:grid-cols-2'
63 | )}
64 | >
65 | {nearbyCameras.map(parentCamera => {
66 | const defaultCameraView = parentCamera.cameras.find(
67 | camera => camera.direction === 'Default'
68 | );
69 | if (!defaultCameraView) {
70 | return null;
71 | }
72 | return (
73 | -
78 |
79 |
84 |
85 |
86 | {parentCamera.name}
87 |
88 |
89 | );
90 | })}
91 |
92 |
93 | );
94 | };
95 |
96 | export default CameraSection;
97 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as DialogPrimitive from "@radix-ui/react-dialog"
3 | import { XIcon } from "lucide-react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | function Dialog({
8 | ...props
9 | }: React.ComponentProps) {
10 | return
11 | }
12 |
13 | function DialogTrigger({
14 | ...props
15 | }: React.ComponentProps) {
16 | return
17 | }
18 |
19 | function DialogPortal({
20 | ...props
21 | }: React.ComponentProps) {
22 | return
23 | }
24 |
25 | function DialogClose({
26 | ...props
27 | }: React.ComponentProps) {
28 | return
29 | }
30 |
31 | function DialogOverlay({
32 | className,
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
44 | )
45 | }
46 |
47 | function DialogContent({
48 | className,
49 | children,
50 | showCloseButton = true,
51 | ...props
52 | }: React.ComponentProps & {
53 | showCloseButton?: boolean
54 | }) {
55 | return (
56 |
57 |
58 |
66 | {children}
67 | {showCloseButton && (
68 |
72 |
73 | Close
74 |
75 | )}
76 |
77 |
78 | )
79 | }
80 |
81 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
82 | return (
83 |
88 | )
89 | }
90 |
91 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
92 | return (
93 |
101 | )
102 | }
103 |
104 | function DialogTitle({
105 | className,
106 | ...props
107 | }: React.ComponentProps) {
108 | return (
109 |
114 | )
115 | }
116 |
117 | function DialogDescription({
118 | className,
119 | ...props
120 | }: React.ComponentProps) {
121 | return (
122 |
127 | )
128 | }
129 |
130 | export {
131 | Dialog,
132 | DialogClose,
133 | DialogContent,
134 | DialogDescription,
135 | DialogFooter,
136 | DialogHeader,
137 | DialogOverlay,
138 | DialogPortal,
139 | DialogTitle,
140 | DialogTrigger,
141 | }
142 |
--------------------------------------------------------------------------------
/src/components/MapIncidentInfo/index.tsx:
--------------------------------------------------------------------------------
1 | import { toast } from 'sonner';
2 | import { Link } from 'lucide-react';
3 | import { MapRef } from 'react-map-gl';
4 | import { FunctionComponent, useEffect } from 'react';
5 |
6 | import { Separator } from '../ui/separator';
7 |
8 | import { Button } from '../ui/button';
9 | import { TwitterIcon } from '../Icon/custom/Twitter';
10 |
11 | import { createShareUrl, createTwitterShareUrl } from '../../helpers/url';
12 | import CameraSection from './parts/CameraSection';
13 | import {
14 | Sheet,
15 | SheetContent,
16 | SheetDescription,
17 | SheetFooter,
18 | SheetHeader,
19 | SheetTitle,
20 | } from '../ui/sheet';
21 | import { formatIncidentDate } from '../../helpers/date';
22 | import useIsMobile from '../../hooks/useIsMobile';
23 |
24 | import { LocalIncident } from '../../types';
25 |
26 | interface MapIncidentInfoProps {
27 | incident?: LocalIncident;
28 | drawerOpen: boolean;
29 | close: () => void;
30 | mapRef: MapRef | null;
31 | }
32 |
33 | const MapIncidentInfo: FunctionComponent = ({
34 | incident,
35 | close,
36 | mapRef,
37 | }) => {
38 | const { isMobile } = useIsMobile();
39 |
40 | const onClickCopyToClipboard = () => {
41 | if (!incident || !navigator || navigator.clipboard === undefined) return;
42 | navigator.clipboard.writeText(createShareUrl(incident.id));
43 | toast.success('Share link copied to clipboard', {
44 | position: 'top-center',
45 | });
46 | };
47 |
48 | const onClickShareOnTwitter = () => {
49 | if (!incident) return;
50 | window.open(`${createTwitterShareUrl(incident)}`, '_blank');
51 | };
52 |
53 | useEffect(() => {
54 | // When an incident is selected on mobile, bring it into view with offset for the drawer
55 | if (incident && isMobile && mapRef) {
56 | // Wait for the sheet animation to complete before measuring
57 | // The sheet has a 500ms animation duration (data-[state=open]:duration-500)
58 | const measureSheetHeight = () => {
59 | // Find the sheet content element by its data attribute
60 | const sheetElement = document.querySelector(
61 | '[data-slot="sheet-content"]'
62 | ) as HTMLElement;
63 | if (!sheetElement) return;
64 |
65 | // Get the actual height of the sheet content
66 | const sheetHeight = sheetElement.getBoundingClientRect().height;
67 |
68 | // Offset upward by half the drawer height so the marker appears in the visible area above the drawer
69 | // The offset is [x, y] in pixels, negative y moves upward
70 | const offsetY = -(sheetHeight / 2);
71 |
72 | mapRef.flyTo({
73 | center: [
74 | incident.coordinates.longitude,
75 | incident.coordinates.latitude,
76 | ],
77 | offset: [0, offsetY],
78 | speed: 1,
79 | zoom: 15,
80 | });
81 | };
82 |
83 | // Wait for the sheet to finish animating in (500ms) before measuring
84 | const timeoutId = setTimeout(measureSheetHeight, 600);
85 |
86 | return () => clearTimeout(timeoutId);
87 | }
88 | }, [incident, isMobile, mapRef]);
89 |
90 | if (!incident) return null;
91 |
92 | return (
93 | {
96 | if (!open) {
97 | close();
98 | }
99 | }}
100 | >
101 |
105 |
106 |
107 | {incident?.name}
108 |
109 | {incident?.location}
110 |
111 | {formatIncidentDate(new Date(incident?.date))}
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
122 |
123 |
124 |
125 |
126 |
127 | Copy to Clipboard
128 |
129 |
130 |
131 | Share on Twitter
132 |
133 |
134 |
135 |
136 | );
137 | };
138 |
139 | export default MapIncidentInfo;
140 |
--------------------------------------------------------------------------------
/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as SheetPrimitive from '@radix-ui/react-dialog';
3 | import { XIcon } from 'lucide-react';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | function Sheet({ ...props }: React.ComponentProps) {
8 | return ;
9 | }
10 |
11 | function SheetTrigger({
12 | ...props
13 | }: React.ComponentProps) {
14 | return ;
15 | }
16 |
17 | function SheetClose({
18 | ...props
19 | }: React.ComponentProps) {
20 | return ;
21 | }
22 |
23 | function SheetPortal({
24 | ...props
25 | }: React.ComponentProps) {
26 | return ;
27 | }
28 |
29 | function SheetOverlay({
30 | className,
31 | ...props
32 | }: React.ComponentProps) {
33 | return (
34 |
42 | );
43 | }
44 |
45 | function SheetContent({
46 | className,
47 | children,
48 | side = 'right',
49 | overlayClassName,
50 | ...props
51 | }: React.ComponentProps & {
52 | side?: 'top' | 'right' | 'bottom' | 'left';
53 | overlayClassName?: string;
54 | }) {
55 | return (
56 |
57 |
58 |
74 | {children}
75 |
76 |
77 | Close
78 |
79 |
80 |
81 | );
82 | }
83 |
84 | function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
85 | return (
86 |
91 | );
92 | }
93 |
94 | function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
95 | return (
96 |
101 | );
102 | }
103 |
104 | function SheetTitle({
105 | className,
106 | ...props
107 | }: React.ComponentProps) {
108 | return (
109 |
114 | );
115 | }
116 |
117 | function SheetDescription({
118 | className,
119 | ...props
120 | }: React.ComponentProps) {
121 | return (
122 |
127 | );
128 | }
129 |
130 | export {
131 | Sheet,
132 | SheetTrigger,
133 | SheetClose,
134 | SheetContent,
135 | SheetHeader,
136 | SheetFooter,
137 | SheetTitle,
138 | SheetDescription,
139 | };
140 |
--------------------------------------------------------------------------------
/src/components/Modal/ProjectInfo.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { ModalProps } from '.';
4 |
5 | import AppStoreIcon from '../../assets/images/appStoreDownload.png';
6 | import PlayStoreIcon from '../../assets/images/googlePlayDownload.png';
7 |
8 | import { Analytics } from '../../helpers';
9 | import { DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
10 | import { Button } from '../ui/button';
11 | import {
12 | Accordion,
13 | AccordionItem,
14 | AccordionTrigger,
15 | AccordionContent,
16 | } from '../ui/accordion';
17 | import { Heart, Mail } from 'lucide-react';
18 | import { APPSTORE_DOWNLOAD_LINK } from '../../config';
19 | import { Typography } from '../Typography';
20 |
21 | type ProjectInfoModalProps = ModalProps;
22 |
23 | const ProjectInfoModal: React.FunctionComponent = () => {
24 | React.useEffect(() => {
25 | Analytics.event({
26 | category: 'UI',
27 | action: Analytics.UI.SHOW_PROJECT_INFO,
28 | });
29 | }, []);
30 |
31 | return (
32 | <>
33 |
34 | tpscalls
35 |
36 |
37 |
38 | About
39 |
40 | {`tpscalls.live gives a live overview of TPS activity in Toronto. You can see arrests, gun calls, collisions, assaults and various emergency incidents. Anything sensitive or tied to an active investigation is filtered out to maintain privacy and safety.`}
41 |
42 |
43 |
44 | Why am I seeing nothing new?
45 |
46 | {`Once in a while the Toronto Police's data feed goes offline. Unfortunately this is out of my control. If you have any questions, feel free to contact me.`}
47 |
48 |
49 |
50 | Contact
51 |
52 |
53 | Have a question? Feedback? Bug report? Feel free to connect with
54 | me via the button below.
55 |
56 |
57 |
58 | Contact
59 |
60 |
61 |
62 |
63 |
64 |
65 | Download the mobile app
66 |
67 |
84 |
85 |
86 |
87 |
88 | API & Open Source
89 |
90 |
91 | {`Tpscalls is proudly open source and now offers a REST API anyone can use! Explore the codebase and find more information on how to get started with the API at the links below.`}
92 |
93 |
98 | API Docs
99 |
100 |
101 |
102 |
103 |
108 | GitHub
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
128 |
129 | >
130 | );
131 | };
132 |
133 | export default ProjectInfoModal;
134 |
--------------------------------------------------------------------------------
/src/components/ui/item.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 | import { Separator } from "@/components/ui/separator"
7 |
8 | function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
9 | return (
10 |
16 | )
17 | }
18 |
19 | function ItemSeparator({
20 | className,
21 | ...props
22 | }: React.ComponentProps) {
23 | return (
24 |
30 | )
31 | }
32 |
33 | const itemVariants = cva(
34 | "group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
35 | {
36 | variants: {
37 | variant: {
38 | default: "bg-transparent",
39 | outline: "border-border",
40 | muted: "bg-muted/50",
41 | },
42 | size: {
43 | default: "p-4 gap-4 ",
44 | sm: "py-3 px-4 gap-2.5",
45 | },
46 | },
47 | defaultVariants: {
48 | variant: "default",
49 | size: "default",
50 | },
51 | }
52 | )
53 |
54 | function Item({
55 | className,
56 | variant = "default",
57 | size = "default",
58 | asChild = false,
59 | ...props
60 | }: React.ComponentProps<"div"> &
61 | VariantProps & { asChild?: boolean }) {
62 | const Comp = asChild ? Slot : "div"
63 | return (
64 |
71 | )
72 | }
73 |
74 | const itemMediaVariants = cva(
75 | "flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
76 | {
77 | variants: {
78 | variant: {
79 | default: "bg-transparent",
80 | icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
81 | image:
82 | "size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
83 | },
84 | },
85 | defaultVariants: {
86 | variant: "default",
87 | },
88 | }
89 | )
90 |
91 | function ItemMedia({
92 | className,
93 | variant = "default",
94 | ...props
95 | }: React.ComponentProps<"div"> & VariantProps) {
96 | return (
97 |
103 | )
104 | }
105 |
106 | function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
107 | return (
108 |
116 | )
117 | }
118 |
119 | function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
120 | return (
121 |
129 | )
130 | }
131 |
132 | function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
133 | return (
134 | a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
139 | className
140 | )}
141 | {...props}
142 | />
143 | )
144 | }
145 |
146 | function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
147 | return (
148 |
153 | )
154 | }
155 |
156 | function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
157 | return (
158 |
166 | )
167 | }
168 |
169 | function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
170 | return (
171 |
179 | )
180 | }
181 |
182 | export {
183 | Item,
184 | ItemMedia,
185 | ItemContent,
186 | ItemActions,
187 | ItemGroup,
188 | ItemSeparator,
189 | ItemTitle,
190 | ItemDescription,
191 | ItemHeader,
192 | ItemFooter,
193 | }
194 |
--------------------------------------------------------------------------------
/src/components/ui/input-group.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 | import { Button } from "@/components/ui/button"
6 | import { Input } from "@/components/ui/input"
7 | import { Textarea } from "@/components/ui/textarea"
8 |
9 | function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
10 | return (
11 | textarea]:h-auto",
17 |
18 | // Variants based on alignment.
19 | "has-[>[data-align=inline-start]]:[&>input]:pl-2",
20 | "has-[>[data-align=inline-end]]:[&>input]:pr-2",
21 | "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
22 | "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
23 |
24 | // Focus state.
25 | "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
26 |
27 | // Error state.
28 | "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
29 |
30 | className
31 | )}
32 | {...props}
33 | />
34 | )
35 | }
36 |
37 | const inputGroupAddonVariants = cva(
38 | "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
39 | {
40 | variants: {
41 | align: {
42 | "inline-start":
43 | "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
44 | "inline-end":
45 | "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
46 | "block-start":
47 | "order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
48 | "block-end":
49 | "order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
50 | },
51 | },
52 | defaultVariants: {
53 | align: "inline-start",
54 | },
55 | }
56 | )
57 |
58 | function InputGroupAddon({
59 | className,
60 | align = "inline-start",
61 | ...props
62 | }: React.ComponentProps<"div"> & VariantProps
) {
63 | return (
64 | {
70 | if ((e.target as HTMLElement).closest("button")) {
71 | return
72 | }
73 | e.currentTarget.parentElement?.querySelector("input")?.focus()
74 | }}
75 | {...props}
76 | />
77 | )
78 | }
79 |
80 | const inputGroupButtonVariants = cva(
81 | "text-sm shadow-none flex gap-2 items-center",
82 | {
83 | variants: {
84 | size: {
85 | xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
86 | sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
87 | "icon-xs":
88 | "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
89 | "icon-sm": "size-8 p-0 has-[>svg]:p-0",
90 | },
91 | },
92 | defaultVariants: {
93 | size: "xs",
94 | },
95 | }
96 | )
97 |
98 | function InputGroupButton({
99 | className,
100 | type = "button",
101 | variant = "ghost",
102 | size = "xs",
103 | ...props
104 | }: Omit
, "size"> &
105 | VariantProps) {
106 | return (
107 |
114 | )
115 | }
116 |
117 | function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
118 | return (
119 |
126 | )
127 | }
128 |
129 | function InputGroupInput({
130 | className,
131 | ...props
132 | }: React.ComponentProps<"input">) {
133 | return (
134 |
142 | )
143 | }
144 |
145 | function InputGroupTextarea({
146 | className,
147 | ...props
148 | }: React.ComponentProps<"textarea">) {
149 | return (
150 |
158 | )
159 | }
160 |
161 | export {
162 | InputGroup,
163 | InputGroupAddon,
164 | InputGroupButton,
165 | InputGroupText,
166 | InputGroupInput,
167 | InputGroupTextarea,
168 | }
169 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @import "tw-animate-css";
3 |
4 | @theme {
5 | /* Shading */
6 | --color-tpscalls-primary: oklch(0.5759 0.1859 262.86);
7 | }
8 |
9 | body {
10 | margin: 0;
11 | overflow: hidden;
12 | width: 100vw;
13 | height: 100vh;
14 | }
15 |
16 | * {
17 | box-sizing: border-box;
18 | }
19 |
20 | @custom-variant dark (&:is(.dark *));
21 |
22 | @theme inline {
23 | --radius-sm: calc(var(--radius) - 4px);
24 | --radius-md: calc(var(--radius) - 2px);
25 | --radius-lg: var(--radius);
26 | --radius-xl: calc(var(--radius) + 4px);
27 | --color-background: var(--background);
28 | --color-foreground: var(--foreground);
29 | --color-card: var(--card);
30 | --color-card-foreground: var(--card-foreground);
31 | --color-popover: var(--popover);
32 | --color-popover-foreground: var(--popover-foreground);
33 | --color-primary: var(--primary);
34 | --color-primary-foreground: var(--primary-foreground);
35 | --color-secondary: var(--secondary);
36 | --color-secondary-foreground: var(--secondary-foreground);
37 | --color-muted: var(--muted);
38 | --color-muted-foreground: var(--muted-foreground);
39 | --color-accent: var(--accent);
40 | --color-accent-foreground: var(--accent-foreground);
41 | --color-destructive: var(--destructive);
42 | --color-border: var(--border);
43 | --color-input: var(--input);
44 | --color-ring: var(--ring);
45 | --color-chart-1: var(--chart-1);
46 | --color-chart-2: var(--chart-2);
47 | --color-chart-3: var(--chart-3);
48 | --color-chart-4: var(--chart-4);
49 | --color-chart-5: var(--chart-5);
50 | --color-sidebar: var(--sidebar);
51 | --color-sidebar-foreground: var(--sidebar-foreground);
52 | --color-sidebar-primary: var(--sidebar-primary);
53 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
54 | --color-sidebar-accent: var(--sidebar-accent);
55 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
56 | --color-sidebar-border: var(--sidebar-border);
57 | --color-sidebar-ring: var(--sidebar-ring);
58 | }
59 |
60 | :root {
61 | --radius: 0.625rem;
62 | --background: oklch(1 0 0);
63 | --foreground: oklch(0.145 0 0);
64 | --card: oklch(1 0 0);
65 | --card-foreground: oklch(0.145 0 0);
66 | --popover: oklch(1 0 0);
67 | --popover-foreground: oklch(0.145 0 0);
68 | --primary: oklch(0.205 0 0);
69 | --primary-foreground: oklch(0.985 0 0);
70 | --secondary: oklch(0.97 0 0);
71 | --secondary-foreground: oklch(0.205 0 0);
72 | --muted: oklch(0.97 0 0);
73 | --muted-foreground: oklch(0.556 0 0);
74 | --accent: oklch(0.97 0 0);
75 | --accent-foreground: oklch(0.205 0 0);
76 | --destructive: oklch(0.577 0.245 27.325);
77 | --border: oklch(0.922 0 0);
78 | --input: oklch(0.922 0 0);
79 | --ring: oklch(0.708 0 0);
80 | --chart-1: oklch(0.646 0.222 41.116);
81 | --chart-2: oklch(0.6 0.118 184.704);
82 | --chart-3: oklch(0.398 0.07 227.392);
83 | --chart-4: oklch(0.828 0.189 84.429);
84 | --chart-5: oklch(0.769 0.188 70.08);
85 | --sidebar: oklch(0.985 0 0);
86 | --sidebar-foreground: oklch(0.145 0 0);
87 | --sidebar-primary: oklch(0.205 0 0);
88 | --sidebar-primary-foreground: oklch(0.985 0 0);
89 | --sidebar-accent: oklch(0.97 0 0);
90 | --sidebar-accent-foreground: oklch(0.205 0 0);
91 | --sidebar-border: oklch(0.922 0 0);
92 | --sidebar-ring: oklch(0.708 0 0);
93 |
94 | --safe-top: env(safe-area-inset-top, 0px);
95 | --safe-right: env(safe-area-inset-right, 0px);
96 | --safe-bottom: env(safe-area-inset-bottom, 0px);
97 | --safe-left: env(safe-area-inset-left, 0px);
98 | }
99 |
100 | .dark {
101 | --background: oklch(0.145 0 0);
102 | --foreground: oklch(0.985 0 0);
103 | --card: oklch(0.205 0 0);
104 | --card-foreground: oklch(0.985 0 0);
105 | --popover: oklch(0.205 0 0);
106 | --popover-foreground: oklch(0.985 0 0);
107 | --primary: oklch(0.922 0 0);
108 | --primary-foreground: oklch(0.205 0 0);
109 | --secondary: oklch(0.269 0 0);
110 | --secondary-foreground: oklch(0.985 0 0);
111 | --muted: oklch(0.269 0 0);
112 | --muted-foreground: oklch(0.708 0 0);
113 | --accent: oklch(0.269 0 0);
114 | --accent-foreground: oklch(0.985 0 0);
115 | --destructive: oklch(0.704 0.191 22.216);
116 | --border: oklch(1 0 0 / 10%);
117 | --input: oklch(1 0 0 / 15%);
118 | --ring: oklch(0.556 0 0);
119 | --chart-1: oklch(0.488 0.243 264.376);
120 | --chart-2: oklch(0.696 0.17 162.48);
121 | --chart-3: oklch(0.769 0.188 70.08);
122 | --chart-4: oklch(0.627 0.265 303.9);
123 | --chart-5: oklch(0.645 0.246 16.439);
124 | --sidebar: oklch(0.205 0 0);
125 | --sidebar-foreground: oklch(0.985 0 0);
126 | --sidebar-primary: oklch(0.488 0.243 264.376);
127 | --sidebar-primary-foreground: oklch(0.985 0 0);
128 | --sidebar-accent: oklch(0.269 0 0);
129 | --sidebar-accent-foreground: oklch(0.985 0 0);
130 | --sidebar-border: oklch(1 0 0 / 10%);
131 | --sidebar-ring: oklch(0.556 0 0);
132 | }
133 |
134 | @layer base {
135 | * {
136 | @apply border-border outline-ring/50;
137 | }
138 | body {
139 | @apply bg-background text-foreground;
140 | }
141 | /* Set cursor to pointer for buttons and links */
142 | button:not(:disabled),
143 | [role="button"]:not(:disabled) {
144 | cursor: pointer;
145 | }
146 | }
147 |
148 | @layer utilities {
149 | .pt-safe-top { padding-top: var(--safe-top); }
150 | .pr-safe-right { padding-right: var(--safe-right); }
151 | .pb-safe-bottom { padding-bottom: var(--safe-bottom); }
152 | .pl-safe-left { padding-left: var(--safe-left); }
153 |
154 | /* this one is important:
155 | content shouldn't sit flush against the very bottom
156 | on mobile safari, because Safari has that grabber/search bar.
157 | So we add some extra breathing room on top of the safe inset.
158 | */
159 | .pb-safe-bottom-zone {
160 | padding-bottom: calc(var(--safe-bottom) + 3.5rem); /* ~56px zone */
161 | }
162 | }
--------------------------------------------------------------------------------
/src/routes/TrafficCams.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useMemo, useState } from 'react';
2 | import { collection, getDocs, orderBy, query } from 'firebase/firestore';
3 | import Fuse from 'fuse.js';
4 |
5 | import { Analytics } from '../helpers';
6 | import {
7 | Card,
8 | CardContent,
9 | CardDescription,
10 | CardHeader,
11 | CardTitle,
12 | } from '../components/ui/card';
13 | import { Input } from '../components/ui/input';
14 |
15 | import { firestore } from '../helpers/firebase';
16 |
17 | import {
18 | ServerTorontoTrafficCamera,
19 | LocalTorontoTrafficCamera,
20 | } from '../types';
21 |
22 | const TrafficCams = () => {
23 | const [search, setSearch] = useState('');
24 | const [torontoTrafficCameras, setTorontoTrafficCameras] = useState<
25 | LocalTorontoTrafficCamera[]
26 | >([]);
27 | const [defaultTorontoTrafficCameras, setDefaultTorontoTrafficCameras] =
28 | useState([]);
29 |
30 | useEffect(() => {
31 | const camerasCollection = collection(firestore, 'toronto-traffic-cameras');
32 | const camerasQuery = query(camerasCollection, orderBy('date', 'desc'));
33 | getDocs(camerasQuery).then(docs => {
34 | const cameras = docs.docs.map(
35 | doc => doc.data() as ServerTorontoTrafficCamera
36 | );
37 | const convertedCameras: LocalTorontoTrafficCamera[] = cameras.map(
38 | camera => ({
39 | ...camera,
40 | date: camera.date.toDate().valueOf(),
41 | })
42 | );
43 | setTorontoTrafficCameras(convertedCameras);
44 | setDefaultTorontoTrafficCameras(convertedCameras);
45 | });
46 | }, []);
47 |
48 | useEffect(() => {
49 | Analytics.pageview('/traffic-cams');
50 | }, []);
51 |
52 | const camerasToUse = useMemo(() => {
53 | if (search.length > 0) {
54 | const fuse = new Fuse(torontoTrafficCameras, { keys: ['name'] });
55 | const results = fuse.search(search);
56 | return results.map(result => result.item);
57 | }
58 | return defaultTorontoTrafficCameras;
59 | }, [torontoTrafficCameras, search]);
60 |
61 | return (
62 |
63 |
64 |
65 |
66 | Toronto Traffic Cameras
67 |
68 |
69 | Real-time traffic camera feeds from intersections across Toronto.
70 |
71 |
setSearch(e.target.value)}
77 | />
78 |
79 |
80 | {defaultTorontoTrafficCameras.length === 0 ? (
81 |
82 |
Loading traffic cameras...
83 |
84 | ) : (
85 |
86 | {camerasToUse.map(camera => (
87 |
91 |
92 | {camera.name}
93 |
94 |
95 | {camera.location.latitude.toFixed(4)},{' '}
96 | {camera.location.longitude.toFixed(4)}
97 |
98 | {/*
99 |
100 |
101 |
102 |
107 | View On Map
108 |
109 |
110 |
111 |
112 | */}
113 |
114 |
115 |
116 | {camera.cameras
117 | .filter(cam => cam.direction === 'Default')
118 | .map((cameraView, index) => (
119 |
123 |
124 |
{
129 | const target = e.target as HTMLImageElement;
130 | target.src =
131 | 'https://via.placeholder.com/300x200/6B7280/FFFFFF?text=Image+Not+Available';
132 | }}
133 | />
134 |
135 |
136 | ))}
137 |
138 |
139 |
140 | ))}
141 |
142 | )}
143 |
144 |
145 | );
146 | };
147 |
148 | export default TrafficCams;
149 |
--------------------------------------------------------------------------------
/src/components/MapSidebar/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { motion, AnimatePresence } from 'motion/react';
3 | import { useDebouncedCallback } from 'use-debounce';
4 | import { Search, X } from 'lucide-react';
5 |
6 | import { Button } from '../ui/button';
7 | import MapSidebarItem from './parts/Item';
8 | import { useAppDispatch, useAppSelector } from '../../store';
9 | import {
10 | InputGroup,
11 | InputGroupAddon,
12 | InputGroupButton,
13 | InputGroupInput,
14 | } from '../ui/input-group';
15 |
16 | import {
17 | setSelectedIncident,
18 | toggleDrawer,
19 | setIncidentFilter,
20 | } from '../../store/actions';
21 | import { LocalIncident } from '../../types';
22 |
23 | interface MapSidebarProps {
24 | isOpen: boolean;
25 |
26 | onClose: () => void;
27 |
28 | children?: React.ReactNode;
29 | }
30 |
31 | const MapSidebar: React.FC = ({
32 | isOpen,
33 | onClose,
34 | children,
35 | }) => {
36 | const dispatch = useAppDispatch();
37 | const incidents = useAppSelector(state => state.incidents.list);
38 | const filter = useAppSelector(state => state.incidents.filter);
39 |
40 | // Local state for search input (updates immediately, prevents lag)
41 | const [localSearchInputValue, setLocalSearchInputValue] = useState(
42 | filter?.search || ''
43 | );
44 |
45 | // Sync local search input with Redux when filter changes externally
46 | useEffect(() => {
47 | setLocalSearchInputValue(filter?.search || '');
48 | }, [filter?.search]);
49 |
50 | // Debounced function to update Redux search filter
51 | const [debouncedUpdateReduxSearch] = useDebouncedCallback(
52 | (searchValue: string) => {
53 | dispatch(
54 | setIncidentFilter({
55 | values: { search: searchValue.trim() || undefined },
56 | })
57 | );
58 | },
59 | 300
60 | );
61 |
62 | // Update local state immediately, debounce Redux update
63 | const handleLocalSearchInputChange = (newValue: string) => {
64 | setLocalSearchInputValue(newValue); // local: immediate
65 | debouncedUpdateReduxSearch(newValue); // redux: debounced
66 | };
67 |
68 | const onItemClick = (incident: LocalIncident) => {
69 | dispatch(toggleDrawer(false));
70 | dispatch(setSelectedIncident(incident));
71 | };
72 |
73 | return (
74 |
75 | {/*
76 | Sidebar container with Framer Motion animations
77 |
78 | */}
79 | {isOpen && (
80 |
101 | {/*
102 | Sticky Header Container with subtle fade-in animation
103 | - sticky top-0: Sticks to the top of the viewport when scrolling
104 | - z-10: Ensures header stays above content
105 | */}
106 |
112 |
113 |
119 | Incidents
120 |
121 |
122 |
129 |
130 |
131 |
132 |
133 |
134 |
138 | handleLocalSearchInputChange(event.target.value)
139 | }
140 | />
141 |
142 | {localSearchInputValue ? (
143 | handleLocalSearchInputChange('')}
145 | variant="ghost"
146 | size="icon-sm"
147 | >
148 |
149 |
150 | ) : (
151 |
152 | )}
153 |
154 |
155 | {incidents.length > 0 && filter?.search && (
156 |
157 | {incidents.length} results
158 |
159 | )}
160 |
161 |
162 |
163 |
164 | {/*
165 | Scrollable Content Area with staggered content animation
166 | - h-[calc(100vh-73px)]: Height calculation to account for sticky header
167 | (73px is approximate header height including padding and border)
168 | */}
169 |
175 | {children || (
176 |
182 | {incidents.map(incident => (
183 | onItemClick(incident)}
187 | />
188 | ))}
189 |
190 | )}
191 |
192 |
193 | )}
194 |
195 | );
196 | };
197 |
198 | export default MapSidebar;
199 |
--------------------------------------------------------------------------------
/src/routes/Map.tsx:
--------------------------------------------------------------------------------
1 | import 'mapbox-gl/dist/mapbox-gl.css';
2 | import * as React from 'react';
3 | import { Incident } from '@rdrnt/tps-calls-shared';
4 | import ReactMapGl, { AttributionControl, MapRef } from 'react-map-gl';
5 | import { useParams } from 'react-router';
6 | import {
7 | MenuIcon,
8 | NavigationIcon,
9 | InfoIcon,
10 | TabletSmartphoneIcon,
11 | } from 'lucide-react';
12 | import { toast, Toaster } from 'sonner';
13 |
14 | import { MAPBOX_THEME_URL, Colors } from '../config';
15 | import { Environment, Analytics } from '../helpers';
16 | import * as FirebaseIncidents from '../helpers/firebase/incident';
17 |
18 | import { useAppDispatch, useAppSelector } from '../store';
19 |
20 | import MapIncidentInfo from '../components/MapIncidentInfo';
21 | import AnimatedMapMarker from '../components/MapMarker/Animated';
22 | import MapMarker from '../components/MapMarker';
23 | import { Button } from '../components/ui/button';
24 | import {
25 | ButtonGroup,
26 | ButtonGroupSeparator,
27 | } from '../components/ui/button-group';
28 | import {
29 | closeLoader,
30 | openLoader,
31 | openModal,
32 | setRequestingLocationPermissions,
33 | setSelectedCamera,
34 | setSelectedIncident,
35 | toggleDrawer,
36 | } from '../store/actions';
37 |
38 | import MapSidebar from '../components/MapSidebar';
39 | import { SafeArea } from '../components/SafeArea';
40 | import MapCameraInfo from '../components/MapCameraInfo';
41 |
42 | const DEFAULTS = {
43 | latitude: 43.653225,
44 | longitude: -79.383186,
45 | zoom: 11.0,
46 | };
47 |
48 | const Map: React.FunctionComponent = () => {
49 | const dispatch = useAppDispatch();
50 | const { id } = useParams<{ id?: string }>();
51 |
52 | const incidentList = useAppSelector(state => state.incidents.list);
53 | const selectedIncident = useAppSelector(state => state.incidents.selected);
54 | const { drawerOpen, loader } = useAppSelector(state => state.ui);
55 | const userLocation = useAppSelector(state => state.user.location);
56 |
57 | const selectedCamera = useAppSelector(state => state.cameras.selected);
58 |
59 | // I want to reffer to mapRef instead of mapRef.current throughout the app
60 | // thats why theres two vars lol
61 | const refForMap = React.useRef(null);
62 | const mapRef = refForMap.current;
63 |
64 | const [isMapLoaded, setIsMapLoaded] = React.useState(false);
65 | const [interactingWithMap, setInteractingWithMap] =
66 | React.useState(false);
67 |
68 | // Finds and returns an incident from the store or database
69 | const getIncidentWithId = async (
70 | id: string,
71 | searchDB = false
72 | ): Promise | undefined> => {
73 | const matchingIncident: Incident | undefined = incidentList.find(
74 | incident => incident.id === id
75 | );
76 |
77 | if (!matchingIncident && searchDB) {
78 | const incidentFromDB = await FirebaseIncidents.getIncidentFromId(id);
79 | return incidentFromDB;
80 | }
81 |
82 | return matchingIncident;
83 | };
84 |
85 | React.useEffect(() => {
86 | // if the map isn't loaded, show the loader
87 | if (!isMapLoaded && !loader.open) {
88 | dispatch(openLoader('Loading map...'));
89 | Analytics.pageview('/map');
90 | }
91 |
92 | // if the map has been loaded, and we have a list of incidents
93 | if (isMapLoaded && incidentList.length !== 0) {
94 | // Close the loader if it's open
95 |
96 | setTimeout(() => {
97 | dispatch(closeLoader());
98 | }, 500);
99 |
100 | // If we have an id in the params, see if there's a matching incident in the db/store
101 | if (id) {
102 | getIncidentWithId(id, true).then(incident => {
103 | if (!incident) {
104 | toast.error('Incident no longer exists', {
105 | description: 'The incident you are looking for no longer exists.',
106 | position: 'top-center',
107 | });
108 | } else {
109 | dispatch(setSelectedIncident(incident));
110 | }
111 | });
112 | }
113 | }
114 | }, [isMapLoaded, incidentList.length, id]);
115 |
116 | // Close the drawer if we're interacting with the map & the drawer is open d
117 | React.useEffect(() => {
118 | if (interactingWithMap) {
119 | if (drawerOpen) {
120 | dispatch(toggleDrawer(false));
121 | }
122 |
123 | if (selectedIncident) {
124 | dispatch(setSelectedIncident(undefined));
125 | }
126 | }
127 | }, [interactingWithMap]);
128 |
129 | React.useEffect(() => {
130 | if (userLocation.coordinates && mapRef) {
131 | mapRef.flyTo({
132 | center: [
133 | userLocation.coordinates.longitude,
134 | userLocation.coordinates.latitude,
135 | ],
136 | speed: 1,
137 | zoom: 15,
138 | });
139 | }
140 | }, [userLocation.available, userLocation.coordinates]);
141 |
142 | React.useEffect(() => {
143 | // If the selected incident changes, zoom into it
144 | if (selectedIncident && mapRef) {
145 | mapRef.flyTo({
146 | center: [
147 | selectedIncident.coordinates.longitude,
148 | selectedIncident.coordinates.latitude,
149 | ],
150 | speed: 1,
151 | zoom: 15,
152 | });
153 | }
154 | }, [selectedIncident]);
155 |
156 | return (
157 | <>
158 | {
181 | setIsMapLoaded(true);
182 | }}
183 | onDragStart={() => {
184 | setInteractingWithMap(true);
185 | }}
186 | onDragEnd={() => {
187 | setInteractingWithMap(false);
188 | }}
189 | onClick={() => {
190 | if (drawerOpen) {
191 | dispatch(toggleDrawer(false));
192 | }
193 | }}
194 | >
195 |
196 |
197 |
198 | {/* Overlay button for opening the drawer */}
199 | {!drawerOpen && (
200 | {
204 | dispatch(toggleDrawer(true));
205 | if (selectedIncident) {
206 | dispatch(setSelectedIncident(undefined));
207 | }
208 | }}
209 | >
210 |
211 |
212 | )}
213 |
214 | dispatch(setSelectedIncident(undefined))}
218 | mapRef={mapRef}
219 | />
220 |
221 | dispatch(setSelectedCamera(undefined))}
225 | />
226 |
227 |
231 | dispatch(openModal('mobile-app-download'))}
234 | className="bg-background hover:bg-background/80"
235 | >
236 |
237 |
238 |
239 | {userLocation.available && (
240 | <>
241 |
245 | dispatch(setRequestingLocationPermissions(true))
246 | }
247 | >
248 |
249 |
250 |
251 | >
252 | )}
253 |
254 | dispatch(openModal('project-info'))}
257 | className="bg-background hover:bg-background/80"
258 | >
259 |
260 |
261 |
262 |
263 |
264 | {userLocation.coordinates && (
265 |
270 | )}
271 |
272 | {selectedIncident && (
273 |
277 | )}
278 |
279 | {/* The incident features */}
280 | {incidentList
281 | .map(incident => {
282 | const selected = Boolean(
283 | selectedIncident && selectedIncident.id === incident.id
284 | );
285 | if (!selected) {
286 | return (
287 | {
291 | dispatch(setSelectedIncident(incident));
292 | }}
293 | />
294 | );
295 | }
296 |
297 | return null;
298 | })
299 | .filter(incidentFeature => Boolean(incidentFeature))}
300 |
301 |
302 | dispatch(toggleDrawer(false))}
305 | />
306 |
307 | >
308 | );
309 | };
310 |
311 | export default Map;
312 |
--------------------------------------------------------------------------------
/src/assets/images/googlePlayDownload.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | Google Play Badge US
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/src/assets/images/appStoreDownload.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | App Store Badge US Black
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------