spec update
This commit is contained in:
1 parent
0665fa7ae7
commit
8d1c212a00
3 files changed
+289
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,289 @@
|
||||
# Monorepo — Design
|
||||
|
||||
**Date:** 2026-04-24
|
||||
**Status:** Approved, ready for plan
|
||||
|
||||
## Goal
|
||||
|
||||
Restructure the hudozka repo into an npm-workspaces monorepo. Consolidate the current Next.js app, the Vite-based `hudozka-writer`, and the Node-based `migrate-to-pocketbase` under `apps/`. Extract reusable UI primitives, hooks, and utilities into `packages/` scoped as `@hudozka/*`. Leave the PocketBase directory (`pb/`) untouched.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No behavioral changes to any app. This is a move + reorganization only.
|
||||
- No adoption of `@hudozka/ui` or `@hudozka/hooks` inside `hudozka-writer`. Writer is relocated, not rewired.
|
||||
- No Turborepo or other orchestration layer.
|
||||
- No switch to built-output packages. Packages are consumed as TypeScript source.
|
||||
- No publishing to npm. All packages are private workspace-only.
|
||||
|
||||
## Workspace layout
|
||||
|
||||
```
|
||||
hudozka/
|
||||
├─ apps/
|
||||
│ ├─ hudozka/ # Next.js app (was repo root)
|
||||
│ │ ├─ app/
|
||||
│ │ ├─ src/
|
||||
│ │ ├─ public/
|
||||
│ │ ├─ Dockerfile # moved here from repo root
|
||||
│ │ ├─ next.config.ts
|
||||
│ │ ├─ tsconfig.json
|
||||
│ │ └─ package.json
|
||||
│ ├─ hudozka-writer/ # was modules/hudozka-writer
|
||||
│ └─ migrate-to-pocketbase/ # was modules/migrate-to-pocketbase
|
||||
├─ packages/
|
||||
│ ├─ ui/ # @hudozka/ui
|
||||
│ ├─ hooks/ # @hudozka/hooks
|
||||
│ ├─ utils/ # @hudozka/utils
|
||||
│ └─ text/ # @hudozka/text
|
||||
├─ pb/ # PocketBase — untouched
|
||||
├─ docs/
|
||||
├─ .github/workflows/
|
||||
├─ package.json # workspaces only
|
||||
├─ tsconfig.base.json
|
||||
├─ eslint.config.js
|
||||
├─ vitest.config.ts # root web-app config stays; packages opt in
|
||||
└─ mise.toml
|
||||
```
|
||||
|
||||
App package names are **not** scoped (`hudozka`, `hudozka-writer`, `migrate-to-pocketbase`). Shared packages use the `@hudozka/*` scope.
|
||||
|
||||
## Package boundaries
|
||||
|
||||
All `packages/*` are consumed as TypeScript source. Each has:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "@hudozka/<name>",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./src/index.ts" }
|
||||
}
|
||||
```
|
||||
|
||||
No build step. Next, Vite, and `node --experimental-strip-types` handle TS source natively.
|
||||
|
||||
### `@hudozka/utils`
|
||||
- **Source:** `array.ts`, `string.ts`, `url.ts`, `math.ts`, `image.ts`, `file.ts`, `date.ts` (+ co-located `.test.ts`)
|
||||
- **Runtime deps:** `date-fns`
|
||||
- **Peer deps:** none
|
||||
- **Consumers:** `apps/hudozka`; `apps/migrate-to-pocketbase` if it uses any
|
||||
|
||||
### `@hudozka/text`
|
||||
- **Source:** `text.ts`
|
||||
- **Runtime deps:** `markdown-it`, `typograf`
|
||||
- **Dev deps:** `@types/markdown-it`
|
||||
- **Peer deps:** none
|
||||
- **Consumers:** `apps/hudozka`
|
||||
|
||||
### `@hudozka/hooks`
|
||||
- **Source:** `useToggle.ts`, `useLockBodyScroll.ts`, `useMediaQuery.ts`, `useMobile.ts`, `useReducedMotion.ts` (+ co-located `.test.ts`)
|
||||
- **Peer deps:** `react` (>=19)
|
||||
- **Consumers:** `@hudozka/ui`, `apps/hudozka`
|
||||
|
||||
Business-coupled hooks (`useDarkTheme`, `useAccessibility` — depend on the Valtio theme store) stay in `apps/hudozka/src/hooks/`.
|
||||
|
||||
### `@hudozka/ui`
|
||||
- **Components:** `Box`, `Button`, `Panel`, `Title`, `Overlay` (+ `Transition` subcomponent) with co-located `.test.tsx` and CSS Modules
|
||||
- **Runtime deps:** `classnames`
|
||||
- **Peer deps:** `react` (>=19), `react-dom` (>=19)
|
||||
- **Workspace deps:** `@hudozka/hooks`
|
||||
- **Consumers:** `apps/hudozka`
|
||||
|
||||
`Picture` (imports `next/image`, `next/link`, `useDarkTheme`) and `ThemeColor` (bridges Valtio theme store) **stay in `apps/hudozka/src/components/`** to avoid leaking Next and business state into shared packages.
|
||||
|
||||
### Dependency graph
|
||||
|
||||
```
|
||||
apps/hudozka ─┬─> @hudozka/ui ──> @hudozka/hooks
|
||||
├─> @hudozka/hooks
|
||||
├─> @hudozka/utils
|
||||
└─> @hudozka/text
|
||||
|
||||
apps/hudozka-writer (no workspace deps — relocated only)
|
||||
|
||||
apps/migrate-to-pocketbase ──> @hudozka/utils (optional; only if used)
|
||||
```
|
||||
|
||||
Acyclic. `ui → hooks` is the only inter-package edge.
|
||||
|
||||
## TypeScript
|
||||
|
||||
### Root `tsconfig.base.json`
|
||||
|
||||
Shared compiler options:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"allowJs": true,
|
||||
"incremental": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Per-workspace tsconfig
|
||||
|
||||
- `apps/hudozka/tsconfig.json` — extends base; adds `"plugins": [{ "name": "next" }]`; `"paths": { "@/*": ["src/*"] }`; includes `.next/types/**`.
|
||||
- `apps/hudozka-writer/tsconfig.json` — moved as-is; updated to extend base.
|
||||
- `apps/migrate-to-pocketbase/tsconfig.json` — new, minimal; extends base; Node-flavored `lib`.
|
||||
- `packages/*/tsconfig.json` — extends base; `include: ["src/**/*"]`; no path aliases.
|
||||
|
||||
### Resolution
|
||||
|
||||
npm workspaces symlinks `@hudozka/*` into `node_modules`. `"exports"` points to source. No project references needed.
|
||||
|
||||
### Path aliases
|
||||
|
||||
`@/*` → `src/*` stays **only** in `apps/hudozka`. Packages use short relative imports. This matches the existing rule in the project's CLAUDE.md.
|
||||
|
||||
## ESLint
|
||||
|
||||
Single flat `eslint.config.js` at repo root. Changes vs. current:
|
||||
|
||||
- Drop `modules/` exclusion; add `apps/*/dist`, `apps/*/.next`, `public/static/` to global ignores.
|
||||
- `eslint-config-next` scoped to `apps/hudozka/**` via a targeted config block.
|
||||
- Style rules unchanged: no semicolons, double quotes, 4-space indent, always-multiline trailing commas, `eol-last`.
|
||||
|
||||
## Vitest
|
||||
|
||||
- `apps/hudozka/vitest.config.ts` and `apps/hudozka/vitest.setup.tsx` — moved as-is.
|
||||
- `packages/ui/vitest.config.ts` and `packages/hooks/vitest.config.ts` — new, minimal; include jsdom/happy-dom setup for component tests.
|
||||
- `packages/utils/vitest.config.ts` — new, no DOM setup needed.
|
||||
- `packages/text/` — no tests currently; `test` script is a no-op runnable.
|
||||
|
||||
## Scripts
|
||||
|
||||
### Root `package.json`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"private": true,
|
||||
"workspaces": ["apps/*", "packages/*"],
|
||||
"scripts": {
|
||||
"dev": "npm run dev -w apps/hudozka",
|
||||
"build": "npm run build -w apps/hudozka",
|
||||
"start": "npm run start -w apps/hudozka",
|
||||
"lint": "eslint .",
|
||||
"format": "eslint --fix .",
|
||||
"test": "npm test --workspaces --if-present"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Root `dev`/`build`/`start` default to the web app (most common path).
|
||||
- Other apps run via `-w`: `npm run dev -w apps/hudozka-writer`, `npm run migrate -w apps/migrate-to-pocketbase`.
|
||||
- `--if-present` makes packages without a `test` script silent, not failing.
|
||||
|
||||
### Per-app/package scripts
|
||||
|
||||
- `apps/hudozka` — existing `dev`/`build`/`start`/`lint`/`format`/`test` relocated.
|
||||
- `apps/hudozka-writer` — existing `dev`/`build`/`preview`.
|
||||
- `apps/migrate-to-pocketbase` — existing `migrate`/`wipe`.
|
||||
- `packages/*` — `test: vitest run` where applicable.
|
||||
|
||||
## Dev-dep hoisting
|
||||
|
||||
Shared tooling (`typescript`, `eslint`, `vitest`, `@testing-library/*`, `happy-dom`) lives only in the root `devDependencies`. Workspaces resolve them from the hoisted root `node_modules`. Apps declare their runtime deps in their own `dependencies`.
|
||||
|
||||
## CI / CD
|
||||
|
||||
### `.github/workflows/test-app.yml`
|
||||
|
||||
- `npm ci` at root, then `npm run lint` + `npm test` + `npm run build`.
|
||||
- No per-workspace fan-out.
|
||||
|
||||
### `.github/workflows/build-app.yaml`
|
||||
|
||||
- `context:` stays `.` (repo root).
|
||||
- `file:` becomes `./apps/hudozka/Dockerfile`.
|
||||
|
||||
### Dockerfile (at `apps/hudozka/Dockerfile`)
|
||||
|
||||
Multi-stage, workspace-aware:
|
||||
|
||||
1. **Install stage** — copy root `package.json`, `package-lock.json`, every `apps/*/package.json` + `packages/*/package.json`. Run `npm ci`.
|
||||
2. **Build stage** — copy the rest of the monorepo. Run `npm run build -w apps/hudozka`.
|
||||
3. **Runner** — copy `apps/hudozka/.next/standalone`, `apps/hudozka/.next/static`, `apps/hudozka/public` into the final image. Standalone output already bundles what it needs from `packages/*`.
|
||||
|
||||
Runtime path change: `./.next/standalone` → `./apps/hudozka/.next/standalone`.
|
||||
|
||||
## Migration sequencing
|
||||
|
||||
Each phase ends with a verification gate (`npm install` + lint + test + build all pass).
|
||||
|
||||
### Phase 1 — Workspace skeleton
|
||||
- Add `workspaces: ["apps/*", "packages/*"]` to root `package.json`.
|
||||
- Create empty `apps/`, `packages/` directories.
|
||||
- Create `tsconfig.base.json`.
|
||||
- **Gate:** `npm install` succeeds.
|
||||
|
||||
### Phase 2 — Relocate writer and migrate apps
|
||||
- `git mv modules/hudozka-writer apps/hudozka-writer`
|
||||
- `git mv modules/migrate-to-pocketbase apps/migrate-to-pocketbase`
|
||||
- Update each app's tsconfig to extend `../../tsconfig.base.json` (writer already has a tsconfig; migrate gets a new minimal one).
|
||||
- Remove empty `modules/` directory.
|
||||
- **Gate:** `npm run dev -w apps/hudozka-writer` and `npm run migrate -w apps/migrate-to-pocketbase` work.
|
||||
|
||||
### Phase 3 — Relocate Next app to `apps/hudozka`
|
||||
- `git mv` all top-level Next artifacts into `apps/hudozka/`: `app/`, `src/`, `public/`, `next.config.ts`, `next-env.d.ts`, `tsconfig.json`, `tsconfig.tsbuildinfo`, `vitest.config.ts`, `vitest.setup.tsx`, `eslint.config.js` (temporary; consolidated in phase 6), `Dockerfile`.
|
||||
- Split root `package.json`: runtime deps + web-specific scripts move to `apps/hudozka/package.json`; root keeps only workspace config and shared dev deps.
|
||||
- Update `apps/hudozka/tsconfig.json` to extend the root base.
|
||||
- Update `.github/workflows/build-app.yaml` `file:` path.
|
||||
- Update Dockerfile: context remains repo root; `COPY` paths now target `apps/hudozka/*`; adjust standalone output path.
|
||||
- **Gate:** `dev`, `test`, `build` of `apps/hudozka` all pass.
|
||||
|
||||
### Phase 4 — Extract `@hudozka/utils`
|
||||
- Create `packages/utils/` with `package.json`, `tsconfig.json`, `src/`, `src/index.ts`.
|
||||
- `git mv` `array.ts`, `string.ts`, `url.ts`, `math.ts`, `image.ts`, `file.ts`, `date.ts` (+ tests) from `apps/hudozka/src/lib/` to `packages/utils/src/`.
|
||||
- Move `date-fns` from `apps/hudozka/package.json` to `packages/utils/package.json`.
|
||||
- Codemod imports in `apps/hudozka`: `@/lib/array` → `@hudozka/utils`, etc.
|
||||
- **Gate:** lint + test + build pass.
|
||||
|
||||
### Phase 5 — Extract `@hudozka/text`, `@hudozka/hooks`, `@hudozka/ui`
|
||||
|
||||
Order: text → hooks → ui (ui depends on hooks).
|
||||
|
||||
- **`@hudozka/text`:** move `text.ts`, `markdown-it`, `typograf`, `@types/markdown-it`. Codemod imports.
|
||||
- **`@hudozka/hooks`:** move `useToggle`, `useLockBodyScroll`, `useMediaQuery`, `useMobile`, `useReducedMotion` (+ tests). `useDarkTheme` and `useAccessibility` stay in `apps/hudozka`. Codemod imports.
|
||||
- **`@hudozka/ui`:** move `Box`, `Button`, `Panel`, `Title`, `Overlay` (+ tests, CSS Modules). `Picture` and `ThemeColor` stay in `apps/hudozka/src/components/`. Move `classnames` dep. Codemod imports; Overlay's hook imports become `@hudozka/hooks`.
|
||||
- **Gate after each package:** lint + test + build pass.
|
||||
|
||||
### Phase 6 — Consolidate ESLint & Vitest configs
|
||||
- Move `eslint.config.js` from `apps/hudozka/` back to repo root; scope Next config to `apps/hudozka/**`.
|
||||
- Add minimal `vitest.config.ts` (and setup where needed) to `packages/ui` and `packages/hooks`; `packages/utils` gets a non-DOM config.
|
||||
- Update root `package.json` scripts as above.
|
||||
- **Gate:** `npm run lint` at root lints everything; `npm test` runs all workspace tests.
|
||||
|
||||
### Phase 7 — Cleanup
|
||||
- Delete orphaned files (stray `.DS_Store`, empty directories).
|
||||
- Update root `README.md` with new layout (brief).
|
||||
- Update root `CLAUDE.md` (Commands, Key Directories, Architecture paths).
|
||||
- Spot-check `.github/workflows/*`, Dockerfile paths, `mise.toml`.
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
- **CSS Modules in `@hudozka/ui` consumed as source.** Next and Vite import `.module.css` natively; confirmed by current `src/ui/` usage. No build step needed. If a future consumer doesn't handle CSS, it must add that support itself.
|
||||
- **Standalone Next output path change.** The Dockerfile runner copies a different path now. Explicit step in phase 3; guarded by the build gate.
|
||||
- **Hoisted dev deps.** Different workspaces could theoretically disagree on major versions (e.g., `typescript`). Mitigation: all TS tooling lives only at the root; no workspace declares a conflicting version.
|
||||
- **`experimental-strip-types` in `migrate-to-pocketbase`.** Still works when the script imports `@hudozka/utils` as source — Node strips types across the import boundary. Verified by the fact that utils has no non-TS-native syntax.
|
||||
- **`eslint-config-next` globs.** Flat-config scoping must precisely target `apps/hudozka/**` to avoid leaking Next rules into packages. Phase 6 explicitly tests this by running `npm run lint` at root.
|
||||
|
||||
## Deferred
|
||||
|
||||
- Adopting `@hudozka/ui` or `@hudozka/hooks` in `apps/hudozka-writer`.
|
||||
- Turborepo (revisit if build times or CI duration warrant it).
|
||||
- Built-output packages + `.d.ts` emission (revisit if publishing or non-TS consumers appear).
|
||||
- Further extraction from `apps/hudozka/src/` (e.g., splitting `remote/`, breaking up `components/`) — out of scope for this migration.
|
||||
Reference in new issue
Block a user