6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
type ComponentType,
|
|
createContext,
|
|
type ReactNode,
|
|
type Ref,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import { Chip, ChipInput, ChipLink, cn, Search, Tooltip } from '@sim/emcn'
|
|
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
|
|
|
|
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
|
|
|
|
/**
|
|
* A collapsed desktop sidebar removes the content shell's 8px top gutter and the
|
|
* pane's 1px border. Top-level settings headers have no left-side control to keep
|
|
* clear of the traffic lights, so they reserve only those missing 9px instead of
|
|
* the full title-bar lane. Their actions and body therefore stay at the same
|
|
* window-relative height as the expanded layout. Detail headers with a Back
|
|
* control retain the full lane inset.
|
|
*/
|
|
const COLLAPSED_DESKTOP_TOP_LEVEL_INSET =
|
|
'[[data-sim-desktop-title-bar=inset]_[data-sidebar-collapsed]_&]:[--workspace-content-title-bar-inset:9px]'
|
|
|
|
export interface SettingsAction {
|
|
/** Stable render identity. Falls back to `text`, which remounts the chip whenever the label flips (Save → Saving...). */
|
|
id?: string
|
|
text: string
|
|
textTone?: 'error'
|
|
icon?: ComponentType<{ className?: string }>
|
|
variant?: 'primary' | 'destructive'
|
|
active?: boolean
|
|
onSelect: () => void
|
|
disabled?: boolean
|
|
tooltip?: string
|
|
onPrefetch?: () => void
|
|
}
|
|
|
|
export interface SettingsHeaderSearch {
|
|
value: string
|
|
onChange: (value: string) => void
|
|
placeholder?: string
|
|
disabled?: boolean
|
|
}
|
|
|
|
export interface SettingsBackAction {
|
|
text: string
|
|
icon?: ComponentType<{ className?: string }>
|
|
onSelect: () => void
|
|
}
|
|
|
|
export interface SettingsHeaderConfig {
|
|
title?: string
|
|
description?: string
|
|
docsLink?: string
|
|
back?: SettingsBackAction
|
|
actions?: SettingsAction[]
|
|
search?: SettingsHeaderSearch
|
|
scrollContainerRef?: Ref<HTMLDivElement>
|
|
}
|
|
|
|
const EMPTY_CONFIG: SettingsHeaderConfig = {}
|
|
const RegisterContext = createContext<((config: SettingsHeaderConfig) => void) | null>(null)
|
|
|
|
interface ReadContextValue {
|
|
configRef: { current: SettingsHeaderConfig }
|
|
signature: string
|
|
}
|
|
|
|
const ReadContext = createContext<ReadContextValue | null>(null)
|
|
|
|
function computeSignature(config: SettingsHeaderConfig): string {
|
|
return JSON.stringify({
|
|
title: config.title ?? '',
|
|
description: config.description ?? '',
|
|
docsLink: config.docsLink ?? '',
|
|
back: config.back ? [config.back.text, config.back.icon ? 1 : 0] : null,
|
|
actions: config.actions?.map((action) => [
|
|
action.text,
|
|
// `id` participates in ordering, so a config that changes only the id
|
|
// must still re-render — the sort key cannot be wider than the signature.
|
|
action.id ?? '',
|
|
action.textTone ?? '',
|
|
action.variant ?? '',
|
|
action.active ?? false,
|
|
action.disabled ?? false,
|
|
action.icon ? 1 : 0,
|
|
action.tooltip ?? '',
|
|
action.onPrefetch ? 1 : 0,
|
|
]),
|
|
search: config.search
|
|
? [config.search.value, config.search.placeholder ?? '', config.search.disabled ?? false]
|
|
: null,
|
|
})
|
|
}
|
|
|
|
export function SettingsHeaderProvider({ children }: { children: ReactNode }) {
|
|
const configRef = useRef<SettingsHeaderConfig>(EMPTY_CONFIG)
|
|
const [signature, setSignature] = useState('')
|
|
|
|
const register = useCallback((config: SettingsHeaderConfig) => {
|
|
configRef.current = config
|
|
const next = computeSignature(config)
|
|
setSignature((previous) => (previous === next ? previous : next))
|
|
}, [])
|
|
|
|
const readValue = useMemo<ReadContextValue>(() => ({ configRef, signature }), [signature])
|
|
|
|
return (
|
|
<RegisterContext.Provider value={register}>
|
|
<ReadContext.Provider value={readValue}>{children}</ReadContext.Provider>
|
|
</RegisterContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useSettingsHeader(config: SettingsHeaderConfig) {
|
|
const register = useContext(RegisterContext)
|
|
|
|
useIsomorphicLayoutEffect(() => {
|
|
register?.(config)
|
|
})
|
|
|
|
useIsomorphicLayoutEffect(() => {
|
|
return () => register?.(EMPTY_CONFIG)
|
|
}, [register])
|
|
}
|
|
|
|
interface SettingsActionChipProps {
|
|
/** Presentation fields plus the default `onSelect` / `onPrefetch` handlers. */
|
|
action: SettingsAction
|
|
/** Overrides `action.onSelect` — the header shell passes a ref-reading indirection to avoid stale closures. */
|
|
onSelect?: () => void
|
|
/** Overrides `action.onPrefetch`, same reason. */
|
|
onPrefetch?: () => void
|
|
}
|
|
|
|
/**
|
|
* The one chip rendering of a {@link SettingsAction}. Both header stacks render
|
|
* through this, so an action's chrome — tone, icon, variant, disabled, tooltip —
|
|
* is identical wherever it is mounted. Container spacing still belongs to the
|
|
* enclosing shell.
|
|
*/
|
|
export function SettingsActionChip({
|
|
action,
|
|
onSelect = action.onSelect,
|
|
onPrefetch = action.onPrefetch,
|
|
}: SettingsActionChipProps) {
|
|
const chip = (
|
|
<Chip
|
|
variant={action.variant}
|
|
active={action.active}
|
|
leftIcon={action.icon}
|
|
onClick={onSelect}
|
|
onMouseEnter={onPrefetch}
|
|
onFocus={onPrefetch}
|
|
disabled={action.disabled}
|
|
// A disabled <button> is not a hit-test target, so the tooltip's wrapping
|
|
// span would never see pointerenter and the explanation would never show.
|
|
className={cn(action.tooltip && action.disabled && 'pointer-events-none')}
|
|
>
|
|
{action.textTone === 'error' ? (
|
|
<span className='text-[var(--text-error)]'>{action.text}</span>
|
|
) : (
|
|
action.text
|
|
)}
|
|
</Chip>
|
|
)
|
|
if (!action.tooltip) return chip
|
|
return (
|
|
<Tooltip.Root>
|
|
<Tooltip.Trigger asChild>
|
|
<span className='inline-flex'>{chip}</span>
|
|
</Tooltip.Trigger>
|
|
<Tooltip.Content>{action.tooltip}</Tooltip.Content>
|
|
</Tooltip.Root>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Renders a {@link SettingsAction} list as chips using each action's own
|
|
* handlers. This is the data path for headers that take a `ReactNode`
|
|
* (`CredentialDetailLayout`) — reach for it instead of hand-rolling `Chip`s, so
|
|
* those surfaces keep the same chrome as the settings shell. The shell itself
|
|
* maps through {@link SettingsActionChip} directly, since it must route every
|
|
* handler through a ref to avoid stale closures.
|
|
*/
|
|
export function SettingsActionChips({ actions }: { actions: SettingsAction[] }) {
|
|
return (
|
|
<>
|
|
{orderHeaderActions(actions).map(({ action }) => (
|
|
<SettingsActionChip key={action.id ?? action.text} action={action} />
|
|
))}
|
|
</>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Every header reads left→right as
|
|
* `[secondary actions] → [Delete] → [Discard] → [Save]`.
|
|
*
|
|
* Delete is placed by its `id`, not by where the caller happened to put it, so a
|
|
* page with no primary action still can't leave a destructive chip in the slot a
|
|
* primary would occupy.
|
|
*
|
|
* The shell enforces it rather than trusting callsites, because the natural way
|
|
* to write the array — spreading {@link saveDiscardActions} first, then adding a
|
|
* Delete — produces the opposite order and puts a destructive chip to the right
|
|
* of the primary one. Ranking is stable, so an action's position within its own
|
|
* band is still the caller's to choose.
|
|
*
|
|
* Pairs each action with its ORIGINAL index: the shell dereferences
|
|
* `actions[index]` on a live ref to dodge stale closures, so a reordered render
|
|
* must not renumber them.
|
|
*/
|
|
export function orderHeaderActions(
|
|
actions: SettingsAction[] | undefined
|
|
): { action: SettingsAction; index: number }[] {
|
|
const rank = (action: SettingsAction) => {
|
|
if (action.variant === 'primary') return 3
|
|
if (action.id === 'discard') return 2
|
|
if (action.id === 'delete') return 1
|
|
return 0
|
|
}
|
|
return (actions ?? [])
|
|
.map((action, index) => ({ action, index }))
|
|
.sort((a, b) => rank(a.action) - rank(b.action))
|
|
}
|
|
|
|
export function SettingsHeaderShell({ children }: { children: ReactNode }) {
|
|
const read = useContext(ReadContext)
|
|
const configRef = read?.configRef
|
|
const config = configRef?.current ?? EMPTY_CONFIG
|
|
const { title, description, docsLink, back, actions, search, scrollContainerRef } = config
|
|
|
|
return (
|
|
<div className='flex h-full flex-col bg-[var(--bg)]'>
|
|
<div
|
|
className={cn(
|
|
PAGE_HEADER_BAR,
|
|
'justify-between',
|
|
!back && COLLAPSED_DESKTOP_TOP_LEVEL_INSET
|
|
)}
|
|
>
|
|
{back ? (
|
|
<Chip leftIcon={back.icon} onClick={() => configRef?.current.back?.onSelect()}>
|
|
{back.text}
|
|
</Chip>
|
|
) : (
|
|
<div />
|
|
)}
|
|
<div className={HEADER_ACTION_CLUSTER}>
|
|
{docsLink && (
|
|
<ChipLink href={docsLink} target='_blank' rel='noopener noreferrer'>
|
|
Docs
|
|
</ChipLink>
|
|
)}
|
|
{orderHeaderActions(actions).map(({ action, index }) => (
|
|
<SettingsActionChip
|
|
key={action.id ?? action.text}
|
|
action={action}
|
|
onSelect={() => configRef?.current.actions?.[index]?.onSelect()}
|
|
onPrefetch={
|
|
action.onPrefetch
|
|
? () => configRef?.current.actions?.[index]?.onPrefetch?.()
|
|
: undefined
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div
|
|
ref={scrollContainerRef}
|
|
className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'
|
|
>
|
|
<div className='mx-auto flex w-full max-w-[48rem] flex-col gap-7 pb-6'>
|
|
{(title || description) && (
|
|
<div className='flex flex-col gap-1'>
|
|
{title && <h1 className='text-[var(--text-body)] text-lg'>{title}</h1>}
|
|
{description && <p className='text-[var(--text-muted)] text-md'>{description}</p>}
|
|
</div>
|
|
)}
|
|
{search && (
|
|
<ChipInput
|
|
icon={Search}
|
|
placeholder={search.placeholder ?? 'Search...'}
|
|
value={search.value}
|
|
onChange={(event) => configRef?.current.search?.onChange(event.target.value)}
|
|
disabled={search.disabled}
|
|
autoComplete='off'
|
|
className='w-full'
|
|
/>
|
|
)}
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|