Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 66 additions & 31 deletions packages/web/src/AppFragment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { parseFlowYaml } from '@openhop/shared'
import { FlowCanvas } from './components/FlowCanvas'
import { DataInspectionPanel, BookmarkTab, type DockSide } from './components/DataInspectionPanel'
import { FlowEditorModal } from './components/FlowEditorModal'
import { Sidebar } from './components/Sidebar'
import { buildStarterYaml } from './lib/starter-yaml'
import { buildShareUrl, decodeFragment, encodeFragment } from './lib/share-url'
import { EXAMPLE_FLOWS } from './lib/example-flows'
import type { FlowListItem } from './hooks/useFlowPolling'
import type { FlowNode, FlowStep, FlowData, Flow } from './types'

interface FlowNavItem {
Expand All @@ -16,16 +18,29 @@ interface FlowNavItem {
resumeFromStep?: number
}

// Static FlowListItem[] derived from EXAMPLE_FLOWS so the same Sidebar
// component the local app uses can render examples on Pages. version
// and updatedAt are placeholders — Pages mode never re-fetches, and
// the Sidebar only reads them for the local-app's update indicator.
const EXAMPLE_FLOW_LIST: FlowListItem[] = EXAMPLE_FLOWS.map((ex) => ({
id: ex.id,
title: ex.title,
description: ex.description,
path: ex.path,
version: 0,
updatedAt: '',
}))

/**
* Fragment-mode app shell — used by the GitHub Pages deploy. There is no API
* server, so:
* - The flow is decoded from `location.hash` (lz-compressed YAML).
* - "Save" doesn't POST anywhere; it builds a share URL and copies it to
* the clipboard.
* - There's no sidebar / flow list — Pages serves one flow per URL.
* - The left sidebar is read-only and lists the bundled example flows;
* clicking one swaps `location.hash` to its encoded YAML.
*
* The empty state ("no fragment, no flow") shows a "+ New flow" CTA. Invalid
* fragments surface as a banner with an empty editor below.
* Invalid fragments surface as a banner with an empty editor below.
*/
export default function AppFragment() {
// Re-decode whenever the hash changes (deep-link, browser back, paste).
Expand Down Expand Up @@ -170,6 +185,28 @@ export default function AppFragment() {
const [inspectorOpen, setInspectorOpen] = useState(true)
const [inspectorSide, setInspectorSide] = useState<DockSide>('right')
const [inspectorSize, setInspectorSize] = useState(320)
const [sidebarOpen, setSidebarOpen] = useState(true)

// Match the current hash against each example's encoded form so the
// sidebar can highlight the active example. Stable across re-renders
// because EXAMPLE_FLOWS is a module-level const.
const selectedExampleId = useMemo<string | null>(() => {
if (!hash) return null
for (const ex of EXAMPLE_FLOWS) {
if (encodeFragment(ex.yaml) === hash) return ex.id
}
return null
}, [hash])

const handleSelectExample = useCallback((id: string | null) => {
if (id === null) {
window.location.hash = ''
return
}
const ex = EXAMPLE_FLOWS.find((e) => e.id === id)
if (!ex) return
window.location.hash = encodeFragment(ex.yaml)
}, [])
useEffect(() => {
setInspectedStep(null)
setInspectedFocus(null)
Expand Down Expand Up @@ -312,12 +349,32 @@ export default function AppFragment() {
)}

<div className="flex flex-1 min-h-0">
{/* Examples sidebar — read-only on Pages (no create / edit / delete);
clicking an entry swaps location.hash to that example's encoded
YAML so the existing decode pipeline takes over. */}
{sidebarOpen && (
<Sidebar
flows={EXAMPLE_FLOW_LIST}
loading={false}
selectedFlowId={selectedExampleId}
onSelectFlow={handleSelectExample}
/>
)}

<div
className={`flex-1 min-w-0 min-h-0 flex ${inspectorSide === 'right' ? 'flex-row' : 'flex-col'}`}
>
<main className="flex-1 min-w-0 min-h-0 relative" style={{ background: '#0a1f0e' }}>
{/* Inspector bookmark tab — fragment mode has no sidebar, so
only the right tab is rendered. */}
{/* FLOWS bookmark tab on the left edge — same UX as the local
app. Always rendered (even on the empty state) so the sidebar
can be toggled. */}
<BookmarkTab
edge="left"
open={sidebarOpen}
onToggle={() => setSidebarOpen((o) => !o)}
label="FLOWS"
ariaLabel={sidebarOpen ? 'Collapse flows' : 'Expand flows'}
/>
{decodedFlow && (
<BookmarkTab
edge="right"
Expand All @@ -329,36 +386,14 @@ export default function AppFragment() {
)}
{!decodedFlow ? (
<div className="w-full h-full flex items-center justify-center">
<div className="text-center max-w-lg px-6">
<div className="text-center max-w-md px-6">
<p className="font-pixel text-text/60 mb-2" style={{ fontSize: 14 }}>
{decodeError ? 'No flow loaded' : 'No flow shared'}
</p>
<p className="font-terminal text-text/40 text-sm mb-6">
Open a flow by visiting a share URL, click "+ New flow" above, or pick a
pre-built example:
<p className="font-terminal text-text/40 text-sm">
Pick an example from the sidebar, paste a share URL, or click "+ New flow"
above.
</p>
<ul className="flex flex-col gap-2 text-left">
{EXAMPLE_FLOWS.map((ex) => (
<li key={ex.id}>
<button
onClick={() => {
// Setting hash to the encoded YAML triggers the
// existing decode → render path. The ?example=<id>
// is purely cosmetic so the URL bar reads
// recognizably; the example loads from the hash.
window.location.hash = encodeFragment(ex.yaml)
}}
className="w-full text-left px-3 py-2 border border-border hover:border-accent hover:text-accent transition-colors font-terminal text-sm"
style={{ background: '#0d2612', color: '#7fffaa' }}
>
<div className="font-pixel mb-1" style={{ fontSize: 11 }}>
{ex.title}
</div>
<div className="text-text/50 text-xs">{ex.description}</div>
</button>
</li>
))}
</ul>
</div>
</div>
) : displayFlow ? (
Expand Down
10 changes: 9 additions & 1 deletion packages/web/src/components/DataPixel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,12 +183,20 @@ export function DataPixel({
// Pass the carrot's specific data slice. FlowCanvas adds the from/to
// context; we don't pass `step` here because for parallel sub-steps
// it'd be the sub-step (not the parent the inspect panel needs).
//
// For string-data steps we deliberately pass `undefined` instead of
// synthesizing a `{ label: step.data }` object: the inspector
// highlights via reference equality (`d === focus.data`), and a
// freshly-constructed wrapper would never match the wrapper
// normalizeData() builds on the inspector side. Passing undefined
// makes the highlight fall back to from/to-only matching, which is
// exactly what we want for string data (only one block per section).
const emitPixelClick = () => {
if (!onPixelClick) return
const focusData =
dataOverride ??
(typeof step.data === 'string'
? { label: step.data }
? undefined
: Array.isArray(step.data)
? step.data[0]
: step.data)
Expand Down
7 changes: 7 additions & 0 deletions packages/web/src/lib/example-flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,33 @@ export interface ExampleFlow {
id: string
title: string
description: string
path: string
yaml: string
}

// Path field is what the Sidebar component groups into folders. Each
// example's path mirrors the meta.path in its YAML so a Pages visitor
// sees the same directory tree the local app would display.
export const EXAMPLE_FLOWS: ExampleFlow[] = [
{
id: 'simple-crud',
title: 'Simple CRUD',
description: 'Basic REST API CRUD — the smallest useful flow.',
path: 'examples/crud',
yaml: simpleCrud,
},
{
id: 'auth-flow',
title: 'OAuth2 Login',
description: 'Browser → app → Google OAuth → DB + cache.',
path: 'examples/auth',
yaml: authFlow,
},
{
id: 'order-flow',
title: 'Order Processing',
description: 'Multi-service order pipeline with payment, audit, and retry.',
path: 'e-commerce/orders',
yaml: orderFlow,
},
]
Loading