mirror of
https://github.com/tale/headplane.git
synced 2026-08-30 16:59:56 +00:00
feat: switch to mist color from tailwind
This commit is contained in:
@@ -1,86 +1,83 @@
|
|||||||
import { Check, Copy, Info } from 'lucide-react';
|
import { Check, Copy, Info } from "lucide-react";
|
||||||
import cn from '~/utils/cn';
|
|
||||||
import toast from '~/utils/toast';
|
import cn from "~/utils/cn";
|
||||||
import Tooltip from './Tooltip';
|
import toast from "~/utils/toast";
|
||||||
|
|
||||||
|
import Tooltip from "./Tooltip";
|
||||||
|
|
||||||
export interface AttributeProps {
|
export interface AttributeProps {
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: string;
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
isCopyable?: boolean;
|
isCopyable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Attribute({
|
export default function Attribute({ name, value, tooltip, isCopyable }: AttributeProps) {
|
||||||
name,
|
return (
|
||||||
value,
|
<dl className="flex items-center gap-1 text-sm">
|
||||||
tooltip,
|
<dt
|
||||||
isCopyable,
|
className={cn(
|
||||||
}: AttributeProps) {
|
"w-1/3 sm:w-1/4 lg:w-1/3 shrink-0 min-w-0",
|
||||||
return (
|
"text-mist-500 dark:text-mist-400",
|
||||||
<dl className="flex gap-1 items-center text-sm">
|
tooltip ? "flex items-center gap-1" : undefined,
|
||||||
<dt
|
)}
|
||||||
className={cn(
|
>
|
||||||
'w-1/3 sm:w-1/4 lg:w-1/3 shrink-0 min-w-0',
|
{name}
|
||||||
'text-headplane-500 dark:text-headplane-400',
|
{tooltip ? (
|
||||||
tooltip ? 'flex items-center gap-1' : undefined,
|
<Tooltip>
|
||||||
)}
|
<Info className="size-4" />
|
||||||
>
|
<Tooltip.Body>{tooltip}</Tooltip.Body>
|
||||||
{name}
|
</Tooltip>
|
||||||
{tooltip ? (
|
) : undefined}
|
||||||
<Tooltip>
|
</dt>
|
||||||
<Info className="size-4" />
|
<dd
|
||||||
<Tooltip.Body>{tooltip}</Tooltip.Body>
|
className={cn(
|
||||||
</Tooltip>
|
"min-w-0 px-1.5 py-1 rounded-lg border border-transparent",
|
||||||
) : undefined}
|
...(isCopyable
|
||||||
</dt>
|
? [
|
||||||
<dd
|
"cursor-pointer hover:shadow-xs",
|
||||||
className={cn(
|
"hover:bg-mist-50 dark:hover:bg-mist-800",
|
||||||
'min-w-0 px-1.5 py-1 rounded-lg border border-transparent',
|
"hover:border-mist-100 dark:hover:border-mist-700",
|
||||||
...(isCopyable
|
]
|
||||||
? [
|
: []),
|
||||||
'cursor-pointer hover:shadow-xs',
|
)}
|
||||||
'hover:bg-headplane-50 dark:hover:bg-headplane-800',
|
>
|
||||||
'hover:border-headplane-100 dark:hover:border-headplane-700',
|
{isCopyable ? (
|
||||||
]
|
<button
|
||||||
: []),
|
type="button"
|
||||||
)}
|
className="relative flex w-full min-w-0 items-center gap-1.5"
|
||||||
>
|
onClick={async (event) => {
|
||||||
{isCopyable ? (
|
const svgs = event.currentTarget.querySelectorAll("svg");
|
||||||
<button
|
for (const svg of svgs) {
|
||||||
type="button"
|
svg.toggleAttribute("data-copied", true);
|
||||||
className="flex items-center gap-1.5 relative min-w-0 w-full"
|
}
|
||||||
onClick={async (event) => {
|
|
||||||
const svgs = event.currentTarget.querySelectorAll('svg');
|
|
||||||
for (const svg of svgs) {
|
|
||||||
svg.toggleAttribute('data-copied', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
await navigator.clipboard.writeText(value);
|
await navigator.clipboard.writeText(value);
|
||||||
toast(`Copied ${name} to clipboard`);
|
toast(`Copied ${name} to clipboard`);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
for (const svg of svgs) {
|
for (const svg of svgs) {
|
||||||
svg.toggleAttribute('data-copied', false);
|
svg.toggleAttribute("data-copied", false);
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div suppressHydrationWarning className="truncate">
|
<div suppressHydrationWarning className="truncate">
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
{isCopyable ? (
|
{isCopyable ? (
|
||||||
<div>
|
<div>
|
||||||
<Check className="size-4 hidden data-copied:block" />
|
<Check className="hidden size-4 data-copied:block" />
|
||||||
<Copy className="size-4 block data-copied:hidden" />
|
<Copy className="block size-4 data-copied:hidden" />
|
||||||
</div>
|
</div>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative min-w-0 truncate" suppressHydrationWarning>
|
<div className="relative min-w-0 truncate" suppressHydrationWarning>
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-38
@@ -1,43 +1,45 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useRef } from "react";
|
||||||
import { type AriaButtonOptions, useButton } from 'react-aria';
|
import { type AriaButtonOptions, useButton } from "react-aria";
|
||||||
import cn from '~/utils/cn';
|
|
||||||
|
|
||||||
export interface ButtonProps extends AriaButtonOptions<'button'> {
|
import cn from "~/utils/cn";
|
||||||
variant?: 'heavy' | 'light' | 'danger';
|
|
||||||
className?: string;
|
export interface ButtonProps extends AriaButtonOptions<"button"> {
|
||||||
children?: React.ReactNode;
|
variant?: "heavy" | "light" | "danger";
|
||||||
ref?: React.RefObject<HTMLButtonElement | null>;
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
ref?: React.RefObject<HTMLButtonElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Button({ variant = 'light', ...props }: ButtonProps) {
|
export default function Button({ variant = "light", ...props }: ButtonProps) {
|
||||||
// In case the button is used as a trigger ref
|
// In case the button is used as a trigger ref
|
||||||
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
|
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
|
||||||
const { buttonProps } = useButton(props, ref);
|
const { buttonProps } = useButton(props, ref);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-fit text-sm rounded-xl px-3 py-2',
|
"w-fit text-sm rounded-md px-3 py-2",
|
||||||
'focus:outline-hidden focus:ring-3',
|
"focus:outline-hidden focus:ring-3",
|
||||||
props.isDisabled && 'opacity-60 cursor-not-allowed',
|
props.isDisabled && "opacity-60 cursor-not-allowed",
|
||||||
...(variant === 'heavy'
|
...(variant === "heavy"
|
||||||
? [
|
? [
|
||||||
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
|
"bg-mist-900 dark:bg-mist-50 font-semibold",
|
||||||
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
|
"hover:bg-mist-900/90 dark:hover:bg-mist-50/90",
|
||||||
'text-headplane-200 dark:text-headplane-800',
|
"text-mist-200 dark:text-mist-800",
|
||||||
]
|
]
|
||||||
: variant === 'danger'
|
: variant === "danger"
|
||||||
? ['bg-red-500 text-white font-semibold', 'hover:bg-red-500/90']
|
? ["bg-red-500 text-white font-semibold", "hover:bg-red-500/90"]
|
||||||
: [
|
: [
|
||||||
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
"bg-mist-100 dark:bg-mist-800/50 font-medium",
|
||||||
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
"border border-mist-200 dark:border-mist-700",
|
||||||
]),
|
"hover:bg-mist-200/90 dark:hover:bg-mist-700/50",
|
||||||
props.className,
|
]),
|
||||||
)}
|
props.className,
|
||||||
>
|
)}
|
||||||
{props.children}
|
>
|
||||||
</button>
|
{props.children}
|
||||||
);
|
</button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-21
@@ -1,28 +1,29 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import Text from '~/components/Text';
|
|
||||||
import Title from '~/components/Title';
|
import Text from "~/components/Text";
|
||||||
import cn from '~/utils/cn';
|
import Title from "~/components/Title";
|
||||||
|
import cn from "~/utils/cn";
|
||||||
|
|
||||||
interface Props extends React.HTMLProps<HTMLDivElement> {
|
interface Props extends React.HTMLProps<HTMLDivElement> {
|
||||||
variant?: 'raised' | 'flat';
|
variant?: "raised" | "flat";
|
||||||
}
|
}
|
||||||
|
|
||||||
function Card({ variant = 'raised', ...props }: Props) {
|
function Card({ variant = "raised", ...props }: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...props}
|
{...props}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full max-w-md rounded-3xl p-5',
|
"w-full max-w-md rounded-lg p-5",
|
||||||
variant === 'flat'
|
variant === "flat"
|
||||||
? 'bg-transparent shadow-none'
|
? "bg-transparent shadow-none"
|
||||||
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-xs',
|
: "bg-mist-50/50 dark:bg-mist-950/50 shadow-surface",
|
||||||
'border border-headplane-100 dark:border-headplane-800',
|
"border border-mist-100 dark:border-mist-800",
|
||||||
props.className,
|
props.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Object.assign(Card, { Title, Text });
|
export default Object.assign(Card, { Title, Text });
|
||||||
|
|||||||
+23
-27
@@ -1,32 +1,28 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import cn from '~/utils/cn';
|
|
||||||
|
import cn from "~/utils/cn";
|
||||||
|
|
||||||
export interface ChipProps {
|
export interface ChipProps {
|
||||||
text: string;
|
text: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
leftIcon?: React.ReactNode;
|
leftIcon?: React.ReactNode;
|
||||||
rightIcon?: React.ReactNode;
|
rightIcon?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Chip({
|
export default function Chip({ text, className, leftIcon, rightIcon }: ChipProps) {
|
||||||
text,
|
return (
|
||||||
className,
|
<span
|
||||||
leftIcon,
|
className={cn(
|
||||||
rightIcon,
|
"h-5 text-xs py-0.5 px-1 rounded-md text-nowrap",
|
||||||
}: ChipProps) {
|
"text-mist-700 dark:text-mist-100",
|
||||||
return (
|
"bg-mist-100 dark:bg-mist-700",
|
||||||
<span
|
"inline-flex items-center gap-x-1",
|
||||||
className={cn(
|
className,
|
||||||
'h-5 text-xs py-0.5 px-1 rounded-md text-nowrap',
|
)}
|
||||||
'text-headplane-700 dark:text-headplane-100',
|
>
|
||||||
'bg-headplane-100 dark:bg-headplane-700',
|
{leftIcon}
|
||||||
'inline-flex items-center gap-x-1',
|
{text}
|
||||||
className,
|
{rightIcon}
|
||||||
)}
|
</span>
|
||||||
>
|
);
|
||||||
{leftIcon}
|
|
||||||
{text}
|
|
||||||
{rightIcon}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-41
@@ -1,50 +1,51 @@
|
|||||||
import { Check, Copy } from 'lucide-react';
|
import { Check, Copy } from "lucide-react";
|
||||||
import { HTMLProps } from 'react';
|
import { HTMLProps } from "react";
|
||||||
import cn from '~/utils/cn';
|
|
||||||
import toast from '~/utils/toast';
|
import cn from "~/utils/cn";
|
||||||
|
import toast from "~/utils/toast";
|
||||||
|
|
||||||
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
|
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
|
||||||
isCopyable?: boolean;
|
isCopyable?: boolean;
|
||||||
children: string | string[] | number;
|
children: string | string[] | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Code({ isCopyable, children, className }: CodeProps) {
|
export default function Code({ isCopyable, children, className }: CodeProps) {
|
||||||
return (
|
return (
|
||||||
<code
|
<code
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-headplane-100 dark:bg-headplane-800 px-1 py-0.5 font-mono',
|
"bg-mist-100 dark:bg-mist-800 px-1 py-0.5 font-mono",
|
||||||
'rounded-lg focus-within:outline-hidden focus-within:ring-2',
|
"rounded-sm focus-within:outline-hidden focus-within:ring-2",
|
||||||
isCopyable && 'relative pr-7',
|
isCopyable && "relative pr-7",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{isCopyable && (
|
{isCopyable && (
|
||||||
<button
|
<button
|
||||||
className="bottom-0 right-0 absolute"
|
className="absolute right-0 bottom-0"
|
||||||
onClick={async (event) => {
|
onClick={async (event) => {
|
||||||
const text = Array.isArray(children) ? children.join('') : children;
|
const text = Array.isArray(children) ? children.join("") : children;
|
||||||
|
|
||||||
const svgs = event.currentTarget.querySelectorAll('svg');
|
const svgs = event.currentTarget.querySelectorAll("svg");
|
||||||
for (const svg of svgs) {
|
for (const svg of svgs) {
|
||||||
svg.toggleAttribute('data-copied', true);
|
svg.toggleAttribute("data-copied", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
toast('Copied to clipboard');
|
toast("Copied to clipboard");
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
for (const svg of svgs) {
|
for (const svg of svgs) {
|
||||||
svg.toggleAttribute('data-copied', false);
|
svg.toggleAttribute("data-copied", false);
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Check className="h-4.5 w-4.5 p-1 hidden data-copied:block" />
|
<Check className="hidden h-4.5 w-4.5 p-1 data-copied:block" />
|
||||||
<Copy className="h-4.5 w-4.5 p-1 block data-copied:hidden" />
|
<Copy className="block h-4.5 w-4.5 p-1 data-copied:hidden" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+151
-163
@@ -1,194 +1,182 @@
|
|||||||
import React, { cloneElement, useEffect, useRef } from 'react';
|
import React, { cloneElement, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
type AriaDialogProps,
|
type AriaDialogProps,
|
||||||
type AriaModalOverlayProps,
|
type AriaModalOverlayProps,
|
||||||
Overlay,
|
Overlay,
|
||||||
useDialog,
|
useDialog,
|
||||||
useModalOverlay,
|
useModalOverlay,
|
||||||
useOverlayTrigger,
|
useOverlayTrigger,
|
||||||
} from 'react-aria';
|
} from "react-aria";
|
||||||
import { Form, type HTMLFormMethod } from 'react-router';
|
import { Form, type HTMLFormMethod } from "react-router";
|
||||||
import {
|
import {
|
||||||
type OverlayTriggerProps,
|
type OverlayTriggerProps,
|
||||||
type OverlayTriggerState,
|
type OverlayTriggerState,
|
||||||
useOverlayTriggerState,
|
useOverlayTriggerState,
|
||||||
} from 'react-stately';
|
} from "react-stately";
|
||||||
import Button, { ButtonProps } from '~/components/Button';
|
|
||||||
import Card from '~/components/Card';
|
import Button, { ButtonProps } from "~/components/Button";
|
||||||
import IconButton, { IconButtonProps } from '~/components/IconButton';
|
import Card from "~/components/Card";
|
||||||
import Text from '~/components/Text';
|
import IconButton, { IconButtonProps } from "~/components/IconButton";
|
||||||
import Title from '~/components/Title';
|
import Text from "~/components/Text";
|
||||||
import cn from '~/utils/cn';
|
import Title from "~/components/Title";
|
||||||
import { useLiveData } from '~/utils/live-data';
|
import cn from "~/utils/cn";
|
||||||
|
import { useLiveData } from "~/utils/live-data";
|
||||||
|
|
||||||
export interface DialogProps extends OverlayTriggerProps {
|
export interface DialogProps extends OverlayTriggerProps {
|
||||||
children:
|
children:
|
||||||
| [
|
| [
|
||||||
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
|
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
|
||||||
React.ReactElement<DialogPanelProps>,
|
React.ReactElement<DialogPanelProps>,
|
||||||
]
|
]
|
||||||
| React.ReactElement<DialogPanelProps>;
|
| React.ReactElement<DialogPanelProps>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Dialog(props: DialogProps) {
|
function Dialog(props: DialogProps) {
|
||||||
const { pause, resume } = useLiveData();
|
const { pause, resume } = useLiveData();
|
||||||
const state = useOverlayTriggerState(props);
|
const state = useOverlayTriggerState(props);
|
||||||
const { triggerProps, overlayProps } = useOverlayTrigger(
|
const { triggerProps, overlayProps } = useOverlayTrigger(
|
||||||
{
|
{
|
||||||
type: 'dialog',
|
type: "dialog",
|
||||||
},
|
},
|
||||||
state,
|
state,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.isOpen) {
|
if (state.isOpen) {
|
||||||
pause();
|
pause();
|
||||||
} else {
|
} else {
|
||||||
resume();
|
resume();
|
||||||
}
|
}
|
||||||
}, [state.isOpen]);
|
}, [state.isOpen]);
|
||||||
|
|
||||||
if (Array.isArray(props.children)) {
|
if (Array.isArray(props.children)) {
|
||||||
const [button, panel] = props.children;
|
const [button, panel] = props.children;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{cloneElement(button, triggerProps)}
|
{cloneElement(button, triggerProps)}
|
||||||
{state.isOpen && (
|
{state.isOpen && (
|
||||||
<DModal state={state}>
|
<DModal state={state}>
|
||||||
{cloneElement(panel, {
|
{cloneElement(panel, {
|
||||||
...overlayProps,
|
...overlayProps,
|
||||||
close: () => state.close(),
|
close: () => state.close(),
|
||||||
})}
|
})}
|
||||||
</DModal>
|
</DModal>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DModal state={state}>
|
<DModal state={state}>
|
||||||
{cloneElement(props.children, {
|
{cloneElement(props.children, {
|
||||||
...overlayProps,
|
...overlayProps,
|
||||||
close: () => state.close(),
|
close: () => state.close(),
|
||||||
})}
|
})}
|
||||||
</DModal>
|
</DModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DialogPanelProps extends AriaDialogProps {
|
export interface DialogPanelProps extends AriaDialogProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
variant?: 'normal' | 'destructive' | 'unactionable';
|
variant?: "normal" | "destructive" | "unactionable";
|
||||||
onSubmit?: React.FormEventHandler<HTMLFormElement>;
|
onSubmit?: React.FormEventHandler<HTMLFormElement>;
|
||||||
method?: HTMLFormMethod;
|
method?: HTMLFormMethod;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
|
|
||||||
// Anonymous (passed by parent)
|
// Anonymous (passed by parent)
|
||||||
close?: () => void;
|
close?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Panel(props: DialogPanelProps) {
|
function Panel(props: DialogPanelProps) {
|
||||||
const {
|
const { children, onSubmit, isDisabled, close, variant, method = "POST" } = props;
|
||||||
children,
|
const ref = useRef<HTMLFormElement | null>(null);
|
||||||
onSubmit,
|
const { dialogProps } = useDialog(
|
||||||
isDisabled,
|
{
|
||||||
close,
|
...props,
|
||||||
variant,
|
role: "alertdialog",
|
||||||
method = 'POST',
|
},
|
||||||
} = props;
|
ref,
|
||||||
const ref = useRef<HTMLFormElement | null>(null);
|
);
|
||||||
const { dialogProps } = useDialog(
|
|
||||||
{
|
|
||||||
...props,
|
|
||||||
role: 'alertdialog',
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
{...dialogProps}
|
{...dialogProps}
|
||||||
className={cn(
|
className={cn("outline-hidden rounded-lg w-full max-w-lg", "bg-white dark:bg-mist-900")}
|
||||||
'outline-hidden rounded-3xl w-full max-w-lg',
|
method={method ?? "POST"}
|
||||||
'bg-white dark:bg-headplane-900',
|
onSubmit={(event) => {
|
||||||
)}
|
if (onSubmit) {
|
||||||
method={method ?? 'POST'}
|
onSubmit(event);
|
||||||
onSubmit={(event) => {
|
}
|
||||||
if (onSubmit) {
|
|
||||||
onSubmit(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
close?.();
|
close?.();
|
||||||
}}
|
}}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
<Card className="w-full max-w-lg" variant="flat">
|
<Card className="w-full max-w-lg" variant="flat">
|
||||||
{children}
|
{children}
|
||||||
<div className="mt-6 flex justify-end gap-4">
|
<div className="mt-6 flex justify-end gap-4">
|
||||||
{variant === 'unactionable' ? (
|
{variant === "unactionable" ? (
|
||||||
<Button onPress={close}>Close</Button>
|
<Button onPress={close}>Close</Button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button onPress={close}>Cancel</Button>
|
<Button onPress={close}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
type="submit"
|
type="submit"
|
||||||
variant={variant === 'destructive' ? 'danger' : 'heavy'}
|
variant={variant === "destructive" ? "danger" : "heavy"}
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DModalProps extends AriaModalOverlayProps {
|
interface DModalProps extends AriaModalOverlayProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
state: OverlayTriggerState;
|
state: OverlayTriggerState;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DModal(props: DModalProps) {
|
function DModal(props: DModalProps) {
|
||||||
const { children, state } = props;
|
const { children, state } = props;
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const { modalProps, underlayProps } = useModalOverlay(props, state, ref);
|
const { modalProps, underlayProps } = useModalOverlay(props, state, ref);
|
||||||
|
|
||||||
if (!state.isOpen) {
|
if (!state.isOpen) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay>
|
<Overlay>
|
||||||
<div
|
<div
|
||||||
{...underlayProps}
|
{...underlayProps}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-0 h-screen w-screen z-20',
|
"fixed inset-0 h-screen w-screen z-20",
|
||||||
'flex items-center justify-center',
|
"flex items-center justify-center",
|
||||||
'bg-headplane-900/15 dark:bg-headplane-900/30',
|
"bg-mist-900/15 dark:bg-mist-900/30",
|
||||||
'entering:animate-in exiting:animate-out',
|
"entering:animate-in exiting:animate-out",
|
||||||
'entering:fade-in entering:duration-100 entering:ease-out',
|
"entering:fade-in entering:duration-100 entering:ease-out",
|
||||||
'exiting:fade-out exiting:duration-50 exiting:ease-in',
|
"exiting:fade-out exiting:duration-50 exiting:ease-in",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
{...modalProps}
|
{...modalProps}
|
||||||
className={cn(
|
className={cn("fixed inset-0 h-screen w-screen z-20", "flex items-center justify-center")}
|
||||||
'fixed inset-0 h-screen w-screen z-20',
|
>
|
||||||
'flex items-center justify-center',
|
{children}
|
||||||
)}
|
</div>
|
||||||
>
|
</Overlay>
|
||||||
{children}
|
);
|
||||||
</div>
|
|
||||||
</Overlay>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Object.assign(Dialog, {
|
export default Object.assign(Dialog, {
|
||||||
Button,
|
Button,
|
||||||
IconButton,
|
IconButton,
|
||||||
Panel,
|
Panel,
|
||||||
Title,
|
Title,
|
||||||
Text,
|
Text,
|
||||||
});
|
});
|
||||||
|
|||||||
+61
-65
@@ -1,71 +1,67 @@
|
|||||||
import { CircleX } from 'lucide-react';
|
import { CircleX } from "lucide-react";
|
||||||
import Link from '~/components/Link';
|
|
||||||
import cn from '~/utils/cn';
|
import Link from "~/components/Link";
|
||||||
|
import cn from "~/utils/cn";
|
||||||
|
|
||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
url: string;
|
url: string;
|
||||||
debug: boolean;
|
debug: boolean;
|
||||||
healthy: boolean;
|
healthy: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Footer({ url, debug, healthy }: FooterProps) {
|
export default function Footer({ url, debug, healthy }: FooterProps) {
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed w-full bottom-0 left-0 z-40 h-12',
|
"fixed w-full bottom-0 left-0 z-40 h-12",
|
||||||
'flex items-center justify-center',
|
"flex items-center justify-center",
|
||||||
'bg-headplane-50 dark:bg-headplane-950',
|
"bg-mist-50 dark:bg-mist-950",
|
||||||
'dark:border-t dark:border-headplane-800',
|
"dark:border-t dark:border-mist-800",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'grid grid-rows-1 items-center container mx-auto',
|
"grid grid-rows-1 items-center container",
|
||||||
!healthy && 'md:grid-cols-[1fr_auto] grid-cols-1',
|
!healthy && "md:grid-cols-[1fr_auto] grid-cols-1",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div className={cn("text-xs leading-none", !healthy && "hidden md:block")}>
|
||||||
className={cn('text-xs leading-none', !healthy && 'hidden md:block')}
|
<p>
|
||||||
>
|
Headplane is free. Please consider{" "}
|
||||||
<p>
|
<Link to="https://github.com/sponsors/tale" name="Aarnav's GitHub Sponsors">
|
||||||
Headplane is free. Please consider{' '}
|
donating
|
||||||
<Link
|
</Link>{" "}
|
||||||
to="https://github.com/sponsors/tale"
|
to support development.{" "}
|
||||||
name="Aarnav's GitHub Sponsors"
|
</p>
|
||||||
>
|
<p className="opacity-75">
|
||||||
donating
|
Version: {__VERSION__}
|
||||||
</Link>{' '}
|
{" — "}
|
||||||
to support development.{' '}
|
Connecting to{" "}
|
||||||
</p>
|
<button
|
||||||
<p className="opacity-75">
|
type="button"
|
||||||
Version: {__VERSION__}
|
tabIndex={0} // Allows keyboard focus
|
||||||
{' — '}
|
className={cn(
|
||||||
Connecting to{' '}
|
"blur-sm hover:blur-none focus:blur-none transition",
|
||||||
<button
|
"focus:outline-hidden focus:ring-2 rounded-xs",
|
||||||
type="button"
|
)}
|
||||||
tabIndex={0} // Allows keyboard focus
|
>
|
||||||
className={cn(
|
{url}
|
||||||
'blur-sm hover:blur-none focus:blur-none transition',
|
</button>
|
||||||
'focus:outline-hidden focus:ring-2 rounded-xs',
|
{debug && " (Debug mode enabled)"}
|
||||||
)}
|
</p>
|
||||||
>
|
</div>
|
||||||
{url}
|
{!healthy ? (
|
||||||
</button>
|
<div
|
||||||
{debug && ' (Debug mode enabled)'}
|
className={cn(
|
||||||
</p>
|
"flex gap-1.5 items-center p-2 rounded-md text-sm",
|
||||||
</div>
|
"bg-red-500 text-white font-semibold",
|
||||||
{!healthy ? (
|
)}
|
||||||
<div
|
>
|
||||||
className={cn(
|
<CircleX size={16} strokeWidth={3} />
|
||||||
'flex gap-1.5 items-center p-2 rounded-xl text-sm',
|
<p className="text-nowrap">Headscale is unreachable</p>
|
||||||
'bg-red-500 text-white font-semibold',
|
</div>
|
||||||
)}
|
) : undefined}
|
||||||
>
|
</div>
|
||||||
<CircleX size={16} strokeWidth={3} />
|
</footer>
|
||||||
<p className="text-nowrap">Headscale is unreachable</p>
|
);
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+144
-173
@@ -1,192 +1,163 @@
|
|||||||
import {
|
import { CircleUser, Globe2, Lock, Server, Settings, Users } from "lucide-react";
|
||||||
CircleUser,
|
import type { ReactNode } from "react";
|
||||||
Globe2,
|
import { NavLink, useSubmit } from "react-router";
|
||||||
Lock,
|
|
||||||
Server,
|
import Logo from "~/components/Logo";
|
||||||
Settings,
|
import Menu from "~/components/Menu";
|
||||||
Users,
|
import { AuthSession } from "~/server/web/sessions";
|
||||||
} from 'lucide-react';
|
import cn from "~/utils/cn";
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import { NavLink, useSubmit } from 'react-router';
|
|
||||||
import Logo from '~/components/Logo';
|
|
||||||
import Menu from '~/components/Menu';
|
|
||||||
import { AuthSession } from '~/server/web/sessions';
|
|
||||||
import cn from '~/utils/cn';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
configAvailable: boolean;
|
configAvailable: boolean;
|
||||||
onboarding: boolean;
|
onboarding: boolean;
|
||||||
user?: AuthSession['user'];
|
user?: AuthSession["user"];
|
||||||
access: {
|
access: {
|
||||||
ui: boolean;
|
ui: boolean;
|
||||||
machines: boolean;
|
machines: boolean;
|
||||||
dns: boolean;
|
dns: boolean;
|
||||||
users: boolean;
|
users: boolean;
|
||||||
policy: boolean;
|
policy: boolean;
|
||||||
settings: boolean;
|
settings: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LinkProps {
|
interface LinkProps {
|
||||||
href: string;
|
href: string;
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TabLinkProps {
|
interface TabLinkProps {
|
||||||
name: string;
|
name: string;
|
||||||
to: string;
|
to: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabLink({ name, to, icon }: TabLinkProps) {
|
function TabLink({ name, to, icon }: TabLinkProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative py-2">
|
<div className="relative py-2">
|
||||||
<NavLink
|
<NavLink
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'px-3 py-2 flex items-center rounded-md text-nowrap gap-x-2.5',
|
"px-3 py-2 flex items-center rounded-md text-nowrap gap-x-2.5",
|
||||||
'after:absolute after:bottom-0 after:left-3 after:right-3',
|
"after:absolute after:bottom-0 after:left-3 after:right-3",
|
||||||
'after:h-0.5 after:bg-headplane-900 dark:after:bg-headplane-200',
|
"after:h-0.5 after:bg-mist-900 dark:after:bg-mist-200",
|
||||||
'hover:bg-headplane-200 dark:hover:bg-headplane-900',
|
"hover:bg-mist-200 dark:hover:bg-mist-900",
|
||||||
'focus:outline-hidden focus:ring-3',
|
"focus:outline-hidden focus:ring-3",
|
||||||
isActive ? 'after:visible' : 'after:invisible',
|
isActive ? "after:visible" : "after:invisible",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
prefetch="intent"
|
prefetch="intent"
|
||||||
to={to}
|
to={to}
|
||||||
>
|
>
|
||||||
{icon} {name}
|
{icon} {name}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Link({ href, text }: LinkProps) {
|
function Link({ href, text }: LinkProps) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
className={cn(
|
className={cn(
|
||||||
'hidden sm:block hover:underline text-sm',
|
"hidden sm:block hover:underline text-sm",
|
||||||
'focus:outline-hidden focus:ring-3 rounded-md',
|
"focus:outline-hidden focus:ring-3 rounded-md",
|
||||||
)}
|
)}
|
||||||
href={href}
|
href={href}
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Header(data: Props) {
|
export default function Header(data: Props) {
|
||||||
const submit = useSubmit();
|
const submit = useSubmit();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-headplane-100 dark:bg-headplane-950',
|
"bg-mist-100 dark:bg-mist-950",
|
||||||
'text-headplane-800 dark:text-headplane-200',
|
"text-mist-800 dark:text-mist-200",
|
||||||
'dark:border-b dark:border-headplane-800',
|
"dark:border-b dark:border-mist-800",
|
||||||
'shadow-inner',
|
"shadow-inner",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="container flex items-center justify-between py-4">
|
<div className="container flex items-center justify-between py-4">
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
<Logo />
|
<Logo />
|
||||||
<h1 className="text-2xl font-semibold">headplane</h1>
|
<h1 className="text-2xl font-semibold">headplane</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<Link href="https://tailscale.com/download" text="Download" />
|
<Link href="https://tailscale.com/download" text="Download" />
|
||||||
<Link href="https://github.com/tale/headplane" text="GitHub" />
|
<Link href="https://github.com/tale/headplane" text="GitHub" />
|
||||||
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
|
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
|
||||||
{data.user ? (
|
{data.user ? (
|
||||||
<Menu>
|
<Menu>
|
||||||
<Menu.IconButton
|
<Menu.IconButton className={cn(data.user.picture ? "p-0" : "")} label="User">
|
||||||
className={cn(data.user.picture ? 'p-0' : '')}
|
{data.user.picture ? (
|
||||||
label="User"
|
<img
|
||||||
>
|
alt={data.user.name}
|
||||||
{data.user.picture ? (
|
className="h-8 w-8 rounded-full"
|
||||||
<img
|
src={data.user.picture}
|
||||||
alt={data.user.name}
|
/>
|
||||||
className="w-8 h-8 rounded-full"
|
) : (
|
||||||
src={data.user.picture}
|
<CircleUser />
|
||||||
/>
|
)}
|
||||||
) : (
|
</Menu.IconButton>
|
||||||
<CircleUser />
|
<Menu.Panel
|
||||||
)}
|
disabledKeys={["profile"]}
|
||||||
</Menu.IconButton>
|
onAction={(key) => {
|
||||||
<Menu.Panel
|
if (key === "logout") {
|
||||||
disabledKeys={['profile']}
|
submit(
|
||||||
onAction={(key) => {
|
{},
|
||||||
if (key === 'logout') {
|
{
|
||||||
submit(
|
method: "POST",
|
||||||
{},
|
action: "/logout",
|
||||||
{
|
},
|
||||||
method: 'POST',
|
);
|
||||||
action: '/logout',
|
}
|
||||||
},
|
}}
|
||||||
);
|
>
|
||||||
}
|
<Menu.Section>
|
||||||
}}
|
<Menu.Item key="profile" textValue="Profile">
|
||||||
>
|
<div className="text-black dark:text-mist-50">
|
||||||
<Menu.Section>
|
<p className="font-bold">{data.user.name}</p>
|
||||||
<Menu.Item key="profile" textValue="Profile">
|
<p>{data.user.email}</p>
|
||||||
<div className="text-black dark:text-headplane-50">
|
</div>
|
||||||
<p className="font-bold">{data.user.name}</p>
|
</Menu.Item>
|
||||||
<p>{data.user.email}</p>
|
<Menu.Item key="logout" textValue="Logout">
|
||||||
</div>
|
<p className="text-red-500 dark:text-red-400">Logout</p>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item key="logout" textValue="Logout">
|
</Menu.Section>
|
||||||
<p className="text-red-500 dark:text-red-400">Logout</p>
|
</Menu.Panel>
|
||||||
</Menu.Item>
|
</Menu>
|
||||||
</Menu.Section>
|
) : undefined}
|
||||||
</Menu.Panel>
|
</div>
|
||||||
</Menu>
|
</div>
|
||||||
) : undefined}
|
{data.access.ui && !data.onboarding ? (
|
||||||
</div>
|
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
|
||||||
</div>
|
{data.access.machines ? (
|
||||||
{data.access.ui && !data.onboarding ? (
|
<TabLink icon={<Server className="w-5" />} name="Machines" to="/machines" />
|
||||||
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
|
) : undefined}
|
||||||
{data.access.machines ? (
|
{data.access.users ? (
|
||||||
<TabLink
|
<TabLink icon={<Users className="w-5" />} name="Users" to="/users" />
|
||||||
icon={<Server className="w-5" />}
|
) : undefined}
|
||||||
name="Machines"
|
{data.access.policy ? (
|
||||||
to="/machines"
|
<TabLink icon={<Lock className="w-5" />} name="Access Control" to="/acls" />
|
||||||
/>
|
) : undefined}
|
||||||
) : undefined}
|
{data.configAvailable ? (
|
||||||
{data.access.users ? (
|
<>
|
||||||
<TabLink
|
{data.access.dns ? (
|
||||||
icon={<Users className="w-5" />}
|
<TabLink icon={<Globe2 className="w-5" />} name="DNS" to="/dns" />
|
||||||
name="Users"
|
) : undefined}
|
||||||
to="/users"
|
{data.access.settings ? (
|
||||||
/>
|
<TabLink icon={<Settings className="w-5" />} name="Settings" to="/settings" />
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{data.access.policy ? (
|
</>
|
||||||
<TabLink
|
) : undefined}
|
||||||
icon={<Lock className="w-5" />}
|
</nav>
|
||||||
name="Access Control"
|
) : undefined}
|
||||||
to="/acls"
|
</header>
|
||||||
/>
|
);
|
||||||
) : undefined}
|
|
||||||
{data.configAvailable ? (
|
|
||||||
<>
|
|
||||||
{data.access.dns ? (
|
|
||||||
<TabLink
|
|
||||||
icon={<Globe2 className="w-5" />}
|
|
||||||
name="DNS"
|
|
||||||
to="/dns"
|
|
||||||
/>
|
|
||||||
) : undefined}
|
|
||||||
{data.access.settings ? (
|
|
||||||
<TabLink
|
|
||||||
icon={<Settings className="w-5" />}
|
|
||||||
name="Settings"
|
|
||||||
to="/settings"
|
|
||||||
/>
|
|
||||||
) : undefined}
|
|
||||||
</>
|
|
||||||
) : undefined}
|
|
||||||
</nav>
|
|
||||||
) : undefined}
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,44 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useRef } from "react";
|
||||||
import { type AriaButtonOptions, useButton } from 'react-aria';
|
import { type AriaButtonOptions, useButton } from "react-aria";
|
||||||
import cn from '~/utils/cn';
|
|
||||||
|
|
||||||
export interface IconButtonProps extends AriaButtonOptions<'button'> {
|
import cn from "~/utils/cn";
|
||||||
variant?: 'heavy' | 'light';
|
|
||||||
className?: string;
|
export interface IconButtonProps extends AriaButtonOptions<"button"> {
|
||||||
children: React.ReactNode;
|
variant?: "heavy" | "light";
|
||||||
label: string;
|
className?: string;
|
||||||
ref?: React.RefObject<HTMLButtonElement | null>;
|
children: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
ref?: React.RefObject<HTMLButtonElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function IconButton({
|
export default function IconButton({ variant = "light", ...props }: IconButtonProps) {
|
||||||
variant = 'light',
|
// In case the button is used as a trigger ref
|
||||||
...props
|
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
|
||||||
}: IconButtonProps) {
|
const { buttonProps } = useButton(props, ref);
|
||||||
// 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
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
aria-label={props.label}
|
aria-label={props.label}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-full flex items-center justify-center p-1',
|
"rounded-full flex items-center justify-center p-1",
|
||||||
'focus:outline-hidden focus:ring-3',
|
"focus:outline-hidden focus:ring-3",
|
||||||
props.isDisabled && 'opacity-60 cursor-not-allowed',
|
props.isDisabled && "opacity-60 cursor-not-allowed",
|
||||||
...(variant === 'heavy'
|
...(variant === "heavy"
|
||||||
? [
|
? [
|
||||||
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
|
"bg-mist-900 dark:bg-mist-50 font-semibold",
|
||||||
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
|
"hover:bg-mist-900/90 dark:hover:bg-mist-50/90",
|
||||||
'text-headplane-200 dark:text-headplane-800',
|
"text-mist-200 dark:text-mist-800",
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
"bg-mist-100 dark:bg-mist-700/30 font-medium",
|
||||||
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
"hover:bg-mist-200/90 dark:hover:bg-mist-800/30",
|
||||||
]),
|
]),
|
||||||
props.className,
|
props.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-84
@@ -1,93 +1,83 @@
|
|||||||
import { Asterisk } from 'lucide-react';
|
import { Asterisk } from "lucide-react";
|
||||||
import { useRef } from 'react';
|
import { useRef } from "react";
|
||||||
import { type AriaTextFieldProps, useId, useTextField } from 'react-aria';
|
import { type AriaTextFieldProps, useId, useTextField } from "react-aria";
|
||||||
import cn from '~/utils/cn';
|
|
||||||
|
import cn from "~/utils/cn";
|
||||||
|
|
||||||
export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
|
export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
|
||||||
label: string;
|
label: string;
|
||||||
labelHidden?: boolean;
|
labelHidden?: boolean;
|
||||||
isRequired?: boolean;
|
isRequired?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
isInvalid?: boolean;
|
isInvalid?: boolean;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Input(props: InputProps) {
|
export default function Input(props: InputProps) {
|
||||||
const {
|
const { label, labelHidden, className, isInvalid: customIsInvalid, errorMessage } = props;
|
||||||
label,
|
const ref = useRef<HTMLInputElement | null>(null);
|
||||||
labelHidden,
|
const id = useId(props.id);
|
||||||
className,
|
|
||||||
isInvalid: customIsInvalid,
|
|
||||||
errorMessage,
|
|
||||||
} = props;
|
|
||||||
const ref = useRef<HTMLInputElement | null>(null);
|
|
||||||
const id = useId(props.id);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
labelProps,
|
labelProps,
|
||||||
inputProps,
|
inputProps,
|
||||||
descriptionProps,
|
descriptionProps,
|
||||||
errorMessageProps,
|
errorMessageProps,
|
||||||
isInvalid: ariaIsInvalid,
|
isInvalid: ariaIsInvalid,
|
||||||
validationErrors,
|
validationErrors,
|
||||||
} = useTextField(
|
} = useTextField(
|
||||||
{
|
{
|
||||||
...props,
|
...props,
|
||||||
label,
|
label,
|
||||||
'aria-label': label,
|
"aria-label": label,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isInvalid = customIsInvalid ?? ariaIsInvalid;
|
const isInvalid = customIsInvalid ?? ariaIsInvalid;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full">
|
<div className="flex w-full flex-col">
|
||||||
<label
|
<label
|
||||||
{...labelProps}
|
{...labelProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs font-medium px-3 mb-0.5',
|
"text-xs font-medium px-3 mb-0.5",
|
||||||
'text-headplane-700 dark:text-headplane-100',
|
"text-mist-700 dark:text-mist-100",
|
||||||
labelHidden && 'sr-only',
|
labelHidden && "sr-only",
|
||||||
)}
|
)}
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{props.isRequired && (
|
{props.isRequired && <Asterisk className="ml-0.5 inline w-3.5 pb-1 text-red-500" />}
|
||||||
<Asterisk className="inline w-3.5 text-red-500 pb-1 ml-0.5" />
|
</label>
|
||||||
)}
|
<input
|
||||||
</label>
|
{...inputProps}
|
||||||
<input
|
className={cn(
|
||||||
{...inputProps}
|
"rounded-md px-3 py-2",
|
||||||
className={cn(
|
"focus:outline-hidden focus:ring-3",
|
||||||
'rounded-xl px-3 py-2',
|
"bg-white dark:bg-mist-900",
|
||||||
'focus:outline-hidden focus:ring-3',
|
"border border-mist-100 dark:border-mist-800",
|
||||||
'bg-white dark:bg-headplane-900',
|
className,
|
||||||
'border border-headplane-100 dark:border-headplane-800',
|
)}
|
||||||
className,
|
ref={ref}
|
||||||
)}
|
required={props.isRequired}
|
||||||
ref={ref}
|
/>
|
||||||
required={props.isRequired}
|
{props.description && (
|
||||||
/>
|
<div
|
||||||
{props.description && (
|
{...descriptionProps}
|
||||||
<div
|
className={cn("text-xs px-3 mt-1", "text-mist-500 dark:text-mist-400")}
|
||||||
{...descriptionProps}
|
>
|
||||||
className={cn(
|
{props.description}
|
||||||
'text-xs px-3 mt-1',
|
</div>
|
||||||
'text-headplane-500 dark:text-headplane-400',
|
)}
|
||||||
)}
|
{isInvalid ? (
|
||||||
>
|
<div
|
||||||
{props.description}
|
{...errorMessageProps}
|
||||||
</div>
|
className={cn("text-xs px-3 mt-1", "text-red-500 dark:text-red-400")}
|
||||||
)}
|
>
|
||||||
{isInvalid ? (
|
{errorMessage ?? validationErrors.join(" ")}
|
||||||
<div
|
</div>
|
||||||
{...errorMessageProps}
|
) : null}
|
||||||
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
|
</div>
|
||||||
>
|
);
|
||||||
{errorMessage ?? validationErrors.join(' ')}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+128
-134
@@ -1,170 +1,164 @@
|
|||||||
import React, { useRef, cloneElement } from 'react';
|
import React, { useRef, cloneElement } from "react";
|
||||||
import { type AriaMenuProps, Key, Placement, useMenuTrigger } from 'react-aria';
|
import { type AriaMenuProps, Key, Placement, useMenuTrigger } from "react-aria";
|
||||||
import { useMenu, useMenuItem, useMenuSection, useSeparator } from 'react-aria';
|
import { useMenu, useMenuItem, useMenuSection, useSeparator } from "react-aria";
|
||||||
import { Item, Section } from 'react-stately';
|
import { Item, Section } from "react-stately";
|
||||||
import {
|
import {
|
||||||
type MenuTriggerProps,
|
type MenuTriggerProps,
|
||||||
Node,
|
Node,
|
||||||
TreeState,
|
TreeState,
|
||||||
useMenuTriggerState,
|
useMenuTriggerState,
|
||||||
useTreeState,
|
useTreeState,
|
||||||
} from 'react-stately';
|
} from "react-stately";
|
||||||
import Button, { ButtonProps } from '~/components/Button';
|
|
||||||
import IconButton, { IconButtonProps } from '~/components/IconButton';
|
import Button, { ButtonProps } from "~/components/Button";
|
||||||
import Popover from '~/components/Popover';
|
import IconButton, { IconButtonProps } from "~/components/IconButton";
|
||||||
import cn from '~/utils/cn';
|
import Popover from "~/components/Popover";
|
||||||
|
import cn from "~/utils/cn";
|
||||||
|
|
||||||
interface MenuProps extends MenuTriggerProps {
|
interface MenuProps extends MenuTriggerProps {
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
disabledKeys?: Key[];
|
disabledKeys?: Key[];
|
||||||
children: [
|
children: [
|
||||||
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
|
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
|
||||||
React.ReactElement<MenuPanelProps>,
|
React.ReactElement<MenuPanelProps>,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: onAction is called twice for some reason?
|
// TODO: onAction is called twice for some reason?
|
||||||
// TODO: isDisabled per-prop
|
// TODO: isDisabled per-prop
|
||||||
function Menu(props: MenuProps) {
|
function Menu(props: MenuProps) {
|
||||||
const { placement = 'bottom', isDisabled, disabledKeys = [] } = props;
|
const { placement = "bottom", isDisabled, disabledKeys = [] } = props;
|
||||||
const state = useMenuTriggerState(props);
|
const state = useMenuTriggerState(props);
|
||||||
const ref = useRef<HTMLButtonElement | null>(null);
|
const ref = useRef<HTMLButtonElement | null>(null);
|
||||||
const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
|
const { menuTriggerProps, menuProps } = useMenuTrigger<object>({}, state, ref);
|
||||||
{},
|
|
||||||
state,
|
|
||||||
ref,
|
|
||||||
);
|
|
||||||
|
|
||||||
// cloneElement is necessary because the button is a union type
|
// cloneElement is necessary because the button is a union type
|
||||||
// of multiple things and we need to join props from our hooks
|
// of multiple things and we need to join props from our hooks
|
||||||
const [button, panel] = props.children;
|
const [button, panel] = props.children;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{cloneElement(button, {
|
{cloneElement(button, {
|
||||||
...menuTriggerProps,
|
...menuTriggerProps,
|
||||||
isDisabled: isDisabled,
|
isDisabled: isDisabled,
|
||||||
ref,
|
ref,
|
||||||
})}
|
})}
|
||||||
{state.isOpen && (
|
{state.isOpen && (
|
||||||
<Popover state={state} triggerRef={ref} placement={placement}>
|
<Popover state={state} triggerRef={ref} placement={placement}>
|
||||||
{cloneElement(panel, {
|
{cloneElement(panel, {
|
||||||
...menuProps,
|
...menuProps,
|
||||||
autoFocus: state.focusStrategy ?? true,
|
autoFocus: state.focusStrategy ?? true,
|
||||||
onClose: () => state.close(),
|
onClose: () => state.close(),
|
||||||
disabledKeys,
|
disabledKeys,
|
||||||
})}
|
})}
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuPanelProps extends AriaMenuProps<object> {
|
interface MenuPanelProps extends AriaMenuProps<object> {
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
disabledKeys?: Key[];
|
disabledKeys?: Key[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function Panel(props: MenuPanelProps) {
|
function Panel(props: MenuPanelProps) {
|
||||||
const state = useTreeState(props);
|
const state = useTreeState(props);
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
|
|
||||||
const { menuProps } = useMenu(props, state, ref);
|
const { menuProps } = useMenu(props, state, ref);
|
||||||
return (
|
return (
|
||||||
<ul
|
<ul
|
||||||
{...menuProps}
|
{...menuProps}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="pt-1 pb-1 shadow-2xs rounded-md min-w-[200px] focus:outline-hidden"
|
className="min-w-[200px] rounded-md pt-1 pb-1 shadow-2xs focus:outline-hidden"
|
||||||
>
|
>
|
||||||
{[...state.collection].map((item) => (
|
{[...state.collection].map((item) => (
|
||||||
<MenuSection
|
<MenuSection
|
||||||
key={item.key}
|
key={item.key}
|
||||||
section={item}
|
section={item}
|
||||||
state={state}
|
state={state}
|
||||||
disabledKeys={props.disabledKeys}
|
disabledKeys={props.disabledKeys}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuSectionProps<T> {
|
interface MenuSectionProps<T> {
|
||||||
section: Node<T>;
|
section: Node<T>;
|
||||||
state: TreeState<T>;
|
state: TreeState<T>;
|
||||||
disabledKeys?: Key[];
|
disabledKeys?: Key[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function MenuSection<T>({ section, state, disabledKeys }: MenuSectionProps<T>) {
|
function MenuSection<T>({ section, state, disabledKeys }: MenuSectionProps<T>) {
|
||||||
const { itemProps, groupProps } = useMenuSection({
|
const { itemProps, groupProps } = useMenuSection({
|
||||||
heading: section.rendered,
|
heading: section.rendered,
|
||||||
'aria-label': section['aria-label'],
|
"aria-label": section["aria-label"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { separatorProps } = useSeparator({
|
const { separatorProps } = useSeparator({
|
||||||
elementType: 'li',
|
elementType: "li",
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{section.key !== state.collection.getFirstKey() ? (
|
{section.key !== state.collection.getFirstKey() ? (
|
||||||
<li
|
<li
|
||||||
{...separatorProps}
|
{...separatorProps}
|
||||||
className={cn(
|
className={cn("mx-2 mt-1 mb-1 border-t", "border-mist-200 dark:border-mist-800")}
|
||||||
'mx-2 mt-1 mb-1 border-t',
|
/>
|
||||||
'border-headplane-200 dark:border-headplane-800',
|
) : undefined}
|
||||||
)}
|
<li {...itemProps}>
|
||||||
/>
|
<ul {...groupProps}>
|
||||||
) : undefined}
|
{[...section.childNodes].map((item) => (
|
||||||
<li {...itemProps}>
|
<MenuItem
|
||||||
<ul {...groupProps}>
|
key={item.key}
|
||||||
{[...section.childNodes].map((item) => (
|
item={item}
|
||||||
<MenuItem
|
state={state}
|
||||||
key={item.key}
|
isDisabled={disabledKeys?.includes(item.key)}
|
||||||
item={item}
|
/>
|
||||||
state={state}
|
))}
|
||||||
isDisabled={disabledKeys?.includes(item.key)}
|
</ul>
|
||||||
/>
|
</li>
|
||||||
))}
|
</>
|
||||||
</ul>
|
);
|
||||||
</li>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuItemProps<T> {
|
interface MenuItemProps<T> {
|
||||||
item: Node<T>;
|
item: Node<T>;
|
||||||
state: TreeState<T>;
|
state: TreeState<T>;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
|
function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
|
||||||
const ref = useRef<HTMLLIElement | null>(null);
|
const ref = useRef<HTMLLIElement | null>(null);
|
||||||
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
|
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
|
||||||
|
|
||||||
const isFocused = state.selectionManager.focusedKey === item.key;
|
const isFocused = state.selectionManager.focusedKey === item.key;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
{...menuItemProps}
|
{...menuItemProps}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'py-2 px-3 mx-1 rounded-lg',
|
"py-2 px-3 mx-1 rounded-lg",
|
||||||
'focus:outline-hidden select-none',
|
"focus:outline-hidden select-none",
|
||||||
isFocused && 'bg-headplane-100/50 dark:bg-headplane-800',
|
isFocused && "bg-mist-100/50 dark:bg-mist-800",
|
||||||
isDisabled
|
isDisabled
|
||||||
? 'text-headplane-400 dark:text-headplane-600'
|
? "text-mist-400 dark:text-mist-600"
|
||||||
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800 cursor-pointer',
|
: "hover:bg-mist-100/50 dark:hover:bg-mist-800 cursor-pointer",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.rendered}
|
{item.rendered}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Object.assign(Menu, {
|
export default Object.assign(Menu, {
|
||||||
Button,
|
Button,
|
||||||
IconButton,
|
IconButton,
|
||||||
Panel,
|
Panel,
|
||||||
Section,
|
Section,
|
||||||
Item,
|
Item,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,102 +1,84 @@
|
|||||||
import { Minus, Plus } from 'lucide-react';
|
import { Minus, Plus } from "lucide-react";
|
||||||
import { useRef } from 'react';
|
import { useRef } from "react";
|
||||||
import {
|
import { type AriaNumberFieldProps, useId, useLocale, useNumberField } from "react-aria";
|
||||||
type AriaNumberFieldProps,
|
import { useNumberFieldState } from "react-stately";
|
||||||
useId,
|
|
||||||
useLocale,
|
import IconButton from "~/components/IconButton";
|
||||||
useNumberField,
|
import cn from "~/utils/cn";
|
||||||
} from 'react-aria';
|
|
||||||
import { useNumberFieldState } from 'react-stately';
|
|
||||||
import IconButton from '~/components/IconButton';
|
|
||||||
import cn from '~/utils/cn';
|
|
||||||
|
|
||||||
export interface InputProps extends AriaNumberFieldProps {
|
export interface InputProps extends AriaNumberFieldProps {
|
||||||
isRequired?: boolean;
|
isRequired?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NumberInput(props: InputProps) {
|
export default function NumberInput(props: InputProps) {
|
||||||
const { label, name } = props;
|
const { label, name } = props;
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
const state = useNumberFieldState({ ...props, locale });
|
const state = useNumberFieldState({ ...props, locale });
|
||||||
const ref = useRef<HTMLInputElement | null>(null);
|
const ref = useRef<HTMLInputElement | null>(null);
|
||||||
const id = useId(props.id);
|
const id = useId(props.id);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
labelProps,
|
labelProps,
|
||||||
inputProps,
|
inputProps,
|
||||||
groupProps,
|
groupProps,
|
||||||
incrementButtonProps,
|
incrementButtonProps,
|
||||||
decrementButtonProps,
|
decrementButtonProps,
|
||||||
descriptionProps,
|
descriptionProps,
|
||||||
errorMessageProps,
|
errorMessageProps,
|
||||||
isInvalid,
|
isInvalid,
|
||||||
validationErrors,
|
validationErrors,
|
||||||
} = useNumberField(props, state, ref);
|
} = useNumberField(props, state, ref);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<label
|
<label
|
||||||
{...labelProps}
|
{...labelProps}
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
className={cn(
|
className={cn("text-xs font-medium px-3 mb-0.5", "text-mist-700 dark:text-mist-100")}
|
||||||
'text-xs font-medium px-3 mb-0.5',
|
>
|
||||||
'text-headplane-700 dark:text-headplane-100',
|
{label}
|
||||||
)}
|
</label>
|
||||||
>
|
<div
|
||||||
{label}
|
{...groupProps}
|
||||||
</label>
|
className={cn(
|
||||||
<div
|
"flex items-center gap-1 rounded-md pr-1",
|
||||||
{...groupProps}
|
"focus-within:outline-hidden focus-within:ring-3",
|
||||||
className={cn(
|
"bg-white dark:bg-mist-900",
|
||||||
'flex items-center gap-1 rounded-xl pr-1',
|
"border border-mist-100 dark:border-mist-800",
|
||||||
'focus-within:outline-hidden focus-within:ring-3',
|
)}
|
||||||
'bg-white dark:bg-headplane-900',
|
>
|
||||||
'border border-headplane-100 dark:border-headplane-800',
|
<input
|
||||||
)}
|
{...inputProps}
|
||||||
>
|
required={props.isRequired}
|
||||||
<input
|
ref={ref}
|
||||||
{...inputProps}
|
id={id}
|
||||||
required={props.isRequired}
|
className="w-full rounded-l-md bg-transparent py-2 pl-3 focus:outline-hidden"
|
||||||
ref={ref}
|
/>
|
||||||
id={id}
|
<input type="hidden" name={name} value={state.numberValue} />
|
||||||
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-hidden"
|
<IconButton {...decrementButtonProps} label="Decrement" className="h-7.5 w-7.5 rounded-lg">
|
||||||
/>
|
<Minus className="p-1" />
|
||||||
<input type="hidden" name={name} value={state.numberValue} />
|
</IconButton>
|
||||||
<IconButton
|
<IconButton {...incrementButtonProps} label="Increment" className="h-7.5 w-7.5 rounded-lg">
|
||||||
{...decrementButtonProps}
|
<Plus className="p-1" />
|
||||||
label="Decrement"
|
</IconButton>
|
||||||
className="w-7.5 h-7.5 rounded-lg"
|
</div>
|
||||||
>
|
{props.description && (
|
||||||
<Minus className="p-1" />
|
<div
|
||||||
</IconButton>
|
{...descriptionProps}
|
||||||
<IconButton
|
className={cn("text-xs px-3 mt-1", "text-mist-500 dark:text-mist-400")}
|
||||||
{...incrementButtonProps}
|
>
|
||||||
label="Increment"
|
{props.description}
|
||||||
className="w-7.5 h-7.5 rounded-lg"
|
</div>
|
||||||
>
|
)}
|
||||||
<Plus className="p-1" />
|
{isInvalid && (
|
||||||
</IconButton>
|
<div
|
||||||
</div>
|
{...errorMessageProps}
|
||||||
{props.description && (
|
className={cn("text-xs px-3 mt-1", "text-red-500 dark:text-red-400")}
|
||||||
<div
|
>
|
||||||
{...descriptionProps}
|
{validationErrors.join(" ")}
|
||||||
className={cn(
|
</div>
|
||||||
'text-xs px-3 mt-1',
|
)}
|
||||||
'text-headplane-500 dark:text-headplane-400',
|
</div>
|
||||||
)}
|
);
|
||||||
>
|
|
||||||
{props.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isInvalid && (
|
|
||||||
<div
|
|
||||||
{...errorMessageProps}
|
|
||||||
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
|
|
||||||
>
|
|
||||||
{validationErrors.join(' ')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-56
@@ -1,78 +1,69 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from "react";
|
||||||
import {
|
import { AriaTabListProps, AriaTabPanelProps, useTab, useTabList, useTabPanel } from "react-aria";
|
||||||
AriaTabListProps,
|
import { Item, Node, TabListState, useTabListState } from "react-stately";
|
||||||
AriaTabPanelProps,
|
|
||||||
useTab,
|
import cn from "~/utils/cn";
|
||||||
useTabList,
|
|
||||||
useTabPanel,
|
|
||||||
} from 'react-aria';
|
|
||||||
import { Item, Node, TabListState, useTabListState } from 'react-stately';
|
|
||||||
import cn from '~/utils/cn';
|
|
||||||
|
|
||||||
export interface OptionsProps extends AriaTabListProps<object> {
|
export interface OptionsProps extends AriaTabListProps<object> {
|
||||||
label: string;
|
label: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Options({ label, className, ...props }: OptionsProps) {
|
function Options({ label, className, ...props }: OptionsProps) {
|
||||||
const state = useTabListState(props);
|
const state = useTabListState(props);
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const { tabListProps } = useTabList(props, state, ref);
|
const { tabListProps } = useTabList(props, state, ref);
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)}>
|
<div className={cn("flex flex-col", className)}>
|
||||||
<div
|
<div {...tabListProps} ref={ref} className="flex items-center gap-2 overflow-x-scroll">
|
||||||
{...tabListProps}
|
{[...state.collection].map((item) => (
|
||||||
ref={ref}
|
<Option key={item.key} item={item} state={state} />
|
||||||
className="flex items-center gap-2 overflow-x-scroll"
|
))}
|
||||||
>
|
</div>
|
||||||
{[...state.collection].map((item) => (
|
<OptionsPanel key={state.selectedItem?.key} state={state} />
|
||||||
<Option key={item.key} item={item} state={state} />
|
</div>
|
||||||
))}
|
);
|
||||||
</div>
|
|
||||||
<OptionsPanel key={state.selectedItem?.key} state={state} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OptionsOptionProps {
|
export interface OptionsOptionProps {
|
||||||
item: Node<object>;
|
item: Node<object>;
|
||||||
state: TabListState<object>;
|
state: TabListState<object>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Option({ item, state }: OptionsOptionProps) {
|
function Option({ item, state }: OptionsOptionProps) {
|
||||||
const { key, rendered } = item;
|
const { key, rendered } = item;
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const { tabProps } = useTab({ key }, state, ref);
|
const { tabProps } = useTab({ key }, state, ref);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...tabProps}
|
{...tabProps}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'pl-0.5 pr-2 py-0.5 rounded-lg cursor-pointer',
|
"pl-0.5 pr-2 py-0.5 rounded-md cursor-pointer",
|
||||||
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
|
"aria-selected:bg-mist-100 dark:aria-selected:bg-mist-950",
|
||||||
'focus:outline-hidden focus:ring-3 z-10',
|
"focus:outline-hidden focus:ring-3 z-10",
|
||||||
'border border-headplane-100 dark:border-headplane-800',
|
"border border-mist-100 dark:border-mist-800",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{rendered}
|
{rendered}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OptionsPanelProps extends AriaTabPanelProps {
|
export interface OptionsPanelProps extends AriaTabPanelProps {
|
||||||
state: TabListState<object>;
|
state: TabListState<object>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function OptionsPanel({ state, ...props }: OptionsPanelProps) {
|
function OptionsPanel({ state, ...props }: OptionsPanelProps) {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
const { tabPanelProps } = useTabPanel(props, state, ref);
|
const { tabPanelProps } = useTabPanel(props, state, ref);
|
||||||
return (
|
return (
|
||||||
<div {...tabPanelProps} ref={ref} className="w-full mt-2">
|
<div {...tabPanelProps} ref={ref} className="mt-2 w-full">
|
||||||
{state.selectedItem?.props.children}
|
{state.selectedItem?.props.children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Object.assign(Options, { Item });
|
export default Object.assign(Options, { Item });
|
||||||
|
|||||||
+39
-43
@@ -1,49 +1,45 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useRef } from "react";
|
||||||
import {
|
import { type AriaPopoverProps, DismissButton, Overlay, usePopover } from "react-aria";
|
||||||
type AriaPopoverProps,
|
import type { OverlayTriggerState } from "react-stately";
|
||||||
DismissButton,
|
|
||||||
Overlay,
|
|
||||||
usePopover,
|
|
||||||
} from 'react-aria';
|
|
||||||
import type { OverlayTriggerState } from 'react-stately';
|
|
||||||
import cn from '~/utils/cn';
|
|
||||||
|
|
||||||
export interface PopoverProps extends Omit<AriaPopoverProps, 'popoverRef'> {
|
import cn from "~/utils/cn";
|
||||||
children: React.ReactNode;
|
|
||||||
state: OverlayTriggerState;
|
export interface PopoverProps extends Omit<AriaPopoverProps, "popoverRef"> {
|
||||||
popoverRef?: React.RefObject<HTMLDivElement | null>;
|
children: React.ReactNode;
|
||||||
className?: string;
|
state: OverlayTriggerState;
|
||||||
|
popoverRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Popover(props: PopoverProps) {
|
export default function Popover(props: PopoverProps) {
|
||||||
const ref = props.popoverRef ?? useRef<HTMLDivElement | null>(null);
|
const ref = props.popoverRef ?? useRef<HTMLDivElement | null>(null);
|
||||||
const { state, children, className } = props;
|
const { state, children, className } = props;
|
||||||
const { popoverProps, underlayProps } = usePopover(
|
const { popoverProps, underlayProps } = usePopover(
|
||||||
{
|
{
|
||||||
...props,
|
...props,
|
||||||
popoverRef: ref,
|
popoverRef: ref,
|
||||||
offset: 8,
|
offset: 8,
|
||||||
},
|
},
|
||||||
state,
|
state,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay>
|
<Overlay>
|
||||||
<div {...underlayProps} className="fixed inset-0" />
|
<div {...underlayProps} className="fixed inset-0" />
|
||||||
<div
|
<div
|
||||||
{...popoverProps}
|
{...popoverProps}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'z-10 shadow-xs rounded-xl',
|
"z-10 shadow-overlay rounded-lg",
|
||||||
'bg-white dark:bg-headplane-900',
|
"bg-white dark:bg-mist-900",
|
||||||
'border border-headplane-200 dark:border-headplane-800',
|
"border border-mist-200 dark:border-mist-800",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DismissButton onDismiss={state.close} />
|
<DismissButton onDismiss={state.close} />
|
||||||
{children}
|
{children}
|
||||||
<DismissButton onDismiss={state.close} />
|
<DismissButton onDismiss={state.close} />
|
||||||
</div>
|
</div>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
import { useProgressBar } from 'react-aria';
|
import { useProgressBar } from "react-aria";
|
||||||
import cn from '~/utils/cn';
|
|
||||||
|
import cn from "~/utils/cn";
|
||||||
|
|
||||||
export interface ProgressBarProps {
|
export interface ProgressBarProps {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProgressBar(props: ProgressBarProps) {
|
export default function ProgressBar(props: ProgressBarProps) {
|
||||||
const { isVisible } = props;
|
const { isVisible } = props;
|
||||||
const { progressBarProps } = useProgressBar({
|
const { progressBarProps } = useProgressBar({
|
||||||
label: 'Loading...',
|
label: "Loading...",
|
||||||
isIndeterminate: true,
|
isIndeterminate: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...progressBarProps}
|
{...progressBarProps}
|
||||||
aria-hidden={!isVisible}
|
aria-hidden={!isVisible}
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed top-0 left-0 z-50 w-1/2 h-1 opacity-0',
|
"fixed top-0 left-0 z-50 w-1/2 h-1 opacity-0",
|
||||||
'bg-headplane-950 dark:bg-headplane-50',
|
"bg-mist-950 dark:bg-mist-50",
|
||||||
isVisible && 'animate-loading opacity-100',
|
isVisible && "animate-loading opacity-100",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +1,76 @@
|
|||||||
import React, { createContext, useContext, useRef } from 'react';
|
import React, { createContext, useContext, useRef } from "react";
|
||||||
import {
|
import { AriaRadioGroupProps, AriaRadioProps, VisuallyHidden, useFocusRing } from "react-aria";
|
||||||
AriaRadioGroupProps,
|
import { useRadio, useRadioGroup } from "react-aria";
|
||||||
AriaRadioProps,
|
import { RadioGroupState } from "react-stately";
|
||||||
VisuallyHidden,
|
import { useRadioGroupState } from "react-stately";
|
||||||
useFocusRing,
|
|
||||||
} from 'react-aria';
|
|
||||||
import { RadioGroupState } from 'react-stately';
|
|
||||||
import cn from '~/utils/cn';
|
|
||||||
|
|
||||||
import { useRadio, useRadioGroup } from 'react-aria';
|
import cn from "~/utils/cn";
|
||||||
import { useRadioGroupState } from 'react-stately';
|
|
||||||
|
|
||||||
interface RadioGroupProps extends AriaRadioGroupProps {
|
interface RadioGroupProps extends AriaRadioGroupProps {
|
||||||
children: React.ReactElement<RadioProps>[];
|
children: React.ReactElement<RadioProps>[];
|
||||||
label: string;
|
label: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RadioContext = createContext<RadioGroupState | null>(null);
|
const RadioContext = createContext<RadioGroupState | null>(null);
|
||||||
|
|
||||||
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
|
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
|
||||||
const state = useRadioGroupState(props);
|
const state = useRadioGroupState(props);
|
||||||
const { radioGroupProps, labelProps } = useRadioGroup(
|
const { radioGroupProps, labelProps } = useRadioGroup(
|
||||||
{
|
{
|
||||||
...props,
|
...props,
|
||||||
'aria-label': label,
|
"aria-label": label,
|
||||||
},
|
},
|
||||||
state,
|
state,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...radioGroupProps} className={cn('flex flex-col gap-2', className)}>
|
<div {...radioGroupProps} className={cn("flex flex-col gap-2", className)}>
|
||||||
<VisuallyHidden>
|
<VisuallyHidden>
|
||||||
<span {...labelProps}>{label}</span>
|
<span {...labelProps}>{label}</span>
|
||||||
</VisuallyHidden>
|
</VisuallyHidden>
|
||||||
<RadioContext.Provider value={state}>{children}</RadioContext.Provider>
|
<RadioContext.Provider value={state}>{children}</RadioContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RadioProps extends AriaRadioProps {
|
interface RadioProps extends AriaRadioProps {
|
||||||
label: string;
|
label: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Radio({ children, label, className, ...props }: RadioProps) {
|
function Radio({ children, label, className, ...props }: RadioProps) {
|
||||||
const state = useContext(RadioContext);
|
const state = useContext(RadioContext);
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const { inputProps, isSelected, isDisabled } = useRadio(
|
const { inputProps, isSelected, isDisabled } = useRadio(
|
||||||
{
|
{
|
||||||
...props,
|
...props,
|
||||||
'aria-label': label,
|
"aria-label": label,
|
||||||
},
|
},
|
||||||
state!,
|
state!,
|
||||||
ref,
|
ref,
|
||||||
);
|
);
|
||||||
const { isFocusVisible, focusProps } = useFocusRing();
|
const { isFocusVisible, focusProps } = useFocusRing();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<VisuallyHidden>
|
<VisuallyHidden>
|
||||||
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
|
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
|
||||||
</VisuallyHidden>
|
</VisuallyHidden>
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-5 h-5 aspect-square rounded-full p-1 border-2',
|
"w-5 h-5 aspect-square rounded-full p-1 border-2",
|
||||||
'border border-headplane-600 dark:border-headplane-300',
|
"border border-mist-600 dark:border-mist-300",
|
||||||
isFocusVisible ? 'ring-4' : '',
|
isFocusVisible ? "ring-4" : "",
|
||||||
isDisabled ? 'opacity-50 cursor-not-allowed' : '',
|
isDisabled ? "opacity-50 cursor-not-allowed" : "",
|
||||||
isSelected
|
isSelected ? "border-[6px] border-mist-900 dark:border-mist-100" : "",
|
||||||
? 'border-[6px] border-headplane-900 dark:border-headplane-100'
|
className,
|
||||||
: '',
|
)}
|
||||||
className,
|
/>
|
||||||
)}
|
{children}
|
||||||
/>
|
</label>
|
||||||
{children}
|
);
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Object.assign(RadioGroup, { Radio });
|
export default Object.assign(RadioGroup, { Radio });
|
||||||
|
|||||||
+143
-148
@@ -1,174 +1,169 @@
|
|||||||
import { Check, ChevronDown } from 'lucide-react';
|
import { Check, ChevronDown } from "lucide-react";
|
||||||
import { useRef } from 'react';
|
import { useRef } from "react";
|
||||||
import {
|
import {
|
||||||
AriaComboBoxProps,
|
AriaComboBoxProps,
|
||||||
AriaListBoxOptions,
|
AriaListBoxOptions,
|
||||||
useButton,
|
useButton,
|
||||||
useComboBox,
|
useComboBox,
|
||||||
useFilter,
|
useFilter,
|
||||||
useId,
|
useId,
|
||||||
useListBox,
|
useListBox,
|
||||||
useOption,
|
useOption,
|
||||||
} from 'react-aria';
|
} from "react-aria";
|
||||||
import { Item, ListState, Node, useComboBoxState } from 'react-stately';
|
import { Item, ListState, Node, useComboBoxState } from "react-stately";
|
||||||
import Popover from '~/components/Popover';
|
|
||||||
import cn from '~/utils/cn';
|
import Popover from "~/components/Popover";
|
||||||
|
import cn from "~/utils/cn";
|
||||||
|
|
||||||
export interface SelectProps extends AriaComboBoxProps<object> {
|
export interface SelectProps extends AriaComboBoxProps<object> {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Select(props: SelectProps) {
|
function Select(props: SelectProps) {
|
||||||
const { contains } = useFilter({ sensitivity: 'base' });
|
const { contains } = useFilter({ sensitivity: "base" });
|
||||||
const state = useComboBoxState({ ...props, defaultFilter: contains });
|
const state = useComboBoxState({ ...props, defaultFilter: contains });
|
||||||
const id = useId(props.id);
|
const id = useId(props.id);
|
||||||
|
|
||||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const listBoxRef = useRef<HTMLUListElement | null>(null);
|
const listBoxRef = useRef<HTMLUListElement | null>(null);
|
||||||
const popoverRef = useRef<HTMLDivElement | null>(null);
|
const popoverRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
buttonProps: triggerProps,
|
buttonProps: triggerProps,
|
||||||
inputProps,
|
inputProps,
|
||||||
listBoxProps,
|
listBoxProps,
|
||||||
labelProps,
|
labelProps,
|
||||||
descriptionProps,
|
descriptionProps,
|
||||||
} = useComboBox(
|
} = useComboBox(
|
||||||
{
|
{
|
||||||
...props,
|
...props,
|
||||||
inputRef,
|
inputRef,
|
||||||
buttonRef,
|
buttonRef,
|
||||||
listBoxRef,
|
listBoxRef,
|
||||||
popoverRef,
|
popoverRef,
|
||||||
},
|
},
|
||||||
state,
|
state,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { buttonProps } = useButton(triggerProps, buttonRef);
|
const { buttonProps } = useButton(triggerProps, buttonRef);
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', props.className)}>
|
<div className={cn("flex flex-col", props.className)}>
|
||||||
<label
|
<label
|
||||||
{...labelProps}
|
{...labelProps}
|
||||||
className={cn(
|
className={cn("text-xs font-medium px-3 mb-0.5", "text-mist-700 dark:text-mist-100")}
|
||||||
'text-xs font-medium px-3 mb-0.5',
|
htmlFor={id}
|
||||||
'text-headplane-700 dark:text-headplane-100',
|
>
|
||||||
)}
|
{props.label}
|
||||||
htmlFor={id}
|
</label>
|
||||||
>
|
<div
|
||||||
{props.label}
|
className={cn(
|
||||||
</label>
|
"flex rounded-md focus:outline-hidden focus-within:ring-3",
|
||||||
<div
|
"bg-white dark:bg-mist-900",
|
||||||
className={cn(
|
"border border-mist-100 dark:border-mist-800",
|
||||||
'flex rounded-xl focus:outline-hidden focus-within:ring-3',
|
props.isInvalid && "ring-red-400",
|
||||||
'bg-white dark:bg-headplane-900',
|
)}
|
||||||
'border border-headplane-100 dark:border-headplane-800',
|
>
|
||||||
props.isInvalid && 'ring-red-400',
|
<input
|
||||||
)}
|
{...inputProps}
|
||||||
>
|
className="w-full rounded-l-md bg-transparent px-3 py-2 outline-hidden"
|
||||||
<input
|
data-1p-ignore
|
||||||
{...inputProps}
|
id={id}
|
||||||
className="outline-hidden px-3 py-2 rounded-l-xl w-full bg-transparent"
|
ref={inputRef}
|
||||||
data-1p-ignore
|
/>
|
||||||
id={id}
|
<button
|
||||||
ref={inputRef}
|
{...buttonProps}
|
||||||
/>
|
className={cn(
|
||||||
<button
|
"flex items-center justify-center p-1 rounded-lg m-1",
|
||||||
{...buttonProps}
|
"bg-mist-100 dark:bg-mist-700/30 font-medium",
|
||||||
className={cn(
|
props.isDisabled
|
||||||
'flex items-center justify-center p-1 rounded-lg m-1',
|
? "opacity-50 cursor-not-allowed"
|
||||||
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
: "hover:bg-mist-200/90 dark:hover:bg-mist-800/30",
|
||||||
props.isDisabled
|
)}
|
||||||
? 'opacity-50 cursor-not-allowed'
|
ref={buttonRef}
|
||||||
: 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
>
|
||||||
)}
|
<ChevronDown className="p-0.5" />
|
||||||
ref={buttonRef}
|
</button>
|
||||||
>
|
</div>
|
||||||
<ChevronDown className="p-0.5" />
|
{props.description && (
|
||||||
</button>
|
<div
|
||||||
</div>
|
{...descriptionProps}
|
||||||
{props.description && (
|
className={cn("text-xs px-3 mt-1", "text-mist-500 dark:text-mist-400")}
|
||||||
<div
|
>
|
||||||
{...descriptionProps}
|
{props.description}
|
||||||
className={cn(
|
</div>
|
||||||
'text-xs px-3 mt-1',
|
)}
|
||||||
'text-headplane-500 dark:text-headplane-400',
|
{state.isOpen && (
|
||||||
)}
|
<Popover
|
||||||
>
|
className="w-full max-w-xs"
|
||||||
{props.description}
|
isNonModal
|
||||||
</div>
|
placement="bottom start"
|
||||||
)}
|
popoverRef={popoverRef}
|
||||||
{state.isOpen && (
|
state={state}
|
||||||
<Popover
|
triggerRef={inputRef}
|
||||||
className="w-full max-w-xs"
|
>
|
||||||
isNonModal
|
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
|
||||||
placement="bottom start"
|
</Popover>
|
||||||
popoverRef={popoverRef}
|
)}
|
||||||
state={state}
|
</div>
|
||||||
triggerRef={inputRef}
|
);
|
||||||
>
|
|
||||||
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ListBoxProps extends AriaListBoxOptions<object> {
|
interface ListBoxProps extends AriaListBoxOptions<object> {
|
||||||
listBoxRef: React.RefObject<HTMLUListElement | null>;
|
listBoxRef: React.RefObject<HTMLUListElement | null>;
|
||||||
state: ListState<object>;
|
state: ListState<object>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ListBox(props: ListBoxProps) {
|
function ListBox(props: ListBoxProps) {
|
||||||
const { listBoxRef, state } = props;
|
const { listBoxRef, state } = props;
|
||||||
const { listBoxProps } = useListBox(props, state, listBoxRef);
|
const { listBoxProps } = useListBox(props, state, listBoxRef);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul
|
<ul
|
||||||
{...listBoxProps}
|
{...listBoxProps}
|
||||||
className="w-full max-h-72 overflow-auto outline-hidden pt-1"
|
className="max-h-72 w-full overflow-auto pt-1 outline-hidden"
|
||||||
ref={listBoxRef}
|
ref={listBoxRef}
|
||||||
>
|
>
|
||||||
{[...state.collection].map((item) => (
|
{[...state.collection].map((item) => (
|
||||||
<Option item={item} key={item.key} state={state} />
|
<Option item={item} key={item.key} state={state} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OptionProps {
|
interface OptionProps {
|
||||||
item: Node<unknown>;
|
item: Node<unknown>;
|
||||||
state: ListState<unknown>;
|
state: ListState<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Option({ item, state }: OptionProps) {
|
function Option({ item, state }: OptionProps) {
|
||||||
const ref = useRef<HTMLLIElement | null>(null);
|
const ref = useRef<HTMLLIElement | null>(null);
|
||||||
const { optionProps, isDisabled, isSelected, isFocused } = useOption(
|
const { optionProps, isDisabled, isSelected, isFocused } = useOption(
|
||||||
{
|
{
|
||||||
key: item.key,
|
key: item.key,
|
||||||
},
|
},
|
||||||
state,
|
state,
|
||||||
ref,
|
ref,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
{...optionProps}
|
{...optionProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between',
|
"flex items-center justify-between",
|
||||||
'py-2 px-3 mx-1 rounded-lg mb-1',
|
"py-2 px-3 mx-1 rounded-lg mb-1",
|
||||||
'focus:outline-hidden select-none',
|
"focus:outline-hidden select-none",
|
||||||
isFocused || isSelected
|
isFocused || isSelected
|
||||||
? 'bg-headplane-100/50 dark:bg-headplane-800'
|
? "bg-mist-100/50 dark:bg-mist-800"
|
||||||
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
|
: "hover:bg-mist-100/50 dark:hover:bg-mist-800",
|
||||||
isDisabled && 'text-headplane-300 dark:text-headplane-600',
|
isDisabled && "text-mist-300 dark:text-mist-600",
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
{item.rendered}
|
{item.rendered}
|
||||||
{isSelected && <Check className="p-0.5" />}
|
{isSelected && <Check className="p-0.5" />}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Object.assign(Select, { Item });
|
export default Object.assign(Select, { Item });
|
||||||
|
|||||||
@@ -1,27 +1,22 @@
|
|||||||
import cn from '~/utils/cn';
|
import cn from "~/utils/cn";
|
||||||
|
|
||||||
export interface StatusCircleProps {
|
export interface StatusCircleProps {
|
||||||
isOnline: boolean;
|
isOnline: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StatusCircle({
|
export default function StatusCircle({ isOnline, className }: StatusCircleProps) {
|
||||||
isOnline,
|
return (
|
||||||
className,
|
<svg
|
||||||
}: StatusCircleProps) {
|
className={cn(
|
||||||
return (
|
isOnline ? "text-green-600 dark:text-green-500" : "text-mist-200 dark:text-mist-800",
|
||||||
<svg
|
className,
|
||||||
className={cn(
|
)}
|
||||||
isOnline
|
viewBox="0 0 24 24"
|
||||||
? 'text-green-600 dark:text-green-500'
|
fill="currentColor"
|
||||||
: 'text-headplane-200 dark:text-headplane-800',
|
>
|
||||||
className,
|
<title>{isOnline ? "Online" : "Offline"}</title>
|
||||||
)}
|
<circle cx="12" cy="12" r="8" />
|
||||||
viewBox="0 0 24 24"
|
</svg>
|
||||||
fill="currentColor"
|
);
|
||||||
>
|
|
||||||
<title>{isOnline ? 'Online' : 'Offline'}</title>
|
|
||||||
<circle cx="12" cy="12" r="8" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-57
@@ -1,64 +1,55 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from "react";
|
||||||
import {
|
import { AriaSwitchProps, VisuallyHidden, useFocusRing, useSwitch } from "react-aria";
|
||||||
AriaSwitchProps,
|
import { useToggleState } from "react-stately";
|
||||||
VisuallyHidden,
|
|
||||||
useFocusRing,
|
import cn from "~/utils/cn";
|
||||||
useSwitch,
|
|
||||||
} from 'react-aria';
|
|
||||||
import { useToggleState } from 'react-stately';
|
|
||||||
import cn from '~/utils/cn';
|
|
||||||
|
|
||||||
export interface SwitchProps extends AriaSwitchProps {
|
export interface SwitchProps extends AriaSwitchProps {
|
||||||
label: string;
|
label: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
switchClassName?: string;
|
switchClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Switch(props: SwitchProps) {
|
export default function Switch(props: SwitchProps) {
|
||||||
const state = useToggleState(props);
|
const state = useToggleState(props);
|
||||||
const ref = useRef<HTMLInputElement | null>(null);
|
const ref = useRef<HTMLInputElement | null>(null);
|
||||||
const { focusProps, isFocusVisible } = useFocusRing();
|
const { focusProps, isFocusVisible } = useFocusRing();
|
||||||
const { inputProps } = useSwitch(
|
const { inputProps } = useSwitch(
|
||||||
{
|
{
|
||||||
...props,
|
...props,
|
||||||
'aria-label': props.label,
|
"aria-label": props.label,
|
||||||
},
|
},
|
||||||
state,
|
state,
|
||||||
ref,
|
ref,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className="flex items-center gap-x-2">
|
<label className="flex items-center gap-x-2">
|
||||||
<VisuallyHidden elementType="span">
|
<VisuallyHidden elementType="span">
|
||||||
<input
|
<input {...inputProps} {...focusProps} aria-label={props.label} ref={ref} />
|
||||||
{...inputProps}
|
</VisuallyHidden>
|
||||||
{...focusProps}
|
<div
|
||||||
aria-label={props.label}
|
aria-hidden
|
||||||
ref={ref}
|
className={cn(
|
||||||
/>
|
"flex h-[28px] w-[46px] p-[4px] shrink-0 rounded-full",
|
||||||
</VisuallyHidden>
|
"bg-mist-300 dark:bg-mist-700",
|
||||||
<div
|
"border border-transparent dark:border-mist-800",
|
||||||
aria-hidden
|
state.isSelected && "bg-mist-900 dark:bg-mist-950",
|
||||||
className={cn(
|
isFocusVisible && "ring-2",
|
||||||
'flex h-[28px] w-[46px] p-[4px] shrink-0 rounded-full',
|
props.isDisabled && "opacity-50",
|
||||||
'bg-headplane-300 dark:bg-headplane-700',
|
props.className,
|
||||||
'border border-transparent dark:border-headplane-800',
|
)}
|
||||||
state.isSelected && 'bg-headplane-900 dark:bg-headplane-950',
|
>
|
||||||
isFocusVisible && 'ring-2',
|
<span
|
||||||
props.isDisabled && 'opacity-50',
|
className={cn(
|
||||||
props.className,
|
"h-[18px] w-[18px] transform rounded-full",
|
||||||
)}
|
"bg-white transition duration-50 ease-in-out",
|
||||||
>
|
"translate-x-0 group-selected:translate-x-full",
|
||||||
<span
|
state.isSelected && "translate-x-full",
|
||||||
className={cn(
|
props.switchClassName,
|
||||||
'h-[18px] w-[18px] transform rounded-full',
|
)}
|
||||||
'bg-white transition duration-50 ease-in-out',
|
/>
|
||||||
'translate-x-0 group-selected:translate-x-full',
|
</div>
|
||||||
state.isSelected && 'translate-x-full',
|
</label>
|
||||||
props.switchClassName,
|
);
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,31 @@
|
|||||||
import type { HTMLProps } from 'react';
|
import type { HTMLProps } from "react";
|
||||||
import cn from '~/utils/cn';
|
|
||||||
|
import cn from "~/utils/cn";
|
||||||
|
|
||||||
function TableList(props: HTMLProps<HTMLDivElement>) {
|
function TableList(props: HTMLProps<HTMLDivElement>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...props}
|
{...props}
|
||||||
className={cn(
|
className={cn("rounded-lg", "border border-mist-100 dark:border-mist-800", props.className)}
|
||||||
'rounded-xl',
|
>
|
||||||
'border border-headplane-100 dark:border-headplane-800',
|
{props.children}
|
||||||
props.className,
|
</div>
|
||||||
)}
|
);
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Item(props: HTMLProps<HTMLDivElement>) {
|
function Item(props: HTMLProps<HTMLDivElement>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...props}
|
{...props}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between p-2 last:border-b-0',
|
"flex items-center justify-between p-2 last:border-b-0",
|
||||||
'border-b border-headplane-100 dark:border-headplane-800',
|
"border-b border-mist-100 dark:border-mist-800",
|
||||||
props.className,
|
props.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Object.assign(TableList, { Item });
|
export default Object.assign(TableList, { Item });
|
||||||
|
|||||||
+63
-68
@@ -1,90 +1,85 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from "react";
|
||||||
import {
|
import { AriaTabListProps, AriaTabPanelProps, useTab, useTabList, useTabPanel } from "react-aria";
|
||||||
AriaTabListProps,
|
import { Item, Node, TabListState, useTabListState } from "react-stately";
|
||||||
AriaTabPanelProps,
|
|
||||||
useTab,
|
import cn from "~/utils/cn";
|
||||||
useTabList,
|
|
||||||
useTabPanel,
|
|
||||||
} from 'react-aria';
|
|
||||||
import { Item, Node, TabListState, useTabListState } from 'react-stately';
|
|
||||||
import cn from '~/utils/cn';
|
|
||||||
|
|
||||||
export interface TabsProps extends AriaTabListProps<object> {
|
export interface TabsProps extends AriaTabListProps<object> {
|
||||||
label: string;
|
label: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Tabs({ label, className, ...props }: TabsProps) {
|
function Tabs({ label, className, ...props }: TabsProps) {
|
||||||
const state = useTabListState(props);
|
const state = useTabListState(props);
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const { tabListProps } = useTabList(props, state, ref);
|
const { tabListProps } = useTabList(props, state, ref);
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)}>
|
<div className={cn("flex flex-col", className)}>
|
||||||
<div
|
<div
|
||||||
{...tabListProps}
|
{...tabListProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center rounded-t-xl w-fit max-w-full overflow-x-auto',
|
"flex items-center rounded-t-lg w-fit max-w-full overflow-x-auto",
|
||||||
'border-headplane-100 dark:border-headplane-800',
|
"border-mist-100 dark:border-mist-800",
|
||||||
'border-t border-x',
|
"border-t border-x",
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
{[...state.collection].map((item) => (
|
{[...state.collection].map((item) => (
|
||||||
<Tab item={item} key={item.key} state={state} />
|
<Tab item={item} key={item.key} state={state} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<TabsPanel key={state.selectedItem?.key} state={state} />
|
<TabsPanel key={state.selectedItem?.key} state={state} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TabsTabProps {
|
export interface TabsTabProps {
|
||||||
item: Node<object>;
|
item: Node<object>;
|
||||||
state: TabListState<object>;
|
state: TabListState<object>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Tab({ item, state }: TabsTabProps) {
|
function Tab({ item, state }: TabsTabProps) {
|
||||||
const { key, rendered } = item;
|
const { key, rendered } = item;
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const { tabProps } = useTab({ key }, state, ref);
|
const { tabProps } = useTab({ key }, state, ref);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...tabProps}
|
{...tabProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
'pl-2 pr-3 py-2.5',
|
"pl-2 pr-3 py-2.5",
|
||||||
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
|
"aria-selected:bg-mist-100 dark:aria-selected:bg-mist-950",
|
||||||
'focus:outline-hidden focus:ring-3 z-10',
|
"focus:outline-hidden focus:ring-3 z-10",
|
||||||
'border-r border-headplane-100 dark:border-headplane-800',
|
"border-r border-mist-100 dark:border-mist-800",
|
||||||
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
|
"first:rounded-tl-lg last:rounded-tr-lg last:border-r-0",
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
{rendered}
|
{rendered}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TabsPanelProps extends AriaTabPanelProps {
|
export interface TabsPanelProps extends AriaTabPanelProps {
|
||||||
state: TabListState<object>;
|
state: TabListState<object>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabsPanel({ state, ...props }: TabsPanelProps) {
|
function TabsPanel({ state, ...props }: TabsPanelProps) {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
const { tabPanelProps } = useTabPanel(props, state, ref);
|
const { tabPanelProps } = useTabPanel(props, state, ref);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...tabPanelProps}
|
{...tabPanelProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full overflow-clip rounded-b-xl rounded-r-xl',
|
"w-full overflow-clip rounded-b-lg rounded-r-lg",
|
||||||
'border border-headplane-100 dark:border-headplane-800',
|
"border border-mist-100 dark:border-mist-800",
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
{state.selectedItem?.props.children}
|
{state.selectedItem?.props.children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Object.assign(Tabs, { Item });
|
export default Object.assign(Tabs, { Item });
|
||||||
|
|||||||
@@ -1,87 +1,73 @@
|
|||||||
import {
|
import { AriaToastProps, AriaToastRegionProps, useToast, useToastRegion } from "@react-aria/toast";
|
||||||
AriaToastProps,
|
import { ToastQueue, ToastState, useToastQueue } from "@react-stately/toast";
|
||||||
AriaToastRegionProps,
|
import { X } from "lucide-react";
|
||||||
useToast,
|
import React, { useRef } from "react";
|
||||||
useToastRegion,
|
|
||||||
} from '@react-aria/toast';
|
import IconButton from "~/components/IconButton";
|
||||||
import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast';
|
import cn from "~/utils/cn";
|
||||||
import { X } from 'lucide-react';
|
|
||||||
import React, { useRef } from 'react';
|
|
||||||
import IconButton from '~/components/IconButton';
|
|
||||||
import cn from '~/utils/cn';
|
|
||||||
|
|
||||||
interface ToastProps extends AriaToastProps<React.ReactNode> {
|
interface ToastProps extends AriaToastProps<React.ReactNode> {
|
||||||
state: ToastState<React.ReactNode>;
|
state: ToastState<React.ReactNode>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toast({ state, ...props }: ToastProps) {
|
function Toast({ state, ...props }: ToastProps) {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
|
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(props, state, ref);
|
||||||
props,
|
|
||||||
state,
|
|
||||||
ref,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...toastProps}
|
{...toastProps}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between gap-x-3 pl-4 pr-3',
|
"flex items-center justify-between gap-x-3 pl-4 pr-3",
|
||||||
'text-white shadow-lg dark:shadow-md rounded-xl py-3',
|
"text-white shadow-overlay rounded-lg py-3",
|
||||||
'bg-headplane-900 dark:bg-headplane-950',
|
"bg-mist-900 dark:bg-mist-950",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div {...contentProps} className="flex flex-col gap-2">
|
<div {...contentProps} className="flex flex-col gap-2">
|
||||||
<div {...titleProps}>{props.toast.content}</div>
|
<div {...titleProps}>{props.toast.content}</div>
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
<IconButton
|
||||||
{...closeButtonProps}
|
{...closeButtonProps}
|
||||||
label="Close"
|
label="Close"
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-transparent hover:bg-headplane-700',
|
"bg-transparent hover:bg-mist-700",
|
||||||
'dark:bg-transparent dark:hover:bg-headplane-800',
|
"dark:bg-transparent dark:hover:bg-mist-800",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<X className="p-1" />
|
<X className="p-1" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToastRegionProps extends AriaToastRegionProps {
|
interface ToastRegionProps extends AriaToastRegionProps {
|
||||||
state: ToastState<React.ReactNode>;
|
state: ToastState<React.ReactNode>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToastRegion({ state, ...props }: ToastRegionProps) {
|
function ToastRegion({ state, ...props }: ToastRegionProps) {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
const { regionProps } = useToastRegion(props, state, ref);
|
const { regionProps } = useToastRegion(props, state, ref);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...regionProps}
|
{...regionProps}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('fixed bottom-20 right-4', 'flex flex-col gap-4')}
|
className={cn("fixed bottom-20 right-4", "flex flex-col gap-4")}
|
||||||
>
|
>
|
||||||
{state.visibleToasts.map((toast) => (
|
{state.visibleToasts.map((toast) => (
|
||||||
<Toast key={toast.key} toast={toast} state={state} />
|
<Toast key={toast.key} toast={toast} state={state} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToastProviderProps extends AriaToastRegionProps {
|
export interface ToastProviderProps extends AriaToastRegionProps {
|
||||||
queue: ToastQueue<React.ReactNode>;
|
queue: ToastQueue<React.ReactNode>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ToastProvider({ queue, ...props }: ToastProviderProps) {
|
export default function ToastProvider({ queue, ...props }: ToastProviderProps) {
|
||||||
const state = useToastQueue(queue);
|
const state = useToastQueue(queue);
|
||||||
|
|
||||||
return (
|
return <>{state.visibleToasts.length > 0 && <ToastRegion {...props} state={state} />}</>;
|
||||||
<>
|
|
||||||
{state.visibleToasts.length > 0 && (
|
|
||||||
<ToastRegion {...props} state={state} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-66
@@ -1,83 +1,79 @@
|
|||||||
import React, { cloneElement, useRef } from 'react';
|
import React, { cloneElement, useRef } from "react";
|
||||||
import {
|
import { AriaTooltipProps, mergeProps, useTooltip, useTooltipTrigger } from "react-aria";
|
||||||
AriaTooltipProps,
|
import { TooltipTriggerState, useTooltipTriggerState } from "react-stately";
|
||||||
mergeProps,
|
|
||||||
useTooltip,
|
import cn from "~/utils/cn";
|
||||||
useTooltipTrigger,
|
|
||||||
} from 'react-aria';
|
|
||||||
import { TooltipTriggerState, useTooltipTriggerState } from 'react-stately';
|
|
||||||
import cn from '~/utils/cn';
|
|
||||||
|
|
||||||
export interface TooltipProps extends AriaTooltipProps {
|
export interface TooltipProps extends AriaTooltipProps {
|
||||||
children: [React.ReactElement, React.ReactElement<TooltipBodyProps>];
|
children: [React.ReactElement, React.ReactElement<TooltipBodyProps>];
|
||||||
}
|
}
|
||||||
|
|
||||||
function Tooltip(props: TooltipProps) {
|
function Tooltip(props: TooltipProps) {
|
||||||
const state = useTooltipTriggerState({
|
const state = useTooltipTriggerState({
|
||||||
...props,
|
...props,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
closeDelay: 0,
|
closeDelay: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ref = useRef<HTMLButtonElement | null>(null);
|
const ref = useRef<HTMLButtonElement | null>(null);
|
||||||
const { triggerProps, tooltipProps } = useTooltipTrigger(
|
const { triggerProps, tooltipProps } = useTooltipTrigger(
|
||||||
{
|
{
|
||||||
...props,
|
...props,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
closeDelay: 0,
|
closeDelay: 0,
|
||||||
},
|
},
|
||||||
state,
|
state,
|
||||||
ref,
|
ref,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [component, body] = props.children;
|
const [component, body] = props.children;
|
||||||
return (
|
return (
|
||||||
<span className="relative">
|
<span className="relative">
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...triggerProps}
|
{...triggerProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-center',
|
"flex items-center justify-center",
|
||||||
'focus:outline-hidden focus:ring-3 rounded-xl',
|
"focus:outline-hidden focus:ring-3 rounded-md",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{component}
|
{component}
|
||||||
</button>
|
</button>
|
||||||
{state.isOpen &&
|
{state.isOpen &&
|
||||||
cloneElement(body, {
|
cloneElement(body, {
|
||||||
...tooltipProps,
|
...tooltipProps,
|
||||||
state,
|
state,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TooltipBodyProps extends AriaTooltipProps {
|
interface TooltipBodyProps extends AriaTooltipProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
state?: TooltipTriggerState;
|
state?: TooltipTriggerState;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Body({ state, className, ...props }: TooltipBodyProps) {
|
function Body({ state, className, ...props }: TooltipBodyProps) {
|
||||||
const { tooltipProps } = useTooltip(props, state);
|
const { tooltipProps } = useTooltip(props, state);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
{...mergeProps(props, tooltipProps)}
|
{...mergeProps(props, tooltipProps)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute z-50 p-3 top-full mt-1',
|
"absolute z-50 p-3 top-full mt-1",
|
||||||
'outline-hidden rounded-3xl text-sm w-48',
|
"outline-hidden rounded-lg text-sm w-48",
|
||||||
'bg-white dark:bg-headplane-950',
|
"bg-white dark:bg-mist-950",
|
||||||
'text-black dark:text-white',
|
"text-black dark:text-white",
|
||||||
'shadow-lg dark:shadow-md rounded-xl',
|
"shadow-overlay",
|
||||||
'border border-headplane-100 dark:border-headplane-800',
|
"border border-mist-100 dark:border-mist-800",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Object.assign(Tooltip, {
|
export default Object.assign(Tooltip, {
|
||||||
Body,
|
Body,
|
||||||
});
|
});
|
||||||
|
|||||||
+175
-192
@@ -1,211 +1,194 @@
|
|||||||
import { AlertCircle } from 'lucide-react';
|
import { AlertCircle } from "lucide-react";
|
||||||
import { isRouteErrorResponse } from 'react-router';
|
import { isRouteErrorResponse } from "react-router";
|
||||||
import {
|
|
||||||
isApiError,
|
import { isApiError, isConnectionError } from "~/server/headscale/api/error-client";
|
||||||
isConnectionError,
|
import cn from "~/utils/cn";
|
||||||
} from '~/server/headscale/api/error-client';
|
|
||||||
import cn from '~/utils/cn';
|
import Card from "./Card";
|
||||||
import Card from './Card';
|
import Code from "./Code";
|
||||||
import Code from './Code';
|
import Link from "./Link";
|
||||||
import Link from './Link';
|
|
||||||
|
|
||||||
export function getErrorMessage(error: Error | unknown): {
|
export function getErrorMessage(error: Error | unknown): {
|
||||||
title: string;
|
title: string;
|
||||||
jsxMessage: React.ReactNode;
|
jsxMessage: React.ReactNode;
|
||||||
} {
|
} {
|
||||||
if (isRouteErrorResponse(error)) {
|
if (isRouteErrorResponse(error)) {
|
||||||
if (isApiError(error.data)) {
|
if (isApiError(error.data)) {
|
||||||
const { statusCode, rawData, data, requestUrl } = error.data;
|
const { statusCode, rawData, data, requestUrl } = error.data;
|
||||||
if (statusCode >= 500) {
|
if (statusCode >= 500) {
|
||||||
return {
|
return {
|
||||||
title: 'Headscale API Error',
|
title: "Headscale API Error",
|
||||||
jsxMessage: (
|
jsxMessage: (
|
||||||
<>
|
<>
|
||||||
<Card.Text>
|
<Card.Text>
|
||||||
There was an error communicating with the Headscale API.
|
There was an error communicating with the Headscale API.
|
||||||
<br />
|
<br />
|
||||||
The server responded with a status code of{' '}
|
The server responded with a status code of <strong>{statusCode}</strong>, indicating
|
||||||
<strong>{statusCode}</strong>, indicating a server-side issue.
|
a server-side issue. Please check the Headscale server status and try again later.
|
||||||
Please check the Headscale server status and try again later.
|
</Card.Text>
|
||||||
</Card.Text>
|
{(error.data.data != null || error.data.rawData != null) && (
|
||||||
{(error.data.data != null || error.data.rawData != null) && (
|
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
|
||||||
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
|
{error.data.data != null ? (
|
||||||
{error.data.data != null ? (
|
<code>{JSON.stringify(error.data.data, null, 2)}</code>
|
||||||
<code>{JSON.stringify(error.data.data, null, 2)}</code>
|
) : (
|
||||||
) : (
|
<code>{error.data.rawData}</code>
|
||||||
<code>{error.data.rawData}</code>
|
)}
|
||||||
)}
|
</pre>
|
||||||
</pre>
|
)}
|
||||||
)}
|
</>
|
||||||
</>
|
),
|
||||||
),
|
};
|
||||||
};
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const authError =
|
const authError = error.data.statusCode === 401 || error.data.statusCode === 403;
|
||||||
error.data.statusCode === 401 || error.data.statusCode === 403;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: 'Invalid response from Headscale API',
|
title: "Invalid response from Headscale API",
|
||||||
jsxMessage: (
|
jsxMessage: (
|
||||||
<>
|
<>
|
||||||
<Card.Text className="leading-snug">
|
<Card.Text className="leading-snug">
|
||||||
The Headscale API returned an unexpected response.
|
The Headscale API returned an unexpected response.
|
||||||
{authError ? (
|
{authError ? (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{" "}
|
||||||
The status code indicates an authentication error. Please
|
The status code indicates an authentication error. Please verify your API key and
|
||||||
verify your API key and Headplane configuration.
|
Headplane configuration.
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<> You may be using an unsupported version of Headscale or this may be a bug.</>
|
||||||
{' '}
|
)}
|
||||||
You may be using an unsupported version of Headscale or this
|
</Card.Text>
|
||||||
may be a bug.
|
<ul className="mt-2 list-inside list-disc">
|
||||||
</>
|
<li>
|
||||||
)}
|
Request URL: <Code>{requestUrl}</Code>
|
||||||
</Card.Text>
|
</li>
|
||||||
<ul className="list-disc list-inside mt-2">
|
<li>
|
||||||
<li>
|
Status Code:{" "}
|
||||||
Request URL: <Code>{requestUrl}</Code>
|
<Code>
|
||||||
</li>
|
{/* @ts-expect-error */}
|
||||||
<li>
|
{data === null ? (
|
||||||
Status Code:{' '}
|
<>
|
||||||
<Code>
|
{statusCode} {rawData}
|
||||||
{/* @ts-expect-error */}
|
</>
|
||||||
{data === null ? (
|
) : (
|
||||||
<>
|
<>
|
||||||
{statusCode} {rawData}
|
{statusCode} {error.statusText}
|
||||||
</>
|
</>
|
||||||
) : (
|
)}
|
||||||
<>
|
</Code>
|
||||||
{statusCode} {error.statusText}
|
</li>
|
||||||
</>
|
</ul>
|
||||||
)}
|
<Card.Text className="mt-4 text-lg font-semibold">Error Details</Card.Text>
|
||||||
</Code>
|
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
|
||||||
</li>
|
<code>{JSON.stringify(error.data, null, 2)}</code>
|
||||||
</ul>
|
</pre>
|
||||||
<Card.Text className="text-lg font-semibold mt-4">
|
</>
|
||||||
Error Details
|
),
|
||||||
</Card.Text>
|
};
|
||||||
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
|
}
|
||||||
<code>{JSON.stringify(error.data, null, 2)}</code>
|
|
||||||
</pre>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isConnectionError(error.data)) {
|
if (isConnectionError(error.data)) {
|
||||||
const { requestUrl, errorCode, errorMessage, extraData } = error.data;
|
const { requestUrl, errorCode, errorMessage, extraData } = error.data;
|
||||||
return {
|
return {
|
||||||
title: 'Cannot connect to Headscale API',
|
title: "Cannot connect to Headscale API",
|
||||||
jsxMessage: (
|
jsxMessage: (
|
||||||
<>
|
<>
|
||||||
<Card.Text className="leading-snug">
|
<Card.Text className="leading-snug">
|
||||||
Headplane was unable to reach the Headscale API. Please check your
|
Headplane was unable to reach the Headscale API. Please check your network setup and
|
||||||
network setup and configuration to ensure Headplane is able to
|
configuration to ensure Headplane is able to connect.
|
||||||
connect.
|
</Card.Text>
|
||||||
</Card.Text>
|
<Card.Text className="mt-4 text-lg font-semibold">Error Details</Card.Text>
|
||||||
<Card.Text className="text-lg font-semibold mt-4">
|
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
|
||||||
Error Details
|
{requestUrl}
|
||||||
</Card.Text>
|
<br />
|
||||||
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
|
{errorCode}: {errorMessage}
|
||||||
{requestUrl}
|
{extraData != null && (
|
||||||
<br />
|
<>
|
||||||
{errorCode}: {errorMessage}
|
<br />
|
||||||
{extraData != null && (
|
<br />
|
||||||
<>
|
<code>{JSON.stringify(extraData, null, 2)}</code>
|
||||||
<br />
|
</>
|
||||||
<br />
|
)}
|
||||||
<code>{JSON.stringify(extraData, null, 2)}</code>
|
</pre>
|
||||||
</>
|
</>
|
||||||
)}
|
),
|
||||||
</pre>
|
};
|
||||||
</>
|
}
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `Error ${error.status}`,
|
title: `Error ${error.status}`,
|
||||||
jsxMessage: (
|
jsxMessage: (
|
||||||
<>
|
<>
|
||||||
There was an error processing your request.
|
There was an error processing your request.
|
||||||
<br />
|
<br />
|
||||||
Status Code: <strong>{error.status}</strong>
|
Status Code: <strong>{error.status}</strong>
|
||||||
<br />
|
<br />
|
||||||
Status Text: <strong>{error.data}</strong>
|
Status Text: <strong>{error.data}</strong>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(error instanceof Error)) {
|
if (!(error instanceof Error)) {
|
||||||
return {
|
return {
|
||||||
title: 'Unexpected Error',
|
title: "Unexpected Error",
|
||||||
jsxMessage: (
|
jsxMessage: (
|
||||||
<>
|
<>
|
||||||
<Card.Text>
|
<Card.Text>
|
||||||
An unexpected error occurred which is most likely a bug. Please
|
An unexpected error occurred which is most likely a bug. Please consider reporting
|
||||||
consider reporting filing an issue on the{' '}
|
filing an issue on the{" "}
|
||||||
<Link
|
<Link name="Headplane GitHub" to="https://github.com/tale/headplane/issues">
|
||||||
name="Headplane GitHub"
|
Headplane GitHub
|
||||||
to="https://github.com/tale/headplane/issues"
|
</Link>{" "}
|
||||||
>
|
repository with the details below.
|
||||||
Headplane GitHub
|
</Card.Text>
|
||||||
</Link>{' '}
|
<Card.Text className="mt-4 text-lg font-semibold">Error Details</Card.Text>
|
||||||
repository with the details below.
|
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
|
||||||
</Card.Text>
|
<code>{JSON.stringify(error, null, 2)}</code>
|
||||||
<Card.Text className="text-lg font-semibold mt-4">
|
</pre>
|
||||||
Error Details
|
</>
|
||||||
</Card.Text>
|
),
|
||||||
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
|
};
|
||||||
<code>{JSON.stringify(error, null, 2)}</code>
|
}
|
||||||
</pre>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Traverse the error chain to find the root cause
|
// Traverse the error chain to find the root cause
|
||||||
let rootError = error;
|
let rootError = error;
|
||||||
if (error.cause != null) {
|
if (error.cause != null) {
|
||||||
rootError = error.cause as Error;
|
rootError = error.cause as Error;
|
||||||
while (rootError.cause != null) {
|
while (rootError.cause != null) {
|
||||||
rootError = rootError.cause as Error;
|
rootError = rootError.cause as Error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: If we are aggregate, concat into a single message
|
// TODO: If we are aggregate, concat into a single message
|
||||||
if (rootError instanceof AggregateError) {
|
if (rootError instanceof AggregateError) {
|
||||||
throw new Error('AggregateError handling not implemented yet');
|
throw new Error("AggregateError handling not implemented yet");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title:
|
title:
|
||||||
rootError.name.length > 0 && rootError.name !== 'Error'
|
rootError.name.length > 0 && rootError.name !== "Error"
|
||||||
? `Error: ${rootError.name}`
|
? `Error: ${rootError.name}`
|
||||||
: 'Error',
|
: "Error",
|
||||||
jsxMessage: rootError.message,
|
jsxMessage: rootError.message,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorBannerProps {
|
interface ErrorBannerProps {
|
||||||
error: unknown;
|
error: unknown;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBanner({ error, className }: ErrorBannerProps) {
|
export function ErrorBanner({ error, className }: ErrorBannerProps) {
|
||||||
const { title, jsxMessage } = getErrorMessage(error);
|
const { title, jsxMessage } = getErrorMessage(error);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={cn('w-screen', className)} variant="flat">
|
<Card className={cn("w-screen", className)} variant="flat">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<Card.Title>{title}</Card.Title>
|
<Card.Title>{title}</Card.Title>
|
||||||
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
|
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
|
||||||
</div>
|
</div>
|
||||||
{jsxMessage}
|
{jsxMessage}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,36 @@
|
|||||||
import Chip from '../Chip';
|
import Chip from "../Chip";
|
||||||
import Tooltip from '../Tooltip';
|
import Tooltip from "../Tooltip";
|
||||||
|
|
||||||
export interface ExpiryTagProps {
|
export interface ExpiryTagProps {
|
||||||
variant: 'expired' | 'no-expiry';
|
variant: "expired" | "no-expiry";
|
||||||
expiry?: string;
|
expiry?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExpiryTag({ variant, expiry }: ExpiryTagProps) {
|
export function ExpiryTag({ variant, expiry }: ExpiryTagProps) {
|
||||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||||
month: 'short',
|
month: "short",
|
||||||
day: 'numeric',
|
day: "numeric",
|
||||||
year: 'numeric',
|
year: "numeric",
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Chip
|
<Chip
|
||||||
text={
|
text={
|
||||||
variant === 'expired'
|
variant === "expired" ? `Expired ${formatter.format(new Date(expiry!))}` : "No expiry"
|
||||||
? `Expired ${formatter.format(new Date(expiry!))}`
|
}
|
||||||
: 'No expiry'
|
className="bg-mist-200 text-mist-800 dark:bg-mist-800 dark:text-mist-200"
|
||||||
}
|
/>
|
||||||
className="bg-headplane-200 text-headplane-800 dark:bg-headplane-800 dark:text-headplane-200"
|
<Tooltip.Body>
|
||||||
/>
|
{variant === "expired" ? (
|
||||||
<Tooltip.Body>
|
<>
|
||||||
{variant === 'expired' ? (
|
This machine is expired and will not be able to connect to the network. Re-authenticate
|
||||||
<>
|
with Tailscale on the machine to re-enable it.
|
||||||
This machine is expired and will not be able to connect to the
|
</>
|
||||||
network. Re-authenticate with Tailscale on the machine to re-enable
|
) : (
|
||||||
it.
|
<>This machine has key expiry disabled and will never need to re-authenticate.</>
|
||||||
</>
|
)}
|
||||||
) : (
|
</Tooltip.Body>
|
||||||
<>
|
</Tooltip>
|
||||||
This machine has key expiry disabled and will never need to
|
);
|
||||||
re-authenticate.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Tooltip.Body>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto mt-4 mb-24 overscroll-contain">
|
<main className="container mt-4 mb-24 overscroll-contain">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import { Form } from "react-router";
|
||||||
|
|
||||||
|
import Button from "~/components/Button";
|
||||||
|
import Card from "~/components/Card";
|
||||||
|
import Link from "~/components/Link";
|
||||||
|
import Options from "~/components/Options";
|
||||||
|
import toast from "~/utils/toast";
|
||||||
|
|
||||||
|
interface NoAccessProps {
|
||||||
|
linkedUserName?: string;
|
||||||
|
osValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NoAccess({ linkedUserName, osValue }: NoAccessProps) {
|
||||||
|
return (
|
||||||
|
<main className="container mt-6 mb-24 overscroll-contain">
|
||||||
|
<div className="mx-auto flex max-w-xl flex-col gap-4">
|
||||||
|
{linkedUserName ? (
|
||||||
|
<Card className="max-w-xl" variant="flat">
|
||||||
|
<p className="text-sm">
|
||||||
|
✓ Your account is linked to Headscale user <strong>{linkedUserName}</strong>.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : undefined}
|
||||||
|
<Card className="max-w-xl" variant="flat">
|
||||||
|
<Card.Title className="mb-3">Access your network via Tailscale</Card.Title>
|
||||||
|
<Card.Text>
|
||||||
|
You don't have dashboard access, but you can still connect to your Headscale network.
|
||||||
|
Install Tailscale on your device to get started.
|
||||||
|
</Card.Text>
|
||||||
|
|
||||||
|
<Options
|
||||||
|
className="my-4"
|
||||||
|
defaultSelectedKey={osValue ?? "linux"}
|
||||||
|
label="Download Selector"
|
||||||
|
>
|
||||||
|
<Options.Item
|
||||||
|
key="linux"
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Icon className="ml-1 w-4" icon="ion:terminal" />
|
||||||
|
<span>Linux</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
className="text-md flex font-mono"
|
||||||
|
onPress={async () => {
|
||||||
|
await navigator.clipboard.writeText(
|
||||||
|
"curl -fsSL https://tailscale.com/install.sh | sh",
|
||||||
|
);
|
||||||
|
toast("Copied to clipboard");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
curl -fsSL https://tailscale.com/install.sh | sh
|
||||||
|
</Button>
|
||||||
|
<p className="mt-1 text-center text-xs text-mist-600 dark:text-mist-300">
|
||||||
|
Click this button to copy the command.{" "}
|
||||||
|
<Link
|
||||||
|
name="Linux installation script"
|
||||||
|
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
|
||||||
|
>
|
||||||
|
View script source
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</Options.Item>
|
||||||
|
<Options.Item
|
||||||
|
key="windows"
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Icon className="ml-1 w-4" icon="mdi:microsoft" />
|
||||||
|
<span>Windows</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
aria-label="Download for Windows"
|
||||||
|
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<Button className="my-4 w-full" variant="heavy">
|
||||||
|
Download for Windows
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
|
||||||
|
Requires Windows 10 or later.
|
||||||
|
</p>
|
||||||
|
</Options.Item>
|
||||||
|
<Options.Item
|
||||||
|
key="macos"
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Icon className="ml-1 w-4" icon="streamline-logos:mac-finder-logo-solid" />
|
||||||
|
<span>macOS</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
aria-label="Download for macOS"
|
||||||
|
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<Button className="my-4 w-full" variant="heavy">
|
||||||
|
Download for macOS
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
|
||||||
|
Requires macOS Big Sur 11.0 or later.
|
||||||
|
<br />
|
||||||
|
You can also download Tailscale on the{" "}
|
||||||
|
<Link
|
||||||
|
name="macOS App Store"
|
||||||
|
to="https://apps.apple.com/ca/app/tailscale/id1475387142"
|
||||||
|
>
|
||||||
|
macOS App Store
|
||||||
|
</Link>
|
||||||
|
{"."}
|
||||||
|
</p>
|
||||||
|
</Options.Item>
|
||||||
|
<Options.Item
|
||||||
|
key="ios"
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Icon className="ml-1 w-4" icon="grommet-icons:apple" />
|
||||||
|
<span>iOS</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
aria-label="Download for iOS"
|
||||||
|
href="https://apps.apple.com/us/app/tailscale/id1470499037"
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<Button className="my-4 w-full" variant="heavy">
|
||||||
|
Download for iOS
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
|
||||||
|
Requires iOS 15 or later.
|
||||||
|
</p>
|
||||||
|
</Options.Item>
|
||||||
|
<Options.Item
|
||||||
|
key="android"
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Icon className="ml-1 w-4" icon="material-symbols:android" />
|
||||||
|
<span>Android</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
aria-label="Download for Android"
|
||||||
|
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<Button className="my-4 w-full" variant="heavy">
|
||||||
|
Download for Android
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
|
||||||
|
Requires Android 8 or later.
|
||||||
|
</p>
|
||||||
|
</Options.Item>
|
||||||
|
</Options>
|
||||||
|
</Card>
|
||||||
|
<Card className="max-w-xl" variant="flat">
|
||||||
|
<Card.Title className="mb-3">Need dashboard access?</Card.Title>
|
||||||
|
<Card.Text>
|
||||||
|
Your account is signed in but doesn't have permission to manage the dashboard. Contact
|
||||||
|
an administrator to request access.
|
||||||
|
</Card.Text>
|
||||||
|
<Form action="/logout" className="mt-4" method="POST">
|
||||||
|
<Button className="w-full" type="submit" variant="light">
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+3
-184
@@ -1,17 +1,12 @@
|
|||||||
import { Icon } from "@iconify/react";
|
import { Outlet, redirect } from "react-router";
|
||||||
import { Form, Outlet, redirect } from "react-router";
|
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
|
||||||
import Card from "~/components/Card";
|
|
||||||
import Footer from "~/components/Footer";
|
import Footer from "~/components/Footer";
|
||||||
import Header from "~/components/Header";
|
import Header from "~/components/Header";
|
||||||
import Link from "~/components/Link";
|
|
||||||
import Options from "~/components/Options";
|
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import toast from "~/utils/toast";
|
|
||||||
import { getUserDisplayName } from "~/utils/user";
|
import { getUserDisplayName } from "~/utils/user";
|
||||||
|
|
||||||
import { Route } from "./+types/shell";
|
import { Route } from "./+types/shell";
|
||||||
|
import NoAccess from "./no-access";
|
||||||
|
|
||||||
// This loads the bare minimum for the application to function
|
// This loads the bare minimum for the application to function
|
||||||
// So we know that if context fails to load then well, oops?
|
// So we know that if context fails to load then well, oops?
|
||||||
@@ -115,183 +110,7 @@ export default function Shell({ loaderData }: Route.ComponentProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header {...loaderData} />
|
<Header {...loaderData} />
|
||||||
<main className="container mx-auto mt-4 mb-24 overscroll-contain">
|
<NoAccess linkedUserName={loaderData.linkedUserName} osValue={loaderData.osValue} />
|
||||||
<div className="mx-auto mt-12 grid w-fit grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
{loaderData.linkedUserName ? (
|
|
||||||
<Card className="col-span-1 mx-auto max-w-lg md:col-span-2" variant="flat">
|
|
||||||
<p className="text-sm">
|
|
||||||
✓ Your account is linked to Headscale user{" "}
|
|
||||||
<strong>{loaderData.linkedUserName}</strong>.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
) : undefined}
|
|
||||||
<Card className="max-w-lg" variant="flat">
|
|
||||||
<Card.Title className="mb-8">
|
|
||||||
Access your network
|
|
||||||
<br />
|
|
||||||
via Tailscale
|
|
||||||
</Card.Title>
|
|
||||||
<Card.Text>
|
|
||||||
You don't have dashboard access, but you can still connect to your Headscale
|
|
||||||
network. Install Tailscale on your device to get started.
|
|
||||||
</Card.Text>
|
|
||||||
|
|
||||||
<Options
|
|
||||||
className="my-4"
|
|
||||||
defaultSelectedKey={loaderData.osValue ?? "linux"}
|
|
||||||
label="Download Selector"
|
|
||||||
>
|
|
||||||
<Options.Item
|
|
||||||
key="linux"
|
|
||||||
title={
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Icon className="ml-1 w-4" icon="ion:terminal" />
|
|
||||||
<span>Linux</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
className="text-md flex font-mono"
|
|
||||||
onPress={async () => {
|
|
||||||
await navigator.clipboard.writeText(
|
|
||||||
"curl -fsSL https://tailscale.com/install.sh | sh",
|
|
||||||
);
|
|
||||||
toast("Copied to clipboard");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
curl -fsSL https://tailscale.com/install.sh | sh
|
|
||||||
</Button>
|
|
||||||
<p className="text-headplane-600 dark:text-headplane-300 mt-1 text-center text-xs">
|
|
||||||
Click this button to copy the command.{" "}
|
|
||||||
<Link
|
|
||||||
name="Linux installation script"
|
|
||||||
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
|
|
||||||
>
|
|
||||||
View script source
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</Options.Item>
|
|
||||||
<Options.Item
|
|
||||||
key="windows"
|
|
||||||
title={
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Icon className="ml-1 w-4" icon="mdi:microsoft" />
|
|
||||||
<span>Windows</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
aria-label="Download for Windows"
|
|
||||||
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<Button className="my-4 w-full" variant="heavy">
|
|
||||||
Download for Windows
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
|
||||||
Requires Windows 10 or later.
|
|
||||||
</p>
|
|
||||||
</Options.Item>
|
|
||||||
<Options.Item
|
|
||||||
key="macos"
|
|
||||||
title={
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Icon className="ml-1 w-4" icon="streamline-logos:mac-finder-logo-solid" />
|
|
||||||
<span>macOS</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
aria-label="Download for macOS"
|
|
||||||
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<Button className="my-4 w-full" variant="heavy">
|
|
||||||
Download for macOS
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
|
||||||
Requires macOS Big Sur 11.0 or later.
|
|
||||||
<br />
|
|
||||||
You can also download Tailscale on the{" "}
|
|
||||||
<Link
|
|
||||||
name="macOS App Store"
|
|
||||||
to="https://apps.apple.com/ca/app/tailscale/id1475387142"
|
|
||||||
>
|
|
||||||
macOS App Store
|
|
||||||
</Link>
|
|
||||||
{"."}
|
|
||||||
</p>
|
|
||||||
</Options.Item>
|
|
||||||
<Options.Item
|
|
||||||
key="ios"
|
|
||||||
title={
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Icon className="ml-1 w-4" icon="grommet-icons:apple" />
|
|
||||||
<span>iOS</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
aria-label="Download for iOS"
|
|
||||||
href="https://apps.apple.com/us/app/tailscale/id1470499037"
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<Button className="my-4 w-full" variant="heavy">
|
|
||||||
Download for iOS
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
|
||||||
Requires iOS 15 or later.
|
|
||||||
</p>
|
|
||||||
</Options.Item>
|
|
||||||
<Options.Item
|
|
||||||
key="android"
|
|
||||||
title={
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Icon className="ml-1 w-4" icon="material-symbols:android" />
|
|
||||||
<span>Android</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
aria-label="Download for Android"
|
|
||||||
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<Button className="my-4 w-full" variant="heavy">
|
|
||||||
Download for Android
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
|
||||||
Requires Android 8 or later.
|
|
||||||
</p>
|
|
||||||
</Options.Item>
|
|
||||||
</Options>
|
|
||||||
</Card>
|
|
||||||
<Card className="max-w-lg" variant="flat">
|
|
||||||
<div className="flex h-full flex-col justify-between">
|
|
||||||
<div>
|
|
||||||
<Card.Title className="mb-4">Need dashboard access?</Card.Title>
|
|
||||||
<Card.Text>
|
|
||||||
Your account is signed in but doesn't have permission to manage the dashboard.
|
|
||||||
Contact an administrator to request access.
|
|
||||||
</Card.Text>
|
|
||||||
</div>
|
|
||||||
<Form action="/logout" className="mt-6" method="POST">
|
|
||||||
<Button className="w-full" type="submit" variant="light">
|
|
||||||
Sign out
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<Footer {...loaderData} />
|
<Footer {...loaderData} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
|
|||||||
<Links />
|
<Links />
|
||||||
<link href={`${__PREFIX__}/favicon.ico`} rel="icon" />
|
<link href={`${__PREFIX__}/favicon.ico`} rel="icon" />
|
||||||
</head>
|
</head>
|
||||||
<body className="dark:bg-headplane-900 dark:text-headplane-50 overflow-x-hidden overscroll-none">
|
<body className="overflow-x-hidden overscroll-none dark:bg-mist-900 dark:text-mist-50">
|
||||||
{children}
|
{children}
|
||||||
<ToastProvider queue={toastQueue} />
|
<ToastProvider queue={toastQueue} />
|
||||||
<ScrollRestoration />
|
<ScrollRestoration />
|
||||||
|
|||||||
@@ -1,36 +1,36 @@
|
|||||||
import cn from '~/utils/cn';
|
import cn from "~/utils/cn";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
readonly acl: string;
|
readonly acl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Fallback({ acl }: Props) {
|
export default function Fallback({ acl }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-editor flex">
|
<div className="h-editor relative flex w-full">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-full w-8 flex justify-center p-1',
|
"h-full w-8 flex justify-center p-1",
|
||||||
'border-r border-headscale-400 dark:border-headscale-800',
|
"border-r border-mist-400 dark:border-mist-800",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-5 w-5 animate-spin rounded-full',
|
"h-5 w-5 animate-spin rounded-full",
|
||||||
'border-headplane-900 dark:border-headplane-100',
|
"border-mist-900 dark:border-mist-100",
|
||||||
'border-2 border-t-transparent dark:border-t-transparent',
|
"border-2 border-t-transparent dark:border-t-transparent",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full h-editor font-mono resize-none text-sm',
|
"w-full h-editor font-mono resize-none text-sm",
|
||||||
'bg-headplane-50 dark:bg-headplane-950 opacity-60',
|
"bg-mist-50 dark:bg-mist-950 opacity-60",
|
||||||
'pl-1 pt-1 leading-snug',
|
"pl-1 pt-1 leading-snug",
|
||||||
)}
|
)}
|
||||||
readOnly
|
readOnly
|
||||||
value={acl}
|
value={acl}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,205 +1,182 @@
|
|||||||
import { closestCorners, DndContext, DragOverlay } from '@dnd-kit/core';
|
import { closestCorners, DndContext, DragOverlay } from "@dnd-kit/core";
|
||||||
|
import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||||
import {
|
import {
|
||||||
restrictToParentElement,
|
arrayMove,
|
||||||
restrictToVerticalAxis,
|
SortableContext,
|
||||||
} from '@dnd-kit/modifiers';
|
useSortable,
|
||||||
import {
|
verticalListSortingStrategy,
|
||||||
arrayMove,
|
} from "@dnd-kit/sortable";
|
||||||
SortableContext,
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
useSortable,
|
import { GripVertical, Lock } from "lucide-react";
|
||||||
verticalListSortingStrategy,
|
import { useEffect, useState } from "react";
|
||||||
} from '@dnd-kit/sortable';
|
import { Form } from "react-router";
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
|
||||||
import { GripVertical, Lock } from 'lucide-react';
|
import Button from "~/components/Button";
|
||||||
import { useEffect, useState } from 'react';
|
import Input from "~/components/Input";
|
||||||
import { Form } from 'react-router';
|
import TableList from "~/components/TableList";
|
||||||
import Button from '~/components/Button';
|
import cn from "~/utils/cn";
|
||||||
import Input from '~/components/Input';
|
|
||||||
import TableList from '~/components/TableList';
|
|
||||||
import cn from '~/utils/cn';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
searchDomains: string[];
|
searchDomains: string[];
|
||||||
isDisabled: boolean;
|
isDisabled: boolean;
|
||||||
magic?: string;
|
magic?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ManageDomains({
|
export default function ManageDomains({ searchDomains, isDisabled, magic }: Props) {
|
||||||
searchDomains,
|
const [activeId, setActiveId] = useState<number | string | null>(null);
|
||||||
isDisabled,
|
const [localDomains, setLocalDomains] = useState(searchDomains);
|
||||||
magic,
|
|
||||||
}: Props) {
|
|
||||||
const [activeId, setActiveId] = useState<number | string | null>(null);
|
|
||||||
const [localDomains, setLocalDomains] = useState(searchDomains);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalDomains(searchDomains);
|
setLocalDomains(searchDomains);
|
||||||
}, [searchDomains]);
|
}, [searchDomains]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full sm:w-2/3">
|
<div className="flex w-full flex-col sm:w-2/3">
|
||||||
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
|
<h1 className="mb-4 text-2xl font-medium">Search Domains</h1>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Set custom DNS search domains for your Tailnet. When using Magic DNS,
|
Set custom DNS search domains for your Tailnet. When using Magic DNS, your tailnet domain is
|
||||||
your tailnet domain is used as the first search domain.
|
used as the first search domain.
|
||||||
</p>
|
</p>
|
||||||
<DndContext
|
<DndContext
|
||||||
collisionDetection={closestCorners}
|
collisionDetection={closestCorners}
|
||||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||||
onDragEnd={(event) => {
|
onDragEnd={(event) => {
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over) {
|
if (!over) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeItem = localDomains[(active.id as number) - 1];
|
const activeItem = localDomains[(active.id as number) - 1];
|
||||||
const overItem = localDomains[(over.id as number) - 1];
|
const overItem = localDomains[(over.id as number) - 1];
|
||||||
|
|
||||||
if (!activeItem || !overItem) {
|
if (!activeItem || !overItem) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldIndex = localDomains.indexOf(activeItem);
|
const oldIndex = localDomains.indexOf(activeItem);
|
||||||
const newIndex = localDomains.indexOf(overItem);
|
const newIndex = localDomains.indexOf(overItem);
|
||||||
|
|
||||||
if (oldIndex !== newIndex) {
|
if (oldIndex !== newIndex) {
|
||||||
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
|
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
setActiveId(event.active.id);
|
setActiveId(event.active.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TableList>
|
<TableList>
|
||||||
{magic ? (
|
{magic ? (
|
||||||
<TableList.Item key="magic-dns-sd">
|
<TableList.Item key="magic-dns-sd">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-4',
|
"flex items-center gap-4",
|
||||||
isDisabled ? 'flex-row-reverse justify-between w-full' : '',
|
isDisabled ? "flex-row-reverse justify-between w-full" : "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Lock className="p-0.5" />
|
<Lock className="p-0.5" />
|
||||||
<p className="font-mono text-sm py-0.5">{magic}</p>
|
<p className="py-0.5 font-mono text-sm">{magic}</p>
|
||||||
</div>
|
</div>
|
||||||
</TableList.Item>
|
</TableList.Item>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<SortableContext
|
<SortableContext items={localDomains} strategy={verticalListSortingStrategy}>
|
||||||
items={localDomains}
|
{localDomains.map((sd, index) => (
|
||||||
strategy={verticalListSortingStrategy}
|
<Domain domain={sd} id={index + 1} isDisabled={isDisabled} key={sd} />
|
||||||
>
|
))}
|
||||||
{localDomains.map((sd, index) => (
|
<DragOverlay adjustScale>
|
||||||
<Domain
|
{activeId ? (
|
||||||
domain={sd}
|
<Domain
|
||||||
id={index + 1}
|
domain={localDomains[(activeId as number) - 1]}
|
||||||
isDisabled={isDisabled}
|
id={(activeId as number) - 1}
|
||||||
key={sd}
|
isDisabled={isDisabled}
|
||||||
/>
|
isDragging
|
||||||
))}
|
/>
|
||||||
<DragOverlay adjustScale>
|
) : undefined}
|
||||||
{activeId ? (
|
</DragOverlay>
|
||||||
<Domain
|
</SortableContext>
|
||||||
domain={localDomains[(activeId as number) - 1]}
|
{isDisabled ? undefined : (
|
||||||
id={(activeId as number) - 1}
|
<TableList.Item key="add-sd">
|
||||||
isDisabled={isDisabled}
|
<Form className="flex w-full items-center justify-between" method="POST">
|
||||||
isDragging
|
<input name="action_id" type="hidden" value="add_domain" />
|
||||||
/>
|
<Input
|
||||||
) : undefined}
|
className={cn(
|
||||||
</DragOverlay>
|
"border-none font-mono p-0 text-sm",
|
||||||
</SortableContext>
|
"rounded-none focus:ring-0 w-full ml-1",
|
||||||
{isDisabled ? undefined : (
|
)}
|
||||||
<TableList.Item key="add-sd">
|
isRequired
|
||||||
<Form
|
label="Search Domain"
|
||||||
className="flex items-center justify-between w-full"
|
labelHidden
|
||||||
method="POST"
|
name="domain"
|
||||||
>
|
placeholder="Search Domain"
|
||||||
<input name="action_id" type="hidden" value="add_domain" />
|
type="text"
|
||||||
<Input
|
/>
|
||||||
className={cn(
|
<Button
|
||||||
'border-none font-mono p-0 text-sm',
|
className={cn("px-2 py-1 rounded-md", "text-blue-500 dark:text-blue-400")}
|
||||||
'rounded-none focus:ring-0 w-full ml-1',
|
type="submit"
|
||||||
)}
|
>
|
||||||
isRequired
|
Add
|
||||||
label="Search Domain"
|
</Button>
|
||||||
labelHidden
|
</Form>
|
||||||
name="domain"
|
</TableList.Item>
|
||||||
placeholder="Search Domain"
|
)}
|
||||||
type="text"
|
</TableList>
|
||||||
/>
|
</DndContext>
|
||||||
<Button
|
</div>
|
||||||
className={cn(
|
);
|
||||||
'px-2 py-1 rounded-md',
|
|
||||||
'text-blue-500 dark:text-blue-400',
|
|
||||||
)}
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</TableList.Item>
|
|
||||||
)}
|
|
||||||
</TableList>
|
|
||||||
</DndContext>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DomainProps {
|
interface DomainProps {
|
||||||
domain: string;
|
domain: string;
|
||||||
id: number;
|
id: number;
|
||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
isDisabled: boolean;
|
isDisabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
|
function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
setNodeRef,
|
setNodeRef,
|
||||||
transform,
|
transform,
|
||||||
transition,
|
transition,
|
||||||
isDragging: isSortableDragging,
|
isDragging: isSortableDragging,
|
||||||
} = useSortable({ id });
|
} = useSortable({ id });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableList.Item
|
<TableList.Item
|
||||||
className={cn(
|
className={cn(
|
||||||
isSortableDragging ? 'opacity-50' : '',
|
isSortableDragging ? "opacity-50" : "",
|
||||||
isDragging ? 'ring-3 bg-white dark:bg-headplane-900' : '',
|
isDragging ? "ring-3 bg-white dark:bg-mist-900" : "",
|
||||||
)}
|
)}
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={{
|
style={{
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="font-mono text-sm flex items-center gap-4">
|
<p className="flex items-center gap-4 font-mono text-sm">
|
||||||
{isDisabled ? undefined : (
|
{isDisabled ? undefined : (
|
||||||
<GripVertical
|
<GripVertical
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
className="p-0.5 focus:ring-3 outline-hidden rounded-md"
|
className="rounded-md p-0.5 outline-hidden focus:ring-3"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{domain}
|
{domain}
|
||||||
</p>
|
</p>
|
||||||
{isDragging ? undefined : (
|
{isDragging ? undefined : (
|
||||||
<Form method="POST">
|
<Form method="POST">
|
||||||
<input name="action_id" type="hidden" value="remove_domain" />
|
<input name="action_id" type="hidden" value="remove_domain" />
|
||||||
<input name="domain" type="hidden" value={domain} />
|
<input name="domain" type="hidden" value={domain} />
|
||||||
<Button
|
<Button
|
||||||
className={cn(
|
className={cn("px-2 py-1 rounded-md", "text-red-500 dark:text-red-400")}
|
||||||
'px-2 py-1 rounded-md',
|
isDisabled={isDisabled}
|
||||||
'text-red-500 dark:text-red-400',
|
type="submit"
|
||||||
)}
|
>
|
||||||
isDisabled={isDisabled}
|
Remove
|
||||||
type="submit"
|
</Button>
|
||||||
>
|
</Form>
|
||||||
Remove
|
)}
|
||||||
</Button>
|
</TableList.Item>
|
||||||
</Form>
|
);
|
||||||
)}
|
|
||||||
</TableList.Item>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,73 @@
|
|||||||
import { Form } from 'react-router';
|
import { Form } from "react-router";
|
||||||
import Button from '~/components/Button';
|
|
||||||
import Code from '~/components/Code';
|
import Button from "~/components/Button";
|
||||||
import Link from '~/components/Link';
|
import Code from "~/components/Code";
|
||||||
import TableList from '~/components/TableList';
|
import Link from "~/components/Link";
|
||||||
import cn from '~/utils/cn';
|
import TableList from "~/components/TableList";
|
||||||
import AddRecord from '../dialogs/add-record';
|
import cn from "~/utils/cn";
|
||||||
|
|
||||||
|
import AddRecord from "../dialogs/add-record";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
records: { name: string; type: 'A' | string; value: string }[];
|
records: { name: string; type: "A" | string; value: string }[];
|
||||||
isDisabled: boolean;
|
isDisabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ManageRecords({ records, isDisabled }: Props) {
|
export default function ManageRecords({ records, isDisabled }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full sm:w-2/3">
|
<div className="flex w-full flex-col sm:w-2/3">
|
||||||
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
|
<h1 className="mb-4 text-2xl font-medium">DNS Records</h1>
|
||||||
<p>
|
<p>
|
||||||
Headscale supports adding custom DNS records to your Tailnet. As of now,
|
Headscale supports adding custom DNS records to your Tailnet. As of now, only <Code>A</Code>{" "}
|
||||||
only <Code>A</Code> and <Code>AAAA</Code> records are supported.{' '}
|
and <Code>AAAA</Code> records are supported.{" "}
|
||||||
<Link
|
<Link name="Headscale DNS Records documentation" to="https://headscale.net/stable/ref/dns">
|
||||||
name="Headscale DNS Records documentation"
|
Learn More
|
||||||
to="https://headscale.net/stable/ref/dns"
|
</Link>
|
||||||
>
|
</p>
|
||||||
Learn More
|
<div className="mt-4">
|
||||||
</Link>
|
<TableList className="mb-8">
|
||||||
</p>
|
{records.length === 0 ? (
|
||||||
<div className="mt-4">
|
<TableList.Item>
|
||||||
<TableList className="mb-8">
|
<p className="mx-auto opacity-50">No DNS records found</p>
|
||||||
{records.length === 0 ? (
|
</TableList.Item>
|
||||||
<TableList.Item>
|
) : (
|
||||||
<p className="opacity-50 mx-auto">No DNS records found</p>
|
records.map((record) => (
|
||||||
</TableList.Item>
|
<TableList.Item key={`${record.name}-${record.value}`}>
|
||||||
) : (
|
<div className="flex w-full items-center gap-2">
|
||||||
records.map((record) => (
|
<p
|
||||||
<TableList.Item key={`${record.name}-${record.value}`}>
|
className={cn(
|
||||||
<div className="flex gap-2 items-center w-full">
|
"font-mono text-sm font-bold py-1 px-2 rounded-md text-center",
|
||||||
<p
|
"bg-mist-100 dark:bg-mist-700/30 min-w-12",
|
||||||
className={cn(
|
)}
|
||||||
'font-mono text-sm font-bold py-1 px-2 rounded-md text-center',
|
>
|
||||||
'bg-headplane-100 dark:bg-headplane-700/30 min-w-12',
|
{record.type}
|
||||||
)}
|
</p>
|
||||||
>
|
<div className="flex min-w-0 flex-1 flex-col sm:flex-row sm:gap-2">
|
||||||
{record.type}
|
<p className="truncate font-mono text-sm">{record.name}</p>
|
||||||
</p>
|
<p className="truncate font-mono text-sm opacity-70 sm:opacity-100">
|
||||||
<div className="flex flex-col sm:flex-row sm:gap-2 flex-1 min-w-0">
|
{record.value}
|
||||||
<p className="font-mono text-sm truncate">{record.name}</p>
|
</p>
|
||||||
<p className="font-mono text-sm truncate opacity-70 sm:opacity-100">
|
</div>
|
||||||
{record.value}
|
</div>
|
||||||
</p>
|
<Form method="POST">
|
||||||
</div>
|
<input name="action_id" type="hidden" value="remove_record" />
|
||||||
</div>
|
<input name="record_name" type="hidden" value={record.name} />
|
||||||
<Form method="POST">
|
<input name="record_type" type="hidden" value={record.type} />
|
||||||
<input name="action_id" type="hidden" value="remove_record" />
|
<Button
|
||||||
<input name="record_name" type="hidden" value={record.name} />
|
className={cn("px-2 py-1 rounded-md", "text-red-500 dark:text-red-400")}
|
||||||
<input name="record_type" type="hidden" value={record.type} />
|
isDisabled={isDisabled}
|
||||||
<Button
|
type="submit"
|
||||||
className={cn(
|
>
|
||||||
'px-2 py-1 rounded-md',
|
Remove
|
||||||
'text-red-500 dark:text-red-400',
|
</Button>
|
||||||
)}
|
</Form>
|
||||||
isDisabled={isDisabled}
|
</TableList.Item>
|
||||||
type="submit"
|
))
|
||||||
>
|
)}
|
||||||
Remove
|
</TableList>
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</TableList.Item>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableList>
|
|
||||||
|
|
||||||
{isDisabled ? undefined : <AddRecord records={records} />}
|
{isDisabled ? undefined : <AddRecord records={records} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function MachineRow({
|
|||||||
}, [magic, node.ipAddresses]);
|
}, [magic, node.ipAddresses]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" key={node.id}>
|
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={node.id}>
|
||||||
<td className="py-2 pl-0.5 focus-within:ring-3">
|
<td className="py-2 pl-0.5 focus-within:ring-3">
|
||||||
<Link className={cn("group/link h-full focus:outline-hidden")} to={`/machines/${node.id}`}>
|
<Link className={cn("group/link h-full focus:outline-hidden")} to={`/machines/${node.id}`}>
|
||||||
<p
|
<p
|
||||||
@@ -122,7 +122,7 @@ export default function MachineRow({
|
|||||||
<StatusCircle className="mt-0.5 h-4 w-4" isOnline={node.online && !node.expired} />
|
<StatusCircle className="mt-0.5 h-4 w-4" isOnline={node.online && !node.expired} />
|
||||||
<div>
|
<div>
|
||||||
<p
|
<p
|
||||||
className={cn("text-sm", "text-headplane-600 dark:text-headplane-300")}
|
className={cn("text-sm", "text-mist-600 dark:text-mist-300")}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
{node.online && !node.expired
|
{node.online && !node.expired
|
||||||
|
|||||||
@@ -1,183 +1,184 @@
|
|||||||
import { Cog, Ellipsis, SquareTerminal } from 'lucide-react';
|
import { Cog, Ellipsis, SquareTerminal } from "lucide-react";
|
||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import Button from '~/components/Button';
|
|
||||||
import Menu from '~/components/Menu';
|
import Button from "~/components/Button";
|
||||||
import type { User } from '~/types';
|
import Menu from "~/components/Menu";
|
||||||
import cn from '~/utils/cn';
|
import type { User } from "~/types";
|
||||||
import { PopulatedNode } from '~/utils/node-info';
|
import cn from "~/utils/cn";
|
||||||
import Delete from '../dialogs/delete';
|
import { PopulatedNode } from "~/utils/node-info";
|
||||||
import Expire from '../dialogs/expire';
|
|
||||||
import Move from '../dialogs/move';
|
import Delete from "../dialogs/delete";
|
||||||
import Rename from '../dialogs/rename';
|
import Expire from "../dialogs/expire";
|
||||||
import Routes from '../dialogs/routes';
|
import Move from "../dialogs/move";
|
||||||
import Tags from '../dialogs/tags';
|
import Rename from "../dialogs/rename";
|
||||||
|
import Routes from "../dialogs/routes";
|
||||||
|
import Tags from "../dialogs/tags";
|
||||||
|
|
||||||
interface MenuProps {
|
interface MenuProps {
|
||||||
node: PopulatedNode;
|
node: PopulatedNode;
|
||||||
users: User[];
|
users: User[];
|
||||||
magic?: string;
|
magic?: string;
|
||||||
isFullButton?: boolean;
|
isFullButton?: boolean;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
existingTags?: string[];
|
existingTags?: string[];
|
||||||
supportsNodeOwnerChange: boolean;
|
supportsNodeOwnerChange: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
|
type Modal = "rename" | "expire" | "remove" | "routes" | "move" | "tags" | null;
|
||||||
|
|
||||||
export default function MachineMenu({
|
export default function MachineMenu({
|
||||||
node,
|
node,
|
||||||
magic,
|
magic,
|
||||||
users,
|
users,
|
||||||
isFullButton,
|
isFullButton,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
existingTags,
|
existingTags,
|
||||||
supportsNodeOwnerChange,
|
supportsNodeOwnerChange,
|
||||||
}: MenuProps) {
|
}: MenuProps) {
|
||||||
const [modal, setModal] = useState<Modal>(null);
|
const [modal, setModal] = useState<Modal>(null);
|
||||||
const supportsTailscaleSSH =
|
const supportsTailscaleSSH = node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0;
|
||||||
node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-end px-4 gap-1.5">
|
<div className="flex items-center justify-end gap-1.5 px-4">
|
||||||
{modal === 'remove' && (
|
{modal === "remove" && (
|
||||||
<Delete
|
<Delete
|
||||||
isOpen={modal === 'remove'}
|
isOpen={modal === "remove"}
|
||||||
machine={node}
|
machine={node}
|
||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
if (!isOpen) setModal(null);
|
if (!isOpen) setModal(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{modal === 'move' && (
|
{modal === "move" && (
|
||||||
<Move
|
<Move
|
||||||
isOpen={modal === 'move'}
|
isOpen={modal === "move"}
|
||||||
machine={node}
|
machine={node}
|
||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
if (!isOpen) setModal(null);
|
if (!isOpen) setModal(null);
|
||||||
}}
|
}}
|
||||||
users={users}
|
users={users}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{modal === 'rename' && (
|
{modal === "rename" && (
|
||||||
<Rename
|
<Rename
|
||||||
isOpen={modal === 'rename'}
|
isOpen={modal === "rename"}
|
||||||
machine={node}
|
machine={node}
|
||||||
magic={magic}
|
magic={magic}
|
||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
if (!isOpen) setModal(null);
|
if (!isOpen) setModal(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{modal === 'routes' && (
|
{modal === "routes" && (
|
||||||
<Routes
|
<Routes
|
||||||
isOpen={modal === 'routes'}
|
isOpen={modal === "routes"}
|
||||||
node={node}
|
node={node}
|
||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
if (!isOpen) setModal(null);
|
if (!isOpen) setModal(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{modal === 'tags' && (
|
{modal === "tags" && (
|
||||||
<Tags
|
<Tags
|
||||||
existingTags={existingTags}
|
existingTags={existingTags}
|
||||||
isOpen={modal === 'tags'}
|
isOpen={modal === "tags"}
|
||||||
machine={node}
|
machine={node}
|
||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
if (!isOpen) setModal(null);
|
if (!isOpen) setModal(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{node.expired && modal === 'expire' ? undefined : (
|
{node.expired && modal === "expire" ? undefined : (
|
||||||
<Expire
|
<Expire
|
||||||
isOpen={modal === 'expire'}
|
isOpen={modal === "expire"}
|
||||||
machine={node}
|
machine={node}
|
||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
if (!isOpen) setModal(null);
|
if (!isOpen) setModal(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{supportsTailscaleSSH ? (
|
{supportsTailscaleSSH ? (
|
||||||
isFullButton ? (
|
isFullButton ? (
|
||||||
<Button
|
<Button
|
||||||
className="flex items-center gap-x-2"
|
className="flex items-center gap-x-2"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
// 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.
|
||||||
window.open(
|
window.open(
|
||||||
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
|
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
|
||||||
'_blank',
|
"_blank",
|
||||||
'noopener,noreferrer,width=800,height=600',
|
"noopener,noreferrer,width=800,height=600",
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
variant="heavy"
|
variant="heavy"
|
||||||
>
|
>
|
||||||
<SquareTerminal className="h-5" />
|
<SquareTerminal className="h-5" />
|
||||||
<p>SSH</p>
|
<p>SSH</p>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
className={cn(
|
className={cn(
|
||||||
'py-0.5 w-fit bg-transparent border-transparent',
|
"py-0.5 w-fit bg-transparent border-transparent",
|
||||||
'border group-hover:border-headplane-200',
|
"border group-hover:border-mist-200",
|
||||||
'dark:group-hover:border-headplane-700',
|
"dark:group-hover:border-mist-700",
|
||||||
'opacity-0 pointer-events-none group-hover:opacity-100',
|
"opacity-0 pointer-events-none group-hover:opacity-100",
|
||||||
'group-hover:pointer-events-auto',
|
"group-hover:pointer-events-auto",
|
||||||
)}
|
)}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
// 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.
|
||||||
window.open(
|
window.open(
|
||||||
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
|
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
|
||||||
'_blank',
|
"_blank",
|
||||||
'noopener,noreferrer,width=800,height=600',
|
"noopener,noreferrer,width=800,height=600",
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
SSH
|
SSH
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<Menu isDisabled={isDisabled}>
|
<Menu isDisabled={isDisabled}>
|
||||||
{isFullButton ? (
|
{isFullButton ? (
|
||||||
<Menu.Button className="flex items-center gap-x-2">
|
<Menu.Button className="flex items-center gap-x-2">
|
||||||
<Cog className="h-5" />
|
<Cog className="h-5" />
|
||||||
<p>Machine Settings</p>
|
<p>Machine Settings</p>
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
) : (
|
) : (
|
||||||
<Menu.IconButton
|
<Menu.IconButton
|
||||||
className={cn(
|
className={cn(
|
||||||
'py-0.5 w-10 bg-transparent border-transparent',
|
"py-0.5 w-10 bg-transparent border-transparent",
|
||||||
'border group-hover:border-headplane-200',
|
"border group-hover:border-mist-200",
|
||||||
'dark:group-hover:border-headplane-700',
|
"dark:group-hover:border-mist-700",
|
||||||
)}
|
)}
|
||||||
label="Machine Options"
|
label="Machine Options"
|
||||||
>
|
>
|
||||||
<Ellipsis className="h-5" />
|
<Ellipsis className="h-5" />
|
||||||
</Menu.IconButton>
|
</Menu.IconButton>
|
||||||
)}
|
)}
|
||||||
<Menu.Panel
|
<Menu.Panel
|
||||||
disabledKeys={node.expired ? ['expire'] : []}
|
disabledKeys={node.expired ? ["expire"] : []}
|
||||||
onAction={(key) => setModal(key as Modal)}
|
onAction={(key) => setModal(key as Modal)}
|
||||||
>
|
>
|
||||||
<Menu.Section>
|
<Menu.Section>
|
||||||
<Menu.Item key="rename">Edit machine name</Menu.Item>
|
<Menu.Item key="rename">Edit machine name</Menu.Item>
|
||||||
<Menu.Item key="routes">Edit route settings</Menu.Item>
|
<Menu.Item key="routes">Edit route settings</Menu.Item>
|
||||||
<Menu.Item key="tags">Edit ACL tags</Menu.Item>
|
<Menu.Item key="tags">Edit ACL tags</Menu.Item>
|
||||||
{supportsNodeOwnerChange && <Menu.Item key="move">Change owner</Menu.Item>}
|
{supportsNodeOwnerChange && <Menu.Item key="move">Change owner</Menu.Item>}
|
||||||
</Menu.Section>
|
</Menu.Section>
|
||||||
<Menu.Section>
|
<Menu.Section>
|
||||||
<Menu.Item key="expire" textValue="Expire">
|
<Menu.Item key="expire" textValue="Expire">
|
||||||
<p className="text-red-500 dark:text-red-400">Expire</p>
|
<p className="text-red-500 dark:text-red-400">Expire</p>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item key="remove" textValue="Remove">
|
<Menu.Item key="remove" textValue="Remove">
|
||||||
<p className="text-red-500 dark:text-red-400">Remove</p>
|
<p className="text-red-500 dark:text-red-400">Remove</p>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Section>
|
</Menu.Section>
|
||||||
</Menu.Panel>
|
</Menu.Panel>
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +1,85 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import Code from '~/components/Code';
|
|
||||||
import Dialog from '~/components/Dialog';
|
import Code from "~/components/Code";
|
||||||
import Input from '~/components/Input';
|
import Dialog from "~/components/Dialog";
|
||||||
import type { Machine } from '~/types';
|
import Input from "~/components/Input";
|
||||||
|
import type { Machine } from "~/types";
|
||||||
|
|
||||||
interface RenameProps {
|
interface RenameProps {
|
||||||
machine: Machine;
|
machine: Machine;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
magic?: string;
|
magic?: string;
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Rename({
|
export default function Rename({ machine, magic, isOpen, setIsOpen }: RenameProps) {
|
||||||
machine,
|
const [name, setName] = useState(machine.givenName);
|
||||||
magic,
|
|
||||||
isOpen,
|
|
||||||
setIsOpen,
|
|
||||||
}: RenameProps) {
|
|
||||||
const [name, setName] = useState(machine.givenName);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
<Dialog.Panel>
|
<Dialog.Panel>
|
||||||
<Dialog.Title>Edit machine name for {machine.givenName}</Dialog.Title>
|
<Dialog.Title>Edit machine name for {machine.givenName}</Dialog.Title>
|
||||||
<Dialog.Text className="mb-6">
|
<Dialog.Text className="mb-6">
|
||||||
This name is shown in the admin panel, in Tailscale clients, and used
|
This name is shown in the admin panel, in Tailscale clients, and used when generating
|
||||||
when generating MagicDNS names.
|
MagicDNS names.
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<input name="action_id" type="hidden" value="rename" />
|
<input name="action_id" type="hidden" value="rename" />
|
||||||
<input name="node_id" type="hidden" value={machine.id} />
|
<input name="node_id" type="hidden" value={machine.id} />
|
||||||
<Input
|
<Input
|
||||||
defaultValue={machine.givenName}
|
defaultValue={machine.givenName}
|
||||||
isRequired
|
isRequired
|
||||||
label="Machine name"
|
label="Machine name"
|
||||||
name="name"
|
name="name"
|
||||||
onChange={setName}
|
onChange={setName}
|
||||||
placeholder="Machine name"
|
placeholder="Machine name"
|
||||||
validate={(value) => {
|
validate={(value) => {
|
||||||
if (value.length === 0) {
|
if (value.length === 0) {
|
||||||
return 'Cannot be empty';
|
return "Cannot be empty";
|
||||||
}
|
}
|
||||||
|
|
||||||
// DNS hostname validation
|
// DNS hostname validation
|
||||||
if (value.toLowerCase() !== value) {
|
if (value.toLowerCase() !== value) {
|
||||||
return 'Cannot contain uppercase letters';
|
return "Cannot contain uppercase letters";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.length > 63) {
|
if (value.length > 63) {
|
||||||
return 'DNS hostnames cannot be 64+ characters';
|
return "DNS hostnames cannot be 64+ characters";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test for invalid characters
|
// Test for invalid characters
|
||||||
if (!/^[a-z0-9-]+$/.test(value)) {
|
if (!/^[a-z0-9-]+$/.test(value)) {
|
||||||
return 'Cannot contain special characters';
|
return "Cannot contain special characters";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test for leading/trailing hyphens
|
// Test for leading/trailing hyphens
|
||||||
if (value.startsWith('-') || value.endsWith('-')) {
|
if (value.startsWith("-") || value.endsWith("-")) {
|
||||||
return 'Cannot start or end with a hyphen';
|
return "Cannot start or end with a hyphen";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test for consecutive hyphens
|
// Test for consecutive hyphens
|
||||||
if (value.includes('--')) {
|
if (value.includes("--")) {
|
||||||
return 'Cannot contain consecutive hyphens';
|
return "Cannot contain consecutive hyphens";
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
validationBehavior="native"
|
validationBehavior="native"
|
||||||
/>
|
/>
|
||||||
{magic ? (
|
{magic ? (
|
||||||
name.length > 0 && name !== machine.givenName ? (
|
name.length > 0 && name !== machine.givenName ? (
|
||||||
<p className="text-sm text-headplane-600 dark:text-headplane-300 leading-tight mt-2">
|
<p className="mt-2 text-sm leading-tight text-mist-600 dark:text-mist-300">
|
||||||
This machine will be accessible by the hostname{' '}
|
This machine will be accessible by the hostname{" "}
|
||||||
<Code className="text-sm">
|
<Code className="text-sm">{name.toLowerCase().replaceAll(/\s+/g, "-")}</Code>
|
||||||
{name.toLowerCase().replaceAll(/\s+/g, '-')}
|
{". "}
|
||||||
</Code>
|
The hostname <Code className="text-sm">{machine.givenName}</Code> will no longer point
|
||||||
{'. '}
|
to this machine.
|
||||||
The hostname <Code className="text-sm">{machine.givenName}</Code>{' '}
|
</p>
|
||||||
will no longer point to this machine.
|
) : (
|
||||||
</p>
|
<p className="mt-2 text-sm leading-tight text-mist-600 dark:text-mist-300">
|
||||||
) : (
|
This machine is accessible by the hostname{" "}
|
||||||
<p className="text-sm text-headplane-600 dark:text-headplane-300 leading-tight mt-2">
|
<Code className="text-sm">{machine.givenName}</Code>.
|
||||||
This machine is accessible by the hostname{' '}
|
</p>
|
||||||
<Code className="text-sm">{machine.givenName}</Code>.
|
)
|
||||||
</p>
|
) : undefined}
|
||||||
)
|
</Dialog.Panel>
|
||||||
) : undefined}
|
</Dialog>
|
||||||
</Dialog.Panel>
|
);
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export default function Page({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex justify-between items-center pb-2",
|
"flex justify-between items-center pb-2",
|
||||||
"border-b border-headplane-100 dark:border-headplane-800",
|
"border-b border-mist-100 dark:border-mist-800",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex items-baseline gap-x-4 text-sm">
|
<span className="flex items-baseline gap-x-4 text-sm">
|
||||||
@@ -101,8 +101,8 @@ export default function Page({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4 flex gap-1">
|
<div className="mb-4 flex gap-1">
|
||||||
<div className="border-headplane-100 dark:border-headplane-800 border-r p-2 pr-4">
|
<div className="border-r border-mist-100 p-2 pr-4 dark:border-mist-800">
|
||||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1 text-sm">
|
<span className="flex items-center gap-x-1 text-sm text-mist-600 dark:text-mist-300">
|
||||||
Managed by
|
Managed by
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Info className="p-1" />
|
<Info className="p-1" />
|
||||||
@@ -115,7 +115,7 @@ export default function Page({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2 pl-4">
|
<div className="p-2 pl-4">
|
||||||
<p className="text-headplane-600 dark:text-headplane-300 text-sm">Status</p>
|
<p className="text-sm text-mist-600 dark:text-mist-300">Status</p>
|
||||||
<div className="mt-1 mb-8 flex gap-1">
|
<div className="mt-1 mb-8 flex gap-1">
|
||||||
{mapTagsToComponents(node, uiTags)}
|
{mapTagsToComponents(node, uiTags)}
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
@@ -143,7 +143,7 @@ export default function Page({
|
|||||||
variant="flat"
|
variant="flat"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
<span className="flex items-center gap-x-1 text-mist-600 dark:text-mist-300">
|
||||||
Approved
|
Approved
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Info className="h-3.5 w-3.5" />
|
<Info className="h-3.5 w-3.5" />
|
||||||
@@ -171,7 +171,7 @@ export default function Page({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
<span className="flex items-center gap-x-1 text-mist-600 dark:text-mist-300">
|
||||||
Awaiting Approval
|
Awaiting Approval
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Info className="h-3.5 w-3.5" />
|
<Info className="h-3.5 w-3.5" />
|
||||||
@@ -200,7 +200,7 @@ export default function Page({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
<span className="flex items-center gap-x-1 text-mist-600 dark:text-mist-300">
|
||||||
Exit Node
|
Exit Node
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Info className="h-3.5 w-3.5" />
|
<Info className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -181,9 +181,9 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-2 top-1/2 -translate-y-1/2",
|
"absolute right-2 top-1/2 -translate-y-1/2",
|
||||||
"p-1 rounded-full",
|
"p-1 rounded-full",
|
||||||
"text-headplane-400 hover:text-headplane-600",
|
"text-mist-400 hover:text-mist-600",
|
||||||
"dark:text-headplane-500 dark:hover:text-headplane-300",
|
"dark:text-mist-500 dark:hover:text-mist-300",
|
||||||
"hover:bg-headplane-100 dark:hover:bg-headplane-800",
|
"hover:bg-mist-100 dark:hover:bg-mist-800",
|
||||||
)}
|
)}
|
||||||
onClick={() => setSearchQuery("")}
|
onClick={() => setSearchQuery("")}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -192,7 +192,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-headplane-500 text-sm whitespace-nowrap">
|
<span className="text-sm whitespace-nowrap text-mist-500">
|
||||||
{searchQuery
|
{searchQuery
|
||||||
? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines`
|
? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines`
|
||||||
: `${loaderData.populatedNodes.length} machines`}
|
: `${loaderData.populatedNodes.length} machines`}
|
||||||
@@ -200,7 +200,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[640px] table-auto rounded-lg">
|
<table className="w-full min-w-[640px] table-auto rounded-lg">
|
||||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
<thead className="text-mist-600 dark:text-mist-300">
|
||||||
<tr className="px-0.5 text-left">
|
<tr className="px-0.5 text-left">
|
||||||
<th
|
<th
|
||||||
aria-sort={
|
aria-sort={
|
||||||
@@ -216,7 +216,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
aria-label="Sort by name"
|
aria-label="Sort by name"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-x-1 cursor-pointer",
|
"flex items-center gap-x-1 cursor-pointer",
|
||||||
"hover:text-headplane-900 dark:hover:text-headplane-100",
|
"hover:text-mist-900 dark:hover:text-mist-100",
|
||||||
)}
|
)}
|
||||||
onClick={() => handleSort("name")}
|
onClick={() => handleSort("name")}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -245,7 +245,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
aria-label="Sort by IP address"
|
aria-label="Sort by IP address"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-x-1 cursor-pointer uppercase text-xs font-bold",
|
"flex items-center gap-x-1 cursor-pointer uppercase text-xs font-bold",
|
||||||
"hover:text-headplane-900 dark:hover:text-headplane-100",
|
"hover:text-mist-900 dark:hover:text-mist-100",
|
||||||
)}
|
)}
|
||||||
onClick={() => handleSort("ip")}
|
onClick={() => handleSort("ip")}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -289,7 +289,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
aria-label="Sort by version"
|
aria-label="Sort by version"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-x-1 cursor-pointer",
|
"flex items-center gap-x-1 cursor-pointer",
|
||||||
"hover:text-headplane-900 dark:hover:text-headplane-100",
|
"hover:text-mist-900 dark:hover:text-mist-100",
|
||||||
)}
|
)}
|
||||||
onClick={() => handleSort("version")}
|
onClick={() => handleSort("version")}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -318,7 +318,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
aria-label="Sort by last seen"
|
aria-label="Sort by last seen"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-x-1 cursor-pointer",
|
"flex items-center gap-x-1 cursor-pointer",
|
||||||
"hover:text-headplane-900 dark:hover:text-headplane-100",
|
"hover:text-mist-900 dark:hover:text-mist-100",
|
||||||
)}
|
)}
|
||||||
onClick={() => handleSort("lastSeen")}
|
onClick={() => handleSort("lastSeen")}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -332,19 +332,22 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
))}
|
))}
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
|
<th className="w-12 pb-2">
|
||||||
|
<span className="sr-only">Actions</span>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody
|
<tbody
|
||||||
className={cn(
|
className={cn(
|
||||||
"divide-y divide-headplane-100 dark:divide-headplane-800 align-top",
|
"divide-y divide-mist-100 dark:divide-mist-800 align-top",
|
||||||
"border-t border-headplane-100 dark:border-headplane-800",
|
"border-t border-mist-100 dark:border-mist-800",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{filteredAndSortedNodes.length === 0 ? (
|
{filteredAndSortedNodes.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
className="text-headplane-500 py-8 text-center"
|
className="py-8 text-center text-mist-500"
|
||||||
colSpan={loaderData.agent !== undefined ? 5 : 4}
|
colSpan={loaderData.agent !== undefined ? 6 : 5}
|
||||||
>
|
>
|
||||||
No machines found matching "{searchQuery}"
|
No machines found matching "{searchQuery}"
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default function AddAuthKey({
|
|||||||
<Dialog.Text>
|
<Dialog.Text>
|
||||||
Copy this key now. You will not be able to see the full key again.
|
Copy this key now. You will not be able to see the full key again.
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<div className="bg-headplane-100 dark:bg-headplane-800 mt-4 flex items-center gap-2 rounded-lg px-3 py-2">
|
<div className="mt-4 flex items-center gap-2 rounded-lg bg-mist-100 px-3 py-2 dark:bg-mist-800">
|
||||||
<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"
|
||||||
|
|||||||
@@ -1,85 +1,70 @@
|
|||||||
import { GlobeLock, Group, User2 } from 'lucide-react';
|
import { GlobeLock, Group, User2 } from "lucide-react";
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Form } from 'react-router';
|
import { Form } from "react-router";
|
||||||
import Button from '~/components/Button';
|
|
||||||
import TableList from '~/components/TableList';
|
import Button from "~/components/Button";
|
||||||
import cn from '~/utils/cn';
|
import TableList from "~/components/TableList";
|
||||||
|
import cn from "~/utils/cn";
|
||||||
|
|
||||||
interface RestrictionProps {
|
interface RestrictionProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
type: 'domain' | 'group' | 'user';
|
type: "domain" | "group" | "user";
|
||||||
values: string[];
|
values: string[];
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RestrictionTable({
|
export default function RestrictionTable({ children, type, values, isDisabled }: RestrictionProps) {
|
||||||
children,
|
return (
|
||||||
type,
|
<div className="w-full sm:w-2/3">
|
||||||
values,
|
<h2 className="mt-8 text-2xl font-medium">
|
||||||
isDisabled,
|
Permitted {type.charAt(0).toUpperCase() + type.slice(1)}s
|
||||||
}: RestrictionProps) {
|
</h2>
|
||||||
return (
|
<TableList className="my-4">
|
||||||
<div className="w-full sm:w-2/3">
|
{values.length > 0 ? (
|
||||||
<h2 className="text-2xl font-medium mt-8">
|
values.map((value) => (
|
||||||
Permitted {type.charAt(0).toUpperCase() + type.slice(1)}s
|
<TableList.Item key={`${type}-${value}`}>
|
||||||
</h2>
|
{type === "domain" ? (
|
||||||
<TableList className="my-4">
|
<p>
|
||||||
{values.length > 0 ? (
|
<span className="text-mist-600 dark:text-mist-300">{"<user>"}</span>
|
||||||
values.map((value) => (
|
<span className="font-bold">@</span>
|
||||||
<TableList.Item key={`${type}-${value}`}>
|
<span>{value}</span>
|
||||||
{type === 'domain' ? (
|
</p>
|
||||||
<p>
|
) : (
|
||||||
<span className="text-headplane-600 dark:text-headplane-300">
|
<p>{value}</p>
|
||||||
{'<user>'}
|
)}
|
||||||
</span>
|
<Form method="POST">
|
||||||
<span className="font-bold">@</span>
|
<input name="action_id" type="hidden" value={`remove_${type}`} />
|
||||||
<span>{value}</span>
|
<input name={type} type="hidden" value={value} />
|
||||||
</p>
|
<Button
|
||||||
) : (
|
className={cn("px-2 py-1 rounded-md", "text-red-500 dark:text-red-400")}
|
||||||
<p>{value}</p>
|
isDisabled={isDisabled}
|
||||||
)}
|
type="submit"
|
||||||
<Form method="POST">
|
>
|
||||||
<input
|
Remove
|
||||||
name="action_id"
|
</Button>
|
||||||
type="hidden"
|
</Form>
|
||||||
value={`remove_${type}`}
|
</TableList.Item>
|
||||||
/>
|
))
|
||||||
<input name={type} type="hidden" value={value} />
|
) : (
|
||||||
<Button
|
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||||
className={cn(
|
{iconForType(type)}
|
||||||
'px-2 py-1 rounded-md',
|
<p className="text-center font-semibold">All {type}s are permitted to authenticate.</p>
|
||||||
'text-red-500 dark:text-red-400',
|
</TableList.Item>
|
||||||
)}
|
)}
|
||||||
isDisabled={isDisabled}
|
</TableList>
|
||||||
type="submit"
|
{children}
|
||||||
>
|
</div>
|
||||||
Remove
|
);
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</TableList.Item>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
|
||||||
{iconForType(type)}
|
|
||||||
<p className="font-semibold text-center">
|
|
||||||
All {type}s are permitted to authenticate.
|
|
||||||
</p>
|
|
||||||
</TableList.Item>
|
|
||||||
)}
|
|
||||||
</TableList>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function iconForType(type: 'domain' | 'group' | 'user') {
|
function iconForType(type: "domain" | "group" | "user") {
|
||||||
if (type === 'domain') {
|
if (type === "domain") {
|
||||||
return <GlobeLock />;
|
return <GlobeLock />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'group') {
|
if (type === "group") {
|
||||||
return <Group />;
|
return <Group />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <User2 />;
|
return <User2 />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,10 +256,10 @@ export default function Page({ loaderData: { ipnDetails, sshDetails } }: Route.C
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-headplane-900 h-screen w-screen">
|
<div className="h-screen w-screen bg-mist-900">
|
||||||
{ipn === null ? (
|
{ipn === null ? (
|
||||||
<div className="mx-auto flex h-screen items-center justify-center">
|
<div className="mx-auto flex h-screen items-center justify-center">
|
||||||
<Loader2 className="text-headplane-50 size-10 animate-spin" />
|
<Loader2 className="size-10 animate-spin text-mist-50" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
|
|||||||
+179
-187
@@ -1,226 +1,218 @@
|
|||||||
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||||
import { FitAddon } from '@xterm/addon-fit';
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
import { Unicode11Addon } from '@xterm/addon-unicode11';
|
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
import * as xterm from '@xterm/xterm';
|
import * as xterm from "@xterm/xterm";
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from "react";
|
||||||
import cn from '~/utils/cn';
|
|
||||||
import { useLiveData } from '~/utils/live-data';
|
|
||||||
import toast from '~/utils/toast';
|
|
||||||
|
|
||||||
import '@xterm/xterm/css/xterm.css';
|
import cn from "~/utils/cn";
|
||||||
|
import { useLiveData } from "~/utils/live-data";
|
||||||
|
import toast from "~/utils/toast";
|
||||||
|
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
interface XTermProps {
|
interface XTermProps {
|
||||||
ipn: TsWasmNet;
|
ipn: TsWasmNet;
|
||||||
username: string;
|
username: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Go's WASM -> JS crosses realms so we might have to normalize the data under
|
// Go's WASM -> JS crosses realms so we might have to normalize the data under
|
||||||
// certain conditions. This also enforces bytes instead of strings being sent.
|
// certain conditions. This also enforces bytes instead of strings being sent.
|
||||||
function normU8(data: unknown) {
|
function normU8(data: unknown) {
|
||||||
if (data instanceof Uint8Array) {
|
if (data instanceof Uint8Array) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data && typeof data === 'object') {
|
if (data && typeof data === "object") {
|
||||||
const any = data as {
|
const any = data as {
|
||||||
buffer?: ArrayBufferLike;
|
buffer?: ArrayBufferLike;
|
||||||
byteOffset?: number;
|
byteOffset?: number;
|
||||||
byteLength?: number;
|
byteLength?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (any.buffer instanceof ArrayBuffer && typeof any.byteLength === "number") {
|
||||||
any.buffer instanceof ArrayBuffer &&
|
return new Uint8Array(
|
||||||
typeof any.byteLength === 'number'
|
any.buffer.slice(any.byteOffset ?? 0, (any.byteOffset ?? 0) + any.byteLength),
|
||||||
) {
|
);
|
||||||
return new Uint8Array(
|
}
|
||||||
any.buffer.slice(
|
}
|
||||||
any.byteOffset ?? 0,
|
|
||||||
(any.byteOffset ?? 0) + any.byteLength,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Data is not a Uint8Array or ArrayBuffer-like object');
|
throw new Error("Data is not a Uint8Array or ArrayBuffer-like object");
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function XTerm({ ipn, username, hostname }: XTermProps) {
|
export default function XTerm({ ipn, username, hostname }: XTermProps) {
|
||||||
const { pause } = useLiveData();
|
const { pause } = useLiveData();
|
||||||
|
|
||||||
const genRef = useRef(0);
|
const genRef = useRef(0);
|
||||||
const termRef = useRef<xterm.Terminal>(null);
|
const termRef = useRef<xterm.Terminal>(null);
|
||||||
const roRef = useRef<ResizeObserver>(null);
|
const roRef = useRef<ResizeObserver>(null);
|
||||||
const inputRef = useRef<(input: Uint8Array) => void>(null);
|
const inputRef = useRef<(input: Uint8Array) => void>(null);
|
||||||
const sshRef = useRef<SSHSession>(null);
|
const sshRef = useRef<SSHSession>(null);
|
||||||
const divRef = useRef<HTMLDivElement>(null);
|
const divRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pause();
|
pause();
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!divRef.current) {
|
if (!divRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentGen = ++genRef.current;
|
const currentGen = ++genRef.current;
|
||||||
const term = new xterm.Terminal({
|
const term = new xterm.Terminal({
|
||||||
allowProposedApi: true,
|
allowProposedApi: true,
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fit = new FitAddon();
|
const fit = new FitAddon();
|
||||||
term.loadAddon(fit);
|
term.loadAddon(fit);
|
||||||
|
|
||||||
term.loadAddon(new Unicode11Addon());
|
term.loadAddon(new Unicode11Addon());
|
||||||
term.loadAddon(new ClipboardAddon());
|
term.loadAddon(new ClipboardAddon());
|
||||||
term.loadAddon(
|
term.loadAddon(
|
||||||
new WebLinksAddon((event, uri) => {
|
new WebLinksAddon((event, uri) => {
|
||||||
event.view?.open(uri, '_blank', 'noopener noreferrer');
|
event.view?.open(uri, "_blank", "noopener noreferrer");
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
term.unicode.activeVersion = '11';
|
term.unicode.activeVersion = "11";
|
||||||
termRef.current = term;
|
termRef.current = term;
|
||||||
term.open(divRef.current!);
|
term.open(divRef.current!);
|
||||||
fit.fit();
|
fit.fit();
|
||||||
term.focus();
|
term.focus();
|
||||||
|
|
||||||
const session = ipn.OpenSSH(hostname, username, {
|
const session = ipn.OpenSSH(hostname, username, {
|
||||||
rows: term.rows,
|
rows: term.rows,
|
||||||
cols: term.cols,
|
cols: term.cols,
|
||||||
onStdout: (data) => {
|
onStdout: (data) => {
|
||||||
if (currentGen !== genRef.current || term !== termRef.current) {
|
if (currentGen !== genRef.current || term !== termRef.current) {
|
||||||
console.warn('Stale terminal instance, ignoring stdout');
|
console.warn("Stale terminal instance, ignoring stdout");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = normU8(data);
|
const text = normU8(data);
|
||||||
term.write(text);
|
term.write(text);
|
||||||
},
|
},
|
||||||
onStderr: (data) => {
|
onStderr: (data) => {
|
||||||
if (currentGen !== genRef.current || term !== termRef.current) {
|
if (currentGen !== genRef.current || term !== termRef.current) {
|
||||||
console.warn('Stale terminal instance, ignoring stderr');
|
console.warn("Stale terminal instance, ignoring stderr");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = normU8(data);
|
const text = normU8(data);
|
||||||
term.write(text);
|
term.write(text);
|
||||||
const str = new TextDecoder().decode(text);
|
const str = new TextDecoder().decode(text);
|
||||||
setError(str);
|
setError(str);
|
||||||
},
|
},
|
||||||
onStdin: (func) => {
|
onStdin: (func) => {
|
||||||
inputRef.current = func;
|
inputRef.current = func;
|
||||||
},
|
},
|
||||||
onConnect: () => {
|
onConnect: () => {
|
||||||
if (currentGen !== genRef.current) {
|
if (currentGen !== genRef.current) {
|
||||||
console.warn('Stale terminal instance, ignoring onConnect');
|
console.warn("Stale terminal instance, ignoring onConnect");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
},
|
},
|
||||||
onDisconnect: () => {
|
onDisconnect: () => {
|
||||||
if (currentGen !== genRef.current) {
|
if (currentGen !== genRef.current) {
|
||||||
console.warn('Stale terminal instance, ignoring onDisconnect');
|
console.warn("Stale terminal instance, ignoring onDisconnect");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
roRef.current?.disconnect();
|
roRef.current?.disconnect();
|
||||||
term.dispose();
|
term.dispose();
|
||||||
termRef.current = null;
|
termRef.current = null;
|
||||||
inputRef.current = null;
|
inputRef.current = null;
|
||||||
sshRef.current = null;
|
sshRef.current = null;
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
sshRef.current = session;
|
sshRef.current = session;
|
||||||
const enc = new TextEncoder();
|
const enc = new TextEncoder();
|
||||||
term.onData((data) => {
|
term.onData((data) => {
|
||||||
if (currentGen !== genRef.current) {
|
if (currentGen !== genRef.current) {
|
||||||
console.warn('Stale terminal instance, ignoring onData');
|
console.warn("Stale terminal instance, ignoring onData");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bytes = enc.encode(data);
|
const bytes = enc.encode(data);
|
||||||
inputRef.current?.(bytes);
|
inputRef.current?.(bytes);
|
||||||
});
|
});
|
||||||
|
|
||||||
const ro = new ResizeObserver(() => {
|
const ro = new ResizeObserver(() => {
|
||||||
if (currentGen !== genRef.current || term !== termRef.current) {
|
if (currentGen !== genRef.current || term !== termRef.current) {
|
||||||
console.warn('Stale terminal instance, ignoring resize');
|
console.warn("Stale terminal instance, ignoring resize");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsResizing(true);
|
setIsResizing(true);
|
||||||
fit.fit();
|
fit.fit();
|
||||||
sshRef.current?.Resize(term.cols, term.rows);
|
sshRef.current?.Resize(term.cols, term.rows);
|
||||||
setTimeout(() => setIsResizing(false), 100);
|
setTimeout(() => setIsResizing(false), 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
roRef.current = ro;
|
roRef.current = ro;
|
||||||
ro.observe(divRef.current!);
|
ro.observe(divRef.current!);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
++genRef.current;
|
++genRef.current;
|
||||||
roRef.current?.disconnect();
|
roRef.current?.disconnect();
|
||||||
roRef.current = null;
|
roRef.current = null;
|
||||||
|
|
||||||
sshRef.current?.Close();
|
sshRef.current?.Close();
|
||||||
sshRef.current = null;
|
sshRef.current = null;
|
||||||
|
|
||||||
term.dispose();
|
term.dispose();
|
||||||
if (termRef.current === term) {
|
if (termRef.current === term) {
|
||||||
termRef.current = null;
|
termRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
inputRef.current = null;
|
inputRef.current = null;
|
||||||
};
|
};
|
||||||
}, [ipn, username, hostname]);
|
}, [ipn, username, hostname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="absolute w-screen z-50 mx-auto h-screen flex items-center justify-center">
|
<div className="absolute z-50 mx-auto flex h-screen w-screen items-center justify-center">
|
||||||
<Loader2 className="animate-spin size-10 text-headplane-50" />
|
<Loader2 className="size-10 animate-spin text-mist-50" />
|
||||||
</div>
|
</div>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<div
|
<div className={cn("w-full h-full", isLoading ? "opacity-0" : "opacity-100")} ref={divRef} />
|
||||||
className={cn('w-full h-full', isLoading ? 'opacity-0' : 'opacity-100')}
|
{termRef.current && isResizing ? (
|
||||||
ref={divRef}
|
<div
|
||||||
/>
|
className={cn(
|
||||||
{termRef.current && isResizing ? (
|
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
|
||||||
<div
|
"px-4 py-2 bg-mist-800 text-white rounded-full shadow z-50",
|
||||||
className={cn(
|
)}
|
||||||
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
|
>
|
||||||
'px-4 py-2 bg-headplane-800 text-white rounded-full shadow z-50',
|
{termRef.current.cols}x{termRef.current.rows}
|
||||||
)}
|
</div>
|
||||||
>
|
) : undefined}
|
||||||
{termRef.current.cols}x{termRef.current.rows}
|
{error !== null ? (
|
||||||
</div>
|
<div
|
||||||
) : undefined}
|
className={cn(
|
||||||
{error !== null ? (
|
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center",
|
||||||
<div
|
"px-4 py-2 bg-mist-800 text-white rounded-full shadow z-50",
|
||||||
className={cn(
|
)}
|
||||||
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center',
|
>
|
||||||
'px-4 py-2 bg-headplane-800 text-white rounded-full shadow z-50',
|
Failed to connect to SSH session
|
||||||
)}
|
{error}
|
||||||
>
|
</div>
|
||||||
Failed to connect to SSH session
|
) : undefined}
|
||||||
{error}
|
</>
|
||||||
</div>
|
);
|
||||||
) : undefined}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,65 @@
|
|||||||
import { Building2, House, Key } from 'lucide-react';
|
import { Building2, House, Key } from "lucide-react";
|
||||||
import Card from '~/components/Card';
|
|
||||||
import Link from '~/components/Link';
|
import Card from "~/components/Card";
|
||||||
import CreateUser from '../dialogs/create-user';
|
import Link from "~/components/Link";
|
||||||
|
|
||||||
|
import CreateUser from "../dialogs/create-user";
|
||||||
|
|
||||||
interface ManageBannerProps {
|
interface ManageBannerProps {
|
||||||
oidc?: { issuer: string };
|
oidc?: { issuer: string };
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
|
export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="mb-8 w-full max-w-full p-0" variant="flat">
|
<Card className="mb-8 w-full max-w-full p-0" variant="flat">
|
||||||
<div className="flex flex-col md:flex-row">
|
<div className="flex flex-col md:flex-row">
|
||||||
<div className="w-full p-4 border-b md:border-b-0 border-headplane-100 dark:border-headplane-800">
|
<div className="w-full border-b border-mist-100 p-4 md:border-b-0 dark:border-mist-800">
|
||||||
{oidc ? (
|
{oidc ? <Building2 className="mb-2 h-5 w-5" /> : <House className="mb-2 h-5 w-5" />}
|
||||||
<Building2 className="w-5 h-5 mb-2" />
|
<h2 className="mb-1 font-medium">{oidc ? "OpenID Connect" : "User Authentication"}</h2>
|
||||||
) : (
|
<p className="text-sm text-mist-600 dark:text-mist-300">
|
||||||
<House className="w-5 h-5 mb-2" />
|
{oidc ? (
|
||||||
)}
|
<>
|
||||||
<h2 className="font-medium mb-1">
|
Users are managed through your{" "}
|
||||||
{oidc ? 'OpenID Connect' : 'User Authentication'}
|
<Link name="OIDC Provider" to={oidc.issuer}>
|
||||||
</h2>
|
OpenID Connect provider
|
||||||
<p className="text-sm text-headplane-600 dark:text-headplane-300">
|
</Link>
|
||||||
{oidc ? (
|
{". "}
|
||||||
<>
|
Groups and user information do not automatically sync.{" "}
|
||||||
Users are managed through your{' '}
|
<Link
|
||||||
<Link name="OIDC Provider" to={oidc.issuer}>
|
name="Headscale OIDC Documentation"
|
||||||
OpenID Connect provider
|
to="https://headscale.net/stable/ref/oidc"
|
||||||
</Link>
|
>
|
||||||
{'. '}
|
Learn more
|
||||||
Groups and user information do not automatically sync.{' '}
|
</Link>
|
||||||
<Link
|
</>
|
||||||
name="Headscale OIDC Documentation"
|
) : (
|
||||||
to="https://headscale.net/stable/ref/oidc"
|
<>
|
||||||
>
|
Users are not managed externally. Using OpenID Connect can create a better
|
||||||
Learn more
|
experience when using Headscale.{" "}
|
||||||
</Link>
|
<Link
|
||||||
</>
|
name="Headscale OIDC Documentation"
|
||||||
) : (
|
to="https://headscale.net/stable/ref/oidc"
|
||||||
<>
|
>
|
||||||
Users are not managed externally. Using OpenID Connect can
|
Learn more
|
||||||
create a better experience when using Headscale.{' '}
|
</Link>
|
||||||
<Link
|
</>
|
||||||
name="Headscale OIDC Documentation"
|
)}
|
||||||
to="https://headscale.net/stable/ref/oidc"
|
</p>
|
||||||
>
|
</div>
|
||||||
Learn more
|
<div className="w-full border-mist-100 p-4 md:border-l dark:border-mist-800">
|
||||||
</Link>
|
<Key className="mb-2 h-5 w-5" />
|
||||||
</>
|
<h2 className="mb-1 font-medium">User Management</h2>
|
||||||
)}
|
<p className="text-sm text-mist-600 dark:text-mist-300">
|
||||||
</p>
|
{oidc
|
||||||
</div>
|
? "You can still add users manually, however it is recommended that you manage users through your OIDC provider."
|
||||||
<div className="w-full p-4 md:border-l border-headplane-100 dark:border-headplane-800">
|
: "You can add, remove, and rename users here."}
|
||||||
<Key className="w-5 h-5 mb-2" />
|
</p>
|
||||||
<h2 className="font-medium mb-1">User Management</h2>
|
<div className="mt-4 flex items-center gap-2">
|
||||||
<p className="text-sm text-headplane-600 dark:text-headplane-300">
|
<CreateUser isDisabled={isDisabled} isOidc={oidc !== undefined} />
|
||||||
{oidc
|
</div>
|
||||||
? 'You can still add users manually, however it is recommended that you manage users through your OIDC provider.'
|
</div>
|
||||||
: 'You can add, remove, and rename users here.'}
|
</div>
|
||||||
</p>
|
</Card>
|
||||||
<div className="flex items-center gap-2 mt-4">
|
);
|
||||||
<CreateUser isDisabled={isDisabled} isOidc={oidc !== undefined} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ export default function UserMenu({ user, headscaleUsers, currentLink }: MenuProp
|
|||||||
<Menu.IconButton
|
<Menu.IconButton
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-10 border-transparent bg-transparent py-0.5",
|
"w-10 border-transparent bg-transparent py-0.5",
|
||||||
"border group-hover:border-headplane-200",
|
"border group-hover:border-mist-200",
|
||||||
"dark:group-hover:border-headplane-700",
|
"dark:group-hover:border-mist-700",
|
||||||
)}
|
)}
|
||||||
label="User Options"
|
label="User Options"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function UserRow({ user, role, headscaleUsers, currentLink }: Use
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" key={user.id}>
|
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={user.id}>
|
||||||
<td className="py-2 pl-0.5">
|
<td className="py-2 pl-0.5">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{user.profilePicUrl ? (
|
{user.profilePicUrl ? (
|
||||||
@@ -43,16 +43,13 @@ export default function UserRow({ user, role, headscaleUsers, currentLink }: Use
|
|||||||
<p>{mapRoleToName(role)}</p>
|
<p>{mapRoleToName(role)}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pl-0.5">
|
<td className="py-2 pl-0.5">
|
||||||
<p className="text-headplane-600 dark:text-headplane-300 text-sm" suppressHydrationWarning>
|
<p className="text-sm text-mist-600 dark:text-mist-300" suppressHydrationWarning>
|
||||||
{new Date(user.createdAt).toLocaleDateString()}
|
{new Date(user.createdAt).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pl-0.5">
|
<td className="py-2 pl-0.5">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn("flex items-center gap-x-1 text-sm", "text-mist-600 dark:text-mist-300")}
|
||||||
"flex items-center gap-x-1 text-sm",
|
|
||||||
"text-headplane-600 dark:text-headplane-300",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<StatusCircle className="h-4 w-4" isOnline={isOnline} />
|
<StatusCircle className="h-4 w-4" isOnline={isOnline} />
|
||||||
<p suppressHydrationWarning>
|
<p suppressHydrationWarning>
|
||||||
|
|||||||
@@ -1,40 +1,38 @@
|
|||||||
import Dialog from '~/components/Dialog';
|
import Dialog from "~/components/Dialog";
|
||||||
import { Machine, User } from '~/types';
|
import { Machine, User } from "~/types";
|
||||||
|
|
||||||
interface DeleteProps {
|
interface DeleteProps {
|
||||||
user: User & { machines: Machine[] };
|
user: User & { machines: Machine[] };
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DeleteUser({ user, isOpen, setIsOpen }: DeleteProps) {
|
export default function DeleteUser({ user, isOpen, setIsOpen }: DeleteProps) {
|
||||||
const name = user.name || user.displayName;
|
const name = user.name || user.displayName;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
<Dialog.Panel
|
<Dialog.Panel variant={user.machines.length > 0 ? "unactionable" : "normal"}>
|
||||||
variant={user.machines.length > 0 ? 'unactionable' : 'normal'}
|
<Dialog.Title>Delete {name}?</Dialog.Title>
|
||||||
>
|
{user.machines.length > 0 ? (
|
||||||
<Dialog.Title>Delete {name}?</Dialog.Title>
|
<Dialog.Text className="mb-6">
|
||||||
{user.machines.length > 0 ? (
|
Users cannot be deleted if they have machines. Please delete or re-assign their machines
|
||||||
<Dialog.Text className="mb-6">
|
to other users before proceeding.
|
||||||
Users cannot be deleted if they have machines. Please delete or
|
</Dialog.Text>
|
||||||
re-assign their machines to other users before proceeding.
|
) : (
|
||||||
</Dialog.Text>
|
<Dialog.Text className="mb-6">
|
||||||
) : (
|
Deleted users cannot be recovered.
|
||||||
<Dialog.Text className="mb-6">
|
{user.provider === "oidc" && (
|
||||||
Deleted users cannot be recovered.
|
<p className="mt-4 text-sm text-mist-600 dark:text-mist-300">
|
||||||
{user.provider === 'oidc' && (
|
Since this user is authenticated via an external provider, they will be recreated if
|
||||||
<p className="mt-4 text-sm text-headplane-600 dark:text-headplane-300">
|
they sign in again.
|
||||||
Since this user is authenticated via an external provider, they
|
</p>
|
||||||
will be recreated if they sign in again.
|
)}
|
||||||
</p>
|
</Dialog.Text>
|
||||||
)}
|
)}
|
||||||
</Dialog.Text>
|
<input name="action_id" type="hidden" value="delete_user" />
|
||||||
)}
|
<input name="user_id" type="hidden" value={user.id} />
|
||||||
<input name="action_id" type="hidden" value="delete_user" />
|
</Dialog.Panel>
|
||||||
<input name="user_id" type="hidden" value={user.id} />
|
</Dialog>
|
||||||
</Dialog.Panel>
|
);
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ export default function LinkUser({
|
|||||||
<select
|
<select
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full rounded-lg border p-2",
|
"w-full rounded-lg border p-2",
|
||||||
"border-headplane-200 dark:border-headplane-700",
|
"border-mist-200 dark:border-mist-700",
|
||||||
"bg-headplane-50 dark:bg-headplane-900",
|
"bg-mist-50 dark:bg-mist-900",
|
||||||
)}
|
)}
|
||||||
defaultValue={currentLink ?? ""}
|
defaultValue={currentLink ?? ""}
|
||||||
name="headscale_user_id"
|
name="headscale_user_id"
|
||||||
|
|||||||
@@ -144,8 +144,8 @@ export default function Page({
|
|||||||
<select
|
<select
|
||||||
className={cn(
|
className={cn(
|
||||||
"mb-4 w-full rounded-lg border p-2",
|
"mb-4 w-full rounded-lg border p-2",
|
||||||
"border-headplane-200 dark:border-headplane-700",
|
"border-mist-200 dark:border-mist-700",
|
||||||
"bg-headplane-50 dark:bg-headplane-900",
|
"bg-mist-50 dark:bg-mist-900",
|
||||||
)}
|
)}
|
||||||
name="headscale_user_id"
|
name="headscale_user_id"
|
||||||
required
|
required
|
||||||
@@ -167,7 +167,7 @@ export default function Page({
|
|||||||
Skip — I'll do this later
|
Skip — I'll do this later
|
||||||
</Button>
|
</Button>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<p className="text-headplane-500 mt-2 text-center text-xs">
|
<p className="mt-2 text-center text-xs text-mist-500">
|
||||||
Without linking, you won't be able to see your own machines or generate pre-auth keys.
|
Without linking, you won't be able to see your own machines or generate pre-auth keys.
|
||||||
An admin can link your account later from the Users page.
|
An admin can link your account later from the Users page.
|
||||||
</p>
|
</p>
|
||||||
@@ -213,7 +213,7 @@ export default function Page({
|
|||||||
>
|
>
|
||||||
curl -fsSL https://tailscale.com/install.sh | sh
|
curl -fsSL https://tailscale.com/install.sh | sh
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-headplane-600 dark:text-headplane-300 mt-1 text-center text-xs">
|
<p className="mt-1 text-center text-xs text-mist-600 dark:text-mist-300">
|
||||||
Click this button to copy the command.{" "}
|
Click this button to copy the command.{" "}
|
||||||
<Link
|
<Link
|
||||||
name="Linux installation script"
|
name="Linux installation script"
|
||||||
@@ -242,7 +242,7 @@ export default function Page({
|
|||||||
Download for Windows
|
Download for Windows
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
|
||||||
Requires Windows 10 or later.
|
Requires Windows 10 or later.
|
||||||
</p>
|
</p>
|
||||||
</Options.Item>
|
</Options.Item>
|
||||||
@@ -265,7 +265,7 @@ export default function Page({
|
|||||||
Download for macOS
|
Download for macOS
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
|
||||||
Requires macOS Big Sur 11.0 or later.
|
Requires macOS Big Sur 11.0 or later.
|
||||||
<br />
|
<br />
|
||||||
You can also download Tailscale on the{" "}
|
You can also download Tailscale on the{" "}
|
||||||
@@ -297,7 +297,7 @@ export default function Page({
|
|||||||
Download for iOS
|
Download for iOS
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
|
||||||
Requires iOS 15 or later.
|
Requires iOS 15 or later.
|
||||||
</p>
|
</p>
|
||||||
</Options.Item>
|
</Options.Item>
|
||||||
@@ -320,7 +320,7 @@ export default function Page({
|
|||||||
Download for Android
|
Download for Android
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
|
||||||
Requires Android 8 or later.
|
Requires Android 8 or later.
|
||||||
</p>
|
</p>
|
||||||
</Options.Item>
|
</Options.Item>
|
||||||
@@ -334,7 +334,7 @@ export default function Page({
|
|||||||
<br />
|
<br />
|
||||||
We found your first device
|
We found your first device
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
<div className="border-headplane-100 dark:border-headplane-800 rounded-xl border p-4">
|
<div className="rounded-xl border border-mist-100 p-4 dark:border-mist-800">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<StatusCircle className="mt-3 size-6" isOnline={firstMachine.online} />
|
<StatusCircle className="mt-3 size-6" isOnline={firstMachine.online} />
|
||||||
<div>
|
<div>
|
||||||
@@ -364,12 +364,10 @@ export default function Page({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"absolute inline-flex h-full w-full",
|
"absolute inline-flex h-full w-full",
|
||||||
"rounded-full opacity-75 animate-ping",
|
"rounded-full opacity-75 animate-ping",
|
||||||
"bg-headplane-500",
|
"bg-mist-500",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span
|
<span className={cn("relative inline-flex size-4 rounded-full", "bg-mist-400")} />
|
||||||
className={cn("relative inline-flex size-4 rounded-full", "bg-headplane-400")}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
<p className="font-lg">Waiting for your first device...</p>
|
<p className="font-lg">Waiting for your first device...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -134,18 +134,21 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
|
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[640px] table-auto rounded-lg">
|
<table className="w-full min-w-[640px] table-auto rounded-lg">
|
||||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
<thead className="text-mist-600 dark:text-mist-300">
|
||||||
<tr className="px-0.5 text-left">
|
<tr className="px-0.5 text-left">
|
||||||
<th className="pb-2 text-xs font-bold uppercase">User</th>
|
<th className="pb-2 text-xs font-bold uppercase">User</th>
|
||||||
<th className="pb-2 text-xs font-bold uppercase">Role</th>
|
<th className="pb-2 text-xs font-bold uppercase">Role</th>
|
||||||
<th className="pb-2 text-xs font-bold uppercase">Created At</th>
|
<th className="pb-2 text-xs font-bold uppercase">Created At</th>
|
||||||
<th className="pb-2 text-xs font-bold uppercase">Last Seen</th>
|
<th className="pb-2 text-xs font-bold uppercase">Last Seen</th>
|
||||||
|
<th className="w-12 pb-2">
|
||||||
|
<span className="sr-only">Actions</span>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody
|
<tbody
|
||||||
className={cn(
|
className={cn(
|
||||||
"divide-y divide-headplane-100 dark:divide-headplane-800 align-top",
|
"divide-y divide-mist-100 dark:divide-mist-800 align-top",
|
||||||
"border-t border-headplane-100 dark:border-headplane-800",
|
"border-t border-mist-100 dark:border-mist-800",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{users
|
{users
|
||||||
|
|||||||
+7
-34
@@ -13,41 +13,14 @@
|
|||||||
--transition-duration-25: 25ms;
|
--transition-duration-25: 25ms;
|
||||||
--transition-duration-50: 50ms;
|
--transition-duration-50: 50ms;
|
||||||
|
|
||||||
--color-main-50: #f8fafc;
|
/* Design tokens: border-radius */
|
||||||
--color-main-100: #f1f5f9;
|
--radius-sm: 0.375rem; /* 6px — chips, badges, inline elements */
|
||||||
--color-main-200: #e2e8f0;
|
--radius-md: 0.5rem; /* 8px — buttons, inputs, interactive controls */
|
||||||
--color-main-300: #cbd5e1;
|
--radius-lg: 0.75rem; /* 12px — cards, panels, dialogs, popovers */
|
||||||
--color-main-400: #94a3b8;
|
|
||||||
--color-main-500: #64748b;
|
|
||||||
--color-main-600: #475569;
|
|
||||||
--color-main-700: #334155;
|
|
||||||
--color-main-800: #1e293b;
|
|
||||||
--color-main-900: #0f172a;
|
|
||||||
--color-main-950: #020617;
|
|
||||||
|
|
||||||
--color-ui-50: #fafafa;
|
/* Design tokens: box-shadow */
|
||||||
--color-ui-100: #f5f5f5;
|
--shadow-surface: 0 1px 2px 0 rgb(0 0 0 / 0.04), 0 1px 3px 0 rgb(0 0 0 / 0.06);
|
||||||
--color-ui-200: #e5e5e5;
|
--shadow-overlay: 0 4px 12px -2px rgb(0 0 0 / 0.08), 0 2px 6px -1px rgb(0 0 0 / 0.06);
|
||||||
--color-ui-300: #d4d4d4;
|
|
||||||
--color-ui-400: #a3a3a3;
|
|
||||||
--color-ui-500: #737373;
|
|
||||||
--color-ui-600: #525252;
|
|
||||||
--color-ui-700: #404040;
|
|
||||||
--color-ui-800: #262626;
|
|
||||||
--color-ui-900: #171717;
|
|
||||||
--color-ui-950: #0a0a0a;
|
|
||||||
|
|
||||||
--color-headplane-50: #f2f2f2;
|
|
||||||
--color-headplane-100: #e6e6e6;
|
|
||||||
--color-headplane-200: #cccccc;
|
|
||||||
--color-headplane-300: #b3b3b3;
|
|
||||||
--color-headplane-400: #999999;
|
|
||||||
--color-headplane-500: #808080;
|
|
||||||
--color-headplane-600: #666666;
|
|
||||||
--color-headplane-700: #4d4d4d;
|
|
||||||
--color-headplane-800: #343434;
|
|
||||||
--color-headplane-900: #1a1a1a;
|
|
||||||
--color-headplane-950: #0d0d0d;
|
|
||||||
|
|
||||||
--animate-loading: loader 0.8s infinite ease-in-out;
|
--animate-loading: loader 0.8s infinite ease-in-out;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user