From cdc3a6361a2488bdb33a65a396e9ad3ecf339192 Mon Sep 17 00:00:00 2001
From: pitoi
Date: Wed, 22 Apr 2026 15:20:49 +0000
Subject: [PATCH] Generated with Hive: Fix View Source link rendering for web
page nodes in NodePreviewPanel
---
src/app/ontology/page.tsx | 19 ++++-
src/components/layout/node-preview-panel.tsx | 25 ++++++-
src/components/layout/sources-panel.tsx | 3 +-
src/components/modals/radar-settings.tsx | 12 +++-
src/lib/__tests__/node-preview-panel.test.tsx | 69 +++++++++++++++++++
src/lib/mock-data.ts | 19 +++++
6 files changed, 138 insertions(+), 9 deletions(-)
diff --git a/src/app/ontology/page.tsx b/src/app/ontology/page.tsx
index e784230..4d61eb2 100644
--- a/src/app/ontology/page.tsx
+++ b/src/app/ontology/page.tsx
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation"
import { OntologyGraph } from "./ontology-graph"
import { TypeEditor } from "./type-editor"
import { Plus, ArrowLeft, Box, Grid2x2 } from "lucide-react"
+import { useUserStore } from "@/stores/user-store"
const OntologyGraph3D = dynamic(
() => import("./ontology-graph-3d").then((m) => ({ default: m.OntologyGraph3D })),
@@ -47,11 +48,20 @@ export interface SchemaEdge {
export default function OntologyPage() {
const router = useRouter()
+ const isAdmin = useUserStore((s) => s.isAdmin)
+ const isAuthenticated = useUserStore((s) => s.isAuthenticated)
const store = useSchemaStore()
const [selectedId, setSelectedId] = useState(null)
const [view3D, setView3D] = useState(false)
const [schemaError, setSchemaError] = useState(null)
+ useEffect(() => {
+ if (isAuthenticated && !isAdmin) {
+ router.replace("/")
+ return
+ }
+ }, [isAdmin, isAuthenticated, router])
+
useEffect(() => {
if (isMocksEnabled()) {
store.setSchemas(SMALL_SCHEMAS)
@@ -66,6 +76,7 @@ export default function OntologyPage() {
const handleUpdateSchema = useCallback(
async (updated: SchemaNode) => {
+ if (!isAdmin) return
try {
await store.updateSchema(updated)
setSchemaError(null)
@@ -73,10 +84,11 @@ export default function OntologyPage() {
setSchemaError(err instanceof Error ? err.message : "Failed to save schema")
}
},
- [store]
+ [isAdmin, store]
)
const handleAddType = useCallback(async () => {
+ if (!isAdmin) return
// Find next available name
const existing = new Set(store.schemas.map((s) => s.type))
let n = 1
@@ -98,14 +110,15 @@ export default function OntologyPage() {
setSchemaError(err instanceof Error ? err.message : "Failed to save schema")
}
setSelectedId(id)
- }, [store])
+ }, [isAdmin, store])
const handleDeleteSchema = useCallback(
(refId: string) => {
+ if (!isAdmin) return
store.removeSchema(refId)
if (selectedId === refId) setSelectedId(null)
},
- [selectedId, store]
+ [isAdmin, selectedId, store]
)
return (
diff --git a/src/components/layout/node-preview-panel.tsx b/src/components/layout/node-preview-panel.tsx
index a51d0dc..57a28ba 100644
--- a/src/components/layout/node-preview-panel.tsx
+++ b/src/components/layout/node-preview-panel.tsx
@@ -45,6 +45,10 @@ function isUrl(value: string): boolean {
}
}
+function isMediaUrl(value: string): boolean {
+ return /\.(mp4|webm|mov|mp3|ogg|wav|m4a)(\?.*)?$/i.test(value)
+}
+
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
@@ -108,7 +112,8 @@ function MediaCard({ node, props }: { node: GraphNode; props: Record s.playingNode?.ref_id === node.ref_id
)
- const mediaUrl = (props.media_url ?? props.link) as string | undefined
+ const rawLink = typeof props.link === "string" ? props.link : undefined
+ const mediaUrl = (props.media_url ?? (rawLink && isMediaUrl(rawLink) ? rawLink : undefined)) as string | undefined
const duration = typeof props.duration === "number" ? props.duration : undefined
const show = props.show as string | undefined
const channel = props.channel as string | undefined
@@ -376,7 +381,12 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp
// Detect property-driven content type
const hasTweet = fp && ("tweet_id" in fp || "twitter_handle" in fp) && "text" in fp
- const hasMedia = fp && ("media_url" in fp || "link" in fp)
+ const linkValue = typeof fp?.link === "string" ? fp.link : undefined
+ const hasMedia = fp && (
+ "media_url" in fp ||
+ (linkValue !== undefined && isMediaUrl(linkValue))
+ )
+ const hasWebPageLink = !!linkValue && !isMediaUrl(linkValue)
const hasTranscript = fp && typeof fp.transcript === "string"
const hasSummary = hasMedia && fp && typeof fp.summary === "string" && fp.summary.length > 0
const hasArticle = fp && ("source_link" in fp || (typeof fp.text === "string" && !hasTweet))
@@ -545,6 +555,17 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp
{hasMedia && fullNode && }
{hasSummary && }
{hasArticle && !hasTweet && }
+ {hasWebPageLink && (
+
+
+ View Source
+
+ )}
{hasTranscript && }
{/* Fallback: remaining properties not covered by widgets */}
diff --git a/src/components/layout/sources-panel.tsx b/src/components/layout/sources-panel.tsx
index 8872c83..e20ba3d 100644
--- a/src/components/layout/sources-panel.tsx
+++ b/src/components/layout/sources-panel.tsx
@@ -44,6 +44,7 @@ function SourceRow({
const [deleting, setDeleting] = useState(false)
const handleDelete = useCallback(async () => {
+ if (!canEdit) return
setDeleting(true)
try {
await api.delete(`/radar/${source.ref_id}`)
@@ -53,7 +54,7 @@ function SourceRow({
} finally {
setDeleting(false)
}
- }, [source.ref_id, onDelete])
+ }, [canEdit, source.ref_id, onDelete])
const displayName = extractNameFromSource(source.source, source.source_type as never)
diff --git a/src/components/modals/radar-settings.tsx b/src/components/modals/radar-settings.tsx
index 5546421..37f2775 100644
--- a/src/components/modals/radar-settings.tsx
+++ b/src/components/modals/radar-settings.tsx
@@ -14,6 +14,7 @@ import {
updateRadarConfig,
} from "@/lib/graph-api"
import { isMocksEnabled, MOCK_RADAR_CONFIGS } from "@/lib/mock-data"
+import { useUserStore } from "@/stores/user-store"
const SOURCE_TYPE_LABELS: Record = {
twitter_handle: "Twitter handles",
@@ -37,6 +38,7 @@ function formatLastRun(ts?: number): string {
}
export function RadarSettings({ open }: { open: boolean }) {
+ const isAdmin = useUserStore((s) => s.isAdmin)
const [configs, setConfigs] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -68,6 +70,7 @@ export function RadarSettings({ open }: { open: boolean }) {
sourceType: RadarSourceType,
fields: Partial>
) => {
+ if (!isAdmin) return
// Optimistic update so the row reacts immediately on toggle/edit.
setConfigs((prev) =>
prev
@@ -90,7 +93,7 @@ export function RadarSettings({ open }: { open: boolean }) {
load()
}
},
- [load]
+ [isAdmin, load]
)
if (loading && !configs) {
@@ -115,7 +118,7 @@ export function RadarSettings({ open }: { open: boolean }) {
off to pause without losing the cadence.
{configs?.map((cfg) => (
-
+
))}
)
@@ -124,12 +127,14 @@ export function RadarSettings({ open }: { open: boolean }) {
function RadarRow({
config,
onUpdate,
+ isAdmin,
}: {
config: RadarConfig
onUpdate: (
sourceType: RadarSourceType,
fields: Partial>
) => Promise
+ isAdmin: boolean
}) {
const [cadence, setCadence] = useState(config.cadence)
const [running, setRunning] = useState(false)
@@ -149,6 +154,7 @@ function RadarRow({
}, [cadence, config.cadence, config.source_type, cadenceInvalid, onUpdate])
const handleRunNow = useCallback(async () => {
+ if (!isAdmin) return
if (isMocksEnabled()) {
setRunMessage("Mock: dispatched")
return
@@ -166,7 +172,7 @@ function RadarRow({
} finally {
setRunning(false)
}
- }, [config.source_type])
+ }, [isAdmin, config.source_type])
return (
diff --git a/src/lib/__tests__/node-preview-panel.test.tsx b/src/lib/__tests__/node-preview-panel.test.tsx
index 2b57eb5..d16a8b3 100644
--- a/src/lib/__tests__/node-preview-panel.test.tsx
+++ b/src/lib/__tests__/node-preview-panel.test.tsx
@@ -621,3 +621,72 @@ describe("NodePreviewPanel – paid_properties lock placeholders", () => {
expect(screen.queryByText("🔒")).toBeNull()
})
})
+
+describe("NodePreviewPanel – View Source / web page link", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ userStoreOverrides = {}
+ })
+
+ it("renders 'View Source' link for a web page node with a plain link URL", async () => {
+ const node: GraphNode = {
+ ref_id: "n9",
+ node_type: "WebPage",
+ properties: { name: "Sphinx Chat Website", description: "Decentralised messaging on Lightning." },
+ }
+ const fullNode: GraphNode = {
+ ...node,
+ properties: { ...node.properties, link: "https://sphinx.chat" },
+ }
+ mockApiGet.mockResolvedValue({ nodes: [fullNode], edges: [] })
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText("View Source")).toBeInTheDocument()
+ })
+ const link = screen.getByRole("link", { name: /view source/i })
+ expect(link).toHaveAttribute("href", "https://sphinx.chat")
+ expect(link).toHaveAttribute("target", "_blank")
+ })
+
+ it("does not render 'View Source' for an audio node with a media link URL", async () => {
+ const node: GraphNode = {
+ ref_id: "a1",
+ node_type: "Episode",
+ properties: { name: "Audio Episode" },
+ }
+ const fullNode: GraphNode = {
+ ...node,
+ properties: { ...node.properties, link: "https://example.com/audio.mp3" },
+ }
+ mockApiGet.mockResolvedValue({ nodes: [fullNode], edges: [] })
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText("Play Audio")).toBeInTheDocument()
+ })
+ expect(screen.queryByText("View Source")).toBeNull()
+ })
+
+ it("renders player and no View Source for a node with media_url", async () => {
+ const node: GraphNode = {
+ ref_id: "m1",
+ node_type: "Episode",
+ properties: { name: "Media Node" },
+ }
+ const fullNode: GraphNode = {
+ ...node,
+ properties: { ...node.properties, media_url: "https://example.com/episode.mp3" },
+ }
+ mockApiGet.mockResolvedValue({ nodes: [fullNode], edges: [] })
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText("Play Audio")).toBeInTheDocument()
+ })
+ expect(screen.queryByText("View Source")).toBeNull()
+ })
+})
diff --git a/src/lib/mock-data.ts b/src/lib/mock-data.ts
index 381c600..3db1a46 100644
--- a/src/lib/mock-data.ts
+++ b/src/lib/mock-data.ts
@@ -41,6 +41,11 @@ export const MOCK_NODES: GraphNode[] = [
node_type: "Clip",
properties: { name: "Bitcoin Mining Explained", description: "A 3-minute clip explaining proof-of-work mining" },
},
+ {
+ ref_id: "n9",
+ node_type: "WebPage",
+ properties: { name: "Sphinx Chat Website", description: "Decentralised messaging on Lightning." },
+ },
]
// Full node data returned after unlock (simulates GET /v2/nodes/:ref_id?expand=edges)
@@ -204,6 +209,20 @@ export const MOCK_FULL_NODES: Record = {
],
edges: [],
},
+ n9: {
+ nodes: [
+ {
+ ref_id: "n9",
+ node_type: "WebPage",
+ properties: {
+ name: "Sphinx Chat Website",
+ description: "Decentralised messaging on Lightning.",
+ link: "https://sphinx.chat",
+ },
+ },
+ ],
+ edges: [],
+ },
}
export const MOCK_EDGES: GraphEdge[] = [