From f0fccd55f12ce102b59895ccb543239572b10d38 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 23 Apr 2026 21:45:40 +0300 Subject: [PATCH] fix: replace revalidate with cache force-cache for SSG compatibility --- src/shared/api/client.ts | 49 ++++++++++--------- .../BioSection/ui/BioSection/BioSection.tsx | 22 +++++++++ .../ui/IntroSection/IntroSection.tsx | 22 +++++++++ src/widgets/Navigation/ui/SidebarNav.test.tsx | 16 +++--- .../ui/SectionFactory/SectionFactory.tsx | 38 ++++++++++++++ .../ui/SkillsSection/SkillsSection.tsx | 39 +++++++++++++++ 6 files changed, 154 insertions(+), 32 deletions(-) create mode 100644 src/widgets/BioSection/ui/BioSection/BioSection.tsx create mode 100644 src/widgets/IntroSection/ui/IntroSection/IntroSection.tsx create mode 100644 src/widgets/SectionFactory/ui/SectionFactory/SectionFactory.tsx create mode 100644 src/widgets/SkillsSection/ui/SkillsSection/SkillsSection.tsx diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index b1f988a..7609cc3 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -1,9 +1,16 @@ import type { ListResponse } from './types'; -/** +/* * Native fetch wrapper for PocketBase API requests. */ -const PB_URL = process.env.NEXT_PUBLIC_PB_URL || 'http://127.0.0.1:8090'; + +const PB_URL = + process.env.NEXT_PUBLIC_PB_URL || + (process.env.NODE_ENV === 'production' + ? (() => { + throw new Error('NEXT_PUBLIC_PB_URL is not set'); + })() + : 'http://127.0.0.1:8090'); /** * Options for PocketBase collection fetching. @@ -21,46 +28,42 @@ export type PBFetchOptions = { * Fields to expand (e.g., "stack") */ expand?: string; - /** - * Cache revalidation time in seconds - * @default 3600 - */ - revalidate?: number; }; /** * Fetch a list of records from a PocketBase collection. */ export async function getCollection(collection: string, options: PBFetchOptions = {}): Promise> { - const { sort, filter, expand, revalidate = 3600 } = options; + const { sort, filter, expand } = options; const params = new URLSearchParams(); - if (sort) params.set('sort', sort); - if (filter) params.set('filter', filter); - if (expand) params.set('expand', expand); + if (sort) { + params.set('sort', sort); + } + if (filter) { + params.set('filter', filter); + } + if (expand) { + params.set('expand', expand); + } const url = `${PB_URL}/api/collections/${collection}/records?${params.toString()}`; - const res = await fetch(url, { - next: { revalidate }, - }); + /* force-cache deduplicates identical fetches during the static build phase; + * it has no runtime effect in `output: 'export'` mode. */ + const res = await fetch(url, { cache: 'force-cache' }); if (!res.ok) { - throw new Error(`Failed to fetch collection: ${collection}`); + throw new Error(`PocketBase ${res.status} ${res.statusText} on collection "${collection}"`); } return res.json(); } /** - * Fetch a single record from a PocketBase collection by ID or filter. + * Fetch the first record matching an optional filter from a PocketBase collection. */ export async function getFirstRecord(collection: string, options: PBFetchOptions = {}): Promise { - const data = await getCollection(collection, { - ...options, - // PocketBase convention for "first" or "singleton" patterns - filter: options.filter, - }); - - return data.items[0] || null; + const data = await getCollection(collection, options); + return data.items[0] ?? null; } diff --git a/src/widgets/BioSection/ui/BioSection/BioSection.tsx b/src/widgets/BioSection/ui/BioSection/BioSection.tsx new file mode 100644 index 0000000..b6fb8d0 --- /dev/null +++ b/src/widgets/BioSection/ui/BioSection/BioSection.tsx @@ -0,0 +1,22 @@ +import type { PageContentRecord } from '$shared/api'; +import { getFirstRecord } from '$shared/api'; + +/** + * Bio section component. + * Displays personal biography content from PocketBase. + */ +export default async function BioSection() { + const data = await getFirstRecord('bio', { + filter: 'slug = "bio"', + }); + + if (!data) { + return

Loading bio content...

; + } + + return ( +
+

{data.content}

+
+ ); +} diff --git a/src/widgets/IntroSection/ui/IntroSection/IntroSection.tsx b/src/widgets/IntroSection/ui/IntroSection/IntroSection.tsx new file mode 100644 index 0000000..20b0b92 --- /dev/null +++ b/src/widgets/IntroSection/ui/IntroSection/IntroSection.tsx @@ -0,0 +1,22 @@ +import type { PageContentRecord } from '$shared/api'; +import { getFirstRecord } from '$shared/api'; + +/** + * Intro section component. + * Displays primary introduction content from PocketBase. + */ +export default async function IntroSection() { + const data = await getFirstRecord('intro', { + filter: 'slug = "intro"', + }); + + if (!data) { + return

Loading intro content...

; + } + + return ( +
+

{data.content}

+
+ ); +} diff --git a/src/widgets/Navigation/ui/SidebarNav.test.tsx b/src/widgets/Navigation/ui/SidebarNav.test.tsx index 75a7355..6ed1281 100644 --- a/src/widgets/Navigation/ui/SidebarNav.test.tsx +++ b/src/widgets/Navigation/ui/SidebarNav.test.tsx @@ -1,7 +1,7 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '@testing-library/react'; -import { SidebarNav } from './SidebarNav'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { NavItem } from '../model/types'; +import { SidebarNav } from './SidebarNav'; const ITEMS: NavItem[] = [ { id: 'bio', label: 'Bio', number: '01' }, @@ -9,13 +9,11 @@ const ITEMS: NavItem[] = [ ]; beforeEach(() => { - global.IntersectionObserver = vi.fn(function () { - return { - observe: vi.fn(), - disconnect: vi.fn(), - unobserve: vi.fn(), - }; - }) as unknown as typeof IntersectionObserver; + global.IntersectionObserver = class { + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); + } as unknown as typeof IntersectionObserver; }); describe('SidebarNav', () => { diff --git a/src/widgets/SectionFactory/ui/SectionFactory/SectionFactory.tsx b/src/widgets/SectionFactory/ui/SectionFactory/SectionFactory.tsx new file mode 100644 index 0000000..8c626a4 --- /dev/null +++ b/src/widgets/SectionFactory/ui/SectionFactory/SectionFactory.tsx @@ -0,0 +1,38 @@ +import { notFound } from 'next/navigation'; + +/** + * Registry of dynamic section widgets. + */ +const SECTIONS: Record Promise<{ default: React.ComponentType> }>> = { + intro: () => import('../../../IntroSection/ui/IntroSection/IntroSection'), + bio: () => import('../../../BioSection/ui/BioSection/BioSection'), + skills: () => import('../../../SkillsSection/ui/SkillsSection/SkillsSection'), + experience: () => import('../../../ExperienceSection/ui/ExperienceSection/ExperienceSection'), + projects: () => import('../../../ProjectsSection/ui/ProjectsSection/ProjectsSection'), +}; + +/** + * Props for the SectionFactory widget. + */ +export type SectionFactoryProps = { + /** + * Section slug to render + */ + slug: string; +}; + +/** + * Factory widget that dynamically imports and renders the correct section widget. + * Based on the provided slug. + */ +export async function SectionFactory({ slug }: SectionFactoryProps) { + const loadSection = SECTIONS[slug]; + + if (!loadSection) { + notFound(); + } + + const { default: SectionComponent } = await loadSection(); + + return ; +} diff --git a/src/widgets/SkillsSection/ui/SkillsSection/SkillsSection.tsx b/src/widgets/SkillsSection/ui/SkillsSection/SkillsSection.tsx new file mode 100644 index 0000000..7669043 --- /dev/null +++ b/src/widgets/SkillsSection/ui/SkillsSection/SkillsSection.tsx @@ -0,0 +1,39 @@ +import type { SkillRecord } from '$shared/api'; +import { getCollection } from '$shared/api'; +import { Badge } from '$shared/ui'; + +/** + * Skills section component. + * Displays technology skills grouped by category. + */ +export default async function SkillsSection() { + const data = await getCollection('skills', { + sort: 'category,order', + }); + + const categories = data.items.reduce( + (acc, skill) => { + if (!acc[skill.category]) { + acc[skill.category] = []; + } + acc[skill.category].push(skill); + return acc; + }, + {} as Record, + ); + + return ( +
+ {Object.entries(categories).map(([category, items]) => ( +
+

{category}

+
+ {items.map((skill) => ( + {skill.name} + ))} +
+
+ ))} +
+ ); +}