mirror of
https://github.com/tale/headplane.git
synced 2026-09-04 19:25:43 +00:00
feat: replace button and toast provider with base-ui
This commit is contained in:
@@ -44,9 +44,10 @@ function Dialog(props: DialogProps) {
|
|||||||
|
|
||||||
if (Array.isArray(props.children)) {
|
if (Array.isArray(props.children)) {
|
||||||
const [button, panel] = props.children;
|
const [button, panel] = props.children;
|
||||||
|
const buttonTriggerProps = { onClick: triggerProps.onPress } as Record<string, unknown>;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{cloneElement(button, triggerProps)}
|
{cloneElement(button, buttonTriggerProps)}
|
||||||
{state.isOpen && (
|
{state.isOpen && (
|
||||||
<DModal state={state}>
|
<DModal state={state}>
|
||||||
{cloneElement(panel, {
|
{cloneElement(panel, {
|
||||||
@@ -114,12 +115,12 @@ function Panel(props: DialogPanelProps) {
|
|||||||
<div className="flex flex-col gap-4">{children}</div>
|
<div className="flex flex-col gap-4">{children}</div>
|
||||||
<div className="mt-5 flex justify-end gap-3">
|
<div className="mt-5 flex justify-end gap-3">
|
||||||
{variant === "unactionable" ? (
|
{variant === "unactionable" ? (
|
||||||
<Button onPress={close}>Close</Button>
|
<Button onClick={close}>Close</Button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button onPress={close}>Cancel</Button>
|
<Button onClick={close}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
isDisabled={isDisabled}
|
disabled={isDisabled}
|
||||||
type="submit"
|
type="submit"
|
||||||
variant={variant === "destructive" ? "danger" : "heavy"}
|
variant={variant === "destructive" ? "danger" : "heavy"}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Minus, Plus } from "lucide-react";
|
import { Minus, Plus } from "lucide-react";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { type AriaNumberFieldProps, useId, useLocale, useNumberField } from "react-aria";
|
import { type AriaNumberFieldProps, useButton, useId, useLocale, useNumberField } from "react-aria";
|
||||||
import { useNumberFieldState } from "react-stately";
|
import { useNumberFieldState } from "react-stately";
|
||||||
|
|
||||||
import Button from "~/components/button";
|
|
||||||
import cn from "~/utils/cn";
|
import cn from "~/utils/cn";
|
||||||
|
|
||||||
export interface InputProps extends AriaNumberFieldProps {
|
export interface InputProps extends AriaNumberFieldProps {
|
||||||
@@ -30,6 +29,11 @@ export default function NumberInput(props: InputProps) {
|
|||||||
validationErrors,
|
validationErrors,
|
||||||
} = useNumberField(props, state, ref);
|
} = useNumberField(props, state, ref);
|
||||||
|
|
||||||
|
const decrRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const incrRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const { buttonProps: decrProps } = useButton(decrementButtonProps, decrRef);
|
||||||
|
const { buttonProps: incrProps } = useButton(incrementButtonProps, incrRef);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<label
|
<label
|
||||||
@@ -57,20 +61,22 @@ export default function NumberInput(props: InputProps) {
|
|||||||
className="w-full rounded-l-md bg-transparent py-2 pl-3 focus:outline-hidden"
|
className="w-full rounded-l-md bg-transparent py-2 pl-3 focus:outline-hidden"
|
||||||
/>
|
/>
|
||||||
<input type="hidden" name={name} value={state.numberValue} />
|
<input type="hidden" name={name} value={state.numberValue} />
|
||||||
<Button
|
<button
|
||||||
{...decrementButtonProps}
|
{...decrProps}
|
||||||
|
ref={decrRef}
|
||||||
aria-label="Decrement"
|
aria-label="Decrement"
|
||||||
className="h-7.5 w-7.5 rounded-lg p-1"
|
className="h-7.5 w-7.5 rounded-lg p-1"
|
||||||
>
|
>
|
||||||
<Minus className="h-4 w-4" />
|
<Minus className="h-4 w-4" />
|
||||||
</Button>
|
</button>
|
||||||
<Button
|
<button
|
||||||
{...incrementButtonProps}
|
{...incrProps}
|
||||||
|
ref={incrRef}
|
||||||
aria-label="Increment"
|
aria-label="Increment"
|
||||||
className="h-7.5 w-7.5 rounded-lg p-1"
|
className="h-7.5 w-7.5 rounded-lg p-1"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{props.description && (
|
{props.description && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import { AriaToastProps, AriaToastRegionProps, useToast, useToastRegion } from "@react-aria/toast";
|
|
||||||
import { ToastQueue, ToastState, useToastQueue } from "@react-stately/toast";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import React, { useRef } from "react";
|
|
||||||
|
|
||||||
import Button from "~/components/button";
|
|
||||||
import cn from "~/utils/cn";
|
|
||||||
|
|
||||||
interface ToastProps extends AriaToastProps<React.ReactNode> {
|
|
||||||
state: ToastState<React.ReactNode>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toast({ state, ...props }: ToastProps) {
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
|
||||||
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(props, state, ref);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...toastProps}
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-between gap-x-3 pl-4 pr-3",
|
|
||||||
"text-white shadow-overlay rounded-lg py-3",
|
|
||||||
"bg-mist-900 dark:bg-mist-950",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div {...contentProps} className="flex flex-col gap-2">
|
|
||||||
<div {...titleProps}>{props.toast.content}</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
{...closeButtonProps}
|
|
||||||
aria-label="Close"
|
|
||||||
className={cn(
|
|
||||||
"rounded-full p-1",
|
|
||||||
"bg-transparent hover:bg-mist-700",
|
|
||||||
"dark:bg-transparent dark:hover:bg-mist-800",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ToastRegionProps extends AriaToastRegionProps {
|
|
||||||
state: ToastState<React.ReactNode>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToastRegion({ state, ...props }: ToastRegionProps) {
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
|
||||||
const { regionProps } = useToastRegion(props, state, ref);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...regionProps}
|
|
||||||
ref={ref}
|
|
||||||
className={cn("fixed bottom-20 right-4", "flex flex-col gap-4")}
|
|
||||||
>
|
|
||||||
{state.visibleToasts.map((toast) => (
|
|
||||||
<Toast key={toast.key} toast={toast} state={state} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToastProviderProps extends AriaToastRegionProps {
|
|
||||||
queue: ToastQueue<React.ReactNode>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ToastProvider({ queue, ...props }: ToastProviderProps) {
|
|
||||||
const state = useToastQueue(queue);
|
|
||||||
|
|
||||||
return <>{state.visibleToasts.length > 0 && <ToastRegion {...props} state={state} />}</>;
|
|
||||||
}
|
|
||||||
@@ -1,32 +1,22 @@
|
|||||||
import React, { useRef } from "react";
|
import { Button as BaseButton } from "@base-ui/react/button";
|
||||||
import { type AriaButtonOptions, useButton } from "react-aria";
|
import React from "react";
|
||||||
|
|
||||||
import cn from "~/utils/cn";
|
import cn from "~/utils/cn";
|
||||||
|
|
||||||
export interface ButtonProps extends AriaButtonOptions<"button"> {
|
export interface ButtonProps extends React.ComponentProps<typeof BaseButton> {
|
||||||
variant?: "heavy" | "light" | "danger" | "ghost";
|
variant?: "heavy" | "light" | "danger" | "ghost";
|
||||||
className?: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
"aria-label"?: string;
|
|
||||||
ref?: React.RefObject<HTMLButtonElement | null>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Button({ variant = "light", ...props }: ButtonProps) {
|
export default function Button({ variant = "light", className, ...props }: ButtonProps) {
|
||||||
// In case the button is used as a trigger ref
|
|
||||||
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
|
|
||||||
const { buttonProps } = useButton(props, ref);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<BaseButton
|
||||||
ref={ref}
|
{...props}
|
||||||
{...buttonProps}
|
|
||||||
aria-label={props["aria-label"]}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex w-fit items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm",
|
"inline-flex w-fit items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm",
|
||||||
"transition-colors duration-100",
|
"transition-colors duration-100",
|
||||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
|
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
|
||||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||||
props.isDisabled && "pointer-events-none opacity-50",
|
props.disabled && "pointer-events-none opacity-50",
|
||||||
...(variant === "heavy"
|
...(variant === "heavy"
|
||||||
? [
|
? [
|
||||||
"bg-indigo-500 font-semibold text-white",
|
"bg-indigo-500 font-semibold text-white",
|
||||||
@@ -50,10 +40,8 @@ export default function Button({ variant = "light", ...props }: ButtonProps) {
|
|||||||
"dark:border-mist-700 dark:bg-mist-800/50",
|
"dark:border-mist-700 dark:bg-mist-800/50",
|
||||||
"dark:hover:bg-mist-700/50",
|
"dark:hover:bg-mist-700/50",
|
||||||
]),
|
]),
|
||||||
props.className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
/>
|
||||||
{props.children}
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export default function PageError({ error, page }: PageErrorProps) {
|
|||||||
<Button
|
<Button
|
||||||
className="mt-6"
|
className="mt-6"
|
||||||
variant="light"
|
variant="light"
|
||||||
onPress={() => revalidate()}
|
onClick={() => revalidate()}
|
||||||
isDisabled={state === "loading"}
|
disabled={state === "loading"}
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
className={cn("mr-2 inline-block h-4 w-4", state === "loading" && "animate-spin")}
|
className={cn("mr-2 inline-block h-4 w-4", state === "loading" && "animate-spin")}
|
||||||
|
|||||||
+2
-5
@@ -3,9 +3,8 @@ import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
|
|||||||
import "@fontsource-variable/inter";
|
import "@fontsource-variable/inter";
|
||||||
import { ExternalScripts } from "remix-utils/external-scripts";
|
import { ExternalScripts } from "remix-utils/external-scripts";
|
||||||
|
|
||||||
import ToastProvider from "~/components/ToastProvider";
|
|
||||||
import { LiveDataProvider } from "~/utils/live-data";
|
import { LiveDataProvider } from "~/utils/live-data";
|
||||||
import { useToastQueue } from "~/utils/toast";
|
import ToastProvider from "~/utils/toast-provider";
|
||||||
|
|
||||||
import type { Route } from "./+types/root";
|
import type { Route } from "./+types/root";
|
||||||
import { ErrorBanner } from "./components/error-banner";
|
import { ErrorBanner } from "./components/error-banner";
|
||||||
@@ -23,8 +22,6 @@ export const meta: MetaFunction = () => [
|
|||||||
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
|
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
|
||||||
|
|
||||||
export function Layout({ children }: { readonly children: React.ReactNode }) {
|
export function Layout({ children }: { readonly children: React.ReactNode }) {
|
||||||
const toastQueue = useToastQueue();
|
|
||||||
|
|
||||||
// LiveDataProvider is wrapped at the top level since dialogs and things
|
// LiveDataProvider is wrapped at the top level since dialogs and things
|
||||||
// that control its state are usually open in portal containers which
|
// that control its state are usually open in portal containers which
|
||||||
// are not a part of the normal React tree.
|
// are not a part of the normal React tree.
|
||||||
@@ -40,7 +37,7 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
|
|||||||
</head>
|
</head>
|
||||||
<body className="overflow-x-hidden overscroll-none dark:bg-mist-900 dark:text-mist-50">
|
<body className="overflow-x-hidden overscroll-none dark:bg-mist-900 dark:text-mist-50">
|
||||||
{children}
|
{children}
|
||||||
<ToastProvider queue={toastQueue} />
|
<ToastProvider />
|
||||||
<ScrollRestoration />
|
<ScrollRestoration />
|
||||||
<Scripts />
|
<Scripts />
|
||||||
<ExternalScripts />
|
<ExternalScripts />
|
||||||
|
|||||||
@@ -122,10 +122,10 @@ export default function Page({ loaderData: { access, writable, policy } }: Route
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
<Button
|
<Button
|
||||||
className="mr-2"
|
className="mr-2"
|
||||||
isDisabled={
|
disabled={
|
||||||
disabled || fetcher.state !== "idle" || codePolicy.length === 0 || codePolicy === policy
|
disabled || fetcher.state !== "idle" || codePolicy.length === 0 || codePolicy === policy
|
||||||
}
|
}
|
||||||
onPress={() => {
|
onClick={() => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("policy", codePolicy);
|
formData.append("policy", codePolicy);
|
||||||
fetcher.submit(formData, { method: "PATCH" });
|
fetcher.submit(formData, { method: "PATCH" });
|
||||||
@@ -135,8 +135,8 @@ export default function Page({ loaderData: { access, writable, policy } }: Route
|
|||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
isDisabled={disabled || fetcher.state !== "idle" || codePolicy === policy}
|
disabled={disabled || fetcher.state !== "idle" || codePolicy === policy}
|
||||||
onPress={() => {
|
onClick={() => {
|
||||||
// Reset the editor to the original policy
|
// Reset the editor to the original policy
|
||||||
setCodePolicy(policy);
|
setCodePolicy(policy);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -140,11 +140,7 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
|
|||||||
</Form>
|
</Form>
|
||||||
{isOidcConnectorEnabled ? (
|
{isOidcConnectorEnabled ? (
|
||||||
<Link to="/oidc/start">
|
<Link to="/oidc/start">
|
||||||
<Button
|
<Button className="mt-2 w-full" disabled={oidcErrorCodes.length > 0} variant="light">
|
||||||
className="mt-2 w-full"
|
|
||||||
isDisabled={oidcErrorCodes.length > 0}
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
Single Sign-On
|
Single Sign-On
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
|
|||||||
<input name="domain" type="hidden" value={domain} />
|
<input name="domain" type="hidden" value={domain} />
|
||||||
<Button
|
<Button
|
||||||
className={cn("px-2 py-1 rounded-md", "text-red-500 dark:text-red-400")}
|
className={cn("px-2 py-1 rounded-md", "text-red-500 dark:text-red-400")}
|
||||||
isDisabled={isDisabled}
|
disabled={isDisabled}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ function NameserverList({ isGlobal, isDisabled, nameservers, overrideLocalDns, n
|
|||||||
<input name="split_name" type="hidden" value={isGlobal ? "global" : name} />
|
<input name="split_name" type="hidden" value={isGlobal ? "global" : name} />
|
||||||
<Button
|
<Button
|
||||||
className={cn("px-2 py-1 rounded-md", "text-red-500 dark:text-red-400")}
|
className={cn("px-2 py-1 rounded-md", "text-red-500 dark:text-red-400")}
|
||||||
isDisabled={isDisabled}
|
disabled={isDisabled}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function ManageRecords({ records, isDisabled }: Props) {
|
|||||||
<input name="record_type" type="hidden" value={record.type} />
|
<input name="record_type" type="hidden" value={record.type} />
|
||||||
<Button
|
<Button
|
||||||
className={cn("px-2 py-1 rounded-md", "text-red-500 dark:text-red-400")}
|
className={cn("px-2 py-1 rounded-md", "text-red-500 dark:text-red-400")}
|
||||||
isDisabled={isDisabled}
|
disabled={isDisabled}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function RenameTailnet({ name, isDisabled }: Props) {
|
|||||||
value={name}
|
value={name}
|
||||||
/>
|
/>
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Button isDisabled={isDisabled}>Rename Tailnet</Button>
|
<Button disabled={isDisabled}>Rename Tailnet</Button>
|
||||||
<DialogPanel isDisabled={isDisabled}>
|
<DialogPanel isDisabled={isDisabled}>
|
||||||
<Title>Rename Tailnet</Title>
|
<Title>Rename Tailnet</Title>
|
||||||
<Text className="mb-8">
|
<Text className="mb-8">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface Props {
|
|||||||
export default function Modal({ isEnabled, isDisabled }: Props) {
|
export default function Modal({ isEnabled, isDisabled }: Props) {
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Button isDisabled={isDisabled}>{isEnabled ? "Disable" : "Enable"} Magic DNS</Button>
|
<Button disabled={isDisabled}>{isEnabled ? "Disable" : "Enable"} Magic DNS</Button>
|
||||||
<DialogPanel isDisabled={isDisabled}>
|
<DialogPanel isDisabled={isDisabled}>
|
||||||
<Title>{isEnabled ? "Disable" : "Enable"} Magic DNS</Title>
|
<Title>{isEnabled ? "Disable" : "Enable"} Magic DNS</Title>
|
||||||
<Text>
|
<Text>
|
||||||
|
|||||||
+1
-1
@@ -164,7 +164,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
<Button
|
<Button
|
||||||
className="h-8 p-1 px-2"
|
className="h-8 p-1 px-2"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onPress={async () => {
|
onClick={async () => {
|
||||||
await navigator.clipboard.writeText(
|
await navigator.clipboard.writeText(
|
||||||
"curl -fsSL https://tailscale.com/install.sh | sh",
|
"curl -fsSL https://tailscale.com/install.sh | sh",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function MachineMenu({
|
|||||||
isFullButton ? (
|
isFullButton ? (
|
||||||
<Button
|
<Button
|
||||||
className="flex items-center gap-x-2"
|
className="flex items-center gap-x-2"
|
||||||
onPress={() => {
|
onClick={() => {
|
||||||
// We need to use JS to open the SSH URL
|
// We need to use JS to open the SSH URL
|
||||||
// in a new WINDOW since href can only
|
// in a new WINDOW since href can only
|
||||||
// do a new TAB.
|
// do a new TAB.
|
||||||
@@ -125,7 +125,7 @@ export default function MachineMenu({
|
|||||||
"group-hover:pointer-events-auto",
|
"group-hover:pointer-events-auto",
|
||||||
)}
|
)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onPress={() => {
|
onClick={() => {
|
||||||
// We need to use JS to open the SSH URL
|
// We need to use JS to open the SSH URL
|
||||||
// in a new WINDOW since href can only
|
// in a new WINDOW since href can only
|
||||||
// do a new TAB.
|
// do a new TAB.
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
|
|||||||
{item}
|
{item}
|
||||||
<Button
|
<Button
|
||||||
className="rounded-md p-0.5"
|
className="rounded-md p-0.5"
|
||||||
onPress={() => {
|
onClick={() => {
|
||||||
setTags(tags.filter((tag) => tag !== item));
|
setTags(tags.filter((tag) => tag !== item));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -127,8 +127,8 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
|
|||||||
</Select>
|
</Select>
|
||||||
<Button
|
<Button
|
||||||
className={cn("rounded-md p-1", tagIsInvalid && "opacity-50 cursor-not-allowed")}
|
className={cn("rounded-md p-1", tagIsInvalid && "opacity-50 cursor-not-allowed")}
|
||||||
isDisabled={tagIsInvalid}
|
disabled={tagIsInvalid}
|
||||||
onPress={() => {
|
onClick={() => {
|
||||||
setTags([...tags, tag]);
|
setTags([...tags, tag]);
|
||||||
setTag("tag:");
|
setTag("tag:");
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export default function Page({
|
|||||||
Learn More
|
Learn More
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
<Button onPress={() => setShowRouting(true)}>Review</Button>
|
<Button onClick={() => setShowRouting(true)}>Review</Button>
|
||||||
</div>
|
</div>
|
||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -174,7 +174,7 @@ export default function Page({
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="mt-1.5 px-1.5 py-0.5"
|
className="mt-1.5 px-1.5 py-0.5"
|
||||||
onPress={() => setShowRouting(true)}
|
onClick={() => setShowRouting(true)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
@@ -204,7 +204,7 @@ export default function Page({
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="mt-1.5 px-1.5 py-0.5"
|
className="mt-1.5 px-1.5 py-0.5"
|
||||||
onPress={() => setShowRouting(true)}
|
onClick={() => setShowRouting(true)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
@@ -237,7 +237,7 @@ export default function Page({
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="mt-1.5 px-1.5 py-0.5"
|
className="mt-1.5 px-1.5 py-0.5"
|
||||||
onPress={() => setShowRouting(true)}
|
onClick={() => setShowRouting(true)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default function AddAuthKey({
|
|||||||
setIsOpen(open);
|
setIsOpen(open);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button className="my-4" onPress={() => setIsOpen(true)}>
|
<Button className="my-4" onClick={() => setIsOpen(true)}>
|
||||||
Create pre-auth key
|
Create pre-auth key
|
||||||
</Button>
|
</Button>
|
||||||
{createdKey ? (
|
{createdKey ? (
|
||||||
@@ -100,7 +100,7 @@ export default function AddAuthKey({
|
|||||||
<code className="min-w-0 flex-1 truncate font-mono text-sm">{createdKey}</code>
|
<code className="min-w-0 flex-1 truncate font-mono text-sm">{createdKey}</code>
|
||||||
<Button
|
<Button
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
onPress={async () => {
|
onClick={async () => {
|
||||||
await navigator.clipboard.writeText(createdKey);
|
await navigator.clipboard.writeText(createdKey);
|
||||||
toast("Copied key to clipboard");
|
toast("Copied key to clipboard");
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function AddDomain({ domains, isDisabled }: AddDomainProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Button isDisabled={isDisabled}>Add domain</Button>
|
<Button disabled={isDisabled}>Add domain</Button>
|
||||||
<DialogPanel>
|
<DialogPanel>
|
||||||
<Title>Add domain</Title>
|
<Title>Add domain</Title>
|
||||||
<Text className="mb-4">
|
<Text className="mb-4">
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function AddGroup({ groups, isDisabled }: AddGroupProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Button isDisabled={isDisabled}>Add group</Button>
|
<Button disabled={isDisabled}>Add group</Button>
|
||||||
<DialogPanel>
|
<DialogPanel>
|
||||||
<Title>Add group</Title>
|
<Title>Add group</Title>
|
||||||
<Text className="mb-4">
|
<Text className="mb-4">
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function AddUser({ users, isDisabled }: AddUserProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Button isDisabled={isDisabled}>Add user</Button>
|
<Button disabled={isDisabled}>Add user</Button>
|
||||||
<DialogPanel>
|
<DialogPanel>
|
||||||
<Title>Add user</Title>
|
<Title>Add user</Title>
|
||||||
<Text className="mb-4">
|
<Text className="mb-4">
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function RestrictionTable({ children, type, values, isDisabled }:
|
|||||||
<input name={type} type="hidden" value={value} />
|
<input name={type} type="hidden" value={value} />
|
||||||
<Button
|
<Button
|
||||||
className={cn("px-2 py-1 rounded-md", "text-red-500 dark:text-red-400")}
|
className={cn("px-2 py-1 rounded-md", "text-red-500 dark:text-red-400")}
|
||||||
isDisabled={isDisabled}
|
disabled={isDisabled}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function UserPrompt({ hostname }: UserPromptProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="heavy"
|
variant="heavy"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onPress={() => {
|
onClick={() => {
|
||||||
// We can't use the navigate hook here as we need to do a
|
// We can't use the navigate hook here as we need to do a
|
||||||
// full page reload to ensure the SSH connection is established
|
// full page reload to ensure the SSH connection is established
|
||||||
window.location.href = `${__PREFIX__}/ssh?hostname=${hostname}&username=${username}`;
|
window.location.href = `${__PREFIX__}/ssh?hostname=${hostname}&username=${username}`;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface CreateUserProps {
|
|||||||
export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
|
export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Button isDisabled={isDisabled}>Add user</Button>
|
<Button disabled={isDisabled}>Add user</Button>
|
||||||
<DialogPanel>
|
<DialogPanel>
|
||||||
<Title>Create a Headscale user</Title>
|
<Title>Create a Headscale user</Title>
|
||||||
<Text className="mb-6">
|
<Text className="mb-6">
|
||||||
|
|||||||
@@ -78,6 +78,106 @@
|
|||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toast-viewport {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 50;
|
||||||
|
bottom: 2rem;
|
||||||
|
right: 1rem;
|
||||||
|
left: auto;
|
||||||
|
top: auto;
|
||||||
|
width: 20rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-root {
|
||||||
|
--gap: 0.75rem;
|
||||||
|
--peek: 0.75rem;
|
||||||
|
--scale: calc(max(0, 1 - (var(--toast-index) * 0.1)));
|
||||||
|
--shrink: calc(1 - var(--scale));
|
||||||
|
--height: var(--toast-frontmost-height, var(--toast-height));
|
||||||
|
--offset-y: calc(
|
||||||
|
var(--toast-offset-y) * -1 + (var(--toast-index) * var(--gap) * -1) +
|
||||||
|
var(--toast-swipe-movement-y)
|
||||||
|
);
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: auto;
|
||||||
|
width: 100%;
|
||||||
|
height: var(--height);
|
||||||
|
z-index: calc(1000 - var(--toast-index));
|
||||||
|
transform-origin: bottom center;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
transform: translateX(var(--toast-swipe-movement-x))
|
||||||
|
translateY(
|
||||||
|
calc(
|
||||||
|
var(--toast-swipe-movement-y) - (var(--toast-index) * var(--peek)) -
|
||||||
|
(var(--shrink) * var(--height))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
scale(var(--scale));
|
||||||
|
|
||||||
|
transition:
|
||||||
|
transform 0.5s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
opacity 0.5s,
|
||||||
|
height 0.15s;
|
||||||
|
|
||||||
|
&[data-expanded] {
|
||||||
|
transform: translateX(var(--toast-swipe-movement-x)) translateY(var(--offset-y));
|
||||||
|
height: var(--toast-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-starting-style] {
|
||||||
|
transform: translateY(150%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-ending-style] {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(150%);
|
||||||
|
|
||||||
|
&[data-swipe-direction="up"] {
|
||||||
|
transform: translateY(calc(var(--toast-swipe-movement-y) - 150%));
|
||||||
|
}
|
||||||
|
&[data-swipe-direction="down"] {
|
||||||
|
transform: translateY(calc(var(--toast-swipe-movement-y) + 150%));
|
||||||
|
}
|
||||||
|
&[data-swipe-direction="left"] {
|
||||||
|
transform: translateX(calc(var(--toast-swipe-movement-x) - 150%)) translateY(var(--offset-y));
|
||||||
|
}
|
||||||
|
&[data-swipe-direction="right"] {
|
||||||
|
transform: translateX(calc(var(--toast-swipe-movement-x) + 150%)) translateY(var(--offset-y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-limited] {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Invisible hit area to bridge hover gap between toasts */
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: calc(var(--gap) + 1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-content {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: opacity 0.25s;
|
||||||
|
|
||||||
|
&[data-behind] {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-expanded] {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Weirdest class name characters but ok */
|
/* Weirdest class name characters but ok */
|
||||||
.cm-mergeView .ͼ1 .cm-scroller,
|
.cm-mergeView .ͼ1 .cm-scroller,
|
||||||
.cm-mergeView .ͼ1 {
|
.cm-mergeView .ͼ1 {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Toast } from "@base-ui/react/toast";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
import cn from "~/utils/cn";
|
||||||
|
import { toastManager } from "~/utils/toast";
|
||||||
|
|
||||||
|
function ToastList() {
|
||||||
|
const { toasts } = Toast.useToastManager();
|
||||||
|
return toasts.map((toast) => (
|
||||||
|
<Toast.Root
|
||||||
|
key={toast.id}
|
||||||
|
toast={toast}
|
||||||
|
className={cn(
|
||||||
|
"toast-root rounded-lg",
|
||||||
|
"bg-white text-mist-900 border border-mist-200",
|
||||||
|
"dark:bg-mist-800 dark:text-mist-50 dark:border-mist-700",
|
||||||
|
"shadow-lg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Toast.Content
|
||||||
|
className={cn("toast-content", "flex items-center justify-between gap-x-3 pl-4 pr-3 py-3")}
|
||||||
|
>
|
||||||
|
<Toast.Description>{toast.description}</Toast.Description>
|
||||||
|
<Toast.Close
|
||||||
|
aria-label="Close"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex shrink-0 items-center justify-center rounded-full p-1",
|
||||||
|
"bg-transparent hover:bg-mist-100",
|
||||||
|
"dark:bg-transparent dark:hover:bg-mist-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Toast.Close>
|
||||||
|
</Toast.Content>
|
||||||
|
</Toast.Root>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToastProvider() {
|
||||||
|
return (
|
||||||
|
<Toast.Provider toastManager={toastManager}>
|
||||||
|
<Toast.Portal>
|
||||||
|
<Toast.Viewport className="toast-viewport">
|
||||||
|
<ToastList />
|
||||||
|
</Toast.Viewport>
|
||||||
|
</Toast.Portal>
|
||||||
|
</Toast.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
+4
-11
@@ -1,14 +1,7 @@
|
|||||||
import { ToastQueue } from '@react-stately/toast';
|
import { Toast } from "@base-ui/react/toast";
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
const toastQueue = new ToastQueue<React.ReactNode>({
|
export const toastManager = Toast.createToastManager();
|
||||||
maxVisibleToasts: 7,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function useToastQueue() {
|
export default function toast(content: string, duration = 3000) {
|
||||||
return toastQueue;
|
return toastManager.add({ description: content, timeout: duration });
|
||||||
}
|
|
||||||
|
|
||||||
export default function toast(content: React.ReactNode, duration = 3000) {
|
|
||||||
return toastQueue.add(content, { timeout: duration });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,7 @@
|
|||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@iconify/react": "^6.0.2",
|
"@iconify/react": "^6.0.2",
|
||||||
"@kubernetes/client-node": "^1.4.0",
|
"@kubernetes/client-node": "^1.4.0",
|
||||||
"@react-aria/toast": "3.0.10",
|
|
||||||
"@react-router/node": "^7.13.1",
|
"@react-router/node": "^7.13.1",
|
||||||
"@react-stately/toast": "3.1.3",
|
|
||||||
"@readme/openapi-parser": "^5.5.0",
|
"@readme/openapi-parser": "^5.5.0",
|
||||||
"@uiw/codemirror-theme-github": "4.25.1",
|
"@uiw/codemirror-theme-github": "4.25.1",
|
||||||
"@uiw/codemirror-theme-xcode": "4.25.5",
|
"@uiw/codemirror-theme-xcode": "4.25.5",
|
||||||
|
|||||||
Generated
-6
@@ -37,15 +37,9 @@ importers:
|
|||||||
'@kubernetes/client-node':
|
'@kubernetes/client-node':
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.4.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
|
version: 1.4.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
|
||||||
'@react-aria/toast':
|
|
||||||
specifier: 3.0.10
|
|
||||||
version: 3.0.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
|
||||||
'@react-router/node':
|
'@react-router/node':
|
||||||
specifier: ^7.13.1
|
specifier: ^7.13.1
|
||||||
version: 7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
|
version: 7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
|
||||||
'@react-stately/toast':
|
|
||||||
specifier: 3.1.3
|
|
||||||
version: 3.1.3(react@19.2.4)
|
|
||||||
'@readme/openapi-parser':
|
'@readme/openapi-parser':
|
||||||
specifier: ^5.5.0
|
specifier: ^5.5.0
|
||||||
version: 5.5.0(openapi-types@12.1.3)
|
version: 5.5.0(openapi-types@12.1.3)
|
||||||
|
|||||||
Reference in New Issue
Block a user