aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/components
diff options
context:
space:
mode:
authorLibravatarLibravatar Biswa Kalyan Bhuyan <[email protected]> 2025-04-26 15:31:33 +0530
committerLibravatarLibravatar Biswa Kalyan Bhuyan <[email protected]> 2025-04-26 15:31:33 +0530
commit8c9677ffc5aef95964b42c03690eb5ea1b912b13 (patch)
tree8d1941b0e591e601288387c464db098e66e9b365 /app/src/components
downloadrealtimeloc-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.tsx273
-rw-r--r--app/src/components/ShareLocationForm.tsx176
-rw-r--r--app/src/components/ui/avatar.tsx53
-rw-r--r--app/src/components/ui/button.tsx59
-rw-r--r--app/src/components/ui/card.tsx92
-rw-r--r--app/src/components/ui/dialog.tsx135
-rw-r--r--app/src/components/ui/dropdown-menu.tsx257
-rw-r--r--app/src/components/ui/form.tsx167
-rw-r--r--app/src/components/ui/input.tsx21
-rw-r--r--app/src/components/ui/label.tsx24
-rw-r--r--app/src/components/ui/sonner.tsx25
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='&copy; <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 }