All files / src/components/features/admin/api-docs ApiDocsSidebar.tsx

0% Statements 0/152
100% Branches 0/0
0% Functions 0/1
0% Lines 0/152

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153                                                                                                                                                                                                                                                                                                                 
'use client';

/**
 * API Documentation Sidebar
 * Navigation tree for browsing API categories and endpoints
 */

import React, { useState, useEffect, useRef, useMemo } from 'react';
import { ApiCategory, ApiEndpoint } from '@/types/api-docs';
import { getMethodColor } from '@/lib/api-docs/executor';
import { Icon } from '@/components/ui/icons';

interface ApiDocsSidebarProps {
  categories: ApiCategory[];
  selectedCategory: string | null;
  selectedEndpoint: ApiEndpoint | null;
  onSelectCategory: (categoryId: string | null) => void;
  onSelectEndpoint: (endpoint: ApiEndpoint) => void;
}

export default function ApiDocsSidebar({
  categories,
  selectedCategory,
  selectedEndpoint,
  onSelectCategory,
  onSelectEndpoint}: ApiDocsSidebarProps) {
  // Track manually toggled categories
  const [manuallyExpanded, setManuallyExpanded] = useState<Set<string>>(
    new Set(selectedCategory ? [selectedCategory] : [])
  );
  const selectedEndpointRef = useRef<HTMLButtonElement>(null);
  const categoryRefs = useRef<Map<string, HTMLDivElement>>(new Map());

  // Derive effective expanded categories: manual toggles + selectedCategory
  const expandedCategories = useMemo(() => {
    const expanded = new Set(manuallyExpanded);
    if (selectedCategory) {
      expanded.add(selectedCategory);
    }
    return expanded;
  }, [manuallyExpanded, selectedCategory]);

  // Scroll category into view when selected (DOM side effect is allowed)
  useEffect(() => {
    if (selectedCategory) {
      const timer = setTimeout(() => {
        const categoryEl = categoryRefs.current.get(selectedCategory);
        if (categoryEl) {
          categoryEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
        }
      }, 50);
      return () => clearTimeout(timer);
    }
  }, [selectedCategory]);

  // Scroll selected endpoint into view after category scrolls
  useEffect(() => {
    if (selectedEndpointRef.current) {
      const timer = setTimeout(() => {
        selectedEndpointRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
      }, 150);
      return () => clearTimeout(timer);
    }
  }, [selectedEndpoint?.id]);

  const toggleCategory = (categoryId: string) => {
    setManuallyExpanded((prev) => {
      const next = new Set(prev);
      if (next.has(categoryId)) {
        next.delete(categoryId);
      } else {
        next.add(categoryId);
      }
      return next;
    });
    onSelectCategory(categoryId);
  };

  return (
    <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
      <div className="p-4 border-b border-gray-200 dark:border-gray-700">
        <h2 className="font-semibold text-gray-900 dark:text-gray-100">Categories</h2>
      </div>
      <nav className="max-h-[calc(100vh-250px)] overflow-y-auto">
        {categories.map((category) => (
          <div
            key={category.id}
            ref={(el) => {
              if (el) categoryRefs.current.set(category.id, el);
            }}
          >
            {/* Category Header */}
            <button
              onClick={() => toggleCategory(category.id)}
              className={`w-full flex items-center justify-between px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors ${
                selectedCategory === category.id ? 'bg-indigo-50 dark:bg-indigo-900/30' : ''
              }`}
            >
              <div className="flex items-center gap-2">
                <Icon
                  name="chevron-right"
                  size={16}
                  className={`text-gray-400 dark:text-gray-500 transition-transform ${
                    expandedCategories.has(category.id) ? 'rotate-90' : ''
                  }`}
                />
                <span className="font-medium text-gray-900 dark:text-gray-100">{category.name}</span>
              </div>
              <span className="text-xs text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
                {category.endpoints.length}
              </span>
            </button>

            {/* Endpoints List */}
            {expandedCategories.has(category.id) && (
              <div className="border-t border-gray-100 dark:border-gray-700">
                {category.endpoints.map((endpoint) => (
                  <button
                    key={endpoint.id}
                    ref={selectedEndpoint?.id === endpoint.id ? selectedEndpointRef : null}
                    onClick={() => onSelectEndpoint(endpoint)}
                    className={`w-full flex items-center gap-2 px-4 py-2 pl-8 text-left hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors ${
                      selectedEndpoint?.id === endpoint.id
                        ? 'bg-indigo-100 dark:bg-indigo-900/40 border-l-2 border-indigo-500'
                        : ''
                    }`}
                  >
                    <span
                      className={`text-xs font-mono font-semibold px-1.5 py-0.5 rounded w-14 text-center inline-block ${getMethodColor(
                        endpoint.method
                      )}`}
                    >
                      {endpoint.method}
                    </span>
                    <span className="text-sm text-gray-700 dark:text-gray-300 truncate flex-1">
                      {endpoint.summary}
                    </span>
                    {endpoint.adminOnly && (
                      <span title="Admin only">
                        <Icon name="lock-closed" size={12} className="text-amber-500 dark:text-amber-400" />
                      </span>
                    )}
                  </button>
                ))}
              </div>
            )}
          </div>
        ))}
      </nav>
    </div>
  );
}