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
127 changes: 126 additions & 1 deletion frontend/components/q&a/AccordionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
useEffect,
useState,
} from 'react';
import { Bookmark } from 'lucide-react';

import { Badge } from '@/components/ui/badge';
import AIWordHelper from '@/components/q&a/AIWordHelper';
import CodeBlock from '@/components/q&a/CodeBlock';
import FloatingExplainButton from '@/components/q&a/FloatingExplainButton';
Expand Down Expand Up @@ -41,6 +43,44 @@ type QaItemStyle = CSSProperties & {
'--qa-accent-soft': string;
};

const QA_VIEWED_STORAGE_KEY = 'devlovers_qa_viewed_questions';
const QA_BOOKMARK_STORAGE_KEY = 'devlovers_qa_bookmarked_questions';

function readStoredQuestionIds(storageKey: string): Set<string> {
if (typeof window === 'undefined') return new Set();

try {
const raw = window.localStorage.getItem(storageKey);
if (!raw) return new Set();

const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();

return new Set(
parsed.filter((value): value is string => typeof value === 'string')
);
} catch {
return new Set();
}
}

function writeStoredQuestionIds(storageKey: string, ids: Set<string>) {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(storageKey, JSON.stringify([...ids]));
} catch {
// Ignore storage write failures in restricted environments.
}
}

function getQuestionStorageId(question: QuestionEntry): string {
if (question.id !== undefined && question.id !== null) {
return String(question.id);
}

return `${question.category}:${question.question}`;
}

function normalizeCachedTerm(term: string): string {
return term.toLowerCase().trim();
}
Expand Down Expand Up @@ -327,17 +367,30 @@ export default function AccordionList({ items }: { items: QuestionEntry[] }) {
const [cachedTerms, setCachedTerms] = useState<Set<string>>(
() => new Set(getCachedTerms().map(normalizeCachedTerm))
);
const [viewedItems, setViewedItems] = useState<Set<string>>(new Set());
const [bookmarkedItems, setBookmarkedItems] = useState<Set<string>>(new Set());

const refreshCachedTerms = useCallback(() => {
const terms = getCachedTerms().map(normalizeCachedTerm);
setCachedTerms(new Set(terms));
}, []);

useEffect(() => {
setViewedItems(readStoredQuestionIds(QA_VIEWED_STORAGE_KEY));
setBookmarkedItems(readStoredQuestionIds(QA_BOOKMARK_STORAGE_KEY));
}, []);

useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === CACHE_KEY) {
refreshCachedTerms();
}
if (e.key === null || e.key === QA_VIEWED_STORAGE_KEY) {
setViewedItems(readStoredQuestionIds(QA_VIEWED_STORAGE_KEY));
}
if (e.key === null || e.key === QA_BOOKMARK_STORAGE_KEY) {
setBookmarkedItems(readStoredQuestionIds(QA_BOOKMARK_STORAGE_KEY));
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
Expand Down Expand Up @@ -393,15 +446,46 @@ export default function AccordionList({ items }: { items: QuestionEntry[] }) {
}
}, []);

const markAsViewed = useCallback((questionId: string) => {
setViewedItems(prev => {
if (prev.has(questionId)) {
return prev;
}

const next = new Set(prev);
next.add(questionId);
writeStoredQuestionIds(QA_VIEWED_STORAGE_KEY, next);
return next;
});
}, []);

const toggleBookmark = useCallback((questionId: string) => {
setBookmarkedItems(prev => {
const next = new Set(prev);

if (next.has(questionId)) {
next.delete(questionId);
} else {
next.add(questionId);
}

writeStoredQuestionIds(QA_BOOKMARK_STORAGE_KEY, next);
return next;
});
}, []);

