spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1'use client';23import { useState } from 'react';4import Link from 'next/link';5import { ChevronRight, Loader2 } from 'lucide-react';6import type { TreeNode } from '@/lib/queries/taxonomy';78/** Lazily expandable hierarchy tree. Children are fetched on first expand from /api/taxonomy/children. */9export function Tree({ roots, hierarchyType }: { roots: TreeNode[]; hierarchyType: string }) {10 return (11 <ul role="tree" className="text-[13.5px]">12 {roots.map((n) => (13 <Node key={n.id} node={n} hierarchyType={hierarchyType} depth={0} />14 ))}15 </ul>16 );17}1819function Node({ node, hierarchyType, depth }: { node: TreeNode; hierarchyType: string; depth: number }) {20 const [open, setOpen] = useState(false);21 const [children, setChildren] = useState<TreeNode[] | null>(null);22 const [loading, setLoading] = useState(false);23 const [error, setError] = useState<string | null>(null);2425 const toggle = async () => {26 const next = !open;27 setOpen(next);28 if (next && children == null && node.child_count > 0) {29 setLoading(true);30 setError(null);31 try {32 const r = await fetch(`/api/taxonomy/children?id=${encodeURIComponent(node.id)}&type=${encodeURIComponent(hierarchyType)}`);33 if (!r.ok) throw new Error(`HTTP ${r.status}`);34 const j = (await r.json()) as { data: TreeNode[] };35 setChildren(j.data);36 } catch (e) {37 setError((e as Error).message);38 } finally {39 setLoading(false);40 }41 }42 };4344 return (45 <li role="treeitem" aria-expanded={node.child_count > 0 ? open : undefined} aria-level={depth + 1}>46 <div className="flex items-center gap-1 border-b border-rule py-1" style={{ paddingLeft: depth * 18 }}>47 {node.child_count > 0 ? (48 <button type="button" onClick={toggle} aria-label={open ? `Collapse ${node.canonical_name}` : `Expand ${node.canonical_name} (${node.child_count} children)`} className="inline-flex h-5 w-5 shrink-0 items-center justify-center text-ink-3 hover:text-accent">49 {loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> : <ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-90' : ''}`} aria-hidden />}50 </button>51 ) : (52 <span className="inline-block h-5 w-5 shrink-0" aria-hidden />53 )}54 <Link href={`/cancer/${node.slug}`} className={`ci-link truncate ${node.malignant ? '' : 'text-ink-3'}`}>55 {node.canonical_name}56 </Link>57 {node.primary_oncotree_code && hierarchyType === 'oncotree' ? <span className="ci-mono shrink-0 text-[10.5px] text-ink-4">{node.primary_oncotree_code}</span> : null}58 {node.primary_ncit_code && hierarchyType === 'ncit' ? <span className="ci-mono shrink-0 text-[10.5px] text-ink-4">{node.primary_ncit_code}</span> : null}59 {node.child_count > 0 ? <span className="ci-num ml-auto shrink-0 text-[11px] text-ink-3">{node.child_count}</span> : null}60 </div>61 {open && error ? (62 <p className="py-1 text-[12px] text-danger" style={{ paddingLeft: depth * 18 + 24 }}>63 Could not load children ({error}).64 </p>65 ) : null}66 {open && children ? (67 <ul role="group">68 {children.map((c) => (69 <Node key={c.id} node={c} hierarchyType={hierarchyType} depth={depth + 1} />70 ))}71 </ul>72 ) : null}73 </li>74 );75}76