diff --git a/README.md b/README.md index 4777046..fcae7de 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ This is a frontend dashboard application for displaying information collected by To get a local copy running, follow these steps: -1. Clone the repository: `git clone git@github.com:tinyaiot-ps/frontend.git` +1. Clone the repository: `git clone git@github.com:TinyAIoT/trashcan-frontend.git` 2. Install [NodeJS](https://nodejs.org/en/download/) 3. Install the dependencies: `npm install` 4. Obtain the `.env` variables (-> cf. documentation) diff --git a/app/globals.css b/app/globals.css index 078fb49..bbec716 100644 --- a/app/globals.css +++ b/app/globals.css @@ -160,3 +160,132 @@ visibility: visible; opacity: 1; } +/* Add this to your global CSS or a dedicated CSS module */ +.language-switcher-container { + position: fixed; /* Fixed position to stay at the top-right of the viewport */ + top: 16px; /* Distance from the top */ + right: 16px; /* Distance from the right */ + z-index: 1000; /* Ensure it's above other elements */ + display: flex; + align-items: center; + justify-content: center; +} + +.language-switcher-container .icon-button { + width: 40px; /* Adjust size */ + height: 40px; + background: white; + border-radius: 50%; /* Circular button */ + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); /* Subtle shadow effect */ + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform 0.2s ease, background-color 0.2s ease; +} + +.language-switcher-container .icon-button:hover { + transform: scale(1.1); /* Slightly enlarge on hover */ + background-color: #f0f0f0; /* Light gray hover effect */ +} + +/* Apply background and text colors dynamically */ +body { + background-color: #ffffff; /* Light mode */ + color: #000000; + transition: background-color 0.3s ease, color 0.3s ease; /* Smooth transitions */ + + /* Light Mode Input */ +.input { + background-color: hsl(var(--input)); /* Light mode background */ + color: hsl(var(--foreground)); /* Light mode text color */ + border: 1px solid hsl(var(--border)); /* Light mode border */ +} + +/* Dark Mode Input */ +html.dark .input { + background-color: hsl(var(--secondary)); /* Dark mode background */ + color: hsl(var(--foreground)); /* Dark mode text color */ + border: 1px solid hsl(var(--border)); /* Dark mode border */ +} + + +} + +/* Additional utility classes for reusable theming */ +.card { + background-color: hsl(var(--card)); + color: hsl(var(--card-foreground)); +} + +.border { + border-color: hsl(var(--border)); +} + +.input { + background-color: hsl(var(--input)); + color: hsl(var(--foreground)); + border-color: hsl(var(--border)); +} + +/* Smooth transition for theme toggle */ +* { + transition: background-color 0.3s ease, color 0.3s ease; +} + +/* Container for theme switcher button */ +.theme-switcher-container { + position: fixed; + top: 16px; /* Align with the language switcher */ + right: 72px; /* Positioned near the language switcher */ + z-index: 1000; /* Ensure it appears above other elements */ + display: flex; + align-items: center; + justify-content: center; +} + +.theme-switcher-container .icon-button { + width: 40px; + height: 40px; + background: white; + border-radius: 50%; /* Circular button */ + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform 0.2s ease, background-color 0.2s ease; +} + +.theme-switcher-container .icon-button:hover { + transform: scale(1.1); /* Hover effect */ + background-color: #f0f0f0; /* Light gray hover effect */ +} + + +html.light body { + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); +} + +html.dark body { + background-color: #1a202c; /* Dark mode */ + color: #ffffff; +} + +/* Focus state for input fields */ +.input:focus { + outline: none; + border-color: hsl(var(--ring)); /* Focus ring color */ + box-shadow: 0 0 0 2px hsl(var(--ring)); /* Outer ring effect */ +} + +/* Placeholder color */ +.input::placeholder { + color: hsl(var(--muted-foreground)); /* Subtle placeholder color */ +} + +/* Dark mode placeholder */ +html.dark .input::placeholder { + color: hsl(var(--muted-foreground)); /* Match dark mode style */ +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index cb83376..73a9488 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,17 +1,21 @@ "use client"; +import { ThemeProvider, useTheme } from "@/lib/ThemeContext"; +import ThemeSwitcher from "@/components/ThemeSwitcher"; import { Inter } from "next/font/google"; -import "./globals.css"; +import { TranslationProvider } from "../lib/TranslationContext"; +import "../app/globals.css" import { cn } from "../lib/utils"; import SideNavbar from "@/components/SideNavbar"; +import LanguageSwitcher from "@/components/LanguageSwitcher"; import { useEffect, useState } from "react"; +//import { useTheme } from "@/lib/ThemeContext"; + const inter = Inter({ subsets: ["latin"] }); type HistoryStateArguments = [data: any, unused: string, url?: string | URL | null | undefined]; -// Override pushState and replaceState methods, as they otherwise don't trigger events -// when only navigating within the app instead of reloading the page const overrideHistoryMethods = () => { const pushState = history.pushState; history.pushState = function (...args: HistoryStateArguments) { @@ -34,7 +38,6 @@ const overrideHistoryMethods = () => { }); }; - export default function RootLayout({ children, }: { @@ -42,8 +45,17 @@ export default function RootLayout({ }) { const [showNavigation, setShowNavigation] = useState(false); const [token, setToken] = useState(""); + const [loading, setLoading] = useState(true); + // Add mounted state + const [mounted, setMounted] = useState(false); + + // Set mounted to true after client-side rendering + useEffect(() => { + setMounted(true); + }, []); + useEffect(() => { overrideHistoryMethods(); const storedToken = localStorage.getItem("authToken"); @@ -51,19 +63,15 @@ export default function RootLayout({ setLoading(false); }, []); - // Enforce login on all pages except the login page useEffect(() => { if (loading) return; - // This effect depends on `token`, so it will re-run when `token` changes. - // Initially, it runs after the token is retrieved from localStorage. const pathname = window.location.pathname; const noAuthPaths = ["/login"]; if (!token && !noAuthPaths.includes(pathname)) { - window.location.href = "/login"; // Redirect to login page + window.location.href = "/login"; } - }, [token, loading]); // Depend on `token` to re-run this effect when it changes + }, [token, loading]); - // Hide the navigation bar on some subpages useEffect(() => { const noNavigationPaths = ["/login", "/projects"]; @@ -72,7 +80,7 @@ export default function RootLayout({ setShowNavigation(!noNavigationPaths.includes(pathname)); }; - handlePathChange(); // Call initially to set the correct state + handlePathChange(); window.addEventListener("locationchange", handlePathChange); return () => { @@ -80,28 +88,48 @@ export default function RootLayout({ }; }, []); - return ( - + const { theme } = useTheme(); // Get theme context + + // Prevent rendering until mounted to avoid hydration errors + if (!mounted) { + return null; // Return nothing until mounted + } + +return ( + + TinyAIoT Dashboard - {showNavigation && ( -
- -
- )} - {/* Main page */} -
{children}
+ + + + {/* Language Switcher in the Header */} +
+ + +
+ {showNavigation && ( +
+ +
+ )} +
{children}
+
+ +
); } diff --git a/app/login/page.tsx b/app/login/page.tsx index 7ec6888..1c697a5 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -7,6 +7,8 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { CardTitle, CardDescription, CardHeader, CardContent, Card } from "@/components/ui/card"; +import { useTranslation } from "@/lib/TranslationContext"; // Import translation hook + function removeLocalData() { if (typeof window !== "undefined") { @@ -15,19 +17,21 @@ function removeLocalData() { } export default function Component() { - const [errorMessage, setErrorMessage] = useState(null); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); + const [errorMessage, setErrorMessage] = useState(null); + const [name, setEmail] = useState(""); + const [password, setPassword] = useState(""); const router = useRouter(); + const { t } = useTranslation(); // Use translation hook for localization + removeLocalData(); const handleLogin = async () => { try { const response = await axios.post( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/auth/login`, + `/api/v1/auth/login`, { - email, + name, password, } ); @@ -35,62 +39,88 @@ export default function Component() { if (response.status === 200) { // Save the token in local storage for future requests localStorage.setItem("authToken", response.data.token); - localStorage.setItem("email", email); + localStorage.setItem("name", name); + localStorage.setItem("userId", response.data.user.id); + // Redirect to the home page router.push("/"); } } catch (error: any) { - setErrorMessage(error.response.data.message); + setErrorMessage(error.response?.data?.message || "An error occurred"); setTimeout(() => { setErrorMessage(null); }, 2000); - // Set the focus to the email input field - document.getElementById("email")?.focus(); + // Set the focus to the name input field + document.getElementById("name")?.focus(); } }; - return ( -
- - - Login - - Enter your email and password to login to your account - - - -
- {errorMessage && ( -
- {errorMessage} + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); // Prevent default form submission + handleLogin(); // Trigger login function explicitly + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + // Simulate a button click by triggering the handleLogin function + handleLogin(); + } + }; + + return ( +
+ +
+ + + {t("login.title")} + + + {t("login.description")} + + + +
+ {errorMessage && ( +
+ {errorMessage} +
+ )} +
+ + setEmail(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+
+ + setPassword(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+
- )} -
- - setEmail(e.target.value)} - /> -
-
- - setPassword(e.target.value)} - /> -
- -
- - -
- ); -} + + + +
+ ); + } diff --git a/app/projects/[city]/noise/page.tsx b/app/projects/[city]/noise/page.tsx index c94402c..14e642e 100644 --- a/app/projects/[city]/noise/page.tsx +++ b/app/projects/[city]/noise/page.tsx @@ -7,6 +7,7 @@ import { CardContent } from "@/components/Card"; import NoiseChart from "@/components/NoiseChart"; import LoadingComponent from "@/components/LoadingComponent"; import { Info, Settings } from "lucide-react"; +import { useRouter } from "next/navigation"; function redirectToSettings() { window.location.href = window.location.href + "/settings"; @@ -18,6 +19,7 @@ export default function NoiseDashboard() { const [noiseThreshold, setNoiseThreshold] = useState(0); const [confidenceThreshold, setConfidenceThreshold] = useState(0); const [loading, setLoading] = useState(true); + const router = useRouter(); useEffect(() => { const fetchData = async () => { @@ -26,7 +28,7 @@ export default function NoiseDashboard() { const projectId = localStorage.getItem("projectId"); const projectResponse = await axios.get( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/project/${projectId}`, + `/api/v1/project/${projectId}`, { headers: { Authorization: `Bearer ${token?.replace(/"/g, "")}`, @@ -55,7 +57,7 @@ export default function NoiseDashboard() { } const historyResponse = await axios.get( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/history/sensor/${sensorId}`, + `/api/v1/history/sensor/${sensorId}`, { headers: { Authorization: `Bearer ${token?.replace(/"/g, "")}`, @@ -73,11 +75,14 @@ export default function NoiseDashboard() { } catch (error) { console.error("Error fetching data:", error); + if (axios.isAxiosError(error) && error.response?.status === 401) { + router.push('/login'); + } } }; fetchData(); - }, []); + }, [router]); if (loading) return ; diff --git a/app/projects/[city]/noise/settings/page.tsx b/app/projects/[city]/noise/settings/page.tsx index 9d3d5a4..569ed2e 100644 --- a/app/projects/[city]/noise/settings/page.tsx +++ b/app/projects/[city]/noise/settings/page.tsx @@ -5,6 +5,7 @@ import axios from "axios"; import PageTitle from "@/components/PageTitle"; import LoadingComponent from "@/components/LoadingComponent"; import { Info } from "lucide-react"; +import { useRouter } from "next/navigation"; export default function AppSettings() { const [activeTimeInterval, setActiveTimeInterval] = useState<[string, string]>(["", ""]); @@ -13,6 +14,7 @@ export default function AppSettings() { const [loading, setLoading] = useState(true); const [updated, setUpdated] = useState(false); const [errors, setErrors] = useState({ noiseThreshold: "", confidenceThreshold: "", activeTimeInterval: "" }); + const router = useRouter(); useEffect(() => { const fetchData = async () => { @@ -21,7 +23,7 @@ export default function AppSettings() { const projectId = localStorage.getItem("projectId"); const projectResponse = await axios.get( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/project/${projectId}`, + `/api/v1/project/${projectId}`, { headers: { Authorization: `Bearer ${token?.replace(/"/g, "")}`, @@ -39,11 +41,14 @@ export default function AppSettings() { setLoading(false); } catch (error) { console.error("Error fetching data:", error); + if (axios.isAxiosError(error) && error.response?.status === 401) { + router.push('/login'); + } } }; fetchData(); - }, []); + }, [router]); const handleSubmit = async (event: any) => { event.preventDefault(); @@ -100,7 +105,7 @@ export default function AppSettings() { const projectId = localStorage.getItem("projectId"); await axios.patch( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/project/${projectId}`, + `/api/v1/project/${projectId}`, { activeTimeInterval: [startHour, endHour], confidenceThreshold: confidenceThresholdNum, diff --git a/app/projects/[city]/trash/map/page.tsx b/app/projects/[city]/trash/map/page.tsx index 9c42ee9..50c2b2a 100644 --- a/app/projects/[city]/trash/map/page.tsx +++ b/app/projects/[city]/trash/map/page.tsx @@ -2,14 +2,17 @@ import React, { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; -import axios from "axios"; +import axios from "axios";; import { LatLngTuple } from "leaflet"; import PageTitle from "@/components/PageTitle"; import Map from "@/components/Map"; import LoadingComponent from "@/components/LoadingComponent"; import { Trashbin } from "@/app/types"; +import { useTranslation } from "@/lib/TranslationContext"; + const MapPage = () => { + const { t } = useTranslation(); // Translation hook const router = useRouter(); const [trashbinData, setTrashbinData] = useState([]); const [centerCoordinates, setCenterCoordinates] = useState(null); @@ -21,6 +24,7 @@ const MapPage = () => { const city = localStorage.getItem("cityName"); const type = localStorage.getItem("projectType"); router.push(`/projects/${city}/${type}/trashbins/${trashbin.identifier}`); + return false; }; useEffect(() => { @@ -30,7 +34,7 @@ const MapPage = () => { const projectId = localStorage.getItem("projectId"); const trashbinResponse = await axios.get( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/trashbin?project=${projectId}`, + `/api/v1/trashbin?project=${projectId}`, { headers: { Authorization: `Bearer ${token?.replace(/"/g, "")}`, @@ -40,7 +44,7 @@ const MapPage = () => { setTrashbinData(trashbinResponse.data.trashbins); const projectResponse = await axios.get( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/project/${projectId}`, + `/api/v1/project/${projectId}`, { headers: { Authorization: `Bearer ${token?.replace(/"/g, "")}`, @@ -53,17 +57,22 @@ const MapPage = () => { setBatteryThresholds(projectResponse.data.project.preferences.batteryThresholds); } catch (error) { console.error("Error fetching data:", error); + if (axios.isAxiosError(error) && error.response?.status === 401) { + router.push('/login'); + } } }; fetchData(); - }, []); + }, [router]); return (
- + {/* Translated title */} +
{/* Make sure that all information was fetched from the backend before rendering the map */} + { (centerCoordinates && initialZoom && fillThresholds && batteryThresholds) ? { batteryThresholds={batteryThresholds} isRoutePlanning={false} onTrashbinClick={redirectToTrashbinDetail} - /> : - - } + /> + : ( + )}
); }; diff --git a/app/projects/[city]/trash/page.tsx b/app/projects/[city]/trash/page.tsx index d8ebb10..b847006 100644 --- a/app/projects/[city]/trash/page.tsx +++ b/app/projects/[city]/trash/page.tsx @@ -1,56 +1,181 @@ +"use client"; + import PageTitle from "@/components/PageTitle"; import Card, { CardContent, CardProps } from "@/components/Card"; import { HeatmapFillLevel } from "@/components/Heatmap/HeatmapFillLevel"; -import { HeatmapBatteryLevel } from "@/components/Heatmap/HeatmapBatteryLevel"; - -// Currently using mocked data, as API endpoints are not available -const cardData: CardProps[] = [ - { - label: "Total number", - amount: "42", - discription: "+1 since last day", - }, - { - label: "Nearly full", - amount: "18", - discription: "+2 since last hour", - }, - { - label: "Low battery", - amount: "5", - discription: "Same as last week", - }, - { - label: "Broken sensors", - amount: "2", - discription: "-1 since last week", - }, -]; +import { Trashbin } from "@/app/types"; +import React, { useCallback, useState, useEffect } from "react"; +import axios from "axios";; +import { useTranslation } from "@/lib/TranslationContext"; // Import the translation hook +import { redirect } from "next/navigation"; + +interface HistoryDataItem { + timestamp: Date; + fillLevel: number; + batteryLevel: number; +} + +// Bins currently always assigned to a single collector +// Treated like a boolean for now: assigned or not assigned export default function Home() { + const { t } = useTranslation(); // Translation hook + const [trashbinData, setTrashbinData] = useState([]); + const [totalCardData, setTotalCardData] = useState({ + label: "", + amount: "0", + description: "", + }); + const [nearlyFullCardData, setNearlyFullCardData] = useState({ + label: "", + amount: "0", + description: "", + }); + const [lowBatteryCardData, setLowBatteryCardData] = useState({ + label: "", + amount: "0", + description: "", + }); + const [brokenSensorsCardData, setBrokenSensorsCardData] = useState({ + label: "", + amount: "0", + description: "", + }); + + useEffect(() => { + // Update card data when translations are loaded + setTotalCardData((prev) => ({ + ...prev, + label: t("menu.total_number"), + })); + setNearlyFullCardData((prev) => ({ + ...prev, + label: t("menu.nearly_full"), + })); + setLowBatteryCardData((prev) => ({ + ...prev, + label: t("menu.low_battery"), + })); + setBrokenSensorsCardData((prev) => ({ + ...prev, + label: t("menu.broken_sensors"), + })); + }, [t]); + + useEffect(() => { + const fetchData = async () => { + try { + const token = localStorage.getItem("authToken"); + const projectId = localStorage.getItem("projectId"); + + if (!token || !projectId) { + console.error("Token or Project ID is missing"); + return; + } + + const headers = { + Authorization: `Bearer ${token?.replace(/"/g, "")}`, + }; + + // Fetch all trashbins + const allTrashbinsResponse = await axios.get( + `/api/v1/trashbin?project=${projectId}`, + { headers } + ); + const transformedTrashbinData: Trashbin[] = allTrashbinsResponse.data.trashbins || []; + + // Fetch assigned trashbins + const assignedTrashbinsResponse = await axios.get( + `/api/v1/trashbin?project=${projectId}`, + { headers } + ); + const assignedTrashbins = assignedTrashbinsResponse.data.assignedTrashbins || []; + + if (!Array.isArray(transformedTrashbinData) || !Array.isArray(assignedTrashbins)) { + throw new Error("Unexpected response structure"); + } + + // Map the assigned status + const updatedTrashbinData = transformedTrashbinData.map((item: Trashbin) => ({ + ...item, + assigned: assignedTrashbins.map((bin: Trashbin) => bin._id).includes(item._id), + })); + + setTrashbinData(updatedTrashbinData); + + setTotalCardData((prev) => ({ + ...prev, + amount: updatedTrashbinData.length.toString(), + })); + + // Fetch project preferences + const projectResponse = await axios.get( + `/api/v1/project/${projectId}`, + { headers } + ); + + const { fillThresholds, batteryThresholds } = projectResponse.data.project.preferences; + + setNearlyFullCardData((prev) => { + const count = updatedTrashbinData.reduce( + (acc, item) => (item.fillLevel > fillThresholds[1] ? acc + 1 : acc), + 0 + ); + return { ...prev, amount: count.toString() }; + }); + + setLowBatteryCardData((prev) => { + const count = updatedTrashbinData.reduce( + (acc, item) => (item.batteryLevel < batteryThresholds[1] ? acc + 1 : acc), + 0 + ); + return { ...prev, amount: count.toString() }; + }); + + setBrokenSensorsCardData((prev) => { + const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000); + const count = updatedTrashbinData.filter( + (item) => new Date(item.updatedAt) < threeDaysAgo + ).length; + return { ...prev, amount: count.toString() }; + }); + } catch (error) { + console.error("Error fetching data:", error); + if (axios.isAxiosError(error) && error.response?.status === 401) { + redirect('/login'); + } + } + }; + + fetchData(); +}, []); + + return ( -
- -
- -

Distribution of fill levels of all bins

- -
+
+ {/* Translated title */} + +
-

Distribution of battery levels of all bins

- +

+ {t("menu.Distribution of fill levels of all bins")} +

+
- {cardData.map((d, i) => ( - - ))} -
+ {[totalCardData, nearlyFullCardData, lowBatteryCardData, brokenSensorsCardData].map( + (cardData, index) => ( + + ) + )} +
+
); } diff --git a/app/projects/[city]/trash/route/page.tsx b/app/projects/[city]/trash/route/page.tsx index 66ee548..4a49f72 100644 --- a/app/projects/[city]/trash/route/page.tsx +++ b/app/projects/[city]/trash/route/page.tsx @@ -2,7 +2,8 @@ import React, { useState, useCallback, useEffect } from 'react'; import { CopyToClipboard } from 'react-copy-to-clipboard'; -import axios from 'axios'; +import axios from "axios"; +//import { Map } from '@/components/Map'; import { LatLngTuple } from 'leaflet'; import PageTitle from "@/components/PageTitle"; import Map from "@/components/Map"; @@ -14,12 +15,8 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f import LoadingComponent from '@/components/LoadingComponent'; import { Trashbin } from '@/app/types'; import { Copy, Info } from 'lucide-react'; -// import { Input } from "@/components/ui/input"; -// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; - -// Bins currently always assigned to a single collector -// Treated like a boolean for now: assigned or not assigned -const COLLECTOR_ID = "668e6bc9e921750c7a2fe090"; +import {useTranslation} from '@/lib/TranslationContext' +import { useRouter } from "next/navigation"; const headerSortButton = (column: any, displayname: string) => { return ( @@ -56,11 +53,14 @@ const columns: ColumnDef[] = [ ]; // TODO: We need to host our own OSRM server for production -const OSRM_SERVER_URL = 'http://router.project-osrm.org'; +const OSRM_SERVER_URL = 'https://router.project-osrm.org'; const RoutePlanning = () => { // Bins selected by user by clicking on map or table-row const [selectedBins, setSelectedBins] = useState([]); + const [trashbinData, setTrashbinData] = useState([]); + const [initialTrashbinData, setInitialTrashbinData] = useState([]); + // Optimized order of bins based on route optimization const [optimizedBins, setOptimizedBins] = useState([]); // Whether to show the optimized route on the map @@ -72,12 +72,14 @@ const RoutePlanning = () => { // Dialog state for showing the GoogleMaps link const [isDialogOpen, setIsDialogOpen] = useState(false); // Trashbin data fetched from the our backend - const [trashbinData, setTrashbinData] = useState([]); const [centerCoordinates, setCenterCoordinates] = useState(null); const [startEndCoordinates, setStartEndCoordinates] = useState(null); const [initialZoom, setInitialZoom] = useState(null); const [fillThresholds, setFillThresholds] = useState<[number, number] | null>(null); const [batteryThresholds, setBatteryThresholds] = useState<[number, number] | null>(null); + const router = useRouter(); + + const { t } = useTranslation(); useEffect(() => { const fetchData = async () => { @@ -86,31 +88,25 @@ const RoutePlanning = () => { const projectId = localStorage.getItem("projectId"); const allTrashbinsResponse = await axios.get( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/trashbin?project=${projectId}`, + `/api/v1/trashbin?project=${projectId}`, { headers: { Authorization: `Bearer ${token?.replace(/"/g, "")}`, }, } ); + const allTrashbins = allTrashbinsResponse.data.trashbins; const transformedTrashbinData: Trashbin[] = allTrashbinsResponse.data.trashbins; - const assignedTrashbinsResponse = await axios.get( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/trash-collector/${COLLECTOR_ID}/trashbins`, - { - headers: { - Authorization: `Bearer ${token?.replace(/"/g, "")}`, - }, - } - ); - const assignedTrashbins = assignedTrashbinsResponse.data.assignedTrashbins; + const assignedTrashbins = allTrashbins; const unassignedTrashbins = transformedTrashbinData.filter((bin) => !assignedTrashbins.some((assignedBin: Trashbin) => assignedBin._id === bin._id)); - setTrashbinData(unassignedTrashbins); + setInitialTrashbinData(allTrashbins); + setTrashbinData(allTrashbins); const projectResponse = await axios.get( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/project/${projectId}`, + `/api/v1/project/${projectId}`, { headers: { Authorization: `Bearer ${token?.replace(/"/g, "")}`, @@ -128,17 +124,22 @@ const RoutePlanning = () => { setBatteryThresholds(preferences.batteryThresholds); } catch (error) { console.error("Error fetching data:", error); + if (axios.isAxiosError(error) && error.response?.status === 401) { + router.push('/login'); + } } }; fetchData(); - }, []); + }, [router]); // Add trashbin if not already selected, otherwise remove it const handleTrashbinClick = useCallback((trashbin: Trashbin) => { setSelectedBins((prevSelected) => { + if (prevSelected.some((bin) => bin.identifier === trashbin.identifier)) return prevSelected.filter((bin) => bin.identifier !== trashbin.identifier); else return [...prevSelected, trashbin]; }); + return true; }, []); // Fetch optimized route from OSRM Trip Service @@ -220,76 +221,33 @@ const RoutePlanning = () => { }; // Assigns currently selected bins to a collector - const assignRoute = async () => { - // If no bins are selected, we cannot assign a route - if (selectedBins.length === 0) return; - - const token = localStorage.getItem("authToken"); - - // Get the currently assigned bins - const assignedTrashbinsResponse = await axios.get( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/trash-collector/${COLLECTOR_ID}/trashbins`, - { - headers: { - Authorization: `Bearer ${token?.replace(/"/g, "")}`, - }, - } - ); - const assignedTrashbins = assignedTrashbinsResponse.data.assignedTrashbins; - - // Create the union of the currently assigned bins and the selected bins - const allAssignedBins = [...assignedTrashbins, ...selectedBins]; - - try { - const response = await axios.post( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/trash-collector/assign`, - { - trashCollector: COLLECTOR_ID, - assignedTrashbins: allAssignedBins.map(bin => bin._id), - }, - { - headers: { - Authorization: `Bearer ${token?.replace(/"/g, "")}`, - }, - } - ); - - // Reload the page to not show assigned bins anymore - if (response.status === 200) { - location.reload(); - } - } catch (error) { - console.error('Error while assigning route:', error); - } - } - + const removeBins = () => { + if (selectedBins.length === 0) return; // No bins to remove + // Filter out the selected bins from trashbinData + + const updatedTrashbinData = trashbinData.filter( + (bin) => !selectedBins.some((selectedBin) => selectedBin._id === bin._id) + ); + // Update the state to reflect the removal + setTrashbinData(updatedTrashbinData); + // Optionally, clear the selected bins to reset selection + setShowRoute(false) + setSelectedBins([]); + }; + // Unassigns all bins - const unassignAllBins = async () => { - - try { - const token = localStorage.getItem("authToken"); - const response = await axios.post( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/trash-collector/assign`, - { - trashCollector: COLLECTOR_ID, - assignedTrashbins: [], - }, - { - headers: { - Authorization: `Bearer ${token?.replace(/"/g, "")}`, - }, - } - ); - - // Reload the page to show all bins again - if (response.status === 200) { - location.reload(); - } - } catch (error) { - console.error('Error while assigning route:', error); - } - } - + const showAllBins = () => { + // Check if trashbinData already contains all bins + if (trashbinData.length === initialTrashbinData.length) { + + return; + } + // Update the state to reflect all bins + setTrashbinData(initialTrashbinData); + // Optionally, clear the selected bins to reset selection + setSelectedBins([]); + }; + // Handle the animation for the copy button const handleCopy = () => { // Find the button by its ID and add the effect class @@ -303,102 +261,83 @@ const RoutePlanning = () => { return (
- +
-

Select the trashbins to be considered for a route by clicking on the trashbins on the map or table

+

{t("menu.select_bins_instruction")}

-
- - - - +
+ + + +
{/* Only render the tabs when all information was fetched */} - { centerCoordinates && initialZoom && fillThresholds && batteryThresholds && startEndCoordinates ? + {centerCoordinates && initialZoom && fillThresholds && batteryThresholds && startEndCoordinates ? ( - Map View - Table View + + {t("menu.map_view")} + + + {t("menu.table_view")} +
- + ` +
- +
-
: - - } - {/* Commented out, as options are not supported yet */} - {/*
-

Options

-
-

Assignee:

- -
-
-

Time Constraint:

- -

Minutes

-
-
-

Optimization Criterion:

- -
-
*/} + + ) : ( + + )} - Google Maps Link + {t("menu.google_maps_link")}
- + {googleMapsLink}
@@ -407,10 +346,10 @@ const RoutePlanning = () => { - Copy + {t("menu.copy")}
-
+
@@ -418,4 +357,5 @@ const RoutePlanning = () => { ); }; + export default RoutePlanning; diff --git a/app/projects/[city]/trash/settings/page.tsx b/app/projects/[city]/trash/settings/page.tsx index 6636abc..e547b9a 100644 --- a/app/projects/[city]/trash/settings/page.tsx +++ b/app/projects/[city]/trash/settings/page.tsx @@ -5,6 +5,9 @@ import axios from "axios"; import PageTitle from "@/components/PageTitle"; import LoadingComponent from "@/components/LoadingComponent"; import { Info } from "lucide-react"; +import {useTranslation} from '@/lib/TranslationContext' +import { useRouter } from "next/navigation"; + export default function ProjectSettings() { const [mapCenterCoordinates, setMapCenterCoordinates] = useState<[string, string]>(["0", "0"]); @@ -16,6 +19,8 @@ export default function ProjectSettings() { const [loading, setLoading] = useState(true); const [updated, setUpdated] = useState(false); const [errors, setErrors] = useState({ mapCenter: "", startEnd: "", zoomLevel: "", fillLevelInterval: "", fillThresholds: "", batteryThresholds: ""}); + const { t } = useTranslation(); + const router = useRouter(); useEffect(() => { const fetchData = async () => { @@ -24,7 +29,7 @@ export default function ProjectSettings() { const projectId = localStorage.getItem("projectId"); const projectResponse = await axios.get( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/project/${projectId}`, + `/api/v1/project/${projectId}`, { headers: { Authorization: `Bearer ${token?.replace(/"/g, "")}`, @@ -44,10 +49,13 @@ export default function ProjectSettings() { setLoading(false); } catch (error) { console.error("Error fetching data:", error); + if (axios.isAxiosError(error) && error.response?.status === 401) { + router.push('/login'); + } } }; fetchData(); - }, []); + }, [router]); const handleMapCenterCoordinateChange = (key: string, value: string): void => { // Check if value contains something else than numbers or dots @@ -171,13 +179,27 @@ export default function ProjectSettings() { setErrors(newErrors); return; } - + try{ + const token = localStorage.getItem("authToken"); + const response = await axios.put( + `/api/v1/trashbin/updateFillLevelChanges`, // Adjust the endpoint URL + { hours: Number(fillLevelInterval) }, // Pass user input as 'hours' + { + headers: { + Authorization: `Bearer ${token?.replace(/"/g, "")}`, + "Content-Type": "application/json", + }, + } + ); + } catch (error) { + console.error("Error updating fill-level changes:", error); + alert("Failed to update fill-level changes."); + } try { const token = localStorage.getItem("authToken"); const projectId = localStorage.getItem("projectId"); - await axios.patch( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/project/${projectId}`, + `/api/v1/project/${projectId}`, { centerCoords: [mapCenterCoordinates[0], mapCenterCoordinates[1]], startEndCoords: [startEndCoordinates[0], startEndCoordinates[1]], @@ -194,7 +216,7 @@ export default function ProjectSettings() { }, } ); - setUpdated(true); + setUpdated(true); } catch (error) { console.error("Error updating settings:", error); } @@ -215,14 +237,15 @@ export default function ProjectSettings() { return (
- +
+ {/* Map Center Coordinates */}
- + - The maps are centered on this coordinate. + {t("menu.coordinates_city_center_info")}
@@ -230,23 +253,25 @@ export default function ProjectSettings() { type="text" value={mapCenterCoordinates[0]} onChange={(e) => handleMapCenterCoordinateChange("latitude", e.target.value)} - className="border border-gray-300 rounded px-3 py-2 w-[200px] mr-2" + className="border border-gray-300 rounded px-3 py-2 w-[200px] mr-2 bg-white text-black dark:bg-gray-800 dark:text-white" /> handleMapCenterCoordinateChange("longitude", e.target.value)} - className="border border-gray-300 rounded px-3 py-2 w-[200px]" + className="border border-gray-300 rounded px-3 py-2 w-[200px] bg-white text-black dark:bg-gray-800 dark:text-white" />
- {errors.mapCenter &&

{errors.mapCenter}

} + {errors.mapCenter &&

{t(errors.mapCenter)}

}
+ + {/* Start-End Coordinates */}
- + - The route planning takes these coordinates as start and end point. + {t("menu.coordinates_depot_info")}
@@ -254,119 +279,131 @@ export default function ProjectSettings() { type="text" value={startEndCoordinates[0]} onChange={(e) => handleStartEndCoordinateChange("latitude", e.target.value)} - className="border border-gray-300 rounded px-3 py-2 w-[200px] mr-2" + className="border border-gray-300 rounded px-3 py-2 w-[200px] mr-2 bg-white text-black dark:bg-gray-800 dark:text-white" /> handleStartEndCoordinateChange("longitude", e.target.value)} - className="border border-gray-300 rounded px-3 py-2 w-[200px]" + className="border border-gray-300 rounded px-3 py-2 w-[200px] bg-white text-black dark:bg-gray-800 dark:text-white" />
- {errors.startEnd &&

{errors.startEnd}

} + {errors.startEnd &&

{t(errors.startEnd)}

}
+ + {/* Zoom Level */}
- + - Factor how much the map is zoomed in. + {t("menu.zoom_level_info")}
setZoomLevel(e.target.value)} - className="border border-gray-300 rounded px-3 py-2 w-[100px]" - /> - {errors.zoomLevel &&

{errors.zoomLevel}

} + className="border border-gray-300 rounded px-3 py-2 w-[100px] bg-white text-black dark:bg-gray-800 dark:text-white" + /> + {errors.zoomLevel &&

{t(errors.zoomLevel)}

}
+ + {/* Fill Level Interval */}
- + - Over how many hours the fill level change will be computed. + {t("menu.fill_level_interval_info")}
setFillLevelInterval(e.target.value)} - className="border border-gray-300 rounded px-3 py-2 w-[100px]" + className="border border-gray-300 rounded px-3 py-2 w-[100px] bg-white text-black dark:bg-gray-800 dark:text-white" /> - {errors.fillLevelInterval &&

{errors.fillLevelInterval}

} + {errors.fillLevelInterval &&

{t(errors.fillLevelInterval)}

}
+ + {/* Fill Level Thresholds */}
- + - Basis for color coding throughout the dashboard. + {t("menu.fill_level_thresholds_info")}
-
+
setFillThresholds([e.target.value, fillThresholds[1]])} - className="border border-gray-300 rounded-l px-3 py-2 w-1/5" + className="border border-gray-300 rounded-l px-3 py-2 w-1/5 bg-white text-black dark:bg-gray-800 dark:text-white" /> -
+
setFillThresholds([fillThresholds[0], e.target.value])} - className="border border-gray-300 rounded-r px-3 py-2 w-1/5" + className="border border-gray-300 rounded-r px-3 py-2 w-1/5 bg-white text-black dark:bg-gray-800 dark:text-white" /> -
+
- {errors.fillThresholds &&

{errors.fillThresholds}

} + {errors.fillThresholds &&

{t(errors.fillThresholds)}

}
+ + {/* Battery Level Thresholds */}
- + - Basis for color coding throughout the dashboard. + {t("menu.battery_level_thresholds_info")}
-
+
setBatteryThresholds([e.target.value, batteryThresholds[1]])} - className="border border-gray-300 rounded-l px-3 py-2 w-1/5" + className="border border-gray-300 rounded-l px-3 py-2 w-1/5 bg-white text-black dark:bg-gray-800 dark:text-white" /> -
+
setBatteryThresholds([batteryThresholds[0], e.target.value])} - className="border border-gray-300 rounded-r px-3 py-2 w-1/5" + className="border border-gray-300 rounded-r px-3 py-2 w-1/5 bg-white text-black dark:bg-gray-800 dark:text-white" /> -
+
- {errors.batteryThresholds &&

{errors.batteryThresholds}

} + {errors.batteryThresholds &&

{t(errors.batteryThresholds)}

}
+ + {/* Save and Cancel Buttons */}
); + } diff --git a/app/projects/[city]/trash/trashbins/[identifier]/edit/page.tsx b/app/projects/[city]/trash/trashbins/[identifier]/edit/page.tsx index af990e8..036986f 100644 --- a/app/projects/[city]/trash/trashbins/[identifier]/edit/page.tsx +++ b/app/projects/[city]/trash/trashbins/[identifier]/edit/page.tsx @@ -7,6 +7,9 @@ import PageTitle from "@/components/PageTitle"; import { Button } from "@/components/ui/button"; import LoadingComponent from "@/components/LoadingComponent"; import { Info } from "lucide-react"; +import { useTranslation } from '@/lib/TranslationContext'; +import { useRouter } from "next/navigation"; + const EditTrashbinPage = ({ params }: { params: { identifier: string } }) => { type TrashBinUpdate = { @@ -19,13 +22,15 @@ const EditTrashbinPage = ({ params }: { params: { identifier: string } }) => { }; const [trashbin, setTrashbin] = useState(null); const [errors, setErrors] = useState({ name: "", coordinates: "", location: "", image: "" }); + const { t } = useTranslation(); + const router = useRouter(); useEffect(() => { const fetchData = async () => { try { const token = localStorage.getItem("authToken"); const response = await axios.get( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/trashbin/${params.identifier}`, + `/api/v1/trashbin/${params.identifier}`, { headers: { Authorization: `Bearer ${token?.replace(/"/g, "")}`, @@ -36,11 +41,14 @@ const EditTrashbinPage = ({ params }: { params: { identifier: string } }) => { setTrashbin(response.data); } catch (error) { console.error("Error fetching data:", error); + if (axios.isAxiosError(error) && error.response?.status === 401) { + router.push('/login'); + } } }; fetchData(); - }, [params.identifier]); + }, [params.identifier,router]); const goBack = () => { window.history.back(); @@ -101,7 +109,7 @@ const EditTrashbinPage = ({ params }: { params: { identifier: string } }) => { }; await axios.patch( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/trashbin/${trashbin._id}`, + `/api/v1/trashbin/${trashbin._id}`, payload, { headers: { @@ -120,11 +128,11 @@ const EditTrashbinPage = ({ params }: { params: { identifier: string } }) => { return (
+ title={`${t("editTrashbin.title")} ${trashbin?.name || "Unknown"} (${trashbin?.identifier || "Unknown"})`} +/>
- + {
- + - Latitude and longitude are the first entry in the list when - right-clicking on the map in Google Maps. + + {t("editTrashbin.coordinatesTooltip")} +
@@ -177,10 +186,12 @@ const EditTrashbinPage = ({ params }: { params: { identifier: string } }) => { step="any" />
- {errors.coordinates && (

{errors.coordinates}

)} + {errors.coordinates && ( +

{errors.coordinates}

+ )}
- + { } className="border border-gray-300 rounded px-3 py-2 w-[300px] mr-2" /> - {errors.name &&

{errors.name}

} + {errors.location &&

{errors.location}

}
- + { } className="border border-gray-300 rounded px-3 py-2 w-[600px] mr-2" /> - {errors.location &&

{errors.location}

} + {errors.image &&

{errors.image}

}
); }; - export default EditTrashbinPage; diff --git a/app/projects/[city]/trash/trashbins/[identifier]/page.tsx b/app/projects/[city]/trash/trashbins/[identifier]/page.tsx index f75b81f..3388e2c 100644 --- a/app/projects/[city]/trash/trashbins/[identifier]/page.tsx +++ b/app/projects/[city]/trash/trashbins/[identifier]/page.tsx @@ -12,6 +12,9 @@ import { Button } from "@/components/ui/button"; import LineChart from "@/components/LineChart"; import LoadingComponent from "@/components/LoadingComponent"; import { Trashbin } from '@/app/types'; +import { io, Socket } from 'socket.io-client'; +import { useTranslation } from "@/lib/TranslationContext"; +import { useRouter } from "next/navigation"; interface HistoryDataItem { timestamp: Date; @@ -41,6 +44,57 @@ export default function TrashbinDetail({ const [fillLevelData, setFillLevelData] = useState([]); const [batteryLevelData, setBatteryLevelData] = useState([]); const [history, setHistory] = useState([]); + const [socket, setSocket] = useState(null); + const { t } = useTranslation(); + const router = useRouter(); + + + useEffect(() => { + if (!socket) { + const newSocket: Socket = io(`${process.env.NEXT_PUBLIC_BACKEND_URL}`); + + newSocket.on('newData', (data) => { + if(data.message.fill_level) { + let adjustedFillLevel = (data.message.fill_level<=1) ? data.message.fill_level*100 : data.message.fill_level; + setTrashbinData(trashbinData => { + if(trashbinData && trashbinData.sensors.includes(data.message.sensor_id)) { + return { + ...trashbinData, + fillLevel: adjustedFillLevel, + }; + } + return trashbinData; + }); + setFillLevelData(fillLevelData => [...fillLevelData,{'timestamp':new Date(data.message.received_at), 'measurement':adjustedFillLevel}]); + } + if(data.message.battery_level) { + let adjustedBatteryLevel = (data.message.battery_level<=1) ? data.message.battery_level*100 : data.message.battery_level; + setTrashbinData(trashbinData => { + if(trashbinData && trashbinData.sensors.includes(data.message.sensor_id)) { + return { + ...trashbinData, + batteryLevel: adjustedBatteryLevel, + }; + } + return trashbinData; + }); + setBatteryLevelData(batteryLevelData => [...batteryLevelData,{'timestamp':new Date(data.message.received_at), 'measurement':adjustedBatteryLevel}]); + } + // TODO: signal_level + + console.log('Received new data:', data); + }); + + setSocket(newSocket); + } + + return () => { + if (socket) { + socket.close(); + } + }; + }, [socket]); + useEffect(() => { const fetchData = async () => { @@ -49,7 +103,7 @@ export default function TrashbinDetail({ // Fetch trashbin data const response = await axios.get( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/trashbin/${params.identifier}`, + `/api/v1/trashbin/${params.identifier}`, { headers: { Authorization: `Bearer ${token?.replace(/"/g, "")}`, @@ -61,7 +115,7 @@ export default function TrashbinDetail({ // Fetch project settings const projectId = localStorage.getItem("projectId"); const projectResponse = await axios.get( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/project/${projectId}`, + `/api/v1/project/${projectId}`, { headers: { Authorization: `Bearer ${token?.replace(/"/g, "")}`, @@ -74,16 +128,17 @@ export default function TrashbinDetail({ // Sensor IDs of the trashbin const sensorIds = response.data.sensors; // Fetch history data of fill level and battery level + // TODO: this is kind of a shitty solution. Maybe we should first determine what measureType a sensorId has and then do the request // Fetch first history data const historyResponse0 = await axios.get( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/history/sensor/${sensorIds[0]}`, + `/api/v1/history/sensor/${sensorIds[0]}`, { headers: { Authorization: `Bearer ${token?.replace(/"/g, "")}`, }, } ); - if (historyResponse0.data) { + if (historyResponse0.data[0]) { const measurements = historyResponse0.data.map((item: any) => ({ timestamp: new Date(item.createdAt), measurement: item.measurement, @@ -97,17 +152,20 @@ export default function TrashbinDetail({ if (measureType === "battery_level") { setBatteryLevelData(measurements); } + } else { + setFillLevelData([]); + setBatteryLevelData([]); } // Fetch second history data const historyResponse1 = await axios.get( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/history/sensor/${sensorIds[1]}`, + `/api/v1/history/sensor/${sensorIds[1]}`, { headers: { Authorization: `Bearer ${token?.replace(/"/g, "")}`, }, } ); - if (historyResponse1.data) { + if (historyResponse1.data[0]) { const measurements = historyResponse1.data.map((item: any) => ({ timestamp: new Date(item.createdAt), measurement: item.measurement, @@ -120,14 +178,46 @@ export default function TrashbinDetail({ if (measureType === "battery_level") { setBatteryLevelData(measurements); } + } else { + setFillLevelData([]); + setBatteryLevelData([]); + } + // Fetch third history data + const historyResponse2 = await axios.get( + `/api/v1/history/sensor/${sensorIds[2]}`, + { + headers: { + Authorization: `Bearer ${token?.replace(/"/g, "")}`, + }, + } + ); + if (historyResponse2.data[0]) { + const measurements = historyResponse2.data.map((item: any) => ({ + timestamp: new Date(item.createdAt), + measurement: item.measurement, + })); + const measureType = historyResponse2.data[0].measureType; + + if (measureType === "fill_level") { + setFillLevelData(measurements); + } + if (measureType === "battery_level") { + setBatteryLevelData(measurements); + } + } else { + setFillLevelData([]); + setBatteryLevelData([]); } } catch (error) { console.error("Error fetching data:", error); + if (axios.isAxiosError(error) && error.response?.status === 401) { + router.push('/login'); + } } }; fetchData(); - }, [params.identifier]); + }, [params.identifier,router]); // Combine fillLevel and batteryLevel data to one object to display in the table useEffect(() => { @@ -140,7 +230,6 @@ export default function TrashbinDetail({ const roundedSeconds = seconds + (n - (seconds % n)); return new Date(roundedSeconds * 1000); }; - const roundedFillLevelData = fillLevelData.map(item => ({ timestamp: roundToNextNSeconds(item.timestamp, n), measurement: item.measurement, @@ -177,54 +266,59 @@ export default function TrashbinDetail({ const type = localStorage.getItem("projectType"); return `/projects/${city}/${type}/trashbins/${params.identifier}/edit`; } - return (
- + +
- { (fillLevelData.length === 0 && batteryLevelData.length === 0) ? + {fillLevelData.length === 0 && batteryLevelData.length === 0 ? (
- + -
: +
+ ) : ( - Graphical View + {t("trashbin.tabs.graphicalView")} - Table View + {t("trashbin.tabs.tableView")} -
- { fillLevelData.length > 0 && - <> -

Fill Level

+
+ {fillLevelData.length > 0 && ( + +

{t("trashbin.fillLevel")}

-
- } - { batteryLevelData.length > 0 && - <> -

Battery Level

- + red={[fillThresholds[1], 100]} + /> +
+ )} + {batteryLevelData.length > 0 && ( + +

{t("trashbin.batteryLevel")}

-
- } + + )}
@@ -238,23 +332,36 @@ export default function TrashbinDetail({
- } + )}
-
-

Location: {trashbinData.location} ({trashbinData.coordinates[0]}, {trashbinData.coordinates[1]})

- -
-

Last Emptied: {trashbinData.lastEmptied ? trashbinData.lastEmptied.toString() : "N/A"}

-

Signal Strength: {trashbinData.signalStrength}

-
- +
+

+ {t("trashbin.location")}:{" "} + {trashbinData.location} ({trashbinData.coordinates[0]},{" "} + {trashbinData.coordinates[1]}) +

+ +
+

+ {t("trashbin.lastEmptied")}:{" "} + {trashbinData.lastEmptied + ? trashbinData.lastEmptied.toString() + : t("trashbin.notAvailable")} +

+

+ {t("trashbin.signalStrength")}:{" "} + {trashbinData.signalStrength} +

+
); -} +}; + + diff --git a/app/projects/[city]/trash/trashbins/page.tsx b/app/projects/[city]/trash/trashbins/page.tsx index 99da908..810f6e1 100644 --- a/app/projects/[city]/trash/trashbins/page.tsx +++ b/app/projects/[city]/trash/trashbins/page.tsx @@ -8,11 +8,10 @@ import { DataTable } from "@/components/DataTable"; import { ColumnDef } from "@tanstack/react-table"; import { Button } from "@/components/ui/button"; import { Trashbin } from '@/app/types'; +import { io, Socket } from 'socket.io-client'; // Bins currently always assigned to a single collector // Treated like a boolean for now: assigned or not assigned -const COLLECTOR_ID = "668e6bc9e921750c7a2fe090"; - const headerSortButton = (column: any, displayname: string) => { return (
); } diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 8e26058..eac8b0c 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { Button } from "@/components/ui/button"; import PageTitle from '@/components/PageTitle'; +import { useTranslation } from '@/lib/TranslationContext'; // import { Label } from "@/components/ui/label"; // import { Input } from "@/components/ui/input"; // import { Textarea } from "@/components/ui/textarea"; @@ -11,70 +12,75 @@ import PageTitle from '@/components/PageTitle'; // import { useTheme } from "next-themes"; // https://github.com/pacocoursey/next-themes export default function Component() { - // const [language, setLanguage] = useState('de'); - // const [theme, setTheme] = useState('light'); - + const { t } = useTranslation(); // Use translation hook for localization const handleLogout = () => { window.location.href = '/login'; // Redirect to the login page (where the authToken is cleared) } - // TODO: Make it more robust by fetching the email by API, not storing it in local storage + // TODO: Make it more robust by fetching the name by API, not storing it in local storage const getEmail = (): string => { if (typeof window !== 'undefined') { - const email = localStorage.getItem('email'); // Safe to use localStorage here - if (email) return email; + const name = localStorage.getItem('name'); // Safe to use localStorage here + if (name) return name; } - return 'Email not found'; + return 'name'; }; return (
- -

Language and theme settings coming soon!

+

+ {t("page.comingSoon")} +

{/*
-

Language

-

Choose your preferred language

+

{t("page.language.title")}

+

{t("page.language.description")}

- // Comment: Radio button style: https://tailwindcomponents.com/component/radio-buttons-1
- setLanguage('de')} /> - -
-
- setLanguage('en')} /> - -
-
-
-
*/} - {/* - -
-

Theme

-

Choose your preferred theme

-
-
- -
-
- setTheme('light')} /> -
- setTheme('dark')} /> -
@@ -84,3 +90,4 @@ export default function Component() {
); } + diff --git a/app/types.ts b/app/types.ts index f4dc438..cbe5b19 100644 --- a/app/types.ts +++ b/app/types.ts @@ -1,4 +1,5 @@ type Trashbin = { + updatedAt: string | number | Date; _id: string; identifier: string; coordinates: [number, number]; @@ -10,6 +11,7 @@ type Trashbin = { signalStrength: number; image: string; lastEmptied: Date; + sensors: string[]; }; export type { Trashbin }; diff --git a/components/Card.tsx b/components/Card.tsx index d94d421..450694f 100644 --- a/components/Card.tsx +++ b/components/Card.tsx @@ -7,7 +7,7 @@ import { cn } from "@/lib/utils"; export type CardProps = { label: string; amount: string; - discription: string; + description: string; }; export default function Card(props: CardProps) { @@ -18,7 +18,7 @@ export default function Card(props: CardProps) {

{props.amount}

-

{props.discription}

+

{props.description}

); diff --git a/components/DataTable.tsx b/components/DataTable.tsx index 2957cce..39643d5 100644 --- a/components/DataTable.tsx +++ b/components/DataTable.tsx @@ -27,6 +27,7 @@ import { import { Button } from "./ui/button"; import { Input } from "@/components/ui/input"; import { Info, ArrowUpDown, MoveUp, MoveDown } from "lucide-react"; +import { useTranslation } from "@/lib/TranslationContext"; interface DataTableProps { columns: ColumnDef[]; @@ -46,6 +47,7 @@ export function DataTable({ selectedRows, }: DataTableProps) { const [sorting, setSorting] = React.useState([]); + const { t } = useTranslation(); const [columnFilters, setColumnFilters] = React.useState( [] ); @@ -110,7 +112,7 @@ export function DataTable({ document.body.removeChild(a); }; - return ( + return (
{showSearchBar && (
diff --git a/components/Heatmap/ColorLegend.tsx b/components/Heatmap/ColorLegend.tsx index 24bf487..f480293 100644 --- a/components/Heatmap/ColorLegend.tsx +++ b/components/Heatmap/ColorLegend.tsx @@ -5,6 +5,7 @@ type ColorLegendProps = { height: number; width: number; colorScale: d3.ScaleLinear; + thresholds: number[] }; const COLOR_LEGEND_MARGIN = { top: 0, right: 0, bottom: 40, left: 0 }; @@ -13,6 +14,7 @@ export const ColorLegend = ({ height, colorScale, width, + thresholds }: ColorLegendProps) => { const canvasRef = useRef(null); @@ -22,10 +24,10 @@ export const ColorLegend = ({ height - COLOR_LEGEND_MARGIN.top - COLOR_LEGEND_MARGIN.bottom; const domain = colorScale.domain(); - const max = domain[domain.length - 1]; - const xScale = d3.scaleLinear().range([0, boundsWidth]).domain([0, 10]); + const max = domain[domain.length-1]; + const xScale = d3.scaleLinear().range([0, boundsWidth]).domain([0, thresholds[5]]); - const allTicks = xScale.ticks(4).map((tick, index) => { + const allTicks = xScale.ticks(thresholds.filter((value, index, array) => array.indexOf(value) === index).length-1).map((tick, index) => { return ( - {tick === 10 ? "10+" : tick} + {tick} ); diff --git a/components/Heatmap/Heatmap.tsx b/components/Heatmap/Heatmap.tsx index f8a9ef7..8966db1 100644 --- a/components/Heatmap/Heatmap.tsx +++ b/components/Heatmap/Heatmap.tsx @@ -8,7 +8,7 @@ import { COLOR_LEGEND_HEIGHT } from "./constants"; import { ColorLegend } from "./ColorLegend"; import * as d3 from "d3"; import { COLORS, MARGIN, THRESHOLDS } from "./constants"; - +import { useTheme } from "@/lib/ThemeContext"; // Theme hook type HeatmapProps = { data: { @@ -26,7 +26,7 @@ export type InteractionData = { value: number | null; }; -const YAxis = ({ yGroups, height }: { yGroups: string[], height: number }) => { +const YAxis = ({ yGroups = [], height, isDarkMode }: { yGroups: string[]; height: number; isDarkMode: boolean}) => { const yScale = d3 .scaleBand() .range([0, height - MARGIN.top - MARGIN.bottom]) @@ -34,18 +34,19 @@ const YAxis = ({ yGroups, height }: { yGroups: string[], height: number }) => { .padding(0.1); const yLabels = yGroups.map((name, i) => { - const yPos = yScale(name); // Calculate yPos using yScale with the current group name + const yPos = yScale(name); if (yPos === undefined) return null; // Guard against undefined yPos - const displayText = `${Number(name)-10}-${name}%`; + const displayText = `${Number(name) - 25}-${name}%`; return ( {displayText} @@ -61,13 +62,24 @@ const YAxis = ({ yGroups, height }: { yGroups: string[], height: number }) => { ); }; + export const Heatmap = ({ data }: HeatmapProps) => { const [hoveredCell, setHoveredCell] = useState(null); + const [scaleBandWidth, setScaleBandWidth] = useState(0); const scrollableDivRef = useRef(null); - const colorScale = d3.scaleLinear() - .domain(THRESHOLDS) - .range(COLORS); + const maxAmount = data.length ? Math.max(...data.map((obj) => obj.amount)) : 0; +const thresholds = maxAmount + ? [0, Math.ceil(maxAmount / 5), Math.ceil((maxAmount * 2) / 5), Math.ceil((maxAmount * 3) / 5), Math.ceil((maxAmount * 4) / 5), Math.ceil(maxAmount)] + : [0, 1, 2, 3, 4, 5]; +const colorScale = d3 + .scaleLinear() + .domain(thresholds) + .range(COLORS); + + //const { isDarkMode = false } = useTheme(); // Retrieve theme + const { theme, toggleTheme } = useTheme(); // Use theme directly + const isDarkMode = theme === "dark"; // Check if theme is dark mode const allYGroups = Array.from(new Set(data.map(d => d.percentage))) .sort((a, b) => b - a) @@ -75,23 +87,37 @@ export const Heatmap = ({ data }: HeatmapProps) => { // Scroll to the right when the component is mounted to see the latest data useEffect(() => { - if (scrollableDivRef.current) { - scrollableDivRef.current.scrollLeft = scrollableDivRef.current.scrollWidth + 1000; + if (!data.length) { + setScaleBandWidth(0); + return; } - }, []); - + + const dates = data.map((r) => new Date(r.time).setHours(0, 0, 0, 0)); + const startDate = new Date(Math.min(...dates)); + const endDate = new Date(Math.max(...dates)); + + // Ensure valid date range before calculating scaleBandWidth + if (!isNaN(startDate.getTime()) && !isNaN(endDate.getTime())) { + setScaleBandWidth((endDate.getTime() - startDate.getTime()) / 86400000); + } else { + setScaleBandWidth(0); + } + }, [data]); + return (
- +
- + { height={COLOR_LEGEND_HEIGHT} width={250} colorScale={colorScale} + thresholds={thresholds} />
diff --git a/components/Heatmap/HeatmapBatteryLevel.tsx b/components/Heatmap/HeatmapBatteryLevel.tsx index 8d16014..f4b295f 100644 --- a/components/Heatmap/HeatmapBatteryLevel.tsx +++ b/components/Heatmap/HeatmapBatteryLevel.tsx @@ -1,4 +1,9 @@ +import { Trashbin } from "@/app/types"; import { Heatmap } from "./Heatmap"; +import { useEffect, useState } from "react"; +import axios from "axios"; +import { useRouter } from "next/navigation"; +; type Entry = { time: number; // Unix timestamp @@ -6,41 +11,115 @@ type Entry = { amount: number; }; -function generateMockData(startTimestamp: number, endTimestamp: number): Entry[] { - const mockData: Entry[] = []; - const millisecondsPerDay = 24 * 60 * 60 * 1000; - - function skewedRandom() { - return Math.pow(Math.random(), 3); // Cubing a random number skews the distribution towards higher values - } +interface Measurement { + timestamp: Date; + measurement: number; + binName: string; + type: string; +} - for (let time = startTimestamp; time <= endTimestamp; time += millisecondsPerDay) { - const percentages = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; - let remainingAmount = 20; - for (let i = 0; i < percentages.length; i++) { - const percentage = percentages[i]; - let amount = 0; +export const HeatmapBatteryLevel: React.FC<{ trashbins: Trashbin[] }> = ({trashbins}) => { + const [realData, setRealData] = useState([]); + const router = useRouter(); + useEffect(() => { + const fetchData = async () => { + try { + const token = localStorage.getItem("authToken"); + let newMeasurements: Measurement[] = []; + if(trashbins.length>0){ + const historyPromises = trashbins.flatMap(bin => + bin.sensors.map(sensor => ({ + binIdentifier: bin.identifier, + promise: axios.get( + `/api/v1/history/sensor/${sensor}`, + { + headers: { + Authorization: `Bearer ${token?.replace(/"/g, "")}`, + }, + } + ) + })) + ); + + const history = await Promise.all(historyPromises.map(async item => ({ + binIdentifier: item.binIdentifier, + data: await item.promise + }))); - // Distribute the remaining amount over the fill levels with a skewed random value - if (i === percentages.length - 1) { - amount = remainingAmount; - } else { - amount = Math.min(Math.floor(skewedRandom() * remainingAmount), remainingAmount); - remainingAmount -= amount; + newMeasurements = history.flatMap(sensorHistories => + sensorHistories.data.data.map((sensorHistory: { createdAt: any; measurement: any; measureType: any; }) => ({ + binName: sensorHistories.binIdentifier, + timestamp: sensorHistory.createdAt, + measurement: sensorHistory.measurement, + type: sensorHistory.measureType + })) + ).filter(reading => reading.type == "battery_level");; + + } + + if(newMeasurements.length>0) { + // Generate Heatmap Data + const dates = newMeasurements.map(r => new Date(r.timestamp).setHours(23,59,59,0)); + const startDate = new Date(Math.min(...dates)); + const endDate = new Date(Math.max(...dates)); + + // Create array of all days between start and end + const dailySummaries: Entry[] = []; + const currentDate = new Date(startDate); + + while (currentDate <= endDate) { + // Get all readings before this day for each unique sensor + const relevantDate = currentDate.getTime(); + const sensorLatestReadings = new Map(); + + newMeasurements + .filter(reading => new Date(reading.timestamp).getTime() <= relevantDate) + .forEach(reading => { + // Keep only the latest reading for each sensor + sensorLatestReadings.set(reading.binName, reading.measurement); + }); + + // Count sensors in each fill range + const fills = Array.from(sensorLatestReadings.values()); + + dailySummaries.push({ + time: currentDate.getTime(), + percentage: 25, + amount: fills.filter(fill => fill >= 0 && fill < 25).length + }); + dailySummaries.push({ + time: currentDate.getTime(), + percentage: 50, + amount: fills.filter(fill => fill >= 25 && fill < 50).length + }); + dailySummaries.push({ + time: currentDate.getTime(), + percentage: 75, + amount: fills.filter(fill => fill >= 50 && fill < 75).length + }); + dailySummaries.push({ + time: currentDate.getTime(), + percentage: 100, + amount: fills.filter(fill => fill >= 75 && fill <= 100).length + }); + + // Move to next day + currentDate.setDate(currentDate.getDate() + 1); + } + setRealData(realData => [ + ...realData, + ...dailySummaries + ]) + } + } catch (error) { + console.error("Error fetching data:", error); + if (axios.isAxiosError(error) && error.response?.status === 401) { + router.push('/login'); + } } - - mockData.push({ time, percentage, amount }); - } - } - - return mockData; -} - -const startTimestamp = new Date('2024-01-01').getTime(); -const endTimestamp = new Date('2024-07-01').getTime(); -const data = generateMockData(startTimestamp, endTimestamp); - -export const HeatmapBatteryLevel = () => ( - -); + }; + fetchData(); + }, [trashbins, router]); + return +}; diff --git a/components/Heatmap/HeatmapFillLevel.tsx b/components/Heatmap/HeatmapFillLevel.tsx index 9e03439..8906be6 100644 --- a/components/Heatmap/HeatmapFillLevel.tsx +++ b/components/Heatmap/HeatmapFillLevel.tsx @@ -1,4 +1,8 @@ +import { Trashbin } from "@/app/types"; import { Heatmap } from "./Heatmap"; +import React, { useState, useEffect } from "react"; +import axios from "axios"; +import { useRouter } from "next/navigation"; type Entry = { time: number; // Unix timestamp @@ -6,41 +10,114 @@ type Entry = { amount: number; }; -function generateMockData(startTimestamp: number, endTimestamp: number): Entry[] { - const mockData: Entry[] = []; - const millisecondsPerDay = 24 * 60 * 60 * 1000; - - function skewedRandom() { - return Math.pow(Math.random(), 3); // Cubing a random number skews the distribution towards higher values - } +interface Measurement { + timestamp: Date; + measurement: number; + binName: string; + type: string; +} - for (let time = startTimestamp; time <= endTimestamp; time += millisecondsPerDay) { - const percentages = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; - let remainingAmount = 20; +export const HeatmapFillLevel: React.FC<{ trashbins: Trashbin[] }> = ({trashbins}) => { + const [measurements, setMeasurements] = useState([]); + const [realData, setRealData] = useState([]); + const router = useRouter(); + useEffect(() => { + const fetchData = async () => { + try { + const token = localStorage.getItem("authToken"); + let newMeasurements: Measurement[] = []; + if(trashbins.length>0){ + const historyPromises = trashbins.flatMap(bin => + bin.sensors.map(sensor => ({ + binIdentifier: bin.identifier, // or whatever property holds the name + promise: axios.get( + `/api/v1/history/sensor/${sensor}`, + { + headers: { + Authorization: `Bearer ${token?.replace(/"/g, "")}`, + }, + } + ) + })) + ); + + const history = await Promise.all(historyPromises.map(async item => ({ + binIdentifier: item.binIdentifier, + data: await item.promise + }))); - for (let i = 0; i < percentages.length; i++) { - const percentage = percentages[i]; - let amount = 0; + // Transform into your custom objects + newMeasurements = history.flatMap(sensorHistories => + sensorHistories.data.data.map((sensorHistory: { createdAt: any; measurement: any; measureType: any; }) => ({ + binName: sensorHistories.binIdentifier, + timestamp: sensorHistory.createdAt, + measurement: sensorHistory.measurement, + type: sensorHistory.measureType + })) + ).filter(reading => reading.type == "fill_level"); + } - // Distribute the remaining amount over the fill levels with a skewed random value - if (i === percentages.length - 1) { - amount = remainingAmount; - } else { - amount = Math.min(Math.floor(skewedRandom() * remainingAmount), remainingAmount); - remainingAmount -= amount; + if(newMeasurements.length>0) { + // Generate Heatmap Data + const dates = newMeasurements.map(r => new Date(r.timestamp).setHours(23,59,59,0)); + const startDate = new Date(Math.min(...dates)); + const endDate = new Date(Math.max(...dates)); + + // Create array of all days between start and end + const dailySummaries: Entry[] = []; + const currentDate = new Date(startDate); + + while (currentDate <= endDate) { + // Get all readings before this day for each unique sensor + const relevantDate = currentDate.getTime(); + const sensorLatestReadings = new Map(); + + newMeasurements + .filter(reading => new Date(reading.timestamp).getTime() <= relevantDate) + .forEach(reading => { + // Keep only the latest reading for each sensor + sensorLatestReadings.set(reading.binName, reading.measurement); + }); + + // Count sensors in each fill range + const fills = Array.from(sensorLatestReadings.values()); + dailySummaries.push({ + time: currentDate.getTime(), + percentage: 25, + amount: fills.filter(fill => fill >= 0 && fill < 25).length + }); + dailySummaries.push({ + time: currentDate.getTime(), + percentage: 50, + amount: fills.filter(fill => fill >= 25 && fill < 50).length + }); + dailySummaries.push({ + time: currentDate.getTime(), + percentage: 75, + amount: fills.filter(fill => fill >= 50 && fill < 75).length + }); + dailySummaries.push({ + time: currentDate.getTime(), + percentage: 100, + amount: fills.filter(fill => fill >= 75 && fill <= 100).length + }); + + // Move to next day + currentDate.setDate(currentDate.getDate() + 1); + } + setRealData(realData => [ + ...realData, + ...dailySummaries + ]); + } + } catch (error) { + console.error("Error fetching data:", error); + if (axios.isAxiosError(error) && error.response?.status === 401) { + router.push('/login'); + } } - - mockData.push({ time, percentage, amount }); - } - } - - return mockData; -} - -const startTimestamp = new Date('2023-01-01').getTime(); -const endTimestamp = new Date('2024-07-01').getTime(); -const data = generateMockData(startTimestamp, endTimestamp); - -export const HeatmapFillLevel = () => ( - -); + }; + fetchData(); + }, [trashbins, router]); + return (); +}; diff --git a/components/Heatmap/Renderer.tsx b/components/Heatmap/Renderer.tsx index 99ecce4..4417a10 100644 --- a/components/Heatmap/Renderer.tsx +++ b/components/Heatmap/Renderer.tsx @@ -15,6 +15,7 @@ type RendererProps = { data: Dataset; setHoveredCell: (hoveredCell: InteractionData | null) => void; colorScale: d3.ScaleLinear; + isDarkMode: boolean; // Dark mode added }; export const Renderer = ({ @@ -23,6 +24,7 @@ export const Renderer = ({ data, setHoveredCell, colorScale, + isDarkMode, // Added Dark mode here }: RendererProps) => { // bounds = area inside the axis const boundsWidth = width - MARGIN.right - MARGIN.left; @@ -62,6 +64,7 @@ export const Renderer = ({ } return ( + + ); }); @@ -94,7 +98,8 @@ export const Renderer = ({ dominantBaseline="middle" fontSize={10} stroke="none" - fill="black" + fill={isDarkMode ? "#FFFFFF" : "#000000"} // Dynamic color + > {name} @@ -109,6 +114,7 @@ export const Renderer = ({ height={height} onMouseLeave={() => setHoveredCell(null)} className="-ml-12" + style={{ fill: isDarkMode ? "#FFFFFF" : "#000000" }} // Set global fill for the entire SVG > { + const { t } = useTranslation(); + if (!interactionData) { return null; } @@ -15,15 +18,15 @@ export const Tooltip = ({ interactionData, width, height }: TooltipProps) => { return (
- Date: {d3.timeFormat('%Y-%m-%d')(new Date(interactionData.xLabel))} -
- {interactionData.value} bins ({Number(interactionData.yLabel) - 10}-{interactionData.yLabel}%) + {t("menu.Date")} {d3.timeFormat('%Y-%m-%d')(new Date(interactionData.xLabel))} +
+ {interactionData.value} {t("menu.bin")} ({Number(interactionData.yLabel) - 25}-{interactionData.yLabel}%)
); diff --git a/components/LanguageSwitcher.tsx b/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..65170ad --- /dev/null +++ b/components/LanguageSwitcher.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { useTranslation } from "../lib/TranslationContext"; +import { GlobeIcon } from "lucide-react"; // Import Globe icon +import axios from "axios"; // Import Axios for API calls + +export default function LanguageSwitcher() { + const { language, setLanguage } = useTranslation(); + + const toggleLanguage = async () => { + // Determine the new language + const newLanguage = language === "en" ? "de" : "en"; + + try { + // Retrieve the token and project ID from localStorage + const token = localStorage.getItem("authToken"); + const projectId = localStorage.getItem("projectId"); + const userId = localStorage.getItem("userId"); + + + if (!token || !projectId) { + console.error("Token or Project ID is missing"); + return; + } + + // Set up headers for the API request + const headers = { + Authorization: `Bearer ${token.replace(/"/g, "")}`, // Remove quotes if present + "Content-Type": "application/json", + }; + + // Make the PATCH request to update the user's preferences + const response = await axios.patch( + "/api/v1/auth/user", + { + userId: userId, // Replace with dynamic user ID if available + preferences: { + language: newLanguage.toUpperCase(), // Send "EN" or "DE" + themeIsDark: true, // Assuming this value is fixed; replace if dynamic + }, + }, + { headers } + ); + // Update the local state after a successful API call + setLanguage(newLanguage); + } catch (error: any) { + console.error( + "Error updating language preference:", + error.response?.data || error.message + ); + } + }; + + + return ( +
+
+ +
+
+ ); +} diff --git a/components/LineChart.tsx b/components/LineChart.tsx index a5a5ca4..e0f294a 100644 --- a/components/LineChart.tsx +++ b/components/LineChart.tsx @@ -1,6 +1,7 @@ import React, { useRef, useEffect, useState } from "react"; import ResizeObserver from "resize-observer-polyfill"; import * as d3 from "d3"; +import { useTheme } from "../lib/ThemeContext"; // Adjusted import path for ThemeContext interface DataItem { timestamp: Date; @@ -26,16 +27,16 @@ const LineChart: React.FC = ({ historyData, green, yellow, red } const mainChartRef = useRef(null); const scrollableDivRef = useRef(null); const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); + const { theme } = useTheme(); useEffect(() => { - const resizeObserver = new ResizeObserver(entries => { + const resizeObserver = new ResizeObserver((entries) => { if (!entries || entries.length === 0) return; const { width, height } = entries[0].contentRect; setDimensions({ width, height }); }); const currentRef = mainChartRef.current; - if (currentRef) { resizeObserver.observe(currentRef); } @@ -51,10 +52,17 @@ const LineChart: React.FC = ({ historyData, green, yellow, red } if (dimensions.width === 0 || dimensions.height === 0) return; if (!mainChartRef.current) return; - const margin = { top: 5, right: 5, bottom: 100, left: 40 }; + + const margin = { top: 5, right: 5, bottom: 130, left: 90 }; // Increase left margin + + + const height = dimensions.height - margin.top - margin.bottom; - const pointWidth = 10; - const fullWidth = pointWidth * historyData.length; + //const fullWidth = dimensions.width - margin.left - margin.right; + + + //const height = Math.max(dimensions.height - margin.top - margin.bottom, 400); // Ensure a decent height + const fullWidth = Math.max(dimensions.width - margin.left - margin.right, 800); // Ensure a minimum width d3.select(mainChartRef.current).selectAll("*").remove(); @@ -65,59 +73,63 @@ const LineChart: React.FC = ({ historyData, green, yellow, red } .append("g") .attr("transform", `translate(${margin.left},${margin.top})`); - const x = d3.scalePoint() - .domain(historyData.map(d => new Date(d.timestamp).toString())) - .range([0, fullWidth]) - .padding(0.5); + const timeDomain = d3.extent(historyData, d => d.timestamp) as [Date, Date]; + const x = d3.scaleTime() + .domain(timeDomain) + .range([0, fullWidth]); const y = d3.scaleLinear().domain([0, 100]).range([height, 0]); + const sortedData = [...historyData].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + const line = d3.line() - .x(d => x(new Date(d.timestamp).toString()) || 0) - .y(d => y(d.measurement)); + .x(d => x(d.timestamp)) + .y(d => y(d.measurement)) + .curve(d3.curveMonotoneX); const xAxis = d3.axisBottom(x) - .tickValues(x.domain().filter((_d, i) => i % 10 === 9)) - .tickFormat((domainValue: string) => d3.timeFormat('%Y-%m-%d %H:%M')(new Date(domainValue))); + .tickFormat((domainValue) => d3.timeFormat('%Y-%m-%d %H:%M')(domainValue as Date)) + .ticks(10); - svg.append('g') - .attr('transform', `translate(0,${height})`) + svg + .append("g") + .attr("transform", `translate(0,${height})`) .call(xAxis) .selectAll('text') - .attr('transform', 'rotate(-45)') + .attr('transform', 'rotate(-45)') // Rotate text to avoid overlap .style('text-anchor', 'end') .attr('dx', '-.8em'); + // Color range bands + svg.append("rect") + .attr("x", 0) + .attr("y", y(green[1])) + .attr("width", fullWidth) + .attr("height", y(green[0]) - y(green[1])) + .attr("fill", "green") + .attr("opacity", 0.15); + svg - .append("rect") - .attr("x", 0) - .attr("y", y(green[1])) - .attr("width", fullWidth) - .attr("height", y(green[0]) - y(green[1])) - .attr("fill", "green") - .attr("opacity", 0.15); - - svg - .append("rect") - .attr("x", 0) - .attr("y", y(yellow[1])) - .attr("width", fullWidth) - .attr("height", y(yellow[0]) - y(yellow[1])) - .attr("fill", "yellow") - .attr("opacity", 0.15); - - svg - .append("rect") - .attr("x", 0) - .attr("y", y(red[1])) - .attr("width", fullWidth) - .attr("height", y(red[0]) - y(red[1])) - .attr("fill", "red") - .attr("opacity", 0.15); + .append("rect") + .attr("x", 0) + .attr("y", y(yellow[1])) + .attr("width", fullWidth) + .attr("height", y(yellow[0]) - y(yellow[1])) + .attr("fill", "yellow") + .attr("opacity", 0.15); + + svg + .append("rect") + .attr("x", 0) + .attr("y", y(red[1])) + .attr("width", fullWidth) + .attr("height", y(red[0]) - y(red[1])) + .attr("fill", "red") + .attr("opacity", 0.15); svg .append("path") - .datum(historyData) + .datum(sortedData) .attr("fill", "none") .attr("stroke", "black") .attr("stroke-width", 1.5) @@ -125,17 +137,19 @@ const LineChart: React.FC = ({ historyData, green, yellow, red } svg .selectAll(".dot") - .data(historyData) + .data(sortedData) .enter() .append("circle") .attr("class", "dot") .attr("stroke", "black") .attr("fill", d => determineColor(d.measurement, green, yellow, red)) - .attr("cx", d => String(x(new Date(d.timestamp).toString()))) + .attr("cx", d => x(d.timestamp)) .attr("cy", d => y(d.measurement)) .attr("r", 3); - const tooltip = d3.select('body').append('div') + const tooltip = d3 + .select('body') + .append('div') .attr('class', 'tooltip') .style('position', 'absolute') .style('background', '#fff') @@ -145,11 +159,12 @@ const LineChart: React.FC = ({ historyData, green, yellow, red } .style('pointer-events', 'none') .style('opacity', 0); - svg.selectAll('.dot-overlay') - .data(historyData) + svg + .selectAll('.dot-overlay') + .data(sortedData) .enter().append('circle') .attr('class', 'dot-overlay') - .attr('cx', d => x(new Date(d.timestamp).toString()) ?? 0) + .attr('cx', d => x(d.timestamp)) .attr('cy', d => y(d.measurement)) .attr('r', 10) .style('opacity', 0) @@ -158,20 +173,19 @@ const LineChart: React.FC = ({ historyData, green, yellow, red } tooltip.transition() .duration(200) .style('opacity', .95); - tooltip.html(`Timestamp: ${d3.timeFormat('%Y-%m-%d %H:%M')(new Date(d.timestamp))}
Measurement: ${Math.round(d.measurement)}%`) + tooltip.html(`Timestamp: ${d3.timeFormat('%Y-%m-%d %H:%M')(d.timestamp)}
Measurement: ${Math.round(d.measurement)}%`) .style('left', `${event.pageX - 260}px`) .style('top', `${event.pageY + 10}px`); }) - .on('mouseout', () => { - tooltip.transition() - .duration(500) - .style('opacity', 0); + .on("mouseout", () => { + tooltip.transition().duration(500).style("opacity", 0); }); + // Y-Axis d3.select(yAxisRef.current).selectAll("*").remove(); const yAxis = d3.axisLeft(y) .tickValues([10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) - .tickFormat((d) => `${d}%`); + .tickFormat(d => `${d}%`); d3.select(yAxisRef.current) .attr("width", margin.left) @@ -179,29 +193,33 @@ const LineChart: React.FC = ({ historyData, green, yellow, red } .append("g") .attr("transform", `translate(${margin.left - 1},${margin.top})`) .call(yAxis); + // Remove the domain (the line) from the static y-axis + d3.select(yAxisRef.current).select(".domain").remove(); + + // Remove the static ticks (labels and tick marks) + d3.select(yAxisRef.current).selectAll(".tick").remove(); - d3.select(yAxisRef.current) - .append("text") - .attr("transform", "rotate(-90)") - .attr("y", 0 - margin.left) - .attr("x", 0 - height / 2) - .attr("dy", "1em") - .style("text-anchor", "middle") - .text("Fill Level (%)"); + + // Step 2: Render Y-Axis LINE inside the scrollable chart (Moves with scroll) + svg.append("g") + .attr("class", "y-axis") + .attr("transform", `translate(-1, 0)`) // Move slightly left for alignment + .call(yAxis); + }, [dimensions, historyData, green, yellow, red]); - // Scroll to the right when the component is mounted to see the latest data - useEffect(() => { - if (scrollableDivRef.current) { - scrollableDivRef.current.scrollLeft = scrollableDivRef.current.scrollWidth; - } - }, [dimensions]); + // Ensure scrolling behavior after chart resizing + useEffect(() => { + if (scrollableDivRef.current) { + scrollableDivRef.current.scrollLeft = scrollableDivRef.current.scrollWidth; + } + }, [dimensions]); return ( -
- -
+
{/* Increase height */} + +
diff --git a/components/LoadingComponent.tsx b/components/LoadingComponent.tsx index 419471a..8c68351 100644 --- a/components/LoadingComponent.tsx +++ b/components/LoadingComponent.tsx @@ -4,10 +4,13 @@ interface LoadingComponentProps { text?: string; } -const LoadingComponent: React.FC = ({ text = 'Loading...' }) => { +const LoadingComponent: React.FC = ({ text }) => { + // Default to "Loading" if text is not provided + const displayText = text || 'Loading...'; + return (
- {text} + {displayText}
); }; diff --git a/components/Map.tsx b/components/Map.tsx index aaabc8c..40612f7 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -53,7 +53,7 @@ function PopupContent({ trashbin, routePlanning, fillThresholds, batteryThreshol {!routePlanning && (
- {trashbin.signalStrength} + {trashbin.signalStrength}%
)}
@@ -69,25 +69,24 @@ function PopupContent({ trashbin, routePlanning, fillThresholds, batteryThreshol ); } -// Custom leaflet icons: https://leafletjs.com/examples/custom-icons/ // Bin icons based on: https://www.vecteezy.com/vector-art/7820754-recycle-icon-garbage-icon-vector-logo-design-template const createBinIcons = (L: any) => { const BinIcon = L.Icon.extend({ options: { shadowUrl: '/images/leaflet/bin_s.png', - iconSize: [26, 33], // size of icon - shadowSize: [26, 25], // size of shadow - iconAnchor: [26/2, 33/2], // point of icon which will correspond to marker's location - shadowAnchor: [26/2 - 10, 25/2], // the same for the shadow - popupAnchor: [0, -33/4], // point from which popup should open relative to iconAnchor - } + iconSize: [26, 33], // size of icon + shadowSize: [26, 25], // size of shadow + iconAnchor: [26 / 2, 33 / 2], // point of icon which will correspond to marker's location + shadowAnchor: [26 / 2 - 10, 25 / 2], // the same for the shadow + popupAnchor: [0, -33 / 4], // point from which popup should open relative to iconAnchor + }, }); const BinIconSelected = BinIcon.extend({ options: { - iconSize: [35, 35], // size of icon - popupAnchor: [0, -35/4], // point from which popup should open relative to iconAnchor - } + iconSize: [35, 35], // size of icon + popupAnchor: [0, -35 / 4], // point from which popup should open relative to iconAnchor + }, }); return { @@ -97,63 +96,314 @@ const createBinIcons = (L: any) => { yellowBinSelected: new BinIconSelected({ iconUrl: '/images/leaflet/bin_y_b.png' }), redBin: new BinIcon({ iconUrl: '/images/leaflet/bin_r.png' }), redBinSelected: new BinIconSelected({ iconUrl: '/images/leaflet/bin_r_b.png' }), + greyBin: new BinIcon({ iconUrl: '/images/leaflet/bin_grey.png' }), // New grey bin icon + greyBinSelected: new BinIconSelected({ iconUrl: '/images/leaflet/bin_grey_b.png' }), // Selected grey bin }; }; - // Map initialization -const initializeMap = (L: any, centerCoordinates: LatLngTuple, initialZoom: number, mapRef: any, markersRef: any) => { + +export const initializeMap = ( + L: any, + centerCoordinates: LatLngTuple, + initialZoom: number, + mapRef: any, + markersRef: any +) => { + // If no map yet, create one if (!mapRef.current) { mapRef.current = L.map("map").setView(centerCoordinates, initialZoom); L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { - attribution: "© OpenStreetMap contributors" + attribution: "© OpenStreetMap contributors", }).addTo(mapRef.current); } + // If a MarkerClusterGroup already exists, clear it; otherwise create a new one if (markersRef.current) { markersRef.current.clearLayers(); } else { - markersRef.current = L.markerClusterGroup({ maxClusterRadius: 40 }); - if (markersRef.current) { - mapRef.current.addLayer(markersRef.current); - } + markersRef.current = L.markerClusterGroup({ + maxClusterRadius: 40, + iconCreateFunction: (cluster: any) => { + // All markers in this cluster + const childMarkers = cluster.getAllChildMarkers(); + // Tally of bins by color + let colorCounts: Record = { + green: 0, + yellow: 0, + red: 0, + grey: 0, + }; + + // Identify bin color by inspecting icon file name + childMarkers.forEach((marker: any) => { + const iconUrl = (marker.options.icon as L.Icon)?.options.iconUrl || ""; + if (iconUrl.includes("bin_grey")) { + colorCounts.grey++; + } else if (iconUrl.includes("bin_r")) { + colorCounts.red++; + } else if (iconUrl.includes("bin_y")) { + colorCounts.yellow++; + } else if (iconUrl.includes("bin_g")) { + colorCounts.green++; + } + + else if (iconUrl.includes("bin_b")) { } + }); + + const totalBins = + colorCounts.grey + + colorCounts.green + + colorCounts.yellow + + colorCounts.red; + // If for some reason we have zero, fallback to a default grey icon + if (totalBins === 0) { + return L.divIcon({ + html: `
+ ${cluster.getChildCount()} +
`, + className: "my-cluster-icon", + iconSize: [40, 40], + }); + } + + // Build the conic gradient segments + const colorSlices: Array<[string, number]> = []; + if (colorCounts.green > 0) colorSlices.push(["green", colorCounts.green]); + if (colorCounts.yellow > 0) colorSlices.push(["yellow", colorCounts.yellow]); + if (colorCounts.red > 0) colorSlices.push(["red", colorCounts.red]); + if (colorCounts.grey > 0) colorSlices.push(["grey", colorCounts.grey]); + + let currentPercent = 0; + const conicSegments: string[] = []; + + // Each color gets a slice in 0–100% + colorSlices.forEach(([color, count]) => { + const start = (currentPercent / totalBins) * 100; + const end = ((currentPercent + count) / totalBins) * 100; + conicSegments.push(`${color} ${start}% ${end}%`); + currentPercent += count; + }); + + const conicGradient = `conic-gradient(${conicSegments.join(", ")})`; + // Build the HTML + const size = 40; + const iconHtml = ` +
+ ${cluster.getChildCount()} +
+ `; + // Return the DivIcon + return L.divIcon({ + html: iconHtml, + className: "", + iconSize: [size, size], + }); + }, + }); + + // Finally, add the cluster group to the map + mapRef.current.addLayer(markersRef.current); } }; + // Markers addition -const addMarkersToMap = (L: any, trashbinData: Trashbin[], fillThresholds: [number, number], batteryThresholds: [number, number], selectedBins: Trashbin[] | undefined, isRoutePlanning: boolean, onTrashbinClick: (trashbin: Trashbin) => void | undefined, markersRef: any) => { - const { greenBin, greenBinSelected, yellowBin, yellowBinSelected, redBin, redBinSelected } = createBinIcons(L); - - const filteredTrashbinData = trashbinData.filter(trashbin => - trashbin.coordinates[0] !== (null && undefined) && - trashbin.coordinates[1] !== (null && undefined) && - trashbin.coordinates[0] >= -90 && trashbin.coordinates[0] <= 90 && - trashbin.coordinates[1] >= -180 && trashbin.coordinates[1] <= 180 - ); +const addMarkersToMap = async ( + L: any, + trashbinData: Trashbin[], + fillThresholds: [number, number], + batteryThresholds: [number, number], + selectedBins: Trashbin[] | undefined, + isRoutePlanning: boolean, + onTrashbinClick: (trashbin: Trashbin) => void | undefined, + markersRef: any +) => { + const { + greenBin, + greenBinSelected, + yellowBin, + yellowBinSelected, + redBin, + redBinSelected, + greyBin, + greyBinSelected, + } = createBinIcons(L); + const defaultIcon = L.icon({ + iconUrl: "/images/leaflet/bin_grey_b.png", // Replace with your icon path + iconSize: [25, 41], // Adjust the size + iconAnchor: [12, 41], // Anchor point + }); + // const token = process.env.NEXT_PUBLIC_API_TOKEN; + const token = localStorage.getItem("authToken"); + const threeDaysAgo = new Date(); + threeDaysAgo.setDate(threeDaysAgo.getDate() - 3); + + // Helper function to fetch sensor history + const fetchSensorHistory = async (sensorId: string): Promise => { + try { + const response = await fetch( + `/api/v1/history/sensor/${sensorId}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + const data = await response.json(); + return data.map((item: any) => ({ + timestamp: new Date(item.createdAt), // Convert to Date object + measurement: item.measurement, + measureType: item.measureType, + sensor: item.sensor, + })); + } catch (error) { + + return []; + } + }; + + const seenCoordinates = new Set(); + const filteredTrashbinData = trashbinData.filter((trashbin) => { + if ( + !trashbin.coordinates || + trashbin.coordinates[0] === null || + trashbin.coordinates[1] === null || + trashbin.coordinates[0] < -90 || + trashbin.coordinates[0] > 90 || + trashbin.coordinates[1] < -180 || + trashbin.coordinates[1] > 180 + ) { + return false; + } + const coordinateKey = `${trashbin.coordinates[0].toFixed(6)},${trashbin.coordinates[1].toFixed(6)}`; + if (seenCoordinates.has(coordinateKey)) { + return false; + } + seenCoordinates.add(coordinateKey); + return true; + }); + + const addedMarkers = new Set(); + for (const trashbin of filteredTrashbinData) { + const coordinateKey = `${trashbin.coordinates[0].toFixed(6)},${trashbin.coordinates[1].toFixed(6)}`; + + if (addedMarkers.has(coordinateKey)) { + continue; // Skip duplicates + } + addedMarkers.add(coordinateKey); + // Check if required data is missing + const isDataMissing = + !trashbin.name || + !trashbin.coordinates || + trashbin.coordinates.length !== 2 || + !trashbin.sensors?.length; + + let allSensorsHaveOldData = false; + + if (!isDataMissing && trashbin.sensors?.length > 0) { + const sensorData = await Promise.all( + trashbin.sensors.map((sensorId) => fetchSensorHistory(sensorId)) + ); + + // Check if all sensors lack recent data + allSensorsHaveOldData = sensorData.every((historyData) => + historyData.every((data: { timestamp: Date }) => { + const lastHistoryDate = new Date(data.timestamp); + + return lastHistoryDate.getTime() < threeDaysAgo.getTime(); + }) + ); + } + const getIcon = (trashbin: Trashbin): any => { + if (isDataMissing || allSensorsHaveOldData) { + // Check if the bin is selected + if (selectedBins?.some((bin) => bin.identifier === trashbin.identifier)) { + return greyBinSelected; // Grey Bin Selected if selected and data is missing or old + } + return greyBin; // Grey if missing data or all sensors have old data + } + + // Return appropriate icon based on fill level and selection state + if (selectedBins?.some((bin) => bin.identifier === trashbin.identifier)) { + return trashbin.fillLevel < fillThresholds[0] + ? greenBinSelected + : trashbin.fillLevel < fillThresholds[1] + ? yellowBinSelected + : redBinSelected; + } + + // Default bin icons + return trashbin.fillLevel < fillThresholds[0] + ? greenBin + : trashbin.fillLevel < fillThresholds[1] + ? yellowBin + : redBin; + }; - filteredTrashbinData.forEach(trashbin => { const marker = L.marker( [trashbin.coordinates[0] ?? 0, trashbin.coordinates[1] ?? 0], { - icon: selectedBins?.some((bin) => bin.identifier === trashbin.identifier) - ? (trashbin.fillLevel < fillThresholds[0] ? greenBinSelected : trashbin.fillLevel < fillThresholds[1] ? yellowBinSelected : redBinSelected) - : (trashbin.fillLevel < fillThresholds[0] ? greenBin : trashbin.fillLevel < fillThresholds[1] ? yellowBin : redBin) + icon: getIcon(trashbin), } ); - const container = document.createElement('div'); - const popupElement = ; + const container = document.createElement("div"); + const popupElement = ( + + ); createRoot(container).render(popupElement); marker.bindPopup(container); - marker.on("mouseover", () => { marker.openPopup(); }); + marker.on("mouseover", () => marker.openPopup()); marker.on("click", () => { - onTrashbinClick(trashbin); + // Trigger the provided callback + let selected = onTrashbinClick(trashbin); + if (selected) { + // Toggle selection state + const isAlreadySelected = selectedBins?.some( + (bin) => bin.identifier === trashbin.identifier + ); + + if (isAlreadySelected) { + // Remove bin from selectedBins + selectedBins = selectedBins?.filter( + (bin) => bin.identifier !== trashbin.identifier + ); + } else { + // Add bin to selectedBins + selectedBins = [...(selectedBins ?? []), trashbin]; + } + + // Update the marker's icon + marker.setIcon(getIcon(trashbin)); + } }); - marker.on('popupopen', function (e: any) { - L.DomEvent.on(e.popup._contentNode, 'click', () => { onTrashbinClick(trashbin); }); + marker.on("popupopen", (e: any) => { + L.DomEvent.on(e.popup._contentNode, "click", () => { + onTrashbinClick(trashbin); + }); }); - markersRef.current.addLayer(marker); - }); + + markersRef.current.addLayer(marker); // Add marker to layer + } }; // Route handling @@ -183,27 +433,47 @@ const handleRoutingControl = (L: any, showRoute: boolean = false, optimizedBins: } }; -const Map = ({ trashbinData, centerCoordinates, initialZoom = 20, fillThresholds, batteryThresholds, isRoutePlanning, onTrashbinClick, tripStartEnd, selectedBins, optimizedBins, showRoute }: MapProps) => { + +const Map = ({ + trashbinData, + centerCoordinates, + initialZoom = 20, + fillThresholds, + batteryThresholds, + isRoutePlanning, + onTrashbinClick, + tripStartEnd, + selectedBins, + optimizedBins, + showRoute, +}: MapProps) => { const mapRef = useRef(null); const markersRef = useRef(null); const routingControlRef = useRef(null); + // Map Initialization useEffect(() => { - if (typeof window !== 'undefined') { - // Load the leaflet library and the marker cluster plugin - const L = require('leaflet'); - require('leaflet.markercluster'); - require('leaflet-routing-machine'); - + if (typeof window !== "undefined" && mapRef.current == null) { + const L = require("leaflet"); + require("leaflet.markercluster"); + require("leaflet-routing-machine"); initializeMap(L, centerCoordinates, initialZoom, mapRef, markersRef); - if (mapRef.current && markersRef.current) { - addMarkersToMap(L, trashbinData, fillThresholds, batteryThresholds, selectedBins, isRoutePlanning, onTrashbinClick, markersRef); - handleRoutingControl(L, showRoute, optimizedBins, tripStartEnd, mapRef, routingControlRef); - } + addMarkersToMap(L,trashbinData,fillThresholds,batteryThresholds,selectedBins,isRoutePlanning,onTrashbinClick,markersRef); } - }, [trashbinData, isRoutePlanning, onTrashbinClick, selectedBins, optimizedBins, showRoute, batteryThresholds, centerCoordinates, fillThresholds, initialZoom, tripStartEnd ]); + + }, [trashbinData,showRoute, optimizedBins, tripStartEnd,centerCoordinates, initialZoom,fillThresholds, batteryThresholds,selectedBins,isRoutePlanning,onTrashbinClick,markersRef]); + // Route Handling + useEffect(() => { + if (typeof window !== "undefined" && mapRef.current) { + const L = require("leaflet"); + require("leaflet.markercluster"); + require("leaflet-routing-machine"); + handleRoutingControl(L,showRoute,optimizedBins,tripStartEnd,mapRef,routingControlRef); + } + }, [showRoute, optimizedBins, tripStartEnd,centerCoordinates, initialZoom, trashbinData,fillThresholds, batteryThresholds,selectedBins,isRoutePlanning,onTrashbinClick,markersRef]); return
; }; export default Map; + diff --git a/components/SideNavbar.tsx b/components/SideNavbar.tsx index f72ed61..0ca14d6 100644 --- a/components/SideNavbar.tsx +++ b/components/SideNavbar.tsx @@ -4,52 +4,105 @@ import { useEffect, useState } from "react"; import { Nav } from "@/components/ui/nav"; import { Button } from "@/components/ui/button"; +import { useTranslation } from "../lib/TranslationContext"; // Import useTranslation import { useWindowWidth } from "@react-hook/window-size"; import { - ChevronRight, - ChevronLeft, + PanelRightClose, + PanelLeftClose, LayoutDashboard, MapIcon, Route, Trash2Icon, - Settings, Settings2, CornerLeftUp, + MessageSquareReply, } from "lucide-react"; +// Handle logout functionality +const handleLogout = () => { + window.location.href = "/login"; // Redirect to login page (authToken cleared) +}; + export default function SideNavbar() { const [isCollapsed, setIsCollapsed] = useState(false); + const [currentPath, setCurrentPath] = useState(""); // Track current path manually + const [isDarkMode, setIsDarkMode] = useState(false); // Track dark mode status const city = localStorage.getItem("cityName"); const type = localStorage.getItem("projectType"); - const onlyWidth = useWindowWidth(); const mobileWidth = onlyWidth < 768; + const { t } = useTranslation(); // Translation hook + + // Track the initial load and set the current path & dark mode status + useEffect(() => { + setCurrentPath(window.location.pathname); // Set current path + const darkModeClass = document.documentElement.classList.contains("dark"); + setIsDarkMode(darkModeClass); // Check if dark mode is enabled + }, []); function toggleSidebar() { setIsCollapsed(!isCollapsed); } - const overviewLinks = type === "trash" ? [ - { title: "Dashboard", href: `/projects/${city}/${type}`, icon: LayoutDashboard, variant: "default" as "default" | "ghost", }, - { title: "Map", href: `/projects/${city}/${type}/map`, icon: MapIcon, variant: "ghost" as "default" | "ghost", }, - { title: "Route", href: `/projects/${city}/${type}/route`, icon: Route, variant: "ghost" as "default" | "ghost", }, - ] : [ - { title: "Dashboard", href: `/projects/${city}/${type}`, icon: LayoutDashboard, variant: "default" as "default" | "ghost", }, - ]; - - const dataLinks = type === "trash" ? [ - { title: "Trashbins", href: `/projects/${city}/${type}/trashbins`, icon: Trash2Icon, variant: "ghost" as "default" | "ghost", }, - ] : []; + // Combined navigation links for Overview and Data sections + const navigationLinks = + type === "trash" + ? [ + { + title: t("menu.dashboard"), + href: `/projects/${city}/${type}`, + icon: LayoutDashboard, + }, + { + title: t("menu.map"), + href: `/projects/${city}/${type}/map`, + icon: MapIcon, + }, + { + title: t("menu.route"), + href: `/projects/${city}/${type}/route`, + icon: Route, + }, + { + title: t("menu.trashbins"), + href: `/projects/${city}/${type}/trashbins`, + icon: Trash2Icon, + }, + ] + : [ + { + title: t("menu.dashboard"), + href: `/projects/${city}/${type}`, + icon: LayoutDashboard, + }, + ]; + // Settings links const settingsLinks = [ - { title: "Project", href: `/projects/${city}/${type}/settings`, icon: Settings2, variant: "ghost" as "default" | "ghost", }, - { title: "Account", href: "/settings", icon: Settings, variant: "ghost" as "default" | "ghost", }, + { + title: t("menu.project_setting"), + href: `/projects/${city}/${type}/settings`, + icon: Settings2, + }, + { + title: t("menu.logout"), + icon: MessageSquareReply, + href: "/login", + custom: ( + + ), + }, ]; - return ( -
+
{!mobileWidth && (
)} +