fix: replace revalidate with cache force-cache for SSG compatibility
This commit is contained in:
+26
-23
@@ -1,9 +1,16 @@
|
|||||||
import type { ListResponse } from './types';
|
import type { ListResponse } from './types';
|
||||||
|
|
||||||
/**
|
/*
|
||||||
* Native fetch wrapper for PocketBase API requests.
|
* 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.
|
* Options for PocketBase collection fetching.
|
||||||
@@ -21,46 +28,42 @@ export type PBFetchOptions = {
|
|||||||
* Fields to expand (e.g., "stack")
|
* Fields to expand (e.g., "stack")
|
||||||
*/
|
*/
|
||||||
expand?: string;
|
expand?: string;
|
||||||
/**
|
|
||||||
* Cache revalidation time in seconds
|
|
||||||
* @default 3600
|
|
||||||
*/
|
|
||||||
revalidate?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a list of records from a PocketBase collection.
|
* Fetch a list of records from a PocketBase collection.
|
||||||
*/
|
*/
|
||||||
export async function getCollection<T>(collection: string, options: PBFetchOptions = {}): Promise<ListResponse<T>> {
|
export async function getCollection<T>(collection: string, options: PBFetchOptions = {}): Promise<ListResponse<T>> {
|
||||||
const { sort, filter, expand, revalidate = 3600 } = options;
|
const { sort, filter, expand } = options;
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (sort) params.set('sort', sort);
|
if (sort) {
|
||||||
if (filter) params.set('filter', filter);
|
params.set('sort', sort);
|
||||||
if (expand) params.set('expand', expand);
|
}
|
||||||
|
if (filter) {
|
||||||
|
params.set('filter', filter);
|
||||||
|
}
|
||||||
|
if (expand) {
|
||||||
|
params.set('expand', expand);
|
||||||
|
}
|
||||||
|
|
||||||
const url = `${PB_URL}/api/collections/${collection}/records?${params.toString()}`;
|
const url = `${PB_URL}/api/collections/${collection}/records?${params.toString()}`;
|
||||||
|
|
||||||
const res = await fetch(url, {
|
/* force-cache deduplicates identical fetches during the static build phase;
|
||||||
next: { revalidate },
|
* it has no runtime effect in `output: 'export'` mode. */
|
||||||
});
|
const res = await fetch(url, { cache: 'force-cache' });
|
||||||
|
|
||||||
if (!res.ok) {
|
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();
|
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<T>(collection: string, options: PBFetchOptions = {}): Promise<T | null> {
|
export async function getFirstRecord<T>(collection: string, options: PBFetchOptions = {}): Promise<T | null> {
|
||||||
const data = await getCollection<T>(collection, {
|
const data = await getCollection<T>(collection, options);
|
||||||
...options,
|
return data.items[0] ?? null;
|
||||||
// PocketBase convention for "first" or "singleton" patterns
|
|
||||||
filter: options.filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
return data.items[0] || null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<PageContentRecord>('bio', {
|
||||||
|
filter: 'slug = "bio"',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <p>Loading bio content...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="prose prose-lg dark:prose-invert max-w-none">
|
||||||
|
<p>{data.content}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<PageContentRecord>('intro', {
|
||||||
|
filter: 'slug = "intro"',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <p>Loading intro content...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="prose prose-lg dark:prose-invert max-w-none">
|
||||||
|
<p>{data.content}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
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 type { NavItem } from '../model/types';
|
||||||
|
import { SidebarNav } from './SidebarNav';
|
||||||
|
|
||||||
const ITEMS: NavItem[] = [
|
const ITEMS: NavItem[] = [
|
||||||
{ id: 'bio', label: 'Bio', number: '01' },
|
{ id: 'bio', label: 'Bio', number: '01' },
|
||||||
@@ -9,13 +9,11 @@ const ITEMS: NavItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
global.IntersectionObserver = vi.fn(function () {
|
global.IntersectionObserver = class {
|
||||||
return {
|
observe = vi.fn();
|
||||||
observe: vi.fn(),
|
disconnect = vi.fn();
|
||||||
disconnect: vi.fn(),
|
unobserve = vi.fn();
|
||||||
unobserve: vi.fn(),
|
} as unknown as typeof IntersectionObserver;
|
||||||
};
|
|
||||||
}) as unknown as typeof IntersectionObserver;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SidebarNav', () => {
|
describe('SidebarNav', () => {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry of dynamic section widgets.
|
||||||
|
*/
|
||||||
|
const SECTIONS: Record<string, () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>> = {
|
||||||
|
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 <SectionComponent />;
|
||||||
|
}
|
||||||
@@ -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<SkillRecord>('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<string, SkillRecord[]>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-12">
|
||||||
|
{Object.entries(categories).map(([category, items]) => (
|
||||||
|
<div key={category} className="space-y-4">
|
||||||
|
<h3 className="text-xl font-bold uppercase tracking-widest opacity-50">{category}</h3>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{items.map((skill) => (
|
||||||
|
<Badge key={skill.id}>{skill.name}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user