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/admin.json b/bolt-cms-react-router/.bolt/admin.json new file mode 100644 index 00000000..ddc4abc1 --- /dev/null +++ b/bolt-cms-react-router/.bolt/admin.json @@ -0,0 +1,192 @@ +{ + "queries": { + "listPosts": { + "sql": "select id, title, slug, status, author, featured_image, published_at, modified_at from public.cms_posts where (($1::text = 'all' and status is distinct from 'trash') or status = $1::text) and ($2::text = '' or title ilike '%' || $2::text || '%') order by published_at desc nulls last, id desc limit $3::int offset $4::int" + }, + "countPosts": { + "sql": "select coalesce(status, 'publish') as status, count(*)::int as count from public.cms_posts group by 1" + }, + "getPost": { + "sql": "select * from public.cms_posts where id = $1::bigint" + }, + "listPostOptions": { + "sql": "select id, title from public.cms_posts where status is distinct from 'trash' order by title limit 500" + }, + "insertPost": { + "readOnly": false, + "sql": "insert into public.cms_posts (id, title, slug, excerpt, body, content_html, status, author, featured_image, categories, tags, parent, menu_order, published_at, modified_at) values ((select coalesce(max(id), 0) + 1 from public.cms_posts), $1::text, $2::text, $3::text, $4::jsonb, $5::text, $6::text, $7::bigint, $8::text, $9::bigint[], $10::bigint[], $11::bigint, $12::bigint, coalesce($13::timestamptz, now()), now()) returning *" + }, + "updatePost": { + "readOnly": false, + "sql": "update public.cms_posts set title = $2::text, slug = $3::text, excerpt = $4::text, body = $5::jsonb, content_html = $6::text, status = $7::text, author = $8::bigint, featured_image = $9::text, categories = $10::bigint[], tags = $11::bigint[], parent = $12::bigint, menu_order = $13::bigint, published_at = coalesce($14::timestamptz, published_at, now()), modified_at = now() where id = $1::bigint returning *" + }, + "setPostStatus": { + "readOnly": false, + "sql": "update public.cms_posts set status = $2::text, modified_at = now() where id = $1::bigint returning id, status" + }, + "deletePost": { + "readOnly": false, + "confirm": true, + "preview": "select title from public.cms_posts where id = $1::bigint", + "description": "Permanently delete the post \"{title}\"", + "sql": "delete from public.cms_posts where id = $1::bigint returning id" + }, + "listPages": { + "sql": "select id, title, slug, status, author, featured_image, published_at, modified_at from public.cms_pages where (($1::text = 'all' and status is distinct from 'trash') or status = $1::text) and ($2::text = '' or title ilike '%' || $2::text || '%') order by published_at desc nulls last, id desc limit $3::int offset $4::int" + }, + "countPages": { + "sql": "select coalesce(status, 'publish') as status, count(*)::int as count from public.cms_pages group by 1" + }, + "getPage": { + "sql": "select * from public.cms_pages where id = $1::bigint" + }, + "listPageOptions": { + "sql": "select id, title from public.cms_pages where status is distinct from 'trash' order by title limit 500" + }, + "insertPage": { + "readOnly": false, + "sql": "insert into public.cms_pages (id, title, slug, excerpt, body, content_html, status, author, featured_image, categories, tags, parent, menu_order, published_at, modified_at) values ((select coalesce(max(id), 0) + 1 from public.cms_pages), $1::text, $2::text, $3::text, $4::jsonb, $5::text, $6::text, $7::bigint, $8::text, $9::bigint[], $10::bigint[], $11::bigint, $12::bigint, coalesce($13::timestamptz, now()), now()) returning *" + }, + "updatePage": { + "readOnly": false, + "sql": "update public.cms_pages set title = $2::text, slug = $3::text, excerpt = $4::text, body = $5::jsonb, content_html = $6::text, status = $7::text, author = $8::bigint, featured_image = $9::text, categories = $10::bigint[], tags = $11::bigint[], parent = $12::bigint, menu_order = $13::bigint, published_at = coalesce($14::timestamptz, published_at, now()), modified_at = now() where id = $1::bigint returning *" + }, + "setPageStatus": { + "readOnly": false, + "sql": "update public.cms_pages set status = $2::text, modified_at = now() where id = $1::bigint returning id, status" + }, + "deletePage": { + "readOnly": false, + "confirm": true, + "preview": "select title from public.cms_pages where id = $1::bigint", + "description": "Permanently delete the page \"{title}\"", + "sql": "delete from public.cms_pages where id = $1::bigint returning id" + }, + "listAuthors": { + "sql": "select id, name, slug, bio, avatar_url, url from public.cms_authors order by name limit 500" + }, + "insertAuthor": { + "readOnly": false, + "sql": "insert into public.cms_authors (id, name, slug, bio, avatar_url, url) values ((select coalesce(max(id), 0) + 1 from public.cms_authors), $1::text, $2::text, $3::text, $4::text, $5::text) returning *" + }, + "updateAuthor": { + "readOnly": false, + "sql": "update public.cms_authors set name = $2::text, slug = $3::text, bio = $4::text, avatar_url = $5::text, url = $6::text where id = $1::bigint returning *" + }, + "deleteAuthor": { + "readOnly": false, + "confirm": true, + "preview": "select name from public.cms_authors where id = $1::bigint", + "description": "Delete the author \"{name}\" (their posts stay, without an author)", + "sql": "delete from public.cms_authors where id = $1::bigint returning id" + }, + "listCategories": { + "sql": "select id, name, slug, description, parent from public.cms_categories order by name limit 500" + }, + "insertCategory": { + "readOnly": false, + "sql": "insert into public.cms_categories (id, name, slug, description, parent) values ((select coalesce(max(id), 0) + 1 from public.cms_categories), $1::text, $2::text, $3::text, $4::bigint) returning *" + }, + "updateCategory": { + "readOnly": false, + "sql": "update public.cms_categories set name = $2::text, slug = $3::text, description = $4::text, parent = $5::bigint where id = $1::bigint returning *" + }, + "deleteCategory": { + "readOnly": false, + "confirm": true, + "preview": "select name from public.cms_categories where id = $1::bigint", + "description": "Delete the category \"{name}\" (posts keep their other categories)", + "sql": "delete from public.cms_categories where id = $1::bigint returning id" + }, + "listTags": { + "sql": "select id, name, slug, description from public.cms_tags order by name limit 500" + }, + "insertTag": { + "readOnly": false, + "sql": "insert into public.cms_tags (id, name, slug, description) values ((select coalesce(max(id), 0) + 1 from public.cms_tags), $1::text, $2::text, $3::text) returning *" + }, + "updateTag": { + "readOnly": false, + "sql": "update public.cms_tags set name = $2::text, slug = $3::text, description = $4::text where id = $1::bigint returning *" + }, + "deleteTag": { + "readOnly": false, + "confirm": true, + "preview": "select name from public.cms_tags where id = $1::bigint", + "description": "Delete the tag \"{name}\" (posts keep their other tags)", + "sql": "delete from public.cms_tags where id = $1::bigint returning id" + }, + "listComments": { + "sql": "select c.id, c.post, c.parent, c.author_name, c.body, c.created_at, coalesce(p.title, g.title) as post_title, coalesce(p.slug, g.slug) as post_slug from public.cms_comments c left join public.cms_posts p on p.id = c.post left join public.cms_pages g on g.id = c.post order by c.created_at desc nulls last, c.id desc limit $1::int offset $2::int" + }, + "countComments": { + "sql": "select count(*)::int as count from public.cms_comments" + }, + "deleteComment": { + "readOnly": false, + "confirm": true, + "preview": "select coalesce(author_name, 'an anonymous visitor') as author_name from public.cms_comments where id = $1::bigint", + "description": "Delete the comment by {author_name}", + "sql": "delete from public.cms_comments where id = $1::bigint returning id" + }, + "listAssets": { + "sql": "select id, kind, mime_type, filename, title, alt, caption, width, height, original_url, public_url, upload_error from public.cms_assets where $1::text = '' or filename ilike '%' || $1::text || '%' or title ilike '%' || $1::text || '%' order by imported_at desc, id limit $2::int offset $3::int" + }, + "countAssets": { + "sql": "select count(*)::int as count from public.cms_assets where $1::text = '' or filename ilike '%' || $1::text || '%' or title ilike '%' || $1::text || '%'" + }, + "getAsset": { + "sql": "select id, kind, mime_type, filename, title, alt, caption, width, height, original_url, public_url, upload_error from public.cms_assets where id = $1::text" + }, + "updateAsset": { + "readOnly": false, + "sql": "update public.cms_assets set title = $2::text, alt = $3::text, caption = $4::text where id = $1::text returning id" + }, + "listSettings": { + "sql": "select key, value from public.cms_settings" + }, + "getSite": { + "sql": "select name, description, url, home_url, source from public.cms_site where id = 1" + }, + "upsertSetting": { + "readOnly": false, + "sql": "insert into public.cms_settings (key, value) values ($1::text, $2::jsonb) on conflict (key) do update set value = excluded.value, updated_at = now() returning key" + }, + "listMenus": { + "sql": "select id, slug, name, location from public.cms_menus order by name" + }, + "insertMenu": { + "readOnly": false, + "sql": "insert into public.cms_menus (name, slug, location) values ($1::text, $2::text, $3::text) returning *" + }, + "updateMenuLocation": { + "readOnly": false, + "sql": "update public.cms_menus set location = $2::text where id = $1::bigint returning *" + }, + "deleteMenu": { + "readOnly": false, + "confirm": true, + "preview": "select name from public.cms_menus where id = $1::bigint", + "description": "Delete the \"{name}\" menu and all its links", + "sql": "delete from public.cms_menus where id = $1::bigint returning id" + }, + "listMenuItems": { + "sql": "select * from public.cms_menu_items where menu_id = $1::bigint order by position, id" + }, + "insertMenuItem": { + "readOnly": false, + "sql": "insert into public.cms_menu_items (menu_id, parent_id, position, title, url, object_type, object_id, target, classes, description) values ($1::bigint, $2::bigint, $3::int, $4::text, $5::text, $6::text, $7::bigint, $8::text, $9::text, $10::text) returning *" + }, + "updateMenuItem": { + "readOnly": false, + "sql": "update public.cms_menu_items set parent_id = $2::bigint, position = $3::int, title = $4::text, url = $5::text, object_type = $6::text, object_id = $7::bigint, target = $8::text, classes = $9::text, description = $10::text where id = $1::bigint returning *" + }, + "deleteMenuItem": { + "readOnly": false, + "sql": "delete from public.cms_menu_items where id = $1::bigint returning id" + }, + "listFields": { + "sql": "select type_name, name, title, primitive, column_name, required, description, options, position from public.cms_fields where type_name = $1::text order by position" + } + } +} diff --git a/bolt-cms-react-router/.bolt/config.json b/bolt-cms-react-router/.bolt/config.json new file mode 100644 index 00000000..d83d4929 --- /dev/null +++ b/bolt-cms-react-router/.bolt/config.json @@ -0,0 +1,7 @@ +{ + "template": "bolt-cms-react-router", + "build": { + "command": "npm run build", + "output": "build/client" + } +} diff --git a/bolt-cms-react-router/.bolt/prompt b/bolt-cms-react-router/.bolt/prompt new file mode 100644 index 00000000..66642566 --- /dev/null +++ b/bolt-cms-react-router/.bolt/prompt @@ -0,0 +1,11 @@ +This is a Bolt CMS site: a React Router 8 (framework mode, `ssr: false`) website backed by Supabase, shaped like a WordPress site so content imported by Bolt's WordPress importer renders as-is. Content lives in `cms_*` tables (see `supabase/migrations/`); the public site reads them with the anon key; the `/bolt-admin` UI (rendered only inside Bolt's Admin tab) edits them through Bolt's bridge: every SQL statement it runs is declared by name in `.bolt/admin.json`. + +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 field, make another content type editable, or add a site-specific table. + +The browser only has the anon key; there is no server and no secret in this project. Only `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` are configured. + +Schema changes follow Bolt's `bolt-database` skill; the admin follows Bolt's `bolt-admin-dashboard` skill, except that the admin already exists and its queries are authored in `app/admin/queries.ts` (`.bolt/admin.json` is generated) — see the `bolt-cms` skill. 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..ec68f3d4 --- /dev/null +++ b/bolt-cms-react-router/.bolt/skills/bolt-cms-extend/SKILL.md @@ -0,0 +1,115 @@ +--- +name: bolt-cms-extend +description: >- + Extend a Bolt CMS site's content model: add an editable field to posts + or pages, make an imported custom post type editable in the admin, or + add a site-specific table. Use whenever the user wants the admin to + edit something it does not yet show. +--- + +# Extending the content model + +Three places describe a field; all three must agree: + +1. **The column** on the content table (`cms_posts`, `cms_pages`, …). +2. **The registry row** in `cms_fields` — the admin editor picks the + input from its `primitive` and `options`. +3. **The admin queries** in `app/admin/queries.ts` — Bolt only runs SQL + declared in `.bolt/admin.json`, which `npm run manifest` generates + from that file. + +Apply schema changes as Bolt's `bolt-database` skill says, and write +queries by the manifest rules of its `bolt-admin-dashboard` skill — but +in `app/admin/queries.ts`, never in `.bolt/admin.json` (generated; +`npm run typecheck` fails with "`.bolt/admin.json` is stale — run +`npm run manifest`" when it doesn't match `queries.ts`). + +## Add a field to posts (or pages) + +1. Migration (idempotent): + + ```sql + alter table public.cms_posts add column if not exists subtitle text; + insert into public.cms_fields (type_name, name, title, primitive, column_name, required, description, options, position) + values ('post', 'subtitle', 'Subtitle', 'string', 'subtitle', false, null, '{}', 20) + on conflict (type_name, name) do nothing; + ``` + + Repeat for `cms_pages` / `type_name = 'page'` if pages need it. +2. `app/admin/queries.ts` → `contentQueries(table, word)`: add the + column to `insert` (next `$n`, e.g. `$14::text`) and `update` (e.g. + `$15::text`). Both post and page queries come from this one function, + so the column must exist on both tables — otherwise give the new + column its own query instead. +3. `app/admin/api.ts`: add the property to `ContentInput` and append it + to `contentParams` in the same `$n` order. +4. `app/routes/admin/content-edit.tsx`: add the field name to + `FIELD_LAYOUT.main` or `FIELD_LAYOUT.sidebar`, and include it when + building the `ContentInput` on save. +5. `npm run manifest`, then publish — Bolt reads `.bolt/admin.json` from + the deployed revision. +6. To show it on the site, read it from the `Post` row (add it to the + `Post` interface in `app/lib/cms/types.ts`). + +### `primitive` and `options` + +`FieldInput` (`app/admin/components/fields/FieldInput.tsx`) renders: + +| `primitive` | Input | `options` | +| -------------------- | --------------------------------------- | ------------------------------------------- | +| `string` | text (textarea for `excerpt`, `bio`, `description`) | — | +| `slug` | slug input that follows `title`/`name` | — | +| `number` | number input, empty → `null` | `{"integer": true}` | +| `boolean` | checkbox | — | +| `date` / `datetime` | date / datetime-local (stored ISO) | — | +| `image` / `file` | media picker storing `cms_assets.id` | — | +| `reference` | select over another type | `{"to": ["author"]}` (`author`, `category`, `tag`, `post`, `page`) | +| `array` of reference | checklist (`bigint[]` column) | `{"of": {"primitive": "reference", "to": ["category"]}}` | +| `array` of block | rich-text editor (Portable Text `jsonb`) | `{"of": {"primitive": "block"}}` | +| other `array`/`object` | JSON input | — | + +Parameter casts in the SQL follow the client's conventions: scalars as +`$n::text|bigint|int|boolean`, `bigint[]` via `pgArray(ids)` → +`$n::bigint[]`, `jsonb` via `JSON.stringify(value)` → `$n::jsonb`, +timestamps as ISO strings → `$n::timestamptz`. + +## Make an imported custom post type editable + +The importer writes custom post types (e.g. `portfolio`) to their own +table (`select name, table_name from public.cms_types`) with +`cms_fields` rows; the site and admin ignore them until you wire them +up. For a table with the same columns as `cms_posts`: + +1. `app/admin/queries.ts`: widen the `contentQueries` table/word + unions, then spread it into `ADMIN_QUERIES` under new keys: + + ```ts + const portfolio = contentQueries('cms_portfolio', 'project'); + // in ADMIN_QUERIES: + listPortfolio: portfolio.list, countPortfolio: portfolio.count, getPortfolio: portfolio.get, + listPortfolioOptions: portfolio.options, insertPortfolio: portfolio.insert, updatePortfolio: portfolio.update, + setPortfolioStatus: portfolio.setStatus, deletePortfolio: portfolio.remove, + ``` +2. `app/admin/api.ts`: add the type to `ContentType`, `isContentType`, + and `CONTENT_TYPES` (label, singular, table, the eight query names); + add the table to `ContentTable` in `app/lib/cms/types.ts`. +3. Add a sidebar link to `content/` in + `app/routes/admin/layout.tsx`. +4. Optional public route in `app/routes.ts` plus a read helper in + `app/lib/cms/queries.ts`. +5. `npm run manifest`, publish. + +Different columns → write its eight queries by hand following the +`contentQueries` shape. + +## Site-specific tables + +- Prefix with `cms_`, create with `if not exists`, `enable row level + security`, and add a public read policy only if the site reads it + (wrap `create policy` in a `do $$ … if not exists (select 1 from + pg_policies …) … $$` block, as `0002_starter.sql` does). +- For admin editing, add named queries to `app/admin/queries.ts` + following the `bolt-admin-dashboard` manifest rules, then + `npm run manifest`, publish. +- `cms_*` tables the importer owns follow the `bolt-database` safety + rules. 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..9325d9e8 --- /dev/null +++ b/bolt-cms-react-router/.bolt/skills/bolt-cms-theme/SKILL.md @@ -0,0 +1,84 @@ +--- +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 /bolt-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 the +`cms_settings` row with key `theme` (a jsonb string), chosen in +**Admin → Appearance**, and applied by `app/routes/site/layout.tsx` on +the `` element. To switch it without the admin, run the same +upsert the admin's `upsertSetting` query does, via `apply_migration` +(or the Database tab's SQL editor): + +```sql +insert into public.cms_settings (key, value) values ('theme', '"editorial"') +on conflict (key) do update set value = excluded.value, updated_at = now(); +``` + +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 make it active: the upsert above with `'""'`, or + pick it in **Admin → Appearance**. + +## 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 `/bolt-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..1de49964 --- /dev/null +++ b/bolt-cms-react-router/.bolt/skills/bolt-cms/SKILL.md @@ -0,0 +1,142 @@ +--- +name: bolt-cms +description: >- + Work on a Bolt CMS site: a WordPress-shaped React Router + Supabase + website with a built-in /bolt-admin edited through Bolt's Admin tab. + Use this whenever the user asks about their imported WordPress + content, posts, pages, authors, 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 renders the schema Bolt's WordPress importer writes. URLs +and admin screens mirror WordPress so an imported site keeps working. +Keep that shape unless the user asks to change it. + +## Map + +``` +supabase/migrations/0001_bolt_cms_schema.sql importer schema (generated, never hand-edited) +supabase/migrations/0002_starter.sql starter tables, default settings, seed for empty tables +app/lib/cms/ public-site reads (anon key), types, Portable Text, SEO +app/lib/cms/portable-text.ts Portable Text types, portableTextToHtml, proseMirrorToPortableText +app/routes/site/ + app/components/site/ public routes and UI +app/theme/ theme contract + classic / editorial / minimal +app/routes/admin/ + app/admin/ /bolt-admin (renders only inside Bolt's Admin tab) +app/admin/queries.ts every admin SQL statement, by name (source of truth) +.bolt/admin.json generated from queries.ts by `npm run manifest` +app/admin/bridge/client.ts bolt.hello / db.run over postMessage +app/admin/api.ts typed admin data access (one named query per call) +bolt-cms.json `boltCmsVersion` marker used for starter upgrades +``` + +## Data model + +| Table | Owner | Notes | +| -------------------------------------- | -------- | --------------------------------------------------------------------- | +| `cms_site` | importer | single row `id = 1`: site name, description, url, home_url, `source` | +| `cms_types`, `cms_fields` | importer | registry: one row per content type / per field (`primitive`, `options`) | +| `cms_assets` | importer | media; files live in the public Storage bucket `cms-media` | +| `cms_posts`, `cms_pages` | importer | one table per content type, same columns | +| `cms_authors`, `cms_categories`, `cms_tags` | importer | flat collections; `cms_categories.parent` for hierarchy | +| `cms_comments` | importer | approved comments only; read-only on the site | +| `cms_settings` | starter | key → jsonb (`theme`, `show_on_front`, `seo`, …) | +| `cms_menus`, `cms_menu_items` | starter | `location` = `primary`, `footer`, … | +| `cms_redirects` | starter | old WordPress paths → new paths | + +Column conventions on content tables: + +- `id bigint` has no default (WordPress ids are kept). Admin insert + queries use `(select coalesce(max(id), 0) + 1 from …)`. +- `body` is Portable Text `jsonb`; `content_html` is the imported + WordPress HTML, used only when `body` is empty. +- `categories` / `tags` are `bigint[]` of `cms_categories` / `cms_tags` + ids; `author` is a `cms_authors` id; `featured_image` is a + `cms_assets.id` (text). +- `status` is free text: `publish`, `draft`, `trash`. Imported rows are + `publish`; the site shows nothing else. +- There are no foreign keys between content tables; relations are + resolved in code (`attachRelations` in `app/lib/cms/queries.ts`). +- `cms_categories.post_count` / `cms_tags.post_count` are stale import + figures — never display them. +- `cms_products` / `cms_product_categories` (shops only) are neither + rendered nor edited by this starter. + +## Security model + +- The browser only has the **anon** key; there is no server and no + secret in the project. RLS grants anon `select` on every `cms_` table + (`using (true)`), so drafts are readable too — the site filters to + `status = 'publish'` in its queries. Don't store anything private in + `cms_` tables. +- `cms_*` tables follow the safety rules in Bolt's `bolt-database` + skill. They exist because the WordPress importer owns these tables and + may re-run against them. One exception to that skill's "use foreign + key constraints" advice: don't add foreign keys between importer + tables — the importer writes rows (and parent references) in batches + that assume none. + +## Admin (`/bolt-admin`) + +The admin is built on Bolt's `bolt-admin-dashboard` skill: the bridge, +the manifest rules (`readOnly`, `confirm`, `preview`, `description`), +error codes and deploy-before-use all apply as written there. This +project differs in three ways, and these take precedence: + +1. **The admin already exists.** Extend it (`bolt-cms-extend`); never + build a second dashboard or a new `/bolt-admin` route. +2. **Queries are authored in `app/admin/queries.ts`**, not in + `.bolt/admin.json`. Apply the skill's manifest rules to the entries + there, then run `npm run manifest` to regenerate the JSON (it also + validates those rules; `npm run typecheck` fails when the JSON is + stale). +3. **The client already exists** at `app/admin/bridge/client.ts` (the + skill's client, with `runQuery` typed by `AdminQueryName` and errors + as `BoltBridgeError`). Don't create `src/lib/bolt-admin.ts`. Screens + read `useCanEdit()` and treat `isRejectedByUser(e)` as a cancel + (`app/admin/hooks.ts`); all data goes through `app/admin/api.ts`. + +## Working with content + +- Read on the site with `app/lib/cms/queries.ts`: `listPosts`, + `getPostBySlug`, `getPostById`, `getPageById`, `getPageByPath`, + `listTopLevelPages`, `getTermBySlug`, `listTerms`, `getAuthorBySlug`, + `getComments`, `getMenu`, `getSettings`. They filter to published + rows and return `PostWithRelations` (`authorRow`, `featuredAsset`, + `categoryTerms`, `tagTerms`). +- Render bodies with `` + (`app/lib/cms/format.ts`): Portable Text when `body` is non-empty, + else `content_html`; sanitized with DOMPurify. Don't + `dangerouslySetInnerHTML` content elsewhere. If you rewrite + `content_html` by SQL, also update or null `body`, which wins. +- Image URLs: `assetUrl(asset)` (`app/lib/cms/media.ts`) returns the + `cms-media` `public_url`, or `original_url` when the importer could + not copy the file (`upload_error` is set). +- URLs follow WordPress: `/:slug` for posts (date permalinks like + `/2024/01/hello-world/` resolve by the last segment), `/parent/child` + for pages, `/category/:slug`, `/tag/:slug`, `/author/:slug`, + `/search?s=`, `/blog` when `show_on_front = 'page'`. The catch-all + resolves redirects → page path → post slug. Without a `primary` menu + the header lists top-level pages. + +## Schema changes + +Apply them as the `bolt-database` skill says. The two files in +`supabase/migrations/` were applied when the Bolt Database was created; +don't add more files there. New tables start with `cms_`, enable RLS, +and get a public read policy only if the site reads them. See +`bolt-cms-extend` for making a new column editable. + +## Settings + +`SiteSettings` (`app/lib/cms/types.ts`) merges `cms_site` (title, +tagline, url) with `cms_settings` rows; `cms_settings` wins. The site +reads it with `getSettings()` (cached 15 s, `invalidateSettings()` to +clear); the admin writes one row per key with the `upsertSetting` query +(`saveSettings()` in `app/admin/api.ts`). + +## Don'ts + +- Don't hand-edit `.bolt/admin.json` or `0001_bolt_cms_schema.sql`; + both are generated. diff --git a/bolt-cms-react-router/.env.example b/bolt-cms-react-router/.env.example new file mode 100644 index 00000000..5ead3920 --- /dev/null +++ b/bolt-cms-react-router/.env.example @@ -0,0 +1,3 @@ +# 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 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..c750b6e6 --- /dev/null +++ b/bolt-cms-react-router/README.md @@ -0,0 +1,141 @@ +# 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 that renders the schema Bolt's WordPress importer writes, with +three switchable themes and a built-in `/bolt-admin` that Bolt shows in +its Admin tab. + +The agent skills in `.bolt/skills/` cover day-to-day work: `bolt-cms` +(content, schema, security), `bolt-cms-theme` (visuals), and +`bolt-cms-extend` (new fields, editable custom post types, site tables). + +## Quick start + +```bash +cp .env.example .env # fill in your Supabase project values +npm install +npm run dev # public site at http://localhost:5173 +npm run build # static output in build/client +``` + +`/bolt-admin` only works inside Bolt's Admin tab; opened directly it +shows "Open this page from the Admin tab in Bolt." + +### Environment + +| Key | Purpose | +| ------------------------ | ----------------------------------------------- | +| `VITE_SUPABASE_URL` | Supabase project URL | +| `VITE_SUPABASE_ANON_KEY` | anon key; RLS limits it to reading `cms_` tables | + +There is no server and no secret. Without these 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, Portable Text, path resolution, SEO meta +app/theme/ token contract + classic / editorial / minimal themes +app/routes/admin/ dashboard, posts, pages, authors, categories, tags, + comments, media, menus, appearance, settings +app/admin/ admin UI kit, field inputs, Tiptap editor, bridge client, + queries.ts (every admin SQL statement) +.bolt/admin.json admin query manifest, generated from app/admin/queries.ts +scripts/ build-admin-manifest.ts +supabase/migrations/ importer schema (0001) and starter tables + seed (0002) +bolt-cms.json starter version marker (boltCmsVersion) +``` + +## Architecture + +### Public site + +Routes use `clientLoader` and the anon key. URLs follow WordPress: +`/:slug` for posts (date permalinks resolve by their last segment), +`/parent/child` for hierarchical pages, `/search?s=term` for search. +Redirects in `cms_redirects` are honored by the catch-all so old +WordPress paths keep working. Post bodies are Portable Text (`body`), +falling back to the imported `content_html`; the HTML is sanitized with +DOMPurify and WordPress block classes are styled in +`app/theme/tokens.css`. Comments are read-only. Products +(`cms_products`) are neither rendered nor edited. + +### Admin + +Bolt iframes `/bolt-admin`. The page talks to Bolt over +`postMessage` (`app/admin/bridge/client.ts`) and may call only two +methods: + +- `bolt.hello` — protocol version and the user's permissions + (`canEdit`; read-only users get disabled write buttons). +- `db.run { name, parameters }` — runs a query declared by name in + `.bolt/admin.json`, as the signed-in Bolt user. + +Queries are authored in `app/admin/queries.ts` (`ADMIN_QUERIES`; the +`sql` tag collapses whitespace; `contentQueries(table, word)` builds the +eight list/count/get/options/insert/update/setStatus/remove queries for +`cms_posts` and `cms_pages`). `npm run manifest` validates them and +writes `.bolt/admin.json`; never edit the JSON by hand. Deletes carry +`confirm: true`, so Bolt asks the user before running them. Bolt reads +the manifest from the deployed revision, so query changes need a +publish. There is no local stand-in for the host. + +### 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 the +`cms_settings` key `theme`, picked in Admin → Appearance. See the +`bolt-cms-theme` skill for adding one. + +## Schema + +Bolt applies `supabase/migrations/*.sql` when it creates the project's +Bolt Database. Both files are idempotent and leave an imported database +untouched: + +- `0001_bolt_cms_schema.sql` — generated from Bolt's WordPress importer + (`cms_site`, `cms_types`, `cms_fields`, `cms_assets`, `cms_authors`, + `cms_categories`, `cms_tags`, `cms_posts`, `cms_pages`, + `cms_comments`, their public-read RLS policies, and the + `cms_types`/`cms_fields` registry rows). Never hand-edit it. +- `0002_starter.sql` — starter-owned tables (`cms_settings`, + `cms_menus`, `cms_menu_items`, `cms_redirects`) with public-read RLS, + default `cms_site`/`cms_settings` rows that only fill gaps, and a seed + inserted only into empty tables: author "Admin", category + "Uncategorized", post "Hello world!", page "Sample Page", and a + primary menu (Home, Sample Page). + +`0001` is regenerated by Bolt maintainers when the importer's schema +changes. + +Later schema changes go through the `apply_migration` Supabase tool, +not new migration files. + +## Media + +Media rows live in `cms_assets`; files live in the public Supabase +Storage bucket `cms-media`, both written by the WordPress importer. The +admin library is read-only apart from title, alt text, and caption; it +cannot add or delete files. To add one, put it in the `cms-media` bucket +of the Bolt Database and insert a `cms_assets` row. + +## Verification + +```bash +npm run typecheck && npm run check && npm run build +``` + +- `typecheck` runs route typegen, `tsc`, and `npm run manifest -- --check`, + which fails when `.bolt/admin.json` is stale. +- `check` runs the Portable Text converter asserts + (`app/lib/cms/portable-text.check.ts`). +- `build` outputs `build/client` (SPA; Bolt hosting falls back to + `index.html`). + +The starter version is `boltCmsVersion` in `bolt-cms.json`; bump it when +the schema or admin queries change 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..328c0f4c --- /dev/null +++ b/bolt-cms-react-router/app/admin/api.ts @@ -0,0 +1,482 @@ +/** + * Typed data access for the admin. Every function runs one named query from + * `./queries.ts` through Bolt's bridge; nothing talks to Supabase directly. + * + * Parameter conventions: scalars as-is (`null` allowed), `bigint[]` via + * `pgArray`, `jsonb` via `JSON.stringify`, timestamps as ISO strings. + */ +import type { PortableTextBlock } from '@/lib/cms/portable-text'; +import { + mergeSettings, + type Asset, + type Author, + type Comment, + type ContentTable, + type FieldDef, + type Menu, + type MenuItem, + type Post, + type SiteRow, + type SiteSettings, + type Term, +} from '@/lib/cms/types'; + +import { pgArray, runQuery } from './bridge/client'; +import type { AdminQueryName } from './queries'; + +/* ------------------------------------------------------------------------ */ +/* Posts and pages */ +/* ------------------------------------------------------------------------ */ + +export type ContentType = 'post' | 'page'; + +type ContentQuery = + | 'list' + | 'count' + | 'get' + | 'insert' + | 'update' + | 'setStatus' + | 'remove' + | 'options'; + +export const CONTENT_TYPES: Record< + ContentType, + { + label: string; + singular: string; + table: ContentTable; + queries: Record; + } +> = { + post: { + label: 'Posts', + singular: 'post', + table: 'cms_posts', + queries: { + list: 'listPosts', + count: 'countPosts', + get: 'getPost', + insert: 'insertPost', + update: 'updatePost', + setStatus: 'setPostStatus', + remove: 'deletePost', + options: 'listPostOptions', + }, + }, + page: { + label: 'Pages', + singular: 'page', + table: 'cms_pages', + queries: { + list: 'listPages', + count: 'countPages', + get: 'getPage', + insert: 'insertPage', + update: 'updatePage', + setStatus: 'setPageStatus', + remove: 'deletePage', + options: 'listPageOptions', + }, + }, +}; + +export function isContentType(value: string | undefined): value is ContentType { + return value === 'post' || value === 'page'; +} + +/** Bolt stores the WordPress status as free text; imported rows are `publish`. */ +export const STATUSES = ['publish', 'draft', 'trash'] as const; + +export interface ContentInput { + title: string; + slug: string; + excerpt: string | null; + body: PortableTextBlock[] | null; + content_html: string | null; + status: string; + author: number | null; + featured_image: string | null; + categories: number[]; + tags: number[]; + parent: number | null; + menu_order: number | null; + published_at: string | null; +} + +export type ContentListRow = Pick< + Post, + | 'id' + | 'title' + | 'slug' + | 'status' + | 'author' + | 'featured_image' + | 'published_at' + | 'modified_at' +>; + +export interface ContentOption { + id: number; + title: string; +} + +/** imported rows may carry null arrays */ +function normalizePost(row: Post): Post { + return { ...row, categories: row.categories ?? [], tags: row.tags ?? [] }; +} + +function contentParams(input: ContentInput) { + return [ + input.title, + input.slug, + input.excerpt, + input.body === null ? null : JSON.stringify(input.body), + input.content_html, + input.status, + input.author, + input.featured_image, + pgArray(input.categories), + pgArray(input.tags), + input.parent, + input.menu_order, + input.published_at, + ]; +} + +export function listContent( + type: ContentType, + { + status = 'all', + search = '', + page = 1, + perPage = 20, + }: { status?: string; search?: string; page?: number; perPage?: number } = {} +) { + return runQuery(CONTENT_TYPES[type].queries.list, [ + status, + search, + perPage, + (page - 1) * perPage, + ]); +} + +export async function countContent( + type: ContentType +): Promise> { + const rows = await runQuery<{ status: string; count: number }>( + CONTENT_TYPES[type].queries.count + ); + return Object.fromEntries(rows.map((r) => [r.status, r.count])); +} + +export async function getContent( + type: ContentType, + id: number +): Promise { + const [row] = await runQuery(CONTENT_TYPES[type].queries.get, [id]); + return row ? normalizePost(row) : null; +} + +export async function insertContent( + type: ContentType, + input: ContentInput +): Promise { + const [row] = await runQuery( + CONTENT_TYPES[type].queries.insert, + contentParams(input) + ); + return normalizePost(row); +} + +export async function updateContent( + type: ContentType, + id: number, + input: ContentInput +): Promise { + const [row] = await runQuery(CONTENT_TYPES[type].queries.update, [ + id, + ...contentParams(input), + ]); + return normalizePost(row); +} + +export function setContentStatus( + type: ContentType, + id: number, + status: string +) { + return runQuery<{ id: number; status: string }>( + CONTENT_TYPES[type].queries.setStatus, + [id, status] + ); +} + +export function deleteContent(type: ContentType, id: number) { + return runQuery<{ id: number }>(CONTENT_TYPES[type].queries.remove, [id]); +} + +export function listContentOptions(type: ContentType) { + return runQuery(CONTENT_TYPES[type].queries.options); +} + +/* ------------------------------------------------------------------------ */ +/* Authors, categories, tags */ +/* ------------------------------------------------------------------------ */ + +export type CollectionKind = 'author' | 'category' | 'tag'; + +export const COLLECTIONS: Record< + CollectionKind, + { + label: string; + singular: string; + typeName: string; + hierarchical: boolean; + columns: string[]; + queries: Record<'list' | 'insert' | 'update' | 'remove', AdminQueryName>; + } +> = { + author: { + label: 'Authors', + singular: 'author', + typeName: 'author', + hierarchical: false, + columns: ['name', 'slug', 'bio', 'avatar_url', 'url'], + queries: { + list: 'listAuthors', + insert: 'insertAuthor', + update: 'updateAuthor', + remove: 'deleteAuthor', + }, + }, + category: { + label: 'Categories', + singular: 'category', + typeName: 'category', + hierarchical: true, + columns: ['name', 'slug', 'description', 'parent'], + queries: { + list: 'listCategories', + insert: 'insertCategory', + update: 'updateCategory', + remove: 'deleteCategory', + }, + }, + tag: { + label: 'Tags', + singular: 'tag', + typeName: 'tag', + hierarchical: false, + columns: ['name', 'slug', 'description'], + queries: { + list: 'listTags', + insert: 'insertTag', + update: 'updateTag', + remove: 'deleteTag', + }, + }, +}; + +export function isCollectionKind( + value: string | undefined +): value is CollectionKind { + return value === 'author' || value === 'category' || value === 'tag'; +} + +export type CollectionRow = K extends 'author' + ? Author + : Term; + +export type CollectionValues = Record; + +const collectionParams = (kind: CollectionKind, values: CollectionValues) => + COLLECTIONS[kind].columns.map((column) => values[column] ?? null); + +export function listCollection(kind: K) { + return runQuery>(COLLECTIONS[kind].queries.list); +} + +export async function insertCollection( + kind: K, + values: CollectionValues +) { + const [row] = await runQuery>( + COLLECTIONS[kind].queries.insert, + collectionParams(kind, values) + ); + return row; +} + +export async function updateCollection( + kind: K, + id: number, + values: CollectionValues +) { + const [row] = await runQuery>( + COLLECTIONS[kind].queries.update, + [id, ...collectionParams(kind, values)] + ); + return row; +} + +export function deleteCollection(kind: CollectionKind, id: number) { + return runQuery<{ id: number }>(COLLECTIONS[kind].queries.remove, [id]); +} + +/* ------------------------------------------------------------------------ */ +/* Comments (read-only + delete) */ +/* ------------------------------------------------------------------------ */ + +export interface AdminComment extends Omit { + post_title: string | null; + post_slug: string | null; +} + +export function listComments({ + page = 1, + perPage = 20, +}: { page?: number; perPage?: number } = {}) { + return runQuery('listComments', [ + perPage, + (page - 1) * perPage, + ]); +} + +export async function countComments(): Promise { + const [row] = await runQuery<{ count: number }>('countComments'); + return row?.count ?? 0; +} + +export function deleteComment(id: number) { + return runQuery<{ id: number }>('deleteComment', [id]); +} + +/* ------------------------------------------------------------------------ */ +/* Media (`cms_assets`: read + metadata only) */ +/* ------------------------------------------------------------------------ */ + +export function listAssets({ + search = '', + page = 1, + perPage = 40, +}: { search?: string; page?: number; perPage?: number } = {}) { + return runQuery('listAssets', [search, perPage, (page - 1) * perPage]); +} + +export async function countAssets(search = ''): Promise { + const [row] = await runQuery<{ count: number }>('countAssets', [search]); + return row?.count ?? 0; +} + +export async function getAsset(id: string): Promise { + const [row] = await runQuery('getAsset', [id]); + return row ?? null; +} + +export function updateAsset( + id: string, + values: { title: string | null; alt: string | null; caption: string | null } +) { + return runQuery<{ id: string }>('updateAsset', [ + id, + values.title, + values.alt, + values.caption, + ]); +} + +/* ------------------------------------------------------------------------ */ +/* Settings */ +/* ------------------------------------------------------------------------ */ + +export async function getAdminSettings(): Promise { + const [rows, [site]] = await Promise.all([ + runQuery<{ key: string; value: unknown }>('listSettings'), + runQuery('getSite'), + ]); + return mergeSettings(rows, site ?? null); +} + +export async function saveSettings( + values: Partial> +) { + for (const [key, value] of Object.entries(values)) { + await runQuery('upsertSetting', [key, JSON.stringify(value ?? null)]); + } +} + +/* ------------------------------------------------------------------------ */ +/* Menus */ +/* ------------------------------------------------------------------------ */ + +export function listMenus() { + return runQuery('listMenus'); +} + +export async function insertMenu(menu: { + name: string; + slug: string; + location: string | null; +}): Promise { + const [row] = await runQuery('insertMenu', [ + menu.name, + menu.slug, + menu.location, + ]); + return row; +} + +export function updateMenuLocation(id: number, location: string | null) { + return runQuery('updateMenuLocation', [id, location]); +} + +export function deleteMenu(id: number) { + return runQuery<{ id: number }>('deleteMenu', [id]); +} + +export function listMenuItems(menuId: number) { + return runQuery('listMenuItems', [menuId]); +} + +const menuItemParams = (item: Omit) => [ + item.parent_id, + item.position, + item.title, + item.url, + item.object_type, + item.object_id, + item.target, + item.classes, + item.description, +]; + +export async function insertMenuItem( + item: Omit +): Promise { + const [row] = await runQuery('insertMenuItem', [ + item.menu_id, + ...menuItemParams(item), + ]); + return row; +} + +export async function updateMenuItem(item: MenuItem): Promise { + const [row] = await runQuery('updateMenuItem', [ + item.id, + ...menuItemParams(item), + ]); + return row; +} + +export function deleteMenuItem(id: number) { + return runQuery<{ id: number }>('deleteMenuItem', [id]); +} + +/* ------------------------------------------------------------------------ */ +/* Field registry */ +/* ------------------------------------------------------------------------ */ + +export function getFields(typeName: string) { + return runQuery('listFields', [typeName]); +} 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..8fc6c280 --- /dev/null +++ b/bolt-cms-react-router/app/admin/bridge/client.ts @@ -0,0 +1,75 @@ +/** + * Bolt admin bridge client (bolt-admin-dashboard skill). The page runs only + * inside Bolt's Admin tab and may call `bolt.hello` and `db.run`; every query + * it runs is declared by name in `.bolt/admin.json` (see `../queries.ts`). + * + * No timeout: Bolt answers every request, and confirmed queries legitimately + * wait on the user. No origin allowlist: Bolt replies with this frame's origin + * as `targetOrigin`, and replies are matched on `event.source`, source and id. + */ +import type { AdminQueryName } from '../queries'; + +const PAGE_SOURCE = 'bolt-project-admin-page'; +const HOST_SOURCE = 'bolt-project-admin-host'; + +export class BoltBridgeError extends Error { + readonly code: string; + constructor(message: string, code: string) { + super(message); + this.code = code; + } +} + +export interface BoltHello { + protocolVersion: number; + capabilities: string[]; + permissions: { canEdit: boolean }; + queries: string[]; +} + +export function isInsideBolt() { + return window.parent !== window; +} + +export function boltRequest( + method: string, + params?: unknown +): Promise { + const id = crypto.randomUUID(); + + return new Promise((resolve, reject) => { + const onMessage = (event: MessageEvent) => { + const data = event.data; + if ( + event.source !== window.parent || + data?.source !== HOST_SOURCE || + data.id !== id + ) + return; + window.removeEventListener('message', onMessage); + if ('error' in data) + reject(new BoltBridgeError(data.error.message, data.error.code)); + else resolve(data.result as T); + }; + window.addEventListener('message', onMessage); + window.parent.postMessage({ source: PAGE_SOURCE, id, method, params }, '*'); + }); +} + +export function runQuery>( + name: AdminQueryName, + parameters: unknown[] = [] +) { + return boltRequest<{ rows: Row[] }>('db.run', { name, parameters }).then( + (r) => r.rows + ); +} + +export function hello() { + return boltRequest('bolt.hello'); +} + +/** Postgres array literal for `$n::bigint[]` parameters. */ +export function pgArray(values: ReadonlyArray) { + return `{${values.join(',')}}`; +} 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..80745946 --- /dev/null +++ b/bolt-cms-react-router/app/admin/components/MediaPicker.tsx @@ -0,0 +1,155 @@ +import { useState } from 'react'; + +import { countAssets, listAssets } from '@/admin/api'; +import { useAsync, useDebounced } from '@/admin/hooks'; +import { assetUrl } from '@/lib/cms/media'; +import type { Asset } from '@/lib/cms/types'; + +import { Dialog, EmptyState, ErrorNote, Input, Pager, Spinner, cx } from './ui'; + +export const MEDIA_NOTE = + 'Media is managed by the WordPress import; to add files, upload them to the `cms-media` bucket in your Bolt Database and insert a `cms_assets` row.'; + +const PER_PAGE = 40; + +/** Searchable, paginated `cms_assets` grid. Bump `version` to reload after an edit. */ +export function AssetBrowser({ + onSelect, + selectedId, + version = 0, +}: { + onSelect: (asset: Asset) => void; + selectedId?: string | null; + version?: number; +}) { + const [search, setSearch] = useState(''); + const debounced = useDebounced(search); + const [page, setPage] = useState(1); + const assets = useAsync( + () => + Promise.all([ + listAssets({ search: debounced, page, perPage: PER_PAGE }), + countAssets(debounced), + ]), + [debounced, page, version] + ); + const items = assets.data?.[0] ?? []; + const total = assets.data?.[1] ?? 0; + + return ( + <> +
+ { + setSearch(e.target.value); + setPage(1); + }} + className="max-w-xs" + /> +
+ {assets.error && } + {assets.loading && !assets.data ? ( + + ) : !items.length ? ( + + ) : ( + <> + + + + )} + + ); +} + +/** Media library dialog: pick an existing asset. */ +export function MediaPicker({ + open, + onClose, + onSelect, +}: { + open: boolean; + onClose: () => void; + onSelect: (asset: Asset) => void; +}) { + return ( + + + + ); +} + +export function MediaGrid({ + items, + onSelect, + selectedId, +}: { + items: Asset[]; + onSelect: (asset: Asset) => void; + selectedId?: string | null; +}) { + return ( +
+ {items.map((a) => ( + + ))} +
+ ); +} + +export function MediaThumb({ + asset, + className, +}: { + asset: Asset; + className?: string; +}) { + const url = assetUrl(asset); + if (asset.kind === 'image' && url) { + return ( + {asset.alt + ); + } + return ( + + {asset.filename} + + ); +} 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..1ce22947 --- /dev/null +++ b/bolt-cms-react-router/app/admin/components/TermsPanel.tsx @@ -0,0 +1,228 @@ +import { Plus, X } from 'lucide-react'; +import { useState } from 'react'; + +import { COLLECTIONS, insertCollection } from '@/admin/api'; +import { errorMessage, isRejectedByUser } from '@/admin/hooks'; +import { slugify } from '@/lib/cms/format'; +import type { Term } from '@/lib/cms/types'; + +import { Button, Card, Checkbox, Input, useToast } from './ui'; + +/** + * Category checklist (hierarchical) or tag chips, bound to a `bigint[]` of ids. + * New terms are created immediately so their ids exist when the post saves. + */ +export function TermsPanel({ + kind, + terms, + selected, + onChange, + onCreated, + disabled, +}: { + kind: 'category' | 'tag'; + terms: Term[]; + selected: number[]; + onChange: (ids: number[]) => void; + onCreated?: (term: Term) => void; + disabled?: boolean; +}) { + const { label, singular } = COLLECTIONS[kind]; + 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 insertCollection(kind, { + name: clean, + slug: slugify(clean), + }); + onCreated?.(term); + onChange([...selected, term.id]); + setDraft(''); + } catch (e) { + if (!isRejectedByUser(e)) toast(errorMessage(e), 'error'); + } finally { + setAdding(false); + } + } + + if (kind === 'tag') { + 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={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); + } + }} + disabled={disabled} + /> + +
+
+ ); +} + +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 ? byId.get(n.parent) : undefined; + if (parent) parent.children.push(n); + else roots.push(n); + }); + return roots; +} + +function TermChecklist({ + nodes, + selected, + onChange, + disabled, + depth = 0, +}: { + nodes: TermNode[]; + selected: number[]; + onChange: (ids: number[]) => void; + disabled?: boolean; + 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..735d2eb4 --- /dev/null +++ b/bolt-cms-react-router/app/admin/components/editor/RichTextEditor.tsx @@ -0,0 +1,410 @@ +/** + * Tiptap-based rich text editor. + * + * Opens from the post's Portable Text `body` (or the imported `content_html` + * when there is no body) and emits both on change: Portable Text is what the + * site renders, HTML is the fallback copy stored in `content_html`. + */ +import { generateHTML } from '@tiptap/core'; +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 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 { useState } from 'react'; + +import { assetUrl } from '@/lib/cms/media'; +import { + portableTextToHtml, + proseMirrorToPortableText, + type PortableTextBlock, +} from '@/lib/cms/portable-text'; +import type { Asset } from '@/lib/cms/types'; + +import { MediaPicker } from '../MediaPicker'; +import { Button, cx, Input, Textarea } from '../ui'; + +const 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: 'Start writing…' }), +]; + +/** Initial content is read once; remount (`key`) to load another document. */ +export function RichTextEditor({ + initialBody, + initialHtml, + onChange, +}: { + initialBody: PortableTextBlock[] | null; + initialHtml: string | null; + onChange: (value: { body: PortableTextBlock[]; html: string }) => void; +}) { + const [initial] = useState(() => + initialBody?.length ? portableTextToHtml(initialBody) : initialHtml ?? '' + ); + const [mode, setMode] = useState<'visual' | 'html'>('visual'); + const [pickImage, setPickImage] = useState(false); + const [source, setSource] = useState(initial); + + const editor = useEditor({ + immediatelyRender: false, + extensions, + content: initial, + editorProps: { + attributes: { + class: + 'entry-content admin-editor min-h-[24rem] px-5 py-4 focus:outline-none', + }, + }, + onUpdate: ({ editor }) => { + const html = editor.getHTML(); + setSource(html); + onChange({ + body: proseMirrorToPortableText(editor.getJSON(), (node) => + generateHTML({ type: 'doc', content: [node] }, extensions) + ), + html, + }); + }, + }); + + function applySource() { + if (!editor) return; + editor.commands.setContent(source, { emitUpdate: true }); + setMode('visual'); + } + + function insertAsset(asset: Asset) { + const url = assetUrl(asset); + setPickImage(false); + if (!url || !editor) return; + editor + .chain() + .focus() + .setImage({ + src: url, + alt: asset.alt ?? '', + title: asset.caption ?? undefined, + }) + .run(); + } + + return ( +
+ setPickImage(true)} + /> + {mode === 'visual' ? ( + + ) : ( +
+