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[] = [