diff --git a/packages/frontend/src/App.css b/packages/frontend/src/App.css index c3a42e4..73da498 100644 --- a/packages/frontend/src/App.css +++ b/packages/frontend/src/App.css @@ -146,3 +146,10 @@ .font-700 { font-weight: 700; } + +.noselect { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} diff --git a/packages/frontend/src/components/login-form.tsx b/packages/frontend/src/components/login-form.tsx index 587fe54..d7eb621 100644 --- a/packages/frontend/src/components/login-form.tsx +++ b/packages/frontend/src/components/login-form.tsx @@ -1,5 +1,6 @@ /** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */ import { type ChangeEvent, useEffect, useMemo, useState } from "react"; +import { ServerConfigurationDialog } from "@/components/server-configuration-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -176,10 +177,11 @@ export default function LogInForm() {
+ {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 ( + + + {trigger || ( + + )} + + + + + Server Configuration + + +
+
+ +
+ handleServerURLChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && canSave && !isCheckingHealth) { + e.preventDefault(); + void handleSave(); + } + }} + placeholder="https://example.com" + className={!isValid ? "border-destructive" : ""} + /> + + +
+ {!isValid && ( + + )} + {healthError && } +
+
+
+ + {countdown !== null && ( +
+
Redirecting
+
{countdown}
+
+ )} +
+ ); +} 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)