mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 04:20:39 +00:00
Inline style objects became gui props (Box for a div, View or Text rendering the element for the rest, since Box styles only a div), raw colours and sizes became the design's tokens, the brand swatches read @hanzo/design's own values, and the project and creation hues moved to palette.json, which is data. The newsletter's subscribe button is the standard filled Action. connect-steps.tsx, which nothing imports, is deleted with the brand constants nobody read. Pages compared before and after at 1440x900 and 390x844: blockchain, brand, directory, projects, settings, unsubscribe identical; founder 0.03% of pixels; buying specialist's title snaps from 28 to the ramp's 26px and the newsletter's button is now filled. Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
647 lines
23 KiB
TypeScript
647 lines
23 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* The settings area's frame: a column of places beside the page, both panels on
|
|
* the app's ground, and the one place that asks who is reading and in which org.
|
|
*
|
|
* NOTHING PRODUCT-SHAPED IS IN THE EXPORT. Until hydration and until IAM has
|
|
* answered, the frame is the veil the rooms wear, so the static file holds no
|
|
* column, no form and no name — a file cannot know who fetched it. A reader with
|
|
* no session is sent to sign in and back (lib/auth/destination.ts `enter`).
|
|
*
|
|
* WHO MAY DO WHAT IS IAM'S ANSWER, READ HERE ONCE. The role is the one IAM signed
|
|
* into the token for the acting org (lib/auth/session.ts `role`), so "admin" is
|
|
* an owner or admin of THIS org and nothing else (HIP-0527 I15): it is never read
|
|
* from another org, from the account, or from being a SuperAdmin. Pages draw the
|
|
* controls an owner or admin gets from `admin`, and every write is still IAM's to
|
|
* refuse — the frame hides a control, it does not grant one.
|
|
*/
|
|
|
|
import Link from 'next/link'
|
|
import { useRouter } from 'next/navigation'
|
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
import { ArrowLeft, BookOpen, ChevronsUpDown, CircleHelp, Coins, Menu, MessagesSquare, Search, Settings, X } from 'lucide-react'
|
|
import { useIam } from '@hanzo/iam/react'
|
|
import { useI18n } from '@hanzo/i18n/react'
|
|
import { useSignOut } from '@hanzo/app/lib/signout'
|
|
import {
|
|
Avatar,
|
|
AvatarFallback,
|
|
AvatarImage,
|
|
Box,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
Input,
|
|
Kbd,
|
|
Text,
|
|
View,
|
|
XStack,
|
|
YStack,
|
|
} from '@hanzo/ui'
|
|
import { useAccount } from '@hanzo/app/lib/account'
|
|
import { useHydrated } from '@hanzo/app/lib/hydrated'
|
|
import { useRoute } from '@/components/shell/route'
|
|
import { enter } from '@hanzo/app/lib/destination'
|
|
import { ORG, named, org as acting, orgs as memberships, pick, role as roleIn } from '@hanzo/app/lib/session'
|
|
import { readOrg, say, cents, type OrgRow } from '@hanzo/app/lib/org'
|
|
import { useTier, type Tier } from '@hanzo/app/lib/tier'
|
|
import { payPage } from '@hanzo/app/lib/pay'
|
|
import { PLACES, find, here, type Place } from './nav'
|
|
import { Line } from './kit'
|
|
import { floor, pane } from '@hanzo/app/ground'
|
|
import { WindowControls, dragWindow, toggleWindowMaximize } from '@/components/shell/WindowControls'
|
|
|
|
/** What every page reads about the reader and the org they are configuring. */
|
|
export interface Standing {
|
|
/** The acting org's handle, or null where the reader has not chosen one. */
|
|
org: string | null
|
|
/** The reader's role in it as IAM signed it: owner | admin | member. */
|
|
role: string | null
|
|
/** Owner or admin of THIS org. */
|
|
admin: boolean
|
|
/** The org row, once IAM answers it. */
|
|
row: OrgRow | null
|
|
/** Why the row could not be read. */
|
|
rowWrong: string | null
|
|
/** An org of one; null until the row says. */
|
|
personal: boolean | null
|
|
reloadRow: () => void
|
|
tier: Tier | null
|
|
tierWrong: unknown
|
|
reloadTier: () => void
|
|
/** The reader, as IAM names them. */
|
|
name: string
|
|
email: string
|
|
/** Their picture, where IAM holds one. */
|
|
avatar: string
|
|
/** Whether Add funds is theirs to press: an owner or admin, or a wallet of their own. */
|
|
funds: boolean
|
|
}
|
|
|
|
const Ctx = createContext<Standing | null>(null)
|
|
|
|
export function useStanding(): Standing {
|
|
const s = useContext(Ctx)
|
|
if (!s) throw new Error('useStanding is read under the settings Frame')
|
|
return s
|
|
}
|
|
|
|
/** A role, as a label. */
|
|
export const said = (role: string | null): string => (role ? role[0].toUpperCase() + role.slice(1) : 'No role')
|
|
|
|
/** A pane's fill: the frame's own where the app's material defines it, the ladder's first rung where not. */
|
|
const FILL = 'var(--pane-fill, var(--surface-1))'
|
|
|
|
/** The veil sits ten above the room's own modals. gui carries a `calc()` to the page unchanged; its types only name
|
|
* custom properties. */
|
|
const SHEET = 'calc(var(--z-modal) + 10)' as `var(--${string})`
|
|
|
|
/** The rooms' veil: the one element the export holds, identical before and after hydration. */
|
|
function Veil() {
|
|
return <View aria-hidden position="fixed" inset={0} z={SHEET} bg="var(--background)" />
|
|
}
|
|
|
|
export function Frame({ children }: { children: ReactNode }) {
|
|
const hydrated = useHydrated()
|
|
const { isAuthenticated, isLoading } = useIam()
|
|
useEffect(() => {
|
|
if (hydrated && !isLoading && !isAuthenticated) enter()
|
|
}, [hydrated, isLoading, isAuthenticated])
|
|
if (!hydrated || isLoading || !isAuthenticated) return <Veil />
|
|
return <Signed>{children}</Signed>
|
|
}
|
|
|
|
/** The acting org, kept current when it is switched here or in another tab. */
|
|
function useActing(token: string | undefined): string | null {
|
|
const [current, setCurrent] = useState<string | null>(() => acting())
|
|
useEffect(() => {
|
|
setCurrent(acting())
|
|
const on = () => setCurrent(acting())
|
|
window.addEventListener(ORG, on)
|
|
window.addEventListener('storage', on)
|
|
return () => {
|
|
window.removeEventListener(ORG, on)
|
|
window.removeEventListener('storage', on)
|
|
}
|
|
}, [token])
|
|
return current
|
|
}
|
|
|
|
function Signed({ children }: { children: ReactNode }) {
|
|
const { accessToken } = useIam()
|
|
const { user } = useAccount()
|
|
const org = useActing(accessToken ?? undefined)
|
|
const role = useMemo(() => roleIn(org), [org, accessToken]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
const admin = role === 'owner' || role === 'admin'
|
|
|
|
const [held, setHeld] = useState<{ org: string; row: OrgRow | null; wrong: string | null } | null>(null)
|
|
const [nonce, setNonce] = useState(0)
|
|
useEffect(() => {
|
|
if (!org) return
|
|
let live = true
|
|
readOrg(org)
|
|
.then((row) => live && setHeld({ org, row, wrong: null }))
|
|
.catch((e: unknown) => live && setHeld({ org, row: null, wrong: say(e) }))
|
|
return () => {
|
|
live = false
|
|
}
|
|
}, [org, nonce])
|
|
const mine = held && held.org === org ? held : null
|
|
const reloadRow = useCallback(() => setNonce((n) => n + 1), [])
|
|
|
|
const { tier, wrong: tierWrong, reload: reloadTier } = useTier(Boolean(org), org)
|
|
const claimed = useMemo(() => named(), [accessToken]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
const account = tier?.balance.account
|
|
const standing: Standing = {
|
|
org,
|
|
role,
|
|
admin,
|
|
row: mine?.row ?? null,
|
|
rowWrong: mine?.wrong ?? null,
|
|
personal: mine?.row ? Boolean(mine.row.isPersonal) : null,
|
|
reloadRow,
|
|
tier,
|
|
tierWrong,
|
|
reloadTier,
|
|
name: user?.name || claimed?.name || '',
|
|
email: user?.email || claimed?.email || '',
|
|
avatar: user?.avatar || '',
|
|
funds: admin || Boolean(account && org && account !== org),
|
|
}
|
|
|
|
const route = useRoute()
|
|
const place = here(route)
|
|
const [sheet, setSheet] = useState(false)
|
|
const seek = useRef<HTMLInputElement | null>(null)
|
|
|
|
// ⌘K / Ctrl+K takes the reader to the search, on a phone by opening the column.
|
|
useEffect(() => {
|
|
const key = (e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
e.preventDefault()
|
|
if (window.matchMedia('(max-width: 767.98px)').matches) setSheet(true)
|
|
requestAnimationFrame(() => seek.current?.focus())
|
|
}
|
|
if (e.key === 'Escape') setSheet(false)
|
|
}
|
|
window.addEventListener('keydown', key)
|
|
return () => window.removeEventListener('keydown', key)
|
|
}, [])
|
|
|
|
const needsOrg = Boolean(place?.org) && !org
|
|
const isDesktop =
|
|
typeof window !== 'undefined' &&
|
|
Boolean(
|
|
(window as unknown as { __TAURI__?: unknown; __TAURI_INTERNALS__?: unknown }).__TAURI__ ||
|
|
(window as unknown as { __TAURI__?: unknown; __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__,
|
|
)
|
|
|
|
return (
|
|
<Ctx.Provider value={standing}>
|
|
<YStack {...floor} height="100%" overflow="hidden">
|
|
{isDesktop ? (
|
|
<XStack
|
|
data-slot="title-bar"
|
|
data-tauri-drag-region="deep"
|
|
onMouseDown={dragWindow}
|
|
onDoubleClick={toggleWindowMaximize}
|
|
height={38}
|
|
shrink={0}
|
|
items="center"
|
|
gap="$2"
|
|
px="$2"
|
|
>
|
|
<Link href="/" aria-label="Back to app">
|
|
<XStack items="center" justify="center" width={28} height={28} rounded="$3" hoverStyle={{ bg: '$hover' }}>
|
|
<ArrowLeft size={16} aria-hidden data-i18n-mirror />
|
|
</XStack>
|
|
</Link>
|
|
<Text fontSize="$2" fontWeight="500" color="$soft" numberOfLines={1} data-tauri-drag-region="deep">
|
|
{place?.label ?? 'Settings'}
|
|
</Text>
|
|
<View flex={1} self="stretch" data-tauri-drag-region="deep" />
|
|
<WindowControls />
|
|
</XStack>
|
|
) : null}
|
|
{/* The app's frame: panes floating on the lit floor, one gutter round the
|
|
window and between them — the material the app at `/` wears
|
|
(components/workspace/ground.ts). */}
|
|
<XStack data-slot="frame" flex={1} minH={0} gap="$2" p="$2" pt={isDesktop ? 0 : '$2'} $lg={{ gap: '$3', p: '$3', pt: isDesktop ? 0 : '$3' }}>
|
|
<YStack
|
|
render="nav"
|
|
aria-label="Settings"
|
|
{...pane(FILL)}
|
|
display="none"
|
|
$md={{ display: 'flex' }}
|
|
width={280}
|
|
shrink={0}
|
|
overflow="hidden"
|
|
>
|
|
<Column seek={seek} />
|
|
</YStack>
|
|
|
|
<YStack render="main" {...pane(FILL)} flex={1} minW={0} overflow="hidden">
|
|
<XStack
|
|
$md={{ display: 'none' }}
|
|
items="center"
|
|
gap="$2"
|
|
px="$3"
|
|
py="$2"
|
|
borderBottomWidth={1}
|
|
borderColor="$edge"
|
|
>
|
|
<Link href="/" aria-label="Back to app">
|
|
<XStack items="center" justify="center" width={44} height={44} rounded="$4" hoverStyle={{ bg: '$hover' }}>
|
|
<ArrowLeft size={18} aria-hidden data-i18n-mirror />
|
|
</XStack>
|
|
</Link>
|
|
<Text flex={1} fontSize="$4" fontWeight="500" color="$ink" numberOfLines={1}>
|
|
{place?.label ?? 'Settings'}
|
|
</Text>
|
|
<Box
|
|
render="button"
|
|
aria-label="Settings menu"
|
|
aria-expanded={sheet}
|
|
onClick={() => setSheet(true)}
|
|
width={44}
|
|
height={44}
|
|
rounded="$4"
|
|
items="center"
|
|
justify="center"
|
|
bg="transparent"
|
|
borderWidth={0}
|
|
cursor="pointer"
|
|
hoverStyle={{ bg: '$hover' }}
|
|
>
|
|
<Menu size={18} aria-hidden color="var(--foreground)" />
|
|
</Box>
|
|
</XStack>
|
|
|
|
<YStack flex={1} minH={0} overflow="scroll">
|
|
<YStack width="100%" maxW={760} mx="auto" px="$4" py="$6" $md={{ px: '$7', py: '$8' }} gap="$7">
|
|
{needsOrg ? <Choose /> : children}
|
|
</YStack>
|
|
</YStack>
|
|
</YStack>
|
|
|
|
{sheet ? (
|
|
<YStack
|
|
role="dialog"
|
|
aria-modal
|
|
aria-label="Settings"
|
|
position="fixed"
|
|
t={0}
|
|
l={0}
|
|
r={0}
|
|
b={0}
|
|
bg="$background"
|
|
p="$2"
|
|
z="var(--z-modal)"
|
|
>
|
|
<YStack {...pane(FILL)} flex={1} overflow="hidden">
|
|
<XStack justify="flex-end" px="$2" pt="$2">
|
|
<Box
|
|
render="button"
|
|
aria-label="Close settings menu"
|
|
onClick={() => setSheet(false)}
|
|
width={44}
|
|
height={44}
|
|
rounded="$4"
|
|
items="center"
|
|
justify="center"
|
|
bg="transparent"
|
|
borderWidth={0}
|
|
cursor="pointer"
|
|
hoverStyle={{ bg: '$hover' }}
|
|
>
|
|
<X size={18} aria-hidden color="var(--foreground)" />
|
|
</Box>
|
|
</XStack>
|
|
{/* A place chosen here closes the column it was chosen from. */}
|
|
<Column seek={seek} onGo={() => setSheet(false)} />
|
|
</YStack>
|
|
</YStack>
|
|
) : null}
|
|
</XStack>
|
|
</YStack>
|
|
</Ctx.Provider>
|
|
)
|
|
}
|
|
|
|
/** One row of the column: a place, with its mark, marked when it is open. */
|
|
function Row({ place, active, onGo }: { place: Place; active: boolean; onGo?: () => void }) {
|
|
return (
|
|
<Link href={place.href} aria-current={active ? 'page' : undefined} onClick={onGo}>
|
|
<XStack
|
|
items="center"
|
|
gap="$3"
|
|
px="$3"
|
|
minH={40}
|
|
rounded="$4"
|
|
bg={active ? '$raised' : 'transparent'}
|
|
hoverStyle={{ bg: active ? '$raised' : '$hover' }}
|
|
>
|
|
<place.Icon size={16} aria-hidden />
|
|
<Text fontSize="$3" color={active ? '$ink' : '$soft'} fontWeight={active ? '500' : '400'} numberOfLines={1}>
|
|
{place.label}
|
|
</Text>
|
|
</XStack>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
/** The column: back, search, the places, and the reader at its foot. */
|
|
function Column({ seek, onGo }: { seek: React.RefObject<HTMLInputElement | null>; onGo?: () => void }) {
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
const { personal } = useStanding()
|
|
const [q, setQ] = useState('')
|
|
const reachable = PLACES.filter((p) => !(p.team && personal === true))
|
|
const shown = find(reachable, q)
|
|
const mine = shown.filter((p) => !p.org)
|
|
const theirs = shown.filter((p) => p.org)
|
|
const open = here(route)
|
|
const { dir } = useI18n()
|
|
// The column draws only after hydration, so the platform is known here.
|
|
const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)
|
|
|
|
return (
|
|
<YStack flex={1} minH={0}>
|
|
<YStack flex={1} minH={0} overflow="scroll" p="$3" gap="$1">
|
|
<Link href="/">
|
|
<XStack items="center" gap="$3" px="$3" minH={40} rounded="$4" hoverStyle={{ bg: '$hover' }}>
|
|
<ArrowLeft size={16} aria-hidden data-i18n-mirror />
|
|
<Text fontSize="$3" color="$ink">
|
|
Back to app
|
|
</Text>
|
|
</XStack>
|
|
</Link>
|
|
|
|
<XStack position="relative" items="center" my="$2">
|
|
<Input
|
|
ref={seek}
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Enter' && shown[0]) {
|
|
router.push(shown[0].href)
|
|
setQ('')
|
|
onGo?.()
|
|
}
|
|
if (e.key === 'Escape') setQ('')
|
|
}}
|
|
placeholder="Search settings"
|
|
aria-label="Search settings"
|
|
width="100%"
|
|
// The mark stands at the field's start and the keys at its end.
|
|
// Input states its own physical gutters, which a logical padding
|
|
// would race in the cascade, so the two sides trade by direction.
|
|
{...(dir === 'rtl' ? { pl: '$9', pr: '$7' } : { pl: '$7', pr: '$9' })}
|
|
/>
|
|
{/* After the field, so each paints over it without a stacking order of its own. */}
|
|
<Box position="absolute" insetInlineStart="$3" pointerEvents="none" aria-hidden>
|
|
<Search size={15} color="var(--muted-foreground)" />
|
|
</Box>
|
|
<XStack position="absolute" insetInlineEnd="$2" gap="$1" pointerEvents="none" aria-hidden data-i18n-ltr>
|
|
<Kbd>{mac ? '⌘' : 'Ctrl'}</Kbd>
|
|
<Kbd>K</Kbd>
|
|
</XStack>
|
|
</XStack>
|
|
|
|
{mine.map((p) => (
|
|
<Row key={p.href} place={p} active={open?.href === p.href} onGo={onGo} />
|
|
))}
|
|
|
|
{theirs.length > 0 ? (
|
|
<Text
|
|
render={<Link href="/settings/organization" onClick={onGo} />}
|
|
display="inline"
|
|
textDecorationLine="none"
|
|
>
|
|
<XStack
|
|
items="center"
|
|
gap="$3"
|
|
px="$3"
|
|
minH={40}
|
|
mt="$2"
|
|
rounded="$4"
|
|
hoverStyle={{ bg: '$hover' }}
|
|
cursor="pointer"
|
|
>
|
|
<Settings size={16} aria-hidden color="var(--foreground)" />
|
|
<Text fontSize="$3" fontWeight="500" color="$ink">
|
|
Organization settings
|
|
</Text>
|
|
</XStack>
|
|
</Text>
|
|
) : null}
|
|
<YStack gap="$1" paddingInlineStart="$4">
|
|
{theirs.map((p) => (
|
|
<Row key={p.href} place={p} active={open?.href === p.href} onGo={onGo} />
|
|
))}
|
|
</YStack>
|
|
|
|
{shown.length === 0 ? (
|
|
<Box px="$3" py="$2">
|
|
<Line>Nothing in settings matches “{q.trim()}”.</Line>
|
|
</Box>
|
|
) : null}
|
|
</YStack>
|
|
|
|
<Foot />
|
|
</YStack>
|
|
)
|
|
}
|
|
|
|
/** The column's foot: docs, credits, and who is reading in which org. */
|
|
function Foot() {
|
|
const { tier, tierWrong, funds } = useStanding()
|
|
const balance = tier ? cents(tier.balance.effectiveAvailable, tier.balance.currency || 'usd') : tierWrong ? 'Unavailable' : '…'
|
|
return (
|
|
<YStack p="$3" gap="$1" borderTopWidth={1} borderColor="$edge">
|
|
<a href="https://docs.hanzo.ai" target="_blank" rel="noreferrer">
|
|
<XStack items="center" gap="$3" px="$3" minH={36} rounded="$4" hoverStyle={{ bg: '$hover' }}>
|
|
<BookOpen size={16} aria-hidden />
|
|
<Text fontSize="$3" color="$soft">
|
|
Documentation
|
|
</Text>
|
|
</XStack>
|
|
</a>
|
|
<Link href="/support">
|
|
<XStack items="center" gap="$3" px="$3" minH={36} rounded="$4" hoverStyle={{ bg: '$hover' }}>
|
|
<CircleHelp size={16} aria-hidden />
|
|
<Text fontSize="$3" color="$soft">
|
|
Help center
|
|
</Text>
|
|
</XStack>
|
|
</Link>
|
|
<Link href="/contact">
|
|
<XStack items="center" gap="$3" px="$3" minH={36} rounded="$4" hoverStyle={{ bg: '$hover' }}>
|
|
<MessagesSquare size={16} aria-hidden />
|
|
<Text fontSize="$3" color="$soft">
|
|
Contact us
|
|
</Text>
|
|
</XStack>
|
|
</Link>
|
|
<XStack items="center" gap="$2" px="$3" minH={36}>
|
|
<Coins size={16} aria-hidden />
|
|
<Text fontSize="$3" color="$soft" marginInlineStart="$1">
|
|
Credits
|
|
</Text>
|
|
<Text fontSize="$3" color="$ink" marginInlineStart="auto" numberOfLines={1}>
|
|
{balance}
|
|
</Text>
|
|
{funds ? (
|
|
<a href={payPage('/amount/card')}>
|
|
<Text fontSize="$3" color="$ink" textDecorationLine="underline" numberOfLines={1}>
|
|
Add funds
|
|
</Text>
|
|
</a>
|
|
) : null}
|
|
</XStack>
|
|
<Who />
|
|
</YStack>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The reader and the org they are acting in, and the menu that moves them: to
|
|
* another of their orgs, back to the app, or out.
|
|
*/
|
|
function Who() {
|
|
const { name, email, avatar, role, org, row } = useStanding()
|
|
const { organizations } = useAccount()
|
|
const logout = useSignOut()
|
|
const mine = memberships()
|
|
const label = (o: string) => (o === org && row?.displayName) || organizations.find((x) => x.id === o)?.name || o
|
|
return (
|
|
<DropdownMenu placement="top-start">
|
|
<DropdownMenuTrigger asChild>
|
|
<Box
|
|
render="button"
|
|
aria-label="Account and organization"
|
|
mt="$2"
|
|
p="$2"
|
|
rounded="$4"
|
|
bg="transparent"
|
|
borderWidth={0}
|
|
cursor="pointer"
|
|
hoverStyle={{ bg: '$hover' }}
|
|
width="100%"
|
|
>
|
|
<XStack items="center" gap="$3" width="100%">
|
|
<Avatar size={32}>
|
|
<AvatarImage src={avatar || undefined} />
|
|
<AvatarFallback>
|
|
<Text fontSize="$2" fontWeight="500" color="$ink">
|
|
{(name || email || '?')[0].toUpperCase()}
|
|
</Text>
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<YStack flex={1} minW={0} items="flex-start">
|
|
<Text fontSize="$3" fontWeight="500" color="$ink" numberOfLines={1}>
|
|
{name || email}
|
|
</Text>
|
|
<Text fontSize="$2" color="$soft" numberOfLines={1}>
|
|
{org ? `${said(role)} · ${label(org)}` : 'No organization chosen'}
|
|
</Text>
|
|
</YStack>
|
|
<ChevronsUpDown size={15} aria-hidden />
|
|
</XStack>
|
|
</Box>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent minW={240}>
|
|
{mine.length > 0 ? <DropdownMenuLabel>Organizations</DropdownMenuLabel> : null}
|
|
{mine.map((o) => (
|
|
<DropdownMenuItem
|
|
key={o}
|
|
onSelect={() => {
|
|
if (o === org) return
|
|
pick(o)
|
|
}}
|
|
>
|
|
<XStack items="center" justify="space-between" gap="$3" width="100%">
|
|
<Text fontSize="$3" color="$ink" numberOfLines={1}>
|
|
{label(o)}
|
|
</Text>
|
|
<Text fontSize="$2" color="$soft">
|
|
{o === org ? 'Current' : said(roleIn(o))}
|
|
</Text>
|
|
</XStack>
|
|
</DropdownMenuItem>
|
|
))}
|
|
{mine.length > 0 ? <DropdownMenuSeparator /> : null}
|
|
<DropdownMenuItem onSelect={() => window.location.assign('/')}>
|
|
<Text fontSize="$3" color="$ink">
|
|
Back to app
|
|
</Text>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => void logout()}>
|
|
<Text fontSize="$3" color="$ink">
|
|
Sign out
|
|
</Text>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)
|
|
}
|
|
|
|
/** A reader in several orgs who has not chosen one is asked which they mean. */
|
|
function Choose() {
|
|
const { organizations } = useAccount()
|
|
const mine = memberships()
|
|
if (mine.length === 0) {
|
|
return (
|
|
<YStack gap="$3">
|
|
<Text render="h1" fontSize="$8" fontWeight="500" color="$ink">
|
|
Organization settings
|
|
</Text>
|
|
<Line>You do not belong to an organization yet.</Line>
|
|
<Link href="/home">
|
|
<Text fontSize="$3" color="$ink" textDecorationLine="underline">
|
|
Create one
|
|
</Text>
|
|
</Link>
|
|
</YStack>
|
|
)
|
|
}
|
|
return (
|
|
<YStack gap="$4">
|
|
<Text render="h1" fontSize="$8" fontWeight="500" color="$ink">
|
|
Choose an organization
|
|
</Text>
|
|
<Line>Settings apply to one organization at a time.</Line>
|
|
<YStack gap="$2">
|
|
{mine.map((o) => (
|
|
<Box
|
|
key={o}
|
|
render="button"
|
|
onClick={() => pick(o)}
|
|
p="$3"
|
|
rounded="$4"
|
|
borderWidth={1}
|
|
borderColor="$edge"
|
|
bg="transparent"
|
|
cursor="pointer"
|
|
hoverStyle={{ bg: '$hover' }}
|
|
>
|
|
<XStack items="center" justify="space-between" gap="$3">
|
|
<Text fontSize="$3" color="$ink">
|
|
{organizations.find((x) => x.id === o)?.name || o}
|
|
</Text>
|
|
<Text fontSize="$2" color="$soft">
|
|
{said(roleIn(o))}
|
|
</Text>
|
|
</XStack>
|
|
</Box>
|
|
))}
|
|
</YStack>
|
|
</YStack>
|
|
)
|
|
}
|
|
|