Files
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
Every import, the catalog pin and the docs name @hanzo/app and
github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and
components/rooms.tsx is components/app.tsx.
2026-10-22 19:07:20 -07:00

320 lines
9.4 KiB
TypeScript

'use client'
/**
* The shapes every settings page is made of, so a page passes its words and its
* controls and never its spacing.
*
* Heading the page's name, one sentence under it, and the links that leave it
* Section a titled block, ruled off from the one above
* Line a sentence in place of content: loading, empty, or refused
* Copy one press puts a value on the clipboard
* Confirm a destructive act asked about first, and typed where it cannot be undone
* Once a secret shown at the only moment it can be read
* Tag a role or a state, as a small outlined label
* Go a link that reads as a button: it leaves for the page that owns the act
*/
import { useState, type ReactNode } from 'react'
import { Check, Copy as CopyIcon } from 'lucide-react'
import { Anchor, Box, Button, Dialog, DialogContent, DialogTitle, Input, Text, XStack, YStack, toast } from '@hanzo/ui'
import { say } from '@hanzo/app/lib/org'
export function Heading({
title,
lede,
action,
}: {
title: string
lede?: ReactNode
/** Links to where the page's subject is also managed, under the sentence. */
action?: ReactNode
}) {
return (
<YStack gap="$2">
<Text render="h1" fontSize="$8" fontWeight="500" color="$ink">
{title}
</Text>
{lede ? (
<Text render="p" fontSize="$3" color="$soft" m={0}>
{lede}
</Text>
) : null}
{action ? (
<XStack gap="$2" pt="$1" flexWrap="wrap">
{action}
</XStack>
) : null}
</YStack>
)
}
export function Section({
title,
lede,
action,
children,
ruled = true,
}: {
title: string
lede?: ReactNode
/** A control that belongs to the whole section, set against its title. */
action?: ReactNode
children?: ReactNode
/** A rule above it; the first section under a heading has none. */
ruled?: boolean
}) {
return (
<YStack render="section" aria-label={title} gap="$4" pt={ruled ? '$6' : 0} borderTopWidth={ruled ? 1 : 0} borderColor="$edge">
<XStack items="flex-start" justify="space-between" gap="$4" flexWrap="wrap">
<YStack gap="$1" flex={1} minW={220}>
<Text render="h2" fontSize="$5" fontWeight="500" color="$ink">
{title}
</Text>
{lede ? (
<Text render="p" fontSize="$3" color="$soft" m={0}>
{lede}
</Text>
) : null}
</YStack>
{action ? <XStack shrink={0}>{action}</XStack> : null}
</XStack>
{children}
</YStack>
)
}
export function Line({ children, tone = 'soft' }: { children: ReactNode; tone?: 'soft' | 'bad' }) {
return (
<Text render="p" m={0} fontSize="$3" color={tone === 'bad' ? '$bad' : '$soft'} role={tone === 'bad' ? 'alert' : undefined}>
{children}
</Text>
)
}
export function Tag({ children }: { children: ReactNode }) {
return (
<Box px="$2" py="$0.5" rounded="$10" borderWidth={1} borderColor="$edge" shrink={0}>
<Text fontSize="$1" color="$ink">
{children}
</Text>
</Box>
)
}
/**
* A link drawn as a button. It is an anchor because it goes somewhere — money is
* moved on the pay site, and a button inside a link is two controls in one.
*/
export function Go({ href, children, strong = false }: { href: string; children: ReactNode; strong?: boolean }) {
return (
<Anchor
href={href}
px="$3"
minH={36}
display="flex"
items="center"
rounded="$4"
borderWidth={1}
borderColor={strong ? 'transparent' : '$edge'}
bg={strong ? '$ink' : 'transparent'}
color={strong ? '$background' : '$ink'}
fontSize="$3"
fontWeight="500"
textDecorationLine="none"
hoverStyle={{ bg: strong ? '$ink' : '$hover', opacity: strong ? 0.9 : 1 }}
>
{children}
</Anchor>
)
}
/** The first letter of a name, in a disc, where there is no picture. */
export function Initial({ name, size = 32 }: { name: string; size?: number }) {
return (
<YStack width={size} height={size} rounded="$10" bg="$raised" items="center" justify="center" shrink={0} aria-hidden>
<Text fontSize={size > 36 ? '$5' : '$2'} fontWeight="500" color="$ink">
{(name.trim()[0] ?? '?').toUpperCase()}
</Text>
</YStack>
)
}
export function Copy({ value, label }: { value: string; label: string }) {
const [done, setDone] = useState(false)
return (
<Button
variant="ghost"
size="sm"
aria-label={label}
onClick={async () => {
try {
await navigator.clipboard.writeText(value)
setDone(true)
setTimeout(() => setDone(false), 1500)
} catch {
toast.error('The browser refused the clipboard. Select the text and copy it.')
}
}}
>
{done ? <Check size={15} aria-hidden /> : <CopyIcon size={15} aria-hidden />}
</Button>
)
}
/** A value set in the mono face, cut to its box, with its copy control beside it. */
export function Value({ value, label }: { value: string; label: string }) {
return (
<XStack items="center" gap="$2" minW={0}>
<Text fontFamily="$mono" fontSize="$2" color="$ink" numberOfLines={1} minW={0} shrink={1}>
{value}
</Text>
<Copy value={value} label={label} />
</XStack>
)
}
/** A dialog's frame: the same card and width wherever one opens. */
function Card({ open, onClose, title, children }: { open: boolean; onClose: () => void; title: string; children: ReactNode }) {
return (
<Dialog open={open} onOpenChange={(next) => (next ? undefined : onClose())}>
<DialogContent showCloseButton={false} width="calc(100vw - 32px)" maxW={480} rounded="$6" p="$5" gap="$4">
<DialogTitle fontSize="$6" fontWeight="500" color="$ink">
{title}
</DialogTitle>
{children}
</DialogContent>
</Dialog>
)
}
/**
* Asks before an act, and holds the reader to typing `typed` where the act has
* no undo. The act's refusal is shown in the dialog, in the server's words, and
* the dialog stays open so the reader can read it.
*/
export function Confirm({
open,
title,
body,
act,
typed,
onConfirm,
onClose,
}: {
open: boolean
title: string
body: ReactNode
/** The button's words: what pressing it does. */
act: string
typed?: string
onConfirm: () => Promise<void>
onClose: () => void
}) {
const [text, setText] = useState('')
const [busy, setBusy] = useState(false)
const [refused, setRefused] = useState<string | null>(null)
const ready = !typed || text.trim() === typed
const close = () => {
setText('')
setRefused(null)
onClose()
}
return (
<Card open={open} onClose={close} title={title}>
<Text render="p" m={0} fontSize="$3" color="$soft">
{body}
</Text>
{typed ? (
<YStack gap="$2">
<Text render="label" htmlFor="confirm-typed" fontSize="$2" color="$soft">
Type {typed} to confirm
</Text>
<Input id="confirm-typed" value={text} onChange={(e) => setText(e.target.value)} autoComplete="off" />
</YStack>
) : null}
{refused ? <Line tone="bad">{refused}</Line> : null}
<XStack justify="flex-end" gap="$2" flexWrap="wrap">
<Button variant="ghost" onClick={close} disabled={busy}>
Cancel
</Button>
<Button
variant="destructive"
disabled={!ready || busy}
onClick={async () => {
setBusy(true)
setRefused(null)
try {
await onConfirm()
close()
} catch (e) {
setRefused(say(e))
} finally {
setBusy(false)
}
}}
>
{busy ? 'Working…' : act}
</Button>
</XStack>
</Card>
)
}
/**
* A secret at the one moment it is readable. It lives in this component's props
* and nowhere else: closing the dialog drops it, and nothing writes it down.
*/
export function Once({
title,
secret,
note,
onClose,
}: {
title: string
/** Label and value pairs; the last one is the secret. */
secret: [string, string][] | null
note: string
onClose: () => void
}) {
return (
<Card open={secret !== null} onClose={onClose} title={title}>
<Text render="p" m={0} fontSize="$3" color="$soft">
{note}
</Text>
{(secret ?? []).map(([label, value]) => (
<YStack key={label} gap="$1.5">
<Text fontSize="$2" color="$soft">
{label}
</Text>
<XStack items="center" gap="$2" p="$2.5" rounded="$4" bg="$sunken" borderWidth={1} borderColor="$edge">
<Text
fontFamily="$mono"
fontSize="$2"
color="$ink"
flex={1}
minW={0}
select="all"
// A secret breaks anywhere rather than at its hyphens; gui types no `wordBreak`.
$platform-web={{ wordBreak: 'break-all' }}
>
{value}
</Text>
<Copy value={value} label={`Copy ${label.toLowerCase()}`} />
</XStack>
</YStack>
))}
<XStack justify="flex-end">
<Button onClick={onClose}>I have saved it</Button>
</XStack>
</Card>
)
}
/** A date IAM or billing stated, as a reader says it; nothing where there is none. */
export function day(iso?: string): string {
if (!iso) return ''
const t = Date.parse(iso)
if (Number.isNaN(t)) return ''
return new Date(t).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
}