mirror of
https://github.com/tailscale/tailscale.git
synced 2025-12-24 17:47:30 +00:00
client/web: add readonly/manage toggle
Updates tailscale/corp#14335 Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
committed by
Sonia Appasamy
parent
c54d680682
commit
86c8ab7502
@@ -1,22 +1,21 @@
|
||||
import cx from "classnames"
|
||||
import React, { useEffect } from "react"
|
||||
import LoginToggle from "src/components/login-toggle"
|
||||
import DeviceDetailsView from "src/components/views/device-details-view"
|
||||
import HomeView from "src/components/views/home-view"
|
||||
import LegacyClientView from "src/components/views/legacy-client-view"
|
||||
import LoginClientView from "src/components/views/login-client-view"
|
||||
import ManagementClientView from "src/components/views/management-client-view"
|
||||
import ReadonlyClientView from "src/components/views/readonly-client-view"
|
||||
import useAuth, { AuthResponse } from "src/hooks/auth"
|
||||
import useNodeData, { NodeData, NodeUpdate } from "src/hooks/node-data"
|
||||
import useNodeData, { NodeData } from "src/hooks/node-data"
|
||||
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
|
||||
import ProfilePic from "src/ui/profile-pic"
|
||||
import { Link, Route, Router, Switch, useLocation } from "wouter"
|
||||
import DeviceDetailsView from "./views/device-details-view"
|
||||
|
||||
export default function App() {
|
||||
const { data: auth, loading: loadingAuth, newSession } = useAuth()
|
||||
|
||||
return (
|
||||
<main className="min-w-sm max-w-lg mx-auto py-14 px-5">
|
||||
{loadingAuth ? (
|
||||
{loadingAuth || !auth ? (
|
||||
<div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
|
||||
) : (
|
||||
<WebClient auth={auth} newSession={newSession} />
|
||||
@@ -29,7 +28,7 @@ function WebClient({
|
||||
auth,
|
||||
newSession,
|
||||
}: {
|
||||
auth?: AuthResponse
|
||||
auth: AuthResponse
|
||||
newSession: () => Promise<void>
|
||||
}) {
|
||||
const { data, refreshData, updateNode } = useNodeData()
|
||||
@@ -37,36 +36,44 @@ function WebClient({
|
||||
refreshData()
|
||||
}, [auth, refreshData])
|
||||
|
||||
if (!data) {
|
||||
return <div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
|
||||
}
|
||||
|
||||
return (
|
||||
return !data ? (
|
||||
<div className="text-center py-14">Loading...</div>
|
||||
) : data.Status === "NeedsLogin" || data.Status === "NoState" ? (
|
||||
// Client not on a tailnet, render login.
|
||||
<LoginClientView
|
||||
data={data}
|
||||
onLoginClick={() => updateNode({ Reauthenticate: true })}
|
||||
/>
|
||||
) : data.DebugMode !== "full" && data.DebugMode !== "login" ? (
|
||||
// Render legacy client interface.
|
||||
<>
|
||||
{/* TODO(sonia): get rid of the conditions here once full/readonly
|
||||
* views live on same components */}
|
||||
{data.DebugMode === "full" && auth?.ok && <Header node={data} />}
|
||||
<LegacyClientView
|
||||
data={data}
|
||||
refreshData={refreshData}
|
||||
updateNode={updateNode}
|
||||
/>
|
||||
{/* TODO: add license to new client */}
|
||||
<Footer licensesURL={data.LicensesURL} />
|
||||
</>
|
||||
) : (
|
||||
// Otherwise render the new web client.
|
||||
<>
|
||||
<Header node={data} auth={auth} newSession={newSession} />
|
||||
<Router base={data.URLPrefix}>
|
||||
<Switch>
|
||||
<Route path="/">
|
||||
<HomeView
|
||||
auth={auth}
|
||||
data={data}
|
||||
newSession={newSession}
|
||||
refreshData={refreshData}
|
||||
readonly={!auth.canManageNode}
|
||||
node={data}
|
||||
updateNode={updateNode}
|
||||
/>
|
||||
</Route>
|
||||
{data.DebugMode !== "" && (
|
||||
<>
|
||||
<Route path="/details">
|
||||
<DeviceDetailsView node={data} />
|
||||
</Route>
|
||||
<Route path="/subnets">{/* TODO */}Subnet router</Route>
|
||||
<Route path="/ssh">{/* TODO */}Tailscale SSH server</Route>
|
||||
<Route path="/serve">{/* TODO */}Share local content</Route>
|
||||
</>
|
||||
)}
|
||||
<Route path="/details">
|
||||
<DeviceDetailsView readonly={!auth.canManageNode} node={data} />
|
||||
</Route>
|
||||
<Route path="/subnets">{/* TODO */}Subnet router</Route>
|
||||
<Route path="/ssh">{/* TODO */}Tailscale SSH server</Route>
|
||||
<Route path="/serve">{/* TODO */}Share local content</Route>
|
||||
<Route>
|
||||
<h2 className="mt-8">Page not found</h2>
|
||||
</Route>
|
||||
@@ -76,57 +83,27 @@ function WebClient({
|
||||
)
|
||||
}
|
||||
|
||||
function HomeView({
|
||||
function Header({
|
||||
node,
|
||||
auth,
|
||||
data,
|
||||
newSession,
|
||||
refreshData,
|
||||
updateNode,
|
||||
}: {
|
||||
auth?: AuthResponse
|
||||
data: NodeData
|
||||
node: NodeData
|
||||
auth: AuthResponse
|
||||
newSession: () => Promise<void>
|
||||
refreshData: () => Promise<void>
|
||||
updateNode: (update: NodeUpdate) => Promise<void> | undefined
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{data?.Status === "NeedsLogin" || data?.Status === "NoState" ? (
|
||||
// Client not on a tailnet, render login.
|
||||
<LoginClientView
|
||||
data={data}
|
||||
onLoginClick={() => updateNode({ Reauthenticate: true })}
|
||||
/>
|
||||
) : data.DebugMode === "full" && auth?.ok ? (
|
||||
// Render new client interface in management mode.
|
||||
<ManagementClientView node={data} updateNode={updateNode} />
|
||||
) : data.DebugMode === "login" || data.DebugMode === "full" ? (
|
||||
// Render new client interface in readonly mode.
|
||||
<ReadonlyClientView data={data} auth={auth} newSession={newSession} />
|
||||
) : (
|
||||
// Render legacy client interface.
|
||||
<LegacyClientView
|
||||
data={data}
|
||||
refreshData={refreshData}
|
||||
updateNode={updateNode}
|
||||
/>
|
||||
)}
|
||||
{<Footer licensesURL={data.LicensesURL} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Header({ node }: { node: NodeData }) {
|
||||
const [loc] = useLocation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between mb-12">
|
||||
<TailscaleIcon />
|
||||
<div className="flex">
|
||||
<p className="mr-2">{node.Profile.LoginName}</p>
|
||||
<ProfilePic url={node.Profile.ProfilePicURL} />
|
||||
<div className="flex gap-3">
|
||||
<TailscaleIcon />
|
||||
<div className="inline text-neutral-800 text-lg font-medium leading-snug">
|
||||
{node.DomainName}
|
||||
</div>
|
||||
</div>
|
||||
<LoginToggle node={node} auth={auth} newSession={newSession} />
|
||||
</div>
|
||||
{loc !== "/" && (
|
||||
<Link
|
||||
@@ -140,7 +117,7 @@ function Header({ node }: { node: NodeData }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function Footer({
|
||||
function Footer({
|
||||
licensesURL,
|
||||
className,
|
||||
}: {
|
||||
|
||||
@@ -12,10 +12,12 @@ export default function ExitNodeSelector({
|
||||
className,
|
||||
node,
|
||||
updateNode,
|
||||
disabled,
|
||||
}: {
|
||||
className?: string
|
||||
node: NodeData
|
||||
updateNode: (update: NodeUpdate) => Promise<void> | undefined
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean>(false)
|
||||
const [selected, setSelected] = useState(
|
||||
@@ -78,12 +80,14 @@ export default function ExitNodeSelector({
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className={cx("flex-1 px-2 py-1.5 rounded-[1px] cursor-pointer", {
|
||||
className={cx("flex-1 px-2 py-1.5 rounded-[1px]", {
|
||||
"bg-white hover:bg-stone-100": none,
|
||||
"bg-amber-600 hover:bg-orange-400": advertising,
|
||||
"bg-indigo-500 hover:bg-indigo-400": using,
|
||||
"cursor-not-allowed": disabled,
|
||||
})}
|
||||
onClick={() => setOpen(!open)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<p
|
||||
className={cx(
|
||||
|
||||
149
client/web/src/components/login-toggle.tsx
Normal file
149
client/web/src/components/login-toggle.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import cx from "classnames"
|
||||
import React, { useCallback, useState } from "react"
|
||||
import { AuthResponse, AuthType } from "src/hooks/auth"
|
||||
import { NodeData } from "src/hooks/node-data"
|
||||
import { ReactComponent as ChevronDown } from "src/icons/chevron-down.svg"
|
||||
import { ReactComponent as Eye } from "src/icons/eye.svg"
|
||||
import { ReactComponent as User } from "src/icons/user.svg"
|
||||
import Popover from "src/ui/popover"
|
||||
import ProfilePic from "src/ui/profile-pic"
|
||||
|
||||
export default function LoginToggle({
|
||||
node,
|
||||
auth,
|
||||
newSession,
|
||||
}: {
|
||||
node: NodeData
|
||||
auth: AuthResponse
|
||||
newSession: () => Promise<void>
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean>(false)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
className="p-3 bg-white rounded-lg shadow flex flex-col gap-2 max-w-[317px]"
|
||||
content={
|
||||
<LoginPopoverContent node={node} auth={auth} newSession={newSession} />
|
||||
}
|
||||
side="bottom"
|
||||
align="end"
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
asChild
|
||||
>
|
||||
{!auth.canManageNode ? (
|
||||
<button
|
||||
className={cx(
|
||||
"pl-3 py-1 bg-zinc-800 rounded-full flex justify-start items-center",
|
||||
{ "pr-1": auth.viewerIdentity, "pr-3": !auth.viewerIdentity }
|
||||
)}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<Eye />
|
||||
<div className="text-white leading-snug ml-2 mr-1">Viewing</div>
|
||||
<ChevronDown className="stroke-white w-[15px] h-[15px]" />
|
||||
{auth.viewerIdentity && (
|
||||
<ProfilePic
|
||||
className="ml-2"
|
||||
size="medium"
|
||||
url={auth.viewerIdentity.profilePicUrl}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
className={cx(
|
||||
"w-[34px] h-[34px] p-1 rounded-full items-center inline-flex",
|
||||
{
|
||||
"bg-transparent": !open,
|
||||
"bg-neutral-300": open,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<button onClick={() => setOpen(!open)}>
|
||||
<ProfilePic
|
||||
size="medium"
|
||||
url={auth.viewerIdentity?.profilePicUrl}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginPopoverContent({
|
||||
node,
|
||||
auth,
|
||||
newSession,
|
||||
}: {
|
||||
node: NodeData
|
||||
auth: AuthResponse
|
||||
newSession: () => Promise<void>
|
||||
}) {
|
||||
const handleSignInClick = useCallback(() => {
|
||||
if (auth.viewerIdentity) {
|
||||
newSession()
|
||||
} else {
|
||||
// Must be connected over Tailscale to log in.
|
||||
// If not already connected, reroute to the Tailscale IP
|
||||
// before sending user through check mode.
|
||||
window.location.href = `http://${node.IP}:5252/?check=now`
|
||||
}
|
||||
}, [node.IP, auth.viewerIdentity, newSession])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-black text-sm font-medium leading-tight">
|
||||
{!auth.canManageNode ? "Viewing" : "Managing"}
|
||||
{auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
|
||||
</div>
|
||||
{!auth.canManageNode &&
|
||||
(!auth.viewerIdentity || auth.authNeeded == AuthType.tailscale ? (
|
||||
<>
|
||||
<p className="text-neutral-500 text-xs">
|
||||
{auth.viewerIdentity ? (
|
||||
<>
|
||||
To make changes, sign in to confirm your identity. This extra
|
||||
step helps us keep your device secure.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
You can see most of this device's details. To make changes,
|
||||
you need to sign in.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
className={cx(
|
||||
"w-full px-3 py-2 bg-indigo-500 rounded shadow text-center text-white text-sm font-medium mt-2",
|
||||
{ "mb-2": auth.viewerIdentity }
|
||||
)}
|
||||
onClick={handleSignInClick}
|
||||
>
|
||||
{auth.viewerIdentity ? "Sign in to confirm identity" : "Sign in"}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-neutral-500 text-xs">
|
||||
You don’t have permission to make changes to this device, but you
|
||||
can view most of its details.
|
||||
</p>
|
||||
))}
|
||||
{auth.viewerIdentity && (
|
||||
<>
|
||||
<hr />
|
||||
<div className="flex items-center">
|
||||
<User className="flex-shrink-0" />
|
||||
<p className="text-neutral-500 text-xs ml-2">
|
||||
We recognize you because you are accessing this page from{" "}
|
||||
<span className="font-medium">
|
||||
{auth.viewerIdentity.nodeName || auth.viewerIdentity.nodeIP}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,13 @@ import { NodeData } from "src/hooks/node-data"
|
||||
import { useLocation } from "wouter"
|
||||
import ACLTag from "../acl-tag"
|
||||
|
||||
export default function DeviceDetailsView({ node }: { node: NodeData }) {
|
||||
export default function DeviceDetailsView({
|
||||
readonly,
|
||||
node,
|
||||
}: {
|
||||
readonly: boolean
|
||||
node: NodeData
|
||||
}) {
|
||||
const [, setLocation] = useLocation()
|
||||
|
||||
return (
|
||||
@@ -24,12 +30,16 @@ export default function DeviceDetailsView({ node }: { node: NodeData }) {
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="px-3 py-2 bg-stone-50 rounded shadow border border-stone-200 text-neutral-800 text-sm font-medium"
|
||||
className={cx(
|
||||
"px-3 py-2 bg-stone-50 rounded shadow border border-stone-200 text-neutral-800 text-sm font-medium",
|
||||
{ "cursor-not-allowed": readonly }
|
||||
)}
|
||||
onClick={() =>
|
||||
apiFetch("/local/v0/logout", "POST")
|
||||
.then(() => setLocation("/"))
|
||||
.catch((err) => alert("Logout failed: " + err.message))
|
||||
}
|
||||
disabled={readonly}
|
||||
>
|
||||
Disconnect…
|
||||
</button>
|
||||
|
||||
@@ -6,10 +6,12 @@ import { ReactComponent as ArrowRight } from "src/icons/arrow-right.svg"
|
||||
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
|
||||
import { Link } from "wouter"
|
||||
|
||||
export default function ManagementClientView({
|
||||
export default function HomeView({
|
||||
readonly,
|
||||
node,
|
||||
updateNode,
|
||||
}: {
|
||||
readonly: boolean
|
||||
node: NodeData
|
||||
updateNode: (update: NodeUpdate) => Promise<void> | undefined
|
||||
}) {
|
||||
@@ -34,6 +36,7 @@ export default function ManagementClientView({
|
||||
className="mb-5"
|
||||
node={node}
|
||||
updateNode={updateNode}
|
||||
disabled={readonly}
|
||||
/>
|
||||
<Link
|
||||
className="text-indigo-500 font-medium leading-snug"
|
||||
@@ -1,75 +0,0 @@
|
||||
import React from "react"
|
||||
import { AuthResponse, AuthType } from "src/hooks/auth"
|
||||
import { NodeData } from "src/hooks/node-data"
|
||||
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
|
||||
import { ReactComponent as TailscaleLogo } from "src/icons/tailscale-logo.svg"
|
||||
import ProfilePic from "src/ui/profile-pic"
|
||||
|
||||
/**
|
||||
* ReadonlyClientView is rendered when the web interface is either
|
||||
*
|
||||
* 1. being viewed by a user not allowed to manage the node
|
||||
* (e.g. user does not own the node)
|
||||
*
|
||||
* 2. or the user is allowed to manage the node but does not
|
||||
* yet have a valid browser session.
|
||||
*/
|
||||
export default function ReadonlyClientView({
|
||||
data,
|
||||
auth,
|
||||
newSession,
|
||||
}: {
|
||||
data: NodeData
|
||||
auth?: AuthResponse
|
||||
newSession: () => Promise<void>
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="pb-52 mx-auto">
|
||||
<TailscaleLogo />
|
||||
</div>
|
||||
<div className="w-full p-4 bg-stone-50 rounded-3xl border border-gray-200 flex flex-col gap-4">
|
||||
<div className="flex gap-2.5">
|
||||
<ProfilePic url={data.Profile.ProfilePicURL} />
|
||||
<div className="font-medium">
|
||||
<div className="text-neutral-500 text-xs uppercase tracking-wide">
|
||||
Managed by
|
||||
</div>
|
||||
<div className="text-neutral-800 text-sm leading-tight">
|
||||
{/* TODO(sonia): support tagged node profile view more eloquently */}
|
||||
{data.Profile.LoginName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 bg-white rounded-lg border border-gray-200 justify-between items-center flex">
|
||||
<div className="flex gap-3">
|
||||
<ConnectedDeviceIcon />
|
||||
<div className="text-neutral-800">
|
||||
<div className="text-lg font-medium leading-[25.20px]">
|
||||
{data.DeviceName}
|
||||
</div>
|
||||
<div className="text-sm leading-tight">{data.IP}</div>
|
||||
</div>
|
||||
</div>
|
||||
{auth?.authNeeded == AuthType.tailscale ? (
|
||||
<button className="button button-blue ml-6" onClick={newSession}>
|
||||
Access
|
||||
</button>
|
||||
) : (
|
||||
window.location.hostname != data.IP && (
|
||||
// TODO: check connectivity to tailscale IP
|
||||
<button
|
||||
className="button button-blue ml-6"
|
||||
onClick={() => {
|
||||
window.location.href = `http://${data.IP}:5252/?check=now`
|
||||
}}
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -7,8 +7,14 @@ export enum AuthType {
|
||||
}
|
||||
|
||||
export type AuthResponse = {
|
||||
ok: boolean
|
||||
authNeeded?: AuthType
|
||||
canManageNode: boolean
|
||||
viewerIdentity?: {
|
||||
loginName: string
|
||||
nodeName: string
|
||||
nodeIP: string
|
||||
profilePicUrl?: string
|
||||
}
|
||||
}
|
||||
|
||||
// useAuth reports and refreshes Tailscale auth status
|
||||
|
||||
@@ -21,6 +21,7 @@ export type NodeData = {
|
||||
UnraidToken: string
|
||||
IPNVersion: string
|
||||
URLPrefix: string
|
||||
DomainName: string
|
||||
TailnetName: string
|
||||
IsTagged: boolean
|
||||
Tags: string[]
|
||||
|
||||
11
client/web/src/icons/eye.svg
Normal file
11
client/web/src/icons/eye.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_15367_14595)">
|
||||
<path d="M0.625 8C0.625 8 3.125 3 7.5 3C11.875 3 14.375 8 14.375 8C14.375 8 11.875 13 7.5 13C3.125 13 0.625 8 0.625 8Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.5 9.875C8.53553 9.875 9.375 9.03553 9.375 8C9.375 6.96447 8.53553 6.125 7.5 6.125C6.46447 6.125 5.625 6.96447 5.625 8C5.625 9.03553 6.46447 9.875 7.5 9.875Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_15367_14595">
|
||||
<rect width="15" height="15" fill="white" transform="translate(0 0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 738 B |
4
client/web/src/icons/user.svg
Normal file
4
client/web/src/icons/user.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.5 13.625V12.375C12.5 11.712 12.2366 11.0761 11.7678 10.6072C11.2989 10.1384 10.663 9.875 10 9.875H5C4.33696 9.875 3.70107 10.1384 3.23223 10.6072C2.76339 11.0761 2.5 11.712 2.5 12.375V13.625" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.5 7.375C8.88071 7.375 10 6.25571 10 4.875C10 3.49429 8.88071 2.375 7.5 2.375C6.11929 2.375 5 3.49429 5 4.875C5 6.25571 6.11929 7.375 7.5 7.375Z" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 635 B |
106
client/web/src/ui/popover.tsx
Normal file
106
client/web/src/ui/popover.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
import cx from "classnames"
|
||||
import React, { ReactNode } from "react"
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
content: ReactNode
|
||||
children: ReactNode
|
||||
|
||||
/**
|
||||
* asChild renders the trigger element without wrapping it in a button. Use
|
||||
* this when you want to use a `button` element as the trigger.
|
||||
*/
|
||||
asChild?: boolean
|
||||
/**
|
||||
* side is the side of the direction from the target element to render the
|
||||
* popover.
|
||||
*/
|
||||
side?: "top" | "bottom" | "left" | "right"
|
||||
/**
|
||||
* sideOffset is how far from a give side to render the popover.
|
||||
*/
|
||||
sideOffset?: number
|
||||
/**
|
||||
* align is how to align the popover with the target element.
|
||||
*/
|
||||
align?: "start" | "center" | "end"
|
||||
/**
|
||||
* alignOffset is how far off of the alignment point to render the popover.
|
||||
*/
|
||||
alignOffset?: number
|
||||
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover is a UI component that allows rendering unique controls in a floating
|
||||
* popover, attached to a trigger element. It appears on click and manages focus
|
||||
* on its own behalf.
|
||||
*
|
||||
* To use the Popover, pass the content as children, and give it a `trigger`:
|
||||
*
|
||||
* <Popover trigger={<span>Open popover</span>}>
|
||||
* <p>Hello world!</p>
|
||||
* </Popover>
|
||||
*
|
||||
* By default, the toggle is wrapped in an accessible <button> tag. You can
|
||||
* customize by providing your own button and using the `asChild` prop.
|
||||
*
|
||||
* <Popover trigger={<Button>Hello</Button>} asChild>
|
||||
* <p>Hello world!</p>
|
||||
* </Popover>
|
||||
*
|
||||
* The former style is recommended whenever possible.
|
||||
*/
|
||||
export default function Popover(props: Props) {
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
content,
|
||||
side,
|
||||
sideOffset,
|
||||
align,
|
||||
alignOffset,
|
||||
asChild,
|
||||
open,
|
||||
onOpenChange,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverPrimitive.Trigger asChild={asChild}>
|
||||
{children}
|
||||
</PopoverPrimitive.Trigger>
|
||||
<PortalContainerContext.Consumer>
|
||||
{(portalContainer) => (
|
||||
<PopoverPrimitive.Portal container={portalContainer}>
|
||||
<PopoverPrimitive.Content
|
||||
className={cx(
|
||||
"origin-radix-popover shadow-popover bg-white rounded-md z-50",
|
||||
"state-open:animate-scale-in state-closed:animate-scale-out",
|
||||
className
|
||||
)}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
collisionPadding={12}
|
||||
>
|
||||
{content}
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
)}
|
||||
</PortalContainerContext.Consumer>
|
||||
</PopoverPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
Popover.defaultProps = {
|
||||
sideOffset: 10,
|
||||
}
|
||||
|
||||
const PortalContainerContext = React.createContext<HTMLElement | undefined>(
|
||||
undefined
|
||||
)
|
||||
@@ -3,17 +3,24 @@ import React from "react"
|
||||
|
||||
export default function ProfilePic({
|
||||
url,
|
||||
size = "medium",
|
||||
size = "large",
|
||||
className,
|
||||
}: {
|
||||
url: string
|
||||
size?: "small" | "medium"
|
||||
url?: string
|
||||
size?: "small" | "medium" | "large"
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cx("relative flex-shrink-0 rounded-full overflow-hidden", {
|
||||
"w-5 h-5": size === "small",
|
||||
"w-8 h-8": size === "medium",
|
||||
})}
|
||||
className={cx(
|
||||
"relative flex-shrink-0 rounded-full overflow-hidden",
|
||||
{
|
||||
"w-5 h-5": size === "small",
|
||||
"w-[26px] h-[26px]": size === "medium",
|
||||
"w-8 h-8": size === "large",
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{url ? (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user