* add pb models * api rules has updated to view public * pb setup is completed * add migrate script * add pb migrations * ignore storage * add pocketbase SDK dependency * change Tag.id and PageCardDto.id to string for PocketBase * add DocV1 content block types * add PB client singleton and PB record types * rewrite factory for PocketBase record types * rewrite api.ts for PocketBase SDK * remove unused Strapi files, add PB to config * mark Task 7 complete in plan * feat: verify build, tests, and lint pass with PocketBase API layer * move completed plan: 2026-04-06-pocketbase-api-layer.md * scaffold hudozka-writer with Vite 8, React 19, TS 5.9, Tiptap * add PocketBase client and DocV1 types to writer * add Login component for PocketBase superuser auth * add App shell with auth gate and page loader * add DocV1 <-> Tiptap serialization layer * mark Task 5 complete in writer redesign plan * add custom Tiptap nodes for DocV1 block types * add Tiptap editor with toolbar and save to PocketBase * add image picker with browse and upload * feat: verify writer build and complete integration task * move completed plan: 2026-04-06-hudozka-writer-redesign.md * add local env * rm textblock from writer * add writer editor improvements design spec * add writer editor improvements implementation plan * feat: use @tiptap/markdown for text block serialization * feat: add global markdown/rendered toggle * feat: add BlockMenu component for block type selection * feat: add hover + button for block insertion * feat: add slash commands for block insertion * feat: add interactive file block editor with FilePicker * feat: show resolved page titles in card grid block * move completed plan: 2026-04-07-writer-editor-improvements.md * writer ui improved * pages migration * spec: draft/publish workflow for hudozka-writer * spec: rename draft_doc to draft in spec * plan: writer draft/publish implementation * plan has moved * gitignores * plans upd * feat(writer): update PbPage type for draft/publish workflow * feat(writer): add save/publish/discard handlers with dirty tracking * feat(writer): add DraftBanner component * feat(writer): add Publish button and update toolbar layout * feat(writer): add published toggle and update duplicate for new schema * docs: mark manual verification steps as skipped in writer draft/publish plan * move completed plan: 20260407-writer-draft-publish.md * feat(writer): add BlockActions component for inline block menus * feat(writer): add draggable NodeViews for text blocks * feat(writer): use BlockActions in custom block NodeViews * feat(writer): replace floating BlockInsert with inline BlockActions * fix(writer): ensure all block wrappers have position: relative for BlockActions * chore: mark manual verification tasks as skipped in writer block actions plan * move completed plan: 20260408-writer-block-actions.md * writer update * refactor(writer): simplify BlockActions to always-visible delete + drag handle * feat(writer): add BlockInsert component for inserting blocks between nodes * feat(writer): redesign block sidebar with always-visible actions and insert button * move completed plan: 20260408-writer-block-actions-redesign.md * add spec: textblock container redesign for writer editor * feat(writer): add TextBlock container node definition * feat(writer): replace DraggableXxx wrappers with TextBlock container * feat(writer): update Editor extensions for TextBlock container * feat(writer): add split button to BlockActions for text blocks * feat(writer): update serialization for TextBlock container * feat(writer): update BlockMenu and SlashCommands for TextBlock * fix(writer): use Fragment.from() for replaceWith calls in BlockActions and SlashCommands * move completed plan: 20260408-textblock-container.md * add air
1016 lines
25 KiB
Markdown
1016 lines
25 KiB
Markdown
# PocketBase API Layer Rewrite
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Replace the Strapi 3 API layer in `src/remote/` with PocketBase SDK calls, keeping all public function signatures and output types unchanged.
|
|
|
|
**Architecture:** All PB fetching happens in `api.ts`. Factory functions are pure transforms — they receive resolved PB records and maps, no PB client access. Content blocks use DocV1 format (`{ version: 1, blocks: [...] }`) with block IDs. Image blurhash is pre-computed in PB `images` collection.
|
|
|
|
**Tech Stack:** PocketBase JS SDK (`pocketbase ^0.25.0`), Next.js App Router (SSG/ISR), TypeScript, Vitest.
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-04-06-pocketbase-api-layer-design.md`
|
|
|
|
---
|
|
|
|
### Task 1: Install PocketBase SDK
|
|
|
|
**Files:**
|
|
- Modify: `package.json`
|
|
|
|
- [x] **Step 1: Install pocketbase**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && npm install pocketbase@^0.25.0
|
|
```
|
|
|
|
- [x] **Step 2: Verify installation**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && node -e "import('pocketbase').then(m => console.log('OK', typeof m.default))"
|
|
```
|
|
|
|
Expected: `OK function`
|
|
|
|
- [x] **Step 3: Commit**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && git add package.json package-lock.json && git commit -m "add pocketbase SDK dependency"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Update app types for string IDs
|
|
|
|
**Files:**
|
|
- Modify: `src/types.ts`
|
|
|
|
PocketBase uses string IDs (`"2yrq5oqtg7expg0"`), not numeric. `Tag.id` and `PageCardDto.id` must change from `number` to `string`. Only downstream usage is as React `key` — safe change.
|
|
|
|
- [x] **Step 1: Update `Tag.id` to string**
|
|
|
|
In `src/types.ts`, change:
|
|
|
|
```ts
|
|
export type Tag = {
|
|
id: string
|
|
name: string
|
|
slug: string
|
|
href: string
|
|
count: number
|
|
}
|
|
```
|
|
|
|
- [x] **Step 2: Update `PageCardDto.id` to string**
|
|
|
|
In `src/types.ts`, change:
|
|
|
|
```ts
|
|
export type PageCardDto = {
|
|
id: string
|
|
url: string
|
|
title: string
|
|
featured: boolean
|
|
date: string | null
|
|
cover: Pic
|
|
}
|
|
```
|
|
|
|
- [x] **Step 3: Run lint and tests**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && npm run lint && npm test
|
|
```
|
|
|
|
Expected: pass (existing factory.test.ts uses `id: 1` etc — but those tests will be rewritten in Task 5)
|
|
|
|
- [x] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && git add src/types.ts && git commit -m "change Tag.id and PageCardDto.id to string for PocketBase"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Create DocV1 types
|
|
|
|
**Files:**
|
|
- Create: `src/remote/doc.ts`
|
|
|
|
- [x] **Step 1: Create `src/remote/doc.ts`**
|
|
|
|
```ts
|
|
export type DocV1BlockText = {
|
|
type: "text"
|
|
id: string
|
|
text: string
|
|
}
|
|
|
|
export type DocV1BlockImage = {
|
|
type: "image"
|
|
id: string
|
|
image: string
|
|
wide: boolean
|
|
caption: string
|
|
}
|
|
|
|
export type DocV1BlockDocument = {
|
|
type: "document"
|
|
id: string
|
|
file: string
|
|
title: string
|
|
}
|
|
|
|
export type DocV1BlockEmbed = {
|
|
type: "embed"
|
|
id: string
|
|
src: string
|
|
}
|
|
|
|
export type DocV1BlockCardGridItem = {
|
|
page: string
|
|
layout: "small" | "medium" | "big"
|
|
}
|
|
|
|
export type DocV1BlockCardGrid = {
|
|
type: "card-grid"
|
|
id: string
|
|
items: DocV1BlockCardGridItem[]
|
|
}
|
|
|
|
export type DocV1Block =
|
|
| DocV1BlockText
|
|
| DocV1BlockImage
|
|
| DocV1BlockDocument
|
|
| DocV1BlockEmbed
|
|
| DocV1BlockCardGrid
|
|
|
|
export type DocV1 = {
|
|
version: 1
|
|
blocks: DocV1Block[]
|
|
}
|
|
```
|
|
|
|
- [x] **Step 2: Verify no type errors**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && npx tsc --noEmit src/remote/doc.ts
|
|
```
|
|
|
|
Expected: no errors
|
|
|
|
- [x] **Step 3: Commit**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && git add src/remote/doc.ts && git commit -m "add DocV1 content block types"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Create PB client and rewrite types
|
|
|
|
**Files:**
|
|
- Create: `src/remote/pb.ts`
|
|
- Rewrite: `src/remote/types.ts`
|
|
|
|
- [x] **Step 1: Create `src/remote/pb.ts`**
|
|
|
|
```ts
|
|
import PocketBase from "pocketbase"
|
|
|
|
const pb = new PocketBase(process.env.POCKETBASE_URL)
|
|
pb.autoCancellation(false)
|
|
|
|
export { pb }
|
|
```
|
|
|
|
- [x] **Step 2: Rewrite `src/remote/types.ts`**
|
|
|
|
Replace entire contents with:
|
|
|
|
```ts
|
|
import type { DocV1 } from "./doc"
|
|
|
|
export type PbRecord = {
|
|
id: string
|
|
collectionId: string
|
|
collectionName: string
|
|
created: string
|
|
updated: string
|
|
}
|
|
|
|
export type PbImage = PbRecord & {
|
|
file: string
|
|
filename: string
|
|
width: number
|
|
height: number
|
|
blurhash: string
|
|
alt: string
|
|
caption: string
|
|
}
|
|
|
|
export type PbFile = PbRecord & {
|
|
file: string
|
|
filename: string
|
|
mime: string
|
|
size: number
|
|
title: string
|
|
preview: string
|
|
}
|
|
|
|
export type PbPage = PbRecord & {
|
|
title: string
|
|
slug: string
|
|
excerpt: string
|
|
date: string
|
|
cover: string
|
|
doc: DocV1
|
|
tags: string[]
|
|
draft: boolean
|
|
}
|
|
|
|
export type PbTag = PbRecord & {
|
|
slug: string
|
|
label: string
|
|
}
|
|
|
|
export type PbKv = PbRecord & {
|
|
key: string
|
|
data: unknown
|
|
}
|
|
|
|
export type PbHomeData = {
|
|
cards: { page: string; layout: "small" | "medium" | "big" }[]
|
|
}
|
|
|
|
export type PbMenuData = {
|
|
homeLabel: string
|
|
items: { page: string }[]
|
|
}
|
|
```
|
|
|
|
- [x] **Step 3: Verify no type errors**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && npx tsc --noEmit src/remote/types.ts src/remote/pb.ts
|
|
```
|
|
|
|
Expected: no errors (factory.ts will have errors — expected, fixed in next tasks)
|
|
|
|
- [x] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && git add src/remote/pb.ts src/remote/types.ts && git commit -m "add PB client singleton and PB record types"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Rewrite factory.ts
|
|
|
|
**Files:**
|
|
- Rewrite: `src/remote/factory.ts`
|
|
|
|
The factory is pure transforms — no PB client, no fetching. It receives resolved records and maps.
|
|
|
|
- [x] **Step 1: Write the failing test for `createFeedPages`**
|
|
|
|
Rewrite `src/remote/factory.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expect } from "vitest"
|
|
import { createFeedPages } from "./factory"
|
|
import type { PbPage } from "./types"
|
|
|
|
function stubPage(overrides: Partial<PbPage> = {}): PbPage {
|
|
return {
|
|
id: "abc123",
|
|
collectionId: "pbc_1125843985",
|
|
collectionName: "pages",
|
|
created: "2026-01-01T00:00:00.000Z",
|
|
updated: "2026-01-01T00:00:00.000Z",
|
|
title: "Test",
|
|
slug: "/test",
|
|
excerpt: "Excerpt",
|
|
date: "2024-01-15",
|
|
cover: "",
|
|
doc: { version: 1, blocks: [] },
|
|
tags: [],
|
|
draft: false,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe("createFeedPages", () => {
|
|
it("should map PB pages to feed pages", () => {
|
|
const pages = [stubPage({ id: "x1", title: "Hello", slug: "/hello", date: "2024-03-01", excerpt: "Summary" })]
|
|
const result = createFeedPages(pages)
|
|
expect(result).toEqual([{
|
|
id: "x1",
|
|
title: "Hello",
|
|
url: "/hello",
|
|
date: "2024-03-01",
|
|
excerpt: "Summary",
|
|
}])
|
|
})
|
|
|
|
it("should filter out pages without date", () => {
|
|
const pages = [
|
|
stubPage({ id: "a", date: "2024-01-01" }),
|
|
stubPage({ id: "b", date: "" }),
|
|
]
|
|
const result = createFeedPages(pages)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].id).toBe("a")
|
|
})
|
|
|
|
it("should return empty array for empty input", () => {
|
|
expect(createFeedPages([])).toEqual([])
|
|
})
|
|
})
|
|
```
|
|
|
|
- [x] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && npx vitest run src/remote/factory.test.ts
|
|
```
|
|
|
|
Expected: FAIL (factory.ts still imports old Strapi types)
|
|
|
|
- [x] **Step 3: Rewrite `src/remote/factory.ts`**
|
|
|
|
Replace entire contents with:
|
|
|
|
```ts
|
|
import type { MenuItem, Page, Tag, PageCardDto, Pic, Token, FeedPage } from "@/types"
|
|
import type { PbPage, PbImage, PbFile, PbTag, PbHomeData, PbMenuData } from "./types"
|
|
import type { DocV1Block } from "./doc"
|
|
import { typograf, markdownToHtml } from "@/lib/text"
|
|
import { pb } from "./pb"
|
|
|
|
const md = (text: string) => typograf(markdownToHtml(text))
|
|
|
|
const DEFAULT_COVER: Pic = {
|
|
src: "/static/img/main.jpg",
|
|
width: 1920,
|
|
height: 1858,
|
|
}
|
|
|
|
function pbFileUrl(collectionId: string, recordId: string, filename: string): string {
|
|
return `${pb.baseURL}/api/files/${collectionId}/${recordId}/${filename}`
|
|
}
|
|
|
|
function imageRecordToPic(image: PbImage, overrides?: { alt?: string; caption?: string }): Pic {
|
|
const pic: Pic = {
|
|
src: pbFileUrl(image.collectionId, image.id, image.file),
|
|
width: image.width,
|
|
height: image.height,
|
|
}
|
|
|
|
const alt = overrides?.alt || image.alt || undefined
|
|
if (alt) {
|
|
pic.alt = alt
|
|
}
|
|
|
|
const caption = overrides?.caption || image.caption || undefined
|
|
if (caption) {
|
|
pic.caption = caption
|
|
}
|
|
|
|
if (image.blurhash) {
|
|
pic.blur = image.blurhash
|
|
}
|
|
|
|
return pic
|
|
}
|
|
|
|
function getCoverPic(coverId: string, images: Map<string, PbImage>): Pic {
|
|
if (coverId) {
|
|
const image = images.get(coverId)
|
|
if (image) {
|
|
return imageRecordToPic(image)
|
|
}
|
|
}
|
|
return DEFAULT_COVER
|
|
}
|
|
|
|
export function isYoutubeUrl(url: string): boolean {
|
|
return /youtube\.com/.test(url)
|
|
}
|
|
|
|
export function createEmbed(src: string): Token {
|
|
if (isYoutubeUrl(src)) {
|
|
return {
|
|
token: "youtube",
|
|
data: { url: src },
|
|
}
|
|
}
|
|
|
|
return {
|
|
token: "html",
|
|
data: `<iframe src="${src}" width="100%" height="480" frameborder="0"></iframe>`,
|
|
}
|
|
}
|
|
|
|
export function createPageToken(
|
|
block: DocV1Block,
|
|
images: Map<string, PbImage>,
|
|
files: Map<string, PbFile>,
|
|
cardGridPages: Map<string, PbPage>,
|
|
cardGridImages: Map<string, PbImage>,
|
|
): Token {
|
|
switch (block.type) {
|
|
case "text":
|
|
return {
|
|
token: "html",
|
|
data: md(block.text),
|
|
}
|
|
|
|
case "image": {
|
|
const image = images.get(block.image)
|
|
if (!image) {
|
|
return { token: "text", data: "" }
|
|
}
|
|
return {
|
|
token: "image",
|
|
wide: block.wide,
|
|
data: imageRecordToPic(image, {
|
|
alt: block.caption,
|
|
caption: block.caption,
|
|
}),
|
|
}
|
|
}
|
|
|
|
case "document": {
|
|
const file = files.get(block.file)
|
|
if (!file) {
|
|
return { token: "text", data: "" }
|
|
}
|
|
const fileUrl = pbFileUrl(file.collectionId, file.id, file.file)
|
|
return {
|
|
token: "file",
|
|
data: {
|
|
url: fileUrl,
|
|
slug: "",
|
|
image_url: fileUrl,
|
|
file_url: fileUrl,
|
|
title: block.title,
|
|
file_size: file.size,
|
|
file_format: file.mime,
|
|
},
|
|
}
|
|
}
|
|
|
|
case "embed":
|
|
return createEmbed(block.src)
|
|
|
|
case "card-grid": {
|
|
const items: PageCardDto[] = block.items
|
|
.map(item => {
|
|
const page = cardGridPages.get(item.page)
|
|
if (!page) {
|
|
return null
|
|
}
|
|
const cover = getCoverPic(page.cover, cardGridImages)
|
|
return {
|
|
id: page.id,
|
|
url: page.slug,
|
|
title: page.title,
|
|
featured: item.layout === "big" || item.layout === "medium",
|
|
date: page.date || null,
|
|
cover,
|
|
}
|
|
})
|
|
.filter((x): x is PageCardDto => x !== null)
|
|
return {
|
|
token: "grid",
|
|
data: { items },
|
|
}
|
|
}
|
|
|
|
default:
|
|
return {
|
|
token: "text",
|
|
data: JSON.stringify(block),
|
|
}
|
|
}
|
|
}
|
|
|
|
export function createPage(
|
|
record: PbPage,
|
|
images: Map<string, PbImage>,
|
|
files: Map<string, PbFile>,
|
|
tags: PbTag[],
|
|
cardGridPages: Map<string, PbPage>,
|
|
cardGridImages: Map<string, PbImage>,
|
|
): Page {
|
|
const cover = getCoverPic(record.cover, images)
|
|
|
|
const pageTags: Tag[] = tags.map(tag => ({
|
|
id: tag.id,
|
|
name: tag.label,
|
|
slug: tag.slug,
|
|
href: `/tags/${tag.slug}`,
|
|
count: 0,
|
|
}))
|
|
|
|
const tokens: Token[] = record.doc.blocks.map(block =>
|
|
createPageToken(block, images, files, cardGridPages, cardGridImages),
|
|
)
|
|
|
|
return {
|
|
title: record.title,
|
|
description: record.excerpt,
|
|
id: record.id,
|
|
url: record.slug,
|
|
data: "",
|
|
date: "",
|
|
cover,
|
|
tokens: [
|
|
{
|
|
token: "html",
|
|
data: md(`# ${record.title}`),
|
|
},
|
|
...tokens,
|
|
],
|
|
tags: pageTags,
|
|
featured: false,
|
|
}
|
|
}
|
|
|
|
export function createHomeCards(
|
|
data: PbHomeData,
|
|
pages: Map<string, PbPage>,
|
|
images: Map<string, PbImage>,
|
|
): PageCardDto[] {
|
|
return data.cards
|
|
.map(card => {
|
|
const page = pages.get(card.page)
|
|
if (!page) {
|
|
return null
|
|
}
|
|
const cover = getCoverPic(page.cover, images)
|
|
return {
|
|
id: page.id,
|
|
url: page.slug,
|
|
title: page.title,
|
|
featured: card.layout === "big" || card.layout === "medium",
|
|
date: page.date || null,
|
|
cover,
|
|
}
|
|
})
|
|
.filter((x): x is PageCardDto => x !== null)
|
|
}
|
|
|
|
export function createMenu(
|
|
data: PbMenuData,
|
|
pages: Map<string, PbPage>,
|
|
): MenuItem[] {
|
|
const items: MenuItem[] = data.items
|
|
.map(item => {
|
|
const page = pages.get(item.page)
|
|
if (!page) {
|
|
return null
|
|
}
|
|
return {
|
|
href: page.slug,
|
|
name: page.title,
|
|
}
|
|
})
|
|
.filter((x): x is MenuItem => x !== null)
|
|
|
|
return [{
|
|
href: "/",
|
|
name: data.homeLabel,
|
|
}, ...items]
|
|
}
|
|
|
|
export function createFeedPages(pages: PbPage[]): FeedPage[] {
|
|
return pages
|
|
.filter(page => page.date)
|
|
.map(page => ({
|
|
id: page.id,
|
|
title: page.title,
|
|
url: page.slug,
|
|
date: page.date,
|
|
excerpt: page.excerpt,
|
|
}))
|
|
}
|
|
```
|
|
|
|
- [x] **Step 4: Run test to verify it passes**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && npx vitest run src/remote/factory.test.ts
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [x] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && git add src/remote/factory.ts src/remote/factory.test.ts && git commit -m "rewrite factory for PocketBase record types"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Rewrite api.ts
|
|
|
|
**Files:**
|
|
- Rewrite: `src/remote/api.ts`
|
|
- Rewrite: `src/remote/api.test.ts`
|
|
|
|
- [x] **Step 1: Rewrite `src/remote/api.ts`**
|
|
|
|
Replace entire contents with:
|
|
|
|
```ts
|
|
import { pb } from "./pb"
|
|
import { createPage, createHomeCards, createMenu, createFeedPages } from "./factory"
|
|
import type { PbPage, PbImage, PbFile, PbTag, PbHomeData, PbMenuData } from "./types"
|
|
import type { DocV1Block } from "./doc"
|
|
import type { MenuItem, Page, PageCardDto, FeedPage } from "@/types"
|
|
|
|
function buildIdFilter(ids: string[]): string {
|
|
return ids.map(id => `id="${id}"`).join(" || ")
|
|
}
|
|
|
|
async function fetchImagesByIds(ids: string[]): Promise<Map<string, PbImage>> {
|
|
if (ids.length === 0) return new Map()
|
|
const records = await pb.collection("images").getFullList<PbImage>({
|
|
filter: buildIdFilter(ids),
|
|
})
|
|
return new Map(records.map(r => [r.id, r]))
|
|
}
|
|
|
|
async function fetchFilesByIds(ids: string[]): Promise<Map<string, PbFile>> {
|
|
if (ids.length === 0) return new Map()
|
|
const records = await pb.collection("files").getFullList<PbFile>({
|
|
filter: buildIdFilter(ids),
|
|
})
|
|
return new Map(records.map(r => [r.id, r]))
|
|
}
|
|
|
|
async function fetchPagesByIds(ids: string[]): Promise<Map<string, PbPage>> {
|
|
if (ids.length === 0) return new Map()
|
|
const records = await pb.collection("pages").getFullList<PbPage>({
|
|
filter: buildIdFilter(ids),
|
|
})
|
|
return new Map(records.map(r => [r.id, r]))
|
|
}
|
|
|
|
async function fetchTagsByIds(ids: string[]): Promise<PbTag[]> {
|
|
if (ids.length === 0) return []
|
|
return pb.collection("tags").getFullList<PbTag>({
|
|
filter: buildIdFilter(ids),
|
|
})
|
|
}
|
|
|
|
function collectBlockRefs(blocks: DocV1Block[]) {
|
|
const imageIds = new Set<string>()
|
|
const fileIds = new Set<string>()
|
|
const pageIds = new Set<string>()
|
|
|
|
for (const block of blocks) {
|
|
switch (block.type) {
|
|
case "image":
|
|
imageIds.add(block.image)
|
|
break
|
|
case "document":
|
|
fileIds.add(block.file)
|
|
break
|
|
case "card-grid":
|
|
for (const item of block.items) {
|
|
pageIds.add(item.page)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
return { imageIds, fileIds, pageIds }
|
|
}
|
|
|
|
export async function getUrls(): Promise<string[]> {
|
|
try {
|
|
const records = await pb.collection("pages").getFullList<PbPage>({
|
|
fields: "slug",
|
|
filter: "draft=false",
|
|
})
|
|
return records.map(r => r.slug)
|
|
} catch (error) {
|
|
console.error(`Failed to fetch URLs: ${error}`)
|
|
return []
|
|
}
|
|
}
|
|
|
|
export async function getPageBySlug(slug: string): Promise<Page | null> {
|
|
try {
|
|
const record = await pb.collection("pages").getFirstListItem<PbPage>(
|
|
`slug="${slug}"`,
|
|
)
|
|
|
|
// Collect all referenced IDs from blocks
|
|
const refs = collectBlockRefs(record.doc.blocks)
|
|
|
|
// Include cover image ID
|
|
if (record.cover) {
|
|
refs.imageIds.add(record.cover)
|
|
}
|
|
|
|
// Round 2: fetch images, files, tags, card-grid pages in parallel
|
|
const [images, files, tags, cardGridPages] = await Promise.all([
|
|
fetchImagesByIds([...refs.imageIds]),
|
|
fetchFilesByIds([...refs.fileIds]),
|
|
fetchTagsByIds(record.tags),
|
|
fetchPagesByIds([...refs.pageIds]),
|
|
])
|
|
|
|
// Round 3: fetch cover images for card-grid pages
|
|
const cardGridCoverIds = new Set<string>()
|
|
for (const page of cardGridPages.values()) {
|
|
if (page.cover && !images.has(page.cover)) {
|
|
cardGridCoverIds.add(page.cover)
|
|
}
|
|
}
|
|
const cardGridImages = cardGridCoverIds.size > 0
|
|
? await fetchImagesByIds([...cardGridCoverIds])
|
|
: new Map<string, PbImage>()
|
|
|
|
// Merge all images for card-grid covers
|
|
const allCardGridImages = new Map([...images, ...cardGridImages])
|
|
|
|
return createPage(record, images, files, tags, cardGridPages, allCardGridImages)
|
|
} catch (error) {
|
|
console.error(`Failed to fetch page: ${error}`)
|
|
return null
|
|
}
|
|
}
|
|
|
|
export async function getHomeCards(): Promise<PageCardDto[]> {
|
|
try {
|
|
const kv = await pb.collection("kv").getFirstListItem<{ data: PbHomeData }>(
|
|
"key=\"home\"",
|
|
)
|
|
const data = kv.data
|
|
if (!data.cards || data.cards.length === 0) return []
|
|
|
|
const pageIds = data.cards.map(c => c.page)
|
|
const pages = await fetchPagesByIds(pageIds)
|
|
|
|
const coverIds = [...pages.values()]
|
|
.map(p => p.cover)
|
|
.filter(Boolean)
|
|
const images = await fetchImagesByIds(coverIds)
|
|
|
|
return createHomeCards(data, pages, images)
|
|
} catch (error) {
|
|
console.error(`Failed to fetch home cards: ${error}`)
|
|
return []
|
|
}
|
|
}
|
|
|
|
export async function getMenu(): Promise<MenuItem[]> {
|
|
try {
|
|
const kv = await pb.collection("kv").getFirstListItem<{ data: PbMenuData }>(
|
|
"key=\"menu\"",
|
|
)
|
|
const data = kv.data
|
|
if (!data.items || data.items.length === 0) {
|
|
return [{
|
|
href: "/",
|
|
name: data.homeLabel,
|
|
}]
|
|
}
|
|
|
|
const pageIds = data.items.map(i => i.page)
|
|
const pages = await fetchPagesByIds(pageIds)
|
|
|
|
return createMenu(data, pages)
|
|
} catch (error) {
|
|
console.error(`Failed to fetch menu: ${error}`)
|
|
return []
|
|
}
|
|
}
|
|
|
|
export async function getRecentPages(limit: number = 30): Promise<FeedPage[]> {
|
|
try {
|
|
const result = await pb.collection("pages").getList<PbPage>(1, limit, {
|
|
filter: "date!='' && draft=false",
|
|
sort: "-date",
|
|
})
|
|
return createFeedPages(result.items)
|
|
} catch (error) {
|
|
console.error(`Failed to fetch recent pages: ${error}`)
|
|
return []
|
|
}
|
|
}
|
|
```
|
|
|
|
- [x] **Step 2: Rewrite `src/remote/api.test.ts`**
|
|
|
|
Replace entire contents with:
|
|
|
|
```ts
|
|
import { describe, it, expect, vi, beforeEach } from "vitest"
|
|
|
|
vi.mock("./pb", () => ({
|
|
pb: {
|
|
baseURL: "http://127.0.0.1:8090",
|
|
collection: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
import { getUrls, getRecentPages } from "./api"
|
|
import { pb } from "./pb"
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
describe("getUrls", () => {
|
|
it("should return slugs from all non-draft pages", async () => {
|
|
const mockGetFullList = vi.fn().mockResolvedValue([
|
|
{ slug: "/info" },
|
|
{ slug: "/2024/test" },
|
|
])
|
|
vi.mocked(pb.collection).mockReturnValue({
|
|
getFullList: mockGetFullList,
|
|
} as never)
|
|
|
|
const result = await getUrls()
|
|
expect(result).toEqual(["/info", "/2024/test"])
|
|
expect(pb.collection).toHaveBeenCalledWith("pages")
|
|
expect(mockGetFullList).toHaveBeenCalledWith({
|
|
fields: "slug",
|
|
filter: "draft=false",
|
|
})
|
|
})
|
|
|
|
it("should return empty array on error", async () => {
|
|
vi.mocked(pb.collection).mockReturnValue({
|
|
getFullList: vi.fn().mockRejectedValue(new Error("fail")),
|
|
} as never)
|
|
vi.spyOn(console, "error").mockImplementation(() => {})
|
|
|
|
const result = await getUrls()
|
|
expect(result).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe("getRecentPages", () => {
|
|
it("should fetch recent pages with default limit", async () => {
|
|
const mockGetList = vi.fn().mockResolvedValue({
|
|
items: [
|
|
{
|
|
id: "x1",
|
|
title: "Recent",
|
|
slug: "/2024/recent",
|
|
date: "2024-03-01",
|
|
excerpt: "Summary",
|
|
doc: { version: 1, blocks: [] },
|
|
tags: [],
|
|
draft: false,
|
|
},
|
|
],
|
|
})
|
|
vi.mocked(pb.collection).mockReturnValue({
|
|
getList: mockGetList,
|
|
} as never)
|
|
|
|
const result = await getRecentPages()
|
|
expect(result).toEqual([{
|
|
id: "x1",
|
|
title: "Recent",
|
|
url: "/2024/recent",
|
|
date: "2024-03-01",
|
|
excerpt: "Summary",
|
|
}])
|
|
expect(mockGetList).toHaveBeenCalledWith(1, 30, {
|
|
filter: "date!='' && draft=false",
|
|
sort: "-date",
|
|
})
|
|
})
|
|
|
|
it("should return empty array on error", async () => {
|
|
vi.mocked(pb.collection).mockReturnValue({
|
|
getList: vi.fn().mockRejectedValue(new Error("fail")),
|
|
} as never)
|
|
vi.spyOn(console, "error").mockImplementation(() => {})
|
|
|
|
const result = await getRecentPages()
|
|
expect(result).toEqual([])
|
|
})
|
|
})
|
|
```
|
|
|
|
- [x] **Step 3: Run tests**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && npx vitest run src/remote/
|
|
```
|
|
|
|
Expected: all tests pass
|
|
|
|
- [x] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && git add src/remote/api.ts src/remote/api.test.ts && git commit -m "rewrite api.ts for PocketBase SDK"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Delete unused files and update config
|
|
|
|
**Files:**
|
|
- Delete: `src/remote/image.ts`
|
|
- Delete: `src/remote/lib.ts`
|
|
- Modify: `next.config.ts`
|
|
- Modify: `.env.example`
|
|
|
|
- [x] **Step 1: Delete `src/remote/image.ts` and `src/remote/lib.ts`**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && git rm src/remote/image.ts src/remote/lib.ts
|
|
```
|
|
|
|
- [x] **Step 2: Update `next.config.ts`**
|
|
|
|
Add `127.0.0.1` to `remotePatterns`:
|
|
|
|
```ts
|
|
import type { NextConfig } from "next"
|
|
|
|
const config: NextConfig = {
|
|
output: "standalone",
|
|
trailingSlash: false,
|
|
images: {
|
|
remotePatterns: [
|
|
{ hostname: "hudozkacdn.tmshv.com" },
|
|
{ hostname: "images.weserv.nl" },
|
|
{ hostname: "127.0.0.1" },
|
|
],
|
|
},
|
|
}
|
|
|
|
export default config
|
|
```
|
|
|
|
- [x] **Step 3: Update `.env.example`**
|
|
|
|
Add `POCKETBASE_URL`:
|
|
|
|
```
|
|
APP_CARD_DEFAULT_IMAGE=https://art.shlisselburg.org/static/img/main.jpg
|
|
NEXT_PUBLIC_YMETRIKA_ACCOUNT=XXX
|
|
POCKETBASE_URL=http://127.0.0.1:8090
|
|
```
|
|
|
|
- [x] **Step 4: Run lint and full test suite**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && npm run lint && npm test
|
|
```
|
|
|
|
Expected: all pass
|
|
|
|
- [x] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && git add src/remote/image.ts src/remote/lib.ts next.config.ts .env.example && git commit -m "remove unused Strapi files, add PB to config"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Build verification
|
|
|
|
**Files:** none (verification only)
|
|
|
|
- [x] **Step 1: Create `.env.local` if not exists**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && echo 'POCKETBASE_URL=http://127.0.0.1:8090' >> .env.local
|
|
```
|
|
|
|
- [x] **Step 2: Run build against PB**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && npm run build
|
|
```
|
|
|
|
Expected: build succeeds. Pages generate against PocketBase. Some pages may show warnings if card-grid references can't be resolved — that's OK for now.
|
|
|
|
- [x] **Step 3: Run full test suite**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && npm test
|
|
```
|
|
|
|
Expected: all tests pass
|
|
|
|
- [x] **Step 4: Verify lint**
|
|
|
|
```bash
|
|
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && npm run lint
|
|
```
|
|
|
|
Expected: no errors
|