Files
hudozka/docs/plans/completed/2026-04-06-pocketbase-api-layer.md
T
tmshv ed3f93de53 PB + Writer (#287)
* 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
2026-04-12 19:02:03 +03:00

25 KiB

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

  • Step 1: Install pocketbase

cd /Users/tmshv/Workspace/__github_tmshv/hudozka && npm install pocketbase@^0.25.0
  • Step 2: Verify installation
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && node -e "import('pocketbase').then(m => console.log('OK', typeof m.default))"

Expected: OK function

  • Step 3: Commit
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.

  • Step 1: Update Tag.id to string

In src/types.ts, change:

export type Tag = {
    id: string
    name: string
    slug: string
    href: string
    count: number
}
  • Step 2: Update PageCardDto.id to string

In src/types.ts, change:

export type PageCardDto = {
    id: string
    url: string
    title: string
    featured: boolean
    date: string | null
    cover: Pic
}
  • Step 3: Run lint and tests
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)

  • Step 4: Commit
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

  • Step 1: Create src/remote/doc.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[]
}
  • Step 2: Verify no type errors
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && npx tsc --noEmit src/remote/doc.ts

Expected: no errors

  • Step 3: Commit
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

  • Step 1: Create src/remote/pb.ts

import PocketBase from "pocketbase"

const pb = new PocketBase(process.env.POCKETBASE_URL)
pb.autoCancellation(false)

export { pb }
  • Step 2: Rewrite src/remote/types.ts

Replace entire contents with:

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 }[]
}
  • Step 3: Verify no type errors
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)

  • Step 4: Commit
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.

  • Step 1: Write the failing test for createFeedPages

Rewrite src/remote/factory.test.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([])
    })
})
  • Step 2: Run test to verify it fails
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && npx vitest run src/remote/factory.test.ts

Expected: FAIL (factory.ts still imports old Strapi types)

  • Step 3: Rewrite src/remote/factory.ts

Replace entire contents with:

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,
        }))
}
  • Step 4: Run test to verify it passes
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && npx vitest run src/remote/factory.test.ts

Expected: PASS

  • Step 5: Commit
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

  • Step 1: Rewrite src/remote/api.ts

Replace entire contents with:

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 []
    }
}
  • Step 2: Rewrite src/remote/api.test.ts

Replace entire contents with:

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([])
    })
})
  • Step 3: Run tests
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && npx vitest run src/remote/

Expected: all tests pass

  • Step 4: Commit
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

  • Step 1: Delete src/remote/image.ts and src/remote/lib.ts

cd /Users/tmshv/Workspace/__github_tmshv/hudozka && git rm src/remote/image.ts src/remote/lib.ts
  • Step 2: Update next.config.ts

Add 127.0.0.1 to remotePatterns:

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
  • 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
  • Step 4: Run lint and full test suite
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && npm run lint && npm test

Expected: all pass

  • Step 5: Commit
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)

  • Step 1: Create .env.local if not exists
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && echo 'POCKETBASE_URL=http://127.0.0.1:8090' >> .env.local
  • Step 2: Run build against PB
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.

  • Step 3: Run full test suite
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && npm test

Expected: all tests pass

  • Step 4: Verify lint
cd /Users/tmshv/Workspace/__github_tmshv/hudozka && npm run lint

Expected: no errors