Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions frontend/app/api/ai/explain/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createExplainPrompt,
type ExplanationResponse,
} from '@/lib/ai/prompts';
import { getClientIp } from '@/lib/security/rate-limit';

const rateLimiter = new Map<string, { count: number; resetAt: number }>();
const MAX_REQUESTS_PER_WINDOW = 10;
Expand Down Expand Up @@ -36,13 +37,6 @@ const requestSchema = z.object({
.optional(),
});

function getClientIp(request: NextRequest): string {
const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) {
return forwarded.split(',')[0].trim();
}
return request.headers.get('x-real-ip') || 'unknown';
}

function checkRateLimit(ip: string): { allowed: boolean; remaining: number; resetIn: number } {
cleanupRateLimiter();
Expand Down Expand Up @@ -104,7 +98,7 @@ export async function POST(request: NextRequest) {
);
}

const clientIp = getClientIp(request);
const clientIp = getClientIp(request) ?? 'unknown';
const rateLimit = checkRateLimit(clientIp);

if (!rateLimit.allowed) {
Expand Down
4 changes: 2 additions & 2 deletions frontend/components/q&a/AccordionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import SelectableText from '@/components/q&a/SelectableText';
import FloatingExplainButton from '@/components/q&a/FloatingExplainButton';
import AIWordHelper from '@/components/q&a/AIWordHelper';
import HighlightCachedTerms from '@/components/q&a/HighlightCachedTerms';
import { getCachedTerms } from '@/lib/ai/explainCache';
import { getCachedTerms, CACHE_KEY } from '@/lib/ai/explainCache';
import type {
AnswerBlock,
BulletListBlock,
Expand Down Expand Up @@ -315,7 +315,7 @@ export default function AccordionList({ items }: { items: QuestionEntry[] }) {

useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === 'ai-word-explanations') {
if (e.key === CACHE_KEY) {
refreshCachedTerms();
}
};
Expand Down
11 changes: 8 additions & 3 deletions frontend/components/q&a/FloatingExplainButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,20 @@ export default function FloatingExplainButton({
}: FloatingExplainButtonProps) {
const t = useTranslations('aiHelper');
const buttonRef = useRef<HTMLButtonElement>(null);
const onDismissRef = useRef(onDismiss);

useEffect(() => {
onDismissRef.current = onDismiss;
});

useEffect(() => {
const handleScroll = () => {
onDismiss();
onDismissRef.current();
};

const handleClickOutside = (e: MouseEvent) => {
if (buttonRef.current && !buttonRef.current.contains(e.target as Node)) {
onDismiss();
onDismissRef.current();
}
};

Expand All @@ -39,7 +44,7 @@ export default function FloatingExplainButton({
window.removeEventListener('scroll', handleScroll, true);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onDismiss]);
}, []);

return (
<button
Expand Down
10 changes: 10 additions & 0 deletions frontend/components/q&a/HighlightCachedTerms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,25 @@ export default function HighlightCachedTerms({
return (
<span
key={index}
role="button"
tabIndex={0}
onClick={e => {
e.stopPropagation();
onTermClick?.(segment.originalTerm || segment.text);
}}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onTermClick?.(segment.originalTerm || segment.text);
}
}}
className={cn(
'cursor-pointer',
'border-b border-dashed border-emerald-500/60',
'bg-emerald-50/50 dark:bg-emerald-900/20',
'hover:bg-emerald-100 dark:hover:bg-emerald-900/40',
'focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:ring-offset-1',
'transition-colors duration-150',
'rounded-sm px-0.5 -mx-0.5'
)}
Expand Down
47 changes: 31 additions & 16 deletions frontend/components/q&a/SelectableText.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { ReactNode, useCallback, useRef } from 'react';
import { ReactNode, useEffect, useRef } from 'react';

interface SelectableTextProps {
children: ReactNode;
Expand All @@ -16,46 +16,61 @@ export default function SelectableText({
minSelectionLength = 2,
}: SelectableTextProps) {
const containerRef = useRef<HTMLDivElement>(null);
const onTextSelectRef = useRef(onTextSelect);
const onSelectionClearRef = useRef(onSelectionClear);

const handleMouseUp = useCallback(() => {
setTimeout(() => {
// Keep refs updated with latest callbacks
useEffect(() => {
onTextSelectRef.current = onTextSelect;
onSelectionClearRef.current = onSelectionClear;
});

useEffect(() => {
const handleSelectionChange = () => {
const selection = window.getSelection();

if (!selection || selection.isCollapsed) {
onSelectionClear();
onSelectionClearRef.current();
return;
}

const selectedText = selection.toString().trim();

if (selectedText.length < minSelectionLength) {
onSelectionClear();
onSelectionClearRef.current();
return;
}

// Check if selection is within our container
if (containerRef.current) {
const range = selection.getRangeAt(0);
const selectionContainer = range.commonAncestorContainer;

if (!containerRef.current.contains(selectionContainer)) {
return;
}
}

const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
const rect = range.getBoundingClientRect();

const position = {
x: rect.left + rect.width / 2,
y: rect.top,
};

onTextSelectRef.current(selectedText, position);
}
};

const position = {
x: rect.left + rect.width / 2,
y: rect.top,
};
// Use selectionchange event - works on desktop (mouse/keyboard) and mobile (touch)
document.addEventListener('selectionchange', handleSelectionChange);

onTextSelect(selectedText, position);
}, 10);
}, [onTextSelect, onSelectionClear, minSelectionLength]);
return () => {
document.removeEventListener('selectionchange', handleSelectionChange);
};
}, [minSelectionLength]);

return (
<div ref={containerRef} onMouseUp={handleMouseUp} className="cursor-text">
<div ref={containerRef} className="cursor-text">
{children}
</div>
);
Expand Down
34 changes: 28 additions & 6 deletions frontend/lib/ai/explainCache.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ExplanationResponse } from './prompts';

const CACHE_KEY = 'ai-word-explanations';
export const CACHE_KEY = 'ai-word-explanations';
const CACHE_VERSION = 1;

interface CacheEntry {
Expand All @@ -21,27 +21,49 @@ function isBrowser(): boolean {
return typeof window !== 'undefined' && typeof localStorage !== 'undefined';
}

function isValidCacheData(data: unknown): data is CacheData {
return (
typeof data === 'object' &&
data !== null &&
'version' in data &&
typeof (data as CacheData).version === 'number' &&
'entries' in data &&
typeof (data as CacheData).entries === 'object' &&
(data as CacheData).entries !== null
);
}

function getDefaultCache(): CacheData {
return { version: CACHE_VERSION, entries: {} };
}

function readCache(): CacheData {
if (!isBrowser()) {
return { version: CACHE_VERSION, entries: {} };
return getDefaultCache();
}

try {
const raw = localStorage.getItem(CACHE_KEY);
if (!raw) {
return { version: CACHE_VERSION, entries: {} };
return getDefaultCache();
}

const data = JSON.parse(raw) as CacheData;
const data: unknown = JSON.parse(raw);

if (!isValidCacheData(data)) {
localStorage.removeItem(CACHE_KEY);
return getDefaultCache();
}

if (data.version !== CACHE_VERSION) {
localStorage.removeItem(CACHE_KEY);
return { version: CACHE_VERSION, entries: {} };
return getDefaultCache();
}

return data;
} catch {
return { version: CACHE_VERSION, entries: {} };
localStorage.removeItem(CACHE_KEY);
return getDefaultCache();
}
}

Expand Down
Loading