Skip to content

Commit 622cde3

Browse files
Blog and Sanity: fixes and refactoring (#267)
1 parent 4ef0806 commit 622cde3

8 files changed

Lines changed: 208 additions & 24 deletions

File tree

frontend/app/[locale]/blog/[slug]/PostDetails.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ function renderPortableTextSpans(
8484
const text = child?.text || '';
8585
if (!text) return null;
8686
const marks = child?.marks || [];
87-
const linkKey = marks.find(mark => linkMap.has(mark));
8887

8988
let node: React.ReactNode = marks.length === 0 ? linkifyText(text) : text;
9089

@@ -280,11 +279,14 @@ function renderPortableText(
280279

281280
if (block?._type === 'image' && block?.url) {
282281
nodes.push(
283-
<img
282+
<Image
284283
key={block._key || `image-${i}`}
285284
src={block.url}
286285
alt={postTitle || 'Post image'}
287-
className="my-6 rounded-xl border border-gray-200"
286+
width={1200}
287+
height={800}
288+
sizes="100vw"
289+
className="my-6 h-auto w-full rounded-xl border border-gray-200"
288290
/>
289291
);
290292
i += 1;

frontend/components/blog/BlogFilters.tsx

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,6 @@ export default function BlogFilters({
192192
name: string;
193193
norm: string;
194194
} | null>(null);
195-
const [currentPage, setCurrentPage] = useState(1);
196195
const lastFiltersRef = useRef<{
197196
author: string;
198197
category: string;
@@ -327,11 +326,6 @@ export default function BlogFilters({
327326
didClearSearchRef.current = true;
328327
}, [pathname, router, searchParams, searchQuery]);
329328

330-
useEffect(() => {
331-
if (currentPage === parsedPage) return;
332-
setCurrentPage(parsedPage);
333-
}, [currentPage, parsedPage]);
334-
335329
const resolvedAuthor = useMemo(() => {
336330
const normParam = normalizeAuthor(authorParam);
337331
if (!normParam) return selectedAuthor;
@@ -363,12 +357,11 @@ export default function BlogFilters({
363357
prevFilters.query !== nextFilters.query;
364358
if (!hasChanged) return;
365359
lastFiltersRef.current = nextFilters;
366-
if (currentPage !== 1) {
367-
setCurrentPage(1);
360+
if (parsedPage !== 1) {
368361
updatePageParam(1);
369362
}
370363
}, [
371-
currentPage,
364+
parsedPage,
372365
resolvedAuthor?.norm,
373366
resolvedCategory?.norm,
374367
searchQueryNormalized,
@@ -402,7 +395,7 @@ export default function BlogFilters({
402395
}, [locale, resolvedAuthor?.name]);
403396

404397
useEffect(() => {
405-
if (!resolvedAuthor) return;
398+
if (!resolvedAuthor?.norm) return;
406399
const frame = window.requestAnimationFrame(() => {
407400
authorHeadingRef.current?.scrollIntoView({
408401
behavior: 'smooth',
@@ -446,6 +439,7 @@ export default function BlogFilters({
446439
1,
447440
Math.ceil(filteredPosts.length / POSTS_PER_PAGE)
448441
);
442+
const currentPage = Math.min(parsedPage, totalPages);
449443
const paginatedPosts = useMemo(() => {
450444
const start = (currentPage - 1) * POSTS_PER_PAGE;
451445
return filteredPosts.slice(start, start + POSTS_PER_PAGE);
@@ -462,10 +456,9 @@ export default function BlogFilters({
462456
}, [selectedAuthorData]);
463457

464458
useEffect(() => {
465-
if (currentPage <= totalPages) return;
466-
setCurrentPage(totalPages);
459+
if (parsedPage <= totalPages) return;
467460
updatePageParam(totalPages);
468-
}, [currentPage, totalPages, updatePageParam]);
461+
}, [parsedPage, totalPages, updatePageParam]);
469462

470463
const scrollToCategoryFilters = useCallback(() => {
471464
const target = categoryFiltersRef.current || postsGridRef.current;
@@ -494,11 +487,14 @@ export default function BlogFilters({
494487
href={`/blog/${featuredPost.slug.current}`}
495488
className="group block"
496489
>
497-
<div className="h-[300px] overflow-hidden rounded-3xl border-0 shadow-[0_12px_30px_rgba(0,0,0,0.12)] sm:h-[340px] md:h-full md:min-h-[400px] lg:min-h-[440px]">
498-
<img
490+
<div className="relative h-[300px] overflow-hidden rounded-3xl border-0 shadow-[0_12px_30px_rgba(0,0,0,0.12)] sm:h-[340px] md:h-full md:min-h-[400px] lg:min-h-[440px]">
491+
<Image
499492
src={featuredPost.mainImage}
500493
alt={featuredPost.title}
501-
className="block h-full w-full scale-[1.02] border-0 object-cover transition-transform duration-300 group-hover:scale-[1.05]"
494+
fill
495+
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 60vw, 720px"
496+
className="scale-[1.02] object-cover transition-transform duration-300 group-hover:scale-[1.05]"
497+
priority
502498
/>
503499
</div>
504500
</Link>
@@ -689,7 +685,6 @@ export default function BlogFilters({
689685
totalPages={totalPages}
690686
onPageChange={page => {
691687
pendingScrollRef.current = true;
692-
setCurrentPage(page);
693688
updatePageParam(page);
694689
}}
695690
accentColor="var(--accent-primary)"

frontend/components/blog/BlogHeaderSearch.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export function BlogHeaderSearch() {
9595
return () => {
9696
active = false;
9797
};
98-
}, [open, items.length, isLoading]);
98+
}, [open, items.length, isLoading, locale]);
9999

100100
useEffect(() => {
101101
if (!open) return;

frontend/components/tests/blog/blog-card.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import type { Author, Post } from '@/components/blog/BlogFilters';
77

88
vi.mock('next/image', () => ({
99
__esModule: true,
10-
default: (props: any) => <img {...props} />,
10+
// eslint-disable-next-line @next/next/no-img-element
11+
default: (props: any) => <img alt={props.alt ?? ''} {...props} />,
1112
}));
1213

1314
vi.mock('next/link', () => ({

frontend/components/tests/blog/blog-filters.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// @vitest-environment jsdom
22
import { render, screen, waitFor } from '@testing-library/react';
3-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { afterEach, describe, expect, it, vi } from 'vitest';
44

55
import type { Author, Post } from '@/components/blog/BlogFilters';
66
import BlogFilters from '@/components/blog/BlogFilters';

frontend/components/tests/blog/blog-header-search.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// @vitest-environment jsdom
22
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { afterEach, describe, expect, it, vi } from 'vitest';
44

55
import { BlogHeaderSearch } from '@/components/blog/BlogHeaderSearch';
66

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2+
3+
import {
4+
createEncryptedAnswersBlob,
5+
decryptAnswers,
6+
encryptAnswers,
7+
} from '@/lib/quiz/quiz-crypto';
8+
9+
import {
10+
createCorrectAnswersMap,
11+
createMockQuestions,
12+
resetFactoryCounters,
13+
} from '../factories/quiz/quiz';
14+
import { cleanupQuizTestEnv, setupQuizTestEnv } from './setup';
15+
16+
describe('quiz-crypto', () => {
17+
// Setup: set encryption key before each test
18+
beforeEach(() => {
19+
setupQuizTestEnv();
20+
resetFactoryCounters();
21+
});
22+
23+
// Cleanup: remove encryption key after each test
24+
afterEach(() => {
25+
cleanupQuizTestEnv();
26+
});
27+
28+
describe('encryptAnswers', () => {
29+
it('returns a base64 string', () => {
30+
const answers = { 'q-1': 'a-1' };
31+
32+
const result = encryptAnswers(answers);
33+
34+
// Base64 pattern: letters, numbers, +, /, ends with optional =
35+
expect(result).toMatch(/^[A-Za-z0-9+/]+=*$/);
36+
});
37+
38+
it('returns different output for same input (random IV)', () => {
39+
const answers = { 'q-1': 'a-1' };
40+
41+
const result1 = encryptAnswers(answers);
42+
const result2 = encryptAnswers(answers);
43+
44+
// Each encryption uses random IV, so outputs differ
45+
expect(result1).not.toBe(result2);
46+
});
47+
48+
it('handles empty object', () => {
49+
const result = encryptAnswers({});
50+
51+
expect(result).toBeDefined();
52+
expect(typeof result).toBe('string');
53+
});
54+
55+
it('handles multiple questions', () => {
56+
const answers = {
57+
'q-1': 'a-1',
58+
'q-2': 'a-2',
59+
'q-3': 'a-3',
60+
};
61+
62+
const result = encryptAnswers(answers);
63+
64+
expect(result).toBeDefined();
65+
expect(result.length).toBeGreaterThan(0);
66+
});
67+
});
68+
69+
describe('decryptAnswers', () => {
70+
it('decrypts back to original data', () => {
71+
const original = { 'q-1': 'a-1', 'q-2': 'a-2' };
72+
73+
const encrypted = encryptAnswers(original);
74+
const decrypted = decryptAnswers(encrypted);
75+
76+
expect(decrypted).toEqual(original);
77+
});
78+
79+
it('returns null for tampered data', () => {
80+
const original = { 'q-1': 'a-1' };
81+
const encrypted = encryptAnswers(original);
82+
83+
// Tamper: change last 5 characters
84+
const tampered = encrypted.slice(0, -5) + 'XXXXX';
85+
86+
const result = decryptAnswers(tampered);
87+
88+
expect(result).toBeNull();
89+
});
90+
91+
it('returns null for invalid base64', () => {
92+
const result = decryptAnswers('not-valid-base64!!!');
93+
94+
expect(result).toBeNull();
95+
});
96+
97+
it('returns null for empty string', () => {
98+
const result = decryptAnswers('');
99+
100+
expect(result).toBeNull();
101+
});
102+
103+
it('returns null for truncated data', () => {
104+
const original = { 'q-1': 'a-1' };
105+
const encrypted = encryptAnswers(original);
106+
107+
// Truncate: remove half of the data
108+
const truncated = encrypted.slice(0, encrypted.length / 2);
109+
110+
const result = decryptAnswers(truncated);
111+
112+
expect(result).toBeNull();
113+
});
114+
});
115+
116+
describe('createEncryptedAnswersBlob', () => {
117+
it('creates encrypted blob from questions', () => {
118+
const questions = createMockQuestions(3);
119+
120+
const blob = createEncryptedAnswersBlob(questions);
121+
122+
expect(blob).toBeDefined();
123+
expect(typeof blob).toBe('string');
124+
expect(blob.length).toBeGreaterThan(0);
125+
});
126+
127+
it('encrypted blob decrypts to correct answers map', () => {
128+
const questions = createMockQuestions(3);
129+
const expectedMap = createCorrectAnswersMap(questions);
130+
131+
const blob = createEncryptedAnswersBlob(questions);
132+
const decrypted = decryptAnswers(blob);
133+
134+
expect(decrypted).toEqual(expectedMap);
135+
});
136+
137+
it('handles questions with no correct answer', () => {
138+
const questions = [
139+
{
140+
id: 'q-no-correct',
141+
answers: [
142+
{ id: 'a-1', isCorrect: false },
143+
{ id: 'a-2', isCorrect: false },
144+
],
145+
},
146+
];
147+
148+
const blob = createEncryptedAnswersBlob(questions);
149+
const decrypted = decryptAnswers(blob);
150+
151+
// Question with no correct answer is not included in map
152+
expect(decrypted).toEqual({});
153+
});
154+
155+
it('handles empty questions array', () => {
156+
const blob = createEncryptedAnswersBlob([]);
157+
const decrypted = decryptAnswers(blob);
158+
159+
expect(decrypted).toEqual({});
160+
});
161+
});
162+
});

0 commit comments

Comments
 (0)