Add server

This commit is contained in:
2026-08-18 17:24:40 +02:00
parent cba9af34a0
commit 591d121204
11 changed files with 560 additions and 8 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ export default function App() {
}
return (
<main className="size-screen dark bg-background">
<main className="size-screen">
<ServerList appRef={appRef} />
</main>
);
+8 -2
View File
@@ -1,4 +1,5 @@
import {
getHTTPUrl,
getServerList,
getWSUrl,
KnownServer,
@@ -9,6 +10,8 @@ import * as ed from "@noble/ed25519";
import { sha512 } from "@noble/hashes/sha2.js";
import { base58 } from "@scure/base";
import axios from "axios";
ed.hashes.sha512 = sha512;
/**
@@ -52,9 +55,12 @@ export default class Enclave {
return;
}
const metaResponse = await axios.get(
getHTTPUrl(hostname, isSecure, "/meta"),
);
this.serverList[hostname] = {
name: "",
description: "",
meta: metaResponse.data as any,
isSecure,
publicKey: base58.encode(this.server.serverPublicKey),
};
+131
View File
@@ -0,0 +1,131 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { PlusIcon } from "lucide-react";
// hostname[:port][/path] — explicitly no protocol/scheme allowed
const HOSTNAME_PATTERN =
/^(?!.*:\/\/)[a-zA-Z0-9.-]+(:\d{1,5})?(\/[a-zA-Z0-9\-._~%!$&'()*+,;=:@/]*)?$/;
function validateHostname(value: string): string | null {
if (!value.trim()) {
return "Hostname is required";
}
if (value.includes("://")) {
return "Don't include a protocol (e.g. https://)";
}
if (!HOSTNAME_PATTERN.test(value)) {
return "Invalid hostname format";
}
return null;
}
type AddServerDialogProps = {
onAdd: (hostname: string, isSecure: boolean) => Promise<void> | void;
};
export function AddServerDialog({ onAdd }: AddServerDialogProps) {
const [open, setOpen] = useState(false);
const [hostname, setHostname] = useState("");
const [isSecure, setIsSecure] = useState(true);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
function handleHostnameChange(value: string) {
setHostname(value);
if (error) {
setError(validateHostname(value));
}
}
async function handleSubmit() {
const validationError = validateHostname(hostname);
if (validationError) {
setError(validationError);
return;
}
setSubmitting(true);
try {
await onAdd(hostname.trim(), isSecure);
setHostname("");
setIsSecure(true);
setError(null);
setOpen(false);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to add server");
} finally {
setSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={() => (
<Button
variant="secondary"
className="aspect-square w-full h-auto rounded-full"
onClick={() => setOpen(true)}
>
<PlusIcon />
</Button>
)}
/>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add a Server</DialogTitle>
<DialogDescription>
Enter the server's address to connect.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="hostname">Hostname</Label>
<Input
id="hostname"
placeholder="myserver.example.com:8080"
value={hostname}
onChange={(e) => handleHostnameChange(e.target.value)}
aria-invalid={!!error}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<div className="flex items-center justify-between">
<Label htmlFor="secure-toggle" className="flex flex-col gap-1">
Secure connection
</Label>
<Switch
id="secure-toggle"
checked={isSecure}
onCheckedChange={setIsSecure}
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? "Adding..." : "Add"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+158
View File
@@ -0,0 +1,158 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+32
View File
@@ -0,0 +1,32 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+20 -3
View File
@@ -1,6 +1,6 @@
import Enclave from "@/app/app";
import EnclaveServer from "@/app/server";
import { getHTTPUrl, getWSUrl } from "@/lib/serverList";
import { getHTTPUrl, saveServerList } from "@/lib/serverList";
import { AddServerDialog } from "../dialog/AddServerDialog";
export default function ServerList({
appRef,
@@ -10,7 +10,7 @@ export default function ServerList({
if (!appRef.current) return null;
return (
<aside className="bg-card w-16 h-screen p-2 flex flex-col gap-2.5">
<aside className="bg-card w-16 h-screen p-2 flex flex-col gap-2.5 border-r border-r-border">
{Object.entries(appRef.current.serverList).map(([hostname, server]) => {
return (
<img
@@ -32,6 +32,23 @@ export default function ServerList({
/>
);
})}
<AddServerDialog
onAdd={(hostname, isSecure) => {
if (!appRef.current) {
console.error("AppRef is not initialized yet");
return;
}
appRef.current
.connectToServer(hostname, isSecure)
.then(
() =>
appRef.current?.serverList &&
saveServerList(appRef.current?.serverList),
);
}}
/>
</aside>
);
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { ServerMeta } from "@/app/protocol";
import { invoke } from "@tauri-apps/api/core";
export interface KnownServer {
name: string;
description: string;
meta: ServerMeta;
publicKey: string;
isSecure: boolean;
}