diff options
author | 2025-04-26 15:31:33 +0530 | |
---|---|---|
committer | 2025-04-26 15:31:33 +0530 | |
commit | 8c9677ffc5aef95964b42c03690eb5ea1b912b13 (patch) | |
tree | 8d1941b0e591e601288387c464db098e66e9b365 /app/src/components | |
download | realtimeloc-8c9677ffc5aef95964b42c03690eb5ea1b912b13.tar.gz realtimeloc-8c9677ffc5aef95964b42c03690eb5ea1b912b13.tar.bz2 realtimeloc-8c9677ffc5aef95964b42c03690eb5ea1b912b13.zip |
testing location tracker
Diffstat (limited to 'app/src/components')
-rw-r--r-- | app/src/components/Map.tsx | 273 | ||||
-rw-r--r-- | app/src/components/ShareLocationForm.tsx | 176 | ||||
-rw-r--r-- | app/src/components/ui/avatar.tsx | 53 | ||||
-rw-r--r-- | app/src/components/ui/button.tsx | 59 | ||||
-rw-r--r-- | app/src/components/ui/card.tsx | 92 | ||||
-rw-r--r-- | app/src/components/ui/dialog.tsx | 135 | ||||
-rw-r--r-- | app/src/components/ui/dropdown-menu.tsx | 257 | ||||
-rw-r--r-- | app/src/components/ui/form.tsx | 167 | ||||
-rw-r--r-- | app/src/components/ui/input.tsx | 21 | ||||
-rw-r--r-- | app/src/components/ui/label.tsx | 24 | ||||
-rw-r--r-- | app/src/components/ui/sonner.tsx | 25 |
11 files changed, 1282 insertions, 0 deletions
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 ( + <> + <Marker position={position}> + <Popup> + {shareToken ? "Sharing location in real-time" : "You are here"} + {isConnected && shareToken && ( + <div className="text-xs mt-1 text-green-600">Connected</div> + )} + </Popup> + </Marker> + + {/* Draw path if we have position history and we're on client */} + {showPolyline && ( + <Polyline + pathOptions={{ color: "blue", weight: 3 }} + positions={positionHistory} + /> + )} + </> + ); +} + +// Component to display a shared location +function SharedLocationMarker({ position, lastUpdate }: { position: [number, number]; lastUpdate?: string }) { + return ( + <Marker position={position} icon={new L.Icon({ + iconUrl: '/marker-icon-red.png', + iconRetinaUrl: '/marker-icon-2x-red.png', + shadowUrl: '/marker-shadow.png', + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41] + })}> + <Popup> + <div> + <div>Shared Location</div> + {lastUpdate && ( + <div className="text-xs mt-1">Last updated: {new Date(lastUpdate).toLocaleTimeString()}</div> + )} + </div> + </Popup> + </Marker> + ); +} + +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<string | undefined>(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 <div className="h-full w-full flex items-center justify-center">Loading map...</div>; + } + + // 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 ( + <div className="h-full w-full"> + <MapContainer + center={defaultPosition} + zoom={13} + style={{ height: "100%", width: "100%" }} + > + <TileLayer + attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' + url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" + /> + + {/* Show our location marker in tracking mode */} + {mode === 'tracking' && position && ( + <LocationMarker + position={position} + onLocationUpdate={handleLocationUpdate} + shareToken={shareToken} + /> + )} + + {/* Show shared location marker in viewing mode */} + {mode === 'viewing' && sharedPosition && ( + <SharedLocationMarker position={sharedPosition} lastUpdate={lastUpdate} /> + )} + </MapContainer> + </div> + ); +}
\ 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<typeof shareFormSchema>; + +interface ShareLocationFormProps { + location: { + latitude: number; + longitude: number; + accuracy?: number; + } | null; +} + +export default function ShareLocationForm({ location }: ShareLocationFormProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + + const form = useForm<ShareFormValues>({ + 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 ( + <Card> + <CardHeader> + <CardTitle className="text-xl">Share Your Location</CardTitle> + </CardHeader> + <CardContent> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email Address</FormLabel> + <FormControl> + <Input placeholder="[email protected]" {...field} /> + </FormControl> + <FormDescription> + The email address to share your location with. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="senderName" + render={({ field }) => ( + <FormItem> + <FormLabel>Your Name</FormLabel> + <FormControl> + <Input placeholder="Your Name" {...field} /> + </FormControl> + <FormDescription> + Your name will be included in the email. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="expiryHours" + render={({ field }) => ( + <FormItem> + <FormLabel>Expiry Time (hours)</FormLabel> + <FormControl> + <Input + type="number" + min="0" + placeholder="24" + {...field} + /> + </FormControl> + <FormDescription> + How long the location share will be valid (0 for no expiry) + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <Button + type="submit" + className="w-full" + disabled={isSubmitting || !location} + > + {isSubmitting ? "Sharing..." : "Share Location"} + </Button> + </form> + </Form> + </CardContent> + </Card> + ); +}
\ 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<typeof AvatarPrimitive.Root>) { + return ( + <AvatarPrimitive.Root + data-slot="avatar" + className={cn( + "relative flex size-8 shrink-0 overflow-hidden rounded-full", + className + )} + {...props} + /> + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps<typeof AvatarPrimitive.Image>) { + return ( + <AvatarPrimitive.Image + data-slot="avatar-image" + className={cn("aspect-square size-full", className)} + {...props} + /> + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) { + return ( + <AvatarPrimitive.Fallback + data-slot="avatar-fallback" + className={cn( + "bg-muted flex size-full items-center justify-center rounded-full", + className + )} + {...props} + /> + ) +} + +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<typeof buttonVariants> & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + <Comp + data-slot="button" + className={cn(buttonVariants({ variant, size, className }))} + {...props} + /> + ) +} + +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 ( + <div + data-slot="card" + className={cn( + "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-header" + className={cn( + "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", + className + )} + {...props} + /> + ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-title" + className={cn("leading-none font-semibold", className)} + {...props} + /> + ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-description" + className={cn("text-muted-foreground text-sm", className)} + {...props} + /> + ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-action" + className={cn( + "col-start-2 row-span-2 row-start-1 self-start justify-self-end", + className + )} + {...props} + /> + ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-content" + className={cn("px-6", className)} + {...props} + /> + ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-footer" + className={cn("flex items-center px-6 [.border-t]:pt-6", className)} + {...props} + /> + ) +} + +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<typeof DialogPrimitive.Root>) { + return <DialogPrimitive.Root data-slot="dialog" {...props} /> +} + +function DialogTrigger({ + ...props +}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { + return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> +} + +function DialogPortal({ + ...props +}: React.ComponentProps<typeof DialogPrimitive.Portal>) { + return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> +} + +function DialogClose({ + ...props +}: React.ComponentProps<typeof DialogPrimitive.Close>) { + return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Overlay>) { + return ( + <DialogPrimitive.Overlay + data-slot="dialog-overlay" + className={cn( + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", + className + )} + {...props} + /> + ) +} + +function DialogContent({ + className, + children, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Content>) { + return ( + <DialogPortal data-slot="dialog-portal"> + <DialogOverlay /> + <DialogPrimitive.Content + data-slot="dialog-content" + className={cn( + "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", + className + )} + {...props} + > + {children} + <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"> + <XIcon /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + </DialogPrimitive.Content> + </DialogPortal> + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="dialog-header" + className={cn("flex flex-col gap-2 text-center sm:text-left", className)} + {...props} + /> + ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="dialog-footer" + className={cn( + "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", + className + )} + {...props} + /> + ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Title>) { + return ( + <DialogPrimitive.Title + data-slot="dialog-title" + className={cn("text-lg leading-none font-semibold", className)} + {...props} + /> + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Description>) { + return ( + <DialogPrimitive.Description + data-slot="dialog-description" + className={cn("text-muted-foreground text-sm", className)} + {...props} + /> + ) +} + +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<typeof DropdownMenuPrimitive.Root>) { + return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} /> +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { + return ( + <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) { + return ( + <DropdownMenuPrimitive.Trigger + data-slot="dropdown-menu-trigger" + {...props} + /> + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { + return ( + <DropdownMenuPrimitive.Portal> + <DropdownMenuPrimitive.Content + data-slot="dropdown-menu-content" + sideOffset={sideOffset} + className={cn( + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", + className + )} + {...props} + /> + </DropdownMenuPrimitive.Portal> + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { + return ( + <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + <DropdownMenuPrimitive.Item + data-slot="dropdown-menu-item" + data-inset={inset} + data-variant={variant} + className={cn( + "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className + )} + {...props} + /> + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) { + return ( + <DropdownMenuPrimitive.CheckboxItem + data-slot="dropdown-menu-checkbox-item" + className={cn( + "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className + )} + checked={checked} + {...props} + > + <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <CheckIcon className="size-4" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.CheckboxItem> + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) { + return ( + <DropdownMenuPrimitive.RadioGroup + data-slot="dropdown-menu-radio-group" + {...props} + /> + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) { + return ( + <DropdownMenuPrimitive.RadioItem + data-slot="dropdown-menu-radio-item" + className={cn( + "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className + )} + {...props} + > + <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <CircleIcon className="size-2 fill-current" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.RadioItem> + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { + inset?: boolean +}) { + return ( + <DropdownMenuPrimitive.Label + data-slot="dropdown-menu-label" + data-inset={inset} + className={cn( + "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", + className + )} + {...props} + /> + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) { + return ( + <DropdownMenuPrimitive.Separator + data-slot="dropdown-menu-separator" + className={cn("bg-border -mx-1 my-1 h-px", className)} + {...props} + /> + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + <span + data-slot="dropdown-menu-shortcut" + className={cn( + "text-muted-foreground ml-auto text-xs tracking-widest", + className + )} + {...props} + /> + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { + return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} /> +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { + inset?: boolean +}) { + return ( + <DropdownMenuPrimitive.SubTrigger + data-slot="dropdown-menu-sub-trigger" + data-inset={inset} + className={cn( + "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8", + className + )} + {...props} + > + {children} + <ChevronRightIcon className="ml-auto size-4" /> + </DropdownMenuPrimitive.SubTrigger> + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) { + return ( + <DropdownMenuPrimitive.SubContent + data-slot="dropdown-menu-sub-content" + className={cn( + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", + className + )} + {...props} + /> + ) +} + +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<TFieldValues> = FieldPath<TFieldValues>, +> = { + name: TName +} + +const FormFieldContext = React.createContext<FormFieldContextValue>( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, +>({ + ...props +}: ControllerProps<TFieldValues, TName>) => { + return ( + <FormFieldContext.Provider value={{ name: props.name }}> + <Controller {...props} /> + </FormFieldContext.Provider> + ) +} + +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 <FormField>") + } + + 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<FormItemContextValue>( + {} as FormItemContextValue +) + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId() + + return ( + <FormItemContext.Provider value={{ id }}> + <div + data-slot="form-item" + className={cn("grid gap-2", className)} + {...props} + /> + </FormItemContext.Provider> + ) +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps<typeof LabelPrimitive.Root>) { + const { error, formItemId } = useFormField() + + return ( + <Label + data-slot="form-label" + data-error={!!error} + className={cn("data-[error=true]:text-destructive", className)} + htmlFor={formItemId} + {...props} + /> + ) +} + +function FormControl({ ...props }: React.ComponentProps<typeof Slot>) { + const { error, formItemId, formDescriptionId, formMessageId } = useFormField() + + return ( + <Slot + data-slot="form-control" + id={formItemId} + aria-describedby={ + !error + ? `${formDescriptionId}` + : `${formDescriptionId} ${formMessageId}` + } + aria-invalid={!!error} + {...props} + /> + ) +} + +function FormDescription({ className, ...props }: React.ComponentProps<"p">) { + const { formDescriptionId } = useFormField() + + return ( + <p + data-slot="form-description" + id={formDescriptionId} + className={cn("text-muted-foreground text-sm", className)} + {...props} + /> + ) +} + +function FormMessage({ className, ...props }: React.ComponentProps<"p">) { + const { error, formMessageId } = useFormField() + const body = error ? String(error?.message ?? "") : props.children + + if (!body) { + return null + } + + return ( + <p + data-slot="form-message" + id={formMessageId} + className={cn("text-destructive text-sm", className)} + {...props} + > + {body} + </p> + ) +} + +export { + useFormField, + Form, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormField, +} diff --git a/app/src/components/ui/input.tsx b/app/src/components/ui/input.tsx new file mode 100644 index 0000000..03295ca --- /dev/null +++ b/app/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + <input + type={type} + data-slot="input" + className={cn( + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "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", + className + )} + {...props} + /> + ) +} + +export { Input } diff --git a/app/src/components/ui/label.tsx b/app/src/components/ui/label.tsx new file mode 100644 index 0000000..fb5fbc3 --- /dev/null +++ b/app/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" + +import { cn } from "@/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps<typeof LabelPrimitive.Root>) { + return ( + <LabelPrimitive.Root + data-slot="label" + className={cn( + "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", + className + )} + {...props} + /> + ) +} + +export { Label } diff --git a/app/src/components/ui/sonner.tsx b/app/src/components/ui/sonner.tsx new file mode 100644 index 0000000..957524e --- /dev/null +++ b/app/src/components/ui/sonner.tsx @@ -0,0 +1,25 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner, ToasterProps } from "sonner" + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + <Sonner + theme={theme as ToasterProps["theme"]} + className="toaster group" + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + } as React.CSSProperties + } + {...props} + /> + ) +} + +export { Toaster } |