From 8c9677ffc5aef95964b42c03690eb5ea1b912b13 Mon Sep 17 00:00:00 2001 From: Biswa Kalyan Bhuyan Date: Sat, 26 Apr 2025 15:31:33 +0530 Subject: testing location tracker --- app/src/components/Map.tsx | 273 +++++++++++++++++++++++++++++++ app/src/components/ShareLocationForm.tsx | 176 ++++++++++++++++++++ app/src/components/ui/avatar.tsx | 53 ++++++ app/src/components/ui/button.tsx | 59 +++++++ app/src/components/ui/card.tsx | 92 +++++++++++ app/src/components/ui/dialog.tsx | 135 +++++++++++++++ app/src/components/ui/dropdown-menu.tsx | 257 +++++++++++++++++++++++++++++ app/src/components/ui/form.tsx | 167 +++++++++++++++++++ app/src/components/ui/input.tsx | 21 +++ app/src/components/ui/label.tsx | 24 +++ app/src/components/ui/sonner.tsx | 25 +++ 11 files changed, 1282 insertions(+) create mode 100644 app/src/components/Map.tsx create mode 100644 app/src/components/ShareLocationForm.tsx create mode 100644 app/src/components/ui/avatar.tsx create mode 100644 app/src/components/ui/button.tsx create mode 100644 app/src/components/ui/card.tsx create mode 100644 app/src/components/ui/dialog.tsx create mode 100644 app/src/components/ui/dropdown-menu.tsx create mode 100644 app/src/components/ui/form.tsx create mode 100644 app/src/components/ui/input.tsx create mode 100644 app/src/components/ui/label.tsx create mode 100644 app/src/components/ui/sonner.tsx (limited to 'app/src/components') diff --git a/app/src/components/Map.tsx b/app/src/components/Map.tsx new file mode 100644 index 0000000..c6e9583 --- /dev/null +++ b/app/src/components/Map.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { MapContainer, TileLayer, Marker, Popup, useMap, Polyline } from "react-leaflet"; +import L from "leaflet"; +import "leaflet/dist/leaflet.css"; +import { toast } from "sonner"; +import { useSocket } from "@/hooks/useSocket"; + +// Type for location +interface LocationType { + latitude: number; + longitude: number; + accuracy?: number; + timestamp: Date; +} + +// Location update handler that recalculates position +function LocationMarker({ + position, + onLocationUpdate, + shareToken, +}: { + position: [number, number] | null; + onLocationUpdate: (location: LocationType) => void; + shareToken?: string; +}) { + const [positionHistory, setPositionHistory] = useState<[number, number][]>([]); + const [isClient, setIsClient] = useState(false); + const map = useMap(); + const { sendLocationUpdate, isConnected } = useSocket(); + + // Safely check if we're on the client side + useEffect(() => { + setIsClient(true); + }, []); + + useEffect(() => { + map.locate({ watch: true, enableHighAccuracy: true }); + + map.on("locationfound", (e) => { + const newPosition: [number, number] = [e.latlng.lat, e.latlng.lng]; + + // Update position history + setPositionHistory((prev) => [...prev, newPosition]); + + // Create location data + const locationData = { + latitude: e.latlng.lat, + longitude: e.latlng.lng, + accuracy: e.accuracy, + timestamp: new Date(), + }; + + // Notify parent component + onLocationUpdate(locationData); + + // Send location update to Socket.IO if sharing + if (shareToken && isConnected) { + sendLocationUpdate({ + latitude: e.latlng.lat, + longitude: e.latlng.lng, + accuracy: e.accuracy, + shareToken, + }); + } + + // Center map on the new position + map.flyTo(e.latlng, map.getZoom()); + }); + + map.on("locationerror", (e) => { + toast.error("Error accessing location: " + e.message); + console.error("Location error: ", e); + }); + + return () => { + map.stopLocate(); + map.off("locationfound"); + map.off("locationerror"); + }; + }, [map, onLocationUpdate, sendLocationUpdate, shareToken, isConnected]); + + // Return null if position is null or if we're not on client yet + if (!position || !isClient) return null; + + // Only render the polyline if we have enough points and we're on the client side + const showPolyline = isClient && positionHistory && positionHistory.length > 1; + + return ( + <> + + + {shareToken ? "Sharing location in real-time" : "You are here"} + {isConnected && shareToken && ( +
Connected
+ )} +
+
+ + {/* Draw path if we have position history and we're on client */} + {showPolyline && ( + + )} + + ); +} + +// Component to display a shared location +function SharedLocationMarker({ position, lastUpdate }: { position: [number, number]; lastUpdate?: string }) { + return ( + + +
+
Shared Location
+ {lastUpdate && ( +
Last updated: {new Date(lastUpdate).toLocaleTimeString()}
+ )} +
+
+
+ ); +} + +export default function Map({ + onLocationUpdate, + shareToken, + mode = 'tracking', + initialLocation, +}: { + onLocationUpdate?: (location: LocationType) => void; + shareToken?: string; + mode?: 'tracking' | 'viewing'; + initialLocation?: { latitude: number; longitude: number }; +}) { + const [position, setPosition] = useState<[number, number] | null>(null); + const [sharedPosition, setSharedPosition] = useState<[number, number] | null>(null); + const [lastUpdate, setLastUpdate] = useState(undefined); + const [isLoading, setIsLoading] = useState(true); + const [isClient, setIsClient] = useState(false); + const { subscribeToLocationUpdates } = useSocket(); + + // Check if we're on client side + useEffect(() => { + setIsClient(true); + }, []); + + // Fix Leaflet marker icon issues in Next.js + useEffect(() => { + // This is needed to fix the marker icon issues with webpack + if (typeof window !== "undefined") { + // @ts-ignore + delete L.Icon.Default.prototype._getIconUrl; + L.Icon.Default.mergeOptions({ + iconRetinaUrl: "/marker-icon-2x.png", + iconUrl: "/marker-icon.png", + shadowUrl: "/marker-shadow.png", + }); + } + }, []); + + // Subscribe to real-time location updates when viewing shared location + useEffect(() => { + if (mode === 'viewing' && shareToken && isClient) { + // If we have initial location, set it + if (initialLocation) { + setSharedPosition([initialLocation.latitude, initialLocation.longitude]); + setIsLoading(false); + } + + // Subscribe to real-time updates + const cleanup = subscribeToLocationUpdates(shareToken, (data) => { + console.log('Received location update:', data); + setSharedPosition([data.latitude, data.longitude]); + setLastUpdate(data.timestamp); + toast.info('Location updated'); + }); + + return cleanup; + } + }, [mode, shareToken, subscribeToLocationUpdates, initialLocation, isClient]); + + useEffect(() => { + if (!isClient) return; + + // Only get current position in tracking mode + if (mode === 'tracking') { + // Try to get initial position + navigator.geolocation.getCurrentPosition( + (position) => { + setPosition([position.coords.latitude, position.coords.longitude]); + setIsLoading(false); + + if (onLocationUpdate) { + onLocationUpdate({ + latitude: position.coords.latitude, + longitude: position.coords.longitude, + accuracy: position.coords.accuracy, + timestamp: new Date(position.timestamp), + }); + } + }, + (error) => { + toast.error(`Error getting location: ${error.message}`); + setIsLoading(false); + }, + { enableHighAccuracy: true } + ); + } + }, [onLocationUpdate, isClient, mode]); + + // Handle location updates from the marker component + const handleLocationUpdate = (location: LocationType) => { + setPosition([location.latitude, location.longitude]); + if (onLocationUpdate) { + onLocationUpdate(location); + } + }; + + if (!isClient || isLoading) { + return
Loading map...
; + } + + // Determine which position to center on + let defaultPosition: [number, number]; + if (mode === 'viewing' && sharedPosition) { + defaultPosition = sharedPosition; + } else if (position) { + defaultPosition = position; + } else { + defaultPosition = [51.505, -0.09]; // Default to London + } + + return ( +
+ + + + {/* Show our location marker in tracking mode */} + {mode === 'tracking' && position && ( + + )} + + {/* Show shared location marker in viewing mode */} + {mode === 'viewing' && sharedPosition && ( + + )} + +
+ ); +} \ No newline at end of file diff --git a/app/src/components/ShareLocationForm.tsx b/app/src/components/ShareLocationForm.tsx new file mode 100644 index 0000000..ddebfab --- /dev/null +++ b/app/src/components/ShareLocationForm.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +// Form validation schema +const shareFormSchema = z.object({ + email: z.string().email("Please enter a valid email address"), + senderName: z.string().min(1, "Please enter your name"), + expiryHours: z.coerce.number().min(0).optional(), +}); + +type ShareFormValues = z.infer; + +interface ShareLocationFormProps { + location: { + latitude: number; + longitude: number; + accuracy?: number; + } | null; +} + +export default function ShareLocationForm({ location }: ShareLocationFormProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + + const form = useForm({ + resolver: zodResolver(shareFormSchema), + defaultValues: { + email: "", + senderName: "", + expiryHours: 24, + }, + }); + + const onSubmit = async (values: ShareFormValues) => { + if (!location) { + toast.error("No location data available to share"); + return; + } + + setIsSubmitting(true); + + try { + const response = await fetch("/api/share-location", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...values, + latitude: location.latitude, + longitude: location.longitude, + accuracy: location.accuracy, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + if (data.emailError) { + toast.error(`Email error: ${data.message || 'Failed to send notification email'}`); + toast.info("Location was generated but notification couldn't be sent"); + return; + } + throw new Error(data.message || data.error || "Failed to share location"); + } + + if (data.data?.devMode) { + toast.success("Dev mode: Email would be sent (check server logs)"); + form.reset(); + return; + } + + toast.success(`Location shared with ${values.email}`); + form.reset(); + } catch (error) { + console.error("Error sharing location:", error); + toast.error("Failed to share location. Please try again."); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + Share Your Location + + +
+ + ( + + Email Address + + + + + The email address to share your location with. + + + + )} + /> + + ( + + Your Name + + + + + Your name will be included in the email. + + + + )} + /> + + ( + + Expiry Time (hours) + + + + + How long the location share will be valid (0 for no expiry) + + + + )} + /> + + + + +
+
+ ); +} \ No newline at end of file diff --git a/app/src/components/ui/avatar.tsx b/app/src/components/ui/avatar.tsx new file mode 100644 index 0000000..71e428b --- /dev/null +++ b/app/src/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/app/src/components/ui/button.tsx b/app/src/components/ui/button.tsx new file mode 100644 index 0000000..a2df8dc --- /dev/null +++ b/app/src/components/ui/button.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/app/src/components/ui/card.tsx b/app/src/components/ui/card.tsx new file mode 100644 index 0000000..d05bbc6 --- /dev/null +++ b/app/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/app/src/components/ui/dialog.tsx b/app/src/components/ui/dialog.tsx new file mode 100644 index 0000000..7d7a9d3 --- /dev/null +++ b/app/src/components/ui/dialog.tsx @@ -0,0 +1,135 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + {children} + + + Close + + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/app/src/components/ui/dropdown-menu.tsx b/app/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..ec51e9c --- /dev/null +++ b/app/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,257 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/app/src/components/ui/form.tsx b/app/src/components/ui/form.tsx new file mode 100644 index 0000000..524b986 --- /dev/null +++ b/app/src/components/ui/form.tsx @@ -0,0 +1,167 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + FormProvider, + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState } = useFormContext() + const formState = useFormState({ name: fieldContext.name }) + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId() + + return ( + +
+ + ) +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { error, formItemId } = useFormField() + + return ( +