mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-25 19:15:34 +00:00
client/web: style tweaks
Style changes made in live pairing session. Updates #10261 Co-authored-by: Will Norris <will@tailscale.com> Co-authored-by: Alessandro Mingione <alessandro@tailscale.com> Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
parent
1a4d423328
commit
014ae98297
@ -21,12 +21,14 @@ export default function AddressCard({
|
||||
v6Address,
|
||||
shortDomain,
|
||||
fullDomain,
|
||||
className,
|
||||
triggerClassName,
|
||||
}: {
|
||||
v4Address: string
|
||||
v6Address: string
|
||||
shortDomain?: string
|
||||
fullDomain?: string
|
||||
className?: string
|
||||
triggerClassName?: string
|
||||
}) {
|
||||
const children = (
|
||||
@ -57,7 +59,7 @@ export default function AddressCard({
|
||||
<Primitive.Trigger asChild>
|
||||
<Button
|
||||
variant="minimal"
|
||||
className="-ml-1 px-1 py-0 hover:!bg-transparent font-normal"
|
||||
className={cx("-ml-1 px-1 py-0 font-normal", className)}
|
||||
suffixIcon={
|
||||
<ChevronDown className="w-5 h-5" stroke="#232222" /* gray-800 */ />
|
||||
}
|
||||
|
@ -22,7 +22,7 @@ export default function App() {
|
||||
const { data: auth, loading: loadingAuth, newSession } = useAuth()
|
||||
|
||||
return (
|
||||
<main className="min-w-sm max-w-lg mx-auto py-4 md:py-14 px-5">
|
||||
<main className="min-w-sm max-w-lg mx-auto py-4 sm:py-14 px-5">
|
||||
{loadingAuth || !auth ? (
|
||||
<div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
|
||||
) : (
|
||||
@ -136,24 +136,21 @@ function Header({
|
||||
auth: AuthResponse
|
||||
newSession: () => Promise<void>
|
||||
}) {
|
||||
const [loc, setLocation] = useLocation()
|
||||
const [loc] = useLocation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-9 md:mb-12">
|
||||
<div className="flex gap-3">
|
||||
<TailscaleIcon
|
||||
className="cursor-pointer"
|
||||
onClick={() => setLocation("/")}
|
||||
/>
|
||||
<div className="inline text-gray-800 text-lg font-medium leading-snug">
|
||||
<div className="flex flex-wrap gap-4 justify-between items-center mb-9 md:mb-12">
|
||||
<Link to="/" className="flex gap-3 overflow-hidden">
|
||||
<TailscaleIcon />
|
||||
<div className="inline text-gray-800 text-lg font-medium leading-snug truncate">
|
||||
{node.DomainName}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<LoginToggle node={node} auth={auth} newSession={newSession} />
|
||||
</div>
|
||||
{loc !== "/" && loc !== "/update" && (
|
||||
<Link to="/" className="link font-medium block mb-[10px]">
|
||||
<Link to="/" className="link font-medium block mb-2">
|
||||
← Back to {node.DeviceName}
|
||||
</Link>
|
||||
)}
|
||||
|
@ -58,38 +58,38 @@ export default function ExitNodeSelector({
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={disabled ? false : open}
|
||||
onOpenChange={setOpen}
|
||||
side="bottom"
|
||||
sideOffset={5}
|
||||
align="start"
|
||||
alignOffset={8}
|
||||
content={
|
||||
<ExitNodeSelectorInner
|
||||
node={node}
|
||||
selected={selected}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
}
|
||||
asChild
|
||||
<div
|
||||
className={cx(
|
||||
"rounded-md",
|
||||
{
|
||||
"bg-red-600": offline,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
"rounded-md",
|
||||
{
|
||||
"bg-red-600": offline,
|
||||
},
|
||||
className
|
||||
)}
|
||||
className={cx("p-1.5 rounded-md border flex items-stretch gap-1.5", {
|
||||
"border-gray-200": none,
|
||||
"bg-yellow-300 border-yellow-300": advertising && !offline,
|
||||
"bg-blue-500 border-blue-500": using && !offline,
|
||||
"bg-red-500 border-red-500": offline,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cx("p-1.5 rounded-md border flex items-stretch gap-1.5", {
|
||||
"border-gray-200": none,
|
||||
"bg-yellow-300 border-yellow-300": advertising && !offline,
|
||||
"bg-blue-500 border-blue-500": using && !offline,
|
||||
"bg-red-500 border-red-500": offline,
|
||||
})}
|
||||
<Popover
|
||||
open={disabled ? false : open}
|
||||
onOpenChange={setOpen}
|
||||
className="overflow-hidden"
|
||||
side="bottom"
|
||||
sideOffset={0}
|
||||
align="start"
|
||||
content={
|
||||
<ExitNodeSelectorInner
|
||||
node={node}
|
||||
selected={selected}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
}
|
||||
asChild
|
||||
>
|
||||
<button
|
||||
className={cx("flex-1 px-2 py-1.5 rounded-[1px]", {
|
||||
@ -108,7 +108,7 @@ export default function ExitNodeSelector({
|
||||
<p
|
||||
className={cx(
|
||||
"text-gray-500 text-xs text-left font-medium uppercase tracking-wide mb-1",
|
||||
{ "bg-opacity-70 text-white": advertising || using }
|
||||
{ "opacity-70 text-white": advertising || using }
|
||||
)}
|
||||
>
|
||||
Exit node{offline && " offline"}
|
||||
@ -138,32 +138,31 @@ export default function ExitNodeSelector({
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{!disabled && (advertising || using) && (
|
||||
<button
|
||||
className={cx("px-3 py-2 rounded-sm text-white", {
|
||||
"bg-yellow-200": advertising && !offline,
|
||||
"bg-blue-400": using && !offline,
|
||||
"bg-red-400": offline,
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleSelect(noExitNode)
|
||||
}}
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{offline && (
|
||||
<p className="text-white p-3">
|
||||
The selected exit node is currently offline. Your internet traffic
|
||||
is blocked until you disable the exit node or select a different
|
||||
one.
|
||||
</p>
|
||||
</Popover>
|
||||
{!disabled && (advertising || using) && (
|
||||
<button
|
||||
className={cx("px-3 py-2 rounded-sm text-white", {
|
||||
"hover:bg-yellow-200": advertising && !offline,
|
||||
"hover:bg-blue-400": using && !offline,
|
||||
"hover:bg-red-400": offline,
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleSelect(noExitNode)
|
||||
}}
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
{offline && (
|
||||
<p className="text-white p-3">
|
||||
The selected exit node is currently offline. Your internet traffic is
|
||||
blocked until you disable the exit node or select a different one.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -205,10 +204,11 @@ function ExitNodeSelectorInner({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="w-[calc(var(--radix-popover-trigger-width)-16px)] pb-1 rounded-lg shadow">
|
||||
<div className="w-[var(--radix-popover-trigger-width)]">
|
||||
<SearchInput
|
||||
name="exit-node-search"
|
||||
inputClassName="w-full px-4 py-2 border-none rounded-b-none"
|
||||
className="px-2"
|
||||
inputClassName="w-full py-3 !h-auto border-none rounded-b-none !ring-0"
|
||||
autoFocus
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
@ -224,7 +224,7 @@ function ExitNodeSelectorInner({
|
||||
{/* TODO(sonia): use loading spinner when loading useExitNodes */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="pt-1 border-t border-gray-200 max-h-64 overflow-y-scroll"
|
||||
className="pt-1 border-t border-gray-200 max-h-60 overflow-y-scroll"
|
||||
>
|
||||
{hasNodes ? (
|
||||
exitNodes.map(
|
||||
|
@ -57,7 +57,7 @@ export default function LoginToggle({
|
||||
) : (
|
||||
<div
|
||||
className={cx(
|
||||
"w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex",
|
||||
"w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex hover:bg-gray-300",
|
||||
{
|
||||
"bg-transparent": !open,
|
||||
"bg-gray-300": open,
|
||||
|
@ -33,7 +33,7 @@ export default function HomeView({
|
||||
<h2 className="mb-3">This device</h2>
|
||||
<div className="-mx-5 card mb-9">
|
||||
<div className="flex justify-between items-center text-lg mb-5">
|
||||
<div className="flex items-center">
|
||||
<Link className="flex items-center" to="/details">
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-full justify-center items-center inline-flex">
|
||||
<Machine />
|
||||
</div>
|
||||
@ -49,9 +49,10 @@ export default function HomeView({
|
||||
{node.Status === "Running" ? "Connected" : "Offline"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<AddressCard
|
||||
triggerClassName="text-gray-800 text-lg leading-[25.20px]"
|
||||
className="-mr-2"
|
||||
triggerClassName="relative text-gray-800 text-lg leading-[25.20px]"
|
||||
v4Address={node.IPv4}
|
||||
v6Address={node.IPv6}
|
||||
shortDomain={node.DeviceName}
|
||||
@ -150,7 +151,7 @@ function SettingsCard({
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cx("-mx-5 card cursor-pointer", className)}
|
||||
className={cx("-mx-5 card cursor-pointer", { "pb-4": footer }, className)}
|
||||
onClick={() => setLocation(link)}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
|
@ -4,6 +4,7 @@
|
||||
import React from "react"
|
||||
import * as Control from "src/components/control-components"
|
||||
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
|
||||
import Card from "src/ui/card"
|
||||
import Toggle from "src/ui/toggle"
|
||||
|
||||
export default function SSHView({
|
||||
@ -30,23 +31,25 @@ export default function SSHView({
|
||||
Learn more →
|
||||
</a>
|
||||
</p>
|
||||
<div className="-mx-5 px-4 py-3 bg-white rounded-lg border border-gray-200 flex gap-2.5 mb-3">
|
||||
<Toggle
|
||||
checked={node.RunningSSHServer}
|
||||
onChange={() =>
|
||||
nodeUpdaters.patchPrefs({
|
||||
RunSSHSet: true,
|
||||
RunSSH: !node.RunningSSHServer,
|
||||
})
|
||||
}
|
||||
disabled={readonly}
|
||||
/>
|
||||
<div className="text-black text-sm font-medium leading-tight">
|
||||
Run Tailscale SSH server
|
||||
</div>
|
||||
</div>
|
||||
<Card className="-mx-5 p-4">
|
||||
<label className="flex gap-3 items-center">
|
||||
<Toggle
|
||||
checked={node.RunningSSHServer}
|
||||
onChange={() =>
|
||||
nodeUpdaters.patchPrefs({
|
||||
RunSSHSet: true,
|
||||
RunSSH: !node.RunningSSHServer,
|
||||
})
|
||||
}
|
||||
disabled={readonly}
|
||||
/>
|
||||
<div className="text-black text-sm font-medium leading-tight">
|
||||
Run Tailscale SSH server
|
||||
</div>
|
||||
</label>
|
||||
</Card>
|
||||
<Control.AdminContainer
|
||||
className="text-gray-500 text-sm leading-tight"
|
||||
className="text-gray-500 text-sm leading-tight mt-3"
|
||||
node={node}
|
||||
>
|
||||
Remember to make sure that the{" "}
|
||||
|
@ -8,6 +8,8 @@ import { ReactComponent as Plus } from "src/assets/icons/plus.svg"
|
||||
import * as Control from "src/components/control-components"
|
||||
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
|
||||
import Button from "src/ui/button"
|
||||
import Card from "src/ui/card"
|
||||
import EmptyState from "src/ui/empty-state"
|
||||
import Input from "src/ui/input"
|
||||
|
||||
export default function SubnetRouterView({
|
||||
@ -50,7 +52,7 @@ export default function SubnetRouterView({
|
||||
</p>
|
||||
{!readonly &&
|
||||
(inputOpen ? (
|
||||
<div className="-mx-5 card shadow">
|
||||
<div className="-mx-5 card !border-0 shadow-popover">
|
||||
<p className="font-medium leading-snug mb-3">
|
||||
Advertise new routes
|
||||
</p>
|
||||
@ -150,9 +152,9 @@ export default function SubnetRouterView({
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="px-5 py-4 bg-gray-50 rounded-lg border border-gray-200 text-center text-gray-500">
|
||||
Not advertising any routes
|
||||
</div>
|
||||
<Card empty>
|
||||
<EmptyState description="Not advertising any routes" />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
@ -226,6 +226,6 @@ export function trimDNSSuffix(s: string, tailnetDNSName: string): string {
|
||||
export const noExitNode: ExitNode = { ID: "NONE", Name: "None", Online: true }
|
||||
export const runAsExitNode: ExitNode = {
|
||||
ID: "RUNNING",
|
||||
Name: "Run as exit node…",
|
||||
Name: "Run as exit node",
|
||||
Online: true,
|
||||
}
|
||||
|
40
client/web/src/ui/card.tsx
Normal file
40
client/web/src/ui/card.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React from "react"
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
elevated?: boolean
|
||||
empty?: boolean
|
||||
noPadding?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Card is a box with a border, rounded corners, and some padding. Use it to
|
||||
* group content into a single container and give it more importance. The
|
||||
* elevation prop gives it a box shadow, while the empty prop a light gray
|
||||
* background color.
|
||||
*
|
||||
* <Card>{content}</Card>
|
||||
* <Card elevated>{content}</Card>
|
||||
* <Card empty><EmptyState description="You don't have any keys" /></Card>
|
||||
*
|
||||
*/
|
||||
export default function Card(props: Props) {
|
||||
const { children, className, elevated, empty, noPadding } = props
|
||||
return (
|
||||
<div
|
||||
className={cx("rounded-md border", className, {
|
||||
"shadow-soft": elevated,
|
||||
"bg-gray-0": empty,
|
||||
"bg-white": !empty,
|
||||
"p-6": !noPadding,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
44
client/web/src/ui/empty-state.tsx
Normal file
44
client/web/src/ui/empty-state.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React, { cloneElement } from "react"
|
||||
|
||||
type Props = {
|
||||
action?: React.ReactNode
|
||||
className?: string
|
||||
description: string
|
||||
icon?: React.ReactElement
|
||||
title?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* EmptyState shows some text and an optional action when some area that can
|
||||
* house content is empty (eg. no search results, empty tables).
|
||||
*/
|
||||
export default function EmptyState(props: Props) {
|
||||
const { action, className, description, icon, title } = props
|
||||
const iconColor = "text-gray-500"
|
||||
const iconComponent = getIcon(icon, iconColor)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx("flex justify-center", className, {
|
||||
"flex-col items-center": action || icon || title,
|
||||
})}
|
||||
>
|
||||
{icon && <div className="mb-2">{iconComponent}</div>}
|
||||
{title && (
|
||||
<h3 className="text-xl font-medium text-center mb-2">{title}</h3>
|
||||
)}
|
||||
<div className="w-full text-center max-w-xl text-gray-500">
|
||||
{description}
|
||||
</div>
|
||||
{action && <div className="mt-3.5">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getIcon(icon: React.ReactElement | undefined, iconColor: string) {
|
||||
return icon ? cloneElement(icon, { className: iconColor }) : null
|
||||
}
|
@ -17,10 +17,10 @@ const SearchInput = forwardRef<HTMLInputElement, Props>((props, ref) => {
|
||||
const { className, inputClassName, ...rest } = props
|
||||
return (
|
||||
<div className={cx("relative", className)}>
|
||||
<Search className="absolute w-[1.25em] h-full ml-2" />
|
||||
<Search className="absolute text-gray-400 w-[1.25em] h-full ml-2" />
|
||||
<input
|
||||
type="text"
|
||||
className={cx("input px-8", inputClassName)}
|
||||
className={cx("input pl-9 pr-8", inputClassName)}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
|
Loading…
Reference in New Issue
Block a user