+
{capitalise(mode)}
{mode === "register" && (
diff --git a/packages/frontend/src/components/server-configuration-dialog.tsx b/packages/frontend/src/components/server-configuration-dialog.tsx
new file mode 100644
index 0000000..f4b0729
--- /dev/null
+++ b/packages/frontend/src/components/server-configuration-dialog.tsx
@@ -0,0 +1,183 @@
+import { CheckIcon, ServerIcon, Undo2 } from "lucide-react";
+import { type ReactNode, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { getServerURL } from "@/lib/utils";
+
+const DEFAULT_URL = "https://eussi.ob248.com";
+
+const formatURL = (url: string) => {
+ if (url.endsWith("/")) {
+ url = url.slice(0, -1);
+ }
+ if (
+ (url.includes("localhost") || url.includes("127.0.0.1")) &&
+ !url.startsWith("http://") &&
+ !url.startsWith("https://")
+ ) {
+ url = `http://${url}`; // use http for localhost
+ } else if (!url.startsWith("http://") && !url.startsWith("https://")) {
+ url = `https://${url}`; // assume https if none is provided
+ }
+ return url;
+};
+
+const isValidURL = (url: string) => {
+ try {
+ new URL(formatURL(url));
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+export function ServerConfigurationDialog({ trigger }: { trigger?: ReactNode }) {
+ const [open, setOpen] = useState(false);
+ const [serverURL, setServerURL] = useState(getServerURL());
+ const [originalURL, setOriginalURL] = useState(getServerURL());
+ const [countdown, setCountdown] = useState
(null);
+ const [isValid, setIsValid] = useState(true);
+ const [isCheckingHealth, setIsCheckingHealth] = useState(false);
+ const [healthError, setHealthError] = useState(null);
+
+ const hasChanged = formatURL(serverURL) !== formatURL(originalURL);
+ const isNotDefault = formatURL(serverURL) !== formatURL(DEFAULT_URL);
+ const canSave = hasChanged && isValidURL(serverURL);
+
+ const handleOpenChange = (nextOpen: boolean) => {
+ setOpen(nextOpen);
+ if (nextOpen) {
+ setServerURL(getServerURL());
+ setOriginalURL(getServerURL());
+ setIsValid(true);
+ setCountdown(null);
+ setHealthError(null);
+ }
+ };
+
+ const handleServerURLChange = (value: string) => {
+ setServerURL(value);
+ setIsValid(isValidURL(value));
+ setHealthError(null);
+ };
+
+ const handleResetToDefault = () => {
+ setServerURL(DEFAULT_URL);
+ setIsValid(true);
+ setHealthError(null);
+ };
+
+ const handleSave = async () => {
+ if (!canSave) return;
+
+ setIsCheckingHealth(true);
+
+ try {
+ const response = await fetch(`${formatURL(serverURL)}/health`, {
+ method: "GET",
+ signal: AbortSignal.timeout(5000),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Server returned ${response.status}`);
+ }
+ setHealthError(null);
+
+ localStorage.setItem("serverURL", formatURL(serverURL));
+
+ let count = 3;
+ setCountdown(count);
+ setOpen(false);
+
+ const interval = setInterval(() => {
+ count--;
+ if (count <= 0) {
+ clearInterval(interval);
+ window.location.reload();
+ } else {
+ setCountdown(count);
+ }
+ }, 1000);
+ } catch (err) {
+ setHealthError(err instanceof Error ? err.message : "Failed to connect to server");
+ } finally {
+ setIsCheckingHealth(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/todo.md b/todo.md
index 2ab8d95..815a80d 100644
--- a/todo.md
+++ b/todo.md
@@ -6,4 +6,3 @@
- issue assignee
- deadline
- time tracking (linked to issues or standalone)
-- store backend/server URL in localstorage (can be changed in user settings). this will be used in substitution of VITE_SERVER_URL (use VITE_SERVER_URL as the default. this way, the frontend releases can ship with eussi.ob248.com, but dev can keep localhost backend)