diff --git a/frontend/components/q&a/AccordionList.tsx b/frontend/components/q&a/AccordionList.tsx index f066a195..2f7f4893 100644 --- a/frontend/components/q&a/AccordionList.tsx +++ b/frontend/components/q&a/AccordionList.tsx @@ -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'; @@ -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 { + 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) { + 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(); } @@ -327,17 +367,30 @@ export default function AccordionList({ items }: { items: QuestionEntry[] }) { const [cachedTerms, setCachedTerms] = useState>( () => new Set(getCachedTerms().map(normalizeCachedTerm)) ); + const [viewedItems, setViewedItems] = useState>(new Set()); + const [bookmarkedItems, setBookmarkedItems] = useState>(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); @@ -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 ( <> {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', @@ -418,8 +502,49 @@ export default function AccordionList({ items }: { items: QuestionEntry[] }) { markAsViewed(questionId)} + trailing={ + isViewed ? ( + + ) : ( + (); + +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(), @@ -15,8 +32,24 @@ vi.mock('@/components/ui/accordion', () => ({ AccordionItem: ({ children }: { children: React.ReactNode }) => (
{children}
), - AccordionTrigger: ({ children }: { children: React.ReactNode }) => ( - + AccordionTrigger: ({ + children, + leading, + trailing, + onClick, + }: { + children: React.ReactNode; + leading?: React.ReactNode; + trailing?: React.ReactNode; + onClick?: () => void; + }) => ( +
+ {leading} + + {trailing} +
), AccordionContent: ({ children }: { children: React.ReactNode }) => (
{children}
@@ -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(); + + 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', () => { @@ -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(); + + 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(); + + 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[] = [ { diff --git a/frontend/components/ui/accordion.tsx b/frontend/components/ui/accordion.tsx index b82473f0..7cb92c5a 100644 --- a/frontend/components/ui/accordion.tsx +++ b/frontend/components/ui/accordion.tsx @@ -28,14 +28,20 @@ function AccordionItem({ function AccordionTrigger({ className, children, + leading, + trailing, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + leading?: React.ReactNode; + trailing?: React.ReactNode; +}) { return ( + {leading} 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} @@ -43,9 +49,10 @@ function AccordionTrigger({ {children} + {trailing} ); }