Add server
This commit is contained in:
+1
-1
@@ -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
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user