diff --git a/netmanager-app/.eslintrc.json b/netmanager-app/.eslintrc.json new file mode 100644 index 0000000000..3722418549 --- /dev/null +++ b/netmanager-app/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/netmanager-app/.gitignore b/netmanager-app/.gitignore new file mode 100644 index 0000000000..f7266e312d --- /dev/null +++ b/netmanager-app/.gitignore @@ -0,0 +1,119 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next/ + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Cypress files +cypress/videos +cypress/screenshots + +# Yarn add output +.pnp.cjs +.pnp.loader.mjs +.yarn/ +.yarnrc.yml + +# vscode files +.vscode/* + diff --git a/netmanager-app/README.md b/netmanager-app/README.md new file mode 100644 index 0000000000..e215bc4ccf --- /dev/null +++ b/netmanager-app/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/netmanager-app/app/(authenticated)/analytics/page.tsx b/netmanager-app/app/(authenticated)/analytics/page.tsx new file mode 100644 index 0000000000..6b30301ad5 --- /dev/null +++ b/netmanager-app/app/(authenticated)/analytics/page.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function Analytics() { + return ( +
+

Dashboard

+
+ + + Total Devices + + +

156

+
+
+ + + Active Sites + + +

42

+
+
+ + + Data Points Collected + + +

1.2M

+
+
+
+
+ ); +} diff --git a/netmanager-app/app/(authenticated)/layout.tsx b/netmanager-app/app/(authenticated)/layout.tsx new file mode 100644 index 0000000000..9a3dc04749 --- /dev/null +++ b/netmanager-app/app/(authenticated)/layout.tsx @@ -0,0 +1,12 @@ +"use client"; + +import "../globals.css"; +import Layout from "../../components/layout"; + +export default function AuthenticatedLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/netmanager-app/app/(authenticated)/sites/[id]/devices.tsx b/netmanager-app/app/(authenticated)/sites/[id]/devices.tsx new file mode 100644 index 0000000000..93a15935cd --- /dev/null +++ b/netmanager-app/app/(authenticated)/sites/[id]/devices.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { Device } from "@/app/types/sites"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; + +interface SiteDevicesProps { + devices: Device[]; +} + +export function SiteDevices({ devices }: SiteDevicesProps) { + return ( +
+ + + + Name + Description + Site + Is Primary + Is Co-located + Added On + Deployment status + + + + {devices.map((device) => ( + + {device.name} + {device.description || "N/A"} + {device.site || "N/A"} + + + {device.isPrimary ? "Yes" : "No"} + + + + + {device.isCoLocated ? "Yes" : "No"} + + + {device.registrationDate} + + + {device.deploymentStatus} + + + + ))} + +
+
+ ); +} diff --git a/netmanager-app/app/(authenticated)/sites/[id]/page.tsx b/netmanager-app/app/(authenticated)/sites/[id]/page.tsx new file mode 100644 index 0000000000..62edff02f7 --- /dev/null +++ b/netmanager-app/app/(authenticated)/sites/[id]/page.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useState } from "react"; +import { ChevronLeft, Edit2 } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { SiteForm } from "../site-form"; +import { SiteDevices } from "./devices"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +// Sample site data +const siteData = { + id: "site_528", + name: "Water and Environment House, Luzira", + description: "Water and Environment House, Luzira", + organization: "AirQo", + latitude: 0.302458, + longitude: 32.641609, + network: "airqo", + parish: "Nakawa", + subCounty: "Nakawa", + district: "Kampala", + region: "Central Region", + altitude: 1177.3994140625, + greenness: "", + nearestRoad: null, + mobileAppName: "Nakawa 528", + mobileAppDescription: "Kampala, Uganda", +}; + +// Sample device data +const devices = [ + { + name: "aq_g5_101", + description: "", + site: "Water and Environment House, Luzira", + isPrimary: true, + isCoLocated: false, + registrationDate: "September 27, 2022", + deploymentStatus: "Deployed" as const, + }, +]; + +export default function SiteDetailsPage() { + const [isEditing, setIsEditing] = useState(false); + + return ( +
+
+ +
+

Site Details

+ + + + + + + Edit Site + + Make changes to the site details here. Click save when you're + done. + + + + + +
+
+ +
+ + + Site Information + Details about the monitoring site + + +
+

Name

+

{siteData.name}

+
+
+

Description

+

{siteData.description}

+
+
+

Organization

+

{siteData.organization}

+
+
+

Network

+

{siteData.network}

+
+
+

Latitude

+

{siteData.latitude}

+
+
+

Longitude

+

{siteData.longitude}

+
+
+

Parish

+

{siteData.parish}

+
+
+

Sub County

+

{siteData.subCounty}

+
+
+

District

+

{siteData.district}

+
+
+

