PageSourceSearch

https://pressthink.org/j/rosen-archive/frontend/components/DetailPanel.js?v=3.8.36

js pressthink.org collected 2026-10-02 04:27:17 UTC 6,969 bytes, 218 lines download raw bytes

1
2import { useState, useEffect, useRef } from 'react';
3import { html } from '../html.js?v=3.8.36';
4import { X, FileText, Quote, BookOpen, Lightbulb, User } from 'lucide-react';
5
6// Type labels for display
7const TYPE_LABELS = {
8  root: 'Dissertation',
9  intro: 'Introduction',
10  part: 'Part',
11  chapter: 'Chapter',
12  conclusion: 'Conclusion',
13  concept: 'Key concept',
14  figure: 'Key figure'
15};
16
17const DetailPanel = ({ node, isOpen, onClose, contained = false }) => {
18  // Keep the last node data so panel can animate out with content
19  const [displayNode, setDisplayNode] = useState(node);
20  const panelRef = useRef(null);
21  const closeButtonRef = useRef(null);
22  const returnFocusRef = useRef(null);
23  const wasOpenRef = useRef(false);
24
25  useEffect(() => {
26    if (node) {
27      setDisplayNode(node);
28    }
29    // Don't clear displayNode when node becomes null - keep it for animation
30  }, [node]);
31
32  // Focus management: focus close button when panel opens
33  useEffect(() => {
34    if (isOpen) {
35      if (!wasOpenRef.current) returnFocusRef.current = document.activeElement;
36      wasOpenRef.current = true;
37      if (!displayNode) return undefined;
38      const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
39      let frame = null;
40      let timer = null;
41      const focusClose = () => {
42        if (contained) panelRef.current?.scrollIntoView({ block: 'start' });
43        closeButtonRef.current?.focus({ preventScroll: contained });
44      };
45      if (reduceMotion) frame = requestAnimationFrame(focusClose);
46      else timer = setTimeout(focusClose, 100);
47      return () => {
48        if (frame !== null) cancelAnimationFrame(frame);
49        if (timer !== null) clearTimeout(timer);
50      };
51    }
52
53    if (wasOpenRef.current) {
54      wasOpenRef.current = false;
55      const trigger = returnFocusRef.current;
56      returnFocusRef.current = null;
57      requestAnimationFrame(() => trigger?.focus?.({ preventScroll: true }));
58    }
59
60    return undefined;
61  }, [isOpen, displayNode, contained]);
62
63  // Handle ESC key to close panel
64  useEffect(() => {
65    const handleKeyDown = (e) => {
66      if (e.key === 'Escape' && isOpen) {
67        onClose();
68      }
69    };
70
71    if (isOpen) {
72      document.addEventListener('keydown', handleKeyDown);
73    }
74    return () => document.removeEventListener('keydown', handleKeyDown);
75  }, [isOpen, onClose]);
76
77  // Don't render anything if we've never had a node
78  if (!displayNode) return null;
79
80  const positionClass = contained ? 'absolute' : 'fixed';
81
82  return html`
83    ${isOpen && html`
84      <div
85        className=${`${positionClass} inset-0 bg-black/20 z-40 sm:hidden`}
86        style=${{ zIndex: 70 }}
87        onClick=${onClose}
88      />
89    `}
90
91    <div
92      ref=${panelRef}
93      role="dialog"
94      aria-hidden=${isOpen ? undefined : 'true'}
95      inert=${isOpen ? undefined : ''}
96      aria-labelledby="detail-panel-title"
97      className=${`
98        archive-detail-panel archive-reading-panel ${contained ? 'is-contained' : ''} ${positionClass} top-0 h-full w-full sm:w-[420px]
99        shadow-xl z-50 transform transition-transform duration-300 ease-out
100        ${isOpen ? 'right-0' : '-right-full sm:-right-[420px]'}
101      `}
102      style=${{
103        maxWidth: contained ? 'calc(100% - 16px)' : 'calc(100vw - 16px)',
104        zIndex: 80,
105      }}
106    >
107      <header className="archive-reading-panel__header">
108        <span>
109          ${TYPE_LABELS[displayNode.type] || 'Section'}
110        </span>
111        <button
112          type="button"
113          ref=${closeButtonRef}
114          onClick=${onClose}
115          className="archive-reading-panel__close"
116          title="Close panel (ESC)"
117          aria-label="Close detail panel"
118        >
119          <${X} className="w-5 h-5 text-stone-500" />
120        </button>
121      </header>
122
123      <div
124        className="archive-detail-content archive-reading-panel__document"
125        tabIndex="0"
126        aria-label="Detail panel content"
127      >
128        <h2 id="detail-panel-title">
129          ${displayNode.label}
130        </h2>
131
132        ${displayNode.subtitle && html`
133          <p className="archive-reading-panel__subtitle">${displayNode.subtitle}</p>
134        `}
135
136        ${(displayNode.pageStart || displayNode.pageRef) && html`
137          <div className="archive-reading-panel__page-reference">
138            <${FileText} className="w-3.5 h-3.5" />
139            <span>
140              ${displayNode.pageRef || `Pages ${displayNode.pageStart}${displayNode.pageEnd ? `–${displayNode.pageEnd}` : '+'}`}
141            </span>
142          </div>
143        `}
144
145        ${displayNode.pullQuote && html`
146          <section className="archive-reading-panel__section">
147            <h3>
148              <${Quote} className="w-3.5 h-3.5" />
149              <span>From the text</span>
150            </h3>
151            <blockquote>
152              “${displayNode.pullQuote}”
153            </blockquote>
154          </section>
155        `}
156
157        ${displayNode.summary && html`
158          <section className="archive-reading-panel__section">
159            <h3>
160              <${BookOpen} className="w-3.5 h-3.5" />
161              <span>Summary</span>
162            </h3>
163            <p>
164              ${displayNode.summary}
165            </p>
166          </section>
167        `}
168
169        ${displayNode.keyConcepts && displayNode.keyConcepts.length > 0 && html`
170          <section className="archive-reading-panel__section">
171            <h3>
172              <${Lightbulb} className="w-3.5 h-3.5" />
173              <span>Key concepts</span>
174            </h3>
175            <div className="archive-reading-panel__tags">
176              ${displayNode.keyConcepts.map((concept, i) => html`
177                <span
178                  key=${i}
179                  className="archive-reading-panel__tag"
180                >
181                  ${concept}
182                </span>
183              `)}
184            </div>
185          </section>
186        `}
187
188        ${displayNode.keyFigures && displayNode.keyFigures.length > 0 && html`
189          <section className="archive-reading-panel__section">
190            <h3>
191              <${User} className="w-3.5 h-3.5" />
192              <span>Key figures</span>
193            </h3>
194            <div className="archive-reading-panel__tags">
195              ${displayNode.keyFigures.map((figure, i) => html`
196                <span
197                  key=${i}
198                  className="archive-reading-panel__tag"
199                >
200                  ${figure}
201                </span>
202              `)}
203            </div>
204          </section>
205        `}
206
207        ${!displayNode.summary && !displayNode.pullQuote && !displayNode.keyConcepts?.length && !displayNode.keyFigures?.length && html`
208          <div className="archive-reading-panel__empty">
209            <${FileText} className="w-8 h-8 mx-auto mb-2 opacity-30" />
210            <p className="text-sm">No additional detail for this item</p>
211          </div>
212        `}
213      </div>
214    </div>
215  `;
216};
217
218export default DetailPanel;

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.