From 39d970b5e5d2887cef43350c9d822947a13d98d2 Mon Sep 17 00:00:00 2001 From: George Treviranus Date: Wed, 23 Sep 2026 14:51:08 -0700 Subject: [PATCH 01/11] feat(bolt-cms-react-router): add WordPress-shaped CMS starter --- .prettierignore | 1 + bolt-cms-react-router/.bolt/prompt | 9 + .../.bolt/skills/bolt-cms-extend/SKILL.md | 95 + .../.bolt/skills/bolt-cms-theme/SKILL.md | 76 + .../.bolt/skills/bolt-cms/SKILL.md | 108 + bolt-cms-react-router/.env.example | 8 + bolt-cms-react-router/.gitignore | 24 + bolt-cms-react-router/README.md | 119 + bolt-cms-react-router/app/admin/api.ts | 489 ++ .../app/admin/bridge/client.ts | 177 + .../app/admin/bridge/protocol.ts | 167 + .../app/admin/components/MediaPicker.tsx | 164 + .../app/admin/components/TermsPanel.tsx | 218 + .../components/editor/RichTextEditor.tsx | 376 ++ .../admin/components/fields/FieldGroup.tsx | 56 + .../admin/components/fields/FieldInput.tsx | 481 ++ .../app/admin/components/ui.tsx | 650 +++ bolt-cms-react-router/app/admin/hooks.ts | 73 + bolt-cms-react-router/app/admin/labels.ts | 58 + bolt-cms-react-router/app/app.css | 23 + .../app/components/site/ArchiveHeader.tsx | 23 + .../app/components/site/Article.tsx | 71 + .../app/components/site/Comments.tsx | 206 + .../app/components/site/Pagination.tsx | 39 + .../app/components/site/PostCard.tsx | 79 + .../app/components/site/PostContent.tsx | 24 + .../app/components/site/PostList.tsx | 41 + .../app/components/site/PostMeta.tsx | 44 + .../app/components/site/SetupNotice.tsx | 28 + .../app/components/site/SiteFooter.tsx | 19 + .../app/components/site/SiteHeader.tsx | 129 + bolt-cms-react-router/app/lib/cms/format.ts | 47 + bolt-cms-react-router/app/lib/cms/index.ts | 7 + bolt-cms-react-router/app/lib/cms/media.ts | 42 + bolt-cms-react-router/app/lib/cms/queries.ts | 323 ++ .../app/lib/cms/resolve-path.ts | 46 + bolt-cms-react-router/app/lib/cms/seo.ts | 119 + bolt-cms-react-router/app/lib/cms/types.ts | 231 + bolt-cms-react-router/app/lib/cms/urls.ts | 36 + bolt-cms-react-router/app/lib/sanitize.ts | 54 + bolt-cms-react-router/app/lib/supabase.ts | 33 + bolt-cms-react-router/app/root.tsx | 75 + bolt-cms-react-router/app/routes.ts | 38 + .../app/routes/admin/appearance.tsx | 155 + .../app/routes/admin/comments.tsx | 221 + .../app/routes/admin/content-edit.tsx | 349 ++ .../app/routes/admin/content-list.tsx | 275 + .../app/routes/admin/dashboard.tsx | 275 + .../app/routes/admin/layout.tsx | 243 + .../app/routes/admin/media.tsx | 242 + .../app/routes/admin/menus.tsx | 577 +++ .../app/routes/admin/settings.tsx | 351 ++ .../app/routes/admin/terms.tsx | 205 + .../app/routes/site/author.tsx | 58 + .../app/routes/site/blog.tsx | 52 + .../app/routes/site/catch-all.tsx | 59 + .../app/routes/site/category.tsx | 57 + .../app/routes/site/home.tsx | 94 + .../app/routes/site/layout.tsx | 90 + .../app/routes/site/search.tsx | 56 + bolt-cms-react-router/app/routes/site/tag.tsx | 57 + .../app/styles/bolt-ds-tokens.css | 148 + bolt-cms-react-router/app/theme/themes.ts | 53 + .../app/theme/themes/classic.css | 40 + .../app/theme/themes/editorial.css | 49 + .../app/theme/themes/minimal.css | 55 + bolt-cms-react-router/app/theme/tokens.css | 381 ++ bolt-cms-react-router/bolt-cms.json | 3 + bolt-cms-react-router/dev/cms-dev-server.ts | 322 ++ bolt-cms-react-router/eslint.config.js | 38 + bolt-cms-react-router/package-lock.json | 4579 +++++++++++++++++ bolt-cms-react-router/package.json | 45 + .../public/wp-content/uploads/.gitkeep | 0 bolt-cms-react-router/react-router.config.ts | 13 + .../supabase/migrations/0001_cms_core.sql | 393 ++ .../supabase/migrations/0002_cms_seed.sql | 161 + bolt-cms-react-router/tsconfig.app.json | 35 + bolt-cms-react-router/tsconfig.json | 7 + bolt-cms-react-router/tsconfig.node.json | 21 + bolt-cms-react-router/vite.config.ts | 12 + test/bolt-cms-react-router.test.ts | 50 + 81 files changed, 14947 insertions(+) create mode 100644 bolt-cms-react-router/.bolt/prompt create mode 100644 bolt-cms-react-router/.bolt/skills/bolt-cms-extend/SKILL.md create mode 100644 bolt-cms-react-router/.bolt/skills/bolt-cms-theme/SKILL.md create mode 100644 bolt-cms-react-router/.bolt/skills/bolt-cms/SKILL.md create mode 100644 bolt-cms-react-router/.env.example create mode 100644 bolt-cms-react-router/.gitignore create mode 100644 bolt-cms-react-router/README.md create mode 100644 bolt-cms-react-router/app/admin/api.ts create mode 100644 bolt-cms-react-router/app/admin/bridge/client.ts create mode 100644 bolt-cms-react-router/app/admin/bridge/protocol.ts create mode 100644 bolt-cms-react-router/app/admin/components/MediaPicker.tsx create mode 100644 bolt-cms-react-router/app/admin/components/TermsPanel.tsx create mode 100644 bolt-cms-react-router/app/admin/components/editor/RichTextEditor.tsx create mode 100644 bolt-cms-react-router/app/admin/components/fields/FieldGroup.tsx create mode 100644 bolt-cms-react-router/app/admin/components/fields/FieldInput.tsx create mode 100644 bolt-cms-react-router/app/admin/components/ui.tsx create mode 100644 bolt-cms-react-router/app/admin/hooks.ts create mode 100644 bolt-cms-react-router/app/admin/labels.ts create mode 100644 bolt-cms-react-router/app/app.css create mode 100644 bolt-cms-react-router/app/components/site/ArchiveHeader.tsx create mode 100644 bolt-cms-react-router/app/components/site/Article.tsx create mode 100644 bolt-cms-react-router/app/components/site/Comments.tsx create mode 100644 bolt-cms-react-router/app/components/site/Pagination.tsx create mode 100644 bolt-cms-react-router/app/components/site/PostCard.tsx create mode 100644 bolt-cms-react-router/app/components/site/PostContent.tsx create mode 100644 bolt-cms-react-router/app/components/site/PostList.tsx create mode 100644 bolt-cms-react-router/app/components/site/PostMeta.tsx create mode 100644 bolt-cms-react-router/app/components/site/SetupNotice.tsx create mode 100644 bolt-cms-react-router/app/components/site/SiteFooter.tsx create mode 100644 bolt-cms-react-router/app/components/site/SiteHeader.tsx create mode 100644 bolt-cms-react-router/app/lib/cms/format.ts create mode 100644 bolt-cms-react-router/app/lib/cms/index.ts create mode 100644 bolt-cms-react-router/app/lib/cms/media.ts create mode 100644 bolt-cms-react-router/app/lib/cms/queries.ts create mode 100644 bolt-cms-react-router/app/lib/cms/resolve-path.ts create mode 100644 bolt-cms-react-router/app/lib/cms/seo.ts create mode 100644 bolt-cms-react-router/app/lib/cms/types.ts create mode 100644 bolt-cms-react-router/app/lib/cms/urls.ts create mode 100644 bolt-cms-react-router/app/lib/sanitize.ts create mode 100644 bolt-cms-react-router/app/lib/supabase.ts create mode 100644 bolt-cms-react-router/app/root.tsx create mode 100644 bolt-cms-react-router/app/routes.ts create mode 100644 bolt-cms-react-router/app/routes/admin/appearance.tsx create mode 100644 bolt-cms-react-router/app/routes/admin/comments.tsx create mode 100644 bolt-cms-react-router/app/routes/admin/content-edit.tsx create mode 100644 bolt-cms-react-router/app/routes/admin/content-list.tsx create mode 100644 bolt-cms-react-router/app/routes/admin/dashboard.tsx create mode 100644 bolt-cms-react-router/app/routes/admin/layout.tsx create mode 100644 bolt-cms-react-router/app/routes/admin/media.tsx create mode 100644 bolt-cms-react-router/app/routes/admin/menus.tsx create mode 100644 bolt-cms-react-router/app/routes/admin/settings.tsx create mode 100644 bolt-cms-react-router/app/routes/admin/terms.tsx create mode 100644 bolt-cms-react-router/app/routes/site/author.tsx create mode 100644 bolt-cms-react-router/app/routes/site/blog.tsx create mode 100644 bolt-cms-react-router/app/routes/site/catch-all.tsx create mode 100644 bolt-cms-react-router/app/routes/site/category.tsx create mode 100644 bolt-cms-react-router/app/routes/site/home.tsx create mode 100644 bolt-cms-react-router/app/routes/site/layout.tsx create mode 100644 bolt-cms-react-router/app/routes/site/search.tsx create mode 100644 bolt-cms-react-router/app/routes/site/tag.tsx create mode 100644 bolt-cms-react-router/app/styles/bolt-ds-tokens.css create mode 100644 bolt-cms-react-router/app/theme/themes.ts create mode 100644 bolt-cms-react-router/app/theme/themes/classic.css create mode 100644 bolt-cms-react-router/app/theme/themes/editorial.css create mode 100644 bolt-cms-react-router/app/theme/themes/minimal.css create mode 100644 bolt-cms-react-router/app/theme/tokens.css create mode 100644 bolt-cms-react-router/bolt-cms.json create mode 100644 bolt-cms-react-router/dev/cms-dev-server.ts create mode 100644 bolt-cms-react-router/eslint.config.js create mode 100644 bolt-cms-react-router/package-lock.json create mode 100644 bolt-cms-react-router/package.json create mode 100644 bolt-cms-react-router/public/wp-content/uploads/.gitkeep create mode 100644 bolt-cms-react-router/react-router.config.ts create mode 100644 bolt-cms-react-router/supabase/migrations/0001_cms_core.sql create mode 100644 bolt-cms-react-router/supabase/migrations/0002_cms_seed.sql create mode 100644 bolt-cms-react-router/tsconfig.app.json create mode 100644 bolt-cms-react-router/tsconfig.json create mode 100644 bolt-cms-react-router/tsconfig.node.json create mode 100644 bolt-cms-react-router/vite.config.ts create mode 100644 test/bolt-cms-react-router.test.ts diff --git a/.prettierignore b/.prettierignore index 203474bf..98dd884c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,6 +6,7 @@ **/.nuxt **/.output **/.svelte-kit +**/.react-router # Prettier formatting of HTML is questionable, let's skip it for now **/*.html diff --git a/bolt-cms-react-router/.bolt/prompt b/bolt-cms-react-router/.bolt/prompt new file mode 100644 index 00000000..7a0fae37 --- /dev/null +++ b/bolt-cms-react-router/.bolt/prompt @@ -0,0 +1,9 @@ +This is a Bolt CMS site: a React Router 8 (framework mode, `ssr: false`) website backed by Supabase, shaped like a WordPress site so imported WordPress content lands in it 1:1. Content lives in `cms_*` tables (see `supabase/migrations/`); the public site reads them with the anon key; the `/admin` UI (only visible inside Bolt) edits them through the host bridge. + +Import project modules with the `@/` path alias, which maps to `app/` (e.g. `@/lib/cms` == `app/lib/cms`), instead of deep relative paths. + +This template uses Vite 8 and React Router 8. `resolve.tsconfigPaths: true` is a valid built-in Vite option; preserve it for the `@/` alias and do not add `vite-tsconfig-paths`. Route modules receive `loaderData` (not `data`) in `meta()` and component props, and use `clientLoader` because the app is a static SPA. + +Follow `.bolt/skills/bolt-cms/SKILL.md` before touching content, schema, or the admin. Use `.bolt/skills/bolt-cms-theme/SKILL.md` for visual changes (edit theme tokens, do not restyle components ad hoc) and `.bolt/skills/bolt-cms-extend/SKILL.md` to add a custom post type, taxonomy, or field. + +Never commit `SUPABASE_SERVICE_ROLE_KEY` or ship it to the browser; only `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` are public. diff --git a/bolt-cms-react-router/.bolt/skills/bolt-cms-extend/SKILL.md b/bolt-cms-react-router/.bolt/skills/bolt-cms-extend/SKILL.md new file mode 100644 index 00000000..61f37b48 --- /dev/null +++ b/bolt-cms-react-router/.bolt/skills/bolt-cms-extend/SKILL.md @@ -0,0 +1,95 @@ +--- +name: bolt-cms-extend +description: >- + Extend a Bolt CMS site's content model: add a custom post type, + custom taxonomy, editable field, or a new table imported from a + WordPress plugin (e.g. WooCommerce products). Use whenever the user + wants the admin to edit something it does not yet show. +--- + +# Extending the content model + +The admin is data-driven. Types and taxonomies are discovered from the +data; editor forms are described by rows in `cms_fields`. Most +extensions are SQL only. + +## Custom post type (WordPress CPT) + +Custom types share `cms_posts`; the `type` column is free text. + +1. Insert or import rows with `type = 'portfolio'` (any slug-safe + name). The admin sidebar shows **Portfolio** as soon as one row + exists (via `cms_post_type_counts`). +2. Optional public route: add + `route('portfolio/:slug', 'routes/site/portfolio.tsx')` in + `app/routes.ts` and load with `getPostBySlug('portfolio', slug)`. + Without a route, the catch-all resolves `/:slug` for `post` only. +3. Optional taxonomy: see below. Taxonomies named + `_` (e.g. `portfolio_category`) automatically appear + in that type's editor; so do non-core taxonomies with no prefix. + +## Custom taxonomy + +Insert `cms_terms` rows with `taxonomy = 'genre'`. The admin lists it +under **Taxonomies**. Hierarchical behavior follows whether any term +has a `parent_id`; `category`-style checklists and `post_tag`-style +chips are chosen by `taxonomiesForType()` in `app/admin/labels.ts` +(edit it to force one mode). + +## Editable field + +The editor renders one input per `cms_fields` row for the table: + +```sql +insert into cms_fields (table_name, column_name, label, type, options, group_name, position) +values ('cms_posts', 'subtitle', 'Subtitle', 'string', '{}', 'main', 3); +``` + +Field `type` is one of: `string`, `text`, `richtext`, `number`, +`boolean`, `date`, `datetime`, `image`, `file`, `reference`, `array`, +`object`, `slug`, `select`, `json`. `group_name` is `main`, `sidebar`, +`seo`, or `advanced`. `position` orders within the group. + +`options` by type: + +- `string`/`text`: `{"required": true, "rows": 3, "readonly": true}` +- `slug`: `{"from": "title"}` — auto-fills from that column +- `richtext`: `{"json_column": "content_json"}` — where editor JSON is stored +- `select`: `{"choices": ["a", "b"]}` or `{"choices": [{"value": "a", "label": "A"}]}` +- `reference`: `{"table": "cms_authors", "label": "name", "same_type": true}` +- `image`/`file`: `{"value": "id"}` (default, stores `cms_media.id`) or `{"value": "url"}` +- `object`: `{"fields": [{"key": "title", "label": "Title", "type": "string"}, …]}` +- `array`: `{"item": {"type": "string"}}` + +Real columns need a migration: + +```sql +alter table cms_posts add column if not exists subtitle text; +``` + +For per-post extras without a column, use `cms_post_meta` +(`post_id, key, value jsonb`) and read it with +`getPostBySlug(...)?.meta`. + +## Plugin data (e.g. WooCommerce) + +Plugin tables get their own `cms_` table so imports stay deterministic: + +1. Migration: `create table cms_products (id bigint primary key, …)` + mirroring the plugin's REST shape (`wc/v3/products` → `name`, + `slug`, `price`, `images jsonb`, …). Enable RLS; add a public read + policy for the published/visible subset. +2. `cms_fields` rows for `cms_products` so the admin can edit it. The + generic admin list/edit screens work for any `cms_` table that has + `id`, `title`-like and `status`-like columns; otherwise add a route + under `app/routes/admin/` following `content-list.tsx`. +3. Public routes in `app/routes.ts` plus a query helper in + `app/lib/cms/`. + +## Rules + +- Tables must start with `cms_`; the host bridge rejects others. +- Always `enable row level security` and grant anon only what the + public site reads. +- Preserve WordPress ids when importing; sequences start at 1e9. +- New migrations go in a new numbered file; do not edit applied ones. diff --git a/bolt-cms-react-router/.bolt/skills/bolt-cms-theme/SKILL.md b/bolt-cms-react-router/.bolt/skills/bolt-cms-theme/SKILL.md new file mode 100644 index 00000000..a8eeaad1 --- /dev/null +++ b/bolt-cms-react-router/.bolt/skills/bolt-cms-theme/SKILL.md @@ -0,0 +1,76 @@ +--- +name: bolt-cms-theme +description: >- + Change how a Bolt CMS site looks: colors, fonts, spacing, header + style, or add a new selectable theme. Use for any visual request on + the public site (not the /admin UI). +--- + +# Theming a Bolt CMS site + +Visuals derive from a small token contract. Change tokens, not +components, and the whole site (including imported WordPress block +markup) follows. + +## The contract + +`app/theme/tokens.css` maps Tailwind utilities to `--theme-*` variables: + +``` +--theme-bg, --theme-bg-alt, --theme-fg, --theme-muted +--theme-accent, --theme-accent-fg, --theme-border +--theme-font-heading, --theme-font-body +--theme-radius, --theme-radius-lg +--theme-measure (reading width), --theme-wide (layout width) +--theme-heading-weight, --theme-heading-tracking +--theme-body-size, --theme-body-leading +``` + +Components use them through utilities such as `bg-site-bg`, +`text-site-fg`, `text-site-muted`, `border-site-border`, +`bg-site-accent`, `font-site-heading`, `rounded-site`, and the `.site-*` +classes in `tokens.css`. `.entry-content` in the same file styles +WordPress block classes (`wp-block-image`, `wp-block-quote`, +`alignwide`, `has-text-align-*`, …). + +## Themes + +Each theme is one file in `app/theme/themes/` scoped to +`html[data-site-theme='']` that sets the variables above and may +add a few structural overrides (e.g. the header layout). Existing +themes: `classic`, `editorial`, `minimal`. The active theme is +`cms_settings.theme`, chosen in **Admin → Appearance**, and applied by +`app/routes/site/layout.tsx` on the `` element. + +Fonts are loaded once in `app/app.css` via Google Fonts; add families +there if a theme needs them. + +## Tweaking the current look + +Edit the matching theme file (`app/theme/themes/.css`). Prefer +changing token values over adding selectors. If you must style a +component, use the token utilities so other themes still work. + +## Adding a theme + +1. Create `app/theme/themes/.css` (copy `classic.css`) scoped to + `html[data-site-theme='']`. +2. Import it in `app/app.css` next to the others. +3. Add the name to the `ThemeName` union in `app/lib/cms/types.ts` and + an entry in `THEMES` in `app/theme/themes.ts` (label, description, + preview swatches). Appearance picks it up automatically. +4. Optionally set it as default: `update cms_settings set value = + '""' where key = 'theme';` in a new migration. + +## Dark mode + +Site themes are light by default. For dark support, add +`html[data-site-theme=''].dark { … }` overrides; the `dark` +class is not toggled automatically on the public site. + +## Don'ts + +- Don't use `--color-bolt-ds-*` tokens on the public site; those are + the Bolt design-system tokens used only by `/admin`. +- Don't style by `data-theme`; the design system uses that attribute. + Site themes use `data-site-theme`. diff --git a/bolt-cms-react-router/.bolt/skills/bolt-cms/SKILL.md b/bolt-cms-react-router/.bolt/skills/bolt-cms/SKILL.md new file mode 100644 index 00000000..415ffabd --- /dev/null +++ b/bolt-cms-react-router/.bolt/skills/bolt-cms/SKILL.md @@ -0,0 +1,108 @@ +--- +name: bolt-cms +description: >- + Work on a Bolt CMS site: a WordPress-shaped React Router + Supabase + website with a built-in /admin. Use this whenever the user asks about + their imported WordPress content, posts, pages, categories, tags, + comments, menus, media, site settings, the admin, or the database + behind the site. +--- + +# Bolt CMS — a WordPress-shaped site on React Router + Supabase + +This project is the landing place for a WordPress import. The schema, +URLs, and admin screens intentionally mirror WordPress so content maps +1:1. Keep that shape unless the user asks to change it. + +## Map + +``` +supabase/migrations/0001_cms_core.sql tables, views, triggers, RLS +supabase/migrations/0002_cms_seed.sql WordPress fresh-install defaults + cms_fields +app/lib/cms/ typed reads for the public site (anon key) +app/routes/site/ public routes (home, blog, category, tag, author, search, catch-all) +app/components/site/ public UI (header, footer, post list, article, comments) +app/theme/ theme contract + classic / editorial / minimal +app/routes/admin/ + app/admin/ /admin (only renders inside Bolt or `npm run dev`) +app/admin/bridge/ postMessage protocol to the Bolt host +dev/cms-dev-server.ts local stand-in for the host: /__cms and /__host +bolt-cms.json `boltCmsVersion` marker used for starter upgrades +``` + +## Data model (WordPress → tables) + +| WordPress | Table | Notes | +| -------------------- | ------------------------ | ---------------------------------------------------- | +| options | `cms_settings` | key → jsonb value (`site_title`, `theme`, `seo`, …) | +| users | `cms_authors` | | +| media | `cms_media` | `local_path` under `public/wp-content/uploads/` | +| posts / pages / CPTs | `cms_posts` | `type` column: `post`, `page`, or a custom type | +| post meta | `cms_post_meta` | key/value, jsonb | +| categories / tags | `cms_terms` | `taxonomy` column: `category`, `post_tag`, or custom | +| term relationships | `cms_term_relationships` | trigger keeps `cms_terms.count` in sync | +| comments | `cms_comments` | anon inserts are forced to `hold` | +| nav menus | `cms_menus`, `cms_menu_items` | `location` = `primary`, `footer`, … | +| redirects | `cms_redirects` | old WordPress paths → new paths | +| (admin form config) | `cms_fields` | drives the admin editor; see bolt-cms-extend | + +WordPress ids are preserved as primary keys. Sequences start at +1,000,000,000 so new rows never collide with imported ids. `cms_posts` +slugs are unique per `(type, slug, parent)` like WordPress. + +Discovery views: `cms_post_type_counts` and `cms_taxonomy_counts`. The +admin sidebar lists whatever types and taxonomies actually exist, so a +new `type` value shows up without code changes. + +## Security model + +- The browser only ever has the **anon** key. RLS lets anon read + published posts, approved comments, terms, menus, media, settings, + and insert a comment (always stored as `hold`, gated by + `comments_enabled` and the post's `comment_status`). +- All writes from `/admin` go through the Bolt host bridge + (`app/admin/bridge/`), which executes them with elevated privileges + on the host side. Locally, `npm run dev` provides the same bridge via + `dev/cms-dev-server.ts` using `SUPABASE_SERVICE_ROLE_KEY` from `.env` + (server-only; never `VITE_`-prefixed). +- Never put the service role key in `VITE_*` env or in app code. + +## Working with content + +- Read content in the site with the helpers in `app/lib/cms/queries.ts` + (`listPosts`, `getPostBySlug`, `getPageByPath`, `getTermBySlug`, + `getMenu`, `getSettings`, …). They already filter to published rows. +- Render post HTML with ``, which sanitizes + with DOMPurify and applies `.entry-content` block styles. Do not + `dangerouslySetInnerHTML` raw content elsewhere. +- Post bodies keep both `content_html` (WordPress HTML, what the site + renders) and `content_json` (Tiptap/ProseMirror JSON the editor + prefers). If you edit `content_html` programmatically, set + `content_json` to `null` so the editor re-derives it from HTML. +- URLs follow WordPress: `/:slug` for posts, `/parent/child` for + hierarchical pages, `/category/:slug`, `/tag/:slug`, `/author/:slug`, + `/?s=` or `/search?s=`, `/blog` when `show_on_front = 'page'`. The + catch-all route resolves redirects → page path → post slug. + +## Schema changes + +Bolt applies migrations with the `apply_migration` tool. Add a new file +in `supabase/migrations/` (next number, snake_case name) instead of +editing `0001`/`0002` after they have been applied. Prefix new tables +with `cms_` so the admin bridge and the dev server will accept them, +enable RLS, and add a public read policy only for what the site needs. + +## Settings + +`cms_settings` rows are jsonb. `app/lib/cms/types.ts#SiteSettings` is +the typed view; `settingsFromRows` fills in `DEFAULT_SETTINGS` for +missing keys. Reading settings on the site is `getSettings()` (cached +15 s). The admin writes them via `saveSettings()`. + +## Don'ts + +- Don't rename `cms_*` tables or columns that mirror WordPress; the + importer depends on them. +- Don't make the admin visible on the published site; `isAdminShell()` + gates it and must stay. +- Don't bypass `cms_fields` when adding editable columns — the admin + won't render them otherwise. diff --git a/bolt-cms-react-router/.env.example b/bolt-cms-react-router/.env.example new file mode 100644 index 00000000..b4bdc027 --- /dev/null +++ b/bolt-cms-react-router/.env.example @@ -0,0 +1,8 @@ +# Public (bundled into the client). Bolt pre-populates these in a project. +VITE_SUPABASE_URL=http://localhost:54321 +VITE_SUPABASE_ANON_KEY=local-anon-key + +# Dev only. Read by the Vite dev middleware that backs /admin outside of Bolt. +# Never shipped to the client. +SUPABASE_URL=http://localhost:54321 +SUPABASE_SERVICE_ROLE_KEY=local-service-role-key diff --git a/bolt-cms-react-router/.gitignore b/bolt-cms-react-router/.gitignore new file mode 100644 index 00000000..f6042fda --- /dev/null +++ b/bolt-cms-react-router/.gitignore @@ -0,0 +1,24 @@ +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +build +.react-router +.vite +*.local + +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.env diff --git a/bolt-cms-react-router/README.md b/bolt-cms-react-router/README.md new file mode 100644 index 00000000..063d7647 --- /dev/null +++ b/bolt-cms-react-router/README.md @@ -0,0 +1,119 @@ +# Bolt CMS (React Router) + +The starter a WordPress site lands in when it is imported into +[Bolt](https://bolt.new). A static React Router 8 site backed by +Supabase, with a WordPress-shaped schema so imported content maps 1:1, +three switchable themes, and a built-in `/admin` that only renders +inside Bolt. + +The agent skills in `.bolt/skills/` cover day-to-day work: `bolt-cms` +(content, schema, security), `bolt-cms-theme` (visuals), and +`bolt-cms-extend` (custom types, taxonomies, fields, plugin data). + +## Quick start + +```bash +cp .env.example .env # fill in your Supabase project values +npm install +npm run dev # site at http://localhost:5173, admin at /admin +npm run lint +npm run typecheck +npm run build # static output in build/client +``` + +Apply `supabase/migrations/0001_cms_core.sql` then +`0002_cms_seed.sql` to the Supabase project (in Bolt the agent does +this with `apply_migration`). The seed is a WordPress fresh install: +"Hello world!", "Sample Page", the Uncategorized category, one comment, +and a primary menu. + +### Environment + +| Key | Where | Purpose | +| --------------------------- | ------------------ | ----------------------------------------- | +| `VITE_SUPABASE_URL` | client | Supabase project URL | +| `VITE_SUPABASE_ANON_KEY` | client | anon key; RLS limits it to published data | +| `SUPABASE_URL` | dev only | used by the local admin bridge | +| `SUPABASE_SERVICE_ROLE_KEY` | dev only | used by the local admin bridge; never shipped | + +Without the `VITE_*` keys the site renders a setup notice instead of +content. + +## What's inside + +``` +app/routes/site/ public routes: /, /blog, /category/:slug, /tag/:slug, + /author/:slug, /search, and a WordPress-style catch-all +app/components/site/ header, footer, post list, article, comments +app/lib/cms/ typed Supabase reads, path resolution, SEO meta +app/theme/ token contract + classic / editorial / minimal themes +app/routes/admin/ dashboard, content, media, comments, terms, menus, + appearance, settings +app/admin/ admin UI kit (Bolt design-system tokens), field + renderers, Tiptap editor, host bridge +dev/cms-dev-server.ts local stand-in for the Bolt host bridge +supabase/migrations/ schema (0001) and WordPress defaults (0002) +public/wp-content/ imported uploads are copied here +bolt-cms.json starter version marker (boltCmsVersion) +``` + +### Public site + +Routes use `clientLoader` and the anon key. URLs follow WordPress: +`/:slug` for posts, `/parent/child` for hierarchical pages, +`/?s=term` for search. Redirects in `cms_redirects` are honored by the +catch-all so old WordPress paths keep working. Post HTML is sanitized +with DOMPurify and WordPress block classes are styled in +`app/theme/tokens.css`. + +### Schema + +Tables are prefixed `cms_` and mirror the WordPress REST API: +`cms_settings`, `cms_authors`, `cms_media`, `cms_posts` (posts, pages, +and custom types via `type`), `cms_post_meta`, `cms_terms` (categories, +tags, custom taxonomies via `taxonomy`), `cms_term_relationships`, +`cms_comments`, `cms_menus`, `cms_menu_items`, `cms_redirects`, plus +`cms_fields`, which describes the admin editor forms. WordPress ids are +preserved; new rows get ids from 1,000,000,000 up. + +RLS gives the anon key read access to published content and a single +write: submitting a comment, which is always stored as `hold`. + +### Admin + +`/admin` is part of the app but only renders when the page is framed by +Bolt (or during `npm run dev`); on the published site it is blank. It +talks to the host over `postMessage` using a small PostgREST-shaped +protocol (`app/admin/bridge/protocol.ts`): select / insert / upsert / +update / delete / upload against `cms_*` tables. The host executes the +operations with elevated privileges; the browser never holds a +privileged key. + +Locally, `dev/cms-dev-server.ts` plays the host: `POST /__cms` runs +operations with the service role key from `.env`, and `GET /__host` +serves a page that frames `/admin` the way Bolt does, so the framed +gate and the postMessage transport can be exercised without Bolt. + +Content is edited with Tiptap. Posts keep both `content_html` (what +the site renders, WordPress-compatible) and `content_json` (editor +state). + +### Themes + +`app/theme/tokens.css` defines the `--theme-*` contract; each theme in +`app/theme/themes/` sets those variables under +`html[data-site-theme='']`. The active theme is +`cms_settings.theme`, picked in Admin → Appearance. See the +`bolt-cms-theme` skill for adding one. + +## Notes for the Bolt side + +- Build: `npm run build`, output `build/client` (SPA; Bolt hosting + falls back to `index.html`). +- Stretch: React Router `prerender` can emit static HTML for known + paths at build time if SEO becomes a priority; that needs the + loaders to be runnable at build time (a `loader` alongside + `clientLoader`). +- The starter version is `boltCmsVersion` in `bolt-cms.json`; bump it + when the schema or admin protocol changes so projects can be + upgraded. diff --git a/bolt-cms-react-router/app/admin/api.ts b/bolt-cms-react-router/app/admin/api.ts new file mode 100644 index 00000000..bfd71b8d --- /dev/null +++ b/bolt-cms-react-router/app/admin/api.ts @@ -0,0 +1,489 @@ +/** + * Typed data access for the admin, built on the bridge client. Every function + * here maps to one or more `CmsOp`s; nothing talks to Supabase directly. + */ +import type { + Author, + Comment, + CommentStatus, + FieldDef, + Media, + Menu, + MenuItem, + Post, + PostStatus, + SiteSettings, + Term, +} from '@/lib/cms/types'; +import { settingsFromRows } from '@/lib/cms/types'; + +import { cms, type CmsResult } from './bridge/client'; +import type { Filter, Order, Row } from './bridge/protocol'; + +/* ------------------------------------------------------------------------ */ +/* Generic rows */ +/* ------------------------------------------------------------------------ */ + +export interface ListOptions { + columns?: string; + filters?: Filter[]; + order?: Order[]; + page?: number; + perPage?: number; + count?: boolean; +} + +export async function listRows( + table: string, + options: ListOptions = {} +): Promise> { + const perPage = options.perPage ?? 20; + const page = Math.max(1, options.page ?? 1); + const range = options.perPage + ? { from: (page - 1) * perPage, to: page * perPage - 1 } + : undefined; + return cms.request({ + kind: 'select', + table, + columns: options.columns, + filters: options.filters, + order: options.order, + range, + count: options.count, + }); +} + +export async function getRow( + table: string, + id: number | string, + columns = '*' +): Promise { + const { data } = await cms.request({ + kind: 'select', + table, + columns, + filters: [{ column: 'id', op: 'eq', value: id }], + single: true, + }); + return data; +} + +export async function insertRow( + table: string, + values: Row +): Promise { + const { data } = await cms.request({ kind: 'insert', table, values }); + return data[0]; +} + +export async function updateRow( + table: string, + id: number | string, + values: Row +): Promise { + const { data } = await cms.request({ + kind: 'update', + table, + values, + filters: [{ column: 'id', op: 'eq', value: id }], + }); + return data[0]; +} + +export async function deleteRow( + table: string, + id: number | string +): Promise { + await cms.request({ + kind: 'delete', + table, + filters: [{ column: 'id', op: 'eq', value: id }], + }); +} + +export async function hello(): Promise<{ host: string; version: number }> { + const { data } = await cms.request<{ host: string; version: number }>({ + kind: 'hello', + }); + return data; +} + +/* ------------------------------------------------------------------------ */ +/* Fields */ +/* ------------------------------------------------------------------------ */ + +export async function getFields(table: string): Promise { + const { data } = await listRows('cms_fields', { + filters: [{ column: 'table_name', op: 'eq', value: table }], + order: [{ column: 'position' }], + }); + return data; +} + +/* ------------------------------------------------------------------------ */ +/* Content (cms_posts) */ +/* ------------------------------------------------------------------------ */ + +export const POST_ADMIN_SELECT = + '*, author:cms_authors(id, name), featured_media:cms_media(id, local_path, source_url, alt_text)'; + +export type AdminPost = Post & { + author: Pick | null; + featured_media: Pick< + Media, + 'id' | 'local_path' | 'source_url' | 'alt_text' + > | null; +}; + +export interface ListContentOptions { + type: string; + status?: PostStatus | 'all'; + search?: string; + page?: number; + perPage?: number; +} + +export async function listContent( + options: ListContentOptions +): Promise> { + const filters: Filter[] = [{ column: 'type', op: 'eq', value: options.type }]; + if (options.status && options.status !== 'all') + filters.push({ column: 'status', op: 'eq', value: options.status }); + else filters.push({ column: 'status', op: 'neq', value: 'trash' }); + if (options.search) + filters.push({ + column: 'title', + op: 'ilike', + value: `%${options.search}%`, + }); + + return listRows('cms_posts', { + columns: POST_ADMIN_SELECT, + filters, + order: [{ column: 'date', ascending: false }], + page: options.page, + perPage: options.perPage ?? 20, + count: true, + }); +} + +export async function getPost(id: number): Promise { + return getRow('cms_posts', id); +} + +export async function savePost( + values: Partial & { type: string }, + id?: number +): Promise { + const row: Row = { ...values }; + delete row.id; + if (id) return updateRow('cms_posts', id, row); + return insertRow('cms_posts', row); +} + +export async function setPostStatus( + id: number, + status: PostStatus +): Promise { + await updateRow('cms_posts', id, { status }); +} + +export async function deletePostPermanently(id: number): Promise { + await deleteRow('cms_posts', id); +} + +export interface PostTypeCount { + type: string; + status: PostStatus; + count: number; +} + +export async function getPostTypeCounts(): Promise { + const { data } = await listRows('cms_post_type_counts'); + return data; +} + +export function summarizeTypes( + counts: PostTypeCount[] +): Array<{ type: string; total: number; byStatus: Record }> { + const map = new Map< + string, + { type: string; total: number; byStatus: Record } + >(); + for (const c of counts) { + const entry = map.get(c.type) ?? { type: c.type, total: 0, byStatus: {} }; + entry.byStatus[c.status] = (entry.byStatus[c.status] ?? 0) + c.count; + if (c.status !== 'trash') entry.total += c.count; + map.set(c.type, entry); + } + // Posts, then pages, then everything else alphabetically. + const order = (t: string) => (t === 'post' ? 0 : t === 'page' ? 1 : 2); + return Array.from(map.values()).sort( + (a, b) => order(a.type) - order(b.type) || a.type.localeCompare(b.type) + ); +} + +/* ------------------------------------------------------------------------ */ +/* Terms */ +/* ------------------------------------------------------------------------ */ + +export async function listTerms(taxonomy: string): Promise { + const { data } = await listRows('cms_terms', { + filters: [{ column: 'taxonomy', op: 'eq', value: taxonomy }], + order: [{ column: 'name' }], + }); + return data; +} + +export async function getTaxonomies(): Promise< + Array<{ taxonomy: string; count: number }> +> { + const { data } = await listRows<{ taxonomy: string; count: number }>( + 'cms_taxonomy_counts' + ); + const order = (t: string) => + t === 'category' ? 0 : t === 'post_tag' ? 1 : 2; + return data.sort( + (a, b) => + order(a.taxonomy) - order(b.taxonomy) || + a.taxonomy.localeCompare(b.taxonomy) + ); +} + +export async function getPostTermIds(postId: number): Promise { + const { data } = await listRows<{ term_id: number }>( + 'cms_term_relationships', + { + columns: 'term_id', + filters: [{ column: 'post_id', op: 'eq', value: postId }], + } + ); + return data.map((r) => r.term_id); +} + +/** Replace a post's terms within the given taxonomy's term ids. */ +export async function setPostTerms( + postId: number, + taxonomyTermIds: number[], + nextIds: number[] +): Promise { + const current = await getPostTermIds(postId); + const inTaxonomy = new Set(taxonomyTermIds); + const currentInTax = current.filter((id) => inTaxonomy.has(id)); + const toRemove = currentInTax.filter((id) => !nextIds.includes(id)); + const toAdd = nextIds.filter((id) => !currentInTax.includes(id)); + + if (toRemove.length) { + await cms.request({ + kind: 'delete', + table: 'cms_term_relationships', + filters: [ + { column: 'post_id', op: 'eq', value: postId }, + { column: 'term_id', op: 'in', value: toRemove }, + ], + }); + } + if (toAdd.length) { + await cms.request({ + kind: 'insert', + table: 'cms_term_relationships', + values: toAdd.map((term_id) => ({ post_id: postId, term_id })), + }); + } +} + +export async function saveTerm( + values: Partial & { taxonomy: string }, + id?: number +): Promise { + const row: Row = { ...values }; + delete row.id; + delete row.count; + if (id) return updateRow('cms_terms', id, row); + return insertRow('cms_terms', row); +} + +/* ------------------------------------------------------------------------ */ +/* Media */ +/* ------------------------------------------------------------------------ */ + +export async function listMedia( + options: { page?: number; perPage?: number; search?: string } = {} +): Promise> { + const filters: Filter[] = []; + if (options.search) + filters.push({ + column: 'title', + op: 'ilike', + value: `%${options.search}%`, + }); + return listRows('cms_media', { + filters, + order: [{ column: 'date', ascending: false }], + page: options.page, + perPage: options.perPage ?? 40, + count: true, + }); +} + +export async function uploadMedia(file: File): Promise { + const dataBase64 = await fileToBase64(file); + const now = new Date(); + const yyyy = now.getFullYear(); + const mm = String(now.getMonth() + 1).padStart(2, '0'); + const safeName = file.name.replace(/[^a-zA-Z0-9._-]+/g, '-'); + const path = `${yyyy}/${mm}/${Date.now().toString(36)}-${safeName}`; + + const { data } = await cms.request<{ url: string }>({ + kind: 'upload', + path, + contentType: file.type, + dataBase64, + }); + + const dims = file.type.startsWith('image/') + ? await imageDimensions(file) + : null; + + return insertRow('cms_media', { + slug: safeName.replace(/\.[^.]+$/, '').toLowerCase(), + title: file.name.replace(/\.[^.]+$/, ''), + mime_type: file.type, + media_type: file.type.startsWith('image/') ? 'image' : 'file', + local_path: data.url, + width: dims?.width ?? null, + height: dims?.height ?? null, + }); +} + +function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result).split(',')[1] ?? ''); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); +} + +function imageDimensions( + file: File +): Promise<{ width: number; height: number } | null> { + return new Promise((resolve) => { + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + resolve({ width: img.naturalWidth, height: img.naturalHeight }); + URL.revokeObjectURL(url); + }; + img.onerror = () => { + resolve(null); + URL.revokeObjectURL(url); + }; + img.src = url; + }); +} + +/* ------------------------------------------------------------------------ */ +/* Comments */ +/* ------------------------------------------------------------------------ */ + +export type AdminComment = Comment & { + post: Pick | null; +}; + +export async function listComments( + options: { + status?: CommentStatus | 'all'; + page?: number; + perPage?: number; + } = {} +) { + const filters: Filter[] = []; + if (options.status && options.status !== 'all') + filters.push({ column: 'status', op: 'eq', value: options.status }); + return listRows('cms_comments', { + columns: '*, post:cms_posts(id, title, slug)', + filters, + order: [{ column: 'date', ascending: false }], + page: options.page, + perPage: options.perPage ?? 20, + count: true, + }); +} + +export async function getCommentCounts(): Promise> { + const { data } = await listRows<{ status: CommentStatus }>('cms_comments', { + columns: 'status', + }); + const counts: Record = {}; + for (const c of data) counts[c.status] = (counts[c.status] ?? 0) + 1; + return counts; +} + +/* ------------------------------------------------------------------------ */ +/* Menus */ +/* ------------------------------------------------------------------------ */ + +export async function listMenus(): Promise { + const { data } = await listRows('cms_menus', { + order: [{ column: 'name' }], + }); + return data; +} + +export async function listMenuItems(menuId: number): Promise { + const { data } = await listRows('cms_menu_items', { + filters: [{ column: 'menu_id', op: 'eq', value: menuId }], + order: [{ column: 'position' }], + }); + return data; +} + +export async function saveMenuItems(items: MenuItem[]): Promise { + if (!items.length) return; + await cms.request({ + kind: 'upsert', + table: 'cms_menu_items', + values: items.map((i) => ({ ...i })), + onConflict: 'id', + }); +} + +/* ------------------------------------------------------------------------ */ +/* Settings */ +/* ------------------------------------------------------------------------ */ + +export async function getAdminSettings(): Promise { + const { data } = await listRows<{ key: string; value: unknown }>( + 'cms_settings', + { columns: 'key, value' } + ); + return settingsFromRows(data); +} + +export async function saveSettings( + values: Partial> +): Promise { + const rows = Object.entries(values).map(([key, value]) => ({ + key, + value: value ?? null, + })); + if (!rows.length) return; + await cms.request({ + kind: 'upsert', + table: 'cms_settings', + values: rows, + onConflict: 'key', + }); +} + +/* ------------------------------------------------------------------------ */ +/* Authors */ +/* ------------------------------------------------------------------------ */ + +export async function listAuthors(): Promise { + const { data } = await listRows('cms_authors', { + order: [{ column: 'name' }], + }); + return data; +} diff --git a/bolt-cms-react-router/app/admin/bridge/client.ts b/bolt-cms-react-router/app/admin/bridge/client.ts new file mode 100644 index 00000000..a8b4ef07 --- /dev/null +++ b/bolt-cms-react-router/app/admin/bridge/client.ts @@ -0,0 +1,177 @@ +/** + * Admin-side bridge client. + * + * Sends `CmsRequest`s to the host and resolves `CmsResponse`s. Two transports: + * + * - postMessage: when framed (inside a Bolt project preview, or /__host in dev). + * Responses are only accepted from `window.parent` on an allowlisted origin. + * - http: local dev, unframed. POSTs to the Vite middleware at `/__cms`. + * + * Outside of both (e.g. the published site opened directly) the admin never + * renders, so no transport is needed. + */ +import { + CMS_CLIENT_SOURCE, + CMS_PROTOCOL_VERSION, + isCmsResponse, + type CmsError, + type CmsOp, + type CmsRequest, + type CmsResponse, +} from './protocol'; + +const REQUEST_TIMEOUT_MS = 15_000; + +/** Origins allowed to host the admin. Extend for staging environments. */ +const HOST_ORIGIN_PATTERNS: RegExp[] = [ + /^https:\/\/([a-z0-9-]+\.)*bolt\.new$/i, + /^https:\/\/([a-z0-9-]+\.)*bolt\.host$/i, + /^https:\/\/([a-z0-9-]+\.)*stackblitz\.(io|com)$/i, +]; + +if (import.meta.env.DEV) { + HOST_ORIGIN_PATTERNS.push( + /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i + ); +} + +export function isAllowedHostOrigin(origin: string): boolean { + if ( + typeof window !== 'undefined' && + origin === window.location.origin && + import.meta.env.DEV + ) { + return true; + } + return HOST_ORIGIN_PATTERNS.some((p) => p.test(origin)); +} + +export function isFramed(): boolean { + return typeof window !== 'undefined' && window.parent !== window; +} + +/** Mirrors bolt-slides: show the shell when framed, or always in local dev. */ +export function isAdminShell(): boolean { + if (typeof window === 'undefined') return false; + return isFramed() || import.meta.env.DEV; +} + +export class CmsBridgeError extends Error { + code?: string; + details?: unknown; + constructor(error: CmsError) { + super(error.message); + this.name = 'CmsBridgeError'; + this.code = error.code; + this.details = error.details; + } +} + +export interface CmsResult { + data: T; + count?: number | null; +} + +type Pending = { + resolve: (value: CmsResult) => void; + reject: (error: Error) => void; + timer: ReturnType; +}; + +export type Transport = 'postmessage' | 'http' | 'none'; + +class CmsClient { + private pending = new Map(); + private listening = false; + private hostOrigin: string | null = null; + + get transport(): Transport { + if (typeof window === 'undefined') return 'none'; + if (isFramed()) return 'postmessage'; + if (import.meta.env.DEV) return 'http'; + return 'none'; + } + + async request(op: CmsOp): Promise> { + const transport = this.transport; + if (transport === 'none') { + throw new CmsBridgeError({ + code: 'no_host', + message: 'Bolt CMS admin is only available inside Bolt.', + }); + } + + const request: CmsRequest = { + source: CMS_CLIENT_SOURCE, + version: CMS_PROTOCOL_VERSION, + id: crypto.randomUUID(), + op, + }; + + if (transport === 'http') { + const res = await fetch('/__cms', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + const body = (await res.json()) as CmsResponse; + if (!body.ok) throw new CmsBridgeError(body.error); + return { data: body.data as T, count: body.count }; + } + + this.ensureListening(); + return new Promise>((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(request.id); + reject( + new CmsBridgeError({ + code: 'timeout', + message: 'The Bolt host did not respond in time.', + }) + ); + }, REQUEST_TIMEOUT_MS); + + this.pending.set(request.id, { + resolve: resolve as (value: CmsResult) => void, + reject, + timer, + }); + + window.parent.postMessage(request, this.targetOrigin()); + }); + } + + private targetOrigin(): string { + if (this.hostOrigin) return this.hostOrigin; + try { + const ref = document.referrer ? new URL(document.referrer).origin : ''; + if (ref && isAllowedHostOrigin(ref)) return ref; + } catch { + /* ignore */ + } + // Requests carry no secrets; the host learns our origin from the event. + return '*'; + } + + private ensureListening() { + if (this.listening) return; + this.listening = true; + window.addEventListener('message', (event: MessageEvent) => { + if (event.source !== window.parent) return; + if (!isAllowedHostOrigin(event.origin)) return; + if (!isCmsResponse(event.data)) return; + + this.hostOrigin = event.origin; + const pending = this.pending.get(event.data.id); + if (!pending) return; + this.pending.delete(event.data.id); + clearTimeout(pending.timer); + + if (event.data.ok) + pending.resolve({ data: event.data.data, count: event.data.count }); + else pending.reject(new CmsBridgeError(event.data.error)); + }); + } +} + +export const cms = new CmsClient(); diff --git a/bolt-cms-react-router/app/admin/bridge/protocol.ts b/bolt-cms-react-router/app/admin/bridge/protocol.ts new file mode 100644 index 00000000..0b45aff3 --- /dev/null +++ b/bolt-cms-react-router/app/admin/bridge/protocol.ts @@ -0,0 +1,167 @@ +/** + * Bolt CMS admin <-> host protocol. + * + * The admin UI never talks to Supabase directly. It posts `CmsRequest` + * messages to its host (the Bolt UI when framed in a Bolt project; the Vite + * dev middleware when developing locally) and receives `CmsResponse`s back. + * + * The operation set is a deliberately narrow, PostgREST-shaped DSL so the host + * can map each op onto a supabase-js call with no CMS knowledge. Hosts MUST + * refuse tables outside the `cms_` prefix. + * + * Keep this file self-contained (types + constants only): it is imported by + * both the browser bundle and the Node dev server. + */ + +export const CMS_CLIENT_SOURCE = 'bolt-cms' as const; +export const CMS_HOST_SOURCE = 'bolt-cms-host' as const; +export const CMS_PROTOCOL_VERSION = 1 as const; +export const CMS_TABLE_PREFIX = 'cms_' as const; + +export type FilterOp = + | 'eq' + | 'neq' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'is' + | 'ilike' + | 'like'; + +export interface Filter { + column: string; + op: FilterOp; + value: unknown; +} + +export interface Order { + column: string; + ascending?: boolean; + nullsFirst?: boolean; +} + +export type Row = Record; + +export interface SelectOp { + kind: 'select'; + table: string; + /** PostgREST select string, e.g. `"*, author:cms_authors(name)"`. Defaults to `*`. */ + columns?: string; + filters?: Filter[]; + order?: Order[]; + range?: { from: number; to: number }; + /** Resolve to a single row (or null) instead of an array. */ + single?: boolean; + /** Ask for an exact total count (returned as `count`). */ + count?: boolean; +} + +export interface InsertOp { + kind: 'insert'; + table: string; + values: Row | Row[]; + returning?: string; +} + +export interface UpsertOp { + kind: 'upsert'; + table: string; + values: Row | Row[]; + onConflict?: string; + returning?: string; +} + +export interface UpdateOp { + kind: 'update'; + table: string; + values: Row; + filters: Filter[]; + returning?: string; +} + +export interface DeleteOp { + kind: 'delete'; + table: string; + filters: Filter[]; +} + +/** + * Write a binary asset into the project at `public/wp-content/uploads/`. + * Resolves to `{ url }`, the public URL the site can render. + */ +export interface UploadOp { + kind: 'upload'; + path: string; + contentType: string; + dataBase64: string; +} + +/** Handshake. Resolves to `{ host: string; version: number }`. */ +export interface HelloOp { + kind: 'hello'; +} + +export type CmsOp = + | SelectOp + | InsertOp + | UpsertOp + | UpdateOp + | DeleteOp + | UploadOp + | HelloOp; + +export interface CmsRequest { + source: typeof CMS_CLIENT_SOURCE; + version: typeof CMS_PROTOCOL_VERSION; + id: string; + op: CmsOp; +} + +export interface CmsError { + message: string; + code?: string; + details?: unknown; +} + +export type CmsResponse = + | { + source: typeof CMS_HOST_SOURCE; + id: string; + ok: true; + data: unknown; + count?: number | null; + } + | { + source: typeof CMS_HOST_SOURCE; + id: string; + ok: false; + error: CmsError; + }; + +export function isCmsRequest(value: unknown): value is CmsRequest { + if (typeof value !== 'object' || value === null) return false; + const v = value as Record; + return ( + v.source === CMS_CLIENT_SOURCE && + typeof v.id === 'string' && + typeof v.op === 'object' && + v.op !== null && + typeof (v.op as Record).kind === 'string' + ); +} + +export function isCmsResponse(value: unknown): value is CmsResponse { + if (typeof value !== 'object' || value === null) return false; + const v = value as Record; + return ( + v.source === CMS_HOST_SOURCE && + typeof v.id === 'string' && + typeof v.ok === 'boolean' + ); +} + +export function isCmsTable(table: string): boolean { + return /^cms_[a-z0-9_]+$/.test(table); +} diff --git a/bolt-cms-react-router/app/admin/components/MediaPicker.tsx b/bolt-cms-react-router/app/admin/components/MediaPicker.tsx new file mode 100644 index 00000000..5d0dc32e --- /dev/null +++ b/bolt-cms-react-router/app/admin/components/MediaPicker.tsx @@ -0,0 +1,164 @@ +import { Upload } from 'lucide-react'; +import { useRef, useState } from 'react'; + +import { listMedia, uploadMedia } from '@/admin/api'; +import { useAsync, useDebounced } from '@/admin/hooks'; +import type { Media } from '@/lib/cms/types'; +import { mediaUrl } from '@/lib/cms/media'; + +import { + Button, + Dialog, + EmptyState, + ErrorNote, + Input, + Spinner, + useToast, + cx, +} from './ui'; + +/** Media library dialog: pick an existing file or upload a new one. */ +export function MediaPicker({ + open, + onClose, + onSelect, + accept = 'image/*', +}: { + open: boolean; + onClose: () => void; + onSelect: (media: Media) => void; + accept?: string; +}) { + const [search, setSearch] = useState(''); + const debounced = useDebounced(search); + const [uploading, setUploading] = useState(false); + const fileInput = useRef(null); + const toast = useToast(); + + const media = useAsync( + async () => + open ? (await listMedia({ search: debounced, perPage: 60 })).data : [], + [open, debounced] + ); + + async function onFiles(files: FileList | null) { + if (!files?.length) return; + setUploading(true); + try { + let last: Media | null = null; + for (const file of Array.from(files)) last = await uploadMedia(file); + await media.refetch(); + if (last && files.length === 1) onSelect(last); + toast(`Uploaded ${files.length} file${files.length === 1 ? '' : 's'}`); + } catch (e) { + toast(e instanceof Error ? e.message : 'Upload failed', 'error'); + } finally { + setUploading(false); + if (fileInput.current) fileInput.current.value = ''; + } + } + + return ( + +
+ setSearch(e.target.value)} + className="max-w-xs" + /> + onFiles(e.target.files)} + /> + +
+ {media.error && } + {media.loading ? ( + + ) : !media.data?.length ? ( + + ) : ( + + )} +
+ ); +} + +export function MediaGrid({ + items, + onSelect, + selectedId, +}: { + items: Media[]; + onSelect: (media: Media) => void; + selectedId?: number | null; +}) { + return ( +
+ {items.map((m) => ( + + ))} +
+ ); +} + +export function MediaThumb({ + media, + className, +}: { + media: Media; + className?: string; +}) { + const url = mediaUrl(media, 'thumbnail') ?? mediaUrl(media); + if (media.media_type === 'image' && url) { + return ( + {media.alt_text + ); + } + return ( + + {media.mime_type || 'file'} + + ); +} diff --git a/bolt-cms-react-router/app/admin/components/TermsPanel.tsx b/bolt-cms-react-router/app/admin/components/TermsPanel.tsx new file mode 100644 index 00000000..54a918ae --- /dev/null +++ b/bolt-cms-react-router/app/admin/components/TermsPanel.tsx @@ -0,0 +1,218 @@ +import { Plus, X } from 'lucide-react'; +import { useState } from 'react'; + +import { saveTerm } from '@/admin/api'; +import { taxonomyLabel } from '@/admin/labels'; +import { slugify } from '@/lib/cms/format'; +import type { Term } from '@/lib/cms/types'; + +import { Button, Card, Checkbox, Input, useToast } from './ui'; + +/** + * Category-style (checklist, hierarchical) or tag-style (chips) term picker. + * New terms are created immediately so ids exist when the post saves. + */ +export function TermsPanel({ + taxonomy, + terms, + selected, + onChange, + onTermCreated, +}: { + taxonomy: string; + terms: Term[]; + selected: number[]; + onChange: (ids: number[]) => void; + onTermCreated: (term: Term) => void; +}) { + const tagStyle = /tag/i.test(taxonomy); + const [draft, setDraft] = useState(''); + const [adding, setAdding] = useState(false); + const toast = useToast(); + + async function create(name: string) { + const clean = name.trim(); + if (!clean) return; + const existing = terms.find( + (t) => t.name.toLowerCase() === clean.toLowerCase() + ); + if (existing) { + if (!selected.includes(existing.id)) onChange([...selected, existing.id]); + setDraft(''); + return; + } + setAdding(true); + try { + const term = await saveTerm({ + taxonomy, + name: clean, + slug: slugify(clean), + }); + onTermCreated(term); + onChange([...selected, term.id]); + setDraft(''); + } catch (e) { + toast(e instanceof Error ? e.message : 'Could not add term', 'error'); + } finally { + setAdding(false); + } + } + + if (tagStyle) { + const chosen = terms.filter((t) => selected.includes(t.id)); + const suggestions = draft + ? terms + .filter( + (t) => + !selected.includes(t.id) && + t.name.toLowerCase().includes(draft.toLowerCase()) + ) + .slice(0, 6) + : []; + return ( + +
+ {chosen.map((t) => ( + + {t.name} + + + ))} +
+
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + void create(draft); + } + }} + disabled={adding} + /> + {suggestions.length > 0 && ( +
    + {suggestions.map((t) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ); + } + + const tree = buildTree(terms); + return ( + +
+ {tree.length === 0 && ( +

None yet.

+ )} + +
+
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void create(draft); + } + }} + /> + +
+
+ ); +} + +type TermNode = Term & { children: TermNode[] }; + +function buildTree(terms: Term[]): TermNode[] { + const byId = new Map(); + terms.forEach((t) => byId.set(t.id, { ...t, children: [] })); + const roots: TermNode[] = []; + byId.forEach((n) => { + const parent = n.parent_id ? byId.get(n.parent_id) : undefined; + if (parent) parent.children.push(n); + else roots.push(n); + }); + return roots; +} + +function TermChecklist({ + nodes, + selected, + onChange, + depth = 0, +}: { + nodes: TermNode[]; + selected: number[]; + onChange: (ids: number[]) => void; + depth?: number; +}) { + return ( +
    + {nodes.map((n) => ( +
  • + + onChange( + e.target.checked + ? [...selected, n.id] + : selected.filter((id) => id !== n.id) + ) + } + label={n.name} + /> + {n.children.length > 0 && ( + + )} +
  • + ))} +
+ ); +} diff --git a/bolt-cms-react-router/app/admin/components/editor/RichTextEditor.tsx b/bolt-cms-react-router/app/admin/components/editor/RichTextEditor.tsx new file mode 100644 index 00000000..9fae957c --- /dev/null +++ b/bolt-cms-react-router/app/admin/components/editor/RichTextEditor.tsx @@ -0,0 +1,376 @@ +/** + * Tiptap-based rich text editor. + * + * Content round-trips as HTML (what WordPress gives us and what the site + * renders) and ProseMirror JSON (`content_json`, the editor's canonical + * document). Imported posts open from HTML; Tiptap parses it natively, so no + * conversion step is needed. On change both representations are emitted. + */ +import Image from '@tiptap/extension-image'; +import Placeholder from '@tiptap/extension-placeholder'; +import { TableKit } from '@tiptap/extension-table'; +import Youtube from '@tiptap/extension-youtube'; +import { + EditorContent, + useEditor, + type Content, + type Editor, +} from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import { + Bold, + Code, + Code2, + Heading2, + Heading3, + Image as ImageIcon, + Italic, + Link as LinkIcon, + List, + ListOrdered, + Minus, + Quote, + Redo, + Strikethrough, + Table as TableIcon, + Undo, + Video as VideoIcon, +} from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +import type { Media } from '@/lib/cms/types'; +import { mediaUrl } from '@/lib/cms/media'; + +import { MediaPicker } from '../MediaPicker'; +import { Button, cx, Textarea } from '../ui'; + +export interface RichTextValue { + html: string; + json: unknown; +} + +function isDoc(value: unknown): value is Content { + return ( + typeof value === 'object' && + value !== null && + (value as { type?: unknown }).type === 'doc' + ); +} + +export function RichTextEditor({ + html, + json, + onChange, + placeholder = 'Start writing…', +}: { + html: string; + json?: unknown; + onChange: (value: RichTextValue) => void; + placeholder?: string; +}) { + const [mode, setMode] = useState<'visual' | 'html'>('visual'); + const [pickImage, setPickImage] = useState(false); + const [source, setSource] = useState(html); + const lastEmitted = useRef(html); + + const editor = useEditor({ + immediatelyRender: false, + extensions: [ + StarterKit.configure({ + heading: { levels: [1, 2, 3, 4] }, + link: { openOnClick: false, autolink: true, defaultProtocol: 'https' }, + }), + Image.configure({ + inline: false, + allowBase64: false, + HTMLAttributes: { loading: 'lazy' }, + }), + TableKit.configure({ table: { resizable: false } }), + Youtube.configure({ nocookie: true, width: 640, height: 360 }), + Placeholder.configure({ placeholder }), + ], + // Prefer the editor's own JSON when we have it; fall back to WordPress HTML. + content: isDoc(json) ? json : html, + editorProps: { + attributes: { + class: + 'entry-content admin-editor min-h-[24rem] px-5 py-4 focus:outline-none', + }, + }, + onUpdate: ({ editor }) => { + const nextHtml = editor.getHTML(); + lastEmitted.current = nextHtml; + setSource(nextHtml); + onChange({ html: nextHtml, json: editor.getJSON() }); + }, + }); + + // External resets (e.g. loading a different post into the same editor). + useEffect(() => { + if (!editor) return; + if (html !== lastEmitted.current && editor.getHTML() !== html) { + editor.commands.setContent(html, { emitUpdate: false }); + lastEmitted.current = html; + setSource(html); + } + }, [html, editor]); + + function applySource() { + if (!editor) return; + editor.commands.setContent(source, { emitUpdate: true }); + setMode('visual'); + } + + function insertMedia(media: Media) { + const url = mediaUrl(media); + if (!url || !editor) return; + editor + .chain() + .focus() + .setImage({ + src: url, + alt: media.alt_text ?? '', + title: media.title ?? undefined, + }) + .run(); + setPickImage(false); + } + + return ( +
+ setPickImage(true)} + /> + {mode === 'visual' ? ( + + ) : ( +
+