feat: better tool parsing ui
This commit is contained in:
@@ -15,33 +15,34 @@ import {
|
||||
import { $previewTarget } from '@/store/preview'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
|
||||
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
|
||||
import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
|
||||
|
||||
interface AppShellProps {
|
||||
commandCenterOpen: boolean
|
||||
children: ReactNode
|
||||
inspectorWidth: string
|
||||
leftTitlebarTools?: readonly TitlebarTool[]
|
||||
leftStatusbarItems?: readonly StatusbarItem[]
|
||||
previewWidth: string
|
||||
rightRailOpen: boolean
|
||||
sidebar: ReactNode
|
||||
statusbarItems?: readonly StatusbarItem[]
|
||||
titlebarTools?: readonly TitlebarTool[]
|
||||
onToggleCommandCenter: () => void
|
||||
onOpenSettings: () => void
|
||||
overlays?: ReactNode
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
commandCenterOpen,
|
||||
children,
|
||||
inspectorWidth,
|
||||
leftTitlebarTools,
|
||||
leftStatusbarItems,
|
||||
previewWidth,
|
||||
rightRailOpen,
|
||||
sidebar,
|
||||
statusbarItems,
|
||||
titlebarTools,
|
||||
onToggleCommandCenter,
|
||||
onOpenSettings,
|
||||
overlays
|
||||
}: AppShellProps) {
|
||||
@@ -135,10 +136,8 @@ export function AppShell({
|
||||
}
|
||||
>
|
||||
<TitlebarControls
|
||||
commandCenterOpen={commandCenterOpen}
|
||||
leftTools={leftTitlebarTools}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onToggleCommandCenter={onToggleCommandCenter}
|
||||
showInspectorToggle={rightRailOpen}
|
||||
tools={titlebarTools}
|
||||
/>
|
||||
@@ -149,7 +148,8 @@ export function AppShell({
|
||||
{
|
||||
'--inspector-col': inspectorColumn,
|
||||
'--preview-col': previewColumn,
|
||||
gridTemplateColumns: shellGridColumns
|
||||
gridTemplateColumns: shellGridColumns,
|
||||
gridTemplateRows: 'minmax(0,1fr) auto'
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
@@ -162,7 +162,7 @@ export function AppShell({
|
||||
className="pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)] [-webkit-app-region:drag]"
|
||||
/>
|
||||
|
||||
{sidebar}
|
||||
<div className="col-start-1 col-end-2 row-start-1 min-h-0 overflow-hidden">{sidebar}</div>
|
||||
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
@@ -178,6 +178,8 @@ export function AppShell({
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
|
||||
</main>
|
||||
|
||||
{overlays}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface StatusbarMenuItem {
|
||||
id: string
|
||||
icon?: ReactNode
|
||||
label: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
hidden?: boolean
|
||||
href?: string
|
||||
onSelect?: () => void
|
||||
title?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
export interface StatusbarItem {
|
||||
id: string
|
||||
label?: ReactNode
|
||||
detail?: ReactNode
|
||||
icon?: ReactNode
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
hidden?: boolean
|
||||
href?: string
|
||||
menuClassName?: string
|
||||
menuItems?: readonly StatusbarMenuItem[]
|
||||
onSelect?: () => void
|
||||
title?: string
|
||||
to?: string
|
||||
variant?: 'action' | 'link' | 'menu' | 'text'
|
||||
}
|
||||
|
||||
export type StatusbarItemSide = 'left' | 'right'
|
||||
export type SetStatusbarItemGroup = (id: string, items: readonly StatusbarItem[], side?: StatusbarItemSide) => void
|
||||
|
||||
interface StatusbarControlsProps extends ComponentProps<'footer'> {
|
||||
leftItems?: readonly StatusbarItem[]
|
||||
items?: readonly StatusbarItem[]
|
||||
}
|
||||
|
||||
const statusbarItemClass =
|
||||
'inline-flex h-6 items-center gap-1.5 rounded-md px-2 text-[0.69rem] text-muted-foreground/95 transition-colors hover:bg-accent/55 hover:text-foreground disabled:cursor-default disabled:opacity-45'
|
||||
|
||||
export function StatusbarControls({ className, leftItems = [], items = [], ...props }: StatusbarControlsProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
'col-span-4 row-start-2 row-end-3 flex h-7 items-center justify-between gap-2 border-t border-border/55 bg-[color-mix(in_srgb,var(--dt-muted)_45%,var(--dt-card))] px-2.5 py-1 text-muted-foreground/95 [-webkit-app-region:no-drag]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
|
||||
{leftItems.filter(item => !item.hidden).map(item => (
|
||||
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
|
||||
{items.filter(item => !item.hidden).map(item => (
|
||||
<StatusbarItemView item={item} key={`right:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusbarItemView({
|
||||
item,
|
||||
navigate
|
||||
}: {
|
||||
item: StatusbarItem
|
||||
navigate: ReturnType<typeof useNavigate>
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
{item.icon}
|
||||
{item.label && <span className="truncate">{item.label}</span>}
|
||||
{item.detail && <span className="truncate text-muted-foreground/80">{item.detail}</span>}
|
||||
</>
|
||||
)
|
||||
|
||||
const title = item.title ?? (typeof item.label === 'string' ? item.label : undefined)
|
||||
|
||||
if (item.variant === 'menu' && item.menuItems && item.menuItems.length > 0) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(statusbarItemClass, item.className)}
|
||||
disabled={item.disabled}
|
||||
title={title}
|
||||
type="button"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className={cn('w-56', item.menuClassName)} side="top" sideOffset={8}>
|
||||
{item.menuItems
|
||||
.filter(menuItem => !menuItem.hidden)
|
||||
.map(menuItem => (
|
||||
<DropdownMenuItem
|
||||
className={cn('gap-2 text-foreground focus:bg-accent [&_svg]:size-4', menuItem.className)}
|
||||
disabled={menuItem.disabled}
|
||||
key={menuItem.id}
|
||||
onSelect={() => {
|
||||
if (menuItem.to) {
|
||||
navigate(menuItem.to)
|
||||
}
|
||||
|
||||
menuItem.onSelect?.()
|
||||
}}
|
||||
>
|
||||
{menuItem.href ? (
|
||||
<a
|
||||
className="inline-flex w-full items-center gap-2"
|
||||
href={menuItem.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={menuItem.title ?? menuItem.label}
|
||||
>
|
||||
{menuItem.icon}
|
||||
<span className="truncate">{menuItem.label}</span>
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
{menuItem.icon}
|
||||
<span className="truncate">{menuItem.label}</span>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.variant === 'text' && !item.onSelect && !item.to && !item.href) {
|
||||
return (
|
||||
<div className={cn('inline-flex items-center gap-1.5 px-1 text-[0.69rem] text-muted-foreground/90', item.className)}>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.href || item.variant === 'link') {
|
||||
return (
|
||||
<a
|
||||
className={cn(statusbarItemClass, item.className)}
|
||||
href={item.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={title}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(statusbarItemClass, item.className)}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
if (item.to) {
|
||||
navigate(item.to)
|
||||
}
|
||||
|
||||
item.onSelect?.()
|
||||
}}
|
||||
title={title}
|
||||
type="button"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -3,12 +3,12 @@ import type { ComponentProps, ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Command, NotebookTabs, Settings, SlidersHorizontal, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { NotebookTabs, Settings, SlidersHorizontal, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
|
||||
import { $inspectorOpen, $sidebarOpen, toggleInspectorOpen, toggleSidebarOpen } from '@/store/layout'
|
||||
|
||||
import { TITLEBAR_ICON_SIZE, titlebarButtonClass } from './titlebar'
|
||||
import { titlebarButtonClass } from './titlebar'
|
||||
|
||||
export interface TitlebarTool {
|
||||
id: string
|
||||
@@ -28,20 +28,16 @@ export type TitlebarToolSide = 'left' | 'right'
|
||||
export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[], side?: TitlebarToolSide) => void
|
||||
|
||||
interface TitlebarControlsProps extends ComponentProps<'div'> {
|
||||
commandCenterOpen: boolean
|
||||
leftTools?: readonly TitlebarTool[]
|
||||
showInspectorToggle: boolean
|
||||
tools?: readonly TitlebarTool[]
|
||||
onToggleCommandCenter: () => void
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
export function TitlebarControls({
|
||||
commandCenterOpen,
|
||||
leftTools = [],
|
||||
showInspectorToggle,
|
||||
tools = [],
|
||||
onToggleCommandCenter,
|
||||
onOpenSettings
|
||||
}: TitlebarControlsProps) {
|
||||
const navigate = useNavigate()
|
||||
@@ -71,17 +67,6 @@ export function TitlebarControls({
|
||||
toggleSidebarOpen()
|
||||
}
|
||||
},
|
||||
{
|
||||
active: commandCenterOpen,
|
||||
icon: <Command size={TITLEBAR_ICON_SIZE} />,
|
||||
id: 'command-center',
|
||||
label: commandCenterOpen ? 'Close command center' : 'Open command center',
|
||||
title: commandCenterOpen ? 'Close command center' : 'Open command center',
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
onToggleCommandCenter()
|
||||
}
|
||||
},
|
||||
...leftTools
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
type Side = 'left' | 'right'
|
||||
type Groups<T> = Record<Side, Record<string, readonly T[]>>
|
||||
|
||||
export type GroupSetter<T> = (id: string, items: readonly T[], side?: Side) => void
|
||||
|
||||
interface GroupRegistry<T> {
|
||||
flat: { left: T[]; right: T[] }
|
||||
set: GroupSetter<T>
|
||||
}
|
||||
|
||||
export function useGroupRegistry<T>(): GroupRegistry<T> {
|
||||
const [groups, setGroups] = useState<Groups<T>>({ left: {}, right: {} })
|
||||
|
||||
const set = useCallback<GroupSetter<T>>((id, items, side = 'right') => {
|
||||
setGroups(current => {
|
||||
const next = { ...current, [side]: { ...current[side] } }
|
||||
|
||||
if (items.length === 0) {
|
||||
delete next[side][id]
|
||||
} else {
|
||||
next[side][id] = items
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const flat = useMemo(
|
||||
() => ({
|
||||
left: Object.values(groups.left).flat(),
|
||||
right: Object.values(groups.right).flat()
|
||||
}),
|
||||
[groups]
|
||||
)
|
||||
|
||||
return { flat, set }
|
||||
}
|
||||
Reference in New Issue
Block a user