SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
3.3 KB · 76 lines tsx
Raw Blame History
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