Region

+

{siteData.region}

+
+
+

Altitude

+

{siteData.altitude} m

+
+
+

Greenness

+

{siteData.greenness || "N/A"}

+
+
+

Nearest Road

+

+ {siteData.nearestRoad ? `${siteData.nearestRoad} m` : "N/A"} +

+
+
+
+ + + + Mobile App Details + + Information displayed in the mobile app + + + +
+

Name

+

{siteData.mobileAppName}

+
+
+

Description

+

{siteData.mobileAppDescription}

+
+
+
+ + + + Site Devices + Devices deployed at this site + + + + + +
+
+ ); +} diff --git a/netmanager-app/app/(authenticated)/sites/create-site-form.tsx b/netmanager-app/app/(authenticated)/sites/create-site-form.tsx new file mode 100644 index 0000000000..ed3380d819 --- /dev/null +++ b/netmanager-app/app/(authenticated)/sites/create-site-form.tsx @@ -0,0 +1,344 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Loader2, MapPin, Check } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { sites } from "@/core/apis/sites"; +import { useAppSelector } from "@/core/redux/hooks"; +import { useQueryClient } from "@tanstack/react-query"; +import { MapContainer, TileLayer, Marker, useMap } from "react-leaflet"; +import "leaflet/dist/leaflet.css"; +import L from "leaflet"; + +const siteFormSchema = z.object({ + name: z.string().min(2, { + message: "Site name must be at least 2 characters.", + }), +}); + +type SiteFormValues = z.infer; + +const steps = [ + { id: "Step 1", name: "Site Details" }, + { id: "Step 2", name: "Map Preview" }, +]; + +const icon = L.icon({ + iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", + iconSize: [25, 41], + iconAnchor: [12, 41], +}); + +function MapUpdater({ center }: { center: [number, number] }) { + const map = useMap(); + map.setView(center); + return null; +} + +const MapPreview = ({ + latitude, + longitude, + onPositionChange, +}: { + latitude: string; + longitude: string; + onPositionChange: (lat: string, lng: string) => void; +}) => { + const lat = parseFloat(latitude); + const lng = parseFloat(longitude); + const position: [number, number] = [lat || 0, lng || 0]; + + const handleMarkerDrag = (e: L.LeafletEvent) => { + const marker = e.target; + const position = marker.getLatLng(); + onPositionChange(position.lat.toFixed(6), position.lng.toFixed(6)); + }; + + if (!lat || !lng) { + return ( +
+

Enter coordinates to see map preview

+
+ ); + } + + return ( +
+ + + + + +
+ ); +}; + +export function CreateSiteForm() { + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [currentStep, setCurrentStep] = useState(0); + const router = useRouter(); + const queryClient = useQueryClient(); + const activeNetwork = useAppSelector((state) => state.user.activeNetwork); + + const form = useForm({ + resolver: zodResolver(siteFormSchema), + defaultValues: { + name: "", + latitude: "", + longitude: "", + }, + }); + + async function onSubmit(values: SiteFormValues) { + if (currentStep === 0) { + setCurrentStep(1); + return; + } + + setLoading(true); + setError(null); + + try { + await sites.createSite({ + ...values, + network: activeNetwork?.net_name || "", + }); + + await queryClient.invalidateQueries({ queryKey: ["sites"] }); + + setOpen(false); + form.reset(); + setCurrentStep(0); + } catch (error: any) { + setError(error.message || "An error occurred while creating the site."); + } finally { + setLoading(false); + } + } + + const onBack = () => { + setCurrentStep(currentStep - 1); + }; + + return ( + + + + + + + Create Site + + Enter the details for the new site. Click next to preview on map. + + + + + +
+ + {currentStep === 0 && ( + <> +
+
+ ( + + Site Name + + + + + This is the name that will be used to identify the + site. + + + + )} + /> + + + Network + + + + + The network under which this site will be created. + + +
+ +
+ ( + + Latitude + + + + + + )} + /> + ( + + Longitude + + + + + + )} + /> +
+
+ + )} + + {currentStep === 1 && ( +
+

Map Preview

+ { + form.setValue("latitude", lat); + form.setValue("longitude", lng); + }} + /> +
+

Site Details

+
+
+

Site Name:

+

{form.getValues("name")}

+
+
+

Network:

+

{activeNetwork?.net_name || "N/A"}

+
+
+

Coordinates:

+

+ {form.getValues("latitude")},{" "} + {form.getValues("longitude")} +

+
+
+
+

+ Please confirm that the location on the map is correct. If + not, go back and adjust the coordinates. +

+
+ )} + + {error && ( + + Error + {error} + + )} + + + {currentStep > 0 && ( + + )} + + +
+ +
+
+ ); +} diff --git a/netmanager-app/app/(authenticated)/sites/page.tsx b/netmanager-app/app/(authenticated)/sites/page.tsx new file mode 100644 index 0000000000..5f5305b42e --- /dev/null +++ b/netmanager-app/app/(authenticated)/sites/page.tsx @@ -0,0 +1,360 @@ +"use client"; + +import { useState } from "react"; +import { Plus, Search, Loader2, ArrowUpDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import { SiteForm } from "@/app/(authenticated)/sites/site-form"; +import { useRouter } from "next/navigation"; +import { RouteGuard } from "@/components/route-guard"; +import { useSites } from "@/core/hooks/useSites"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Site } from "@/core/redux/slices/sitesSlice"; +import { CreateSiteForm } from "./create-site-form"; + +const ITEMS_PER_PAGE = 8; + +type SortField = "name" | "description" | "region" | "isOnline" | "createdAt"; +type SortOrder = "asc" | "desc"; + +export default function SitesPage() { + const router = useRouter(); + const [searchQuery, setSearchQuery] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [sortField, setSortField] = useState("createdAt"); + const [sortOrder, setSortOrder] = useState("desc"); + const { sites, isLoading, error } = useSites(); + + const handleSort = (field: SortField) => { + if (sortField === field) { + // Toggle order if same field + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + // New field, set to ascending + setSortField(field); + setSortOrder("asc"); + } + }; + + const sortSites = (sitesToSort: Site[]) => { + return [...sitesToSort].sort((a, b) => { + // Handle createdAt sorting + if (sortField === "createdAt") { + const dateA = new Date(a.createdAt || 0).getTime(); + const dateB = new Date(b.createdAt || 0).getTime(); + return sortOrder === "asc" ? dateA - dateB : dateB - dateA; + } + + // Handle other fields as before + let compareA = a[sortField].toString(); + let compareB = b[sortField].toString(); + + if (sortField === "isOnline") { + return sortOrder === "asc" + ? Number(b.isOnline) - Number(a.isOnline) + : Number(a.isOnline) - Number(b.isOnline); + } + + if (typeof compareA === "string") { + compareA = compareA.toLowerCase(); + compareB = compareB.toLowerCase(); + } + + if (compareA < compareB) return sortOrder === "asc" ? -1 : 1; + if (compareA > compareB) return sortOrder === "asc" ? 1 : -1; + return 0; + }); + }; + + const filteredSites = sites.filter((site: Site) => { + const searchLower = searchQuery.toLowerCase(); + return ( + site.name?.toLowerCase().includes(searchLower) || + site.location_name?.toLowerCase().includes(searchLower) || + site.generated_name?.toLowerCase().includes(searchLower) || + site.formatted_name?.toLowerCase().includes(searchLower) + ); + }); + + const sortedSites = sortSites(filteredSites); + + // Pagination calculations + const totalPages = Math.ceil(sortedSites.length / ITEMS_PER_PAGE); + const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; + const endIndex = startIndex + ITEMS_PER_PAGE; + const currentSites = sortedSites.slice(startIndex, endIndex); + + // Generate page numbers for pagination + const getPageNumbers = () => { + const pageNumbers = []; + const maxVisiblePages = 5; + + if (totalPages <= maxVisiblePages) { + // Show all pages if total pages is less than max visible + for (let i = 1; i <= totalPages; i++) { + pageNumbers.push(i); + } + } else { + // Show pages with ellipsis + if (currentPage <= 3) { + for (let i = 1; i <= 4; i++) { + pageNumbers.push(i); + } + pageNumbers.push("ellipsis"); + pageNumbers.push(totalPages); + } else if (currentPage >= totalPages - 2) { + pageNumbers.push(1); + pageNumbers.push("ellipsis"); + for (let i = totalPages - 3; i <= totalPages; i++) { + pageNumbers.push(i); + } + } else { + pageNumbers.push(1); + pageNumbers.push("ellipsis"); + for (let i = currentPage - 1; i <= currentPage + 1; i++) { + pageNumbers.push(i); + } + pageNumbers.push("ellipsis"); + pageNumbers.push(totalPages); + } + } + return pageNumbers; + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ + + Error + {error.message} + +
+ ); + } + + return ( + +
+
+

Site Registry

+ +
+ +
+
+ + setSearchQuery(e.target.value)} + /> +
+ + + + + + handleSort("createdAt")}> + Date Created{" "} + {sortField === "createdAt" && (sortOrder === "asc" ? "↑" : "↓")} + + handleSort("name")}> + Name {sortField === "name" && (sortOrder === "asc" ? "↑" : "↓")} + + handleSort("description")}> + Description{" "} + {sortField === "description" && + (sortOrder === "asc" ? "↑" : "↓")} + + handleSort("region")}> + Region{" "} + {sortField === "region" && (sortOrder === "asc" ? "↑" : "↓")} + + handleSort("isOnline")}> + Status{" "} + {sortField === "isOnline" && (sortOrder === "asc" ? "↑" : "↓")} + + + +
+ +
+ + + + handleSort("name")} + > + Name{" "} + {sortField === "name" && (sortOrder === "asc" ? "↑" : "↓")} + + Site ID + handleSort("description")} + > + Description{" "} + {sortField === "description" && + (sortOrder === "asc" ? "↑" : "↓")} + + Country + District + handleSort("region")} + > + Region{" "} + {sortField === "region" && (sortOrder === "asc" ? "↑" : "↓")} + + handleSort("isOnline")} + > + Status{" "} + {sortField === "isOnline" && + (sortOrder === "asc" ? "↑" : "↓")} + + {/* Actions */} + + + + {currentSites.map((site: Site) => ( + router.push(`/sites/${site._id}`)} + > + {site.name} + {site.generated_name} + {site.description} + {site.country} + {site.district} + {site.region} + + + {site.isOnline ? "Online" : "Offline"} + + + {/* + + */} + + ))} + {currentSites.length === 0 && ( + + + No sites found + + + )} + +
+
+ + {/* Pagination */} + {sortedSites.length > 0 && ( +
+ + + + + setCurrentPage((prev) => Math.max(prev - 1, 1)) + } + className={ + currentPage === 1 + ? "pointer-events-none opacity-50" + : "cursor-pointer" + } + /> + + + {getPageNumbers().map((pageNumber, index) => ( + + {pageNumber === "ellipsis" ? ( + + ) : ( + setCurrentPage(pageNumber as number)} + isActive={currentPage === pageNumber} + className="cursor-pointer" + > + {pageNumber} + + )} + + ))} + + + + setCurrentPage((prev) => Math.min(prev + 1, totalPages)) + } + className={ + currentPage === totalPages + ? "pointer-events-none opacity-50" + : "cursor-pointer" + } + /> + + + +
+ )} +
+
+ ); +} diff --git a/netmanager-app/app/(authenticated)/sites/site-form.tsx b/netmanager-app/app/(authenticated)/sites/site-form.tsx new file mode 100644 index 0000000000..cb3e5a48c3 --- /dev/null +++ b/netmanager-app/app/(authenticated)/sites/site-form.tsx @@ -0,0 +1,295 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +const siteFormSchema = z.object({ + name: z.string().min(2, { + message: "Name must be at least 2 characters.", + }), + description: z.string().optional(), + organization: z.string(), + latitude: z.string().regex(/^-?[0-9]\d*(\.\d+)?$/, { + message: "Please enter a valid latitude", + }), + longitude: z.string().regex(/^-?[0-9]\d*(\.\d+)?$/, { + message: "Please enter a valid longitude", + }), + network: z.string().optional(), + parish: z.string().optional(), + subCounty: z.string().optional(), + district: z.string().optional(), + region: z.string().optional(), + altitude: z.string().optional(), + greenness: z.string().optional(), + nearestRoad: z.string().optional(), + mobileAppName: z.string().optional(), + mobileAppDescription: z.string().optional(), +}); + +type SiteFormValues = z.infer; + +interface SiteFormProps { + initialData?: Partial; +} + +export function SiteForm({ initialData }: SiteFormProps) { + const form = useForm({ + resolver: zodResolver(siteFormSchema), + defaultValues: initialData || { + name: "", + organization: "AirQo", // This comes from the current org context + latitude: "", + longitude: "", + }, + }); + + function onSubmit(values: SiteFormValues) { + console.log(values); + // Here you would typically send this data to your API + } + + return ( +
+ +
+ ( + + Site Name * + + + + + + )} + /> + + ( + + Description + + + + + + )} + /> + + ( + + Organization + + + + + + )} + /> + + ( + + Network + + + + + + )} + /> + + ( + + Latitude * + + + + + + )} + /> + + ( + + Longitude * + + + + + + )} + /> + + ( + + Parish + + + + + + )} + /> + + ( + + Sub County + + + + + + )} + /> + + ( + + District + + + + + + )} + /> + + ( + + Region + + + + + + )} + /> + + ( + + Altitude + + + + + + )} + /> + + ( + + Greenness + + + + + + )} + /> + + ( + + Nearest Road (m) + + + + + + )} + /> +
+ +
+

