mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
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.
320 lines
9.4 KiB
TypeScript
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' })
|
|
}
|