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
This commit is contained in:
tmshv authored and GitHub committed 2026-04-12 19:02:03 +03:00
1 parent 907298779c
commit ed3f93de53
109 files changed
+40520 -427

No files matched your search

+1
View File
@@ -1,2 +1,3 @@
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
+1
View File
@@ -0,0 +1 @@
POCKETBASE_URL=http://127.0.0.1:8090
+8
View File
@@ -22,3 +22,11 @@ dist
.next
tsconfig.tsbuildinfo
.env
pb_data/*.db*
pb_data/storage
# ralphex progress logs
.ralphex/progress/
.superpowers/
+129
View File
@@ -0,0 +1,129 @@
# PocketBase Migration Plan
Based on verified live data from `hudozka.tmshv.com`.
## Verified Data Volume
| Entity | Count |
|------------|-------|
| Pages | 316 |
| Tags | 70 |
| Home cards | 17 |
| Menu items | 4 |
---
## Part 1: PocketBase Setup (manual)
See [20260403-pocketbase-setup-guide.md](20260403-pocketbase-setup-guide.md).
---
## Part 2: Migration Script
Script location: `modules/migrate-to-pocketbase/`
### What it does
1. Fetches all data from Strapi (`hudozka.tmshv.com`):
- `GET /pages?_limit=100&_start={n}` (4 batches)
- `GET /tags`
- `GET /home`
- `GET /menu`
2. Creates 70 tag records in PocketBase, builds `strapiTagId -> pbTagId` map.
3. Collects all unique Strapi media used as images (covers + `hudozka.image` blocks). For each:
- Downloads file from `hudozkacdn.tmshv.com`
- Reads dimensions with `sharp`, computes blurhash
- Creates `images` record with `file, alt, caption, width, height, blur, filename`
- Builds `strapiMediaId -> pbImageId` map
4. Collects all unique Strapi media used as documents (`hudozka.document` blocks). For each:
- Downloads file from `hudozkacdn.tmshv.com`
- Creates `files` record with `file, title, mime, size, filename` (preview left empty)
- Builds `strapiMediaId -> pbFileId` map
5. For each of 316 pages:
- Resolves `cover` to `pbImageId` via image map
- Converts `content[]` from Strapi component format to simplified JSON blocks:
- `hudozka.text` -> `{ type: "text", text }`
- `hudozka.image` -> `{ type: "image", image: pbImageId, wide, caption }`
- `hudozka.document` -> `{ type: "document", file: pbFileId, title }`
- `hudozka.embed` -> `{ type: "embed", src }`
- `hudozka.card-grid` -> `{ type: "card-grid", items: [{ page: strapiPageId, layout }] }`
- Creates page record with `cover` + `tags` relations + `content` JSON
6. Second pass: replaces Strapi page IDs in `card-grid` blocks with PocketBase page IDs.
7. Creates 2 `kv` records:
- `key: "home"` with card list (PocketBase page IDs + layouts)
- `key: "menu"` with menu items (PocketBase page IDs) + `homeLabel`
8. Verification: counts check, spot-check, HTTP HEAD on file URLs.
### Environment
Requires `STRAPI_URL` (default `https://hudozka.tmshv.com`) and `POCKETBASE_URL` + `POCKETBASE_EMAIL` + `POCKETBASE_PASSWORD` env vars.
---
## Part 3: App Changes
Only `src/remote/` changes. `src/types.ts` stays **unchanged**.
### New file: `src/remote/pb.ts`
PocketBase client singleton. Reads `POCKETBASE_URL` from env.
### Rewrite: `src/remote/types.ts`
Replace `Strapi*` types with `Pb*` types matching PocketBase record shape + new content block types.
### Rewrite: `src/remote/api.ts`
Same exported function signatures, PocketBase SDK calls instead of raw fetch:
| Function | Current | New (PocketBase SDK) |
|----------------------|------------------------------------------------|-------------------------------------------------------------------------------|
| `getUrls()` | `GET /pages?_limit=100&_start={n}` | `pb.collection("pages").getFullList({ fields: "slug" })` |
| `getPageBySlug(slug)`| `GET /pages?slug={slug}` | `pb.collection("pages").getFirstListItem('slug="..."', { expand: "cover,tags" })` + resolve image/file IDs from content via prefetched maps |
| `getHomeCards()` | `GET /home` | `pb.collection("kv").getFirstListItem('key="home"')` + fetch pages |
| `getMenu()` | `GET /menu` | `pb.collection("kv").getFirstListItem('key="menu"')` + fetch pages |
| `getRecentPages(n)` | `GET /pages?_sort=date:DESC&_limit={n}` | `pb.collection("pages").getList(1, n, { filter: "date!=''", sort: "-date" })` |
### Rewrite: `src/remote/factory.ts`
Transform functions accept PocketBase records, output identical app types. Key changes:
- At build start, prefetch all `images` and `files` records once → in-memory `Map<id, record>`
- Content blocks reference image/file IDs; resolved via the maps (no per-page expand)
- Pages fetched with `expand: "cover,tags"` only
- File URLs via `pb.files.getURL(record, record.file)`
- Blurhash read from image record (`blur` field) — no runtime `sharp` calls needed
- `content` is already clean JSON (no `__component` prefix)
- `asItem()` from `lib.ts` no longer needed
- `createPicFromMedia()` -> `createPicFromImageRecord(imageRecord)`
- Text processing (`markdownToHtml` + `typograf`) stays identical
### Update: `next.config.ts`
Add PocketBase host to `images.remotePatterns`.
### New dependency
`pocketbase` (JS SDK)
### Files summary
| File | Action |
|-----------------------|-------------------------------------------|
| `src/remote/pb.ts` | **New** — PocketBase client |
| `src/remote/types.ts` | **Rewrite** — `Pb*` types |
| `src/remote/api.ts` | **Rewrite** — PocketBase SDK calls |
| `src/remote/factory.ts`| **Rewrite** — new input shapes |
| `src/remote/image.ts` | **Remove** — blurhash precomputed in migration |
| `src/remote/lib.ts` | **Remove or simplify** |
| `src/types.ts` | **No changes** |
| `next.config.ts` | **Update** — remotePatterns |
| `.env.example` | **Update** — add `POCKETBASE_URL` |
| `package.json` | **Update** — add `pocketbase` dependency |
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,59 @@
# PocketBase Setup Guide
Create 5 collections in the PocketBase admin UI. The migration script will populate all records — you only need to create empty collections with the correct fields.
## API Rules (same for all collections)
- List/Search/View: leave empty (public read)
- Create/Update/Delete: `@request.auth.id != ""`
## Step 1 — Create collection `tags`
| Field | Type | Settings |
|--------|------------|------------------|
| `name` | Plain text | Required |
| `slug` | Plain text | Required, Unique |
## Step 2 — Create collection `images`
| Field | Type | Settings |
|------------|------------|------------------------------------------------------------|
| `file` | File | Required, Max 1, MIME: `image/jpeg, image/png, image/webp`, Thumb sizes: `100x100`, `400x0`, `1200x0` |
| `alt` | Plain text | *(optional)* |
| `caption` | Plain text | *(optional)* |
| `width` | Number | Required |
| `height` | Number | Required |
| `blur` | Plain text | *(optional, blurhash)* |
| `filename` | Plain text | *(optional, original filename)* |
## Step 3 — Create collection `files`
| Field | Type | Settings |
|------------|------------|---------------------------------------------------|
| `file` | File | Required, Max 1, MIME: any |
| `preview` | File | Max 1, MIME: `image/jpeg, image/webp`, Thumb sizes: `100x100`, `400x0` |
| `title` | Plain text | *(optional)* |
| `mime` | Plain text | *(optional)* |
| `size` | Number | *(optional, bytes)* |
| `filename` | Plain text | *(optional, original filename)* |
## Step 4 — Create collection `pages`
Create fields in this order:
| # | Field | Type | Settings |
|----|-----------|------------|--------------------------------------------------------|
| 1 | `title` | Plain text | Required |
| 2 | `slug` | Plain text | Required, Unique |
| 3 | `excerpt` | Plain text | *(optional)* |
| 4 | `date` | Date | *(leave blank for undated pages)* |
| 5 | `cover` | Relation | Collection: `images`. Max select: 1 |
| 6 | `content` | JSON | *(content blocks reference image/file IDs directly)* |
| 7 | `tags` | Relation | Collection: `tags`. Max select: unlimited |
## Step 5 — Create collection `kv` (key-value store for home + menu)
| Field | Type | Settings |
|--------|------------|------------------|
| `key` | Plain text | Required, Unique |
| `data` | JSON | Required |
@@ -0,0 +1,718 @@
# Writer Draft/Publish Implementation Plan
> **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:** Add draft-save and explicit publish workflow to hudozka-writer so editors can save work-in-progress without pushing changes live.
**Architecture:** PocketBase `pages` collection gets a `draft` JSON field for WIP content alongside existing `doc` (published). The `draft: boolean` field is replaced with `published: boolean`. The editor loads `draft ?? doc`, tracks dirty state via JSON snapshot comparison, and provides Save (writes `draft`), Publish (writes `doc`, clears `draft`), and Discard (clears `draft`, reloads from `doc`) actions.
**Tech Stack:** React, TipTap, PocketBase JS SDK
**Spec:** `docs/superpowers/specs/2026-04-07-writer-draft-publish.md`
---
### Task 1: Update PbPage type
**Files:**
- Modify: `modules/hudozka-writer/src/types.ts:28-37`
- [x] **Step 1: Update PbPage type definition**
Replace the current `PbPage` type:
```ts
export type PbPage = PbRecord & {
title: string
slug: string
excerpt: string
date: string
cover: string
doc: DocV1
tags: string[]
published: boolean
draft: DocV1 | null
}
```
- [x] **Step 2: Verify build**
Run: `cd modules/hudozka-writer && npx tsc --noEmit`
Expected: errors in `App.tsx` where `draft` was used as boolean — that's expected, we fix it in Task 4.
- [x] **Step 3: Commit**
```bash
git add modules/hudozka-writer/src/types.ts
git commit -m "feat(writer): update PbPage type for draft/publish workflow"
```
---
### Task 2: Add Save/Publish/Discard handlers and dirty tracking to Editor
**Files:**
- Modify: `modules/hudozka-writer/src/components/Editor.tsx`
- [x] **Step 1: Rewrite Editor component with draft support**
Replace the full content of `Editor.tsx`:
```tsx
import { useEditor, EditorContent } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import { Markdown } from "@tiptap/markdown"
import Link from "@tiptap/extension-link"
import { useState, useCallback, useRef, useEffect } from "react"
import { pb } from "../pb"
import { docToTiptap, tiptapToDoc } from "../lib/serialize"
import { Toolbar } from "./Toolbar"
import { DraftBanner } from "./DraftBanner"
import {
WriterDocument,
ImageBlock,
DocumentBlock,
EmbedBlock,
CardGridBlock,
} from "../nodes"
import { BlockInsert } from "../extensions/BlockInsert"
import { SlashCommands } from "../extensions/SlashCommands"
import "../nodes/nodes.css"
import "./Editor.css"
import type { PbPage, DocV1 } from "../types"
function serializeDoc(doc: DocV1): string {
return JSON.stringify(doc)
}
export type EditorProps = {
page: PbPage
}
export function Editor({ page }: EditorProps) {
const [saving, setSaving] = useState(false)
const [publishing, setPublishing] = useState(false)
const [markdownMode, setMarkdownMode] = useState(false)
const [markdownText, setMarkdownText] = useState("")
const [dirty, setDirty] = useState(false)
const [hasDraft, setHasDraft] = useState(page.draft !== null)
const [publishedDoc, setPublishedDoc] = useState(page.doc)
const snapshotRef = useRef("")
const initialDoc = page.draft ?? page.doc
const tiptapDoc = docToTiptap(initialDoc)
// Set initial snapshot
useEffect(() => {
snapshotRef.current = serializeDoc(initialDoc)
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const editor = useEditor({
extensions: [
WriterDocument,
StarterKit.configure({
document: false,
}),
Markdown,
Link.configure({ openOnClick: false }),
ImageBlock,
DocumentBlock,
EmbedBlock,
CardGridBlock,
BlockInsert,
SlashCommands,
],
content: tiptapDoc,
onUpdate: ({ editor }) => {
const json = editor.getJSON()
const doc = tiptapToDoc(json.content ?? [])
const current = serializeDoc(doc)
setDirty(current !== snapshotRef.current)
},
})
const getCurrentDoc = useCallback((): DocV1 => {
if (!editor) return { version: 1, blocks: [] }
if (markdownMode) {
editor.commands.setContent(markdownText, { contentType: "markdown" })
setMarkdownMode(false)
}
const json = editor.getJSON()
return tiptapToDoc(json.content ?? [])
}, [editor, markdownMode, markdownText])
const handleToggleMarkdown = useCallback(() => {
if (!editor) return
if (!markdownMode) {
const md = editor.getMarkdown()
setMarkdownText(md)
setMarkdownMode(true)
} else {
editor.commands.setContent(markdownText, { contentType: "markdown" })
setMarkdownMode(false)
}
}, [editor, markdownMode, markdownText])
const handleSave = useCallback(async () => {
if (!editor) return
setSaving(true)
try {
const doc = getCurrentDoc()
await pb.collection("pages").update(page.id, { draft: doc })
snapshotRef.current = serializeDoc(doc)
setDirty(false)
setHasDraft(true)
} catch (err) {
console.error("Save failed:", err)
alert("Save failed. Check console.")
} finally {
setSaving(false)
}
}, [editor, page.id, getCurrentDoc])
const handlePublish = useCallback(async () => {
if (!editor) return
setPublishing(true)
try {
const doc = getCurrentDoc()
await pb.collection("pages").update(page.id, { doc, draft: null })
snapshotRef.current = serializeDoc(doc)
setDirty(false)
setHasDraft(false)
setPublishedDoc(doc)
} catch (err) {
console.error("Publish failed:", err)
alert("Publish failed. Check console.")
} finally {
setPublishing(false)
}
}, [editor, page.id, getCurrentDoc])
const handleDiscard = useCallback(async () => {
if (!editor) return
try {
await pb.collection("pages").update(page.id, { draft: null })
const tiptap = docToTiptap(publishedDoc)
editor.commands.setContent(tiptap)
snapshotRef.current = serializeDoc(publishedDoc)
setDirty(false)
setHasDraft(false)
setMarkdownMode(false)
} catch (err) {
console.error("Discard failed:", err)
alert("Discard failed. Check console.")
}
}, [editor, page.id, publishedDoc])
return (
<div className="editor">
<Toolbar
editor={editor}
onSave={handleSave}
onPublish={handlePublish}
saving={saving}
publishing={publishing}
dirty={dirty}
hasDraft={hasDraft}
markdownMode={markdownMode}
onToggleMarkdown={handleToggleMarkdown}
/>
{hasDraft && (
<DraftBanner onDiscard={handleDiscard} />
)}
{markdownMode ? (
<textarea
className="editor-markdown"
value={markdownText}
onChange={(e) => setMarkdownText(e.target.value)}
/>
) : (
<EditorContent editor={editor} className="editor-content" />
)}
</div>
)
}
```
- [x] **Step 2: Commit**
```bash
git add modules/hudozka-writer/src/components/Editor.tsx
git commit -m "feat(writer): add save/publish/discard handlers with dirty tracking"
```
---
### Task 3: Create DraftBanner component
**Files:**
- Create: `modules/hudozka-writer/src/components/DraftBanner.tsx`
- Create: `modules/hudozka-writer/src/components/DraftBanner.css`
- [x] **Step 1: Create DraftBanner component**
`modules/hudozka-writer/src/components/DraftBanner.tsx`:
```tsx
import "./DraftBanner.css"
export type DraftBannerProps = {
onDiscard: () => void
}
export function DraftBanner({ onDiscard }: DraftBannerProps) {
return (
<div className="draft-banner">
<span>You have unpublished changes</span>
<button onClick={onDiscard} className="draft-banner-discard">
Discard
</button>
</div>
)
}
```
- [x] **Step 2: Create DraftBanner styles**
`modules/hudozka-writer/src/components/DraftBanner.css`:
```css
.draft-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #fff8e1;
border: 1px solid #ffe082;
border-radius: 4px;
margin-bottom: 12px;
font-size: 0.9rem;
}
.draft-banner-discard {
padding: 4px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 0.85rem;
}
.draft-banner-discard:hover {
background: #f0f0f0;
}
```
- [x] **Step 3: Commit**
```bash
git add modules/hudozka-writer/src/components/DraftBanner.tsx modules/hudozka-writer/src/components/DraftBanner.css
git commit -m "feat(writer): add DraftBanner component"
```
---
### Task 4: Update Toolbar with Publish button
**Files:**
- Modify: `modules/hudozka-writer/src/components/Toolbar.tsx`
- Modify: `modules/hudozka-writer/src/components/Toolbar.css`
- [x] **Step 1: Update Toolbar component**
Replace the full content of `Toolbar.tsx`:
```tsx
import type { Editor } from "@tiptap/react"
import "./Toolbar.css"
export type ToolbarProps = {
editor: Editor | null
onSave: () => void
onPublish: () => void
saving: boolean
publishing: boolean
dirty: boolean
hasDraft: boolean
markdownMode: boolean
onToggleMarkdown: () => void
}
export function Toolbar({
editor,
onSave,
onPublish,
saving,
publishing,
dirty,
hasDraft,
markdownMode,
onToggleMarkdown,
}: ToolbarProps) {
if (!editor) return null
const canSave = dirty
const canPublish = dirty || hasDraft
return (
<div className="toolbar">
<div className="toolbar-group">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive("bold") ? "active" : ""}
title="Bold"
>
B
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive("italic") ? "active" : ""}
title="Italic"
>
I
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
className={editor.isActive("strike") ? "active" : ""}
title="Strikethrough"
>
S
</button>
<button
onClick={() => editor.chain().focus().toggleCode().run()}
className={editor.isActive("code") ? "active" : ""}
title="Inline code"
>
&lt;/&gt;
</button>
</div>
<div className="toolbar-group">
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive("heading", { level: 1 }) ? "active" : ""}
>
H1
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive("heading", { level: 2 }) ? "active" : ""}
>
H2
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={editor.isActive("heading", { level: 3 }) ? "active" : ""}
>
H3
</button>
</div>
<div className="toolbar-group">
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive("bulletList") ? "active" : ""}
title="Bullet list"
>
List
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive("orderedList") ? "active" : ""}
title="Numbered list"
>
1.
</button>
<button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editor.isActive("blockquote") ? "active" : ""}
title="Blockquote"
>
&ldquo;
</button>
<button
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={editor.isActive("codeBlock") ? "active" : ""}
title="Code block"
>
Code
</button>
<button
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Horizontal rule"
>
---
</button>
</div>
<div className="toolbar-group">
<button
onClick={onToggleMarkdown}
className={markdownMode ? "active" : ""}
title="Toggle markdown view"
>
MD
</button>
</div>
<div className="toolbar-group toolbar-actions">
<button
onClick={onSave}
disabled={!canSave || saving}
className="toolbar-save-btn"
>
{saving ? "Saving..." : "Save"}
</button>
<button
onClick={onPublish}
disabled={!canPublish || publishing}
className="toolbar-publish-btn"
>
{publishing ? "Publishing..." : "Publish"}
</button>
</div>
</div>
)
}
```
- [x] **Step 2: Update Toolbar styles**
Replace `.toolbar-save` and `.toolbar-save-btn` rules in `Toolbar.css`. Remove old `.toolbar-save` and `.toolbar-save-btn` rules and add:
```css
.toolbar-actions {
margin-left: auto;
gap: 4px;
}
.toolbar-save-btn {
padding: 4px 16px !important;
}
.toolbar-save-btn:disabled {
opacity: 0.6;
cursor: not-allowed !important;
}
.toolbar-publish-btn {
background: #213547 !important;
color: white !important;
border-color: #213547 !important;
padding: 4px 16px !important;
}
.toolbar-publish-btn:disabled {
opacity: 0.6;
cursor: not-allowed !important;
}
```
- [x] **Step 3: Commit**
```bash
git add modules/hudozka-writer/src/components/Toolbar.tsx modules/hudozka-writer/src/components/Toolbar.css
git commit -m "feat(writer): add Publish button and update toolbar layout"
```
---
### Task 5: Update App with published toggle and fix duplicate handler
**Files:**
- Modify: `modules/hudozka-writer/src/App.tsx`
- Modify: `modules/hudozka-writer/src/App.css`
- [x] **Step 1: Update App component**
Replace the full content of `App.tsx`:
```tsx
import { useState, useEffect, useCallback } from "react"
import { pb } from "./pb"
import { Login } from "./components/Login"
import { Editor } from "./components/Editor"
import type { PbPage } from "./types"
import "./App.css"
function getSlugFromUrl(): string | null {
const params = new URLSearchParams(window.location.search)
return params.get("page")
}
export function App() {
const [authenticated, setAuthenticated] = useState(pb.authStore.isValid)
const [page, setPage] = useState<PbPage | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const slug = getSlugFromUrl()
const loadPage = useCallback(async (slug: string) => {
setLoading(true)
setError("")
try {
const record = await pb.collection("pages").getFirstListItem<PbPage>(
`slug="${slug}"`,
)
setPage(record)
} catch {
setError(`Page "${slug}" not found`)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (authenticated && slug) {
loadPage(slug)
}
}, [authenticated, slug, loadPage])
function handleLogout() {
pb.authStore.clear()
setAuthenticated(false)
setPage(null)
}
async function handleDuplicate() {
if (!page) return
const newSlug = `${page.slug}-copy`
try {
const record = await pb.collection("pages").create<PbPage>({
title: `${page.title} (copy)`,
slug: newSlug,
excerpt: page.excerpt,
date: page.date,
cover: page.cover,
doc: page.doc,
tags: page.tags,
published: false,
draft: null,
})
window.location.search = `?page=${record.slug}`
} catch (err) {
console.error("Duplicate failed:", err)
alert("Duplicate failed. Check console.")
}
}
async function handleTogglePublished() {
if (!page) return
try {
const updated = await pb.collection("pages").update<PbPage>(page.id, {
published: !page.published,
})
setPage(updated)
} catch (err) {
console.error("Toggle published failed:", err)
alert("Toggle published failed. Check console.")
}
}
if (!authenticated) {
return <Login onLogin={() => setAuthenticated(true)} />
}
if (!slug) {
return (
<div className="app-message">
<p>Add <code>?page=slug</code> to the URL to edit a page.</p>
<button onClick={handleLogout}>Logout</button>
</div>
)
}
if (loading) {
return <div className="app-message">Loading...</div>
}
if (error) {
return (
<div className="app-message">
<p className="app-error">{error}</p>
<button onClick={handleLogout}>Logout</button>
</div>
)
}
if (!page) {
return <div className="app-message">Loading...</div>
}
return (
<div className="app">
<header className="app-header">
<span className="app-title">{page.title}</span>
<span className="app-slug">/{page.slug}</span>
<label className="app-published-toggle">
<input
type="checkbox"
checked={page.published}
onChange={handleTogglePublished}
/>
Published
</label>
<button onClick={handleDuplicate}>Duplicate</button>
<button onClick={handleLogout}>Logout</button>
</header>
<main className="app-main">
<Editor page={page} />
</main>
</div>
)
}
```
- [x] **Step 2: Add published toggle styles to App.css**
Add to the end of `App.css`:
```css
.app-published-toggle {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.9rem;
cursor: pointer;
user-select: none;
}
.app-published-toggle input {
cursor: pointer;
}
```
- [x] **Step 3: Verify build**
Run: `cd modules/hudozka-writer && npx tsc --noEmit`
Expected: no errors.
- [x] **Step 4: Commit**
```bash
git add modules/hudozka-writer/src/App.tsx modules/hudozka-writer/src/App.css
git commit -m "feat(writer): add published toggle and update duplicate for new schema"
```
---
### Task 6: Manual verification
- [x] **Step 1: Start the writer dev server** (skipped - manual verification)
Run: `cd modules/hudozka-writer && npm run dev`
- [x] **Step 2: Test the workflow** (skipped - manual verification)
Open the writer in a browser with `?page=<some-slug>`. Verify:
1. Page loads, Save is disabled, Publish is disabled (no changes)
2. Edit some text — Save becomes enabled, Publish becomes enabled
3. Click Save — Save becomes disabled, Publish stays enabled, draft banner appears
4. Reload the page — draft banner shows, editor loads draft content
5. Click Discard — editor reverts to published content, banner disappears
6. Edit and click Publish — both buttons disabled, no banner
7. Toggle Published checkbox — toggles immediately
8. Duplicate — creates unpublished copy
@@ -0,0 +1,627 @@
# TextBlock Container Implementation Plan
> **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:** Introduce a `textBlock` container node so text content (paragraphs, headings, lists) is grouped into cohesive blocks with a single sidebar (delete, drag, split), replacing the current flat structure where every text-level node has its own sidebar.
**Architecture:** A new Tiptap `textBlock` node wraps text-level content. The document schema becomes `(textBlock | imageBlock | documentBlock | embedBlock | cardGridBlock)+`. All `DraggableXxx` wrappers are removed — StarterKit renders text nodes natively inside the container. `BlockActions` gains a split button for text blocks only.
**Tech Stack:** Tiptap/ProseMirror, React, TypeScript
**Spec:** `docs/specs/20260408-textblock-container-redesign.md`
---
## File Structure
| Action | File | Responsibility |
|--------|---------------------------------------------------|------------------------------------------------------|
| Create | `src/nodes/TextBlock.tsx` | TextBlock node definition + React node view |
| Modify | `src/nodes/WriterDocument.ts` | Update content spec to explicit block types |
| Delete | `src/nodes/textBlocks.tsx` | Remove all DraggableXxx wrappers |
| Modify | `src/nodes/index.ts` | Update exports |
| Modify | `src/components/Editor.tsx` | Update extensions list, re-enable StarterKit nodes |
| Modify | `src/components/BlockActions.tsx` | Add split button with `showSplit` prop |
| Modify | `src/components/BlockActions.css` | Style for split button |
| Modify | `src/lib/serialize.ts` | Handle textBlock in serialization/deserialization |
| Modify | `src/components/BlockMenu.tsx` | "Text" inserts a textBlock node |
| Modify | `src/extensions/SlashCommands.tsx` | Handle custom block insertion inside textBlock |
All paths relative to `modules/hudozka-writer/`.
---
### Task 1: Create TextBlock node definition
**Files:**
- Create: `modules/hudozka-writer/src/nodes/TextBlock.tsx`
- [x] **Step 1: Create the TextBlock node and view**
```tsx
import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from "@tiptap/react"
import type { NodeViewProps } from "@tiptap/react"
import { BlockActions } from "../components/BlockActions"
import { BlockInsert } from "../components/BlockInsert"
function TextBlockView({ editor, getPos }: NodeViewProps) {
return (
<>
<NodeViewWrapper className="node-view-block node-text-block">
<BlockActions editor={editor} getPos={getPos} showSplit />
<NodeViewContent className="node-view-content" />
</NodeViewWrapper>
<BlockInsert editor={editor} getPos={getPos} />
</>
)
}
export const TextBlock = Node.create({
name: "textBlock",
group: "block",
content: "(paragraph | heading | bulletList | orderedList | blockquote | codeBlock | horizontalRule)+",
draggable: true,
defining: true,
parseHTML() {
return [{ tag: "div[data-text-block]" }]
},
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes(HTMLAttributes, { "data-text-block": "" }), 0]
},
addNodeView() {
return ReactNodeViewRenderer(TextBlockView)
},
})
```
Key points:
- `content` uses an explicit list of allowed child types — no group references, so textBlock can't nest inside itself
- `defining: true` tells ProseMirror this node defines the boundary of its content — backspace at the start of the first child won't escape the container
- `showSplit` prop is passed to BlockActions (implemented in Task 4)
- [x] **Step 2: Commit**
```bash
git add modules/hudozka-writer/src/nodes/TextBlock.tsx
git commit -m "feat(writer): add TextBlock container node definition"
```
---
### Task 2: Update document schema and node exports
**Files:**
- Modify: `modules/hudozka-writer/src/nodes/WriterDocument.ts`
- Modify: `modules/hudozka-writer/src/nodes/index.ts`
- Delete: `modules/hudozka-writer/src/nodes/textBlocks.tsx`
- [x] **Step 1: Update WriterDocument content spec**
In `WriterDocument.ts`, change the content from `"block+"` to an explicit list:
```ts
import { Node } from "@tiptap/core"
export const WriterDocument = Node.create({
name: "doc",
topNode: true,
content: "(textBlock | imageBlock | documentBlock | embedBlock | cardGridBlock)+",
})
```
This prevents raw paragraphs/headings at the document top level — they must live inside a textBlock.
- [x] **Step 2: Delete textBlocks.tsx**
Delete the file `modules/hudozka-writer/src/nodes/textBlocks.tsx` entirely. All `DraggableXxx` exports are no longer needed.
- [x] **Step 3: Update index.ts exports**
Replace the DraggableXxx exports with the new TextBlock:
```ts
export { WriterDocument } from "./WriterDocument"
export { TextBlock } from "./TextBlock"
export { ImageBlock } from "./ImageBlock"
export { DocumentBlock } from "./DocumentBlock"
export { EmbedBlock } from "./EmbedBlock"
export { CardGridBlock } from "./CardGridBlock"
```
- [x] **Step 4: Commit**
```bash
git add -u modules/hudozka-writer/src/nodes/
git add modules/hudozka-writer/src/nodes/TextBlock.tsx
git commit -m "feat(writer): replace DraggableXxx wrappers with TextBlock container"
```
---
### Task 3: Update Editor extensions
**Files:**
- Modify: `modules/hudozka-writer/src/components/Editor.tsx`
- [x] **Step 1: Update imports and extensions list**
Replace the imports at the top of Editor.tsx. Remove the DraggableXxx imports:
```ts
import {
WriterDocument,
TextBlock,
ImageBlock,
DocumentBlock,
EmbedBlock,
CardGridBlock,
} from "../nodes"
```
Update the extensions array in `useEditor`:
```ts
extensions: [
WriterDocument,
StarterKit.configure({
document: false,
}),
TextBlock,
Markdown,
Link.configure({ openOnClick: false }),
ImageBlock,
DocumentBlock,
EmbedBlock,
CardGridBlock,
SlashCommands,
],
```
Key change: StarterKit no longer disables paragraph, heading, bulletList, etc. — those are now standard StarterKit nodes rendered natively inside TextBlock. Only `document` is still disabled (replaced by WriterDocument).
- [x] **Step 2: Commit**
```bash
git add modules/hudozka-writer/src/components/Editor.tsx
git commit -m "feat(writer): update Editor extensions for TextBlock container"
```
---
### Task 4: Add split button to BlockActions
**Files:**
- Modify: `modules/hudozka-writer/src/components/BlockActions.tsx`
- Modify: `modules/hudozka-writer/src/components/BlockActions.css`
- [x] **Step 1: Add showSplit prop and split handler**
Update `BlockActionsProps`:
```ts
export type BlockActionsProps = {
editor: Editor
getPos: () => number | undefined
showSplit?: boolean
}
```
Add the split handler function inside `BlockActions`:
```ts
const handleSplit = (e: React.MouseEvent) => {
e.preventDefault()
const pos = getPos()
if (typeof pos !== "number") return
const node = editor.state.doc.nodeAt(pos)
if (!node || node.childCount < 2) return
// Find which child the cursor is in
const { from } = editor.state.selection
let childOffset = pos + 1 // +1 to enter the container
let splitAfter = -1
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i)
const childEnd = childOffset + child.nodeSize
if (from >= childOffset && from < childEnd) {
splitAfter = i
break
}
childOffset = childEnd
}
// Don't split if cursor is in the last child (would create empty second block)
if (splitAfter < 0 || splitAfter >= node.childCount - 1) return
// Don't split if cursor is in the first child and there's nothing before it
// (splitAfter === 0 is fine — first block gets child 0, second gets the rest)
// Build two textBlock nodes
const firstChildren: typeof node.content = []
const secondChildren: typeof node.content = []
for (let i = 0; i < node.childCount; i++) {
if (i <= splitAfter) {
firstChildren.push(node.child(i))
} else {
secondChildren.push(node.child(i))
}
}
const { schema } = editor.state
const textBlockType = schema.nodes.textBlock
const first = textBlockType.create(null, firstChildren)
const second = textBlockType.create(null, secondChildren)
const tr = editor.state.tr
tr.replaceWith(pos, pos + node.nodeSize, [first, second])
editor.view.dispatch(tr)
}
```
Add the split button to the render output, between delete and drag handle:
```tsx
return (
<div className="block-actions" contentEditable={false}>
<button
className="block-action-btn block-action-delete"
title="Delete block"
onClick={handleDelete}
>
&times;
</button>
{showSplit && (
<button
className="block-action-btn block-action-split"
title="Split block"
onClick={handleSplit}
>
&#x2702;
</button>
)}
<div
className="block-action-btn block-drag-handle"
data-drag-handle
draggable
title="Drag to reorder"
>
&#x2807;
</div>
</div>
)
```
- [x] **Step 2: Add CSS for split button**
Add to `BlockActions.css`:
```css
.block-action-split:hover {
border-color: #2563eb;
color: #2563eb;
background: #eff6ff;
}
```
- [x] **Step 3: Commit**
```bash
git add modules/hudozka-writer/src/components/BlockActions.tsx modules/hudozka-writer/src/components/BlockActions.css
git commit -m "feat(writer): add split button to BlockActions for text blocks"
```
---
### Task 5: Update serialization
**Files:**
- Modify: `modules/hudozka-writer/src/lib/serialize.ts`
- [x] **Step 1: Update docToTiptap — wrap text in textBlock**
Change `textBlockToTiptap` to return a single textBlock container wrapping the parsed content:
```ts
function textBlockToTiptap(block: DocV1BlockText): JSONContent {
const manager = getMarkdownManager()
const parsed = manager.parse(block.text)
return {
type: "textBlock",
content: parsed.content ?? [],
}
}
```
Update the `docToTiptap` function to push the single node (not spread):
```ts
case "text":
content.push(textBlockToTiptap(block))
break
```
- [x] **Step 2: Update tiptapToDoc — handle textBlock container**
Replace the buffer-based approach. The new logic iterates top-level nodes and handles `textBlock` directly:
```ts
export function tiptapToDoc(content: JSONContent[]): DocV1 {
const blocks: DocV1Block[] = []
for (const node of content) {
switch (node.type) {
case "textBlock": {
const text = textNodesToMarkdown(node.content ?? [])
if (text.trim()) {
blocks.push({
type: "text",
id: generateBlockId(),
text,
})
}
break
}
case "imageBlock": {
const attrs = node.attrs ?? {}
blocks.push({
type: "image",
id: attrs.id ?? generateBlockId(),
image: attrs.imageId ?? "",
wide: attrs.wide ?? false,
caption: attrs.caption ?? "",
})
break
}
case "documentBlock": {
const attrs = node.attrs ?? {}
blocks.push({
type: "document",
id: attrs.id ?? generateBlockId(),
file: attrs.fileId ?? "",
title: attrs.title ?? "",
})
break
}
case "embedBlock": {
const attrs = node.attrs ?? {}
blocks.push({
type: "embed",
id: attrs.id ?? generateBlockId(),
src: attrs.src ?? "",
})
break
}
case "cardGridBlock": {
const attrs = node.attrs ?? {}
blocks.push({
type: "card-grid",
id: attrs.id ?? generateBlockId(),
items: JSON.parse(attrs.items ?? "[]"),
})
break
}
}
}
return { version: 1, blocks }
}
```
Remove the `isCustomBlockNode` function and `CUSTOM_BLOCK_TYPES` set — no longer needed.
- [x] **Step 3: Commit**
```bash
git add modules/hudozka-writer/src/lib/serialize.ts
git commit -m "feat(writer): update serialization for TextBlock container"
```
---
### Task 6: Update BlockMenu and SlashCommands
**Files:**
- Modify: `modules/hudozka-writer/src/components/BlockMenu.tsx`
- Modify: `modules/hudozka-writer/src/extensions/SlashCommands.tsx`
- [x] **Step 1: Update BlockMenu — "Text" inserts a textBlock**
In `BlockMenu.tsx`, change the "Text" entry in `BLOCK_TYPES`:
```ts
const BLOCK_TYPES: BlockType[] = [
{ label: "Text", type: "textBlock" },
{ label: "Image", type: "imageBlock", attrs: { id: "", imageId: "", wide: false, caption: "" } },
{ label: "File", type: "documentBlock", attrs: { id: "", fileId: "", title: "" } },
{ label: "Embed", type: "embedBlock", attrs: { id: "", src: "" } },
{ label: "Card Grid", type: "cardGridBlock", attrs: { id: "", items: "[]" } },
]
```
Update `handleInsert` for the textBlock case:
```ts
function handleInsert(block: BlockType) {
const id = generateBlockId()
if (block.type === "textBlock") {
editor.chain().focus().insertContentAt(position, {
type: "textBlock",
content: [{ type: "paragraph" }],
}).run()
} else {
editor.chain().focus().insertContentAt(position, {
type: block.type,
attrs: { ...block.attrs, id },
}).run()
}
onClose()
}
```
- [x] **Step 2: Update SlashCommands — handle insertion inside textBlock**
In `SlashCommands.tsx`, update the `ITEMS` array:
```ts
const ITEMS: SlashCommandItem[] = [
{ label: "Text", type: "textBlock" },
{ label: "Image", type: "imageBlock", attrs: { id: "", imageId: "", wide: false, caption: "" } },
{ label: "File", type: "documentBlock", attrs: { id: "", fileId: "", title: "" } },
{ label: "Embed", type: "embedBlock", attrs: { id: "", src: "" } },
{ label: "Card Grid", type: "cardGridBlock", attrs: { id: "", items: "[]" } },
]
```
Update the `command` handler. When inserting a custom block while inside a textBlock, the slash command needs to split the textBlock and insert between the halves:
```ts
command: ({ editor, range, props }: { editor: Editor; range: { from: number; to: number }; props: SlashCommandItem }) => {
// Delete the slash command text
editor.chain().focus().deleteRange(range).run()
if (props.type === "textBlock") {
// Already inside a textBlock paragraph — nothing to do
return
}
const { from } = editor.state.selection
const id = generateBlockId()
const newNode = { type: props.type, attrs: { ...props.attrs, id } }
// Check if we're inside a textBlock
const $pos = editor.state.doc.resolve(from)
const textBlockDepth = findTextBlockDepth($pos)
if (textBlockDepth === null) {
// Not inside a textBlock — insert directly
editor.chain().focus().insertContentAt(from, newNode).run()
return
}
// Inside a textBlock — split it and insert the custom block between halves
const textBlockPos = $pos.before(textBlockDepth)
const textBlockNode = editor.state.doc.nodeAt(textBlockPos)
if (!textBlockNode) return
// Find which child contains the cursor
let childOffset = textBlockPos + 1
let splitAfter = -1
for (let i = 0; i < textBlockNode.childCount; i++) {
const child = textBlockNode.child(i)
const childEnd = childOffset + child.nodeSize
if (from >= childOffset && from <= childEnd) {
// If the paragraph is empty (just deleted the slash), split before it
const curChild = textBlockNode.child(i)
if (curChild.content.size === 0 && i > 0) {
splitAfter = i - 1
} else if (curChild.content.size === 0 && i === 0) {
splitAfter = -1 // cursor in empty first child
} else {
splitAfter = i
}
break
}
childOffset = childEnd
}
const { schema } = editor.state
const textBlockType = schema.nodes.textBlock
const fragments: JSONContent[] = []
// Build first textBlock (children 0..splitAfter) — skip if empty
if (splitAfter >= 0) {
const children = []
for (let i = 0; i <= splitAfter; i++) {
children.push(textBlockNode.child(i))
}
// Skip if all children are empty paragraphs
const hasContent = children.some((c) => c.content.size > 0)
if (hasContent) {
fragments.push(textBlockType.create(null, children))
}
}
// Insert the custom block
fragments.push(schema.nodes[props.type].create(
{ ...props.attrs, id },
))
// Build second textBlock (children after splitAfter) — skip empty
const remaining = []
const startIdx = splitAfter + 1
for (let i = startIdx; i < textBlockNode.childCount; i++) {
remaining.push(textBlockNode.child(i))
}
// Filter out empty paragraphs at the edges
const nonEmpty = remaining.filter((c) => c.content.size > 0)
if (nonEmpty.length > 0) {
fragments.push(textBlockType.create(null, remaining))
}
const tr = editor.state.tr
tr.replaceWith(textBlockPos, textBlockPos + textBlockNode.nodeSize, fragments)
editor.view.dispatch(tr)
},
```
Add a helper function above the extension:
```ts
import type { ResolvedPos } from "@tiptap/pm/model"
function findTextBlockDepth($pos: ResolvedPos): number | null {
for (let d = $pos.depth; d > 0; d--) {
if ($pos.node(d).type.name === "textBlock") {
return d
}
}
return null
}
```
- [x] **Step 3: Commit**
```bash
git add modules/hudozka-writer/src/components/BlockMenu.tsx modules/hudozka-writer/src/extensions/SlashCommands.tsx
git commit -m "feat(writer): update BlockMenu and SlashCommands for TextBlock"
```
---
### Task 7: Manual testing and fixes
- [x] **Step 1: Start the writer dev server and test** (verified via build - manual UI testing skipped, not automatable)
```bash
cd modules/hudozka-writer && npm run dev
```
Test the following scenarios:
1. **Basic rendering** — open an existing page, verify text is grouped in textBlock containers with one sidebar per block
2. **Typing** — type text, press Enter for new paragraphs — should stay inside the same textBlock
3. **Split** — place cursor in a text block with multiple paragraphs, click the split (✂) button. Verify the block splits into two at the paragraph boundary. Verify split is disabled when there's only one child or cursor is at the start/end.
4. **BlockInsert (+)** — click the + button between blocks, insert a Text block (creates new textBlock), insert an Image block
5. **Slash commands** — type `/` in a paragraph, select Image. Verify the textBlock splits and the image is inserted between the halves. Empty halves should not be created.
6. **Drag and drop** — drag a textBlock to reorder it among other blocks
7. **Delete** — delete a textBlock via the × button
8. **Save/load round-trip** — make edits, save, reload the page, verify content is preserved
9. **Markdown toggle** — switch to markdown mode and back, verify content integrity
10. **Active block highlight** — cursor inside a textBlock should show the active outline on the textBlock, not on individual paragraphs
- [x] **Step 2: Fix any issues found during testing** (fixed TS error: replaceWith needs Fragment.from() wrapper in SlashCommands.tsx and BlockActions.tsx)
- [x] **Step 3: Commit fixes**
```bash
git add -u modules/hudozka-writer/
git commit -m "fix(writer): address issues from TextBlock testing"
```
@@ -0,0 +1,848 @@
# Writer Block Actions Redesign
> **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:** Redesign the block sidebar to show a persistent left-side menu with delete + drag handle on every block, a persistent [+] insert button between blocks, and an outline on the active block.
**Architecture:** Replace the current hover-triggered `BlockActions` (3 buttons: drag, add, delete) with two separate components: `BlockActions` (always-visible vertical sidebar with delete + drag handle) and `BlockInsert` (always-visible [+] button between blocks). Each `NodeViewWrapper` uses a flex row layout: sidebar on the left, block content on the right. DnD uses only the drag handle element, not the full block wrapper. Active block gets a CSS outline.
**Tech Stack:** React, Tiptap, CSS
---
## File Map
| Action | File | Responsibility |
|--------|-------------------------------------------------------|---------------------------------------------|
| Modify | `modules/hudozka-writer/src/components/BlockActions.tsx` | Simplify to delete + drag handle, always visible |
| Rewrite| `modules/hudozka-writer/src/components/BlockActions.css` | Vertical sidebar layout, always visible |
| Create | `modules/hudozka-writer/src/components/BlockInsert.tsx` | [+] button that opens BlockMenu |
| Create | `modules/hudozka-writer/src/components/BlockInsert.css` | Insert button styles |
| Modify | `modules/hudozka-writer/src/nodes/textBlocks.tsx` | Add BlockInsert, use flex wrapper layout |
| Modify | `modules/hudozka-writer/src/nodes/ImageBlock.tsx` | Remove data-drag-handle, add BlockInsert, flex layout |
| Modify | `modules/hudozka-writer/src/nodes/DocumentBlock.tsx` | Remove data-drag-handle, add BlockInsert, flex layout |
| Modify | `modules/hudozka-writer/src/nodes/EmbedBlock.tsx` | Add BlockInsert, flex layout |
| Modify | `modules/hudozka-writer/src/nodes/CardGridBlock.tsx` | Add BlockInsert, flex layout |
| Modify | `modules/hudozka-writer/src/components/Editor.css` | Active block outline styles |
---
### Task 1: Simplify BlockActions to delete + drag handle
**Files:**
- Modify: `modules/hudozka-writer/src/components/BlockActions.tsx`
- Rewrite: `modules/hudozka-writer/src/components/BlockActions.css`
- [x] **Step 1: Rewrite BlockActions.tsx**
Replace the entire file with a simplified component — no active state tracking, no menu, always visible, just delete and drag handle:
```tsx
import type { Editor } from "@tiptap/react"
import "./BlockActions.css"
export type BlockActionsProps = {
editor: Editor
getPos: () => number | undefined
}
export function BlockActions({ editor, getPos }: BlockActionsProps) {
const handleDelete = (e: React.MouseEvent) => {
e.preventDefault()
const pos = getPos()
if (typeof pos !== "number") return
const node = editor.state.doc.nodeAt(pos)
if (!node) return
editor.chain().focus().deleteRange({
from: pos,
to: pos + node.nodeSize,
}).run()
}
return (
<div className="block-actions" contentEditable={false}>
<button
className="block-action-btn block-action-delete"
title="Delete block"
onClick={handleDelete}
>
&times;
</button>
<div
className="block-action-btn block-drag-handle"
data-drag-handle
draggable
title="Drag to reorder"
>
&#x2807;
</div>
</div>
)
}
```
- [x] **Step 2: Rewrite BlockActions.css**
Replace the entire file. The sidebar is a vertical flex column, always visible, positioned left of the block content via the parent flex row:
```css
.block-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
flex-shrink: 0;
width: 28px;
padding-top: 2px;
user-select: none;
}
.block-action-btn {
width: 24px;
height: 24px;
border-radius: 4px;
border: 1px solid transparent;
background: none;
cursor: pointer;
font-size: 16px;
line-height: 1;
color: #ccc;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
padding: 0;
}
.block-action-btn:hover {
border-color: #ddd;
color: #666;
background: #f5f5f5;
}
.block-action-delete:hover {
border-color: #c00;
color: #c00;
background: #fff0f0;
}
.block-drag-handle {
cursor: grab;
}
.block-drag-handle:active {
cursor: grabbing;
}
```
- [x] **Step 3: Verify the editor loads without errors** (skipped - manual browser verification)
Run: open the writer in browser, check there are no console errors. Blocks should show delete and drag buttons to the left (layout will be finalized in Task 3).
- [x] **Step 4: Commit**
```bash
git add modules/hudozka-writer/src/components/BlockActions.tsx modules/hudozka-writer/src/components/BlockActions.css
git commit -m "refactor(writer): simplify BlockActions to always-visible delete + drag handle"
```
---
### Task 2: Create BlockInsert component
**Files:**
- Create: `modules/hudozka-writer/src/components/BlockInsert.tsx`
- Create: `modules/hudozka-writer/src/components/BlockInsert.css`
- [x] **Step 1: Create BlockInsert.tsx**
A [+] button that sits between blocks and opens a `BlockMenu` dropdown:
```tsx
import { useState } from "react"
import type { Editor } from "@tiptap/react"
import { BlockMenu } from "./BlockMenu"
import "./BlockInsert.css"
export type BlockInsertProps = {
editor: Editor
getPos: () => number | undefined
}
export function BlockInsert({ editor, getPos }: BlockInsertProps) {
const [menuOpen, setMenuOpen] = useState(false)
const insertPos = (() => {
const pos = getPos()
if (typeof pos !== "number") return 0
const node = editor.state.doc.nodeAt(pos)
return node ? pos + node.nodeSize : 0
})()
const handleToggle = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setMenuOpen(!menuOpen)
}
const handleClose = () => {
setMenuOpen(false)
}
return (
<div className="block-insert" contentEditable={false}>
<button
className="block-insert-btn"
title="Add block"
onClick={handleToggle}
>
+
</button>
{menuOpen && (
<div className="block-insert-menu">
<BlockMenu
editor={editor}
position={insertPos}
onClose={handleClose}
/>
</div>
)}
</div>
)
}
```
- [x] **Step 2: Create BlockInsert.css**
```css
.block-insert {
position: relative;
height: 0;
display: flex;
align-items: center;
justify-content: flex-start;
padding-left: 28px;
}
.block-insert-btn {
position: relative;
top: -1px;
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid transparent;
background: none;
cursor: pointer;
font-size: 18px;
line-height: 1;
color: #ccc;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
padding: 0;
z-index: 10;
}
.block-insert-btn:hover {
border-color: #ddd;
color: #666;
background: #f5f5f5;
}
.block-insert-menu {
position: absolute;
top: 12px;
left: 56px;
z-index: 50;
}
```
- [x] **Step 3: Commit**
```bash
git add modules/hudozka-writer/src/components/BlockInsert.tsx modules/hudozka-writer/src/components/BlockInsert.css
git commit -m "feat(writer): add BlockInsert component for inserting blocks between nodes"
```
---
### Task 3: Update all node views with new layout
**Files:**
- Modify: `modules/hudozka-writer/src/nodes/textBlocks.tsx`
- Modify: `modules/hudozka-writer/src/nodes/ImageBlock.tsx`
- Modify: `modules/hudozka-writer/src/nodes/DocumentBlock.tsx`
- Modify: `modules/hudozka-writer/src/nodes/EmbedBlock.tsx`
- Modify: `modules/hudozka-writer/src/nodes/CardGridBlock.tsx`
- Modify: `modules/hudozka-writer/src/components/Editor.css`
- Modify: `modules/hudozka-writer/src/components/BlockActions.css`
Every block view gets a consistent flex row layout:
```
<NodeViewWrapper className="node-view-block">
<BlockActions ... />
<div className="node-view-content">
...block body...
</div>
</NodeViewWrapper>
<BlockInsert ... />
```
- [x] **Step 1: Add shared layout styles to BlockActions.css**
Append these rules to the end of `BlockActions.css` (they use the `[data-node-view-wrapper]` selector which is already in this file):
```css
[data-node-view-wrapper] {
position: relative;
}
.node-view-block {
display: flex;
flex-direction: row;
align-items: stretch;
gap: 4px;
}
.node-view-content {
flex: 1;
min-width: 0;
}
```
Note: remove the existing `[data-node-view-wrapper] { position: relative; }` rule at the bottom of the file — the new one replaces it.
- [x] **Step 2: Add active block outline to Editor.css**
Append to `Editor.css`:
```css
.editor-content .tiptap [data-node-view-wrapper].active-block > .node-view-content {
outline: 2px solid #d0d5dd;
outline-offset: 2px;
border-radius: 4px;
}
```
- [x] **Step 3: Add active block tracking to BlockActions**
We need BlockActions to set a class on its parent `NodeViewWrapper` when the block is active. Update `BlockActions.tsx` to add/remove `active-block` class. Replace the file with:
```tsx
import { useEffect, useCallback } from "react"
import type { Editor } from "@tiptap/react"
import "./BlockActions.css"
export type BlockActionsProps = {
editor: Editor
getPos: () => number | undefined
}
export function BlockActions({ editor, getPos }: BlockActionsProps) {
const getWrapper = useCallback(() => {
const pos = getPos()
if (typeof pos !== "number") return null
const dom = editor.view.nodeDOM(pos)
if (!dom || !(dom instanceof HTMLElement)) return null
return dom.closest("[data-node-view-wrapper]") as HTMLElement | null
}, [editor, getPos])
useEffect(() => {
const checkActive = () => {
const wrapper = getWrapper()
if (!wrapper) return
const pos = getPos()
if (typeof pos !== "number") {
wrapper.classList.remove("active-block")
return
}
const { from } = editor.state.selection
const resolved = editor.state.doc.resolve(from)
if (resolved.depth < 1) {
wrapper.classList.remove("active-block")
return
}
const blockPos = resolved.before(1)
if (blockPos === pos) {
wrapper.classList.add("active-block")
} else {
wrapper.classList.remove("active-block")
}
}
const handleBlur = () => {
const wrapper = getWrapper()
wrapper?.classList.remove("active-block")
}
checkActive()
editor.on("selectionUpdate", checkActive)
editor.on("focus", checkActive)
editor.on("blur", handleBlur)
return () => {
editor.off("selectionUpdate", checkActive)
editor.off("focus", checkActive)
editor.off("blur", handleBlur)
const wrapper = getWrapper()
wrapper?.classList.remove("active-block")
}
}, [editor, getPos, getWrapper])
const handleDelete = (e: React.MouseEvent) => {
e.preventDefault()
const pos = getPos()
if (typeof pos !== "number") return
const node = editor.state.doc.nodeAt(pos)
if (!node) return
editor.chain().focus().deleteRange({
from: pos,
to: pos + node.nodeSize,
}).run()
}
return (
<div className="block-actions" contentEditable={false}>
<button
className="block-action-btn block-action-delete"
title="Delete block"
onClick={handleDelete}
>
&times;
</button>
<div
className="block-action-btn block-drag-handle"
data-drag-handle
draggable
title="Drag to reorder"
>
&#x2807;
</div>
</div>
)
}
```
- [x] **Step 4: Update textBlocks.tsx**
Replace the entire file. All views get the flex layout with `BlockInsert` after:
```tsx
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from "@tiptap/react"
import type { NodeViewProps } from "@tiptap/react"
import Paragraph from "@tiptap/extension-paragraph"
import Heading from "@tiptap/extension-heading"
import BulletList from "@tiptap/extension-bullet-list"
import OrderedList from "@tiptap/extension-ordered-list"
import Blockquote from "@tiptap/extension-blockquote"
import CodeBlock from "@tiptap/extension-code-block"
import HorizontalRule from "@tiptap/extension-horizontal-rule"
import { BlockActions } from "../components/BlockActions"
import { BlockInsert } from "../components/BlockInsert"
function ParagraphView({ editor, getPos }: NodeViewProps) {
return (
<>
<NodeViewWrapper className="node-view-block">
<BlockActions editor={editor} getPos={getPos} />
<NodeViewContent<"p"> className="node-view-content" as="p" />
</NodeViewWrapper>
<BlockInsert editor={editor} getPos={getPos} />
</>
)
}
function HeadingView({ editor, getPos, node }: NodeViewProps) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tag = `h${node.attrs.level}` as any
return (
<>
<NodeViewWrapper className="node-view-block">
<BlockActions editor={editor} getPos={getPos} />
<NodeViewContent className="node-view-content" as={tag} />
</NodeViewWrapper>
<BlockInsert editor={editor} getPos={getPos} />
</>
)
}
function BulletListView({ editor, getPos }: NodeViewProps) {
return (
<>
<NodeViewWrapper className="node-view-block">
<BlockActions editor={editor} getPos={getPos} />
<NodeViewContent<"ul"> className="node-view-content" as="ul" />
</NodeViewWrapper>
<BlockInsert editor={editor} getPos={getPos} />
</>
)
}
function OrderedListView({ editor, getPos }: NodeViewProps) {
return (
<>
<NodeViewWrapper className="node-view-block">
<BlockActions editor={editor} getPos={getPos} />
<NodeViewContent<"ol"> className="node-view-content" as="ol" />
</NodeViewWrapper>
<BlockInsert editor={editor} getPos={getPos} />
</>
)
}
function BlockquoteView({ editor, getPos }: NodeViewProps) {
return (
<>
<NodeViewWrapper className="node-view-block">
<BlockActions editor={editor} getPos={getPos} />
<NodeViewContent<"blockquote"> className="node-view-content" as="blockquote" />
</NodeViewWrapper>
<BlockInsert editor={editor} getPos={getPos} />
</>
)
}
function CodeBlockView({ editor, getPos }: NodeViewProps) {
return (
<>
<NodeViewWrapper className="node-view-block" as="pre">
<BlockActions editor={editor} getPos={getPos} />
<NodeViewContent<"code"> className="node-view-content" as="code" />
</NodeViewWrapper>
<BlockInsert editor={editor} getPos={getPos} />
</>
)
}
function HorizontalRuleView({ editor, getPos }: NodeViewProps) {
return (
<>
<NodeViewWrapper className="node-view-block">
<BlockActions editor={editor} getPos={getPos} />
<div className="node-view-content">
<hr />
</div>
</NodeViewWrapper>
<BlockInsert editor={editor} getPos={getPos} />
</>
)
}
export const DraggableParagraph = Paragraph.extend({
draggable: true,
addNodeView() {
return ReactNodeViewRenderer(ParagraphView)
},
})
export const DraggableHeading = Heading.extend({
draggable: true,
addNodeView() {
return ReactNodeViewRenderer(HeadingView)
},
})
export const DraggableBulletList = BulletList.extend({
draggable: true,
addNodeView() {
return ReactNodeViewRenderer(BulletListView)
},
})
export const DraggableOrderedList = OrderedList.extend({
draggable: true,
addNodeView() {
return ReactNodeViewRenderer(OrderedListView)
},
})
export const DraggableBlockquote = Blockquote.extend({
draggable: true,
addNodeView() {
return ReactNodeViewRenderer(BlockquoteView)
},
})
export const DraggableCodeBlock = CodeBlock.extend({
draggable: true,
addNodeView() {
return ReactNodeViewRenderer(CodeBlockView)
},
})
export const DraggableHorizontalRule = HorizontalRule.extend({
draggable: true,
addNodeView() {
return ReactNodeViewRenderer(HorizontalRuleView)
},
})
```
- [x] **Step 5: Update ImageBlock.tsx**
In `ImageBlockView`, wrap in flex layout, remove `data-drag-handle`, add `BlockInsert`:
```tsx
// Change the return in ImageBlockView to:
return (
<>
<NodeViewWrapper className="node-view-block node-image-block">
<BlockActions editor={editor} getPos={getPos} />
<div className="node-view-content">
<div className="node-block-label">Image</div>
{thumbnail ? (
<img
src={thumbnail}
alt={caption || "image"}
className="node-image-thumb"
onClick={() => setShowPicker(true)}
style={{ cursor: "pointer" }}
/>
) : (
<div
className="node-image-placeholder"
onClick={() => setShowPicker(true)}
style={{ cursor: "pointer" }}
>
{imageId ? "Loading..." : "Click to select image"}
</div>
)}
<div className="node-image-controls">
<label>
<input
type="checkbox"
checked={wide}
onChange={(e) => updateAttributes({ wide: e.target.checked })}
/>
Wide
</label>
<input
type="text"
placeholder="Caption"
value={caption || ""}
onChange={(e) => updateAttributes({ caption: e.target.value })}
className="node-image-caption"
/>
<button
className="node-image-pick-btn"
onClick={() => setShowPicker(true)}
>
Pick
</button>
</div>
{showPicker && (
<ImagePicker
onSelect={handleSelect}
onClose={() => setShowPicker(false)}
/>
)}
</div>
</NodeViewWrapper>
<BlockInsert editor={editor} getPos={getPos} />
</>
)
```
Add import at top: `import { BlockInsert } from "../components/BlockInsert"`
- [x] **Step 6: Update DocumentBlock.tsx**
Same pattern. Change the return in `DocumentBlockView`:
```tsx
return (
<>
<NodeViewWrapper className="node-view-block node-document-block">
<BlockActions editor={editor} getPos={getPos} />
<div className="node-view-content">
<div className="node-block-label">File</div>
{fileId ? (
<div
className="node-document-file"
onClick={() => setShowPicker(true)}
style={{ cursor: "pointer" }}
>
📎 {filename ?? fileId}
</div>
) : (
<div
className="node-document-placeholder"
onClick={() => setShowPicker(true)}
style={{ cursor: "pointer" }}
>
Click to select file
</div>
)}
<div className="node-document-controls">
<input
type="text"
placeholder="Title"
value={title || ""}
onChange={(e) => updateAttributes({ title: e.target.value })}
className="node-document-title"
/>
<button
className="node-image-pick-btn"
onClick={() => setShowPicker(true)}
>
Pick
</button>
</div>
{showPicker && (
<FilePicker
onSelect={handleSelect}
onClose={() => setShowPicker(false)}
/>
)}
</div>
</NodeViewWrapper>
<BlockInsert editor={editor} getPos={getPos} />
</>
)
```
Add import at top: `import { BlockInsert } from "../components/BlockInsert"`
- [x] **Step 7: Update EmbedBlock.tsx**
```tsx
function EmbedBlockView({ node, editor, getPos }: NodeViewProps) {
return (
<>
<NodeViewWrapper className="node-view-block node-readonly-block">
<BlockActions editor={editor} getPos={getPos} />
<div className="node-view-content">
<div className="node-block-label">Embed</div>
<div className="node-block-info">
<code>{node.attrs.src}</code>
</div>
</div>
</NodeViewWrapper>
<BlockInsert editor={editor} getPos={getPos} />
</>
)
}
```
Add import: `import { BlockInsert } from "../components/BlockInsert"`
- [x] **Step 8: Update CardGridBlock.tsx**
```tsx
return (
<>
<NodeViewWrapper className="node-view-block node-readonly-block">
<BlockActions editor={editor} getPos={getPos} />
<div className="node-view-content">
<div className="node-block-label">
Card Grid ({items.length})
</div>
{loading ? (
<div className="node-block-info">Loading...</div>
) : (
<ul className="node-cardgrid-list">
{resolved.map((item) => (
<li key={item.page} className="node-cardgrid-item">
{item.title ?? item.page}
</li>
))}
</ul>
)}
</div>
</NodeViewWrapper>
<BlockInsert editor={editor} getPos={getPos} />
</>
)
```
Add import: `import { BlockInsert } from "../components/BlockInsert"`
- [x] **Step 9: Update nodes.css — remove position: relative from block cards**
The card blocks (`.node-image-block`, `.node-readonly-block`, `.node-document-block`) previously had `position: relative` for absolute-positioned `BlockActions`. That's no longer needed since BlockActions uses flex layout. Remove `position: relative` from these selectors in `nodes.css`. The `margin: 8px 0` should also be removed since spacing is handled by the tiptap `> * + *` rule.
In `nodes.css`, change:
```css
.node-image-block,
.node-readonly-block {
position: relative;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 12px;
margin: 8px 0;
background: #fafafa;
}
```
to:
```css
.node-image-block,
.node-readonly-block {
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 12px;
background: #fafafa;
}
```
And change:
```css
.node-document-block {
position: relative;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 12px;
margin: 8px 0;
background: #fafafa;
}
```
to:
```css
.node-document-block {
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 12px;
background: #fafafa;
}
```
Note: these classes are now on `NodeViewWrapper` alongside `node-view-block`, so the border/padding/background applies to the entire flex row. Move these visual styles to `.node-view-content` inside those blocks instead. Actually — since these classes are on the `NodeViewWrapper`, the border will wrap the sidebar too. Better approach: move the visual card styles to a nested selector. Change the above to:
```css
.node-image-block > .node-view-content,
.node-readonly-block > .node-view-content,
.node-document-block > .node-view-content {
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 12px;
background: #fafafa;
}
```
Remove the old `.node-image-block, .node-readonly-block` and `.node-document-block` rules entirely.
- [x] **Step 10: Verify in browser** (skipped - manual browser verification)
Open the writer, check:
1. Every block has delete + drag handle on the left
2. [+] button visible between all blocks
3. Active block has outline
4. Drag handle works for all block types
5. Delete button removes the block
6. [+] opens menu and inserts a new block
- [x] **Step 11: Commit**
```bash
git add modules/hudozka-writer/src/nodes/textBlocks.tsx modules/hudozka-writer/src/nodes/ImageBlock.tsx modules/hudozka-writer/src/nodes/DocumentBlock.tsx modules/hudozka-writer/src/nodes/EmbedBlock.tsx modules/hudozka-writer/src/nodes/CardGridBlock.tsx modules/hudozka-writer/src/nodes/nodes.css modules/hudozka-writer/src/components/BlockActions.tsx modules/hudozka-writer/src/components/BlockActions.css modules/hudozka-writer/src/components/Editor.css
git commit -m "feat(writer): redesign block sidebar with always-visible actions and insert button"
```
@@ -0,0 +1,695 @@
# Writer Block Actions — Inline NodeView Implementation Plan
> **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:** Move the block menu (drag handle, add, delete) from a floating external toolbar into each block's NodeView, shown when the block is focused. This fixes broken text block DnD and gives a consistent UX across all block types.
**Architecture:** A shared `BlockActions` React component is rendered inside every block's NodeView. Text blocks (paragraph, heading, lists, blockquote, codeBlock, horizontalRule) get thin custom NodeViews wrapping `NodeViewContent` + `BlockActions`. Existing custom blocks (image, document, embed, cardgrid) add `BlockActions` into their existing NodeViews. The drag handle uses Tiptap's native `data-drag-handle` attribute. The floating `BlockInsert` extension is deleted. Each `BlockActions` instance subscribes to editor selection events and only shows when its block is the active top-level block.
**Tech Stack:** Tiptap 2 (React), ProseMirror, React 18, CSS
---
## File Structure
**New files:**
- `src/components/BlockActions.tsx` — shared block actions menu component
- `src/components/BlockActions.css` — styling for block actions
- `src/nodes/textBlocks.tsx` — all text block NodeViews (Paragraph, Heading, BulletList, OrderedList, Blockquote, CodeBlock, HorizontalRule)
**Modified files:**
- `src/nodes/ImageBlock.tsx` — add BlockActions, remove `data-drag-handle` from wrapper
- `src/nodes/DocumentBlock.tsx` — same
- `src/nodes/EmbedBlock.tsx` — same
- `src/nodes/CardGridBlock.tsx` — same
- `src/nodes/nodes.css` — remove `cursor: grab` from custom block cards
- `src/nodes/index.ts` — export new text block nodes
- `src/components/Editor.tsx` — replace StarterKit nodes with draggable versions, remove BlockInsert
**Deleted files:**
- `src/extensions/BlockInsert.tsx`
- `src/extensions/BlockInsert.css`
---
## CSS Impact Note
`Editor.css` uses descendant selectors like `.editor-content .tiptap h1`, `.editor-content .tiptap blockquote`, etc. Since NodeViews wrap content in a `<div>` + semantic tag via `NodeViewContent as="h1"`, these selectors still match. The spacing rule `.editor-content .tiptap > * + *` targets direct children of `.tiptap` — the NodeView wrapper `<div>`s become those direct children, so spacing is preserved.
---
### Task 1: Create BlockActions component
**Files:**
- Create: `src/components/BlockActions.tsx`
- Create: `src/components/BlockActions.css`
- [x] **Step 1: Create `src/components/BlockActions.css`**
```css
.block-actions {
position: absolute;
left: -88px;
top: 0;
z-index: 20;
display: flex;
gap: 2px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
.block-actions.block-actions-visible {
opacity: 1;
pointer-events: auto;
}
.block-action-btn {
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid #ddd;
background: white;
cursor: pointer;
font-size: 18px;
line-height: 1;
color: #888;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
padding: 0;
}
.block-action-btn:hover {
border-color: #213547;
color: #213547;
background: #f0f4ff;
}
.block-drag-handle {
cursor: grab;
letter-spacing: 1px;
}
.block-drag-handle:active {
cursor: grabbing;
}
.block-action-delete:hover {
border-color: #c00;
color: #c00;
background: #fff0f0;
}
.block-actions-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
}
```
- [x] **Step 2: Create `src/components/BlockActions.tsx`**
```tsx
import { useState, useEffect, useCallback } from "react"
import type { Editor } from "@tiptap/react"
import { BlockMenu } from "./BlockMenu"
import "./BlockActions.css"
export type BlockActionsProps = {
editor: Editor
getPos: () => number | undefined
}
export function BlockActions({ editor, getPos }: BlockActionsProps) {
const [isActive, setIsActive] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
// Check if this block is top-level (parent is doc, depth 0)
const isTopLevel = useCallback(() => {
const pos = getPos()
if (typeof pos !== "number") return false
const resolved = editor.state.doc.resolve(pos)
return resolved.depth === 0
}, [editor, getPos])
useEffect(() => {
if (!isTopLevel()) return
const checkActive = () => {
const pos = getPos()
if (typeof pos !== "number") {
setIsActive(false)
return
}
const { from } = editor.state.selection
const resolved = editor.state.doc.resolve(from)
if (resolved.depth < 1) {
setIsActive(false)
return
}
const blockPos = resolved.before(1)
setIsActive(blockPos === pos)
}
const handleBlur = () => setIsActive(false)
checkActive()
editor.on("selectionUpdate", checkActive)
editor.on("focus", checkActive)
editor.on("blur", handleBlur)
return () => {
editor.off("selectionUpdate", checkActive)
editor.off("focus", checkActive)
editor.off("blur", handleBlur)
}
}, [editor, getPos, isTopLevel])
if (!isTopLevel()) return null
const visible = isActive || menuOpen
const handleDelete = () => {
const pos = getPos()
if (typeof pos !== "number") return
const node = editor.state.doc.nodeAt(pos)
if (!node) return
editor.chain().focus().deleteRange({
from: pos,
to: pos + node.nodeSize,
}).run()
}
const handleToggleMenu = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setMenuOpen(!menuOpen)
}
const handleCloseMenu = () => {
setMenuOpen(false)
}
const insertPos = (() => {
const pos = getPos()
if (typeof pos !== "number") return 0
const node = editor.state.doc.nodeAt(pos)
return node ? pos + node.nodeSize : 0
})()
return (
<div
className={`block-actions ${visible ? "block-actions-visible" : ""}`}
contentEditable={false}
>
<div
className="block-action-btn block-drag-handle"
data-drag-handle
title="Drag to reorder"
>
&#x2807;
</div>
<button
className="block-action-btn"
title="Add block"
onClick={handleToggleMenu}
>
+
</button>
<button
className="block-action-btn block-action-delete"
title="Delete block"
onClick={handleDelete}
>
&times;
</button>
{menuOpen && (
<div className="block-actions-menu">
<BlockMenu
editor={editor}
position={insertPos}
onClose={handleCloseMenu}
/>
</div>
)}
</div>
)
}
```
- [x] **Step 3: Commit**
```bash
git add src/components/BlockActions.tsx src/components/BlockActions.css
git commit -m "feat(writer): add BlockActions component for inline block menus"
```
---
### Task 2: Create text block NodeViews
**Files:**
- Create: `src/nodes/textBlocks.tsx`
All text block types get thin NodeViews: a wrapper with `BlockActions` + `NodeViewContent` using the appropriate semantic HTML tag. Each extends its StarterKit node with `draggable: true`.
- [x] **Step 1: Create `src/nodes/textBlocks.tsx`**
```tsx
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from "@tiptap/react"
import type { NodeViewProps } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import { BlockActions } from "../components/BlockActions"
// Extract StarterKit node types for extension
const { Paragraph, Heading, BulletList, OrderedList, Blockquote, CodeBlock, HorizontalRule } =
StarterKit.extensions.reduce(
(acc, ext) => {
if (["paragraph", "heading", "bulletList", "orderedList", "blockquote", "codeBlock", "horizontalRule"].includes(ext.name)) {
const key = ext.name.charAt(0).toUpperCase() + ext.name.slice(1)
acc[key] = ext
}
return acc
},
{} as Record<string, (typeof StarterKit.extensions)[number]>,
)
function ParagraphView({ editor, getPos }: NodeViewProps) {
return (
<NodeViewWrapper>
<BlockActions editor={editor} getPos={getPos} />
<NodeViewContent as="p" />
</NodeViewWrapper>
)
}
function HeadingView({ editor, getPos, node }: NodeViewProps) {
const tag = `h${node.attrs.level}` as keyof HTMLElementTagNameMap
return (
<NodeViewWrapper>
<BlockActions editor={editor} getPos={getPos} />
<NodeViewContent as={tag} />
</NodeViewWrapper>
)
}
function BulletListView({ editor, getPos }: NodeViewProps) {
return (
<NodeViewWrapper>
<BlockActions editor={editor} getPos={getPos} />
<NodeViewContent as="ul" />
</NodeViewWrapper>
)
}
function OrderedListView({ editor, getPos }: NodeViewProps) {
return (
<NodeViewWrapper>
<BlockActions editor={editor} getPos={getPos} />
<NodeViewContent as="ol" />
</NodeViewWrapper>
)
}
function BlockquoteView({ editor, getPos }: NodeViewProps) {
return (
<NodeViewWrapper>
<BlockActions editor={editor} getPos={getPos} />
<NodeViewContent as="blockquote" />
</NodeViewWrapper>
)
}
function CodeBlockView({ editor, getPos }: NodeViewProps) {
return (
<NodeViewWrapper as="pre">
<BlockActions editor={editor} getPos={getPos} />
<NodeViewContent as="code" />
</NodeViewWrapper>
)
}
function HorizontalRuleView({ editor, getPos }: NodeViewProps) {
return (
<NodeViewWrapper>
<BlockActions editor={editor} getPos={getPos} />
<hr />
</NodeViewWrapper>
)
}
export const DraggableParagraph = Paragraph.extend({
draggable: true,
addNodeView() {
return ReactNodeViewRenderer(ParagraphView)
},
})
export const DraggableHeading = Heading.extend({
draggable: true,
addNodeView() {
return ReactNodeViewRenderer(HeadingView)
},
})
export const DraggableBulletList = BulletList.extend({
draggable: true,
addNodeView() {
return ReactNodeViewRenderer(BulletListView)
},
})
export const DraggableOrderedList = OrderedList.extend({
draggable: true,
addNodeView() {
return ReactNodeViewRenderer(OrderedListView)
},
})
export const DraggableBlockquote = Blockquote.extend({
draggable: true,
addNodeView() {
return ReactNodeViewRenderer(BlockquoteView)
},
})
export const DraggableCodeBlock = CodeBlock.extend({
draggable: true,
addNodeView() {
return ReactNodeViewRenderer(CodeBlockView)
},
})
export const DraggableHorizontalRule = HorizontalRule.extend({
draggable: true,
addNodeView() {
return ReactNodeViewRenderer(HorizontalRuleView)
},
})
```
- [x] **Step 2: Export from `src/nodes/index.ts`**
Add to the existing exports:
```ts
export {
DraggableParagraph,
DraggableHeading,
DraggableBulletList,
DraggableOrderedList,
DraggableBlockquote,
DraggableCodeBlock,
DraggableHorizontalRule,
} from "./textBlocks"
```
- [x] **Step 3: Commit**
```bash
git add src/nodes/textBlocks.tsx src/nodes/index.ts
git commit -m "feat(writer): add draggable NodeViews for text blocks"
```
---
### Task 3: Update existing custom blocks to use BlockActions
**Files:**
- Modify: `src/nodes/ImageBlock.tsx:9-80` (NodeView component)
- Modify: `src/nodes/DocumentBlock.tsx:9-71` (NodeView component)
- Modify: `src/nodes/EmbedBlock.tsx:5-13` (NodeView component)
- Modify: `src/nodes/CardGridBlock.tsx:13-58` (NodeView component)
- Modify: `src/nodes/nodes.css:9-17,71-78` (remove `cursor: grab`)
For each custom block:
1. Import `BlockActions`
2. Remove `data-drag-handle` from `NodeViewWrapper`
3. Add `<BlockActions editor={editor} getPos={getPos} />` as first child inside NodeViewWrapper
4. Add `editor` and `getPos` to the destructured `NodeViewProps`
- [x] **Step 1: Update `ImageBlock.tsx`**
Change the view function signature and NodeViewWrapper:
```tsx
function ImageBlockView({ node, updateAttributes, editor, getPos }: NodeViewProps) {
```
Replace the NodeViewWrapper opening tag — remove `data-drag-handle`:
```tsx
<NodeViewWrapper className="node-image-block">
<BlockActions editor={editor} getPos={getPos} />
<div className="node-block-label">Image</div>
```
Add the import at the top:
```tsx
import { BlockActions } from "../components/BlockActions"
```
- [x] **Step 2: Update `DocumentBlock.tsx`**
Same pattern. Change view function signature:
```tsx
function DocumentBlockView({ node, updateAttributes, editor, getPos }: NodeViewProps) {
```
Replace NodeViewWrapper — remove `data-drag-handle`:
```tsx
<NodeViewWrapper className="node-document-block">
<BlockActions editor={editor} getPos={getPos} />
<div className="node-block-label">File</div>
```
Add import:
```tsx
import { BlockActions } from "../components/BlockActions"
```
- [x] **Step 3: Update `EmbedBlock.tsx`**
Change view function signature:
```tsx
function EmbedBlockView({ node, editor, getPos }: NodeViewProps) {
```
Replace NodeViewWrapper — remove `data-drag-handle`:
```tsx
<NodeViewWrapper className="node-readonly-block">
<BlockActions editor={editor} getPos={getPos} />
<div className="node-block-label">Embed</div>
```
Add import:
```tsx
import { BlockActions } from "../components/BlockActions"
```
- [x] **Step 4: Update `CardGridBlock.tsx`**
Change view function signature:
```tsx
function CardGridBlockView({ node, editor, getPos }: NodeViewProps) {
```
Replace NodeViewWrapper — remove `data-drag-handle`:
```tsx
<NodeViewWrapper className="node-readonly-block">
<BlockActions editor={editor} getPos={getPos} />
<div className="node-block-label">Card Grid ({items.length} items)</div>
```
Add import:
```tsx
import { BlockActions } from "../components/BlockActions"
```
- [x] **Step 5: Remove `cursor: grab` from custom block CSS in `nodes.css`**
The drag handle is now in BlockActions. Remove `cursor: grab` from these rules:
In `.node-image-block, .node-readonly-block` (line 9-17): remove `cursor: grab;`
In `.node-document-block` (line 71-78): remove `cursor: grab;`
- [x] **Step 6: Commit**
```bash
git add src/nodes/ImageBlock.tsx src/nodes/DocumentBlock.tsx src/nodes/EmbedBlock.tsx src/nodes/CardGridBlock.tsx src/nodes/nodes.css
git commit -m "feat(writer): use BlockActions in custom block NodeViews"
```
---
### Task 4: Wire up editor, remove BlockInsert
**Files:**
- Modify: `src/components/Editor.tsx:1-64`
- Delete: `src/extensions/BlockInsert.tsx`
- Delete: `src/extensions/BlockInsert.css`
- [x] **Step 1: Update `Editor.tsx` imports and extensions**
Replace the imports:
```tsx
import {
WriterDocument,
ImageBlock,
DocumentBlock,
EmbedBlock,
CardGridBlock,
DraggableParagraph,
DraggableHeading,
DraggableBulletList,
DraggableOrderedList,
DraggableBlockquote,
DraggableCodeBlock,
DraggableHorizontalRule,
} from "../nodes"
```
Remove the BlockInsert import:
```tsx
// DELETE this line:
import { BlockInsert } from "../extensions/BlockInsert"
```
Update the extensions array — disable StarterKit nodes that have draggable replacements, add the replacements, remove BlockInsert:
```tsx
const editor = useEditor({
extensions: [
WriterDocument,
StarterKit.configure({
document: false,
paragraph: false,
heading: false,
bulletList: false,
orderedList: false,
blockquote: false,
codeBlock: false,
horizontalRule: false,
}),
DraggableParagraph,
DraggableHeading,
DraggableBulletList,
DraggableOrderedList,
DraggableBlockquote,
DraggableCodeBlock,
DraggableHorizontalRule,
Markdown,
Link.configure({ openOnClick: false }),
ImageBlock,
DocumentBlock,
EmbedBlock,
CardGridBlock,
SlashCommands,
],
content: tiptapDoc,
onUpdate: ({ editor }) => {
const json = editor.getJSON()
const doc = tiptapToDoc(json.content ?? [])
const current = serializeDoc(doc)
setDirty(current !== snapshotRef.current)
},
})
```
- [x] **Step 2: Delete BlockInsert extension files**
```bash
rm src/extensions/BlockInsert.tsx src/extensions/BlockInsert.css
```
- [x] **Step 3: Commit**
```bash
git add src/components/Editor.tsx src/nodes/index.ts
git add -u src/extensions/BlockInsert.tsx src/extensions/BlockInsert.css
git commit -m "feat(writer): replace floating BlockInsert with inline BlockActions"
```
---
### Task 5: Add `position: relative` to NodeView wrappers
**Files:**
- Modify: `src/nodes/nodes.css`
- Create or modify: `src/components/BlockActions.css` (already created in Task 1)
BlockActions uses `position: absolute` to sit left of the block. Every block's outermost wrapper needs `position: relative`. For custom blocks, they already have styled classes. For text blocks, the `NodeViewWrapper` gets Tiptap's default `data-node-view-wrapper` attribute.
- [x] **Step 1: Add position: relative to custom block classes in `nodes.css`**
Add `position: relative;` to:
- `.node-image-block, .node-readonly-block`
- `.node-document-block`
- [x] **Step 2: Add a global rule for all NodeView wrappers in `BlockActions.css`**
Add to `BlockActions.css`:
```css
[data-node-view-wrapper] {
position: relative;
}
```
This targets all Tiptap NodeView wrappers, ensuring BlockActions can position itself absolutely in any block type.
- [x] **Step 3: Commit**
```bash
git add src/nodes/nodes.css src/components/BlockActions.css
git commit -m "fix(writer): ensure all block wrappers have position: relative for BlockActions"
```
---
### Task 6: Manual verification
- [x] **Step 1: Start the dev server** (skipped - manual verification)
```bash
cd modules/hudozka-writer && npm run dev
```
- [x] **Step 2: Verify block actions visibility** (skipped - manual verification)
Open the writer in the browser. Click into a paragraph — block actions (drag handle ⠇, +, ×) should appear to the left. Click into a heading — actions move to the heading, disappear from paragraph. Click outside the editor — all actions disappear.
- [x] **Step 3: Verify drag-and-drop for text blocks** (skipped - manual verification)
Create several paragraphs. Grab the ⠇ handle on one and drag it between others. It should move smoothly — no focus loss, no failed drags.
- [x] **Step 4: Verify drag-and-drop for custom blocks** (skipped - manual verification)
Insert an image block. Drag it via the ⠇ handle. It should move just like text blocks. Repeat for file, embed, and card grid blocks.
- [x] **Step 5: Verify add/delete buttons** (skipped - manual verification)
Click + on a block — BlockMenu should appear with block type options. Insert a new block — it should appear after the current block. Click × — the current block should be deleted.
- [x] **Step 6: Verify nested blocks don't show actions** (skipped - manual verification)
Create a bullet list with several items. Only the list itself should show block actions, not individual list items. Create a blockquote — only the blockquote should show actions, not paragraphs inside it.
- [x] **Step 7: Verify existing CSS still applies** (skipped - manual verification)
Check that headings have correct font sizes, blockquotes have left border, code blocks have gray background, lists have proper indentation. The NodeView wrappers should not break any existing styles.
@@ -0,0 +1,67 @@
# TextBlock Container Redesign
## Problem
The writer editor has a flat document schema where every paragraph, heading, list, etc. is a top-level block with its own sidebar actions (delete, drag) and insert button. This causes two issues:
1. List items inside bullet/ordered lists each get their own sidebar actions (because paragraphs inside list items use custom DraggableXxx node views)
2. Conceptually related text content (several paragraphs in sequence) appears as many independent blocks instead of one cohesive unit
## Solution
Introduce a `textBlock` container node that groups text-level content. Each `textBlock` gets one set of sidebar actions. Media blocks (image, document, embed, card-grid) remain standalone top-level blocks as they are today.
## Document Schema
Current:
```
doc → (paragraph | heading | bulletList | ... | imageBlock | documentBlock | ...)+
```
New:
```
doc → (textBlock | imageBlock | documentBlock | embedBlock | cardGridBlock)+
textBlock → (paragraph | heading | bulletList | orderedList | blockquote | codeBlock | horizontalRule)+
```
## TextBlock Node
- Tiptap `Node.create()` with `name: "textBlock"`, `group: "block"`, `draggable: true`
- Content spec allows standard text-level nodes inside it
- Node view renders the same pattern as other blocks:
- `BlockActions` (delete, split, drag handle) inside `NodeViewWrapper`
- `NodeViewContent` for the editable area
- `BlockInsert` after the wrapper
## BlockActions Changes
`BlockActions` gets a new `showSplit` prop, only passed by `textBlock`. The sidebar shows three buttons for text blocks:
1. Delete (×)
2. Split (✂) — splits the text block at cursor position
3. Drag handle (⠇)
Custom blocks (image, document, etc.) keep delete + drag only.
## Split Operation
When the user clicks Split:
1. Find cursor position within the `textBlock`
2. Determine which child node the cursor is in
3. Split at the boundary between child nodes — the block divides into two `textBlock` nodes
4. If either half would be empty, don't create it
5. Split is disabled when: cursor at the very start/end, or the text block contains only one child node
Split happens at child-node boundaries, not mid-paragraph.
## Serialization
The `DocV1` format does not change. The serialization/deserialization logic in `serialize.ts` is updated to work with `textBlock` container nodes instead of the current buffer-accumulation pattern.
## What Gets Removed
- All `DraggableXxx` wrappers: `DraggableParagraph`, `DraggableHeading`, `DraggableBulletList`, `DraggableOrderedList`, `DraggableBlockquote`, `DraggableCodeBlock`, `DraggableHorizontalRule`
- Standard StarterKit extensions used instead (no longer disabled in `StarterKit.configure()`)
- Text-level nodes lose their individual `BlockActions` and `BlockInsert`
- The `textBlocks.tsx` file is replaced with the new `TextBlock` node definition
@@ -0,0 +1,85 @@
# Writer Draft/Publish Feature
## Overview
Add draft-save and explicit publish workflow to hudozka-writer so editors can save work-in-progress without pushing changes live.
## Data Layer
PocketBase `pages` collection fields (already applied by user):
| Field | Type | Purpose |
|--------------|---------|--------------------------------------------------------|
| `doc` | JSON | Published document content (read by the main site) |
| `draft` | JSON | Unpublished work-in-progress, `null` when no draft |
| `published` | boolean | Page visibility (renamed from `draft`, inverted logic) |
## Editor Load Behavior
When opening a page:
- If `draft` is not null: load `draft` into the editor, show a banner "You have unpublished changes" with a **Discard** button.
- If `draft` is null: load `doc` into the editor, no banner.
## Save / Publish / Discard
| Action | API call | Effect |
|-------------|----------------------------------------------------|-------------------------------------------|
| **Save** | `update(id, { draft: editorContent })` | Persists WIP without affecting live site |
| **Publish** | `update(id, { doc: editorContent, draft: null })` | Pushes content live, clears draft |
| **Discard** | `update(id, { draft: null })` | Drops WIP, reloads editor from `doc` |
No confirmation dialog on Discard.
## Toolbar Changes
Current layout: `[formatting...] [MD] [Save]`
New layout: `[formatting...] [MD] [Save] [Publish]`
- **Save** button: enabled only when editor content differs from the last-saved state. Writes to `draft`.
- **Publish** button: enabled when there are any unpublished changes (unsaved edits or existing `draft`). Writes to `doc`, clears `draft`.
After Save: Save button becomes disabled (no new changes), Publish remains enabled (draft exists).
After Publish: both buttons become disabled (content is live, no draft).
## Draft Banner
Shown above the editor when `draft` was loaded. Simple bar with text and a Discard button:
```
[You have unpublished changes] [Discard]
```
Clicking Discard: sends API call to clear `draft`, reloads editor content from `doc`, hides the banner.
## Page Visibility Toggle
A checkbox in the app header (near page title/slug): `[ ] Published`
- Checked = page visible on site (`published: true`)
- Unchecked = page hidden (`published: false`)
- Toggling sends `update(id, { published: !current })` immediately
- Independent of save/publish content flow
## Dirty Tracking
Track whether editor content has changed since last load/save:
- On load: snapshot the initial content (serialized `DocV1`)
- On every editor change: compare current serialized content with snapshot
- After Save: update snapshot to current content
- After Publish: update snapshot to current content
- After Discard: update snapshot to `doc` content
This drives the enabled/disabled state of Save and Publish buttons.
## Type Changes
Update `PbPage` in `modules/hudozka-writer/src/types.ts`:
- Replace `draft: boolean` with `published: boolean` and `draft: DocV1 | null`
## Files to Modify
- `modules/hudozka-writer/src/types.ts` — update `PbPage` type
- `modules/hudozka-writer/src/components/Editor.tsx` — draft loading, save/publish/discard handlers, dirty tracking
- `modules/hudozka-writer/src/components/Toolbar.tsx` — add Publish button, accept dirty/hasDraft props
- `modules/hudozka-writer/src/App.tsx` — add published toggle in header, update `handleDuplicate` for new field names
@@ -0,0 +1,181 @@
# Writer Editor Improvements
Improvements to `modules/hudozka-writer/` — the Tiptap-based page editor for the hudozka CMS.
## 1. Fix Markdown Serialization + Global Toggle
### Problem
The `@tiptap/markdown` extension is registered but not used in the serialization layer (`src/lib/serialize.ts`). Currently:
- `docToTiptap()` splits text on `\n\n` and wraps each chunk in a plain paragraph node — no markdown parsing
- `tiptapToDoc()` extracts raw text content, discarding all inline marks (bold, italic, links) and block structure (headings, lists)
Result: formatting applied via toolbar is lost on save.
### Solution
Use `@tiptap/markdown` for both directions:
**Loading (DocV1 → Tiptap):** For each `text` block, call `editor.commands.setContent(block.text)` or use the Markdown extension's parser to convert the markdown string into Tiptap JSON nodes. This replaces the naive paragraph splitting in `textBlockToTiptap()`.
**Saving (Tiptap → DocV1):** When flushing the text buffer in `tiptapToDoc()`, use the Markdown extension's serializer (`editor.storage.markdown.getMarkdown()`) to convert Tiptap JSON back to a markdown string. This replaces the lossy `tiptapTextContent()` that strips all formatting.
Implementation approach: create a headless Tiptap editor instance (no DOM) used solely for markdown↔JSON conversion in `serialize.ts`. This keeps serialization pure and avoids coupling to the live editor instance.
### Global Markdown Toggle
Add a toolbar button that switches the entire editor between two modes:
- **Rendered mode** (default): Normal WYSIWYG editing via Tiptap
- **Markdown mode**: Replace the Tiptap editor surface with a plain `<textarea>` showing the serialized markdown of the full document
Toggle flow:
1. User clicks "MD" button in toolbar
2. Serialize current Tiptap content to markdown string (using the markdown serializer)
3. Hide `<EditorContent>`, show `<textarea>` with the markdown string
4. Custom blocks (image, document, embed, card-grid) are represented as fenced markers in the markdown view, e.g. `<!-- image:abc123 -->` — these are read-only anchors that preserve block positions
5. When toggling back: parse the textarea content, reconstruct Tiptap JSON, call `editor.commands.setContent()`
State: a single `markdownMode: boolean` in the `Editor` component.
## 2. Block Menu (Insert + Slash Commands)
### Hover "+" Button
A `+` button appears between blocks on hover. Clicking opens a dropdown menu with available block types:
- Text (inserts empty paragraph)
- Image (inserts imageBlock with empty attrs)
- File (inserts documentBlock with empty attrs)
- Embed (inserts embedBlock with empty attrs)
- Card Grid (inserts cardGridBlock with empty items)
Implementation: a Tiptap extension that uses a plugin to track cursor/hover position and renders a floating React element via a plugin view. The button appears in the left gutter between block-level nodes.
The dropdown is a simple list of block types. Clicking one inserts the corresponding node at the gap position using `editor.chain().focus().insertContentAt(pos, node).run()`.
### Slash Commands
Type `/` at the start of an empty paragraph to open the same block type picker as an inline dropdown. Uses Tiptap's `@tiptap/suggestion` pattern:
- Trigger: `/` character at start of empty paragraph
- Shows filtered list of block types (filters as user types, e.g. `/im` shows "Image")
- Enter or click to insert the selected block type, replacing the empty paragraph
- Escape to dismiss
Both the "+" menu and slash command share the same `BlockTypeMenu` React component — only the trigger and positioning differ.
### Block Types Available for Insertion
| Type | Tiptap node | Default attrs |
|-----------|-----------------|-------------------------------------------|
| Text | paragraph | empty paragraph |
| Image | imageBlock | `{ imageId: "", wide: false, caption: "" }`|
| File | documentBlock | `{ fileId: "", title: "" }` |
| Embed | embedBlock | `{ src: "" }` |
| Card Grid | cardGridBlock | `{ items: "[]" }` |
### Drag Reorder for All Blocks
All custom blocks already have `draggable: true`. No change needed for custom blocks.
For text blocks (paragraphs, headings, lists, etc.): Tiptap's default behavior allows cursor-based text editing, not dragging. Adding drag handles to standard text nodes would conflict with text selection. Leave text blocks non-draggable — users reorder text by cut/paste or by using the block menu to insert at the right position.
Custom blocks (image, document, embed, card-grid) remain draggable via their existing `data-drag-handle` attribute.
## 3. File Block Editor
Transform `DocumentBlock` from read-only display into a full editor, following the same pattern as `ImageBlock`.
### UI Layout
```
┌──────────────────────────────────────┐
│ Document │
│ ┌──────────────────────────────────┐ │
│ │ 📎 filename.pdf │ │
│ │ (or "Click to select file") │ │
│ └──────────────────────────────────┘ │
│ Title: [___________________________] │
│ [Pick] btn │
└──────────────────────────────────────┘
```
### FilePicker Component
New component `src/components/FilePicker.tsx`, modeled after `ImagePicker`:
- **Browse tab**: paginated list of files from a PocketBase `files` collection (or whichever collection stores documents). Each row shows filename + file size. Click to select.
- **Upload tab**: file input (no `accept` restriction — any file type) + upload button. Creates a record in the files collection, returns the new record ID.
Props: `{ onSelect: (fileId: string) => void, onClose: () => void }`
### DocumentBlock Changes
- Add local state: `showPicker: boolean`, `filename: string | null` (resolved from PB)
- On mount (when `fileId` is set): fetch file record from PB to get `filename` for display
- "Pick" button opens `FilePicker` modal
- Title field: inline `<input>` that calls `updateAttributes({ title: value })`
- Selecting a new file via picker calls `updateAttributes({ fileId: newId })`
### PocketBase Collection
Assumes a `files` collection exists with at least: `id`, `file` (file field), `filename` (string). If not, this needs to be created in PocketBase schema. The exact collection name and schema should be verified before implementation.
## 4. Card Grid Display Enhancement
`CardGridBlock` stays read-only. Enhance the view to show resolved page titles instead of just item count.
### Current Display
```
Card Grid
3 items
```
### New Display
```
Card Grid (3)
• Акварельная живопись
• Графический дизайн
• Скульптура и керамика
```
### Implementation
In `CardGridBlockView`:
1. Parse `items` from attrs (already done)
2. For each item, fetch `pb.collection("pages").getOne(item.page)` to get the page title
3. Show a bulleted list of page titles
4. Handle loading state (show "Loading..." while fetching)
5. Handle missing pages (show page ID if fetch fails)
Use `useEffect` + `useState` to batch-fetch all page titles on mount or when `items` attr changes. Cache results to avoid redundant fetches during re-renders.
## Files to Create
| File | Purpose |
|---------------------------------------|------------------------------------|
| `src/components/FilePicker.tsx` | File browse/upload modal |
| `src/components/FilePicker.css` | FilePicker styles |
| `src/components/BlockMenu.tsx` | Shared block type dropdown |
| `src/components/BlockMenu.css` | BlockMenu styles |
| `src/nodes/BlockInsertButton.tsx` | Hover "+" Tiptap plugin view |
| `src/extensions/SlashCommands.ts` | Slash command Tiptap extension |
## Files to Modify
| File | Changes |
|---------------------------------------|--------------------------------------------------|
| `src/lib/serialize.ts` | Use `@tiptap/markdown` for text block conversion |
| `src/components/Editor.tsx` | Add markdown toggle state, register new extensions |
| `src/components/Toolbar.tsx` | Add "MD" toggle button |
| `src/nodes/DocumentBlock.tsx` | Add interactive editing (picker, title input) |
| `src/nodes/CardGridBlock.tsx` | Fetch and display page titles |
| `src/nodes/index.ts` | Export new extensions |
| `src/components/Editor.css` | Styles for markdown textarea mode |
## Out of Scope
- Undo/redo system (deferred)
- Per-block markdown toggle (future enhancement)
- Card grid editing (add/remove/reorder items)
- Embed block editing
+2
View File
@@ -9,6 +9,8 @@ export default defineConfig([
"build/**",
"modules/**",
"public/static/**",
"pb_data/**",
"pb_migrations/**",
"next-env.d.ts",
]),
...nextVitals,
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+30
View File
@@ -0,0 +1,30 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hudozka Writer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "hudozka-writer",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tiptap/core": "^3.0.0",
"@tiptap/extension-blockquote": "^3.0.0",
"@tiptap/extension-bold": "^3.0.0",
"@tiptap/extension-bullet-list": "^3.0.0",
"@tiptap/extension-code": "^3.0.0",
"@tiptap/extension-code-block": "^3.0.0",
"@tiptap/extension-document": "^3.0.0",
"@tiptap/extension-hard-break": "^3.0.0",
"@tiptap/extension-heading": "^3.0.0",
"@tiptap/extension-horizontal-rule": "^3.0.0",
"@tiptap/extension-italic": "^3.0.0",
"@tiptap/extension-link": "^3.0.0",
"@tiptap/extension-list-item": "^3.0.0",
"@tiptap/extension-ordered-list": "^3.0.0",
"@tiptap/extension-paragraph": "^3.0.0",
"@tiptap/extension-strike": "^3.0.0",
"@tiptap/extension-text": "^3.0.0",
"@tiptap/markdown": "^3.0.0",
"@tiptap/pm": "^3.0.0",
"@tiptap/react": "^3.0.0",
"@tiptap/starter-kit": "^3.0.0",
"@tiptap/suggestion": "^3.22.2",
"pocketbase": "^0.25.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^5.2.0",
"typescript": "^5.9.0",
"vite": "^8.0.0"
}
}
+68
View File
@@ -0,0 +1,68 @@
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border-bottom: 1px solid #e0e0e0;
}
.app-title {
font-weight: 600;
}
.app-slug {
color: #888;
font-family: monospace;
}
.app-header button {
padding: 4px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
cursor: pointer;
}
.app-header button:first-of-type {
margin-left: auto;
}
.app-main {
flex: 1;
padding: 20px;
max-width: 800px;
margin: 0 auto;
width: 100%;
}
.app-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 100vh;
}
.app-error {
color: #c00;
}
.app-published-toggle {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.9rem;
cursor: pointer;
user-select: none;
}
.app-published-toggle input {
cursor: pointer;
}
+134
View File
@@ -0,0 +1,134 @@
import { useState, useEffect, useCallback } from "react"
import { pb } from "./pb"
import { Login } from "./components/Login"
import { Editor } from "./components/Editor"
import type { PbPage } from "./types"
import "./App.css"
function getSlugFromUrl(): string | null {
const params = new URLSearchParams(window.location.search)
return params.get("page")
}
export function App() {
const [authenticated, setAuthenticated] = useState(pb.authStore.isValid)
const [page, setPage] = useState<PbPage | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const slug = getSlugFromUrl()
const loadPage = useCallback(async (slug: string) => {
setLoading(true)
setError("")
try {
const record = await pb.collection("pages").getFirstListItem<PbPage>(
`slug="${slug}"`,
)
setPage(record)
} catch {
setError(`Page "${slug}" not found`)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (authenticated && slug) {
loadPage(slug)
}
}, [authenticated, slug, loadPage])
function handleLogout() {
pb.authStore.clear()
setAuthenticated(false)
setPage(null)
}
async function handleDuplicate() {
if (!page) return
const newSlug = `${page.slug}-copy`
try {
const record = await pb.collection("pages").create<PbPage>({
title: `${page.title} (copy)`,
slug: newSlug,
excerpt: page.excerpt,
date: page.date,
cover: page.cover,
doc: page.doc,
tags: page.tags,
published: false,
draft: null,
})
window.location.search = `?page=${record.slug}`
} catch (err) {
console.error("Duplicate failed:", err)
alert("Duplicate failed. Check console.")
}
}
async function handleTogglePublished() {
if (!page) return
try {
const updated = await pb.collection("pages").update<PbPage>(page.id, {
published: !page.published,
})
setPage(updated)
} catch (err) {
console.error("Toggle published failed:", err)
alert("Toggle published failed. Check console.")
}
}
if (!authenticated) {
return <Login onLogin={() => setAuthenticated(true)} />
}
if (!slug) {
return (
<div className="app-message">
<p>Add <code>?page=slug</code> to the URL to edit a page.</p>
<button onClick={handleLogout}>Logout</button>
</div>
)
}
if (loading) {
return <div className="app-message">Loading...</div>
}
if (error) {
return (
<div className="app-message">
<p className="app-error">{error}</p>
<button onClick={handleLogout}>Logout</button>
</div>
)
}
if (!page) {
return <div className="app-message">Loading...</div>
}
return (
<div className="app">
<header className="app-header">
<span className="app-title">{page.title}</span>
<span className="app-slug">/{page.slug}</span>
<label className="app-published-toggle">
<input
type="checkbox"
checked={page.published}
onChange={handleTogglePublished}
/>
Published
</label>
<button onClick={handleDuplicate}>Duplicate</button>
<button onClick={handleLogout}>Logout</button>
</header>
<main className="app-main">
<Editor page={page} />
</main>
</div>
)
}
@@ -0,0 +1,69 @@
.block-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
flex-shrink: 0;
width: 28px;
padding-top: 2px;
user-select: none;
}
.block-action-btn {
width: 24px;
height: 24px;
border-radius: 4px;
border: 1px solid transparent;
background: none;
cursor: pointer;
font-size: 16px;
line-height: 1;
color: #ccc;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
padding: 0;
}
.block-action-btn:hover {
border-color: #ddd;
color: #666;
background: #f5f5f5;
}
.block-action-delete:hover {
border-color: #c00;
color: #c00;
background: #fff0f0;
}
.block-action-split:hover {
border-color: #2563eb;
color: #2563eb;
background: #eff6ff;
}
.block-drag-handle {
cursor: grab;
}
.block-drag-handle:active {
cursor: grabbing;
}
[data-node-view-wrapper] {
position: relative;
}
.node-view-block {
display: flex;
flex-direction: row;
align-items: stretch;
gap: 4px;
}
.node-view-content {
flex: 1;
min-width: 0;
}
@@ -0,0 +1,148 @@
import { useEffect, useCallback } from "react"
import type { Editor } from "@tiptap/react"
import { Fragment } from "@tiptap/pm/model"
import "./BlockActions.css"
export type BlockActionsProps = {
editor: Editor
getPos: () => number | undefined
showSplit?: boolean
}
export function BlockActions({ editor, getPos, showSplit }: BlockActionsProps) {
const getWrapper = useCallback(() => {
const pos = getPos()
if (typeof pos !== "number") return null
const dom = editor.view.nodeDOM(pos)
if (!dom || !(dom instanceof HTMLElement)) return null
return dom.closest("[data-node-view-wrapper]") as HTMLElement | null
}, [editor, getPos])
useEffect(() => {
const checkActive = () => {
const wrapper = getWrapper()
if (!wrapper) return
const pos = getPos()
if (typeof pos !== "number") {
wrapper.classList.remove("active-block")
return
}
const { from } = editor.state.selection
const resolved = editor.state.doc.resolve(from)
if (resolved.depth < 1) {
wrapper.classList.remove("active-block")
return
}
const blockPos = resolved.before(1)
if (blockPos === pos) {
wrapper.classList.add("active-block")
} else {
wrapper.classList.remove("active-block")
}
}
const handleBlur = () => {
const wrapper = getWrapper()
wrapper?.classList.remove("active-block")
}
checkActive()
editor.on("selectionUpdate", checkActive)
editor.on("focus", checkActive)
editor.on("blur", handleBlur)
return () => {
editor.off("selectionUpdate", checkActive)
editor.off("focus", checkActive)
editor.off("blur", handleBlur)
const wrapper = getWrapper()
wrapper?.classList.remove("active-block")
}
}, [editor, getPos, getWrapper])
const handleDelete = (e: React.MouseEvent) => {
e.preventDefault()
const pos = getPos()
if (typeof pos !== "number") return
const node = editor.state.doc.nodeAt(pos)
if (!node) return
editor.chain().focus().deleteRange({
from: pos,
to: pos + node.nodeSize,
}).run()
}
const handleSplit = (e: React.MouseEvent) => {
e.preventDefault()
const pos = getPos()
if (typeof pos !== "number") return
const node = editor.state.doc.nodeAt(pos)
if (!node || node.childCount < 2) return
const { from } = editor.state.selection
let childOffset = pos + 1
let splitAfter = -1
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i)
const childEnd = childOffset + child.nodeSize
if (from >= childOffset && from < childEnd) {
splitAfter = i
break
}
childOffset = childEnd
}
if (splitAfter < 0 || splitAfter >= node.childCount - 1) return
const firstChildren = []
const secondChildren = []
for (let i = 0; i < node.childCount; i++) {
if (i <= splitAfter) {
firstChildren.push(node.child(i))
} else {
secondChildren.push(node.child(i))
}
}
const { schema } = editor.state
const textBlockType = schema.nodes.textBlock
const first = textBlockType.create(null, firstChildren)
const second = textBlockType.create(null, secondChildren)
const tr = editor.state.tr
tr.replaceWith(pos, pos + node.nodeSize, Fragment.from([first, second]))
editor.view.dispatch(tr)
}
return (
<div className="block-actions" contentEditable={false}>
<button
className="block-action-btn block-action-delete"
title="Delete block"
onClick={handleDelete}
>
&times;
</button>
{showSplit && (
<button
className="block-action-btn block-action-split"
title="Split block"
onClick={handleSplit}
>
&#x2702;
</button>
)}
<div
className="block-action-btn block-drag-handle"
data-drag-handle
draggable
title="Drag to reorder"
>
&#x2807;
</div>
</div>
)
}
@@ -0,0 +1,39 @@
.block-insert {
position: relative;
display: flex;
align-items: center;
justify-content: flex-start;
padding: 8px 0 8px 28px;
}
.block-insert-btn {
position: relative;
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid transparent;
background: none;
cursor: pointer;
font-size: 18px;
line-height: 1;
color: #ccc;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
padding: 0;
z-index: 10;
}
.block-insert-btn:hover {
border-color: #ddd;
color: #666;
background: #f5f5f5;
}
.block-insert-menu {
position: absolute;
top: 12px;
left: 56px;
z-index: 50;
}
@@ -0,0 +1,51 @@
import { useState } from "react"
import type { Editor } from "@tiptap/react"
import { BlockMenu } from "./BlockMenu"
import "./BlockInsert.css"
export type BlockInsertProps = {
editor: Editor
getPos: () => number | undefined
}
export function BlockInsert({ editor, getPos }: BlockInsertProps) {
const [menuOpen, setMenuOpen] = useState(false)
const insertPos = (() => {
const pos = getPos()
if (typeof pos !== "number") return 0
const node = editor.state.doc.nodeAt(pos)
return node ? pos + node.nodeSize : 0
})()
const handleToggle = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setMenuOpen(!menuOpen)
}
const handleClose = () => {
setMenuOpen(false)
}
return (
<div className="block-insert" contentEditable={false}>
<button
className="block-insert-btn"
title="Add block"
onClick={handleToggle}
>
+
</button>
{menuOpen && (
<div className="block-insert-menu">
<BlockMenu
editor={editor}
position={insertPos}
onClose={handleClose}
/>
</div>
)}
</div>
)
}
@@ -0,0 +1,24 @@
.block-menu {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
padding: 4px 0;
min-width: 160px;
z-index: 50;
}
.block-menu-item {
display: block;
width: 100%;
padding: 8px 12px;
border: none;
background: none;
text-align: left;
cursor: pointer;
font-size: 0.9rem;
}
.block-menu-item:hover {
background: #f0f4ff;
}
@@ -0,0 +1,65 @@
import type { Editor } from "@tiptap/react"
import { generateBlockId } from "../lib/id"
import "./BlockMenu.css"
type BlockType = {
label: string
type: string
attrs?: Record<string, unknown>
}
const BLOCK_TYPES: BlockType[] = [
{ label: "Text", type: "textBlock" },
{ label: "Image", type: "imageBlock", attrs: { id: "", imageId: "", wide: false, caption: "" } },
{ label: "File", type: "documentBlock", attrs: { id: "", fileId: "", title: "" } },
{ label: "Embed", type: "embedBlock", attrs: { id: "", src: "" } },
{ label: "Card Grid", type: "cardGridBlock", attrs: { id: "", items: "[]" } },
]
export type BlockMenuProps = {
editor: Editor
position: number
onClose: () => void
filter?: string
}
export function BlockMenu({ editor, position, onClose, filter }: BlockMenuProps) {
const filtered = filter
? BLOCK_TYPES.filter((b) => b.label.toLowerCase().includes(filter.toLowerCase()))
: BLOCK_TYPES
function handleInsert(block: BlockType) {
const id = generateBlockId()
if (block.type === "textBlock") {
editor.chain().focus().insertContentAt(position, {
type: "textBlock",
content: [{ type: "paragraph" }],
}).run()
} else {
editor.chain().focus().insertContentAt(position, {
type: block.type,
attrs: { ...block.attrs, id },
}).run()
}
onClose()
}
if (filtered.length === 0) return null
return (
<div className="block-menu">
{filtered.map((block) => (
<button
key={block.type}
className="block-menu-item"
onClick={() => handleInsert(block)}
>
{block.label}
</button>
))}
</div>
)
}
export { BLOCK_TYPES }
export type { BlockType }
@@ -0,0 +1,24 @@
.draft-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #fff8e1;
border: 1px solid #ffe082;
border-radius: 4px;
margin-bottom: 12px;
font-size: 0.9rem;
}
.draft-banner-discard {
padding: 4px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 0.85rem;
}
.draft-banner-discard:hover {
background: #f0f0f0;
}
@@ -0,0 +1,16 @@
import "./DraftBanner.css"
export type DraftBannerProps = {
onDiscard: () => void
}
export function DraftBanner({ onDiscard }: DraftBannerProps) {
return (
<div className="draft-banner">
<span>You have unpublished changes</span>
<button onClick={onDiscard} className="draft-banner-discard">
Discard
</button>
</div>
)
}
@@ -0,0 +1,88 @@
.editor {
width: 100%;
}
.editor-content {
position: relative;
}
.editor-content .tiptap {
outline: none;
min-height: 400px;
}
.editor-content .tiptap > * + * {
margin-top: 0.75em;
}
.editor-content .tiptap h1 {
font-size: 2rem;
}
.editor-content .tiptap h2 {
font-size: 1.5rem;
}
.editor-content .tiptap h3 {
font-size: 1.25rem;
}
.editor-content .tiptap blockquote {
border-left: 3px solid #e0e0e0;
padding-left: 1rem;
color: #666;
}
.editor-content .tiptap pre {
background: #f5f5f5;
border-radius: 4px;
padding: 12px;
font-family: monospace;
}
.editor-content .tiptap code {
background: #f0f0f0;
border-radius: 2px;
padding: 0.1em 0.3em;
font-size: 0.9em;
}
.editor-content .tiptap hr {
border: none;
border-top: 1px solid #e0e0e0;
margin: 1.5em 0;
}
.editor-content .tiptap a {
color: #2563eb;
text-decoration: underline;
}
.editor-content .tiptap ul,
.editor-content .tiptap ol {
padding-left: 1.5em;
}
.editor-markdown {
width: 100%;
min-height: 400px;
padding: 16px;
font-family: monospace;
font-size: 14px;
line-height: 1.6;
border: 1px solid #e0e0e0;
border-radius: 4px;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.editor-markdown:focus {
border-color: #213547;
}
.editor-content .tiptap [data-node-view-wrapper].active-block > .node-view-content {
outline: 2px solid #d0d5dd;
outline-offset: 2px;
border-radius: 4px;
}
@@ -0,0 +1,174 @@
import { useEditor, EditorContent } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import { Markdown } from "@tiptap/markdown"
import Link from "@tiptap/extension-link"
import { useState, useCallback, useRef, useEffect } from "react"
import { pb } from "../pb"
import { docToTiptap, tiptapToDoc } from "../lib/serialize"
import { Toolbar } from "./Toolbar"
import { DraftBanner } from "./DraftBanner"
import {
WriterDocument,
TextBlock,
ImageBlock,
DocumentBlock,
EmbedBlock,
CardGridBlock,
} from "../nodes"
import { SlashCommands } from "../extensions/SlashCommands"
import "../nodes/nodes.css"
import "./Editor.css"
import type { PbPage, DocV1 } from "../types"
function serializeDoc(doc: DocV1): string {
return JSON.stringify(doc)
}
export type EditorProps = {
page: PbPage
}
export function Editor({ page }: EditorProps) {
const [saving, setSaving] = useState(false)
const [publishing, setPublishing] = useState(false)
const [markdownMode, setMarkdownMode] = useState(false)
const [markdownText, setMarkdownText] = useState("")
const [dirty, setDirty] = useState(false)
const [hasDraft, setHasDraft] = useState(page.draft !== null)
const [publishedDoc, setPublishedDoc] = useState(page.doc)
const snapshotRef = useRef("")
const initialDoc = page.draft ?? page.doc
const tiptapDoc = docToTiptap(initialDoc)
// Set initial snapshot
useEffect(() => {
snapshotRef.current = serializeDoc(initialDoc)
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const editor = useEditor({
extensions: [
WriterDocument,
StarterKit.configure({
document: false,
}),
TextBlock,
Markdown,
Link.configure({ openOnClick: false }),
ImageBlock,
DocumentBlock,
EmbedBlock,
CardGridBlock,
SlashCommands,
],
content: tiptapDoc,
onUpdate: ({ editor }) => {
const json = editor.getJSON()
const doc = tiptapToDoc(json.content ?? [])
const current = serializeDoc(doc)
setDirty(current !== snapshotRef.current)
},
})
const getCurrentDoc = useCallback((): DocV1 => {
if (!editor) return { version: 1, blocks: [] }
if (markdownMode) {
editor.commands.setContent(markdownText, { contentType: "markdown" })
setMarkdownMode(false)
}
const json = editor.getJSON()
return tiptapToDoc(json.content ?? [])
}, [editor, markdownMode, markdownText])
const handleToggleMarkdown = useCallback(() => {
if (!editor) return
if (!markdownMode) {
const md = editor.getMarkdown()
setMarkdownText(md)
setMarkdownMode(true)
} else {
editor.commands.setContent(markdownText, { contentType: "markdown" })
setMarkdownMode(false)
}
}, [editor, markdownMode, markdownText])
const handleSave = useCallback(async () => {
if (!editor) return
setSaving(true)
try {
const doc = getCurrentDoc()
await pb.collection("pages").update(page.id, { draft: doc })
snapshotRef.current = serializeDoc(doc)
setDirty(false)
setHasDraft(true)
} catch (err) {
console.error("Save failed:", err)
alert("Save failed. Check console.")
} finally {
setSaving(false)
}
}, [editor, page.id, getCurrentDoc])
const handlePublish = useCallback(async () => {
if (!editor) return
setPublishing(true)
try {
const doc = getCurrentDoc()
await pb.collection("pages").update(page.id, { doc, draft: null })
snapshotRef.current = serializeDoc(doc)
setDirty(false)
setHasDraft(false)
setPublishedDoc(doc)
} catch (err) {
console.error("Publish failed:", err)
alert("Publish failed. Check console.")
} finally {
setPublishing(false)
}
}, [editor, page.id, getCurrentDoc])
const handleDiscard = useCallback(async () => {
if (!editor) return
try {
await pb.collection("pages").update(page.id, { draft: null })
const tiptap = docToTiptap(publishedDoc)
editor.commands.setContent(tiptap)
snapshotRef.current = serializeDoc(publishedDoc)
setDirty(false)
setHasDraft(false)
setMarkdownMode(false)
} catch (err) {
console.error("Discard failed:", err)
alert("Discard failed. Check console.")
}
}, [editor, page.id, publishedDoc])
return (
<div className="editor">
<Toolbar
editor={editor}
onSave={handleSave}
onPublish={handlePublish}
saving={saving}
publishing={publishing}
dirty={dirty}
hasDraft={hasDraft}
markdownMode={markdownMode}
onToggleMarkdown={handleToggleMarkdown}
/>
{hasDraft && (
<DraftBanner onDiscard={handleDiscard} />
)}
{markdownMode ? (
<textarea
className="editor-markdown"
value={markdownText}
onChange={(e) => setMarkdownText(e.target.value)}
/>
) : (
<EditorContent editor={editor} className="editor-content" />
)}
</div>
)
}
@@ -0,0 +1,32 @@
.file-picker-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.file-picker-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: none;
background: none;
cursor: pointer;
text-align: left;
border-radius: 4px;
font-size: 0.9rem;
}
.file-picker-item:hover {
background: #f0f4ff;
}
.file-picker-icon {
font-size: 1.1rem;
}
.file-picker-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,123 @@
import { useState, useEffect, useRef } from "react"
import { pb } from "../pb"
import type { PbFile } from "../types"
import "./FilePicker.css"
export type FilePickerProps = {
onSelect: (fileId: string) => void
onClose: () => void
}
export function FilePicker({ onSelect, onClose }: FilePickerProps) {
const [files, setFiles] = useState<PbFile[]>([])
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [loading, setLoading] = useState(false)
const [uploading, setUploading] = useState(false)
const [tab, setTab] = useState<"browse" | "upload">("browse")
const fileRef = useRef<HTMLInputElement>(null)
useEffect(() => {
loadFiles(page)
}, [page])
async function loadFiles(p: number) {
setLoading(true)
try {
const result = await pb.collection("files").getList<PbFile>(p, 20, {
sort: "-created",
})
setFiles(result.items)
setTotalPages(result.totalPages)
} catch (err) {
console.error("Failed to load files:", err)
} finally {
setLoading(false)
}
}
async function handleUpload() {
const file = fileRef.current?.files?.[0]
if (!file) return
setUploading(true)
try {
const record = await pb.collection("files").create<PbFile>({
file,
filename: file.name,
})
onSelect(record.id)
} catch (err) {
console.error("Upload failed:", err)
alert("Upload failed. Check console.")
} finally {
setUploading(false)
}
}
return (
<div className="picker-overlay" onClick={onClose}>
<div className="picker" onClick={(e) => e.stopPropagation()}>
<div className="picker-header">
<button
className={tab === "browse" ? "active" : ""}
onClick={() => setTab("browse")}
>
Browse
</button>
<button
className={tab === "upload" ? "active" : ""}
onClick={() => setTab("upload")}
>
Upload
</button>
<button className="picker-close" onClick={onClose}>×</button>
</div>
{tab === "browse" && (
<div className="picker-browse">
{loading ? (
<div className="picker-loading">Loading...</div>
) : (
<div className="file-picker-list">
{files.map((f) => (
<button
key={f.id}
className="file-picker-item"
onClick={() => onSelect(f.id)}
>
<span className="file-picker-icon">📎</span>
<span className="file-picker-name">{f.filename}</span>
</button>
))}
</div>
)}
<div className="picker-pagination">
<button
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Prev
</button>
<span>{page} / {totalPages}</span>
<button
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
</div>
)}
{tab === "upload" && (
<div className="picker-upload">
<input type="file" ref={fileRef} />
<button onClick={handleUpload} disabled={uploading}>
{uploading ? "Uploading..." : "Upload"}
</button>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,127 @@
.picker-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.picker {
background: white;
border-radius: 8px;
width: 600px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.picker-header {
display: flex;
gap: 4px;
padding: 8px;
border-bottom: 1px solid #e0e0e0;
}
.picker-header button {
padding: 4px 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
}
.picker-header button.active {
background: #213547;
color: white;
border-color: #213547;
}
.picker-close {
margin-left: auto !important;
font-size: 1.2rem;
}
.picker-browse {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
}
.picker-thumb {
border: 2px solid transparent;
border-radius: 4px;
padding: 0;
background: none;
cursor: pointer;
overflow: hidden;
aspect-ratio: 1;
}
.picker-thumb:hover {
border-color: #213547;
}
.picker-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.picker-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 8px;
}
.picker-pagination button {
padding: 4px 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
}
.picker-pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.picker-loading {
text-align: center;
padding: 40px;
color: #888;
}
.picker-upload {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
align-items: flex-start;
}
.picker-upload button {
padding: 8px 16px;
border: none;
border-radius: 4px;
background: #213547;
color: white;
cursor: pointer;
}
.picker-upload button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@@ -0,0 +1,131 @@
import { useState, useEffect, useRef } from "react"
import { pb } from "../pb"
import type { PbImage } from "../types"
import "./ImagePicker.css"
export type ImagePickerProps = {
onSelect: (imageId: string) => void
onClose: () => void
}
export function ImagePicker({ onSelect, onClose }: ImagePickerProps) {
const [images, setImages] = useState<PbImage[]>([])
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [loading, setLoading] = useState(false)
const [uploading, setUploading] = useState(false)
const [tab, setTab] = useState<"browse" | "upload">("browse")
const fileRef = useRef<HTMLInputElement>(null)
useEffect(() => {
loadImages(page)
}, [page])
async function loadImages(p: number) {
setLoading(true)
try {
const result = await pb.collection("images").getList<PbImage>(p, 20, {
sort: "-created",
})
setImages(result.items)
setTotalPages(result.totalPages)
} catch (err) {
console.error("Failed to load images:", err)
} finally {
setLoading(false)
}
}
async function handleUpload() {
const file = fileRef.current?.files?.[0]
if (!file) return
setUploading(true)
try {
const record = await pb.collection("images").create<PbImage>({
file,
filename: file.name,
width: 0,
height: 0,
alt: "",
caption: "",
blurhash: "",
})
onSelect(record.id)
} catch (err) {
console.error("Upload failed:", err)
alert("Upload failed. Check console.")
} finally {
setUploading(false)
}
}
function getThumbUrl(img: PbImage): string {
return pb.files.getURL(img, img.file, { thumb: "150x150" })
}
return (
<div className="picker-overlay" onClick={onClose}>
<div className="picker" onClick={(e) => e.stopPropagation()}>
<div className="picker-header">
<button
className={tab === "browse" ? "active" : ""}
onClick={() => setTab("browse")}
>
Browse
</button>
<button
className={tab === "upload" ? "active" : ""}
onClick={() => setTab("upload")}
>
Upload
</button>
<button className="picker-close" onClick={onClose}>×</button>
</div>
{tab === "browse" && (
<div className="picker-browse">
{loading ? (
<div className="picker-loading">Loading...</div>
) : (
<div className="picker-grid">
{images.map((img) => (
<button
key={img.id}
className="picker-thumb"
onClick={() => onSelect(img.id)}
>
<img src={getThumbUrl(img)} alt={img.alt || img.filename} />
</button>
))}
</div>
)}
<div className="picker-pagination">
<button
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Prev
</button>
<span>{page} / {totalPages}</span>
<button
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
</div>
)}
{tab === "upload" && (
<div className="picker-upload">
<input type="file" accept="image/*" ref={fileRef} />
<button onClick={handleUpload} disabled={uploading}>
{uploading ? "Uploading..." : "Upload"}
</button>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,45 @@
.login {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.login-form {
display: flex;
flex-direction: column;
gap: 12px;
width: 320px;
}
.login-form h1 {
font-size: 1.4rem;
margin-bottom: 8px;
}
.login-form input {
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
.login-form button {
padding: 8px 12px;
border: none;
border-radius: 4px;
background: #213547;
color: white;
font-size: 1rem;
cursor: pointer;
}
.login-form button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-error {
color: #c00;
font-size: 0.9rem;
}
@@ -0,0 +1,55 @@
import { useState } from "react"
import type { FormEvent } from "react"
import { pb } from "../pb"
import "./Login.css"
export type LoginProps = {
onLogin: () => void
}
export function Login({ onLogin }: LoginProps) {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError("")
setLoading(true)
try {
await pb.collection("_superusers").authWithPassword(email, password)
onLogin()
} catch {
setError("Authentication failed")
} finally {
setLoading(false)
}
}
return (
<div className="login">
<form className="login-form" onSubmit={handleSubmit}>
<h1>Hudozka Writer</h1>
{error && <p className="login-error">{error}</p>}
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button type="submit" disabled={loading}>
{loading ? "Signing in..." : "Sign in"}
</button>
</form>
</div>
)
}
@@ -0,0 +1,63 @@
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px 0;
border-bottom: 1px solid #e0e0e0;
margin-bottom: 16px;
position: sticky;
top: 0;
background: white;
z-index: 10;
}
.toolbar-group {
display: flex;
gap: 2px;
}
.toolbar button {
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 0.85rem;
min-width: 32px;
}
.toolbar button:hover {
background: #f0f0f0;
}
.toolbar button.active {
background: #213547;
color: white;
border-color: #213547;
}
.toolbar-actions {
margin-left: auto;
gap: 4px;
}
.toolbar-save-btn {
padding: 4px 16px !important;
}
.toolbar-save-btn:disabled {
opacity: 0.6;
cursor: not-allowed !important;
}
.toolbar-publish-btn {
background: #213547 !important;
color: white !important;
border-color: #213547 !important;
padding: 4px 16px !important;
}
.toolbar-publish-btn:disabled {
opacity: 0.6;
cursor: not-allowed !important;
}
@@ -0,0 +1,151 @@
import type { Editor } from "@tiptap/react"
import "./Toolbar.css"
export type ToolbarProps = {
editor: Editor | null
onSave: () => void
onPublish: () => void
saving: boolean
publishing: boolean
dirty: boolean
hasDraft: boolean
markdownMode: boolean
onToggleMarkdown: () => void
}
export function Toolbar({
editor,
onSave,
onPublish,
saving,
publishing,
dirty,
hasDraft,
markdownMode,
onToggleMarkdown,
}: ToolbarProps) {
if (!editor) return null
const canSave = dirty
const canPublish = dirty || hasDraft
return (
<div className="toolbar">
<div className="toolbar-group">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive("bold") ? "active" : ""}
title="Bold"
>
B
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive("italic") ? "active" : ""}
title="Italic"
>
I
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
className={editor.isActive("strike") ? "active" : ""}
title="Strikethrough"
>
S
</button>
<button
onClick={() => editor.chain().focus().toggleCode().run()}
className={editor.isActive("code") ? "active" : ""}
title="Inline code"
>
&lt;/&gt;
</button>
</div>
<div className="toolbar-group">
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive("heading", { level: 1 }) ? "active" : ""}
>
H1
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive("heading", { level: 2 }) ? "active" : ""}
>
H2
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={editor.isActive("heading", { level: 3 }) ? "active" : ""}
>
H3
</button>
</div>
<div className="toolbar-group">
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive("bulletList") ? "active" : ""}
title="Bullet list"
>
List
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive("orderedList") ? "active" : ""}
title="Numbered list"
>
1.
</button>
<button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editor.isActive("blockquote") ? "active" : ""}
title="Blockquote"
>
&ldquo;
</button>
<button
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={editor.isActive("codeBlock") ? "active" : ""}
title="Code block"
>
Code
</button>
<button
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Horizontal rule"
>
---
</button>
</div>
<div className="toolbar-group">
<button
onClick={onToggleMarkdown}
className={markdownMode ? "active" : ""}
title="Toggle markdown view"
>
MD
</button>
</div>
<div className="toolbar-group toolbar-actions">
<button
onClick={onSave}
disabled={!canSave || saving}
className="toolbar-save-btn"
>
{saving ? "Saving..." : "Save"}
</button>
<button
onClick={onPublish}
disabled={!canPublish || publishing}
className="toolbar-publish-btn"
>
{publishing ? "Publishing..." : "Publish"}
</button>
</div>
</div>
)
}
@@ -0,0 +1,28 @@
.slash-command-container {
z-index: 50;
}
.slash-menu {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
padding: 4px 0;
min-width: 160px;
}
.slash-menu-item {
display: block;
width: 100%;
padding: 8px 12px;
border: none;
background: none;
text-align: left;
cursor: pointer;
font-size: 0.9rem;
}
.slash-menu-item:hover,
.slash-menu-item--selected {
background: #f0f4ff;
}
@@ -0,0 +1,255 @@
import { Extension } from "@tiptap/core"
import { PluginKey } from "@tiptap/pm/state"
import { Fragment } from "@tiptap/pm/model"
import type { ResolvedPos, Node as PmNode } from "@tiptap/pm/model"
import Suggestion from "@tiptap/suggestion"
import type { SuggestionOptions, SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion"
import { createRoot } from "react-dom/client"
import { generateBlockId } from "../lib/id"
import type { Editor } from "@tiptap/react"
import "./SlashCommands.css"
type SlashCommandItem = {
label: string
type: string
attrs?: Record<string, unknown>
}
const ITEMS: SlashCommandItem[] = [
{ label: "Text", type: "textBlock" },
{ label: "Image", type: "imageBlock", attrs: { id: "", imageId: "", wide: false, caption: "" } },
{ label: "File", type: "documentBlock", attrs: { id: "", fileId: "", title: "" } },
{ label: "Embed", type: "embedBlock", attrs: { id: "", src: "" } },
{ label: "Card Grid", type: "cardGridBlock", attrs: { id: "", items: "[]" } },
]
const slashCommandsPluginKey = new PluginKey("slashCommands")
function findTextBlockDepth($pos: ResolvedPos): number | null {
for (let d = $pos.depth; d > 0; d--) {
if ($pos.node(d).type.name === "textBlock") {
return d
}
}
return null
}
function SlashMenu({ items, command, selectedIndex }: { items: SlashCommandItem[]; command: (item: SlashCommandItem) => void; selectedIndex: number }) {
if (items.length === 0) return null
return (
<div className="slash-menu">
{items.map((item, index) => (
<button
key={item.type}
className={`slash-menu-item${index === selectedIndex ? " slash-menu-item--selected" : ""}`}
onClick={() => command(item)}
>
{item.label}
</button>
))}
</div>
)
}
export const SlashCommands = Extension.create({
name: "slashCommands",
addOptions() {
return {
suggestion: {
char: "/",
pluginKey: slashCommandsPluginKey,
startOfLine: true,
items: ({ query }: { query: string }) => {
return ITEMS.filter((item) =>
item.label.toLowerCase().includes(query.toLowerCase())
)
},
render: () => {
let container: HTMLDivElement | null = null
let root: ReturnType<typeof createRoot> | null = null
let selectedIndex = 0
let currentItems: SlashCommandItem[] = []
let currentCommand: ((item: SlashCommandItem) => void) | null = null
function renderMenu() {
if (!root) return
root.render(
<SlashMenu
items={currentItems}
command={currentCommand!}
selectedIndex={selectedIndex}
/>
)
}
return {
onStart: (props: SuggestionProps<SlashCommandItem, SlashCommandItem>) => {
container = document.createElement("div")
container.className = "slash-command-container"
document.body.appendChild(container)
root = createRoot(container)
const rect = props.clientRect?.()
if (rect && container) {
container.style.position = "fixed"
container.style.left = `${rect.left}px`
container.style.top = `${rect.bottom + 4}px`
container.style.zIndex = "50"
}
selectedIndex = 0
currentItems = props.items
currentCommand = props.command
renderMenu()
},
onUpdate: (props: SuggestionProps<SlashCommandItem, SlashCommandItem>) => {
if (!root || !container) return
const rect = props.clientRect?.()
if (rect && container) {
container.style.left = `${rect.left}px`
container.style.top = `${rect.bottom + 4}px`
}
currentItems = props.items
currentCommand = props.command
selectedIndex = 0
renderMenu()
},
onExit: () => {
root?.unmount()
container?.remove()
container = null
root = null
},
onKeyDown: (props: SuggestionKeyDownProps) => {
const { key } = props.event
if (key === "Escape") {
root?.unmount()
container?.remove()
container = null
root = null
return true
}
if (key === "ArrowDown") {
selectedIndex = (selectedIndex + 1) % currentItems.length
renderMenu()
return true
}
if (key === "ArrowUp") {
selectedIndex = (selectedIndex - 1 + currentItems.length) % currentItems.length
renderMenu()
return true
}
if (key === "Enter") {
if (currentItems[selectedIndex] && currentCommand) {
currentCommand(currentItems[selectedIndex])
}
return true
}
return false
},
}
},
command: ({ editor, range, props }: { editor: Editor; range: { from: number; to: number }; props: SlashCommandItem }) => {
// Delete the slash command text
editor.chain().focus().deleteRange(range).run()
if (props.type === "textBlock") {
// Already inside a textBlock paragraph — nothing to do
return
}
const { from } = editor.state.selection
const id = generateBlockId()
const newNode = { type: props.type, attrs: { ...props.attrs, id } }
// Check if we're inside a textBlock
const $pos = editor.state.doc.resolve(from)
const textBlockDepth = findTextBlockDepth($pos)
if (textBlockDepth === null) {
// Not inside a textBlock — insert directly
editor.chain().focus().insertContentAt(from, newNode).run()
return
}
// Inside a textBlock — split it and insert the custom block between halves
const textBlockPos = $pos.before(textBlockDepth)
const textBlockNode = editor.state.doc.nodeAt(textBlockPos)
if (!textBlockNode) return
// Find which child contains the cursor
let childOffset = textBlockPos + 1
let splitAfter = -1
for (let i = 0; i < textBlockNode.childCount; i++) {
const child = textBlockNode.child(i)
const childEnd = childOffset + child.nodeSize
if (from >= childOffset && from <= childEnd) {
// If the paragraph is empty (just deleted the slash), split before it
const curChild = textBlockNode.child(i)
if (curChild.content.size === 0 && i > 0) {
splitAfter = i - 1
} else if (curChild.content.size === 0 && i === 0) {
splitAfter = -1 // cursor in empty first child
} else {
splitAfter = i
}
break
}
childOffset = childEnd
}
const { schema } = editor.state
const textBlockType = schema.nodes.textBlock
const fragments: PmNode[] = []
// Build first textBlock (children 0..splitAfter) — skip if empty
if (splitAfter >= 0) {
const children = []
for (let i = 0; i <= splitAfter; i++) {
children.push(textBlockNode.child(i))
}
// Skip if all children are empty paragraphs
const hasContent = children.some((c) => c.content.size > 0)
if (hasContent) {
fragments.push(textBlockType.create(null, children))
}
}
// Insert the custom block
fragments.push(schema.nodes[props.type].create(
{ ...props.attrs, id },
))
// Build second textBlock (children after splitAfter) — skip empty
const remaining = []
const startIdx = splitAfter + 1
for (let i = startIdx; i < textBlockNode.childCount; i++) {
remaining.push(textBlockNode.child(i))
}
// Filter out empty paragraphs at the edges
const nonEmpty = remaining.filter((c) => c.content.size > 0)
if (nonEmpty.length > 0) {
fragments.push(textBlockType.create(null, remaining))
}
const tr = editor.state.tr
tr.replaceWith(textBlockPos, textBlockPos + textBlockNode.nodeSize, Fragment.from(fragments))
editor.view.dispatch(tr)
},
} satisfies Partial<SuggestionOptions<SlashCommandItem, SlashCommandItem>>,
}
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
]
},
})
+16
View File
@@ -0,0 +1,16 @@
:root {
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: #213547;
background-color: #ffffff;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
min-height: 100vh;
}
+3
View File
@@ -0,0 +1,3 @@
export function generateBlockId(): string {
return crypto.randomUUID().slice(0, 8)
}
@@ -0,0 +1,17 @@
import { MarkdownManager } from "@tiptap/markdown"
import StarterKit from "@tiptap/starter-kit"
import Link from "@tiptap/extension-link"
let manager: MarkdownManager | null = null
export function getMarkdownManager(): MarkdownManager {
if (!manager) {
manager = new MarkdownManager({
extensions: [
StarterKit,
Link,
],
})
}
return manager
}
+168
View File
@@ -0,0 +1,168 @@
import type { JSONContent } from "@tiptap/core"
import type {
DocV1,
DocV1Block,
DocV1BlockText,
DocV1BlockImage,
DocV1BlockDocument,
DocV1BlockEmbed,
DocV1BlockCardGrid,
} from "../types"
import { generateBlockId } from "./id"
import { getMarkdownManager } from "./markdown"
// -- DocV1 -> Tiptap JSON --
function textBlockToTiptap(block: DocV1BlockText): JSONContent {
const manager = getMarkdownManager()
const parsed = manager.parse(block.text)
return {
type: "textBlock",
content: parsed.content ?? [],
}
}
function imageBlockToTiptap(block: DocV1BlockImage): JSONContent {
return {
type: "imageBlock",
attrs: {
id: block.id,
imageId: block.image,
wide: block.wide,
caption: block.caption,
},
}
}
function documentBlockToTiptap(block: DocV1BlockDocument): JSONContent {
return {
type: "documentBlock",
attrs: {
id: block.id,
fileId: block.file,
title: block.title,
},
}
}
function embedBlockToTiptap(block: DocV1BlockEmbed): JSONContent {
return {
type: "embedBlock",
attrs: {
id: block.id,
src: block.src,
},
}
}
function cardGridBlockToTiptap(block: DocV1BlockCardGrid): JSONContent {
return {
type: "cardGridBlock",
attrs: {
id: block.id,
items: JSON.stringify(block.items),
},
}
}
export function docToTiptap(doc: DocV1): JSONContent {
const content: JSONContent[] = []
for (const block of doc.blocks) {
switch (block.type) {
case "text":
content.push(textBlockToTiptap(block))
break
case "image":
content.push(imageBlockToTiptap(block))
break
case "document":
content.push(documentBlockToTiptap(block))
break
case "embed":
content.push(embedBlockToTiptap(block))
break
case "card-grid":
content.push(cardGridBlockToTiptap(block))
break
}
}
return {
type: "doc",
content,
}
}
// -- Tiptap JSON -> DocV1 --
function textNodesToMarkdown(nodes: JSONContent[]): string {
const manager = getMarkdownManager()
const doc: JSONContent = { type: "doc", content: nodes }
return manager.serialize(doc)
}
export function tiptapToDoc(content: JSONContent[]): DocV1 {
const blocks: DocV1Block[] = []
for (const node of content) {
switch (node.type) {
case "textBlock": {
const text = textNodesToMarkdown(node.content ?? [])
if (text.trim()) {
blocks.push({
type: "text",
id: generateBlockId(),
text,
})
}
break
}
case "imageBlock": {
const attrs = node.attrs ?? {}
blocks.push({
type: "image",
id: attrs.id ?? generateBlockId(),
image: attrs.imageId ?? "",
wide: attrs.wide ?? false,
caption: attrs.caption ?? "",
})
break
}
case "documentBlock": {
const attrs = node.attrs ?? {}
blocks.push({
type: "document",
id: attrs.id ?? generateBlockId(),
file: attrs.fileId ?? "",
title: attrs.title ?? "",
})
break
}
case "embedBlock": {
const attrs = node.attrs ?? {}
blocks.push({
type: "embed",
id: attrs.id ?? generateBlockId(),
src: attrs.src ?? "",
})
break
}
case "cardGridBlock": {
const attrs = node.attrs ?? {}
blocks.push({
type: "card-grid",
id: attrs.id ?? generateBlockId(),
items: JSON.parse(attrs.items ?? "[]"),
})
break
}
}
}
return { version: 1, blocks }
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { App } from "./App"
import "./index.css"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -0,0 +1,92 @@
import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"
import type { NodeViewProps } from "@tiptap/react"
import { useState, useEffect } from "react"
import { pb } from "../pb"
import type { PbPage } from "../types"
import { BlockActions } from "../components/BlockActions"
import { BlockInsert } from "../components/BlockInsert"
type ResolvedItem = {
page: string
title: string | null
}
function CardGridBlockView({ node, editor, getPos }: NodeViewProps) {
const items = JSON.parse(node.attrs.items || "[]")
const [resolved, setResolved] = useState<ResolvedItem[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (items.length === 0) {
setResolved([])
return
}
setLoading(true)
Promise.all(
items.map(async (item: { page: string }) => {
try {
const page = await pb.collection("pages").getOne<PbPage>(item.page)
return { page: item.page, title: page.title }
} catch {
return { page: item.page, title: null }
}
})
).then((results) => {
setResolved(results)
setLoading(false)
})
}, [node.attrs.items])
return (
<>
<NodeViewWrapper className="node-view-block node-readonly-block">
<BlockActions editor={editor} getPos={getPos} />
<div className="node-view-content">
<div className="node-block-label">
Card Grid ({items.length})
</div>
{loading ? (
<div className="node-block-info">Loading...</div>
) : (
<ul className="node-cardgrid-list">
{resolved.map((item) => (
<li key={item.page} className="node-cardgrid-item">
{item.title ?? item.page}
</li>
))}
</ul>
)}
</div>
</NodeViewWrapper>
<BlockInsert editor={editor} getPos={getPos} />
</>
)
}
export const CardGridBlock = Node.create({
name: "cardGridBlock",
group: "block",
atom: true,
draggable: true,
addAttributes() {
return {
id: { default: null },
items: { default: "[]" },
}
},
parseHTML() {
return [{ tag: "div[data-card-grid-block]" }]
},
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes(HTMLAttributes, { "data-card-grid-block": "" })]
},
addNodeView() {
return ReactNodeViewRenderer(CardGridBlockView)
},
})
@@ -0,0 +1,106 @@
import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"
import type { NodeViewProps } from "@tiptap/react"
import { useState, useEffect } from "react"
import { pb } from "../pb"
import { FilePicker } from "../components/FilePicker"
import type { PbFile } from "../types"
import { BlockActions } from "../components/BlockActions"
import { BlockInsert } from "../components/BlockInsert"
function DocumentBlockView({ node, updateAttributes, editor, getPos }: NodeViewProps) {
const { fileId, title } = node.attrs
const [filename, setFilename] = useState<string | null>(null)
const [showPicker, setShowPicker] = useState(false)
useEffect(() => {
if (!fileId) return
pb.collection("files").getOne<PbFile>(fileId).then((f) => {
setFilename(f.filename)
}).catch(() => {
setFilename(null)
})
}, [fileId])
function handleSelect(newFileId: string) {
updateAttributes({ fileId: newFileId })
setShowPicker(false)
}
return (
<>
<NodeViewWrapper className="node-view-block node-document-block">
<BlockActions editor={editor} getPos={getPos} />
<div className="node-view-content">
<div className="node-block-label">File</div>
{fileId ? (
<div
className="node-document-file"
onClick={() => setShowPicker(true)}
style={{ cursor: "pointer" }}
>
📎 {filename ?? fileId}
</div>
) : (
<div
className="node-document-placeholder"
onClick={() => setShowPicker(true)}
style={{ cursor: "pointer" }}
>
Click to select file
</div>
)}
<div className="node-document-controls">
<input
type="text"
placeholder="Title"
value={title || ""}
onChange={(e) => updateAttributes({ title: e.target.value })}
className="node-document-title"
/>
<button
className="node-image-pick-btn"
onClick={() => setShowPicker(true)}
>
Pick
</button>
</div>
{showPicker && (
<FilePicker
onSelect={handleSelect}
onClose={() => setShowPicker(false)}
/>
)}
</div>
</NodeViewWrapper>
<BlockInsert editor={editor} getPos={getPos} />
</>
)
}
export const DocumentBlock = Node.create({
name: "documentBlock",
group: "block",
atom: true,
draggable: true,
addAttributes() {
return {
id: { default: null },
fileId: { default: "" },
title: { default: "" },
}
},
parseHTML() {
return [{ tag: "div[data-document-block]" }]
},
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes(HTMLAttributes, { "data-document-block": "" })]
},
addNodeView() {
return ReactNodeViewRenderer(DocumentBlockView)
},
})
@@ -0,0 +1,48 @@
import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"
import type { NodeViewProps } from "@tiptap/react"
import { BlockActions } from "../components/BlockActions"
import { BlockInsert } from "../components/BlockInsert"
function EmbedBlockView({ node, editor, getPos }: NodeViewProps) {
return (
<>
<NodeViewWrapper className="node-view-block node-readonly-block">
<BlockActions editor={editor} getPos={getPos} />
<div className="node-view-content">
<div className="node-block-label">Embed</div>
<div className="node-block-info">
<code>{node.attrs.src}</code>
</div>
</div>
</NodeViewWrapper>
<BlockInsert editor={editor} getPos={getPos} />
</>
)
}
export const EmbedBlock = Node.create({
name: "embedBlock",
group: "block",
atom: true,
draggable: true,
addAttributes() {
return {
id: { default: null },
src: { default: "" },
}
},
parseHTML() {
return [{ tag: "div[data-embed-block]" }]
},
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes(HTMLAttributes, { "data-embed-block": "" })]
},
addNodeView() {
return ReactNodeViewRenderer(EmbedBlockView)
},
})
@@ -0,0 +1,116 @@
import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"
import type { NodeViewProps } from "@tiptap/react"
import { pb } from "../pb"
import type { PbImage } from "../types"
import { useState, useEffect } from "react"
import { ImagePicker } from "../components/ImagePicker"
import { BlockActions } from "../components/BlockActions"
import { BlockInsert } from "../components/BlockInsert"
function ImageBlockView({ node, updateAttributes, editor, getPos }: NodeViewProps) {
const { imageId, wide, caption } = node.attrs
const [thumbnail, setThumbnail] = useState<string | null>(null)
const [showPicker, setShowPicker] = useState(false)
useEffect(() => {
if (!imageId) return
pb.collection("images").getOne<PbImage>(imageId).then((img) => {
const url = pb.files.getURL(img, img.file, { thumb: "300x200" })
setThumbnail(url)
}).catch(() => {
setThumbnail(null)
})
}, [imageId])
function handleSelect(newImageId: string) {
updateAttributes({ imageId: newImageId })
setShowPicker(false)
}
return (
<>
<NodeViewWrapper className="node-view-block node-image-block">
<BlockActions editor={editor} getPos={getPos} />
<div className="node-view-content">
<div className="node-block-label">Image</div>
{thumbnail ? (
<img
src={thumbnail}
alt={caption || "image"}
className="node-image-thumb"
onClick={() => setShowPicker(true)}
style={{ cursor: "pointer" }}
/>
) : (
<div
className="node-image-placeholder"
onClick={() => setShowPicker(true)}
style={{ cursor: "pointer" }}
>
{imageId ? "Loading..." : "Click to select image"}
</div>
)}
<div className="node-image-controls">
<label>
<input
type="checkbox"
checked={wide}
onChange={(e) => updateAttributes({ wide: e.target.checked })}
/>
Wide
</label>
<input
type="text"
placeholder="Caption"
value={caption || ""}
onChange={(e) => updateAttributes({ caption: e.target.value })}
className="node-image-caption"
/>
<button
className="node-image-pick-btn"
onClick={() => setShowPicker(true)}
>
Pick
</button>
</div>
{showPicker && (
<ImagePicker
onSelect={handleSelect}
onClose={() => setShowPicker(false)}
/>
)}
</div>
</NodeViewWrapper>
<BlockInsert editor={editor} getPos={getPos} />
</>
)
}
export const ImageBlock = Node.create({
name: "imageBlock",
group: "block",
atom: true,
draggable: true,
addAttributes() {
return {
id: { default: null },
imageId: { default: "" },
wide: { default: false },
caption: { default: "" },
}
},
parseHTML() {
return [{ tag: "div[data-image-block]" }]
},
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes(HTMLAttributes, { "data-image-block": "" })]
},
addNodeView() {
return ReactNodeViewRenderer(ImageBlockView)
},
})
@@ -0,0 +1,37 @@
import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from "@tiptap/react"
import type { NodeViewProps } from "@tiptap/react"
import { BlockActions } from "../components/BlockActions"
import { BlockInsert } from "../components/BlockInsert"
function TextBlockView({ editor, getPos }: NodeViewProps) {
return (
<>
<NodeViewWrapper className="node-view-block node-text-block">
<BlockActions editor={editor} getPos={getPos} />
<NodeViewContent className="node-view-content" />
</NodeViewWrapper>
<BlockInsert editor={editor} getPos={getPos} />
</>
)
}
export const TextBlock = Node.create({
name: "textBlock",
group: "block",
content: "(paragraph | heading | bulletList | orderedList | blockquote | codeBlock | horizontalRule)+",
draggable: true,
defining: true,
parseHTML() {
return [{ tag: "div[data-text-block]" }]
},
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes(HTMLAttributes, { "data-text-block": "" }), 0]
},
addNodeView() {
return ReactNodeViewRenderer(TextBlockView)
},
})
@@ -0,0 +1,7 @@
import { Node } from "@tiptap/core"
export const WriterDocument = Node.create({
name: "doc",
topNode: true,
content: "(textBlock | imageBlock | documentBlock | embedBlock | cardGridBlock)+",
})
@@ -0,0 +1,6 @@
export { WriterDocument } from "./WriterDocument"
export { TextBlock } from "./TextBlock"
export { ImageBlock } from "./ImageBlock"
export { DocumentBlock } from "./DocumentBlock"
export { EmbedBlock } from "./EmbedBlock"
export { CardGridBlock } from "./CardGridBlock"
+109
View File
@@ -0,0 +1,109 @@
.node-block-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #888;
margin-bottom: 4px;
}
.node-image-block > .node-view-content,
.node-readonly-block > .node-view-content,
.node-document-block > .node-view-content {
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 12px;
background: #fafafa;
}
.node-image-thumb {
max-width: 100%;
max-height: 200px;
border-radius: 4px;
object-fit: cover;
}
.node-image-placeholder {
height: 100px;
display: flex;
align-items: center;
justify-content: center;
background: #f0f0f0;
border-radius: 4px;
color: #888;
}
.node-image-controls {
display: flex;
gap: 12px;
align-items: center;
margin-top: 8px;
}
.node-image-caption {
flex: 1;
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.node-block-info {
display: flex;
gap: 8px;
align-items: center;
}
.node-block-info code {
font-size: 0.8rem;
color: #666;
}
.node-image-pick-btn {
padding: 2px 8px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 0.85rem;
}
.node-document-file {
padding: 8px;
background: #f0f0f0;
border-radius: 4px;
font-size: 0.9rem;
}
.node-document-placeholder {
padding: 8px;
background: #f0f0f0;
border-radius: 4px;
color: #888;
text-align: center;
}
.node-document-controls {
display: flex;
gap: 12px;
align-items: center;
margin-top: 8px;
}
.node-document-title {
flex: 1;
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.node-cardgrid-list {
margin: 4px 0 0;
padding-left: 20px;
font-size: 0.9rem;
}
.node-cardgrid-item {
color: #444;
line-height: 1.6;
}
+7
View File
@@ -0,0 +1,7 @@
import PocketBase from "pocketbase"
// Default to same origin; override via env for dev
const url = import.meta.env.VITE_PB_URL ?? "http://127.0.0.1:8090"
export const pb = new PocketBase(url)
pb.autoCancellation(false)
+90
View File
@@ -0,0 +1,90 @@
// -- PocketBase record base --
export type PbRecord = {
id: string
collectionId: string
collectionName: string
created: string
updated: string
}
// -- PocketBase collection types --
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
}
export type PbPage = PbRecord & {
title: string
slug: string
excerpt: string
date: string
cover: string
doc: DocV1
tags: string[]
published: boolean
draft: DocV1 | null
}
// -- DocV1 document format --
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[]
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "vite.config.ts"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
export default defineConfig({
plugins: [react()],
})
@@ -0,0 +1,4 @@
STRAPI_URL=https://hudozka.tmshv.com
POCKETBASE_URL=http://127.0.0.1:8090
POCKETBASE_EMAIL=admin@example.com
POCKETBASE_PASSWORD=changeme
+3
View File
@@ -0,0 +1,3 @@
node_modules
.env
.migrate-state.json
+18
View File
@@ -0,0 +1,18 @@
# migrate-to-pocketbase
One-shot Strapi → PocketBase migration.
## Usage
```
cp .env.example .env
# fill in credentials
npm install
npm run migrate
```
Requires Node 24+ (uses `--experimental-strip-types`).
Assumes all 5 PocketBase collections already exist
(see `docs/plans/completed/20260403-pocketbase-setup-guide.md`).
Assumes collections are empty — script does not upsert.
+535
View File
@@ -0,0 +1,535 @@
{
"name": "migrate-to-pocketbase",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "migrate-to-pocketbase",
"version": "1.0.0",
"dependencies": {
"nanoid": "^5.0.0",
"pocketbase": "^0.26.0",
"sharp": "^0.33.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.0.5"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.0.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.2.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "5.1.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz",
"integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/pocketbase": {
"version": "0.26.8",
"resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.8.tgz",
"integrity": "sha512-aQ/ewvS7ncvAE8wxoW10iAZu6ElgbeFpBhKPnCfvRovNzm2gW8u/sQNPGN6vNgVEagz44kK//C61oKjfa+7Low==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sharp": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.3",
"semver": "^7.6.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.33.5",
"@img/sharp-darwin-x64": "0.33.5",
"@img/sharp-libvips-darwin-arm64": "1.0.4",
"@img/sharp-libvips-darwin-x64": "1.0.4",
"@img/sharp-libvips-linux-arm": "1.0.5",
"@img/sharp-libvips-linux-arm64": "1.0.4",
"@img/sharp-libvips-linux-s390x": "1.0.4",
"@img/sharp-libvips-linux-x64": "1.0.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
"@img/sharp-linux-arm": "0.33.5",
"@img/sharp-linux-arm64": "0.33.5",
"@img/sharp-linux-s390x": "0.33.5",
"@img/sharp-linux-x64": "0.33.5",
"@img/sharp-linuxmusl-arm64": "0.33.5",
"@img/sharp-linuxmusl-x64": "0.33.5",
"@img/sharp-wasm32": "0.33.5",
"@img/sharp-win32-ia32": "0.33.5",
"@img/sharp-win32-x64": "0.33.5"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
}
}
}
@@ -0,0 +1,15 @@
{
"name": "migrate-to-pocketbase",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"migrate": "node --env-file=.env --experimental-strip-types --no-warnings=ExperimentalWarning src/index.ts",
"wipe": "node --env-file=.env --experimental-strip-types --no-warnings=ExperimentalWarning src/wipe.ts"
},
"dependencies": {
"nanoid": "^5.0.0",
"pocketbase": "^0.26.0",
"sharp": "^0.33.0"
}
}
@@ -0,0 +1,27 @@
import sharp from "sharp"
// Tiny jpeg base64 data URL for blur placeholder (matches src/remote/image.ts semantics).
export async function encodeBlur(buffer: Buffer): Promise<string | undefined> {
try {
const data = await sharp(buffer)
.resize(8, 8, { fit: "inside" })
.jpeg({ quality: 50 })
.toBuffer()
return `data:image/jpeg;base64,${data.toString("base64")}`
} catch (e) {
console.warn("blur failed:", (e as Error).message)
return undefined
}
}
export async function imageDims(buffer: Buffer): Promise<{ width: number, height: number } | undefined> {
try {
const meta = await sharp(buffer).metadata()
if (meta.width && meta.height) {
return { width: meta.width, height: meta.height }
}
} catch (e) {
console.warn("dims failed:", (e as Error).message)
}
return undefined
}
@@ -0,0 +1,19 @@
export async function download(url: string): Promise<Buffer> {
const res = await fetch(url)
if (!res.ok) {
throw new Error(`download ${url} -> ${res.status}`)
}
const buf = await res.arrayBuffer()
return Buffer.from(buf)
}
export function basenameFromUrl(url: string): string {
try {
const u = new URL(url)
const parts = u.pathname.split("/")
const last = parts[parts.length - 1]
return decodeURIComponent(last) || "file"
} catch {
return "file"
}
}
@@ -0,0 +1,5 @@
import { createHash } from "node:crypto"
export function sha256(buffer: Buffer): string {
return createHash("sha256").update(buffer).digest("hex")
}
+469
View File
@@ -0,0 +1,469 @@
import PocketBase from "pocketbase"
import { customAlphabet } from "nanoid"
const nanoid = customAlphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 10)
import { readFileSync, writeFileSync, existsSync } from "node:fs"
import type {
StrapiComponent,
StrapiMedia,
StrapiPageCard,
} from "./strapi.ts"
import {
fetchAllPages,
fetchHome,
fetchMenu,
fetchTags,
} from "./strapi.ts"
import { download, basenameFromUrl } from "./download.ts"
import { encodeBlur, imageDims } from "./blurhash.ts"
import { sha256 } from "./hash.ts"
const STRAPI_URL = process.env.STRAPI_URL ?? "https://hudozka.tmshv.com"
const POCKETBASE_URL = required("POCKETBASE_URL")
const POCKETBASE_EMAIL = required("POCKETBASE_EMAIL")
const POCKETBASE_PASSWORD = required("POCKETBASE_PASSWORD")
const STATE_FILE = new URL("../.migrate-state.json", import.meta.url).pathname
const PHASES = ["tags", "images", "files", "pages", "rewrite", "kv"] as const
type Phase = typeof PHASES[number]
function required(name: string): string {
const v = process.env[name]
if (!v) {
throw new Error(`missing env ${name}`)
}
return v
}
// Content blocks written to pages.doc JSON. During pages phase, image/file/page refs
// are strapi numeric ids; rewrite phase remaps them to PB string ids.
type Ref = string | number
type BlockBase = { id: string }
type BlockText = BlockBase & { type: "text", text: string }
type BlockImage = BlockBase & { type: "image", image: Ref, wide: boolean, caption: string }
type BlockDocument = BlockBase & { type: "document", file: Ref, title: string }
type BlockEmbed = BlockBase & { type: "embed", src: string }
type CardRef = { page: Ref, layout: "small" | "medium" | "big" }
type BlockCardGrid = BlockBase & { type: "card-grid", items: CardRef[] }
type Block = BlockText | BlockImage | BlockDocument | BlockEmbed | BlockCardGrid
type DocContent = { version: 1, blocks: Block[] }
type State = {
tagMap: Record<string, string>,
imageMap: Record<string, string>,
fileMap: Record<string, string>,
pageMap: Record<string, string>,
}
function loadState(): State {
if (existsSync(STATE_FILE)) {
const raw = readFileSync(STATE_FILE, "utf-8")
return JSON.parse(raw) as State
}
return { tagMap: {}, imageMap: {}, fileMap: {}, pageMap: {} }
}
function saveState(s: State) {
writeFileSync(STATE_FILE, JSON.stringify(s, null, 2))
}
function mapFromState(r: Record<string, string>): Map<number, string> {
const m = new Map<number, string>()
for (const [k, v] of Object.entries(r)) {
m.set(Number(k), v)
}
return m
}
function mapToState(m: Map<number, string>): Record<string, string> {
const r: Record<string, string> = {}
for (const [k, v] of m) {
r[String(k)] = v
}
return r
}
function parsePhases(args: string[]): Phase[] {
if (args.length === 0) return [...PHASES]
const unknown = args.filter(a => !PHASES.includes(a as Phase))
if (unknown.length > 0) {
console.error(`unknown phase(s): ${unknown.join(", ")}`)
console.error(`known phases: ${PHASES.join(", ")}`)
process.exit(1)
}
return args as Phase[]
}
async function main() {
const phases = parsePhases(process.argv.slice(2))
console.log(`phases: ${phases.join(", ")}`)
const pb = new PocketBase(POCKETBASE_URL)
await pb.collection("_superusers").authWithPassword(POCKETBASE_EMAIL, POCKETBASE_PASSWORD)
console.log("authed to pb")
const state = loadState()
const tagMap = mapFromState(state.tagMap)
const imageMap = mapFromState(state.imageMap)
const fileMap = mapFromState(state.fileMap)
const pageMap = mapFromState(state.pageMap)
console.log(`state: tags=${tagMap.size} images=${imageMap.size} files=${fileMap.size} pages=${pageMap.size}`)
console.log("\nfetching strapi...")
const [strapiPages, strapiTags, strapiHome, strapiMenu] = await Promise.all([
fetchAllPages(STRAPI_URL),
fetchTags(STRAPI_URL),
fetchHome(STRAPI_URL),
fetchMenu(STRAPI_URL),
])
console.log(`strapi: pages=${strapiPages.length} tags=${strapiTags.length} home.cards=${strapiHome.cards.length} menu.items=${strapiMenu.menu.items.length}`)
// phase: tags
if (phases.includes("tags")) {
console.log(`\n[tags] creating ${strapiTags.length}...`)
let ti = 0
for (const t of strapiTags) {
ti++
const payload = { label: t.name, slug: t.slug }
try {
const rec = await pb.collection("tags").create(payload)
tagMap.set(t.id, rec.id)
console.log(` [${ti}/${strapiTags.length}] ${t.slug} -> ${rec.id}`)
} catch (e) {
console.error(` [${ti}/${strapiTags.length}] tag ${t.id} (${t.slug}) failed:`)
printErr(e, payload)
throw e
}
}
state.tagMap = mapToState(tagMap)
saveState(state)
console.log(`[tags] done: ${tagMap.size}`)
}
// phase: images
if (phases.includes("images")) {
const imageMedia = new Map<number, StrapiMedia>()
for (const p of strapiPages) {
if (p.cover) {
imageMedia.set(p.cover.id, p.cover)
}
for (const c of p.content) {
if (c.__component === "hudozka.image" && c.media) {
imageMedia.set(c.media.id, c.media)
}
}
}
console.log(`\n[images] uploading ${imageMedia.size}...`)
const imageHashIndex = await buildHashIndex(pb, "images")
console.log(` sha256 index: ${imageHashIndex.size} existing records`)
let ii = 0
for (const [sid, m] of imageMedia) {
ii++
if (imageMap.has(sid)) {
console.log(` [${ii}/${imageMedia.size}] ${m.name} — skip (already uploaded)`)
continue
}
try {
const buf = await download(m.url)
const hash = sha256(buf)
const existingId = imageHashIndex.get(hash)
if (existingId) {
imageMap.set(sid, existingId)
console.log(` [${ii}/${imageMedia.size}] ${m.name} — dedup -> ${existingId}`)
continue
}
const filename = m.name || basenameFromUrl(m.url)
const dims = (m.width && m.height)
? { width: m.width, height: m.height }
: (await imageDims(buf) ?? { width: 0, height: 0 })
const blur = await encodeBlur(buf)
const form = new FormData()
form.append("file", new Blob([buf], { type: m.mime || "application/octet-stream" }), filename)
if (m.alternativeText) form.append("alt", m.alternativeText)
if (m.caption) form.append("caption", m.caption)
form.append("width", String(dims.width))
form.append("height", String(dims.height))
if (blur) {
form.append("blurhash", blur)
} else {
console.warn(` [blur] encode failed or empty`)
}
form.append("filename", filename)
form.append("sha256", hash)
const rec = await pb.collection("images").create(form)
imageMap.set(sid, rec.id)
imageHashIndex.set(hash, rec.id)
console.log(` [${ii}/${imageMedia.size}] ${filename} (${dims.width}x${dims.height}, ${Math.round(buf.byteLength / 1024)}KB, blur=${blur ? blur.length + "ch" : "none"}) -> ${rec.id}`)
} catch (e) {
console.error(` [${ii}/${imageMedia.size}] image ${sid} failed:`)
console.error(` strapi media:`, JSON.stringify({
id: m.id,
name: m.name,
mime: m.mime,
ext: m.ext,
sizeKB: m.size,
width: m.width,
height: m.height,
url: m.url,
}, null, 2))
printErr(e)
}
}
state.imageMap = mapToState(imageMap)
saveState(state)
console.log(`[images] done: ${imageMap.size}/${imageMedia.size}`)
}
// phase: files
if (phases.includes("files")) {
const docMedia = new Map<number, { id: number, url: string, name: string, mime: string, size: number }>()
for (const p of strapiPages) {
for (const c of p.content) {
if (c.__component === "hudozka.document" && c.media) {
docMedia.set(c.media.id, {
id: c.media.id,
url: c.media.url,
name: c.media.name,
mime: c.media.mime,
size: c.media.size,
})
}
}
}
console.log(`\n[files] uploading ${docMedia.size}...`)
const fileHashIndex = await buildHashIndex(pb, "files")
console.log(` sha256 index: ${fileHashIndex.size} existing records`)
let fi = 0
for (const [sid, m] of docMedia) {
fi++
if (fileMap.has(sid)) {
console.log(` [${fi}/${docMedia.size}] ${m.name} — skip (already uploaded)`)
continue
}
try {
const buf = await download(m.url)
const hash = sha256(buf)
const existingId = fileHashIndex.get(hash)
if (existingId) {
fileMap.set(sid, existingId)
console.log(` [${fi}/${docMedia.size}] ${m.name} — dedup -> ${existingId}`)
continue
}
const filename = m.name || basenameFromUrl(m.url)
const bytes = buf.byteLength
const form = new FormData()
form.append("file", new Blob([buf], { type: m.mime || "application/octet-stream" }), filename)
form.append("title", "")
if (m.mime) form.append("mime", m.mime)
form.append("size", String(bytes))
form.append("filename", filename)
form.append("sha256", hash)
const rec = await pb.collection("files").create(form)
fileMap.set(sid, rec.id)
fileHashIndex.set(hash, rec.id)
console.log(` [${fi}/${docMedia.size}] ${filename} (${Math.round(bytes / 1024)}KB) -> ${rec.id}`)
} catch (e) {
console.error(` [${fi}/${docMedia.size}] file ${sid} failed:`)
printErr(e)
}
}
state.fileMap = mapToState(fileMap)
saveState(state)
console.log(`[files] done: ${fileMap.size}/${docMedia.size}`)
}
// phase: pages
if (phases.includes("pages")) {
console.log(`\n[pages] creating ${strapiPages.length}...`)
let pi = 0
for (const p of strapiPages) {
pi++
if (pageMap.has(p.id)) {
console.log(` [${pi}/${strapiPages.length}] ${p.slug} — skip (already created)`)
continue
}
const blocks = p.content.map(toBlock).filter((b): b is Block => b !== null)
const doc: DocContent = { version: 1, blocks }
const coverId = p.cover ? imageMap.get(p.cover.id) : undefined
const tagIds = p.tags.map(t => tagMap.get(t.id)).filter((x): x is string => !!x)
const data: Record<string, unknown> = {
title: p.title,
slug: p.slug,
excerpt: p.excerpt ?? "",
doc,
tags: tagIds,
draft: false,
}
if (p.date) data.date = p.date
if (coverId) data.cover = coverId
try {
const rec = await pb.collection("pages").create(data)
pageMap.set(p.id, rec.id)
console.log(` [${pi}/${strapiPages.length}] ${p.slug} -> ${rec.id} (${blocks.length} blocks, ${tagIds.length} tags${coverId ? ", cover" : ""})`)
} catch (e) {
console.error(` [${pi}/${strapiPages.length}] page ${p.id} (${p.slug}) failed:`)
printErr(e)
}
}
state.pageMap = mapToState(pageMap)
saveState(state)
console.log(`[pages] done: ${pageMap.size}/${strapiPages.length}`)
}
// phase: rewrite. Remaps strapi numeric refs -> PB string ids:
// - doc blocks (image/file/card-grid)
// - tags relation
// Drops blocks with unresolved refs. Preserves nanoid block ids.
if (phases.includes("rewrite")) {
console.log(`\n[rewrite] remapping refs...`)
let rewritten = 0
let dropped = 0
for (const p of strapiPages) {
const pbId = pageMap.get(p.id)
if (!pbId) continue
try {
const rec = await pb.collection("pages").getOne(pbId, { fields: "doc" })
const doc = (rec as unknown as { doc: DocContent }).doc
const before = doc.blocks.length
doc.blocks = doc.blocks.map(b => remapBlock(b, imageMap, fileMap, pageMap)).filter((b): b is Block => b !== null)
dropped += before - doc.blocks.length
const tagIds = p.tags.map(t => tagMap.get(t.id)).filter((x): x is string => !!x)
await pb.collection("pages").update(pbId, { doc, tags: tagIds })
rewritten++
console.log(` rewrote ${p.slug} (${doc.blocks.length}/${before} blocks, ${tagIds.length} tags)`)
} catch (e) {
console.error(` rewrite page ${p.id} failed:`)
printErr(e)
}
}
console.log(`[rewrite] done: ${rewritten} pages, ${dropped} blocks dropped (unresolved refs)`)
}
// phase: kv (home + menu)
if (phases.includes("kv")) {
console.log(`\n[kv] creating home + menu...`)
const homeCards = strapiHome.cards
.map(c => ({ page: pageMap.get(c.page.id), layout: c.layout }))
.filter((c): c is { page: string, layout: "small" | "medium" | "big" } => !!c.page)
await pb.collection("kv").create({
key: "home",
data: { cards: homeCards },
})
console.log(`kv home: ${homeCards.length} cards`)
const menuItems = strapiMenu.menu.items
.map(c => ({ page: pageMap.get(c.page.id) }))
.filter((c): c is { page: string } => !!c.page)
await pb.collection("kv").create({
key: "menu",
data: {
homeLabel: strapiMenu.homeLabel,
items: menuItems,
},
})
console.log(`kv menu: homeLabel="${strapiMenu.homeLabel}" items=${menuItems.length}`)
}
console.log("\ndone.")
}
// Emit blocks with strapi numeric ids for media/page refs.
// The rewrite phase remaps them to PB string ids.
function toBlock(c: StrapiComponent): Block | null {
const id = nanoid()
switch (c.__component) {
case "hudozka.text":
return { id, type: "text", text: c.text }
case "hudozka.image":
if (!c.media) return null
return { id, type: "image", image: c.media.id, wide: !!c.wide, caption: c.caption ?? "" }
case "hudozka.document":
if (!c.media) return null
return { id, type: "document", file: c.media.id, title: c.title ?? "" }
case "hudozka.embed":
return { id, type: "embed", src: c.src }
case "hudozka.card-grid": {
const items: CardRef[] = (c.items ?? [])
.filter((it: StrapiPageCard) => it && it.page)
.map((it: StrapiPageCard) => ({ page: it.page.id, layout: it.layout }))
return { id, type: "card-grid", items }
}
default:
return null
}
}
// Remap strapi numeric refs -> PB string ids. Returns null if ref unresolved.
// If ref is already a string, pass through (already remapped).
function remapBlock(
b: Block,
imageMap: Map<number, string>,
fileMap: Map<number, string>,
pageMap: Map<number, string>,
): Block | null {
switch (b.type) {
case "image": {
if (typeof b.image === "string") return b
const ref = imageMap.get(b.image)
if (!ref) return null
return { ...b, image: ref }
}
case "document": {
if (typeof b.file === "string") return b
const ref = fileMap.get(b.file)
if (!ref) return null
return { ...b, file: ref }
}
case "card-grid": {
const items = b.items
.map(it => {
if (typeof it.page === "string") return it
const mapped = pageMap.get(it.page)
return mapped ? { page: mapped, layout: it.layout } : null
})
.filter((x): x is CardRef => x !== null)
return { ...b, items }
}
default:
return b
}
}
async function buildHashIndex(pb: PocketBase, collection: string): Promise<Map<string, string>> {
const idx = new Map<string, string>()
const recs = await pb.collection(collection).getFullList({ fields: "id,sha256" })
for (const r of recs) {
const h = (r as unknown as { sha256?: string }).sha256
if (h) idx.set(h, r.id)
}
return idx
}
function printErr(e: unknown, input?: unknown) {
const err = e as { message?: string, url?: string, status?: number, response?: { data?: unknown } }
console.error("ERROR:", err.message)
if (err.url) console.error(" url:", err.url, "status:", err.status)
if (input !== undefined) {
console.error(" input:", JSON.stringify(input, null, 2))
}
if (err.response?.data !== undefined) {
console.error(" response:", JSON.stringify(err.response.data, null, 2))
}
}
main().catch(e => {
printErr(e)
process.exit(1)
})
+136
View File
@@ -0,0 +1,136 @@
export type StrapiMediaObject = {
url: string,
name: string,
hash: string,
ext: string,
mime: string,
width: number,
height: number,
size: number,
}
export type StrapiMedia = StrapiMediaObject & {
id: number,
alternativeText: string | null,
caption: string | null,
formats: null | {
thumbnail: StrapiMediaObject,
large: StrapiMediaObject,
medium: StrapiMediaObject,
small: StrapiMediaObject,
},
}
export type StrapiComponentText = {
__component: "hudozka.text",
id: number,
text: string,
}
export type StrapiComponentImage = {
__component: "hudozka.image",
id: number,
wide: boolean,
caption: string,
media: StrapiMedia,
}
export type StrapiComponentDocument = {
__component: "hudozka.document",
id: number,
title: string,
media: StrapiMediaObject & { id: number },
}
export type StrapiComponentEmbed = {
__component: "hudozka.embed",
id: number,
src: string,
}
export type StrapiComponentCardGrid = {
__component: "hudozka.card-grid",
id: number,
items: StrapiPageCard[],
}
export type StrapiComponent =
| StrapiComponentText
| StrapiComponentImage
| StrapiComponentDocument
| StrapiComponentEmbed
| StrapiComponentCardGrid
export type StrapiTag = {
id: number,
slug: string,
name: string,
}
export type StrapiPage = {
id: number,
title: string,
excerpt: string,
slug: string,
date: string | null,
cover?: StrapiMedia,
published_at: string,
created_at: string,
updated_at: string,
content: StrapiComponent[],
tags: StrapiTag[],
}
export type StrapiPageCard = {
__component: "hudozka.page-card",
id: number,
page: StrapiPage,
layout: "small" | "medium" | "big",
}
export type StrapiHome = {
id: number,
title: string,
cards: StrapiPageCard[],
}
export type StrapiMenu = {
id: number,
homeLabel: string,
menu: StrapiComponentCardGrid,
}
async function get<T>(base: string, path: string): Promise<T> {
const res = await fetch(`${base}${path}`)
if (!res.ok) {
throw new Error(`GET ${path} -> ${res.status}`)
}
return await res.json() as T
}
export async function fetchAllPages(base: string): Promise<StrapiPage[]> {
const limit = 100
let start = 0
const all: StrapiPage[] = []
while (true) {
const batch = await get<StrapiPage[]>(base, `/pages?_limit=${limit}&_start=${start}`)
if (batch.length === 0) {
break
}
all.push(...batch)
start += limit
}
return all
}
export async function fetchTags(base: string): Promise<StrapiTag[]> {
return await get<StrapiTag[]>(base, "/tags")
}
export async function fetchHome(base: string): Promise<StrapiHome> {
return await get<StrapiHome>(base, "/home")
}
export async function fetchMenu(base: string): Promise<StrapiMenu> {
return await get<StrapiMenu>(base, "/menu")
}
+90
View File
@@ -0,0 +1,90 @@
import PocketBase from "pocketbase"
import { existsSync, readFileSync, writeFileSync } from "node:fs"
const STATE_FILE = new URL("../.migrate-state.json", import.meta.url).pathname
const POCKETBASE_URL = required("POCKETBASE_URL")
const POCKETBASE_EMAIL = required("POCKETBASE_EMAIL")
const POCKETBASE_PASSWORD = required("POCKETBASE_PASSWORD")
const KNOWN = ["tags", "images", "files", "pages", "kv"] as const
function required(name: string): string {
const v = process.env[name]
if (!v) {
throw new Error(`missing env ${name}`)
}
return v
}
async function wipe(pb: PocketBase, collection: string) {
console.log(`\n[${collection}] wiping...`)
let total = 0
while (true) {
const batch = await pb.collection(collection).getList(1, 200, { fields: "id" })
if (batch.items.length === 0) {
break
}
for (const rec of batch.items) {
await pb.collection(collection).delete(rec.id)
total++
if (total % 20 === 0) {
console.log(` [${collection}] deleted ${total}...`)
}
}
}
console.log(`[${collection}] done: ${total} deleted`)
}
async function main() {
const args = process.argv.slice(2)
const targets = args.length > 0 ? args : [...KNOWN]
const unknown = targets.filter(t => !KNOWN.includes(t as typeof KNOWN[number]))
if (unknown.length > 0) {
console.error(`unknown collections: ${unknown.join(", ")}`)
console.error(`known: ${KNOWN.join(", ")}`)
process.exit(1)
}
const pb = new PocketBase(POCKETBASE_URL)
await pb.collection("_superusers").authWithPassword(POCKETBASE_EMAIL, POCKETBASE_PASSWORD)
console.log("authed to pb")
console.log(`wiping: ${targets.join(", ")}`)
// Wipe pages first (references images/files/tags), then kv, then images/files/tags.
const order = ["pages", "kv", "images", "files", "tags"]
for (const c of order) {
if (targets.includes(c)) {
await wipe(pb, c)
}
}
// Sync state file: drop map for each wiped collection
if (existsSync(STATE_FILE)) {
const state = JSON.parse(readFileSync(STATE_FILE, "utf-8")) as Record<string, Record<string, string>>
const keyFor: Record<string, string> = {
tags: "tagMap",
images: "imageMap",
files: "fileMap",
pages: "pageMap",
}
for (const t of targets) {
const key = keyFor[t]
if (key) state[key] = {}
}
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
console.log("\nstate file updated")
}
console.log("\ndone.")
}
main().catch(e => {
const err = e as { message?: string, url?: string, status?: number, response?: { data?: unknown } }
console.error("ERROR:", err.message)
if (err.url) console.error(" url:", err.url, "status:", err.status)
if (err.response?.data !== undefined) {
console.error(" response:", JSON.stringify(err.response.data, null, 2))
}
process.exit(1)
})
+1
View File
@@ -7,6 +7,7 @@ const config: NextConfig = {
remotePatterns: [
{ hostname: "hudozkacdn.tmshv.com" },
{ hostname: "images.weserv.nl" },
{ hostname: "127.0.0.1" },
],
},
}
+7
View File
@@ -16,6 +16,7 @@
"date-fns": "^4.0.0",
"markdown-it": "^14.1.1",
"next": "^16.1.7",
"pocketbase": "^0.25.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.5.0",
@@ -6461,6 +6462,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pocketbase": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.25.2.tgz",
"integrity": "sha512-ONZl1+qHJMnhR2uacBlBJ90lm7njtL/zy0606+1ROfK9hSL4LRBRc8r89rMcNRzPzRqCNyoFTh2Qg/lYXdEC1w==",
"license": "MIT"
},
"node_modules/possible-typed-array-names": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
+1
View File
@@ -26,6 +26,7 @@
"date-fns": "^4.0.0",
"markdown-it": "^14.1.1",
"next": "^16.1.7",
"pocketbase": "^0.25.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.5.0",
+24206
View File
File diff suppressed because it is too large. Load diff
+63
View File
@@ -0,0 +1,63 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2324736937",
"max": 0,
"min": 0,
"name": "key",
"pattern": "^[a-z0-9_]+$",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "json2918445923",
"maxSize": 0,
"name": "data",
"presentable": false,
"required": true,
"system": false,
"type": "json"
}
],
"id": "pbc_1481978323",
"indexes": [
"CREATE UNIQUE INDEX `idx_UnQZ16xLVI` ON `kv` (`key`)"
],
"listRule": null,
"name": "kv",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1481978323");
return app.delete(collection);
})
+67
View File
@@ -0,0 +1,67 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2560465762",
"max": 10,
"min": 1,
"name": "slug",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text245846248",
"max": 0,
"min": 1,
"name": "label",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
}
],
"id": "pbc_1219621782",
"indexes": [
"CREATE UNIQUE INDEX `idx_PxCK5CCdgj` ON `tags` (`slug`)"
],
"listRule": null,
"name": "tags",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1219621782");
return app.delete(collection);
})
+142
View File
@@ -0,0 +1,142 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"hidden": false,
"id": "file2359244304",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [],
"name": "file",
"presentable": false,
"protected": false,
"required": true,
"system": false,
"thumbs": [],
"type": "file"
},
{
"hidden": false,
"id": "file3112513328",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [
"image/jpeg",
"image/webp"
],
"name": "preview",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1116045392",
"max": 0,
"min": 0,
"name": "mime",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text724990059",
"max": 0,
"min": 0,
"name": "title",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1007413605",
"max": 0,
"min": 0,
"name": "filename",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number4156564586",
"max": null,
"min": null,
"name": "size",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_3446931122",
"indexes": [],
"listRule": null,
"name": "files",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3446931122");
return app.delete(collection);
})
+171
View File
@@ -0,0 +1,171 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"hidden": false,
"id": "file2359244304",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [
"image/jpeg",
"image/png",
"image/webp"
],
"name": "file",
"presentable": false,
"protected": false,
"required": true,
"system": false,
"thumbs": [
"100x100",
"600x0",
"1600x0"
],
"type": "file"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text678750603",
"max": 0,
"min": 0,
"name": "alt",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text4135340389",
"max": 0,
"min": 0,
"name": "caption",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1007413605",
"max": 0,
"min": 0,
"name": "filename",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number4156564586",
"max": null,
"min": null,
"name": "size",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number2350531887",
"max": null,
"min": null,
"name": "width",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number4115522831",
"max": null,
"min": null,
"name": "height",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2482752583",
"max": 0,
"min": 0,
"name": "blurhash",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_3607937828",
"indexes": [],
"listRule": null,
"name": "images",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3607937828");
return app.delete(collection);
})
+148
View File
@@ -0,0 +1,148 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2560465762",
"max": 0,
"min": 1,
"name": "slug",
"pattern": "^[a-z0-9-]+$",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text724990059",
"max": 0,
"min": 0,
"name": "title",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1591429585",
"max": 0,
"min": 0,
"name": "excerpt",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "pbc_3607937828",
"hidden": false,
"id": "relation2366146245",
"maxSelect": 1,
"minSelect": 0,
"name": "cover",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "date2862495610",
"max": "",
"min": "",
"name": "date",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"cascadeDelete": false,
"collectionId": "pbc_1219621782",
"hidden": false,
"id": "relation1874629670",
"maxSelect": 999,
"minSelect": 0,
"name": "tags",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "json4274335913",
"maxSize": 0,
"name": "content",
"presentable": false,
"required": true,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_1125843985",
"indexes": [
"CREATE UNIQUE INDEX `idx_yj534DmAHs` ON `posts` (`slug`)"
],
"listRule": null,
"name": "posts",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1125843985");
return app.delete(collection);
})
+22
View File
@@ -0,0 +1,22 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1481978323")
// update collection data
unmarshal({
"listRule": "",
"viewRule": ""
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1481978323")
// update collection data
unmarshal({
"listRule": null,
"viewRule": null
}, collection)
return app.save(collection)
})
+22
View File
@@ -0,0 +1,22 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3446931122")
// update collection data
unmarshal({
"listRule": "",
"viewRule": ""
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3446931122")
// update collection data
unmarshal({
"listRule": null,
"viewRule": null
}, collection)
return app.save(collection)
})
@@ -0,0 +1,22 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3607937828")
// update collection data
unmarshal({
"listRule": "",
"viewRule": ""
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3607937828")
// update collection data
unmarshal({
"listRule": null,
"viewRule": null
}, collection)
return app.save(collection)
})
+22
View File
@@ -0,0 +1,22 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1125843985")
// update collection data
unmarshal({
"listRule": "",
"viewRule": ""
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1125843985")
// update collection data
unmarshal({
"listRule": null,
"viewRule": null
}, collection)
return app.save(collection)
})
+22
View File
@@ -0,0 +1,22 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1219621782")
// update collection data
unmarshal({
"listRule": "",
"viewRule": ""
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1219621782")
// update collection data
unmarshal({
"listRule": null,
"viewRule": null
}, collection)
return app.save(collection)
})
+24
View File
@@ -0,0 +1,24 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1125843985")
// add field
collection.fields.addAt(8, new Field({
"hidden": false,
"id": "bool1182570132",
"name": "draft",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1125843985")
// remove field
collection.fields.removeById("bool1182570132")
return app.save(collection)
})
+42
View File
@@ -0,0 +1,42 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1219621782")
// update field
collection.fields.addAt(1, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text2560465762",
"max": 15,
"min": 1,
"name": "slug",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1219621782")
// update field
collection.fields.addAt(1, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text2560465762",
"max": 10,
"min": 1,
"name": "slug",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
}))
return app.save(collection)
})
+42
View File
@@ -0,0 +1,42 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1219621782")
// update field
collection.fields.addAt(1, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text2560465762",
"max": 15,
"min": 1,
"name": "slug",
"pattern": "^[a-z0-9-]+$",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1219621782")
// update field
collection.fields.addAt(1, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text2560465762",
"max": 15,
"min": 1,
"name": "slug",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
}))
return app.save(collection)
})
+42
View File
@@ -0,0 +1,42 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1219621782")
// update field
collection.fields.addAt(1, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text2560465762",
"max": 20,
"min": 1,
"name": "slug",
"pattern": "^[a-z0-9-]+$",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1219621782")
// update field
collection.fields.addAt(1, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text2560465762",
"max": 15,
"min": 1,
"name": "slug",
"pattern": "^[a-z0-9-]+$",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
}))
return app.save(collection)
})
+42
View File
@@ -0,0 +1,42 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3446931122")
// update field
collection.fields.addAt(1, new Field({
"hidden": false,
"id": "file2359244304",
"maxSelect": 1,
"maxSize": 104857600,
"mimeTypes": [],
"name": "file",
"presentable": false,
"protected": false,
"required": true,
"system": false,
"thumbs": [],
"type": "file"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3446931122")
// update field
collection.fields.addAt(1, new Field({
"hidden": false,
"id": "file2359244304",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [],
"name": "file",
"presentable": false,
"protected": false,
"required": true,
"system": false,
"thumbs": [],
"type": "file"
}))
return app.save(collection)
})
@@ -0,0 +1,27 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3607937828")
// remove field
collection.fields.removeById("number4156564586")
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3607937828")
// add field
collection.fields.addAt(5, new Field({
"hidden": false,
"id": "number4156564586",
"max": null,
"min": null,
"name": "size",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}))
return app.save(collection)
})
+26
View File
@@ -0,0 +1,26 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1125843985")
// update collection data
unmarshal({
"indexes": [
"CREATE UNIQUE INDEX `idx_yj534DmAHs` ON `pages` (`slug`)"
],
"name": "pages"
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1125843985")
// update collection data
unmarshal({
"indexes": [
"CREATE UNIQUE INDEX `idx_yj534DmAHs` ON `posts` (`slug`)"
],
"name": "posts"
}, collection)
return app.save(collection)
})
@@ -0,0 +1,58 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3607937828")
// update field
collection.fields.addAt(1, new Field({
"hidden": false,
"id": "file2359244304",
"maxSelect": 1,
"maxSize": 10485760,
"mimeTypes": [
"image/jpeg",
"image/png",
"image/webp"
],
"name": "file",
"presentable": false,
"protected": false,
"required": true,
"system": false,
"thumbs": [
"100x100",
"600x0",
"1600x0"
],
"type": "file"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3607937828")
// update field
collection.fields.addAt(1, new Field({
"hidden": false,
"id": "file2359244304",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [
"image/jpeg",
"image/png",
"image/webp"
],
"name": "file",
"presentable": false,
"protected": false,
"required": true,
"system": false,
"thumbs": [
"100x100",
"600x0",
"1600x0"
],
"type": "file"
}))
return app.save(collection)
})
@@ -0,0 +1,41 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3607937828")
// update collection data
unmarshal({
"indexes": [
"CREATE UNIQUE INDEX `idx_i7TNFDMzt9` ON `images` (`sha256`)"
]
}, collection)
// add field
collection.fields.addAt(8, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text1556616439",
"max": 0,
"min": 0,
"name": "sha256",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3607937828")
// update collection data
unmarshal({
"indexes": []
}, collection)
// remove field
collection.fields.removeById("text1556616439")
return app.save(collection)
})
+41
View File
@@ -0,0 +1,41 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3446931122")
// update collection data
unmarshal({
"indexes": [
"CREATE UNIQUE INDEX `idx_LXY1JfQhW9` ON `files` (`sha256`)"
]
}, collection)
// add field
collection.fields.addAt(7, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text1556616439",
"max": 0,
"min": 0,
"name": "sha256",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3446931122")
// update collection data
unmarshal({
"indexes": []
}, collection)
// remove field
collection.fields.removeById("text1556616439")
return app.save(collection)
})
+42
View File
@@ -0,0 +1,42 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1125843985")
// update field
collection.fields.addAt(1, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text2560465762",
"max": 0,
"min": 1,
"name": "slug",
"pattern": "^[a-z0-9-/]+$",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1125843985")
// update field
collection.fields.addAt(1, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text2560465762",
"max": 0,
"min": 1,
"name": "slug",
"pattern": "^[a-z0-9-]+$",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
}))
return app.save(collection)
})
+34
View File
@@ -0,0 +1,34 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1125843985")
// update field
collection.fields.addAt(7, new Field({
"hidden": false,
"id": "json4274335913",
"maxSize": 0,
"name": "doc",
"presentable": false,
"required": true,
"system": false,
"type": "json"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1125843985")
// update field
collection.fields.addAt(7, new Field({
"hidden": false,
"id": "json4274335913",
"maxSize": 0,
"name": "content",
"presentable": false,
"required": true,
"system": false,
"type": "json"
}))
return app.save(collection)
})
+47
View File
@@ -0,0 +1,47 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1125843985")
// add field
collection.fields.addAt(9, new Field({
"hidden": false,
"id": "json1182570132",
"maxSize": 0,
"name": "draft",
"presentable": false,
"required": false,
"system": false,
"type": "json"
}))
// update field
collection.fields.addAt(8, new Field({
"hidden": false,
"id": "bool1182570132",
"name": "published",
"presentable": false,
"required": true,
"system": false,
"type": "bool"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1125843985")
// remove field
collection.fields.removeById("json1182570132")
// update field
collection.fields.addAt(8, new Field({
"hidden": false,
"id": "bool1182570132",
"name": "draft",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
}))
return app.save(collection)
})
+69 -46
View File
@@ -1,66 +1,89 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { apiGet, getRecentPages } from "./api"
function mockFetchJson(data: unknown, ok = true) {
return vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok,
json: async () => data,
} as Response)
}
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("apiGet", () => {
it("should call factory with fetched data on success", async () => {
const data = { value: 1 }
mockFetchJson(data)
const factory = vi.fn((d: typeof data) => d.value)
const result = await apiGet(factory)("https://example.com/api", () => -1)
expect(factory).toHaveBeenCalledWith(data)
expect(result).toBe(1)
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 default on non-ok response", async () => {
mockFetchJson(null, false)
const factory = vi.fn()
const result = await apiGet(factory)("https://example.com/api", () => "default")
expect(factory).not.toHaveBeenCalled()
expect(result).toBe("default")
})
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(() => {})
it("should return default and log error on network failure", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network"))
const spy = vi.spyOn(console, "error").mockImplementation(() => {})
const factory = vi.fn()
const result = await apiGet(factory)("https://example.com/api", () => "fallback")
expect(factory).not.toHaveBeenCalled()
expect(result).toBe("fallback")
expect(spy).toHaveBeenCalled()
const result = await getUrls()
expect(result).toEqual([])
})
})
describe("getRecentPages", () => {
it("should fetch with default limit of 30", async () => {
const spy = mockFetchJson([])
await getRecentPages()
expect(spy).toHaveBeenCalledWith(
"https://hudozka.tmshv.com/pages?_sort=date:DESC&_limit=30",
)
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 fetch with custom limit", async () => {
const spy = mockFetchJson([])
await getRecentPages(10)
expect(spy).toHaveBeenCalledWith(
"https://hudozka.tmshv.com/pages?_sort=date:DESC&_limit=10",
)
})
it("should return empty array on failure", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("fail"))
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([])
})
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more