blog / src /client /components /ai /TranslationModal.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, Globe, AlertCircle, Brain, ChevronDown, ChevronUp } from "lucide-react";
import { siteConfig } from "@shared/config";
import { renderMarkdown } from "../../utils/markdownRenderer";
import type { TranslationState, SupportedLanguage } from "@shared/types";
import type { CSSProperties } from "react";
interface TranslationModalProps {
translationState: TranslationState;
onClose: () => void;
onSelectLanguage: (language: SupportedLanguage) => 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 languageSelectorContainerStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
gap: "var(--spacing-md)",
};
const languageButtonsContainerStyle: CSSProperties = {
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
gap: "var(--spacing-sm)",
};
const languageButtonStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: "var(--spacing-xs)",
padding: "var(--spacing-md) var(--spacing-lg)",
borderRadius: "var(--border-radius-md)",
border: "2px solid var(--color-border)",
backgroundColor: "var(--color-surface)",
color: "var(--color-text-primary)",
cursor: "pointer",
transition: "all var(--transition-fast)",
fontSize: "var(--font-size-base)",
fontWeight: 500,
};
const languageButtonHoverStyle: CSSProperties = {
borderColor: "var(--color-accent)",
backgroundColor: "var(--color-background)",
};
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 translationContentStyle: 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;
};
const getHeaderTitle = (translationState: TranslationState): string => {
if (!translationState.targetLanguage) {
return siteConfig.messages.translationReady;
}
if (translationState.isLoading) {
return siteConfig.messages.translating;
}
return siteConfig.messages.translationComplete;
};
const getHeaderSubtitle = (translationState: TranslationState): string => {
if (!translationState.targetLanguage) {
return siteConfig.messages.selectLanguage;
}
return siteConfig.languages[translationState.targetLanguage].name;
};
export const TranslationModal = ({ translationState, onClose, onSelectLanguage }: TranslationModalProps): 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 [hoveredLanguage, setHoveredLanguage] = useState<SupportedLanguage | null>(null);
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 (translationState.isLoading && translationState.reasoning.length > 0 && !translationState.isReasoningComplete) {
setIsReasoningExpanded(true);
}
if (translationState.isReasoningComplete && translationState.reasoning.length > 0 && !hasAutoCollapsedRef.current) {
hasAutoCollapsedRef.current = true;
setIsReasoningExpanded(false);
}
}, [translationState.isLoading, translationState.isReasoningComplete, translationState.reasoning, userToggledReasoning]);
useEffect((): void => {
if (!contentRef.current || !translationState.content) {
return;
}
const element = contentRef.current;
if (!userScrolledContentRef.current || isNearBottom(element)) {
requestAnimationFrame((): void => {
element.scrollTop = element.scrollHeight;
});
}
}, [translationState.content]);
useEffect((): (() => void) | void => {
if (!isReasoningExpanded) {
return;
}
const currentLength = translationState.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);
};
}, [translationState.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 (!translationState.isOpen) {
setIsReasoningExpanded(false);
setUserToggledReasoning(false);
setHoveredLanguage(null);
hasAutoCollapsedRef.current = false;
userScrolledContentRef.current = false;
userScrolledReasoningRef.current = false;
lastContentScrollTopRef.current = 0;
lastReasoningScrollTopRef.current = 0;
reasoningLengthRef.current = 0;
}
}, [translationState.isOpen]);
if (!translationState.isOpen) {
return null;
}
const handleReasoningToggle = (): void => {
setUserToggledReasoning(true);
setIsReasoningExpanded((previous) => !previous);
};
const handleLanguageSelect = (language: SupportedLanguage): void => {
if (!translationState.isLoading) {
onSelectLanguage(language);
}
};
const renderLanguageSelector = (): JSX.Element | null => {
if (translationState.targetLanguage) {
return null;
}
const languages: Array<{ code: SupportedLanguage; name: string; flag: string }> = [
{ code: "id", name: siteConfig.languages.id.name, flag: siteConfig.languages.id.flag },
{ code: "zh", name: siteConfig.languages.zh.name, flag: siteConfig.languages.zh.flag },
{ code: "ja", name: siteConfig.languages.ja.name, flag: siteConfig.languages.ja.flag },
{ code: "fr", name: siteConfig.languages.fr.name, flag: siteConfig.languages.fr.flag },
{ code: "es", name: siteConfig.languages.es.name, flag: siteConfig.languages.es.flag },
];
return (
<div style={languageSelectorContainerStyle}>
<div style={languageButtonsContainerStyle}>
{languages.map((language) => (
<button
key={language.code}
type="button"
onClick={() => handleLanguageSelect(language.code)}
onMouseEnter={() => setHoveredLanguage(language.code)}
onMouseLeave={() => setHoveredLanguage(null)}
disabled={translationState.isLoading}
style={{
...languageButtonStyle,
...(hoveredLanguage === language.code ? languageButtonHoverStyle : {}),
}}
>
<span>{language.flag}</span>
<span>{language.name}</span>
</button>
))}
</div>
</div>
);
};
const renderReasoningSection = (): JSX.Element | null => {
if (!translationState.reasoning) {
return null;
}
const reasoningHtml = renderMarkdown(translationState.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}
{translationState.isLoading && !translationState.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 (translationState.error) {
return (
<div style={errorContainerStyle}>
<AlertCircle size={40} style={errorIconStyle} />
<span>{translationState.error}</span>
</div>
);
}
if (translationState.isLoading && !translationState.reasoning && !translationState.content && translationState.targetLanguage) {
return (
<div style={loadingContainerStyle}>
<Globe size={40} style={loadingSpinnerStyle} />
<div style={loadingTextStyle}>
<span>{siteConfig.messages.translating}</span>
<span style={loadingDotsStyle}>
<span style={{ ...pulsingDotStyle, animationDelay: "0s" }} />
<span style={{ ...pulsingDotStyle, animationDelay: "0.2s" }} />
<span style={{ ...pulsingDotStyle, animationDelay: "0.4s" }} />
</span>
</div>
</div>
);
}
if (!translationState.targetLanguage) {
return (
<div style={contentInnerStyle}>
{renderLanguageSelector()}
</div>
);
}
const contentHtml = translationState.content ? renderMarkdown(translationState.content) : "";
return (
<div style={contentInnerStyle}>
{renderReasoningSection()}
{translationState.content && (
<div
style={translationContentStyle}
dangerouslySetInnerHTML={{ __html: contentHtml }}
/>
)}
{translationState.isLoading && translationState.content && (
<div style={streamingIndicatorStyle}>
<span style={streamingDotStyle} />
<span>{siteConfig.messages.translating}</span>
</div>
)}
</div>
);
};
const headerTitle = getHeaderTitle(translationState);
const headerSubtitle = getHeaderSubtitle(translationState);
const modalContent = (
<div style={overlayStyle}>
<div style={panelStyle}>
<div style={headerStyle}>
<div style={headerLeftStyle}>
<div style={headerIconContainerStyle}>
<Globe size={16} />
</div>
<div>
<div style={headerTitleStyle}>
{headerTitle}
</div>
<div style={headerSubtitleStyle}>
{headerSubtitle}
</div>
</div>
</div>
<button
type="button"
onClick={onClose}
style={closeButtonStyle}
title={siteConfig.messages.closeTranslation}
>
<X size={18} />
</button>
</div>
<div
ref={contentRef}
style={contentContainerStyle}
onScroll={handleContentScroll}
>
{renderContent()}
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
};