Skip to content

Commit 0c9d898

Browse files
authored
[Refactor] Code Quality Improvements: Accessibility, Mobile Support, … (#213)
1 parent 74ee1f1 commit 0c9d898

8 files changed

Lines changed: 81 additions & 32221 deletions

File tree

frontend/app/api/ai/explain/route.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
createExplainPrompt,
66
type ExplanationResponse,
77
} from '@/lib/ai/prompts';
8+
import { getClientIp } from '@/lib/security/rate-limit';
89

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

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

4741
function checkRateLimit(ip: string): { allowed: boolean; remaining: number; resetIn: number } {
4842
cleanupRateLimiter();
@@ -104,7 +98,7 @@ export async function POST(request: NextRequest) {
10498
);
10599
}
106100

107-
const clientIp = getClientIp(request);
101+
const clientIp = getClientIp(request) ?? 'unknown';
108102
const rateLimit = checkRateLimit(clientIp);
109103

110104
if (!rateLimit.allowed) {

frontend/components/q&a/AccordionList.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import SelectableText from '@/components/q&a/SelectableText';
2020
import FloatingExplainButton from '@/components/q&a/FloatingExplainButton';
2121
import AIWordHelper from '@/components/q&a/AIWordHelper';
2222
import HighlightCachedTerms from '@/components/q&a/HighlightCachedTerms';
23-
import { getCachedTerms } from '@/lib/ai/explainCache';
23+
import { getCachedTerms, CACHE_KEY } from '@/lib/ai/explainCache';
2424
import type {
2525
AnswerBlock,
2626
BulletListBlock,
@@ -315,7 +315,7 @@ export default function AccordionList({ items }: { items: QuestionEntry[] }) {
315315

316316
useEffect(() => {
317317
const handleStorage = (e: StorageEvent) => {
318-
if (e.key === 'ai-word-explanations') {
318+
if (e.key === CACHE_KEY) {
319319
refreshCachedTerms();
320320
}
321321
};

frontend/components/q&a/FloatingExplainButton.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,20 @@ export default function FloatingExplainButton({
1717
}: FloatingExplainButtonProps) {
1818
const t = useTranslations('aiHelper');
1919
const buttonRef = useRef<HTMLButtonElement>(null);
20+
const onDismissRef = useRef(onDismiss);
21+
22+
useEffect(() => {
23+
onDismissRef.current = onDismiss;
24+
});
2025

2126
useEffect(() => {
2227
const handleScroll = () => {
23-
onDismiss();
28+
onDismissRef.current();
2429
};
2530

2631
const handleClickOutside = (e: MouseEvent) => {
2732
if (buttonRef.current && !buttonRef.current.contains(e.target as Node)) {
28-
onDismiss();
33+
onDismissRef.current();
2934
}
3035
};
3136

@@ -39,7 +44,7 @@ export default function FloatingExplainButton({
3944
window.removeEventListener('scroll', handleScroll, true);
4045
document.removeEventListener('mousedown', handleClickOutside);
4146
};
42-
}, [onDismiss]);
47+
}, []);
4348

4449
return (
4550
<button

frontend/components/q&a/HighlightCachedTerms.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,15 +77,25 @@ export default function HighlightCachedTerms({
7777
return (
7878
<span
7979
key={index}
80+
role="button"
81+
tabIndex={0}
8082
onClick={e => {
8183
e.stopPropagation();
8284
onTermClick?.(segment.originalTerm || segment.text);
8385
}}
86+
onKeyDown={e => {
87+
if (e.key === 'Enter' || e.key === ' ') {
88+
e.preventDefault();
89+
e.stopPropagation();
90+
onTermClick?.(segment.originalTerm || segment.text);
91+
}
92+
}}
8493
className={cn(
8594
'cursor-pointer',
8695
'border-b border-dashed border-emerald-500/60',
8796
'bg-emerald-50/50 dark:bg-emerald-900/20',
8897
'hover:bg-emerald-100 dark:hover:bg-emerald-900/40',
98+
'focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:ring-offset-1',
8999
'transition-colors duration-150',
90100
'rounded-sm px-0.5 -mx-0.5'
91101
)}

frontend/components/q&a/SelectableText.tsx

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { ReactNode, useCallback, useRef } from 'react';
3+
import { ReactNode, useEffect, useRef } from 'react';
44

55
interface SelectableTextProps {
66
children: ReactNode;
@@ -16,46 +16,61 @@ export default function SelectableText({
1616
minSelectionLength = 2,
1717
}: SelectableTextProps) {
1818
const containerRef = useRef<HTMLDivElement>(null);
19+
const onTextSelectRef = useRef(onTextSelect);
20+
const onSelectionClearRef = useRef(onSelectionClear);
1921

20-
const handleMouseUp = useCallback(() => {
21-
setTimeout(() => {
22+
// Keep refs updated with latest callbacks
23+
useEffect(() => {
24+
onTextSelectRef.current = onTextSelect;
25+
onSelectionClearRef.current = onSelectionClear;
26+
});
27+
28+
useEffect(() => {
29+
const handleSelectionChange = () => {
2230
const selection = window.getSelection();
2331

2432
if (!selection || selection.isCollapsed) {
25-
onSelectionClear();
33+
onSelectionClearRef.current();
2634
return;
2735
}
2836

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

3139
if (selectedText.length < minSelectionLength) {
32-
onSelectionClear();
40+
onSelectionClearRef.current();
3341
return;
3442
}
3543

44+
// Check if selection is within our container
3645
if (containerRef.current) {
3746
const range = selection.getRangeAt(0);
3847
const selectionContainer = range.commonAncestorContainer;
3948

4049
if (!containerRef.current.contains(selectionContainer)) {
4150
return;
4251
}
43-
}
4452

45-
const range = selection.getRangeAt(0);
46-
const rect = range.getBoundingClientRect();
53+
const rect = range.getBoundingClientRect();
54+
55+
const position = {
56+
x: rect.left + rect.width / 2,
57+
y: rect.top,
58+
};
59+
60+
onTextSelectRef.current(selectedText, position);
61+
}
62+
};
4763

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

53-
onTextSelect(selectedText, position);
54-
}, 10);
55-
}, [onTextSelect, onSelectionClear, minSelectionLength]);
67+
return () => {
68+
document.removeEventListener('selectionchange', handleSelectionChange);
69+
};
70+
}, [minSelectionLength]);
5671

5772
return (
58-
<div ref={containerRef} onMouseUp={handleMouseUp} className="cursor-text">
73+
<div ref={containerRef} className="cursor-text">
5974
{children}
6075
</div>
6176
);

frontend/lib/ai/explainCache.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ExplanationResponse } from './prompts';
22

3-
const CACHE_KEY = 'ai-word-explanations';
3+
export const CACHE_KEY = 'ai-word-explanations';
44
const CACHE_VERSION = 1;
55

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

24+
function isValidCacheData(data: unknown): data is CacheData {
25+
return (
26+
typeof data === 'object' &&
27+
data !== null &&
28+
'version' in data &&
29+
typeof (data as CacheData).version === 'number' &&
30+
'entries' in data &&
31+
typeof (data as CacheData).entries === 'object' &&
32+
(data as CacheData).entries !== null
33+
);
34+
}
35+
36+
function getDefaultCache(): CacheData {
37+
return { version: CACHE_VERSION, entries: {} };
38+
}
39+
2440
function readCache(): CacheData {
2541
if (!isBrowser()) {
26-
return { version: CACHE_VERSION, entries: {} };
42+
return getDefaultCache();
2743
}
2844

2945
try {
3046
const raw = localStorage.getItem(CACHE_KEY);
3147
if (!raw) {
32-
return { version: CACHE_VERSION, entries: {} };
48+
return getDefaultCache();
3349
}
3450

35-
const data = JSON.parse(raw) as CacheData;
51+
const data: unknown = JSON.parse(raw);
52+
53+
if (!isValidCacheData(data)) {
54+
localStorage.removeItem(CACHE_KEY);
55+
return getDefaultCache();
56+
}
3657

3758
if (data.version !== CACHE_VERSION) {
3859
localStorage.removeItem(CACHE_KEY);
39-
return { version: CACHE_VERSION, entries: {} };
60+
return getDefaultCache();
4061
}
4162

4263
return data;
4364
} catch {
44-
return { version: CACHE_VERSION, entries: {} };
65+
localStorage.removeItem(CACHE_KEY);
66+
return getDefaultCache();
4567
}
4668
}
4769

0 commit comments

Comments
 (0)