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:
1 parent
907298779c
commit
ed3f93de53
109 files changed
+40520
-427
No files matched your search
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
POCKETBASE_URL=http://127.0.0.1:8090
|
||||
@@ -22,3 +22,11 @@ dist
|
||||
.next
|
||||
tsconfig.tsbuildinfo
|
||||
.env
|
||||
|
||||
pb_data/*.db*
|
||||
pb_data/storage
|
||||
|
||||
# ralphex progress logs
|
||||
.ralphex/progress/
|
||||
|
||||
.superpowers/
|
||||
@@ -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"
|
||||
>
|
||||
</>
|
||||
</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"
|
||||
>
|
||||
“
|
||||
</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}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{showSplit && (
|
||||
<button
|
||||
className="block-action-btn block-action-split"
|
||||
title="Split block"
|
||||
onClick={handleSplit}
|
||||
>
|
||||
✂
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className="block-action-btn block-drag-handle"
|
||||
data-drag-handle
|
||||
draggable
|
||||
title="Drag to reorder"
|
||||
>
|
||||
⠇
|
||||
</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}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div
|
||||
className="block-action-btn block-drag-handle"
|
||||
data-drag-handle
|
||||
draggable
|
||||
title="Drag to reorder"
|
||||
>
|
||||
⠇
|
||||
</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}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div
|
||||
className="block-action-btn block-drag-handle"
|
||||
data-drag-handle
|
||||
draggable
|
||||
title="Drag to reorder"
|
||||
>
|
||||
⠇
|
||||
</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"
|
||||
>
|
||||
⠇
|
||||
</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}
|
||||
>
|
||||
×
|
||||
</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
|
||||
@@ -9,6 +9,8 @@ export default defineConfig([
|
||||
"build/**",
|
||||
"modules/**",
|
||||
"public/static/**",
|
||||
"pb_data/**",
|
||||
"pb_migrations/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
...nextVitals,
|
||||
|
||||
@@ -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?
|
||||
@@ -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
|
||||
@@ -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>
|
||||
Generated
+2437
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{showSplit && (
|
||||
<button
|
||||
className="block-action-btn block-action-split"
|
||||
title="Split block"
|
||||
onClick={handleSplit}
|
||||
>
|
||||
✂
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className="block-action-btn block-drag-handle"
|
||||
data-drag-handle
|
||||
draggable
|
||||
title="Drag to reorder"
|
||||
>
|
||||
⠇
|
||||
</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"
|
||||
>
|
||||
</>
|
||||
</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"
|
||||
>
|
||||
“
|
||||
</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,
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.env
|
||||
.migrate-state.json
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -7,6 +7,7 @@ const config: NextConfig = {
|
||||
remotePatterns: [
|
||||
{ hostname: "hudozkacdn.tmshv.com" },
|
||||
{ hostname: "images.weserv.nl" },
|
||||
{ hostname: "127.0.0.1" },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+24206
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
})
|
||||
@@ -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);
|
||||
})
|
||||
@@ -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);
|
||||
})
|
||||
@@ -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);
|
||||
})
|
||||
@@ -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);
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
@@ -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
Reference in new issue
Block a user