| |
| |
| |
| |
|
|
| import { useEffect, useRef, useState, useCallback } from "react"; |
| import { createPortal } from "react-dom"; |
| import { X, Sparkles, AlertCircle, Brain, ChevronDown, ChevronUp } from "lucide-react"; |
| import { siteConfig } from "@shared/config"; |
| import { renderMarkdown } from "../../utils/markdownRenderer"; |
| import type { AnalysisState } from "@shared/types"; |
| import type { CSSProperties } from "react"; |
|
|
| interface AnalysisModalProps { |
| analysisState: AnalysisState; |
| onClose: () => void; |
| } |
|
|
| const overlayStyle: CSSProperties = { |
| position: "fixed", |
| top: 0, |
| left: 0, |
| right: 0, |
| bottom: 0, |
| zIndex: 1000, |
| display: "flex", |
| alignItems: "flex-end", |
| justifyContent: "center", |
| padding: "0 var(--spacing-md)", |
| pointerEvents: "none", |
| }; |
|
|
| const panelStyle: CSSProperties = { |
| width: "100%", |
| maxWidth: "var(--max-width-container)", |
| height: "70vh", |
| backgroundColor: "var(--color-background)", |
| borderRadius: "var(--border-radius-lg) var(--border-radius-lg) 0 0", |
| boxShadow: "0 -8px 32px rgba(0, 0, 0, 0.2)", |
| display: "flex", |
| flexDirection: "column", |
| overflow: "hidden", |
| pointerEvents: "auto", |
| animation: "slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1)", |
| border: "1px solid var(--color-border)", |
| borderBottom: "none", |
| }; |
|
|
| const headerStyle: CSSProperties = { |
| display: "flex", |
| alignItems: "center", |
| justifyContent: "space-between", |
| padding: "var(--spacing-md) var(--spacing-lg)", |
| borderBottom: "1px solid var(--color-border)", |
| backgroundColor: "var(--color-surface)", |
| flexShrink: 0, |
| }; |
|
|
| const headerLeftStyle: CSSProperties = { |
| display: "flex", |
| alignItems: "center", |
| gap: "var(--spacing-sm)", |
| }; |
|
|
| const headerIconContainerStyle: CSSProperties = { |
| display: "flex", |
| alignItems: "center", |
| justifyContent: "center", |
| width: "32px", |
| height: "32px", |
| borderRadius: "var(--border-radius-md)", |
| backgroundColor: "var(--color-accent)", |
| color: "#ffffff", |
| }; |
|
|
| const headerTitleStyle: CSSProperties = { |
| fontSize: "var(--font-size-base)", |
| fontWeight: 600, |
| color: "var(--color-text-primary)", |
| }; |
|
|
| const headerSubtitleStyle: CSSProperties = { |
| fontSize: "var(--font-size-xs)", |
| color: "var(--color-text-muted)", |
| }; |
|
|
| const closeButtonStyle: CSSProperties = { |
| display: "flex", |
| alignItems: "center", |
| justifyContent: "center", |
| width: "32px", |
| height: "32px", |
| borderRadius: "var(--border-radius-md)", |
| border: "2px solid var(--color-border)", |
| backgroundColor: "var(--color-surface)", |
| color: "var(--color-text-muted)", |
| cursor: "pointer", |
| transition: "all var(--transition-fast)", |
| }; |
|
|
| const contentContainerStyle: CSSProperties = { |
| flex: 1, |
| overflowY: "auto", |
| overflowX: "hidden", |
| scrollbarGutter: "stable", |
| }; |
|
|
| const contentInnerStyle: CSSProperties = { |
| padding: "var(--spacing-lg)", |
| display: "flex", |
| flexDirection: "column", |
| gap: "var(--spacing-lg)", |
| }; |
|
|
| const loadingContainerStyle: CSSProperties = { |
| display: "flex", |
| flexDirection: "column", |
| alignItems: "center", |
| justifyContent: "center", |
| padding: "var(--spacing-2xl)", |
| gap: "var(--spacing-md)", |
| color: "var(--color-text-muted)", |
| }; |
|
|
| const loadingSpinnerStyle: CSSProperties = { |
| animation: "spin 1s linear infinite", |
| color: "var(--color-accent)", |
| }; |
|
|
| const loadingTextStyle: CSSProperties = { |
| fontSize: "var(--font-size-sm)", |
| display: "flex", |
| alignItems: "center", |
| gap: "var(--spacing-sm)", |
| }; |
|
|
| const loadingDotsStyle: CSSProperties = { |
| display: "inline-flex", |
| gap: "4px", |
| }; |
|
|
| const errorContainerStyle: CSSProperties = { |
| display: "flex", |
| flexDirection: "column", |
| alignItems: "center", |
| justifyContent: "center", |
| padding: "var(--spacing-2xl)", |
| gap: "var(--spacing-md)", |
| color: "var(--color-text-muted)", |
| textAlign: "center", |
| }; |
|
|
| const errorIconStyle: CSSProperties = { |
| color: "#ef4444", |
| }; |
|
|
| const reasoningSectionStyle: CSSProperties = { |
| borderRadius: "var(--border-radius-md)", |
| border: "1px solid var(--color-border)", |
| backgroundColor: "var(--color-surface)", |
| overflow: "hidden", |
| }; |
|
|
| const reasoningHeaderStyle: CSSProperties = { |
| display: "flex", |
| alignItems: "center", |
| gap: "var(--spacing-sm)", |
| padding: "var(--spacing-sm) var(--spacing-md)", |
| cursor: "pointer", |
| userSelect: "none", |
| backgroundColor: "var(--color-surface)", |
| transition: "background-color var(--transition-fast)", |
| }; |
|
|
| const reasoningIconStyle: CSSProperties = { |
| color: "var(--color-accent)", |
| flexShrink: 0, |
| }; |
|
|
| const reasoningTitleStyle: CSSProperties = { |
| flex: 1, |
| fontSize: "var(--font-size-sm)", |
| fontWeight: 600, |
| color: "var(--color-text-primary)", |
| display: "flex", |
| alignItems: "center", |
| gap: "var(--spacing-xs)", |
| }; |
|
|
| const pulsingDotStyle: CSSProperties = { |
| width: "6px", |
| height: "6px", |
| borderRadius: "50%", |
| backgroundColor: "var(--color-accent)", |
| animation: "pulse 1.5s ease-in-out infinite", |
| }; |
|
|
| const chevronStyle: CSSProperties = { |
| color: "var(--color-text-muted)", |
| flexShrink: 0, |
| transition: "transform var(--transition-fast)", |
| }; |
|
|
| const reasoningContentStyle: CSSProperties = { |
| maxHeight: "350px", |
| overflowY: "auto", |
| padding: "var(--spacing-md)", |
| borderTop: "1px solid var(--color-border)", |
| scrollbarGutter: "stable", |
| }; |
|
|
| const reasoningTextStyle: CSSProperties = { |
| fontSize: "var(--font-size-sm)", |
| color: "var(--color-text-secondary)", |
| lineHeight: 1.7, |
| }; |
|
|
| const analysisContentStyle: CSSProperties = { |
| fontSize: "var(--font-size-base)", |
| color: "var(--color-text-primary)", |
| lineHeight: 1.8, |
| }; |
|
|
| const streamingIndicatorStyle: CSSProperties = { |
| display: "flex", |
| alignItems: "center", |
| gap: "var(--spacing-sm)", |
| padding: "var(--spacing-sm) 0", |
| color: "var(--color-text-muted)", |
| fontSize: "var(--font-size-sm)", |
| }; |
|
|
| const streamingDotStyle: CSSProperties = { |
| width: "8px", |
| height: "8px", |
| borderRadius: "50%", |
| backgroundColor: "var(--color-accent)", |
| animation: "pulse 1s ease-in-out infinite", |
| }; |
|
|
| const isNearBottom = (element: HTMLElement): boolean => { |
| return element.scrollHeight - element.scrollTop - element.clientHeight < 5; |
| }; |
|
|
| export const AnalysisModal = ({ analysisState, onClose }: AnalysisModalProps): JSX.Element | null => { |
| const contentRef = useRef<HTMLDivElement>(null); |
| const reasoningContentRef = useRef<HTMLDivElement>(null); |
| const [isReasoningExpanded, setIsReasoningExpanded] = useState<boolean>(false); |
| const [userToggledReasoning, setUserToggledReasoning] = useState<boolean>(false); |
|
|
| const userScrolledContentRef = useRef<boolean>(false); |
| const userScrolledReasoningRef = useRef<boolean>(false); |
| const lastContentScrollTopRef = useRef<number>(0); |
| const lastReasoningScrollTopRef = useRef<number>(0); |
| const hasAutoCollapsedRef = useRef<boolean>(false); |
| const reasoningLengthRef = useRef<number>(0); |
|
|
| useEffect((): void => { |
| if (userToggledReasoning) { |
| return; |
| } |
|
|
| if (analysisState.isLoading && analysisState.reasoning.length > 0 && !analysisState.isReasoningComplete) { |
| setIsReasoningExpanded(true); |
| } |
|
|
| if (analysisState.isReasoningComplete && analysisState.reasoning.length > 0 && !hasAutoCollapsedRef.current) { |
| hasAutoCollapsedRef.current = true; |
| setIsReasoningExpanded(false); |
| } |
| }, [analysisState.isLoading, analysisState.isReasoningComplete, analysisState.reasoning, userToggledReasoning]); |
|
|
| useEffect((): void => { |
| if (!contentRef.current || !analysisState.content) { |
| return; |
| } |
|
|
| const element = contentRef.current; |
|
|
| if (!userScrolledContentRef.current || isNearBottom(element)) { |
| requestAnimationFrame((): void => { |
| element.scrollTop = element.scrollHeight; |
| }); |
| } |
| }, [analysisState.content]); |
|
|
| useEffect((): (() => void) | void => { |
| if (!isReasoningExpanded) { |
| return; |
| } |
|
|
| const currentLength = analysisState.reasoning.length; |
| const isNewContent = currentLength > reasoningLengthRef.current; |
| reasoningLengthRef.current = currentLength; |
|
|
| if (!isNewContent && currentLength > 0) { |
| return; |
| } |
|
|
| const scrollToBottom = (): void => { |
| const element = reasoningContentRef.current; |
|
|
| if (!element) { |
| return; |
| } |
|
|
| if (!userScrolledReasoningRef.current || isNearBottom(element)) { |
| element.scrollTop = element.scrollHeight; |
| } |
| }; |
|
|
| const rafId = requestAnimationFrame((): void => { |
| requestAnimationFrame(scrollToBottom); |
| }); |
|
|
| return (): void => { |
| cancelAnimationFrame(rafId); |
| }; |
| }, [analysisState.reasoning, isReasoningExpanded]); |
|
|
| const handleContentScroll = useCallback((): void => { |
| if (!contentRef.current) { |
| return; |
| } |
|
|
| const element = contentRef.current; |
| const currentScrollTop = element.scrollTop; |
| const scrollingUp = currentScrollTop < lastContentScrollTopRef.current; |
|
|
| if (scrollingUp && !isNearBottom(element)) { |
| userScrolledContentRef.current = true; |
| } |
|
|
| if (isNearBottom(element)) { |
| userScrolledContentRef.current = false; |
| } |
|
|
| lastContentScrollTopRef.current = currentScrollTop; |
| }, []); |
|
|
| const handleReasoningScroll = useCallback((): void => { |
| if (!reasoningContentRef.current) { |
| return; |
| } |
|
|
| const element = reasoningContentRef.current; |
| const currentScrollTop = element.scrollTop; |
| const scrollingUp = currentScrollTop < lastReasoningScrollTopRef.current; |
|
|
| if (scrollingUp && !isNearBottom(element)) { |
| userScrolledReasoningRef.current = true; |
| } |
|
|
| if (isNearBottom(element)) { |
| userScrolledReasoningRef.current = false; |
| } |
|
|
| lastReasoningScrollTopRef.current = currentScrollTop; |
| }, []); |
|
|
| useEffect((): (() => void) => { |
| const handleEscape = (event: KeyboardEvent): void => { |
| if (event.key === "Escape") { |
| onClose(); |
| } |
| }; |
|
|
| document.addEventListener("keydown", handleEscape); |
|
|
| return (): void => { |
| document.removeEventListener("keydown", handleEscape); |
| }; |
| }, [onClose]); |
|
|
| useEffect((): void => { |
| if (!analysisState.isOpen) { |
| setIsReasoningExpanded(false); |
| setUserToggledReasoning(false); |
| hasAutoCollapsedRef.current = false; |
| userScrolledContentRef.current = false; |
| userScrolledReasoningRef.current = false; |
| lastContentScrollTopRef.current = 0; |
| lastReasoningScrollTopRef.current = 0; |
| reasoningLengthRef.current = 0; |
| } |
| }, [analysisState.isOpen]); |
|
|
| if (!analysisState.isOpen) { |
| return null; |
| } |
|
|
| const handleReasoningToggle = (): void => { |
| setUserToggledReasoning(true); |
| setIsReasoningExpanded((previous) => !previous); |
| }; |
|
|
| const renderReasoningSection = (): JSX.Element | null => { |
| if (!analysisState.reasoning) { |
| return null; |
| } |
|
|
| const reasoningHtml = renderMarkdown(analysisState.reasoning); |
|
|
| return ( |
| <div style={reasoningSectionStyle}> |
| <div |
| style={reasoningHeaderStyle} |
| onClick={handleReasoningToggle} |
| role="button" |
| tabIndex={0} |
| onKeyDown={(event): void => { |
| if (event.key === "Enter" || event.key === " ") { |
| handleReasoningToggle(); |
| } |
| }} |
| > |
| <Brain size={16} style={reasoningIconStyle} /> |
| <span style={reasoningTitleStyle}> |
| {siteConfig.messages.reasoning} |
| {analysisState.isLoading && !analysisState.isReasoningComplete && ( |
| <span style={pulsingDotStyle} /> |
| )} |
| </span> |
| {isReasoningExpanded ? ( |
| <ChevronUp size={16} style={chevronStyle} /> |
| ) : ( |
| <ChevronDown size={16} style={chevronStyle} /> |
| )} |
| </div> |
| {isReasoningExpanded && ( |
| <div |
| ref={reasoningContentRef} |
| style={reasoningContentStyle} |
| onScroll={handleReasoningScroll} |
| > |
| <div |
| style={reasoningTextStyle} |
| dangerouslySetInnerHTML={{ __html: reasoningHtml }} |
| /> |
| </div> |
| )} |
| </div> |
| ); |
| }; |
|
|
| const renderContent = (): JSX.Element => { |
| if (analysisState.error) { |
| return ( |
| <div style={errorContainerStyle}> |
| <AlertCircle size={40} style={errorIconStyle} /> |
| <span>{analysisState.error}</span> |
| </div> |
| ); |
| } |
|
|
| if (analysisState.isLoading && !analysisState.reasoning && !analysisState.content) { |
| return ( |
| <div style={loadingContainerStyle}> |
| <Sparkles size={40} style={loadingSpinnerStyle} /> |
| <div style={loadingTextStyle}> |
| <span>{siteConfig.messages.analyzing}</span> |
| <span style={loadingDotsStyle}> |
| <span style={{ ...pulsingDotStyle, animationDelay: "0s" }} /> |
| <span style={{ ...pulsingDotStyle, animationDelay: "0.2s" }} /> |
| <span style={{ ...pulsingDotStyle, animationDelay: "0.4s" }} /> |
| </span> |
| </div> |
| </div> |
| ); |
| } |
|
|
| const contentHtml = renderMarkdown(analysisState.content); |
|
|
| return ( |
| <div style={contentInnerStyle}> |
| {renderReasoningSection()} |
| {analysisState.content && ( |
| <div |
| style={analysisContentStyle} |
| dangerouslySetInnerHTML={{ __html: contentHtml }} |
| /> |
| )} |
| {analysisState.isLoading && analysisState.content && ( |
| <div style={streamingIndicatorStyle}> |
| <span style={streamingDotStyle} /> |
| <span>{siteConfig.messages.analyzing}</span> |
| </div> |
| )} |
| </div> |
| ); |
| }; |
|
|
| const modalContent = ( |
| <div style={overlayStyle}> |
| <div style={panelStyle}> |
| <div style={headerStyle}> |
| <div style={headerLeftStyle}> |
| <div style={headerIconContainerStyle}> |
| <Sparkles size={16} /> |
| </div> |
| <div> |
| <div style={headerTitleStyle}> |
| {analysisState.isLoading ? siteConfig.messages.analyzing : siteConfig.messages.analysisComplete} |
| </div> |
| <div style={headerSubtitleStyle}> |
| {siteConfig.messages.analysisInfo} |
| </div> |
| </div> |
| </div> |
| <button |
| type="button" |
| onClick={onClose} |
| style={closeButtonStyle} |
| title={siteConfig.messages.closeAnalysis} |
| > |
| <X size={18} /> |
| </button> |
| </div> |
| <div |
| ref={contentRef} |
| style={contentContainerStyle} |
| onScroll={handleContentScroll} |
| > |
| {renderContent()} |
| </div> |
| </div> |
| </div> |
| ); |
|
|
| return createPortal(modalContent, document.body); |
| }; |