return (
<>
<Accordion type="single" collapsible className="w-full">
{items.map((q, idx) => {
const key = q.id ?? idx;
const questionId = getQuestionStorageId(q);
const accentColor =
categoryTabStyles[q.category as keyof typeof categoryTabStyles]
?.accent ?? '#A1A1AA';
const animationDelay = `${Math.min(idx, 10) * 60}ms`;
const isViewed = viewedItems.has(questionId);
const isBookmarked = bookmarkedItems.has(questionId);
const itemStyle: QaItemStyle = {
animationDelay,
animationFillMode: 'both',
Expand All @@ -418,8 +502,49 @@ export default function AccordionList({ items }: { items: QuestionEntry[] }) {
<AccordionTrigger
className="px-4 hover:no-underline"
onPointerDown={clearSelection}
onClick={() => markAsViewed(questionId)}
trailing={
isViewed ? (
<button
type="button"
aria-label={isBookmarked ? 'Remove bookmark' : 'Add bookmark'}
aria-pressed={isBookmarked}
className="mr-2 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-red-500 transition-colors hover:bg-red-500/10 focus-visible:ring-2 focus-visible:ring-red-500/40 focus-visible:outline-none"
onClick={event => {
event.preventDefault();
event.stopPropagation();
toggleBookmark(questionId);
}}
>
<Bookmark
className="h-4 w-4"
fill={isBookmarked ? 'currentColor' : 'none'}
/>
</button>
) : (
<span
aria-hidden="true"
className="mr-2 inline-flex h-6 w-6 shrink-0"
/>
)
}
>
{q.question}
<span className="flex min-w-0 flex-1 items-center gap-3">
<span className="min-w-0 flex-1 truncate">{q.question}</span>
<span className="flex h-6 w-[82px] shrink-0 items-center justify-end">
<Badge
variant="success"
className={
isViewed
? 'h-6 gap-1 rounded-full px-2 py-0 text-[11px]'
: 'invisible h-6 gap-1 rounded-full px-2 py-0 text-[11px]'
}
>
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Viewed
</Badge>
</span>
</span>
</AccordionTrigger>
<AccordionContent className="px-4">
<SelectableText
Expand Down
126 changes: 124 additions & 2 deletions frontend/components/tests/q&a/accordion-list.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const getCachedTermsMock = vi.fn();
const storage = new Map<string, string>();

Object.defineProperty(window, 'localStorage', {
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
clear: () => {
storage.clear();
},
},
configurable: true,
});

vi.mock('@/lib/ai/explainCache', () => ({
getCachedTerms: () => getCachedTermsMock(),
Expand All @@ -15,8 +32,24 @@ vi.mock('@/components/ui/accordion', () => ({
AccordionItem: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AccordionTrigger: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
AccordionTrigger: ({
children,
leading,
trailing,
onClick,
}: {
children: React.ReactNode;
leading?: React.ReactNode;
trailing?: React.ReactNode;
onClick?: () => void;
}) => (
<div>
{leading}
<button type="button" onClick={onClick}>
{children}
</button>
{trailing}
</div>
),
AccordionContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
Expand Down Expand Up @@ -113,6 +146,32 @@ import type { QuestionEntry } from '@/components/q&a/types';
describe('AccordionList', () => {
beforeEach(() => {
getCachedTermsMock.mockReturnValue([]);
localStorage.clear();
});

it('uses a stable fallback id when question id is missing', () => {
const items: QuestionEntry[] = [
{
question: 'What is CSS?',
category: 'css',
answerBlocks: [
{
type: 'paragraph',
children: [{ text: 'CSS styles pages.' }],
},
],
},
];

render(<AccordionList items={items} />);

fireEvent.click(screen.getByText('What is CSS?'));

expect(
JSON.parse(
localStorage.getItem('devlovers_qa_viewed_questions') ?? '[]'
)
).toContain('css:What is CSS?');
});

it('renders questions and answer blocks', () => {
Expand All @@ -136,6 +195,69 @@ describe('AccordionList', () => {
expect(screen.getByText('CSS styles pages.')).toBeTruthy();
});

it('marks an accordion as viewed after opening it', () => {
const items: QuestionEntry[] = [
{
id: 'q1',
question: 'What is CSS?',
category: 'css',
answerBlocks: [
{
type: 'paragraph',
children: [{ text: 'CSS styles pages.' }],
},
],
},
];

render(<AccordionList items={items} />);

expect(
screen.queryByRole('button', { name: 'Add bookmark' })
).toBeNull();

fireEvent.click(screen.getByText('What is CSS?'));

expect(
screen.getByRole('button', { name: 'Add bookmark' })
).toBeTruthy();
expect(
JSON.parse(
localStorage.getItem('devlovers_qa_viewed_questions') ?? '[]'
)
).toContain('q1');
});

it('toggles bookmark state for viewed accordion', () => {
const items: QuestionEntry[] = [
{
id: 'q1',
question: 'What is CSS?',
category: 'css',
answerBlocks: [
{
type: 'paragraph',
children: [{ text: 'CSS styles pages.' }],
},
],
},
];

render(<AccordionList items={items} />);

fireEvent.click(screen.getByText('What is CSS?'));
fireEvent.click(screen.getByRole('button', { name: 'Add bookmark' }));

expect(
screen.getByRole('button', { name: 'Remove bookmark' })
).toBeTruthy();
expect(
JSON.parse(
localStorage.getItem('devlovers_qa_bookmarked_questions') ?? '[]'
)
).toContain('q1');
});

it('opens AI helper from selection', () => {
const items: QuestionEntry[] = [
{
Expand Down
13 changes: 10 additions & 3 deletions frontend/components/ui/accordion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,24 +28,31 @@ function AccordionItem({
function AccordionTrigger({
className,
children,
leading,
trailing,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
}: React.ComponentProps<typeof AccordionPrimitive.Trigger> & {
leading?: React.ReactNode;
trailing?: React.ReactNode;
}) {
return (
<AccordionPrimitive.Header className="flex">
{leading}
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-center justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
className
)}
{...props}
>
{children}
<ChevronDownIcon
aria-hidden="true"
className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
className="text-muted-foreground pointer-events-none size-4 shrink-0 transition-transform duration-200"
/>
</AccordionPrimitive.Trigger>
{trailing}
</AccordionPrimitive.Header>
);
}
Expand Down
Loading