mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 02:33:01 +00:00
ServerConfigurationDialog component
- allows the user to modify localStorage's serverURL - opens the opportunity for users to have access to a self-hosted version of the application
This commit is contained in:
@@ -146,3 +146,10 @@
|
|||||||
.font-700 {
|
.font-700 {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.noselect {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */
|
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */
|
||||||
import { type ChangeEvent, useEffect, useMemo, useState } from "react";
|
import { type ChangeEvent, useEffect, useMemo, useState } from "react";
|
||||||
|
import { ServerConfigurationDialog } from "@/components/server-configuration-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -176,10 +177,11 @@ export default function LogInForm() {
|
|||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col gap-2 items-center border p-6 pb-4",
|
"relative flex flex-col gap-2 items-center border p-6 pb-4",
|
||||||
error !== "" && "border-destructive",
|
error !== "" && "border-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<ServerConfigurationDialog />
|
||||||
<span className="text-xl mb-2">{capitalise(mode)}</span>
|
<span className="text-xl mb-2">{capitalise(mode)}</span>
|
||||||
|
|
||||||
{mode === "register" && (
|
{mode === "register" && (
|
||||||
|
|||||||
183
packages/frontend/src/components/server-configuration-dialog.tsx
Normal file
183
packages/frontend/src/components/server-configuration-dialog.tsx
Normal file
@@ -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<number | null>(null);
|
||||||
|
const [isValid, setIsValid] = useState(true);
|
||||||
|
const [isCheckingHealth, setIsCheckingHealth] = useState(false);
|
||||||
|
const [healthError, setHealthError] = useState<string | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger || (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
title="Server Configuration"
|
||||||
|
>
|
||||||
|
<ServerIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Server Configuration</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="server-url">Server URL</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="server-url"
|
||||||
|
value={serverURL}
|
||||||
|
onChange={(e) => handleServerURLChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && canSave && !isCheckingHealth) {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleSave();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
className={!isValid ? "border-destructive" : ""}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant={canSave ? "default" : "outline"}
|
||||||
|
disabled={!canSave || isCheckingHealth}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!isNotDefault || isCheckingHealth}
|
||||||
|
onClick={handleResetToDefault}
|
||||||
|
title="Reset to default"
|
||||||
|
>
|
||||||
|
<Undo2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!isValid && (
|
||||||
|
<Label className="text-destructive text-sm">Please enter a valid URL</Label>
|
||||||
|
)}
|
||||||
|
{healthError && <Label className="text-destructive text-sm">{healthError}</Label>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
{countdown !== null && (
|
||||||
|
<div className="fixed inset-0 z-[100] bg-black/50 flex flex-col items-center justify-center pointer-events-auto">
|
||||||
|
<div className="text-2xl font-bold pointer-events-none noselect">Redirecting</div>
|
||||||
|
<div className="text-8xl font-bold pointer-events-none noselect">{countdown}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
todo.md
1
todo.md
@@ -6,4 +6,3 @@
|
|||||||
- issue assignee
|
- issue assignee
|
||||||
- deadline
|
- deadline
|
||||||
- time tracking (linked to issues or standalone)
|
- 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)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user