client/web: style tweaks

Style changes made in live pairing session.

Updates 

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:
Sonia Appasamy 2023-12-06 16:11:00 -05:00 committed by Sonia Appasamy
parent 1a4d423328
commit 014ae98297
11 changed files with 186 additions and 97 deletions

@ -21,12 +21,14 @@ export default function AddressCard({
v6Address, v6Address,
shortDomain, shortDomain,
fullDomain, fullDomain,
className,
triggerClassName, triggerClassName,
}: { }: {
v4Address: string v4Address: string
v6Address: string v6Address: string
shortDomain?: string shortDomain?: string
fullDomain?: string fullDomain?: string
className?: string
triggerClassName?: string triggerClassName?: string
}) { }) {
const children = ( const children = (
@ -57,7 +59,7 @@ export default function AddressCard({
<Primitive.Trigger asChild> <Primitive.Trigger asChild>
<Button <Button
variant="minimal" 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={ suffixIcon={
<ChevronDown className="w-5 h-5" stroke="#232222" /* gray-800 */ /> <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() const { data: auth, loading: loadingAuth, newSession } = useAuth()
return ( 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 ? ( {loadingAuth || !auth ? (
<div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view <div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
) : ( ) : (
@ -136,24 +136,21 @@ function Header({
auth: AuthResponse auth: AuthResponse
newSession: () => Promise<void> newSession: () => Promise<void>
}) { }) {
const [loc, setLocation] = useLocation() const [loc] = useLocation()
return ( return (
<> <>
<div className="flex justify-between items-center mb-9 md:mb-12"> <div className="flex flex-wrap gap-4 justify-between items-center mb-9 md:mb-12">
<div className="flex gap-3"> <Link to="/" className="flex gap-3 overflow-hidden">
<TailscaleIcon <TailscaleIcon />
className="cursor-pointer" <div className="inline text-gray-800 text-lg font-medium leading-snug truncate">
onClick={() => setLocation("/")}
/>
<div className="inline text-gray-800 text-lg font-medium leading-snug">
{node.DomainName} {node.DomainName}
</div> </div>
</div> </Link>
<LoginToggle node={node} auth={auth} newSession={newSession} /> <LoginToggle node={node} auth={auth} newSession={newSession} />
</div> </div>
{loc !== "/" && loc !== "/update" && ( {loc !== "/" && loc !== "/update" && (
<Link to="/" className="link font-medium block mb-[10px]"> <Link to="/" className="link font-medium block mb-2">
&larr; Back to {node.DeviceName} &larr; Back to {node.DeviceName}
</Link> </Link>
)} )}

@ -58,22 +58,6 @@ export default function ExitNodeSelector({
) )
return ( 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 <div
className={cx( className={cx(
"rounded-md", "rounded-md",
@ -90,6 +74,22 @@ export default function ExitNodeSelector({
"bg-blue-500 border-blue-500": using && !offline, "bg-blue-500 border-blue-500": using && !offline,
"bg-red-500 border-red-500": 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 <button
className={cx("flex-1 px-2 py-1.5 rounded-[1px]", { className={cx("flex-1 px-2 py-1.5 rounded-[1px]", {
@ -108,7 +108,7 @@ export default function ExitNodeSelector({
<p <p
className={cx( className={cx(
"text-gray-500 text-xs text-left font-medium uppercase tracking-wide mb-1", "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"} Exit node{offline && " offline"}
@ -138,12 +138,13 @@ export default function ExitNodeSelector({
)} )}
</div> </div>
</button> </button>
</Popover>
{!disabled && (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-yellow-200": advertising && !offline, "hover:bg-yellow-200": advertising && !offline,
"bg-blue-400": using && !offline, "hover:bg-blue-400": using && !offline,
"bg-red-400": offline, "hover:bg-red-400": offline,
})} })}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
@ -157,13 +158,11 @@ export default function ExitNodeSelector({
</div> </div>
{offline && ( {offline && (
<p className="text-white p-3"> <p className="text-white p-3">
The selected exit node is currently offline. Your internet traffic The selected exit node is currently offline. Your internet traffic is
is blocked until you disable the exit node or select a different blocked until you disable the exit node or select a different one.
one.
</p> </p>
)} )}
</div> </div>
</Popover>
) )
} }
@ -205,10 +204,11 @@ function ExitNodeSelectorInner({
) )
return ( 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 <SearchInput
name="exit-node-search" 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 autoFocus
autoCorrect="off" autoCorrect="off"
autoComplete="off" autoComplete="off"
@ -224,7 +224,7 @@ function ExitNodeSelectorInner({
{/* TODO(sonia): use loading spinner when loading useExitNodes */} {/* TODO(sonia): use loading spinner when loading useExitNodes */}
<div <div
ref={listRef} 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 ? ( {hasNodes ? (
exitNodes.map( exitNodes.map(

@ -57,7 +57,7 @@ export default function LoginToggle({
) : ( ) : (
<div <div
className={cx( 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-transparent": !open,
"bg-gray-300": open, "bg-gray-300": open,

@ -33,7 +33,7 @@ export default function HomeView({
<h2 className="mb-3">This device</h2> <h2 className="mb-3">This device</h2>
<div className="-mx-5 card mb-9"> <div className="-mx-5 card mb-9">
<div className="flex justify-between items-center text-lg mb-5"> <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"> <div className="w-10 h-10 bg-gray-100 rounded-full justify-center items-center inline-flex">
<Machine /> <Machine />
</div> </div>
@ -49,9 +49,10 @@ export default function HomeView({
{node.Status === "Running" ? "Connected" : "Offline"} {node.Status === "Running" ? "Connected" : "Offline"}
</p> </p>
</div> </div>
</div> </Link>
<AddressCard <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} v4Address={node.IPv4}
v6Address={node.IPv6} v6Address={node.IPv6}
shortDomain={node.DeviceName} shortDomain={node.DeviceName}
@ -150,7 +151,7 @@ function SettingsCard({
return ( return (
<button <button
className={cx("-mx-5 card cursor-pointer", className)} className={cx("-mx-5 card cursor-pointer", { "pb-4": footer }, className)}
onClick={() => setLocation(link)} onClick={() => setLocation(link)}
> >
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">

@ -4,6 +4,7 @@
import React from "react" import React from "react"
import * as Control from "src/components/control-components" import * as Control from "src/components/control-components"
import { NodeData, NodeUpdaters } from "src/hooks/node-data" import { NodeData, NodeUpdaters } from "src/hooks/node-data"
import Card from "src/ui/card"
import Toggle from "src/ui/toggle" import Toggle from "src/ui/toggle"
export default function SSHView({ export default function SSHView({
@ -30,7 +31,8 @@ export default function SSHView({
Learn more &rarr; Learn more &rarr;
</a> </a>
</p> </p>
<div className="-mx-5 px-4 py-3 bg-white rounded-lg border border-gray-200 flex gap-2.5 mb-3"> <Card className="-mx-5 p-4">
<label className="flex gap-3 items-center">
<Toggle <Toggle
checked={node.RunningSSHServer} checked={node.RunningSSHServer}
onChange={() => onChange={() =>
@ -44,9 +46,10 @@ export default function SSHView({
<div className="text-black text-sm font-medium leading-tight"> <div className="text-black text-sm font-medium leading-tight">
Run Tailscale SSH server Run Tailscale SSH server
</div> </div>
</div> </label>
</Card>
<Control.AdminContainer <Control.AdminContainer
className="text-gray-500 text-sm leading-tight" className="text-gray-500 text-sm leading-tight mt-3"
node={node} node={node}
> >
Remember to make sure that the{" "} 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 * as Control from "src/components/control-components"
import { NodeData, NodeUpdaters } from "src/hooks/node-data" import { NodeData, NodeUpdaters } from "src/hooks/node-data"
import Button from "src/ui/button" 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" import Input from "src/ui/input"
export default function SubnetRouterView({ export default function SubnetRouterView({
@ -50,7 +52,7 @@ export default function SubnetRouterView({
</p> </p>
{!readonly && {!readonly &&
(inputOpen ? ( (inputOpen ? (
<div className="-mx-5 card shadow"> <div className="-mx-5 card !border-0 shadow-popover">
<p className="font-medium leading-snug mb-3"> <p className="font-medium leading-snug mb-3">
Advertise new routes Advertise new routes
</p> </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"> <Card empty>
Not advertising any routes <EmptyState description="Not advertising any routes" />
</div> </Card>
)} )}
</div> </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 noExitNode: ExitNode = { ID: "NONE", Name: "None", Online: true }
export const runAsExitNode: ExitNode = { export const runAsExitNode: ExitNode = {
ID: "RUNNING", ID: "RUNNING",
Name: "Run as exit node", Name: "Run as exit node",
Online: true, Online: true,
} }

@ -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>
)
}

@ -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 const { className, inputClassName, ...rest } = props
return ( return (
<div className={cx("relative", className)}> <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 <input
type="text" type="text"
className={cx("input px-8", inputClassName)} className={cx("input pl-9 pr-8", inputClassName)}
ref={ref} ref={ref}
{...rest} {...rest}
/> />