Skip to content

Commit 5fe8367

Browse files
authored
feat(web): add distance unit toggle between metric and imperial (#173)
1 parent 07b4601 commit 5fe8367

5 files changed

Lines changed: 242 additions & 8 deletions

File tree

web/src/components/SettingsMenu.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
*/
55

66
import React, { useState } from "react";
7-
import { Cog6ToothIcon, BellIcon, ClockIcon } from "@heroicons/react/24/outline";
7+
import { Cog6ToothIcon, BellIcon, ClockIcon, MapPinIcon } from "@heroicons/react/24/outline";
88
import { NotificationSettings } from "./settings/NotificationSettings";
99
import { TimeFormatSettings } from "./settings/TimeFormatSettings";
10+
import { DistanceSettings } from "./settings/DistanceSettings";
1011
import { Button } from "@/components/ui/Button";
1112
import {
1213
Dialog,
@@ -41,6 +42,12 @@ const settingsSections: SettingsSection[] = [
4142
icon: <ClockIcon className="w-4 h-4" />,
4243
component: TimeFormatSettings,
4344
},
45+
{
46+
id: "distance",
47+
label: "Distance Units",
48+
icon: <MapPinIcon className="w-4 h-4" />,
49+
component: DistanceSettings,
50+
},
4451
];
4552

4653
export const SettingsMenu: React.FC = () => {
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/*
2+
* Copyright (c) 2024-2025, s0up and the autobrr contributors.
3+
* SPDX-License-Identifier: GPL-2.0-or-later
4+
*/
5+
6+
import React, { useState } from "react";
7+
import { MapPinIcon, CheckIcon } from "@heroicons/react/24/outline";
8+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
9+
import { Button } from "@/components/ui/Button";
10+
import {
11+
getDistanceSettings,
12+
saveDistanceSettings,
13+
formatDistance,
14+
type DistanceSettings as DistanceSettingsType,
15+
type DistanceUnit,
16+
} from "@/utils/distanceSettings";
17+
import { showToast } from "@/components/common/Toast";
18+
19+
export const DistanceSettings: React.FC = () => {
20+
const [settings, setSettings] = useState<DistanceSettingsType>(getDistanceSettings);
21+
const [hasChanges, setHasChanges] = useState(false);
22+
23+
const updateUnit = (unit: DistanceUnit) => {
24+
setSettings({ unit });
25+
setHasChanges(true);
26+
};
27+
28+
const saveSettings = () => {
29+
saveDistanceSettings(settings);
30+
setHasChanges(false);
31+
showToast("Distance settings saved", "success", {
32+
description: "Changes will be applied across the application",
33+
});
34+
};
35+
36+
const resetToDefaults = () => {
37+
setSettings({ unit: "km" });
38+
setHasChanges(true);
39+
};
40+
41+
return (
42+
<div className="space-y-6">
43+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
44+
<div>
45+
<h3 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
46+
<MapPinIcon className="w-6 h-6 text-gray-600 dark:text-gray-400" />
47+
Distance Settings
48+
</h3>
49+
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
50+
Choose between metric and imperial units for distance display
51+
</p>
52+
</div>
53+
54+
{hasChanges && (
55+
<div className="flex items-center gap-2">
56+
<Button
57+
onClick={saveSettings}
58+
className="bg-blue-600 hover:bg-blue-700 text-white"
59+
>
60+
<CheckIcon className="w-4 h-4" />
61+
Save Changes
62+
</Button>
63+
<Button
64+
onClick={() => {
65+
setSettings(getDistanceSettings());
66+
setHasChanges(false);
67+
}}
68+
variant="secondary"
69+
>
70+
Cancel
71+
</Button>
72+
</div>
73+
)}
74+
</div>
75+
76+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
77+
<Card>
78+
<CardHeader>
79+
<CardTitle className="flex items-center gap-2">
80+
<MapPinIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
81+
Distance Unit
82+
</CardTitle>
83+
</CardHeader>
84+
<CardContent className="space-y-4">
85+
<div className="flex gap-3">
86+
<button
87+
onClick={() => updateUnit("km")}
88+
className={`flex-1 p-4 rounded-lg border transition-colors text-left ${
89+
settings.unit === "km"
90+
? "bg-blue-500/10 border-blue-400/50"
91+
: "bg-gray-200/50 dark:bg-gray-800/50 border-gray-300 dark:border-gray-800 hover:bg-gray-300/50 dark:hover:bg-gray-800"
92+
}`}
93+
>
94+
<div className="font-medium text-gray-900 dark:text-white">Metric</div>
95+
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">Kilometers (km)</div>
96+
</button>
97+
<button
98+
onClick={() => updateUnit("mi")}
99+
className={`flex-1 p-4 rounded-lg border transition-colors text-left ${
100+
settings.unit === "mi"
101+
? "bg-blue-500/10 border-blue-400/50"
102+
: "bg-gray-200/50 dark:bg-gray-800/50 border-gray-300 dark:border-gray-800 hover:bg-gray-300/50 dark:hover:bg-gray-800"
103+
}`}
104+
>
105+
<div className="font-medium text-gray-900 dark:text-white">Imperial</div>
106+
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">Miles (mi)</div>
107+
</button>
108+
</div>
109+
110+
<div className="p-3 bg-blue-50 dark:bg-blue-950/30 rounded-lg">
111+
<div className="flex items-start gap-2">
112+
<MapPinIcon className="w-4 h-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
113+
<div className="text-sm">
114+
<div className="font-medium text-blue-900 dark:text-blue-300">
115+
Preview
116+
</div>
117+
<div className="text-blue-700 dark:text-blue-400 mt-1">
118+
100 km = <span className="font-mono">{formatDistance(100, settings)}</span>
119+
</div>
120+
</div>
121+
</div>
122+
</div>
123+
</CardContent>
124+
</Card>
125+
</div>
126+
127+
<Card>
128+
<CardContent className="p-6">
129+
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
130+
<div>
131+
<h4 className="font-medium text-gray-900 dark:text-white">
132+
Quick Actions
133+
</h4>
134+
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
135+
Reset settings or apply common configurations
136+
</p>
137+
</div>
138+
<div className="flex items-center gap-2">
139+
<Button
140+
onClick={resetToDefaults}
141+
variant="outline"
142+
>
143+
Reset to Defaults
144+
</Button>
145+
</div>
146+
</div>
147+
</CardContent>
148+
</Card>
149+
</div>
150+
);
151+
};

web/src/components/speedtest/ServerList.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
CollapsibleContent,
1919
CollapsibleTrigger,
2020
} from "@/components/ui/collapsible";
21+
import { formatDistance, useDistanceSettings } from "@/utils/distanceSettings";
2122
import {
2223
Select,
2324
SelectContent,
@@ -92,6 +93,7 @@ export const ServerList: React.FC<ServerListProps> = ({
9293
const saved = localStorage.getItem("server-list-open");
9394
return saved === null ? true : saved === "true";
9495
});
96+
const { settings: distanceSettings } = useDistanceSettings();
9597

9698
// Persist server list open state to localStorage
9799
useEffect(() => {
@@ -560,9 +562,7 @@ export const ServerList: React.FC<ServerListProps> = ({
560562
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
561563
{filteredServersWithSelect
562564
.slice(0, displayCount)
563-
.map((server) => {
564-
const distance = server.distance;
565-
return (
565+
.map((server) => (
566566
<motion.div
567567
key={server.id}
568568
initial={{ opacity: 0, y: 20 }}
@@ -607,13 +607,12 @@ export const ServerList: React.FC<ServerListProps> = ({
607607
</span>
608608
<span className="text-gray-600 dark:text-gray-400 text-sm mt-1">
609609
{server.country} -{" "}
610-
{Math.floor(distance)} km
610+
{formatDistance(server.distance, distanceSettings)}
611611
</span>
612612
</div>
613613
</button>
614614
</motion.div>
615-
);
616-
})}
615+
))}
617616
</div>
618617
)}
619618
</>

web/src/components/speedtest/traceroute/components/ServerCard.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
getServerTypeLabel,
1212
getServerTypeColorClass,
1313
} from "../utils/serverUtils";
14+
import { formatDistance, useDistanceSettings } from "@/utils/distanceSettings";
1415

1516
interface ServerCardProps {
1617
server: Server;
@@ -25,6 +26,8 @@ export const ServerCard: React.FC<ServerCardProps> = ({
2526
onSelect,
2627
disabled = false,
2728
}) => {
29+
const { settings: distanceSettings } = useDistanceSettings();
30+
2831
return (
2932
<motion.div
3033
initial={{ opacity: 0, y: 20 }}
@@ -53,7 +56,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({
5356
<span className="text-gray-600 dark:text-gray-400 text-sm mt-1">
5457
{server.isIperf
5558
? "Custom Server"
56-
: `${server.country} - ${Math.floor(server.distance)} km`}
59+
: `${server.country} - ${formatDistance(server.distance, distanceSettings)}`}
5760
<span className={`ml-2 ${getServerTypeColorClass(server)}`}>
5861
{getServerTypeLabel(server)}
5962
</span>

web/src/utils/distanceSettings.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* Copyright (c) 2024-2025, s0up and the autobrr contributors.
3+
* SPDX-License-Identifier: GPL-2.0-or-later
4+
*/
5+
6+
import React from "react";
7+
8+
export type DistanceUnit = "km" | "mi";
9+
10+
export interface DistanceSettings {
11+
unit: DistanceUnit;
12+
}
13+
14+
const DISTANCE_SETTINGS_KEY = "netronome-distance-settings";
15+
const DEFAULT_SETTINGS: DistanceSettings = {
16+
unit: "km",
17+
};
18+
19+
const KM_TO_MI = 0.621371;
20+
21+
export const getDistanceSettings = (): DistanceSettings => {
22+
try {
23+
const saved = localStorage.getItem(DISTANCE_SETTINGS_KEY);
24+
if (saved) {
25+
const settings = JSON.parse(saved);
26+
return {
27+
unit: settings.unit === "mi" ? "mi" : "km",
28+
};
29+
}
30+
} catch (error) {
31+
console.warn("Failed to load distance settings from localStorage:", error);
32+
}
33+
return DEFAULT_SETTINGS;
34+
};
35+
36+
export const saveDistanceSettings = (settings: DistanceSettings): void => {
37+
try {
38+
localStorage.setItem(DISTANCE_SETTINGS_KEY, JSON.stringify(settings));
39+
window.dispatchEvent(new CustomEvent("distanceSettingsChanged", { detail: settings }));
40+
} catch (error) {
41+
console.warn("Failed to save distance settings to localStorage:", error);
42+
}
43+
};
44+
45+
export const formatDistance = (distanceKm: number, settings?: DistanceSettings): string => {
46+
const currentSettings = settings || getDistanceSettings();
47+
if (currentSettings.unit === "mi") {
48+
return `${Math.round(distanceKm * KM_TO_MI)} mi`;
49+
}
50+
return `${Math.round(distanceKm)} km`;
51+
};
52+
53+
export const useDistanceSettings = (): { settings: DistanceSettings; updateSettings: (newSettings: DistanceSettings) => void } => {
54+
const [settings, setSettings] = React.useState<DistanceSettings>(getDistanceSettings);
55+
56+
React.useEffect(() => {
57+
const handleSettingsChange = (event: CustomEvent<DistanceSettings>) => {
58+
setSettings(event.detail);
59+
};
60+
61+
window.addEventListener("distanceSettingsChanged", handleSettingsChange as EventListener);
62+
63+
return () => {
64+
window.removeEventListener("distanceSettingsChanged", handleSettingsChange as EventListener);
65+
};
66+
}, []);
67+
68+
const updateSettings = (newSettings: DistanceSettings) => {
69+
saveDistanceSettings(newSettings);
70+
setSettings(newSettings);
71+
};
72+
73+
return { settings, updateSettings };
74+
};

0 commit comments

Comments
 (0)