mirror of
https://github.com/tailscale/tailscale.git
synced 2025-04-16 03:31:39 +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,
|
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">
|
||||||
← Back to {node.DeviceName}
|
← Back to {node.DeviceName}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
@ -58,38 +58,38 @@ export default function ExitNodeSelector({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<div
|
||||||
open={disabled ? false : open}
|
className={cx(
|
||||||
onOpenChange={setOpen}
|
"rounded-md",
|
||||||
side="bottom"
|
{
|
||||||
sideOffset={5}
|
"bg-red-600": offline,
|
||||||
align="start"
|
},
|
||||||
alignOffset={8}
|
className
|
||||||
content={
|
)}
|
||||||
<ExitNodeSelectorInner
|
|
||||||
node={node}
|
|
||||||
selected={selected}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
asChild
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx("p-1.5 rounded-md border flex items-stretch gap-1.5", {
|
||||||
"rounded-md",
|
"border-gray-200": none,
|
||||||
{
|
"bg-yellow-300 border-yellow-300": advertising && !offline,
|
||||||
"bg-red-600": offline,
|
"bg-blue-500 border-blue-500": using && !offline,
|
||||||
},
|
"bg-red-500 border-red-500": offline,
|
||||||
className
|
})}
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div
|
<Popover
|
||||||
className={cx("p-1.5 rounded-md border flex items-stretch gap-1.5", {
|
open={disabled ? false : open}
|
||||||
"border-gray-200": none,
|
onOpenChange={setOpen}
|
||||||
"bg-yellow-300 border-yellow-300": advertising && !offline,
|
className="overflow-hidden"
|
||||||
"bg-blue-500 border-blue-500": using && !offline,
|
side="bottom"
|
||||||
"bg-red-500 border-red-500": offline,
|
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,32 +138,31 @@ export default function ExitNodeSelector({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{!disabled && (advertising || using) && (
|
</Popover>
|
||||||
<button
|
{!disabled && (advertising || using) && (
|
||||||
className={cx("px-3 py-2 rounded-sm text-white", {
|
<button
|
||||||
"bg-yellow-200": advertising && !offline,
|
className={cx("px-3 py-2 rounded-sm text-white", {
|
||||||
"bg-blue-400": using && !offline,
|
"hover:bg-yellow-200": advertising && !offline,
|
||||||
"bg-red-400": offline,
|
"hover:bg-blue-400": using && !offline,
|
||||||
})}
|
"hover:bg-red-400": offline,
|
||||||
onClick={(e) => {
|
})}
|
||||||
e.preventDefault()
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.preventDefault()
|
||||||
handleSelect(noExitNode)
|
e.stopPropagation()
|
||||||
}}
|
handleSelect(noExitNode)
|
||||||
>
|
}}
|
||||||
Disable
|
>
|
||||||
</button>
|
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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 (
|
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,23 +31,25 @@ export default function SSHView({
|
|||||||
Learn more →
|
Learn more →
|
||||||
</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">
|
||||||
<Toggle
|
<label className="flex gap-3 items-center">
|
||||||
checked={node.RunningSSHServer}
|
<Toggle
|
||||||
onChange={() =>
|
checked={node.RunningSSHServer}
|
||||||
nodeUpdaters.patchPrefs({
|
onChange={() =>
|
||||||
RunSSHSet: true,
|
nodeUpdaters.patchPrefs({
|
||||||
RunSSH: !node.RunningSSHServer,
|
RunSSHSet: true,
|
||||||
})
|
RunSSH: !node.RunningSSHServer,
|
||||||
}
|
})
|
||||||
disabled={readonly}
|
}
|
||||||
/>
|
disabled={readonly}
|
||||||
<div className="text-black text-sm font-medium leading-tight">
|
/>
|
||||||
Run Tailscale SSH server
|
<div className="text-black text-sm font-medium leading-tight">
|
||||||
</div>
|
Run Tailscale SSH server
|
||||||
</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,
|
||||||
}
|
}
|
||||||
|
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
|
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}
|
||||||
/>
|
/>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user