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
8 changes: 2 additions & 6 deletions src/components/layout/app-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@
import { useEffect } from "react"
import { LeftPane } from "./left-pane"
import { GraphPane } from "@/components/universe/graph-pane"
import { AddContentModal } from "@/components/modals/add-content-modal"
import { AddModal } from "@/components/modals/add-modal"
import { BudgetModal } from "@/components/modals/budget-modal"
import { AddNodeModal } from "@/components/modals/add-node-modal"
import { EditNodeModal } from "@/components/modals/edit-node-modal"
import { AddEdgeModal } from "@/components/modals/add-edge-modal"
import { MediaPlayer } from "@/components/player/media-player"
import { useDefaultLayout } from "react-resizable-panels"
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"
Expand Down Expand Up @@ -53,10 +51,8 @@ export function AppLayout() {
</ResizablePanel>
</ResizablePanelGroup>

<AddContentModal />
<AddNodeModal />
<AddModal />
<EditNodeModal />
<AddEdgeModal />
<BudgetModal />
<MediaPlayer />
</>
Expand Down
4 changes: 2 additions & 2 deletions src/components/layout/my-content-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function MyContentPanel({ onClose }: { onClose: () => void }) {
const { pubKey, isAdmin } = useUserStore()
const myContentRefreshKey = useAppStore((s) => s.myContentRefreshKey)
const schemas = useSchemaStore((s) => s.schemas)
const openModal = useModalStore((s) => s.open)
const openAdd = useModalStore((s) => s.openAdd)
const setHoveredNode = useGraphStore((s) => s.setHoveredNode)
const setSidebarSelectedNode = useGraphStore((s) => s.setSidebarSelectedNode)
const mocksEnabled = isMocksEnabled()
Expand Down Expand Up @@ -379,7 +379,7 @@ export function MyContentPanel({ onClose }: { onClose: () => void }) {
Add content and start earning money for contributing
</p>
<button
onClick={() => openModal("addContent")}
onClick={() => openAdd("source")}
className="mt-1 text-xs font-medium text-primary hover:text-primary/80 transition-colors underline underline-offset-2"
>
Add Content
Expand Down
18 changes: 4 additions & 14 deletions src/components/layout/toolkit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,12 @@ import {
Zap,
Network,
BookMarked,
Tag,
ClipboardList,
Heart,
Menu,
X,
MessageSquare,
Cpu,
GitMerge,
} from "lucide-react"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { useUserStore } from "@/stores/user-store"
Expand Down Expand Up @@ -114,7 +112,7 @@ export function Toolkit({
const router = useRouter()
const { isAdmin, budget } = useUserStore()
const openModal = useModalStore((s) => s.open)
const openAddEdge = useModalStore((s) => s.openAddEdge)
const openAdd = useModalStore((s) => s.openAdd)
const { pendingCount, setPendingCount } = useReviewStore()

useEffect(() => {
Expand Down Expand Up @@ -184,8 +182,7 @@ export function Toolkit({

<Divider />

<ToolkitButton icon={Plus} ariaLabel="Add Content" onClick={() => openModal("addContent")} />
<ToolkitButton icon={Tag} ariaLabel="Add Node" onClick={() => openModal("addNode")} />
<ToolkitButton icon={Plus} ariaLabel="Add to graph" onClick={() => openAdd("source")} />
<ToolkitButton
icon={MessageSquare}
ariaLabel="Graph Agent"
Expand Down Expand Up @@ -220,11 +217,6 @@ export function Toolkit({
{isAdmin && (
<>
<Divider />
<ToolkitButton
icon={GitMerge}
ariaLabel="Add Edge"
onClick={() => openAddEdge()}
/>
<ToolkitButton
icon={Network}
ariaLabel="Ontology"
Expand Down Expand Up @@ -274,7 +266,7 @@ export function ToolkitFAB({
const router = useRouter()
const { isAdmin, budget } = useUserStore()
const openModal = useModalStore((s) => s.open)
const openAddEdge = useModalStore((s) => s.openAddEdge)
const openAdd = useModalStore((s) => s.openAdd)
const { pendingCount } = useReviewStore()

const formattedBudget =
Expand Down Expand Up @@ -325,8 +317,7 @@ export function ToolkitFAB({
<div className="my-1 mx-2 h-px bg-border/60" />
{/* Action buttons — icon + label */}
{[
{ icon: Plus, label: "Add Content", action: () => openModal("addContent"), active: false },
{ icon: Tag, label: "Add Node", action: () => openModal("addNode"), active: false },
{ icon: Plus, label: "Add to graph", action: () => openAdd("source"), active: false },
{ icon: MessageSquare, label: "Graph Agent", action: onToggleAgent ?? (() => {}), active: agentOpen ?? false },
{ icon: BookMarked, label: "My Content", action: onToggleMyContent, active: myContentOpen },
{ icon: Heart, label: "Following", action: onToggleFollowing, active: followingOpen },
Expand All @@ -350,7 +341,6 @@ export function ToolkitFAB({
<>
<div className="my-1 mx-2 h-px bg-border/60" />
{[
{ icon: GitMerge, label: "Add Edge", action: () => openAddEdge() },
{ icon: Network, label: "Ontology", action: () => router.push("/ontology") },
{
icon: ClipboardList,
Expand Down
202 changes: 202 additions & 0 deletions src/components/modals/add-edge-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"use client"

import { useCallback, useEffect, useMemo, useState } from "react"
import { CheckCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import { SelectCustom } from "@/components/ui/select-custom"
import { NodeSearchInput } from "@/components/ui/node-search-input"
import { useModalStore } from "@/stores/modal-store"
import { useSchemaStore } from "@/stores/schema-store"
import { useUserStore } from "@/stores/user-store"
import { getPrice, payL402 } from "@/lib/sphinx"
import { createEdge, type GraphNode } from "@/lib/graph-api"

type Status = "idle" | "submitting" | "success" | "error"

export function AddEdgeForm() {
const storeSourceNode = useModalStore((s) => s.sourceNode)
const close = useModalStore((s) => s.close)
const openModal = useModalStore((s) => s.open)
const setBudget = useUserStore((s) => s.setBudget)

const schemaEdges = useSchemaStore((s) => s.edges)

const [price, setPrice] = useState<number | null>(null)

// Fetch the edge-creation price on mount so we can show the cost up front.
useEffect(() => {
const controller = new AbortController()
getPrice("v2/edges", "post", controller.signal)
.then(setPrice)
.catch(() => setPrice(null))
return () => controller.abort()
}, [])

// Seed the source from a deep-link (e.g. "Add Edge" off a node). The form
// mounts fresh each time the Edge tab opens, so a lazy initializer captures
// the store's sourceNode without an effect.
const [selectedSource, setSelectedSource] = useState<GraphNode | null>(
() => storeSourceNode ?? null
)
const [selectedTarget, setSelectedTarget] = useState<GraphNode | null>(null)
const [edgeType, setEdgeType] = useState("")
const [status, setStatus] = useState<Status>("idle")
const [errorMsg, setErrorMsg] = useState<string | null>(null)

// Derive unique edge types excluding CHILD_OF
const edgeTypeOptions = useMemo(() => {
const seen = new Set<string>()
const options: { value: string; label: string }[] = []
for (const e of schemaEdges) {
if (e.edge_type && e.edge_type !== "CHILD_OF" && !seen.has(e.edge_type)) {
seen.add(e.edge_type)
options.push({ value: e.edge_type, label: e.edge_type })
}
}
return options.sort((a, b) => a.label.localeCompare(b.label))
}, [schemaEdges])

const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault()

if (!selectedSource || !selectedTarget || !edgeType) {
setErrorMsg("All three fields are required.")
return
}

const doCreate = async () => {
await createEdge({
source: selectedSource.ref_id,
target: selectedTarget.ref_id,
edge_type: edgeType,
})
setStatus("success")
setTimeout(() => close(), 1500)
}

setStatus("submitting")
setErrorMsg(null)

try {
await doCreate()
} catch (err) {
// Paid action: on 402 settle the L402 invoice and retry. If payment
// fails (e.g. not enough sats), surface the budget/top-up bar — same
// flow as Add Content.
if (err instanceof Response && err.status === 402) {
try {
await payL402(setBudget)
await doCreate()
} catch {
openModal("budget")
}
return
}
setStatus("error")
if (err instanceof Response) {
const body = await err.json().catch(() => null) as { message?: string; error?: string } | null
setErrorMsg(body?.message || body?.error || `Request failed (HTTP ${err.status})`)
} else if (err instanceof Error) {
setErrorMsg(err.message || "Something went wrong. Please try again.")
} else {
setErrorMsg("Something went wrong. Please try again.")
}
}
},
[selectedSource, selectedTarget, edgeType, close, openModal, setBudget]
)

const busy = status === "submitting" || status === "success"
const edgeReady = !!(selectedSource && selectedTarget && edgeType)

return (
<form onSubmit={handleSubmit} className="relative z-10 space-y-4 pt-1">
{/* Source node */}
<div className="flex flex-col gap-1.5">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground font-heading">
Source node <span className="text-destructive">*</span>
</label>
<NodeSearchInput
value={selectedSource}
onChange={(node) => { setSelectedSource(node); setErrorMsg(null) }}
placeholder="Search source node…"
disabled={busy}
/>
</div>

{/* Target node */}
<div className="flex flex-col gap-1.5">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground font-heading">
Target node <span className="text-destructive">*</span>
</label>
<NodeSearchInput
value={selectedTarget}
onChange={(node) => { setSelectedTarget(node); setErrorMsg(null) }}
placeholder="Search target node…"
disabled={busy}
/>
</div>

{/* Edge type */}
<div className="flex flex-col gap-1.5">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground font-heading">
Edge type <span className="text-destructive">*</span>
</label>
{edgeTypeOptions.length === 0 ? (
<div className="rounded-md border border-border/50 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
No edge types available. Load schemas first.
</div>
) : (
<SelectCustom
value={edgeType}
onChange={(v) => { setEdgeType(v); setErrorMsg(null) }}
options={edgeTypeOptions}
placeholder="Choose an edge type..."
searchable
/>
)}
</div>

{/* Error */}
{errorMsg && (
<p className="text-xs text-destructive">{errorMsg}</p>
)}

{/* Success */}
{status === "success" && (
<div className="flex items-center gap-2 text-xs text-green-500">
<CheckCircle className="h-4 w-4" />
Edge created!
</div>
)}

{/* Footer — contextual status hint + actions, bled to the modal edges */}
<div className="-mx-4 -mb-4 mt-1 flex items-center gap-3 rounded-b-xl border-t border-border/50 bg-muted/30 px-4 py-3">
<span className="text-xs text-muted-foreground">
{edgeReady ? "Ready to create" : "Pick both nodes and a relationship"}
</span>
<span className="flex-1" />
{price !== null && price > 0 && (
<span className="font-mono text-xs text-muted-foreground">{price} sats</span>
)}
<Button type="button" variant="ghost" onClick={() => close()} className="text-xs" disabled={busy}>
Cancel
</Button>
<Button
type="submit"
disabled={busy}
className="text-xs bg-primary text-primary-foreground hover:bg-primary/90"
>
{status === "submitting"
? "Creating..."
: status === "success"
? "Created!"
: price && price > 0
? `Add Edge · ${price} sats`
: "Add Edge"}
</Button>
</div>
</form>
)
}
Loading
Loading