blog / src /client /components /ai /AnalysisModal.tsx
hadadrjt's picture
blog: Bump to 0.0.6 version.
6c25065
//
// SPDX-FileCopyrightText: Hadad <hadad@linuxmail.org>
// SPDX-License-Identifier: Apache-2.0
//
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);
};