mirror of
https://github.com/tailscale/tailscale.git
synced 2025-02-20 11:58:39 +00:00
client/web: button, link, and other small UI updates
Makes the following changes: * Use “link” class in various spots * Remove button appearance on Exit Node dropdown in readonly mode * Update `-stone-` colors to `-gray-` (couple spots missed by original color config commit) * Pull full ui/button component from admin panel, and update buttons throughout UI to use this component * Remove various buttons in readonly view to match mocks * Add route (and “pending approval”) highlights to Subnet router settings card * Delete legacy client button styles from index.css * Fix overflow of IPv6 address on device details view Updates #10261 Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
parent
64a26b221b
commit
95e9d22a16
@ -153,10 +153,7 @@ function Header({
|
|||||||
<LoginToggle node={node} auth={auth} newSession={newSession} />
|
<LoginToggle node={node} auth={auth} newSession={newSession} />
|
||||||
</div>
|
</div>
|
||||||
{loc !== "/" && loc !== "/update" && (
|
{loc !== "/" && loc !== "/update" && (
|
||||||
<Link
|
<Link to="/" className="link font-medium block mb-[10px]">
|
||||||
to="/"
|
|
||||||
className="text-blue-500 font-medium leading-snug block mb-[10px]"
|
|
||||||
>
|
|
||||||
← Back to {node.DeviceName}
|
← Back to {node.DeviceName}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
@ -85,10 +85,12 @@ export default function ExitNodeSelector({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className={cx("flex-1 px-2 py-1.5 rounded-[1px]", {
|
className={cx("flex-1 px-2 py-1.5 rounded-[1px]", {
|
||||||
"bg-white hover:bg-stone-100": none,
|
"bg-white": none,
|
||||||
"bg-amber-600 hover:bg-orange-400": advertising,
|
"hover:bg-gray-100": none && !disabled,
|
||||||
"bg-blue-500 hover:bg-blue-400": using,
|
"bg-orange-600": advertising,
|
||||||
"cursor-not-allowed": disabled,
|
"hover:bg-orange-400": advertising && !disabled,
|
||||||
|
"bg-blue-500": using,
|
||||||
|
"hover:bg-blue-400": using && !disabled,
|
||||||
})}
|
})}
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@ -116,27 +118,27 @@ export default function ExitNodeSelector({
|
|||||||
? "Running as exit node"
|
? "Running as exit node"
|
||||||
: selected.Name}
|
: selected.Name}
|
||||||
</p>
|
</p>
|
||||||
<ChevronDown
|
{!disabled && (
|
||||||
className={cx("ml-1", {
|
<ChevronDown
|
||||||
"stroke-neutral-800": none,
|
className={cx("ml-1", {
|
||||||
"stroke-white": advertising || using,
|
"stroke-gray-800": none,
|
||||||
})}
|
"stroke-white": advertising || using,
|
||||||
/>
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{(advertising || using) && (
|
{!disabled && (advertising || using) && (
|
||||||
<button
|
<button
|
||||||
className={cx("px-3 py-2 rounded-sm text-white", {
|
className={cx("px-3 py-2 rounded-sm text-white", {
|
||||||
"bg-orange-400": advertising,
|
"bg-orange-400": advertising,
|
||||||
"bg-blue-400": using,
|
"bg-blue-400": using,
|
||||||
"cursor-not-allowed": disabled,
|
|
||||||
})}
|
})}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleSelect(noExitNode)
|
handleSelect(noExitNode)
|
||||||
}}
|
}}
|
||||||
disabled={disabled}
|
|
||||||
>
|
>
|
||||||
Disable
|
Disable
|
||||||
</button>
|
</button>
|
||||||
@ -252,7 +254,7 @@ function ExitNodeSelectorItem({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={node.ID}
|
key={node.ID}
|
||||||
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100"
|
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-gray-100"
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
|
@ -230,9 +230,11 @@ function SignInButton({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
className={cx("w-full text-sm mt-2", {
|
className={cx("text-center w-full mt-2", {
|
||||||
"mb-2": auth.viewerIdentity,
|
"mb-2": auth.viewerIdentity,
|
||||||
})}
|
})}
|
||||||
|
intent="primary"
|
||||||
|
sizeVariant="small"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{auth.viewerIdentity ? "Sign in to confirm identity" : "Sign in"}
|
{auth.viewerIdentity ? "Sign in to confirm identity" : "Sign in"}
|
||||||
|
@ -3,13 +3,16 @@
|
|||||||
|
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { VersionInfo } from "src/hooks/self-update"
|
import { VersionInfo } from "src/hooks/self-update"
|
||||||
import { Link } from "wouter"
|
import Button from "src/ui/button"
|
||||||
|
import { useLocation } from "wouter"
|
||||||
|
|
||||||
export function UpdateAvailableNotification({
|
export function UpdateAvailableNotification({
|
||||||
details,
|
details,
|
||||||
}: {
|
}: {
|
||||||
details: VersionInfo
|
details: VersionInfo
|
||||||
}) {
|
}) {
|
||||||
|
const [, setLocation] = useLocation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2 className="mb-2">
|
<h2 className="mb-2">
|
||||||
@ -22,12 +25,13 @@ export function UpdateAvailableNotification({
|
|||||||
: "A new update"}{" "}
|
: "A new update"}{" "}
|
||||||
is now available. <ChangelogText version={details.LatestVersion} />
|
is now available. <ChangelogText version={details.LatestVersion} />
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Button
|
||||||
className="button button-blue mt-3 text-sm inline-block"
|
className="mt-3 inline-block"
|
||||||
to="/update"
|
sizeVariant="small"
|
||||||
|
onClick={() => setLocation("/update")}
|
||||||
>
|
>
|
||||||
Update now
|
Update now
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,7 @@ import ACLTag from "src/components/acl-tag"
|
|||||||
import * as Control from "src/components/control-components"
|
import * as Control from "src/components/control-components"
|
||||||
import { UpdateAvailableNotification } from "src/components/update-available"
|
import { UpdateAvailableNotification } from "src/components/update-available"
|
||||||
import { NodeData } from "src/hooks/node-data"
|
import { NodeData } from "src/hooks/node-data"
|
||||||
|
import Button from "src/ui/button"
|
||||||
import { useLocation } from "wouter"
|
import { useLocation } from "wouter"
|
||||||
|
|
||||||
export default function DeviceDetailsView({
|
export default function DeviceDetailsView({
|
||||||
@ -34,20 +35,18 @@ export default function DeviceDetailsView({
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{!readonly && (
|
||||||
className={cx(
|
<Button
|
||||||
"px-3 py-2 bg-stone-50 rounded shadow border border-stone-200 text-gray-800 text-sm font-medium",
|
sizeVariant="small"
|
||||||
{ "cursor-not-allowed": readonly }
|
onClick={() =>
|
||||||
)}
|
apiFetch("/local/v0/logout", "POST")
|
||||||
onClick={() =>
|
.then(() => setLocation("/"))
|
||||||
apiFetch("/local/v0/logout", "POST")
|
.catch((err) => alert("Logout failed: " + err.message))
|
||||||
.then(() => setLocation("/"))
|
}
|
||||||
.catch((err) => alert("Logout failed: " + err.message))
|
>
|
||||||
}
|
Disconnect…
|
||||||
disabled={readonly}
|
</Button>
|
||||||
>
|
)}
|
||||||
Disconnect…
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{node.Features["auto-update"] &&
|
{node.Features["auto-update"] &&
|
||||||
|
@ -2,12 +2,13 @@
|
|||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import React from "react"
|
import React, { useMemo } from "react"
|
||||||
import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg"
|
import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg"
|
||||||
import { ReactComponent as ConnectedDeviceIcon } from "src/assets/icons/connected-device.svg"
|
import { ReactComponent as ConnectedDeviceIcon } from "src/assets/icons/connected-device.svg"
|
||||||
import ExitNodeSelector from "src/components/exit-node-selector"
|
import ExitNodeSelector from "src/components/exit-node-selector"
|
||||||
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
|
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
|
||||||
import { Link } from "wouter"
|
import { pluralize } from "src/util"
|
||||||
|
import { Link, useLocation } from "wouter"
|
||||||
|
|
||||||
export default function HomeView({
|
export default function HomeView({
|
||||||
readonly,
|
readonly,
|
||||||
@ -18,6 +19,14 @@ export default function HomeView({
|
|||||||
node: NodeData
|
node: NodeData
|
||||||
nodeUpdaters: NodeUpdaters
|
nodeUpdaters: NodeUpdaters
|
||||||
}) {
|
}) {
|
||||||
|
const [allSubnetRoutes, pendingSubnetRoutes] = useMemo(
|
||||||
|
() => [
|
||||||
|
node.AdvertisedRoutes?.length,
|
||||||
|
node.AdvertisedRoutes?.filter((r) => !r.Approved).length,
|
||||||
|
],
|
||||||
|
[node.AdvertisedRoutes]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-12 w-full">
|
<div className="mb-12 w-full">
|
||||||
<h2 className="mb-3">This device</h2>
|
<h2 className="mb-3">This device</h2>
|
||||||
@ -42,41 +51,63 @@ export default function HomeView({
|
|||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Link className="text-blue-500 font-medium leading-snug" to="/details">
|
<Link className="link font-medium" to="/details">
|
||||||
View device details →
|
View device details →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mb-3">Settings</h2>
|
<h2 className="mb-3">Settings</h2>
|
||||||
{node.Features["advertise-routes"] && (
|
<div className="grid gap-3">
|
||||||
<SettingsCard
|
{node.Features["advertise-routes"] && (
|
||||||
link="/subnets"
|
<SettingsCard
|
||||||
className="mb-3"
|
link="/subnets"
|
||||||
title="Subnet router"
|
title="Subnet router"
|
||||||
body="Add devices to your tailnet without installing Tailscale on them."
|
body="Add devices to your tailnet without installing Tailscale on them."
|
||||||
/>
|
badge={
|
||||||
)}
|
allSubnetRoutes
|
||||||
{node.Features["ssh"] && (
|
? {
|
||||||
<SettingsCard
|
text: `${allSubnetRoutes} ${pluralize(
|
||||||
link="/ssh"
|
"route",
|
||||||
className="mb-3"
|
"routes",
|
||||||
title="Tailscale SSH server"
|
allSubnetRoutes
|
||||||
body="Run a Tailscale SSH server on this device and allow other devices in your tailnet to SSH into it."
|
)}`,
|
||||||
badge={
|
}
|
||||||
node.RunningSSHServer
|
: undefined
|
||||||
? {
|
}
|
||||||
text: "Running",
|
footer={
|
||||||
icon: <div className="w-2 h-2 bg-emerald-500 rounded-full" />,
|
pendingSubnetRoutes
|
||||||
}
|
? `${pendingSubnetRoutes} ${pluralize(
|
||||||
: undefined
|
"route",
|
||||||
}
|
"routes",
|
||||||
/>
|
pendingSubnetRoutes
|
||||||
)}
|
)} pending approval`
|
||||||
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */}
|
: undefined
|
||||||
{/* <SettingsCard
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{node.Features["ssh"] && (
|
||||||
|
<SettingsCard
|
||||||
|
link="/ssh"
|
||||||
|
title="Tailscale SSH server"
|
||||||
|
body="Run a Tailscale SSH server on this device and allow other devices in your tailnet to SSH into it."
|
||||||
|
badge={
|
||||||
|
node.RunningSSHServer
|
||||||
|
? {
|
||||||
|
text: "Running",
|
||||||
|
icon: (
|
||||||
|
<div className="w-2 h-2 bg-emerald-500 rounded-full" />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */}
|
||||||
|
{/* <SettingsCard
|
||||||
link="/serve"
|
link="/serve"
|
||||||
title="Share local content"
|
title="Share local content"
|
||||||
body="Share local ports, services, and content to your Tailscale network or to the broader internet."
|
body="Share local ports, services, and content to your Tailscale network or to the broader internet."
|
||||||
/> */}
|
/> */}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -86,6 +117,7 @@ function SettingsCard({
|
|||||||
link,
|
link,
|
||||||
body,
|
body,
|
||||||
badge,
|
badge,
|
||||||
|
footer,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
@ -95,35 +127,43 @@ function SettingsCard({
|
|||||||
text: string
|
text: string
|
||||||
icon?: JSX.Element
|
icon?: JSX.Element
|
||||||
}
|
}
|
||||||
|
footer?: string
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
|
const [, setLocation] = useLocation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<button
|
||||||
to={link}
|
className={cx("-mx-5 card cursor-pointer", className)}
|
||||||
className={cx(
|
onClick={() => setLocation(link)}
|
||||||
"-mx-5 card flex justify-between items-center cursor-pointer",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div>
|
<div className="flex justify-between items-center">
|
||||||
<div className="flex gap-2">
|
<div>
|
||||||
<p className="text-gray-800 font-medium leading-tight mb-2">
|
<div className="flex gap-2">
|
||||||
{title}
|
<p className="text-gray-800 font-medium leading-tight mb-2">
|
||||||
</p>
|
{title}
|
||||||
{badge && (
|
</p>
|
||||||
<div className="h-5 px-2 bg-stone-100 rounded-full flex items-center gap-2">
|
{badge && (
|
||||||
{badge.icon}
|
<div className="h-5 px-2 bg-gray-100 rounded-full flex items-center gap-2">
|
||||||
<div className="text-gray-500 text-xs font-medium">
|
{badge.icon}
|
||||||
{badge.text}
|
<div className="text-gray-500 text-xs font-medium">
|
||||||
|
{badge.text}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
<p className="text-gray-500 text-sm leading-tight">{body}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ArrowRight className="ml-3" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-500 text-sm leading-tight">{body}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{footer && (
|
||||||
<ArrowRight className="ml-3" />
|
<>
|
||||||
</div>
|
<hr className="my-3" />
|
||||||
</Link>
|
<div className="text-gray-500 text-sm leading-tight">{footer}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ import React, { useCallback, useState } from "react"
|
|||||||
import { apiFetch } from "src/api"
|
import { apiFetch } from "src/api"
|
||||||
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
|
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
|
||||||
import { NodeData } from "src/hooks/node-data"
|
import { NodeData } from "src/hooks/node-data"
|
||||||
|
import Button from "src/ui/button"
|
||||||
import Collapsible from "src/ui/collapsible"
|
import Collapsible from "src/ui/collapsible"
|
||||||
import Input from "src/ui/input"
|
import Input from "src/ui/input"
|
||||||
|
|
||||||
@ -40,12 +41,13 @@ export default function LoginView({
|
|||||||
Your device is disconnected from Tailscale.
|
Your device is disconnected from Tailscale.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
onClick={() => login({})}
|
onClick={() => login({})}
|
||||||
className="button button-blue w-full mb-4"
|
className="w-full mb-4"
|
||||||
|
intent="primary"
|
||||||
>
|
>
|
||||||
Connect to Tailscale
|
Connect to Tailscale
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : data.IP ? (
|
) : data.IP ? (
|
||||||
<>
|
<>
|
||||||
@ -64,12 +66,13 @@ export default function LoginView({
|
|||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
onClick={() => login({ Reauthenticate: true })}
|
onClick={() => login({ Reauthenticate: true })}
|
||||||
className="button button-blue w-full mb-4"
|
className="w-full mb-4"
|
||||||
|
intent="primary"
|
||||||
>
|
>
|
||||||
Reauthenticate
|
Reauthenticate
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@ -89,7 +92,7 @@ export default function LoginView({
|
|||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
login({
|
login({
|
||||||
Reauthenticate: true,
|
Reauthenticate: true,
|
||||||
@ -97,10 +100,11 @@ export default function LoginView({
|
|||||||
AuthKey: authKey,
|
AuthKey: authKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="button button-blue w-full mb-4"
|
className="w-full mb-4"
|
||||||
|
intent="primary"
|
||||||
>
|
>
|
||||||
Log In
|
Log In
|
||||||
</button>
|
</Button>
|
||||||
<Collapsible trigger="Advanced options">
|
<Collapsible trigger="Advanced options">
|
||||||
<h4 className="font-medium mb-1 mt-2">Auth Key</h4>
|
<h4 className="font-medium mb-1 mt-2">Auth Key</h4>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
|
@ -19,10 +19,11 @@ export default function SubnetRouterView({
|
|||||||
node: NodeData
|
node: NodeData
|
||||||
nodeUpdaters: NodeUpdaters
|
nodeUpdaters: NodeUpdaters
|
||||||
}) {
|
}) {
|
||||||
const advertisedRoutes = useMemo(
|
const [advertisedRoutes, hasUnapprovedRoutes] = useMemo(() => {
|
||||||
() => node.AdvertisedRoutes || [],
|
const routes = node.AdvertisedRoutes || []
|
||||||
[node.AdvertisedRoutes]
|
return [routes, routes.find((r) => !r.Approved)]
|
||||||
)
|
}, [node.AdvertisedRoutes])
|
||||||
|
|
||||||
const [inputOpen, setInputOpen] = useState<boolean>(
|
const [inputOpen, setInputOpen] = useState<boolean>(
|
||||||
advertisedRoutes.length === 0 && !readonly
|
advertisedRoutes.length === 0 && !readonly
|
||||||
)
|
)
|
||||||
@ -42,42 +43,49 @@ export default function SubnetRouterView({
|
|||||||
Learn more →
|
Learn more →
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
{inputOpen ? (
|
{!readonly &&
|
||||||
<div className="-mx-5 card shadow">
|
(inputOpen ? (
|
||||||
<p className="font-medium leading-snug mb-3">Advertise new routes</p>
|
<div className="-mx-5 card shadow">
|
||||||
<Input
|
<p className="font-medium leading-snug mb-3">
|
||||||
type="text"
|
Advertise new routes
|
||||||
className="text-sm"
|
</p>
|
||||||
placeholder="192.168.0.0/24"
|
<Input
|
||||||
value={inputText}
|
type="text"
|
||||||
onChange={(e) => setInputText(e.target.value)}
|
className="text-sm"
|
||||||
/>
|
placeholder="192.168.0.0/24"
|
||||||
<p className="my-2 h-6 text-gray-500 text-sm leading-tight">
|
value={inputText}
|
||||||
Add multiple routes by providing a comma-separated list.
|
onChange={(e) => setInputText(e.target.value)}
|
||||||
</p>
|
/>
|
||||||
|
<p className="my-2 h-6 text-gray-500 text-sm leading-tight">
|
||||||
|
Add multiple routes by providing a comma-separated list.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
intent="primary"
|
||||||
|
onClick={() =>
|
||||||
|
nodeUpdaters
|
||||||
|
.postSubnetRoutes([
|
||||||
|
...advertisedRoutes.map((r) => r.Route),
|
||||||
|
...inputText.split(","),
|
||||||
|
])
|
||||||
|
.then(() => {
|
||||||
|
setInputText("")
|
||||||
|
setInputOpen(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!inputText}
|
||||||
|
>
|
||||||
|
Advertise routes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
intent="primary"
|
||||||
nodeUpdaters
|
prefixIcon={<Plus />}
|
||||||
.postSubnetRoutes([
|
onClick={() => setInputOpen(true)}
|
||||||
...advertisedRoutes.map((r) => r.Route),
|
|
||||||
...inputText.split(","),
|
|
||||||
])
|
|
||||||
.then(() => {
|
|
||||||
setInputText("")
|
|
||||||
setInputOpen(false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={readonly || !inputText}
|
|
||||||
>
|
>
|
||||||
Advertise routes
|
Advertise new route
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
))}
|
||||||
) : (
|
|
||||||
<Button onClick={() => setInputOpen(true)} disabled={readonly}>
|
|
||||||
<Plus />
|
|
||||||
Advertise new route
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<div className="-mx-5 mt-10">
|
<div className="-mx-5 mt-10">
|
||||||
{advertisedRoutes.length > 0 ? (
|
{advertisedRoutes.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
@ -96,7 +104,7 @@ export default function SubnetRouterView({
|
|||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
{r.Approved ? (
|
{r.Approved ? (
|
||||||
<div className="text-emerald-800 text-sm leading-tight">
|
<div className="text-green-500 text-sm leading-tight">
|
||||||
Approved
|
Approved
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -105,37 +113,39 @@ export default function SubnetRouterView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{!readonly && (
|
||||||
intent="secondary"
|
<Button
|
||||||
className="text-sm font-medium"
|
sizeVariant="small"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
nodeUpdaters.postSubnetRoutes(
|
nodeUpdaters.postSubnetRoutes(
|
||||||
advertisedRoutes
|
advertisedRoutes
|
||||||
.map((it) => it.Route)
|
.map((it) => it.Route)
|
||||||
.filter((it) => it !== r.Route)
|
.filter((it) => it !== r.Route)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
disabled={readonly}
|
>
|
||||||
>
|
Stop advertising…
|
||||||
Stop advertising…
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Control.AdminContainer
|
{hasUnapprovedRoutes && (
|
||||||
className="mt-3 w-full text-center text-gray-500 text-sm leading-tight"
|
<Control.AdminContainer
|
||||||
node={node}
|
className="mt-3 w-full text-center text-gray-500 text-sm leading-tight"
|
||||||
>
|
node={node}
|
||||||
To approve routes, in the admin console go to{" "}
|
>
|
||||||
<Control.AdminLink node={node} path={`/machines/${node.IP}`}>
|
To approve routes, in the admin console go to{" "}
|
||||||
the machine’s route settings
|
<Control.AdminLink node={node} path={`/machines/${node.IP}`}>
|
||||||
</Control.AdminLink>
|
the machine’s route settings
|
||||||
.
|
</Control.AdminLink>
|
||||||
</Control.AdminContainer>
|
.
|
||||||
|
</Control.AdminContainer>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="px-5 py-4 bg-stone-50 rounded-lg border border-gray-200 text-center text-gray-500">
|
<div className="px-5 py-4 bg-gray-50 rounded-lg border border-gray-200 text-center text-gray-500">
|
||||||
Not advertising any routes
|
Not advertising any routes
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -10,8 +10,9 @@ import {
|
|||||||
useInstallUpdate,
|
useInstallUpdate,
|
||||||
VersionInfo,
|
VersionInfo,
|
||||||
} from "src/hooks/self-update"
|
} from "src/hooks/self-update"
|
||||||
|
import Button from "src/ui/button"
|
||||||
import Spinner from "src/ui/spinner"
|
import Spinner from "src/ui/spinner"
|
||||||
import { Link } from "wouter"
|
import { useLocation } from "wouter"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UpdatingView is rendered when the user initiates a Tailscale update, and
|
* UpdatingView is rendered when the user initiates a Tailscale update, and
|
||||||
@ -24,6 +25,7 @@ export function UpdatingView({
|
|||||||
versionInfo?: VersionInfo
|
versionInfo?: VersionInfo
|
||||||
currentVersion: string
|
currentVersion: string
|
||||||
}) {
|
}) {
|
||||||
|
const [, setLocation] = useLocation()
|
||||||
const { updateState, updateLog } = useInstallUpdate(
|
const { updateState, updateLog } = useInstallUpdate(
|
||||||
currentVersion,
|
currentVersion,
|
||||||
versionInfo
|
versionInfo
|
||||||
@ -51,9 +53,13 @@ export function UpdatingView({
|
|||||||
: null}
|
: null}
|
||||||
. <ChangelogText version={versionInfo?.LatestVersion} />
|
. <ChangelogText version={versionInfo?.LatestVersion} />
|
||||||
</p>
|
</p>
|
||||||
<Link className="button button-blue text-sm m-3" to="/">
|
<Button
|
||||||
|
className="m-3"
|
||||||
|
sizeVariant="small"
|
||||||
|
onClick={() => setLocation("/")}
|
||||||
|
>
|
||||||
Log in to access
|
Log in to access
|
||||||
</Link>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : updateState === UpdateState.UpToDate ? (
|
) : updateState === UpdateState.UpToDate ? (
|
||||||
<>
|
<>
|
||||||
@ -63,9 +69,13 @@ export function UpdatingView({
|
|||||||
You are already running Tailscale {currentVersion}, which is the
|
You are already running Tailscale {currentVersion}, which is the
|
||||||
newest version available.
|
newest version available.
|
||||||
</p>
|
</p>
|
||||||
<Link className="button button-blue text-sm m-3" to="/">
|
<Button
|
||||||
|
className="m-3"
|
||||||
|
sizeVariant="small"
|
||||||
|
onClick={() => setLocation("/")}
|
||||||
|
>
|
||||||
Return
|
Return
|
||||||
</Link>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
/* TODO(naman,sonia): Figure out the body copy and design for this view. */
|
/* TODO(naman,sonia): Figure out the body copy and design for this view. */
|
||||||
@ -79,9 +89,13 @@ export function UpdatingView({
|
|||||||
: null}{" "}
|
: null}{" "}
|
||||||
failed.
|
failed.
|
||||||
</p>
|
</p>
|
||||||
<Link className="button button-blue text-sm m-3" to="/">
|
<Button
|
||||||
|
className="m-3"
|
||||||
|
sizeVariant="small"
|
||||||
|
onClick={() => setLocation("/")}
|
||||||
|
>
|
||||||
Return
|
Return
|
||||||
</Link>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<pre className="h-64 overflow-scroll m-3">
|
<pre className="h-64 overflow-scroll m-3">
|
||||||
|
@ -175,14 +175,20 @@
|
|||||||
.card h2 {
|
.card h2 {
|
||||||
@apply text-gray-500 text-xs font-semibold uppercase tracking-wide;
|
@apply text-gray-500 text-xs font-semibold uppercase tracking-wide;
|
||||||
}
|
}
|
||||||
|
.card table {
|
||||||
|
@apply w-full;
|
||||||
|
}
|
||||||
.card tbody {
|
.card tbody {
|
||||||
@apply flex flex-col gap-2;
|
@apply flex flex-col gap-2;
|
||||||
}
|
}
|
||||||
|
.card tr {
|
||||||
|
@apply grid grid-flow-col grid-cols-3 gap-2;
|
||||||
|
}
|
||||||
.card td:first-child {
|
.card td:first-child {
|
||||||
@apply w-40 text-gray-500 text-sm leading-tight flex-shrink-0;
|
@apply text-gray-500 text-sm leading-tight truncate;
|
||||||
}
|
}
|
||||||
.card td:last-child {
|
.card td:last-child {
|
||||||
@apply text-gray-800 text-sm leading-tight;
|
@apply col-span-2 text-gray-800 text-sm leading-tight truncate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
@ -286,6 +292,39 @@
|
|||||||
@apply w-[0.675rem] translate-x-[0.55rem];
|
@apply w-[0.675rem] translate-x-[0.55rem];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* .button encapsulates all the base button styles we use across the app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.button {
|
||||||
|
@apply relative inline-flex flex-nowrap items-center justify-center font-medium py-2 px-4 rounded-md border border-transparent text-center whitespace-nowrap;
|
||||||
|
transition-property: background-color, border-color, color, box-shadow;
|
||||||
|
transition-duration: 120ms;
|
||||||
|
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
.button:focus-visible {
|
||||||
|
@apply outline-none ring;
|
||||||
|
}
|
||||||
|
.button:disabled {
|
||||||
|
@apply pointer-events-none select-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
@apply whitespace-nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group .button {
|
||||||
|
@apply min-w-[60px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group .button:not(:first-child) {
|
||||||
|
@apply rounded-l-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group .button:not(:last-child) {
|
||||||
|
@apply rounded-r-none border-r-0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* .input defines default text input field styling. These styles should
|
* .input defines default text input field styling. These styles should
|
||||||
* correspond to .button, sharing a similar height and rounding, since .input
|
* correspond to .button, sharing a similar height and rounding, since .input
|
||||||
@ -321,6 +360,104 @@
|
|||||||
.input-error {
|
.input-error {
|
||||||
@apply border-red-200;
|
@apply border-red-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* .loading-dots creates a set of three dots that pulse for indicating loading
|
||||||
|
* states where a more horizontal appearance is helpful.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.loading-dots {
|
||||||
|
@apply inline-flex items-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots span {
|
||||||
|
@apply inline-block w-[0.35rem] h-[0.35rem] rounded-full bg-current mx-[0.15em];
|
||||||
|
animation-name: loading-dots-blink;
|
||||||
|
animation-duration: 1.4s;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots span:nth-child(2) {
|
||||||
|
animation-delay: 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots span:nth-child(3) {
|
||||||
|
animation-delay: 400ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading-dots-blink {
|
||||||
|
0% {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* .spinner creates a circular animated spinner, most often used to indicate a
|
||||||
|
* loading state. The .spinner element must define a width, height, and
|
||||||
|
* border-width for the spinner to apply.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
@apply border-transparent border-t-current border-l-current rounded-full;
|
||||||
|
animation: spin 700ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* .link applies standard styling to links across the app. By default we unstyle
|
||||||
|
* all anchor tags. While this might sound crazy for a website, it's _very_
|
||||||
|
* helpful in an app, since anchor tags can be used to wrap buttons, icons,
|
||||||
|
* and all manner of UI component. As a result, all anchor tags intended to look
|
||||||
|
* like links should have a .link class.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.link {
|
||||||
|
@apply text-text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link:hover,
|
||||||
|
.link:active {
|
||||||
|
@apply text-blue-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-destructive {
|
||||||
|
@apply text-text-danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-destructive:hover,
|
||||||
|
.link-destructive:active {
|
||||||
|
@apply text-red-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-fade {
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-fade:hover {
|
||||||
|
@apply opacity-75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-underline {
|
||||||
|
@apply underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-underline:hover {
|
||||||
|
@apply opacity-75;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
@ -328,150 +465,3 @@
|
|||||||
@apply h-[2.375rem];
|
@apply h-[2.375rem];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Non-Tailwind styles begin here.
|
|
||||||
*/
|
|
||||||
|
|
||||||
.bg-gray-0 {
|
|
||||||
--tw-bg-opacity: 1;
|
|
||||||
background-color: rgba(250, 249, 248, var(--tw-bg-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-gray-50 {
|
|
||||||
--tw-bg-opacity: 1;
|
|
||||||
background-color: rgba(249, 247, 246, var(--tw-bg-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
letter-spacing: -0.015em;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link {
|
|
||||||
--text-opacity: 1;
|
|
||||||
color: #4b70cc;
|
|
||||||
color: rgba(75, 112, 204, var(--text-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.link:hover,
|
|
||||||
.link:active {
|
|
||||||
--text-opacity: 1;
|
|
||||||
color: #19224a;
|
|
||||||
color: rgba(25, 34, 74, var(--text-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-underline {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-underline:hover,
|
|
||||||
.link-underline:active {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-muted {
|
|
||||||
/* same as text-gray-500 */
|
|
||||||
--tw-text-opacity: 1;
|
|
||||||
color: rgba(112, 110, 109, var(--tw-text-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-muted:hover,
|
|
||||||
.link-muted:active {
|
|
||||||
/* same as text-gray-500 */
|
|
||||||
--tw-text-opacity: 1;
|
|
||||||
color: rgba(68, 67, 66, var(--tw-text-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
font-weight: 500;
|
|
||||||
padding-top: 0.45rem;
|
|
||||||
padding-bottom: 0.45rem;
|
|
||||||
padding-left: 1rem;
|
|
||||||
padding-right: 1rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: transparent;
|
|
||||||
transition-property: background-color, border-color, color, box-shadow;
|
|
||||||
transition-duration: 120ms;
|
|
||||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
|
|
||||||
min-width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:focus {
|
|
||||||
outline: 0;
|
|
||||||
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-blue {
|
|
||||||
--bg-opacity: 1;
|
|
||||||
background-color: #4b70cc;
|
|
||||||
background-color: rgba(75, 112, 204, var(--bg-opacity));
|
|
||||||
--border-opacity: 1;
|
|
||||||
border-color: #4b70cc;
|
|
||||||
border-color: rgba(75, 112, 204, var(--border-opacity));
|
|
||||||
--text-opacity: 1;
|
|
||||||
color: #fff;
|
|
||||||
color: rgba(255, 255, 255, var(--text-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-blue:enabled:hover {
|
|
||||||
--bg-opacity: 1;
|
|
||||||
background-color: #3f5db3;
|
|
||||||
background-color: rgba(63, 93, 179, var(--bg-opacity));
|
|
||||||
--border-opacity: 1;
|
|
||||||
border-color: #3f5db3;
|
|
||||||
border-color: rgba(63, 93, 179, var(--border-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-blue:disabled {
|
|
||||||
--text-opacity: 1;
|
|
||||||
color: #cedefd;
|
|
||||||
color: rgba(206, 222, 253, var(--text-opacity));
|
|
||||||
--bg-opacity: 1;
|
|
||||||
background-color: #6c94ec;
|
|
||||||
background-color: rgba(108, 148, 236, var(--bg-opacity));
|
|
||||||
--border-opacity: 1;
|
|
||||||
border-color: #6c94ec;
|
|
||||||
border-color: rgba(108, 148, 236, var(--border-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-red {
|
|
||||||
background-color: #d04841;
|
|
||||||
border-color: #d04841;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-red:enabled:hover {
|
|
||||||
background-color: #b22d30;
|
|
||||||
border-color: #b22d30;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* .spinner creates a circular animated spinner, most often used to indicate a
|
|
||||||
* loading state. The .spinner element must define a width, height, and
|
|
||||||
* border-width for the spinner to apply.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
@apply border-transparent border-t-current border-l-current rounded-full;
|
|
||||||
animation: spin 700ms linear infinite;
|
|
||||||
}
|
|
||||||
|
@ -2,32 +2,148 @@
|
|||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import React, { ButtonHTMLAttributes } from "react"
|
import React, { HTMLProps } from "react"
|
||||||
|
import LoadingDots from "src/ui/loading-dots"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
intent?: "primary" | "secondary"
|
type?: "button" | "submit" | "reset"
|
||||||
} & ButtonHTMLAttributes<HTMLButtonElement>
|
sizeVariant?: "input" | "small" | "medium" | "large"
|
||||||
|
/**
|
||||||
|
* variant is the visual style of the button. By default, this is a filled
|
||||||
|
* button. For a less prominent button, use minimal.
|
||||||
|
*/
|
||||||
|
variant?: Variant
|
||||||
|
/**
|
||||||
|
* intent describes the semantic meaning of the button's action. For
|
||||||
|
* dangerous or destructive actions, use danger. For actions that should
|
||||||
|
* be the primary focus, use primary.
|
||||||
|
*/
|
||||||
|
intent?: Intent
|
||||||
|
|
||||||
export default function Button(props: Props) {
|
active?: boolean
|
||||||
const { intent = "primary", className, disabled, children, ...rest } = props
|
/**
|
||||||
|
* prefixIcon is an icon or piece of content shown at the start of a button.
|
||||||
|
*/
|
||||||
|
prefixIcon?: React.ReactNode
|
||||||
|
/**
|
||||||
|
* suffixIcon is an icon or piece of content shown at the end of a button.
|
||||||
|
*/
|
||||||
|
suffixIcon?: React.ReactNode
|
||||||
|
/**
|
||||||
|
* loading displays a loading indicator inside the button when set to true.
|
||||||
|
* The sizing of the button is not affected by this prop.
|
||||||
|
*/
|
||||||
|
loading?: boolean
|
||||||
|
/**
|
||||||
|
* iconOnly indicates that the button contains only an icon. This is used to
|
||||||
|
* adjust styles to be appropriate for an icon-only button.
|
||||||
|
*/
|
||||||
|
iconOnly?: boolean
|
||||||
|
/**
|
||||||
|
* textAlign align the text center or left. If left aligned, any icons will
|
||||||
|
* move to the sides of the button.
|
||||||
|
*/
|
||||||
|
textAlign?: "center" | "left"
|
||||||
|
} & HTMLProps<HTMLButtonElement>
|
||||||
|
|
||||||
|
export type Variant = "filled" | "minimal"
|
||||||
|
export type Intent = "base" | "primary" | "warning" | "danger" | "black"
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, Props>((props, ref) => {
|
||||||
|
const {
|
||||||
|
className,
|
||||||
|
variant = "filled",
|
||||||
|
intent = "base",
|
||||||
|
sizeVariant = "large",
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
loading,
|
||||||
|
active,
|
||||||
|
iconOnly,
|
||||||
|
prefixIcon,
|
||||||
|
suffixIcon,
|
||||||
|
textAlign,
|
||||||
|
...rest
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const hasIcon = Boolean(prefixIcon || suffixIcon)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={cx(
|
className={cx(
|
||||||
"px-3 py-2 rounded shadow justify-center items-center gap-2.5 inline-flex font-medium",
|
"button",
|
||||||
{
|
{
|
||||||
"bg-blue-500 text-white": intent === "primary" && !disabled,
|
// base filled
|
||||||
"bg-blue-400 text-blue-200": intent === "primary" && disabled,
|
"bg-gray-0 border-gray-300 enabled:hover:bg-gray-100 enabled:hover:border-gray-300 enabled:hover:text-gray-900 disabled:border-gray-200 disabled:text-gray-400":
|
||||||
"bg-stone-50 shadow border border-stone-200 text-gray-800":
|
intent === "base" && variant === "filled",
|
||||||
intent === "secondary",
|
"enabled:bg-gray-200 enabled:border-gray-300":
|
||||||
"cursor-not-allowed": disabled,
|
intent === "base" && variant === "filled" && active,
|
||||||
|
// primary filled
|
||||||
|
"bg-blue-500 border-blue-500 text-white enabled:hover:bg-blue-600 enabled:hover:border-blue-600 disabled:text-blue-50 disabled:bg-blue-300 disabled:border-blue-300":
|
||||||
|
intent === "primary" && variant === "filled",
|
||||||
|
// danger filled
|
||||||
|
"bg-red-400 border-red-400 text-white enabled:hover:bg-red-500 enabled:hover:border-red-500 disabled:text-red-50 disabled:bg-red-300 disabled:border-red-300":
|
||||||
|
intent === "danger" && variant === "filled",
|
||||||
|
// warning filled
|
||||||
|
"bg-yellow-300 border-yellow-300 text-white enabled:hover:bg-yellow-400 enabled:hover:border-yellow-400 disabled:text-yellow-50 disabled:bg-yellow-200 disabled:border-yellow-200":
|
||||||
|
intent === "warning" && variant === "filled",
|
||||||
|
// black filled
|
||||||
|
"bg-gray-800 border-gray-800 text-white enabled:hover:bg-gray-900 enabled:hover:border-gray-900 disabled:opacity-75":
|
||||||
|
intent === "black" && variant === "filled",
|
||||||
|
|
||||||
|
// minimal button (base variant, black is also included because its not supported for minimal buttons)
|
||||||
|
"bg-transparent border-transparent shadow-none disabled:border-transparent disabled:text-gray-400":
|
||||||
|
variant === "minimal",
|
||||||
|
"text-gray-700 enabled:focus-visible:bg-gray-100 enabled:hover:bg-gray-100 enabled:hover:text-gray-800":
|
||||||
|
variant === "minimal" && (intent === "base" || intent === "black"),
|
||||||
|
"enabled:bg-gray-200 border-gray-300":
|
||||||
|
variant === "minimal" &&
|
||||||
|
(intent === "base" || intent === "black") &&
|
||||||
|
active,
|
||||||
|
// primary minimal
|
||||||
|
"text-blue-600 enabled:focus-visible:bg-blue-0 enabled:hover:bg-blue-0 enabled:hover:text-blue-800":
|
||||||
|
variant === "minimal" && intent === "primary",
|
||||||
|
// danger minimal
|
||||||
|
"text-red-600 enabled:focus-visible:bg-red-0 enabled:hover:bg-red-0 enabled:hover:text-red-800":
|
||||||
|
variant === "minimal" && intent === "danger",
|
||||||
|
// warning minimal
|
||||||
|
"text-yellow-600 enabled:focus-visible:bg-orange-0 enabled:hover:bg-orange-0 enabled:hover:text-orange-800":
|
||||||
|
variant === "minimal" && intent === "warning",
|
||||||
|
|
||||||
|
// sizeVariants
|
||||||
|
"px-3 py-[0.35rem]": sizeVariant === "medium",
|
||||||
|
"h-input": sizeVariant === "input",
|
||||||
|
"px-3 text-sm py-[0.35rem]": sizeVariant === "small",
|
||||||
|
"button-active relative z-10": active === true,
|
||||||
|
"px-3":
|
||||||
|
iconOnly && (sizeVariant === "large" || sizeVariant === "input"),
|
||||||
|
"px-2":
|
||||||
|
iconOnly && (sizeVariant === "medium" || sizeVariant === "small"),
|
||||||
|
"icon-parent gap-2": hasIcon,
|
||||||
},
|
},
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
ref={ref}
|
||||||
|
disabled={disabled || loading}
|
||||||
{...rest}
|
{...rest}
|
||||||
disabled={disabled}
|
|
||||||
>
|
>
|
||||||
{children}
|
{prefixIcon && <span className="flex-shrink-0">{prefixIcon}</span>}
|
||||||
|
{loading && (
|
||||||
|
<LoadingDots className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-current" />
|
||||||
|
)}
|
||||||
|
{children && (
|
||||||
|
<span
|
||||||
|
className={cx({
|
||||||
|
"text-transparent": loading === true,
|
||||||
|
"text-left flex-1": textAlign === "left",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{suffixIcon && <span className="flex-shrink-0">{suffixIcon}</span>}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
|
export default Button
|
||||||
|
@ -24,7 +24,7 @@ export default function Collapsible(props: CollapsibleProps) {
|
|||||||
onOpenChange?.(open)
|
onOpenChange?.(open)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Primitive.Trigger className="inline-flex items-center text-gray-600 cursor-pointer hover:bg-stone-100 rounded text-sm font-medium pr-3 py-1 transition-colors">
|
<Primitive.Trigger className="inline-flex items-center text-gray-600 cursor-pointer hover:bg-gray-100 rounded text-sm font-medium pr-3 py-1 transition-colors">
|
||||||
<span className="ml-2 mr-1.5 group-hover:text-gray-500 -rotate-90 state-open:rotate-0">
|
<span className="ml-2 mr-1.5 group-hover:text-gray-500 -rotate-90 state-open:rotate-0">
|
||||||
<ChevronDown strokeWidth={3} className="stroke-gray-400 w-4" />
|
<ChevronDown strokeWidth={3} className="stroke-gray-400 w-4" />
|
||||||
</span>
|
</span>
|
||||||
|
23
client/web/src/ui/loading-dots.tsx
Normal file
23
client/web/src/ui/loading-dots.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
import cx from "classnames"
|
||||||
|
import React, { HTMLAttributes } from "react"
|
||||||
|
|
||||||
|
type Props = HTMLAttributes<HTMLDivElement>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LoadingDots provides a set of horizontal dots to indicate a loading state.
|
||||||
|
* These dots are helpful in horizontal contexts (like buttons) where a spinner
|
||||||
|
* doesn't fit as well.
|
||||||
|
*/
|
||||||
|
export default function LoadingDots(props: Props) {
|
||||||
|
const { className, ...rest } = props
|
||||||
|
return (
|
||||||
|
<div className={cx(className, "loading-dots")} {...rest}>
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -8,3 +8,14 @@
|
|||||||
export function assertNever(a: never): never {
|
export function assertNever(a: never): never {
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pluralize is a very simple function that returns either
|
||||||
|
* the singular or plural form of a string based on the given
|
||||||
|
* quantity.
|
||||||
|
*
|
||||||
|
* TODO: Ideally this would use a localized pluralization.
|
||||||
|
*/
|
||||||
|
export function pluralize(signular: string, plural: string, qty: number) {
|
||||||
|
return qty === 1 ? signular : plural
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user