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
2 changes: 2 additions & 0 deletions src/components/layout/app-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { AddContentModal } from "@/components/modals/add-content-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 @@ -57,6 +58,7 @@ export function AppLayout() {
<AddContentModal />
<AddNodeModal />
<EditNodeModal />
<AddEdgeModal />
<BudgetModal />
<MediaPlayer />
</>
Expand Down
12 changes: 11 additions & 1 deletion src/components/layout/node-preview-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client"

import { useState, useEffect, useRef } from "react"
import { ArrowLeft, Link, Zap, Loader2, Play, Film, ExternalLink, Heart, Repeat2, ChevronDown, ChevronUp, MessageCircle, Quote, Eye, BadgeCheck, AtSign, HeartOff, X, Pencil, FlaskConical } from "lucide-react"
import { ArrowLeft, Link, Zap, Loader2, Play, Film, ExternalLink, Heart, Repeat2, ChevronDown, ChevronUp, MessageCircle, Quote, Eye, BadgeCheck, AtSign, HeartOff, X, Pencil, FlaskConical, GitMerge } from "lucide-react"

import { Badge } from "@/components/ui/badge"
import { BoostButton } from "@/components/boost/boost-button"
Expand Down Expand Up @@ -787,6 +787,7 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp
const hasIdentity = !!pubKey || !!cookieStorage.getItem("l402")
const openModal = useModalStore((s) => s.open)
const openEdit = useModalStore((s) => s.openEdit)
const openAddEdge = useModalStore((s) => s.openAddEdge)

const edges = useGraphStore((s) => s.edges)

Expand Down Expand Up @@ -1099,6 +1100,15 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp
boostCount={boostAmt}
/>
)}
{(isAdmin || hasIdentity) && (
<button
onClick={() => openAddEdge(currentNode.ref_id)}
className="text-muted-foreground hover:text-foreground transition-colors"
title="Add edge from this node"
>
<GitMerge className="h-3.5 w-3.5" />
</button>
)}
{isAdmin && (
<button
onClick={() => openEdit(currentNode)}
Expand Down
9 changes: 9 additions & 0 deletions src/components/layout/toolkit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
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 @@ -113,6 +114,7 @@ export function Toolkit({
const router = useRouter()
const { isAdmin, budget } = useUserStore()
const openModal = useModalStore((s) => s.open)
const openAddEdge = useModalStore((s) => s.openAddEdge)
const { pendingCount, setPendingCount } = useReviewStore()

useEffect(() => {
Expand Down Expand Up @@ -218,6 +220,11 @@ export function Toolkit({
{isAdmin && (
<>
<Divider />
<ToolkitButton
icon={GitMerge}
ariaLabel="Add Edge"
onClick={() => openAddEdge()}
/>
<ToolkitButton
icon={Network}
ariaLabel="Ontology"
Expand Down Expand Up @@ -267,6 +274,7 @@ export function ToolkitFAB({
const router = useRouter()
const { isAdmin, budget } = useUserStore()
const openModal = useModalStore((s) => s.open)
const openAddEdge = useModalStore((s) => s.openAddEdge)
const { pendingCount } = useReviewStore()

const formattedBudget =
Expand Down Expand Up @@ -342,6 +350,7 @@ 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
194 changes: 194 additions & 0 deletions src/components/modals/add-edge-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
"use client"

import { useCallback, useEffect, useMemo, useState } from "react"
import { CheckCircle } from "lucide-react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { SelectCustom } from "@/components/ui/select-custom"
import { useModalStore } from "@/stores/modal-store"
import { useSchemaStore } from "@/stores/schema-store"
import { createEdge } from "@/lib/graph-api"

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

export function AddEdgeModal() {
const activeModal = useModalStore((s) => s.activeModal)
const sourceRefId = useModalStore((s) => s.sourceRefId)
const close = useModalStore((s) => s.close)

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

const [sourceVal, setSourceVal] = useState("")
const [targetVal, setTargetVal] = useState("")
const [edgeType, setEdgeType] = useState("")
const [status, setStatus] = useState<Status>("idle")
const [errorMsg, setErrorMsg] = useState<string | null>(null)

const isOpen = activeModal === "addEdge"

// Sync sourceVal when modal opens with a pre-filled sourceRefId
useEffect(() => {
if (isOpen) {
setSourceVal(sourceRefId ?? "")
setTargetVal("")
setEdgeType("")
setStatus("idle")
setErrorMsg(null)
}
}, [isOpen, sourceRefId])

// Derive unique edge types excluding CHILD_OF, computed once when modal opens
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 handleClose = useCallback(() => {
setSourceVal("")
setTargetVal("")
setEdgeType("")
setStatus("idle")
setErrorMsg(null)
close()
}, [close])

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

if (!sourceVal.trim() || !targetVal.trim() || !edgeType) {
setErrorMsg("All three fields are required.")
return
}

setStatus("submitting")
setErrorMsg(null)

try {
await createEdge({
source: sourceVal.trim(),
target: targetVal.trim(),
edge_type: edgeType,
})
setStatus("success")
setTimeout(() => handleClose(), 1500)
} catch (err) {
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.")
}
}
},
[sourceVal, targetVal, edgeType, handleClose]
)

const busy = status === "submitting" || status === "success"

return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="border-border/50 bg-card noise-bg sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-heading text-lg tracking-wide">
Add Edge
</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
Create a relationship between two graph nodes.
</DialogDescription>
</DialogHeader>

<form onSubmit={handleSubmit} className="relative z-10 space-y-4 pt-2">
{/* Source ref_id */}
<div className="flex flex-col gap-1.5">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground font-heading">
Source ref_id <span className="text-destructive">*</span>
</label>
<input
type="text"
value={sourceVal}
onChange={(e) => { setSourceVal(e.target.value); setErrorMsg(null) }}
placeholder="Source node ref_id"
disabled={busy}
className="h-10 w-full rounded-md border border-border/50 bg-muted/50 px-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary/40 focus:outline-none disabled:opacity-50"
/>
</div>

{/* Target ref_id */}
<div className="flex flex-col gap-1.5">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground font-heading">
Target ref_id <span className="text-destructive">*</span>
</label>
<input
type="text"
value={targetVal}
onChange={(e) => { setTargetVal(e.target.value); setErrorMsg(null) }}
placeholder="Target node ref_id"
disabled={busy}
className="h-10 w-full rounded-md border border-border/50 bg-muted/50 px-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary/40 focus:outline-none disabled:opacity-50"
/>
</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..."
/>
)}
</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>
)}

{/* Submit */}
<div className="flex justify-end pt-1">
<Button
type="submit"
disabled={busy}
className="text-xs bg-primary text-primary-foreground hover:bg-primary/90"
>
{status === "submitting" ? "Creating..." : status === "success" ? "Created!" : "Add Edge"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
Loading
Loading