Freshness diagnostics API, UI trust notes, mission control/stats updates, and deploy scripts.
Made-with: Cursor
This commit is contained in:
137
frontend/src/components/common/ActivityContextPanel.tsx
Normal file
137
frontend/src/components/common/ActivityContextPanel.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import type { ChainActivityContext } from '@/utils/activityContext'
|
||||
import { formatRelativeAge, formatTimestamp } from '@/utils/format'
|
||||
import { Explain, useUiMode } from './UiModeContext'
|
||||
|
||||
function resolveTone(state: ChainActivityContext['state']): 'success' | 'warning' | 'neutral' {
|
||||
switch (state) {
|
||||
case 'active':
|
||||
return 'success'
|
||||
case 'low':
|
||||
case 'inactive':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLabel(state: ChainActivityContext['state']): string {
|
||||
switch (state) {
|
||||
case 'active':
|
||||
return 'active'
|
||||
case 'low':
|
||||
return 'low activity'
|
||||
case 'inactive':
|
||||
return 'inactive'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
function renderHeadline(context: ChainActivityContext): string {
|
||||
if (context.transaction_visibility_unavailable) {
|
||||
return 'Transaction index freshness is currently unavailable, while chain-head visibility remains live.'
|
||||
}
|
||||
if (context.state === 'unknown') {
|
||||
return 'Recent activity context is temporarily unavailable.'
|
||||
}
|
||||
if (context.state === 'active') {
|
||||
return 'Recent transactions are close to the visible chain tip.'
|
||||
}
|
||||
if (context.head_is_idle) {
|
||||
return 'The chain head is advancing, but the latest visible transaction is older than the current tip.'
|
||||
}
|
||||
return 'Recent transaction activity is sparse right now.'
|
||||
}
|
||||
|
||||
export default function ActivityContextPanel({
|
||||
context,
|
||||
title = 'Chain Activity Context',
|
||||
}: {
|
||||
context: ChainActivityContext
|
||||
title?: string
|
||||
}) {
|
||||
const { mode } = useUiMode()
|
||||
const tone = resolveTone(context.state)
|
||||
const dualTimelineLabel =
|
||||
context.latest_block_timestamp && context.latest_transaction_timestamp
|
||||
? `${formatRelativeAge(context.latest_block_timestamp)} head · ${formatRelativeAge(context.latest_transaction_timestamp)} latest tx`
|
||||
: 'Dual timeline unavailable'
|
||||
|
||||
return (
|
||||
<Card className="border border-sky-200 bg-sky-50/60 dark:border-sky-900/40 dark:bg-sky-950/20" title={title}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">{renderHeadline(context)}</div>
|
||||
<Explain>
|
||||
<p className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Use the transaction tip and last non-empty block below to distinguish a quiet chain from a broken explorer.
|
||||
</p>
|
||||
</Explain>
|
||||
</div>
|
||||
<EntityBadge label={resolveLabel(context.state)} tone={tone} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl border border-white/50 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Latest Block</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{context.latest_block_number != null ? `#${context.latest_block_number}` : 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatRelativeAge(context.latest_block_timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/50 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Latest Transaction</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{context.latest_transaction_block_number != null ? `#${context.latest_transaction_block_number}` : 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatRelativeAge(context.latest_transaction_timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/50 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Last Non-Empty Block</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{context.last_non_empty_block_number != null ? `#${context.last_non_empty_block_number}` : 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatRelativeAge(context.last_non_empty_block_timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/50 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Block Gap</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{context.block_gap_to_latest_transaction != null ? context.block_gap_to_latest_transaction.toLocaleString() : 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{mode === 'guided'
|
||||
? 'Difference between the current tip and the latest visible transaction block.'
|
||||
: dualTimelineLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{context.latest_transaction_block_number != null ? (
|
||||
<Link href={`/blocks/${context.latest_transaction_block_number}`} className="text-primary-600 hover:underline">
|
||||
Open latest transaction block →
|
||||
</Link>
|
||||
) : null}
|
||||
{context.last_non_empty_block_number != null ? (
|
||||
<Link href={`/blocks/${context.last_non_empty_block_number}`} className="text-primary-600 hover:underline">
|
||||
Open last non-empty block →
|
||||
</Link>
|
||||
) : null}
|
||||
{context.latest_transaction_timestamp ? (
|
||||
<span>Latest visible transaction time: {formatTimestamp(context.latest_transaction_timestamp)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
27
frontend/src/components/common/BrandLockup.tsx
Normal file
27
frontend/src/components/common/BrandLockup.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import BrandMark from './BrandMark'
|
||||
|
||||
export default function BrandLockup({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<BrandMark size={compact ? 'compact' : 'default'} />
|
||||
<span className="min-w-0">
|
||||
<span
|
||||
className={[
|
||||
'block truncate font-semibold tracking-[-0.02em] text-gray-950 dark:text-white',
|
||||
compact ? 'text-[1.45rem]' : 'text-[1.65rem]',
|
||||
].join(' ')}
|
||||
>
|
||||
SolaceScan
|
||||
</span>
|
||||
<span
|
||||
className={[
|
||||
'block truncate font-medium uppercase text-gray-500 dark:text-gray-400',
|
||||
compact ? 'text-[0.72rem] tracking-[0.14em]' : 'text-[0.8rem] tracking-[0.12em]',
|
||||
].join(' ')}
|
||||
>
|
||||
Chain 138 Explorer by DBIS
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
45
frontend/src/components/common/BrandMark.tsx
Normal file
45
frontend/src/components/common/BrandMark.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
export default function BrandMark({ size = 'default' }: { size?: 'default' | 'compact' }) {
|
||||
const containerClassName =
|
||||
size === 'compact'
|
||||
? 'h-10 w-10 rounded-xl'
|
||||
: 'h-11 w-11 rounded-2xl'
|
||||
const iconClassName = size === 'compact' ? 'h-6 w-6' : 'h-7 w-7'
|
||||
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
'relative inline-flex shrink-0 items-center justify-center border border-primary-200/70 bg-white text-primary-600 shadow-[0_10px_30px_rgba(37,99,235,0.10)] transition-transform group-hover:-translate-y-0.5 dark:border-primary-500/20 dark:bg-gray-900 dark:text-primary-400',
|
||||
containerClassName,
|
||||
].join(' ')}
|
||||
>
|
||||
<svg className={iconClassName} viewBox="0 0 32 32" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M16 4.75 7.5 9.2v9.55L16 23.2l8.5-4.45V9.2L16 4.75Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
<path
|
||||
d="m7.75 9.45 8.25 4.3 8.25-4.3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path d="M16 13.9v9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path
|
||||
d="M22.75 6.8c2.35 1.55 3.9 4.2 3.9 7.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
opacity=".9"
|
||||
/>
|
||||
<path
|
||||
d="M9.35 6.8c-2.3 1.55-3.85 4.2-3.85 7.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
opacity=".65"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -2,22 +2,25 @@ import type { ReactNode } from 'react'
|
||||
import Navbar from './Navbar'
|
||||
import Footer from './Footer'
|
||||
import ExplorerAgentTool from './ExplorerAgentTool'
|
||||
import { UiModeProvider } from './UiModeContext'
|
||||
|
||||
export default function ExplorerChrome({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-md focus:bg-primary-600 focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-white"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<Navbar />
|
||||
<div id="main-content" className="flex-1">
|
||||
{children}
|
||||
<UiModeProvider>
|
||||
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-md focus:bg-primary-600 focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-white"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<Navbar />
|
||||
<div id="main-content" className="flex-1">
|
||||
{children}
|
||||
</div>
|
||||
<ExplorerAgentTool />
|
||||
<Footer />
|
||||
</div>
|
||||
<ExplorerAgentTool />
|
||||
<Footer />
|
||||
</div>
|
||||
</UiModeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
85
frontend/src/components/common/FreshnessTrustNote.tsx
Normal file
85
frontend/src/components/common/FreshnessTrustNote.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import type { ExplorerStats } from '@/services/api/stats'
|
||||
import type { ChainActivityContext } from '@/utils/activityContext'
|
||||
import {
|
||||
resolveFreshnessSourceLabel,
|
||||
summarizeFreshnessConfidence,
|
||||
} from '@/utils/explorerFreshness'
|
||||
import { formatRelativeAge } from '@/utils/format'
|
||||
|
||||
function buildSummary(context: ChainActivityContext) {
|
||||
if (context.transaction_visibility_unavailable) {
|
||||
return 'Chain-head visibility is current, while transaction freshness is currently unavailable.'
|
||||
}
|
||||
|
||||
if (context.state === 'active') {
|
||||
return 'Chain head and latest indexed transactions are closely aligned.'
|
||||
}
|
||||
|
||||
if (context.head_is_idle) {
|
||||
return 'Chain head is current, while latest visible transactions trail the tip.'
|
||||
}
|
||||
|
||||
if (context.state === 'low' || context.state === 'inactive') {
|
||||
return 'Chain head is current, and recent visible transaction activity is sparse.'
|
||||
}
|
||||
|
||||
return 'Freshness context is based on the latest visible public explorer evidence.'
|
||||
}
|
||||
|
||||
function buildDetail(context: ChainActivityContext) {
|
||||
if (context.transaction_visibility_unavailable) {
|
||||
return 'Use chain-head visibility and the last non-empty block as the current trust anchors.'
|
||||
}
|
||||
|
||||
const latestTxAge = formatRelativeAge(context.latest_transaction_timestamp)
|
||||
const latestNonEmptyBlock =
|
||||
context.last_non_empty_block_number != null ? `#${context.last_non_empty_block_number.toLocaleString()}` : 'unknown'
|
||||
|
||||
if (context.head_is_idle) {
|
||||
return `Latest visible transaction: ${latestTxAge}. Last non-empty block: ${latestNonEmptyBlock}.`
|
||||
}
|
||||
|
||||
if (context.state === 'active') {
|
||||
return `Latest visible transaction: ${latestTxAge}. Recent indexed activity remains close to the tip.`
|
||||
}
|
||||
|
||||
return `Latest visible transaction: ${latestTxAge}. Recent head blocks may be quiet even while the chain remains current.`
|
||||
}
|
||||
|
||||
export default function FreshnessTrustNote({
|
||||
context,
|
||||
stats,
|
||||
bridgeStatus,
|
||||
scopeLabel,
|
||||
className = '',
|
||||
}: {
|
||||
context: ChainActivityContext
|
||||
stats?: ExplorerStats | null
|
||||
bridgeStatus?: MissionControlBridgeStatusResponse | null
|
||||
scopeLabel?: string
|
||||
className?: string
|
||||
}) {
|
||||
const sourceLabel = resolveFreshnessSourceLabel(stats, bridgeStatus)
|
||||
const confidenceBadges = summarizeFreshnessConfidence(stats, bridgeStatus)
|
||||
const normalizedClassName = className ? ` ${className}` : ''
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border border-gray-200 bg-white/80 px-4 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/40${normalizedClassName}`}>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{buildSummary(context)}</div>
|
||||
<div className="mt-1 text-gray-600 dark:text-gray-400">
|
||||
{buildDetail(context)} {scopeLabel ? `${scopeLabel}. ` : ''}{sourceLabel}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{confidenceBadges.map((badge) => (
|
||||
<span
|
||||
key={badge}
|
||||
className="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 dark:border-gray-700 dark:bg-gray-900/70"
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
202
frontend/src/components/common/HeaderCommandPalette.tsx
Normal file
202
frontend/src/components/common/HeaderCommandPalette.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Explain, useUiMode } from './UiModeContext'
|
||||
|
||||
export type HeaderCommandItem = {
|
||||
href?: string
|
||||
label: string
|
||||
description?: string
|
||||
section: string
|
||||
keywords?: string[]
|
||||
onSelect?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
function SearchIcon({ className = 'h-4 w-4' }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.9} d="m21 21-4.35-4.35" />
|
||||
<circle cx="11" cy="11" r="6.5" strokeWidth={1.9} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function matchItem(item: HeaderCommandItem, query: string) {
|
||||
const haystack = `${item.label} ${item.description || ''} ${item.section} ${(item.keywords || []).join(' ')}`.toLowerCase()
|
||||
return haystack.includes(query.toLowerCase())
|
||||
}
|
||||
|
||||
export default function HeaderCommandPalette({
|
||||
open,
|
||||
onClose,
|
||||
items,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
items: HeaderCommandItem[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const { mode } = useUiMode()
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const itemRefs = useRef<Array<HTMLButtonElement | null>>([])
|
||||
const [query, setQuery] = useState('')
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const matches = query.trim()
|
||||
? items.filter((item) => matchItem(item, query))
|
||||
: items
|
||||
|
||||
return [
|
||||
{
|
||||
href: `/search${query.trim() ? `?q=${encodeURIComponent(query.trim())}` : ''}`,
|
||||
label: query.trim() ? `Search for “${query.trim()}”` : 'Open full explorer search',
|
||||
description: query.trim()
|
||||
? 'Jump to the full search surface with the current query.'
|
||||
: 'Open the full search page and browse the explorer index.',
|
||||
section: 'Search',
|
||||
keywords: ['query', 'find', 'lookup'],
|
||||
},
|
||||
...matches,
|
||||
]
|
||||
}, [items, query])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery('')
|
||||
setActiveIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onClose, open])
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0)
|
||||
}, [query])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
itemRefs.current[activeIndex]?.scrollIntoView({ block: 'nearest' })
|
||||
}, [activeIndex, open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const handleSelect = async (item: HeaderCommandItem) => {
|
||||
onClose()
|
||||
if (item.onSelect) {
|
||||
await item.onSelect()
|
||||
return
|
||||
}
|
||||
if (item.href) {
|
||||
router.push(item.href)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-start justify-center bg-gray-950/45 px-4 py-20 backdrop-blur-sm">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Explorer command palette"
|
||||
className="w-full max-w-2xl overflow-hidden rounded-3xl border border-gray-200 bg-white shadow-[0_30px_100px_rgba(15,23,42,0.32)] dark:border-gray-700 dark:bg-gray-950"
|
||||
>
|
||||
<div className="border-b border-gray-200 px-5 py-4 dark:border-gray-800">
|
||||
<label htmlFor="header-command-search" className="sr-only">
|
||||
Search explorer destinations
|
||||
</label>
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900">
|
||||
<SearchIcon className="h-5 w-5 text-gray-500 dark:text-gray-400" />
|
||||
<input
|
||||
id="header-command-search"
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setActiveIndex((index) => Math.min(index + 1, filteredItems.length - 1))
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setActiveIndex((index) => Math.max(index - 1, 0))
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const activeItem = filteredItems[activeIndex]
|
||||
if (activeItem) void handleSelect(activeItem)
|
||||
}
|
||||
}}
|
||||
placeholder={mode === 'expert' ? 'Search tx / addr / block / tool' : 'Search pages, tools, tokens, and routes'}
|
||||
className="w-full border-0 bg-transparent text-sm text-gray-900 placeholder:text-gray-500 focus:outline-none dark:text-white dark:placeholder:text-gray-400"
|
||||
/>
|
||||
<kbd className="rounded-lg border border-gray-200 px-2 py-1 text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
||||
Esc
|
||||
</kbd>
|
||||
</div>
|
||||
<Explain>
|
||||
<p className="mt-3 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
Search destinations and run high-frequency header actions from one keyboard-first surface.
|
||||
</p>
|
||||
</Explain>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto p-3">
|
||||
<div className="grid gap-1.5">
|
||||
{filteredItems.map((item, index) => (
|
||||
<button
|
||||
key={`${item.section}-${item.label}-${item.href || item.label}`}
|
||||
ref={(node) => {
|
||||
itemRefs.current[index] = node
|
||||
}}
|
||||
type="button"
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => void handleSelect(item)}
|
||||
className={[
|
||||
'flex w-full items-start gap-3 rounded-2xl px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
activeIndex === index
|
||||
? 'bg-primary-50 text-primary-900 dark:bg-primary-500/10 dark:text-primary-100'
|
||||
: 'bg-white text-gray-800 hover:bg-gray-100 dark:bg-gray-950 dark:text-gray-100 dark:hover:bg-gray-900',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="mt-0.5 inline-flex rounded-lg border border-gray-200 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
||||
{item.section}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-semibold">{item.label}</span>
|
||||
{mode === 'guided' && item.description ? (
|
||||
<span className="mt-0.5 block text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
{item.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 px-5 py-3 text-[11px] uppercase tracking-[0.16em] text-gray-500 dark:border-gray-800 dark:text-gray-400">
|
||||
{mode === 'expert' ? 'Keyboard-first ' : 'Use '}
|
||||
<kbd className="rounded border border-gray-200 px-1.5 py-0.5 font-medium dark:border-gray-700">/</kbd> or{' '}
|
||||
<kbd className="rounded border border-gray-200 px-1.5 py-0.5 font-medium dark:border-gray-700">Ctrl/Cmd + K</kbd>{' '}
|
||||
{mode === 'expert' ? 'to reopen.' : 'to reopen this palette.'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close command palette"
|
||||
className="fixed inset-0 -z-10 cursor-default"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
57
frontend/src/components/common/UiModeContext.tsx
Normal file
57
frontend/src/components/common/UiModeContext.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
export type UiMode = 'guided' | 'expert'
|
||||
|
||||
const UI_MODE_STORAGE_KEY = 'explorer_ui_mode'
|
||||
|
||||
const UiModeContext = createContext<{
|
||||
mode: UiMode
|
||||
setMode: (mode: UiMode) => void
|
||||
toggleMode: () => void
|
||||
} | null>(null)
|
||||
|
||||
export function UiModeProvider({ children }: { children: ReactNode }) {
|
||||
const [mode, setModeState] = useState<UiMode>('guided')
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const stored = window.localStorage.getItem(UI_MODE_STORAGE_KEY)
|
||||
if (stored === 'guided' || stored === 'expert') {
|
||||
setModeState(stored)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setMode = (nextMode: UiMode) => {
|
||||
setModeState(nextMode)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(UI_MODE_STORAGE_KEY, nextMode)
|
||||
}
|
||||
}
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
mode,
|
||||
setMode,
|
||||
toggleMode: () => setMode(mode === 'guided' ? 'expert' : 'guided'),
|
||||
}),
|
||||
[mode],
|
||||
)
|
||||
|
||||
return <UiModeContext.Provider value={value}>{children}</UiModeContext.Provider>
|
||||
}
|
||||
|
||||
export function useUiMode() {
|
||||
const context = useContext(UiModeContext)
|
||||
if (!context) {
|
||||
throw new Error('useUiMode must be used within a UiModeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function Explain({ children }: { children: ReactNode }) {
|
||||
const { mode } = useUiMode()
|
||||
if (mode === 'expert') return null
|
||||
return <>{children}</>
|
||||
}
|
||||
Reference in New Issue
Block a user