Mobile App Details

+
+ ( + + Mobile App Name + + + + + + )} + /> + + ( + + Mobile App Description + + + + + + )} + /> +
+
+ +
+ + +
+
+ + ); +} diff --git a/netmanager-app/app/favicon.ico b/netmanager-app/app/favicon.ico new file mode 100644 index 0000000000..718d6fea48 Binary files /dev/null and b/netmanager-app/app/favicon.ico differ diff --git a/netmanager-app/app/fonts/GeistMonoVF.woff b/netmanager-app/app/fonts/GeistMonoVF.woff new file mode 100644 index 0000000000..f2ae185cbf Binary files /dev/null and b/netmanager-app/app/fonts/GeistMonoVF.woff differ diff --git a/netmanager-app/app/fonts/GeistVF.woff b/netmanager-app/app/fonts/GeistVF.woff new file mode 100644 index 0000000000..1b62daacff Binary files /dev/null and b/netmanager-app/app/fonts/GeistVF.woff differ diff --git a/netmanager-app/app/forgot-password/page.tsx b/netmanager-app/app/forgot-password/page.tsx new file mode 100644 index 0000000000..83970e1814 --- /dev/null +++ b/netmanager-app/app/forgot-password/page.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { + ExclamationTriangleIcon, + CheckCircledIcon, +} from "@radix-ui/react-icons"; + +const forgotPasswordSchema = z.object({ + email: z.string().email({ message: "Please enter a valid email address" }), +}); + +export default function ForgotPasswordPage() { + const [status, setStatus] = useState<"idle" | "success" | "error">("idle"); + + const form = useForm>({ + resolver: zodResolver(forgotPasswordSchema), + defaultValues: { + email: "", + }, + }); + + async function onSubmit(values: z.infer) { + setStatus("idle"); + try { + // Here you would typically send a request to your API to initiate the password reset process + console.log(values); + setStatus("success"); + } catch (error) { + setStatus("error"); + } + } + + return ( +
+ + + Forgot Password + + Enter your email to receive a password reset link. + + + +
+ + ( + + Email + + + + + + )} + /> + {status === "success" && ( + + + Success + + A password reset link has been sent to your email. + + + )} + {status === "error" && ( + + + Error + + An error occurred. Please try again. + + + )} + + + +
+ + + Back to Login + + +
+
+ ); +} diff --git a/netmanager-app/app/globals.css b/netmanager-app/app/globals.css new file mode 100644 index 0000000000..adc3151592 --- /dev/null +++ b/netmanager-app/app/globals.css @@ -0,0 +1,69 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --radius: 0.5rem; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 48%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/netmanager-app/app/layout.tsx b/netmanager-app/app/layout.tsx new file mode 100644 index 0000000000..25716d6d52 --- /dev/null +++ b/netmanager-app/app/layout.tsx @@ -0,0 +1,19 @@ +import { Inter } from "next/font/google"; +import "./globals.css"; +import Providers from "./providers"; + +const inter = Inter({ subsets: ["latin"] }); + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/netmanager-app/app/login/page.tsx b/netmanager-app/app/login/page.tsx new file mode 100644 index 0000000000..4b958f3177 --- /dev/null +++ b/netmanager-app/app/login/page.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; +import { useAuth } from "@/core/hooks/users"; +import { Loader2 } from "lucide-react"; + +const loginSchema = z.object({ + userName: z.string().email({ message: "Please enter a valid email address" }), + password: z + .string() + .min(8, { message: "Password must be at least 8 characters long" }), +}); + +export default function LoginPage() { + const router = useRouter(); + const [error, setError] = useState(null); + const { login, isLoading } = useAuth(); + + const form = useForm>({ + resolver: zodResolver(loginSchema), + defaultValues: { + userName: "", + password: "", + }, + }); + + async function onSubmit(values: z.infer) { + setError(null); + try { + await login(values, { + onSuccess: () => { + router.push("/"); + }, + onError: (error: any) => { + setError( + error?.message || "Invalid email or password. Please try again." + ); + }, + }); + } catch (error: any) { + setError( + error?.message || "An unexpected error occurred. Please try again." + ); + } + } + + return ( +
+ + + Login + + Enter your email and password to access your account. + + + +
+ + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + {error && ( + + + Error + {error} + + )} + + + +
+ + + Forgot password? + + +
+
+ ); +} diff --git a/netmanager-app/app/page.tsx b/netmanager-app/app/page.tsx new file mode 100644 index 0000000000..6388cded61 --- /dev/null +++ b/netmanager-app/app/page.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useAppSelector } from "@/core/redux/hooks"; +import { Loader2 } from "lucide-react"; + +export default function Page() { + const router = useRouter(); + const isAuthenticated = useAppSelector((state) => state.user.isAuthenticated); + + useEffect(() => { + if (!isAuthenticated) { + router.push("/login"); + } else { + router.push("/analytics"); // or whatever your main authenticated route is + } + }, [isAuthenticated, router]); + + // Show loading state while redirecting + return ( +
+ +
+ ); +} diff --git a/netmanager-app/app/providers.tsx b/netmanager-app/app/providers.tsx new file mode 100644 index 0000000000..e6f856a140 --- /dev/null +++ b/netmanager-app/app/providers.tsx @@ -0,0 +1,41 @@ +"use client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { store } from "@/core/redux/store"; +import { Provider } from "react-redux"; +import { useAuth } from "@/core/hooks/users"; + +// Create a separate component for session restoration +function SessionRestorer({ children }: { children: React.ReactNode }) { + const { restoreSession } = useAuth(); + + useEffect(() => { + restoreSession(); + }, [restoreSession]); + + return <>{children}; +} + +export default function Providers({ children }: { children: React.ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + refetchOnWindowFocus: false, + }, + }, + }) + ); + + return ( + + + {children} + + + + ); +} diff --git a/netmanager-app/app/types/layout.ts b/netmanager-app/app/types/layout.ts new file mode 100644 index 0000000000..8bfcf6e828 --- /dev/null +++ b/netmanager-app/app/types/layout.ts @@ -0,0 +1,5 @@ +import { ReactNode } from "react"; + +export interface LayoutProps { + children: ReactNode; +} diff --git a/netmanager-app/app/types/sites.ts b/netmanager-app/app/types/sites.ts new file mode 100644 index 0000000000..6f37d9ecea --- /dev/null +++ b/netmanager-app/app/types/sites.ts @@ -0,0 +1,28 @@ +export interface Site { + id: string; + name: string; + description: string; + country: string; + district: string; + region: string; + latitude: number; + longitude: number; + network: string; + parish: string; + subCounty: string; + altitude: number; + greenness?: string; + nearestRoad?: number; + mobileAppName?: string; + mobileAppDescription?: string; +} + +export interface Device { + name: string; + description?: string; + site?: string; + isPrimary: boolean; + isCoLocated: boolean; + registrationDate: string; + deploymentStatus: "Deployed" | "Pending" | "Removed"; +} diff --git a/netmanager-app/app/types/users.ts b/netmanager-app/app/types/users.ts new file mode 100644 index 0000000000..cfac1853b0 --- /dev/null +++ b/netmanager-app/app/types/users.ts @@ -0,0 +1,119 @@ +export interface Permission { + _id: string; + permission: string; + network_id?: string; + description?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface Role { + _id: string; + role_name: string; + role_permissions: Permission[]; +} + +export interface Network { + net_name: string; + _id: string; + role: Role; + userType: string; + createdAt?: string; + status?: string; +} + +export interface Client { + _id: string; + name: string; + user_id: string; + client_secret: string; + createdAt: string; + updatedAt: string; + isActive: boolean; +} + +export interface Group { + grp_title: string; + _id: string; + createdAt: string; + status: string; + role: Role; + userType: string; +} + +export interface UserDetails { + _id: string; + firstName: string; + lastName: string; + lastLogin: string; + isActive?: boolean; + loginCount?: number; + userName: string; + email: string; + verified?: boolean; + analyticsVersion?: number; + country?: string | null; + privilege?: string; + website?: string | null; + category?: string | null; + organization?: string; + long_organization?: string; + rateLimit: number | null; + jobTitle?: string | null; + description?: string | null; + profilePicture: string | null; + phoneNumber: string | null; + updatedAt: string; + networks?: Network[]; + clients?: Client[]; + groups?: Group[]; + permissions?: Permission[]; + createdAt: string; + my_networks?: string[]; + my_groups?: string[]; + iat?: number; +} + +export interface LoginResponse { + success: boolean; + message: string; + token: string; + _id: string; + userName: string; + email: string; +} + +export interface UserDetailsResponse { + success: boolean; + message: string; + users: UserDetails[]; +} + +export interface LoginCredentials { + userName: string; + password: string; +} + +export interface DecodedToken { + _id: string; + firstName: string; + lastName: string; + userName: string; + email: string; + organization: string; + long_organization: string; + privilege: string; + country: string | null; + profilePicture: string | null; + phoneNumber: string | null; + createdAt: string; + updatedAt: string; + rateLimit: number | null; + lastLogin: string; + iat: number; +} + +export interface CurrentRole { + role_name: string; + permissions: string[]; +} diff --git a/netmanager-app/components.json b/netmanager-app/components.json new file mode 100644 index 0000000000..a2a87a40b0 --- /dev/null +++ b/netmanager-app/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "core": "@/core" + }, + "iconLibrary": "lucide" +} diff --git a/netmanager-app/components/layout.tsx b/netmanager-app/components/layout.tsx new file mode 100644 index 0000000000..dd3f53a41c --- /dev/null +++ b/netmanager-app/components/layout.tsx @@ -0,0 +1,27 @@ +"use client"; +import React, { useEffect, useState } from "react"; +import Sidebar from "./sidebar"; +import Topbar from "./topbar"; +import { LayoutProps } from "../app/types/layout"; + +export default function Layout({ children }: LayoutProps) { + const [darkMode, setDarkMode] = useState(false); + + useEffect(() => { + const isDarkMode = localStorage.getItem("darkMode") === "true"; + setDarkMode(isDarkMode); + document.documentElement.classList.toggle("dark", isDarkMode); + }, []); + + return ( +
+ +
+ +
+
{children}
+
+
+
+ ); +} diff --git a/netmanager-app/components/permission-guard.tsx b/netmanager-app/components/permission-guard.tsx new file mode 100644 index 0000000000..77b6e55d0c --- /dev/null +++ b/netmanager-app/components/permission-guard.tsx @@ -0,0 +1,31 @@ +import { ReactNode } from "react"; +import { usePermissions } from "@/core/hooks/usePermissions"; + +interface PermissionGuardProps { + children: ReactNode; + permission: string | string[]; + requireAll?: boolean; + fallback?: ReactNode; +} + +export const PermissionGuard = ({ + children, + permission, + requireAll = false, + fallback = null, +}: PermissionGuardProps) => { + const { hasPermission, hasAllPermissions, hasAnyPermission } = + usePermissions(); + + const hasAccess = Array.isArray(permission) + ? requireAll + ? hasAllPermissions(permission) + : hasAnyPermission(permission) + : hasPermission(permission); + + if (!hasAccess) { + return fallback; + } + + return <>{children}; +}; diff --git a/netmanager-app/components/route-guard.tsx b/netmanager-app/components/route-guard.tsx new file mode 100644 index 0000000000..f27d4d38c8 --- /dev/null +++ b/netmanager-app/components/route-guard.tsx @@ -0,0 +1,52 @@ +import { ReactNode } from "react"; +import { usePermissions } from "@/core/hooks/usePermissions"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; +import { useAppSelector } from "@/core/redux/hooks"; +import { Loader2 } from "lucide-react"; + +interface RouteGuardProps { + children: ReactNode; + permission: string | string[]; + requireAll?: boolean; +} + +export const RouteGuard = ({ + children, + permission, + requireAll = false, +}: RouteGuardProps) => { + const { hasPermission, hasAllPermissions, hasAnyPermission } = + usePermissions(); + const isInitialized = useAppSelector((state) => state.user.isInitialized); + + if (!isInitialized) { + return ( +
+ +
+ ); + } + + const hasAccess = Array.isArray(permission) + ? requireAll + ? hasAllPermissions(permission) + : hasAnyPermission(permission) + : hasPermission(permission); + + if (!hasAccess) { + return ( +
+ + + Access Denied + + You don't have permission to access this page. + + +
+ ); + } + + return <>{children}; +}; diff --git a/netmanager-app/components/sidebar.tsx b/netmanager-app/components/sidebar.tsx new file mode 100644 index 0000000000..439b1ff697 --- /dev/null +++ b/netmanager-app/components/sidebar.tsx @@ -0,0 +1,406 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + BarChart2, + Users, + Shield, + Radio, + MapPin, + Layers, + Grid, + Building2, + Activity, + UserCircle, + Download, + Map, + ChevronDown, + Check, + PlusCircle, + MonitorSmartphone, + LogOut, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { useAuth } from "@/core/hooks/users"; +import { useAppSelector, useAppDispatch } from "@/core/redux/hooks"; +import { setActiveNetwork } from "@/core/redux/slices/userSlice"; +import type { Network } from "@/app/types/users"; +import { PermissionGuard } from "@/components/permission-guard"; + +const Sidebar = () => { + const pathname = usePathname(); + const [userCollapsed, setUserCollapsed] = useState(false); + const [isDevicesOpen, setIsDevicesOpen] = useState(false); + const { logout } = useAuth(); + const dispatch = useAppDispatch(); + + // Get networks and active network from Redux + const availableNetworks = useAppSelector( + (state) => state.user.availableNetworks + ); + const activeNetwork = useAppSelector((state) => state.user.activeNetwork); + + const isActive = (path: string) => pathname?.startsWith(path); + const isDevicesActive = isActive("/devices"); + + useEffect(() => { + if (isDevicesActive && !userCollapsed) { + setIsDevicesOpen(true); + } else if (!isDevicesActive) { + setUserCollapsed(false); + } + }, [pathname, isDevicesActive, userCollapsed]); + + const handleDevicesToggle = (open: boolean) => { + setIsDevicesOpen(open); + if (isDevicesActive) { + setUserCollapsed(!open); + } + }; + + const handleNetworkChange = (network: Network) => { + dispatch(setActiveNetwork(network)); + localStorage.setItem("activeNetwork", JSON.stringify(network)); + }; + + return ( +
+ {/* Network Switcher */} +
+ + + + + + Switch Network + + {availableNetworks.map((network) => ( + handleNetworkChange(network)} + className="flex items-center justify-between uppercase" + > + {network.net_name} + {activeNetwork?._id === network._id && } + + ))} + + +
+ + {/* Main Navigation */} +
+ +
+ + {/* Logout Section */} +
+ +
+
+ ); +}; + +export default Sidebar; diff --git a/netmanager-app/components/top-nav.tsx b/netmanager-app/components/top-nav.tsx new file mode 100644 index 0000000000..0a865c883b --- /dev/null +++ b/netmanager-app/components/top-nav.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { Bell, Search, User } from "lucide-react"; + +export default function TopNav() { + return ( +
+
+
+
+
+ + +
+
+
+ +
+
+ +
+
+
+
+
+
+ ); +} diff --git a/netmanager-app/components/topbar.tsx b/netmanager-app/components/topbar.tsx new file mode 100644 index 0000000000..46f021e601 --- /dev/null +++ b/netmanager-app/components/topbar.tsx @@ -0,0 +1,125 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import Link from "next/link"; +import { + UserCircle, + LogOut, + GridIcon, + ExternalLink, + Moon, + Sun, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useAuth } from "@/core/hooks/users"; + +const Topbar = () => { + const [darkMode, setDarkMode] = useState(false); + const { logout } = useAuth(); + + useEffect(() => { + if (darkMode) { + document.documentElement.classList.add("dark"); + } else { + document.documentElement.classList.remove("dark"); + } + }, [darkMode]); + + const toggleDarkMode = () => { + setDarkMode(!darkMode); + }; + + const apps = [ + { + name: "Calibrate", + url: "/calibrate", + description: "Device Calibration Tool", + }, + { name: "Documentation", url: "/docs", description: "API & User Guides" }, + { + name: "Analytics", + url: "/analytics", + description: "Advanced Analytics Platform", + }, + ]; + + return ( +
+ {/* Left side - can be used for breadcrumbs or other navigation */} +
+ + {/* Right side - Apps and Profile */} +
+ {/* Apps Navigation */} + + + + + + {apps.map((app) => ( + + +
+
{app.name}
+
+ {app.description} +
+
+ + +
+ ))} +
+
+ + {/* Profile Dropdown */} + + + + + + My Account + + + + + Profile Settings + + + + {darkMode ? ( + + ) : ( + + )} + {darkMode ? "Light Mode" : "Dark Mode"} + + + + + Log out + + + +
+
+ ); +}; + +export default Topbar; diff --git a/netmanager-app/components/ui/alert.tsx b/netmanager-app/components/ui/alert.tsx new file mode 100644 index 0000000000..29bd44f01e --- /dev/null +++ b/netmanager-app/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/netmanager-app/components/ui/badge.tsx b/netmanager-app/components/ui/badge.tsx new file mode 100644 index 0000000000..9ec9a1a049 --- /dev/null +++ b/netmanager-app/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/netmanager-app/components/ui/button.tsx b/netmanager-app/components/ui/button.tsx new file mode 100644 index 0000000000..f2329bdfe6 --- /dev/null +++ b/netmanager-app/components/ui/button.tsx @@ -0,0 +1,56 @@ +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 ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + } +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/netmanager-app/components/ui/card.tsx b/netmanager-app/components/ui/card.tsx new file mode 100644 index 0000000000..7727845916 --- /dev/null +++ b/netmanager-app/components/ui/card.tsx @@ -0,0 +1,86 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = "CardFooter"; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/netmanager-app/components/ui/collapsible.tsx b/netmanager-app/components/ui/collapsible.tsx new file mode 100644 index 0000000000..cb003d1756 --- /dev/null +++ b/netmanager-app/components/ui/collapsible.tsx @@ -0,0 +1,11 @@ +"use client"; + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; + +const Collapsible = CollapsiblePrimitive.Root; + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/netmanager-app/components/ui/dialog.tsx b/netmanager-app/components/ui/dialog.tsx new file mode 100644 index 0000000000..42b363de86 --- /dev/null +++ b/netmanager-app/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/netmanager-app/components/ui/dropdown-menu.tsx b/netmanager-app/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000000..dcc923bce6 --- /dev/null +++ b/netmanager-app/components/ui/dropdown-menu.tsx @@ -0,0 +1,200 @@ +"use client"; + +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/netmanager-app/components/ui/form.tsx b/netmanager-app/components/ui/form.tsx new file mode 100644 index 0000000000..19ff72343c --- /dev/null +++ b/netmanager-app/components/ui/form.tsx @@ -0,0 +1,179 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} 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, formState } = useFormContext(); + + 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 +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +