Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

266
packages/admin/CHANGELOG.md Normal file
View File

@@ -0,0 +1,266 @@
# @emdash-cms/admin
## 0.9.0
### Minor Changes
- [#731](https://github.com/emdash-cms/emdash/pull/731) [`9dfc65c`](https://github.com/emdash-cms/emdash/commit/9dfc65c42c04c41088e0c8f5a8ca4347643e2fea) Thanks [@drudge](https://github.com/drudge)! - Adds a `media_picker` Block Kit element: a thumbnail preview with a modal library picker and mime-type filter. Usable in plugin block forms and in Block Kit field widgets. The stored value is the selected asset's URL string, so it is value-compatible with a plain `text_input` — existing content continues to work after swapping. The `mime_type_filter` is restricted to image MIME types (`image/` or `image/<subtype>`); wildcards and non-image types are rejected.
- [#809](https://github.com/emdash-cms/emdash/pull/809) [`e7df21f`](https://github.com/emdash-cms/emdash/commit/e7df21f0adca795cdb233d6e64cd543ead7e2347) Thanks [@ascorbic](https://github.com/ascorbic)! - Adds an optional `category` field to `PortableTextBlockConfig` for plugin-contributed block types. Plugins can now choose how their blocks are grouped in the admin slash menu (e.g. "Sections", "Marketing", "Media", "Layout") instead of always falling under "Embeds". Existing plugins that omit the field continue to render under "Embeds" exactly as before.
- [#814](https://github.com/emdash-cms/emdash/pull/814) [`a838000`](https://github.com/emdash-cms/emdash/commit/a83800068678daf6391e02bba8acf27ff4db0e19) Thanks [@arashackdev](https://github.com/arashackdev)! - rtl srtyle improvements and LTR/RTL compatible arrow/caret icons
- [#854](https://github.com/emdash-cms/emdash/pull/854) [`491aeec`](https://github.com/emdash-cms/emdash/commit/491aeec5a66e2f764eb9d8ed8425e9d402ada4a7) Thanks [@ask-bonk](https://github.com/apps/ask-bonk)! - Adds consistently-placed sticky Save buttons across editor pages so unsaved changes are always visible. The Content editor, Section editor, Content Type editor, and Settings sub-pages (General, SEO, Social Links) now render their primary save action in a sticky top-right header that stays visible while users scroll long forms. The existing bottom-of-form save buttons are preserved so keyboard and screen-reader users still hit a save action as the last interactive control on the page (DOM order is unchanged). Introduces a shared `EditorHeader` component for editor pages that want the same sticky-header pattern. Fixes #233.
### Patch Changes
- [#849](https://github.com/emdash-cms/emdash/pull/849) [`d6754ae`](https://github.com/emdash-cms/emdash/commit/d6754ae7746b0f9035d2c5e390ece7199762b094) Thanks [@drudge](https://github.com/drudge)! - Fixes the `datetime` field widget so existing values display in the editor and new values pass server validation. The widget passed raw ISO 8601 (`YYYY-MM-DDTHH:mm:ss.sssZ`) into `<input type="datetime-local">`, which silently rendered empty, and emitted `YYYY-MM-DDTHH:mm` on save, which the field's zod schema rejected. Strips the suffix for display, appends `:00.000Z` on save, and normalizes date-only stored values to UTC midnight for the input. Applies to the top-level `datetime` widget in the content editor and the `datetime` sub-field type inside `RepeaterField`.
- [#702](https://github.com/emdash-cms/emdash/pull/702) [`0ee372a`](https://github.com/emdash-cms/emdash/commit/0ee372a7f33eecce7d90e12624923d2d9c132adf) Thanks [@ilicfilip](https://github.com/ilicfilip)! - Adds `@emdash-cms/plugin-field-kit` — composable field widgets for `json` fields. Four widgets (`object-form`, `list`, `grid`, `tags`) are configured entirely through seed `options` so site builders don't need to write React to get a usable editing UI. Widgets store clean JSON (no nesting, no mutation of shape), so removing the plugin leaves valid data in the database. See discussion #571 for background.
Widens `FieldDescriptor.options` to `Array<{ value: string; label: string }> | Record<string, unknown>` so plugin widgets can accept arbitrary widget config (not only enum choices). The array shape for `select` / `multiSelect` continues to work unchanged.
- [#856](https://github.com/emdash-cms/emdash/pull/856) [`ef3f076`](https://github.com/emdash-cms/emdash/commit/ef3f076c8112e9dffc2a87c019e5521e823f5e86) Thanks [@ask-bonk](https://github.com/apps/ask-bonk)! - Fixes `npm install` peer dependency conflicts (#819) by removing `react` and `react-dom` from `dependencies`. They were declared in both `dependencies` and `peerDependencies`, which made npm think the admin package required an exact pinned React version and conflicted with the host Astro app's React. They remain `peerDependencies` (`^18.0.0 || ^19.0.0`), and the host app supplies React.
- [#821](https://github.com/emdash-cms/emdash/pull/821) [`8d0feb3`](https://github.com/emdash-cms/emdash/commit/8d0feb3eece62b01075260bbb79188984a8631b8) Thanks [@r2sake](https://github.com/r2sake)! - Fixes the Settings (gear) icon on the Plugin Manager so it links to the plugin's primary admin page instead of a non-existent `/settings` sub-route.
- [#862](https://github.com/emdash-cms/emdash/pull/862) [`8354088`](https://github.com/emdash-cms/emdash/commit/83540887936a87a6c99230b21d2afe3fe424218c) Thanks [@ask-bonk](https://github.com/apps/ask-bonk)! - Fixes slug-style `<input pattern="...">` attributes so HTML form validation works in current browsers. The patterns used `[a-z0-9-]+`, which is rejected as `Invalid character class` when compiled with the `v` (unicode-sets) flag — the mode browsers now use for the `pattern` attribute. The dangling `-` is now escaped (`[a-z0-9\-]+`), restoring slug validation in the Sections list/edit, Menus list, and Widgets create-area dialogs. Resolves #845.
- [#887](https://github.com/emdash-cms/emdash/pull/887) [`254a443`](https://github.com/emdash-cms/emdash/commit/254a443684ec3bddfc2706b349d6ccce901987af) Thanks [@ascorbic](https://github.com/ascorbic)! - Fixes stale content shown in the Portable Text editor when switching between translations of the same content. Previously, navigating from one locale's editor to another (e.g. from the English version of a post to the French version) kept the previous locale's body in the editor, and any subsequent edit would silently overwrite the new translation's content. The form now resets synchronously when the underlying content item changes, and field editors are keyed by item id so they remount cleanly on a translation switch.
- [#885](https://github.com/emdash-cms/emdash/pull/885) [`25128b2`](https://github.com/emdash-cms/emdash/commit/25128b2444853e3301af7ff09d21a3f5883a599f) Thanks [@ahliweb](https://github.com/ahliweb)! - Fixes malformed ICU plural syntax in Indonesian (id) locale — ContentList item count now renders correctly
- [#872](https://github.com/emdash-cms/emdash/pull/872) [`ab45916`](https://github.com/emdash-cms/emdash/commit/ab45916e8561678ccddf7d6184a7d56729ea03cc) Thanks [@ahliweb](https://github.com/ahliweb)! - Enables Indonesian (Bahasa Indonesia) locale in the admin UI
- [#807](https://github.com/emdash-cms/emdash/pull/807) [`0913a39`](https://github.com/emdash-cms/emdash/commit/0913a39a23538c96bfa62fe7da37bf332d18bb46) Thanks [@ascorbic](https://github.com/ascorbic)! - Sizes the plugin block edit modal based on field complexity so Block Kit forms have room to breathe. Simple URL embeds keep the previous compact dialog; forms with several fields get a wider one, and forms containing a repeater open at the largest size. Inputs inside the dialog now fill the available width.
- [#815](https://github.com/emdash-cms/emdash/pull/815) [`ddbf808`](https://github.com/emdash-cms/emdash/commit/ddbf8088e1bcfa07d6347a953bb1995295e8f8fd) Thanks [@ascorbic](https://github.com/ascorbic)! - Fixes content list loading state showing `No results for ""` instead of a loader while items are being fetched. The trash tab gets the same treatment.
- [#870](https://github.com/emdash-cms/emdash/pull/870) [`1c958fb`](https://github.com/emdash-cms/emdash/commit/1c958fb484387cd8cce7fab53ff4eddfe0dbb7f6) Thanks [@CacheMeOwside](https://github.com/CacheMeOwside)! - Fixes the image-settings icon in the Section editor so it actually opens `<ImageDetailPanel>` in the sidebar.
- [#816](https://github.com/emdash-cms/emdash/pull/816) [`d4be24f`](https://github.com/emdash-cms/emdash/commit/d4be24f478a0c8d0a7bba3c299e11105bba3ed94) Thanks [@ask-bonk](https://github.com/apps/ask-bonk)! - Unifies plugin capability names under a single `<resource>[.<sub-resource>]:<verb>[:<qualifier>]` formula so capabilities read like RBAC permissions, separates hook-registration permissions from data-access ones for clearer audits, and replaces the overloaded `:any` qualifier with the more conspicuous `:unrestricted`. Old names are still accepted with `@deprecated` warnings; `emdash plugin bundle` and `emdash plugin validate` warn for each deprecated name and `emdash plugin publish` refuses manifests that still use them.
The Cloudflare sandbox bridge and HTTP fetch helper now enforce canonical names (`content:read`, `content:write`, `media:read`, `media:write`, `users:read`, `network:request`, `network:request:unrestricted`). Manifests that still declare legacy names continue to work — the runner normalizes capabilities before passing them into the bridge, so installed plugins with `read:content` resolve to `content:read` and reach the same code path.
| Old | New |
| ------------------- | -------------------------------- |
| `read:content` | `content:read` |
| `write:content` | `content:write` |
| `read:media` | `media:read` |
| `write:media` | `media:write` |
| `read:users` | `users:read` |
| `network:fetch` | `network:request` |
| `network:fetch:any` | `network:request:unrestricted` |
| `email:provide` | `hooks.email-transport:register` |
| `email:intercept` | `hooks.email-events:register` |
| `page:inject` | `hooks.page-fragments:register` |
Existing installs keep working — manifests are normalized at every external boundary and `diffCapabilities` normalizes both sides so version upgrades that only rename do not trigger a "capability changed" prompt. Deprecated names will be removed in the next minor.
- Updated dependencies [[`7b8d496`](https://github.com/emdash-cms/emdash/commit/7b8d4964c619821937d1a738cbd6f81e98095a91), [`9dfc65c`](https://github.com/emdash-cms/emdash/commit/9dfc65c42c04c41088e0c8f5a8ca4347643e2fea), [`a838000`](https://github.com/emdash-cms/emdash/commit/a83800068678daf6391e02bba8acf27ff4db0e19)]:
- @emdash-cms/blocks@0.9.0
## 0.8.0
### Minor Changes
- [#679](https://github.com/emdash-cms/emdash/pull/679) [`493e317`](https://github.com/emdash-cms/emdash/commit/493e3172d4539d8e041e6d2bf2d7d2dc89b2a10d) Thanks [@drudge](https://github.com/drudge)! - Adds a `repeater` Block Kit element: array-of-objects with scalar sub-fields, drag-to-reorder, and collapsible item cards. Plugin block forms can now capture repeating data (FAQ rows, carousel slides, card grids) inline in the portable-text editor.
- [#779](https://github.com/emdash-cms/emdash/pull/779) [`e402890`](https://github.com/emdash-cms/emdash/commit/e402890fcd8647fdfe847bb34aa9f9e7094473dd) Thanks [@ascorbic](https://github.com/ascorbic)! - Adds `settings_get` and `settings_update` MCP tools so agents can read and update site-wide settings (title, tagline, logo, favicon, URL, posts-per-page, date format, timezone, social, SEO). `settings_get` resolves media references (logo/favicon/seo.defaultOgImage) to URLs; `settings_update` is a partial update that preserves omitted fields. New `settings:read` (EDITOR+) and `settings:manage` (ADMIN) API token scopes back the tools, with matching options in the personal API token settings UI.
- [#398](https://github.com/emdash-cms/emdash/pull/398) [`31333dc`](https://github.com/emdash-cms/emdash/commit/31333dc593e2b9128113e4e923455209f11853fd) Thanks [@simnaut](https://github.com/simnaut)! - Adds pluggable auth provider system with AT Protocol as the first plugin-based provider. Refactors GitHub and Google OAuth from hardcoded buttons into the same `AuthProviderDescriptor` interface. All auth methods (passkey, AT Protocol, GitHub, Google) are equal options on the login page and setup wizard.
### Patch Changes
- [#611](https://github.com/emdash-cms/emdash/pull/611) [`86b26f6`](https://github.com/emdash-cms/emdash/commit/86b26f6c1067efb28d8f7cb447be23da99d2e38e) Thanks [@drudge](https://github.com/drudge)! - Wires up the block configuration sidebar inside `WidgetEditor`. `PortableTextEditor` now receives `onBlockSidebarOpen`/`onBlockSidebarClose` callbacks that hold the active `BlockSidebarPanel` in local state, and renders `ImageDetailPanel` when the panel type is `"image"` — mirroring the content-entry editor. Without this, clicking a block's settings button or the media picker inside widget content had no visible effect.
- [#786](https://github.com/emdash-cms/emdash/pull/786) [`e998083`](https://github.com/emdash-cms/emdash/commit/e998083115b3c5a6e27707a940dfac557ea72458) Thanks [@smart-cau](https://github.com/smart-cau)! - Adds Korean translations for 21 admin UI strings that previously fell back to English. Korean (ko) coverage is now complete.
- [#670](https://github.com/emdash-cms/emdash/pull/670) [`37ada52`](https://github.com/emdash-cms/emdash/commit/37ada52a62e94f4f0581f4356ba55dc978863f49) Thanks [@segmentationfaulter](https://github.com/segmentationfaulter)! - Change text direction of input fields and tiptap editor depending upon the language entered
- [#720](https://github.com/emdash-cms/emdash/pull/720) [`acab807`](https://github.com/emdash-cms/emdash/commit/acab8071e72a29751a55e923473cd4749e34fefd) Thanks [@Pouf5](https://github.com/Pouf5)! - Fix taxonomies not nesting correctly in a RTL layout
- [#750](https://github.com/emdash-cms/emdash/pull/750) [`0ecd3b4`](https://github.com/emdash-cms/emdash/commit/0ecd3b4901eb721825b36eb4812506032e43da14) Thanks [@edrpls](https://github.com/edrpls)! - Make the admin collection list column headers sortable. `Title`, `Status`, `Locale`, and `Date` are now clickable buttons that toggle direction; the current sort state is exposed via `aria-sort` on the `<th>` so screen readers announce it correctly.
The server's `orderBy` field whitelist now accepts `status`, `locale`, and `name` alongside the existing date fields — unchanged from a security standpoint, the repo still rejects unknown field names to prevent column enumeration.
Callers of `<ContentList>` that don't pass `onSortChange` render the previous static-label headers, so legacy integrations (e.g. the content picker) are unaffected.
- [#184](https://github.com/emdash-cms/emdash/pull/184) [`4c9f04d`](https://github.com/emdash-cms/emdash/commit/4c9f04d9506a9a79cec2425ccb71785a6948843a) Thanks [@masonjames](https://github.com/masonjames)! - Fixes plugin block defaults so initial values are seeded without overriding later edits.
- [#700](https://github.com/emdash-cms/emdash/pull/700) [`ed4d880`](https://github.com/emdash-cms/emdash/commit/ed4d88057e9b26d497181655eecf3e06e12a1001) Thanks [@dcardosods](https://github.com/dcardosods)! - Prefill site title and tagline in Setup Wizard from seed file
- Updated dependencies [[`6e0e921`](https://github.com/emdash-cms/emdash/commit/6e0e9215e00f6f2e84ade30447e4c30b1812dbf5), [`493e317`](https://github.com/emdash-cms/emdash/commit/493e3172d4539d8e041e6d2bf2d7d2dc89b2a10d)]:
- @emdash-cms/blocks@1.0.0
## 0.7.0
### Minor Changes
- [#705](https://github.com/emdash-cms/emdash/pull/705) [`8ebdf1a`](https://github.com/emdash-cms/emdash/commit/8ebdf1af65764cc4b72624e7758c4a666817aade) Thanks [@eba8](https://github.com/eba8)! - Adds admin white-labeling support via `admin` config in `astro.config.mjs`. Agencies can set a custom logo, site name, and favicon for the admin panel, separate from public site settings.
### Patch Changes
- [#680](https://github.com/emdash-cms/emdash/pull/680) [`2e4b205`](https://github.com/emdash-cms/emdash/commit/2e4b205b1df30bdb6bb96259f223b85610de5e78) Thanks [@CacheMeOwside](https://github.com/CacheMeOwside)! - Fixes dark mode toggle having no effect with the classic theme.
- [#732](https://github.com/emdash-cms/emdash/pull/732) [`e3e18aa`](https://github.com/emdash-cms/emdash/commit/e3e18aae92d31cf22efd11a0ba06110de24a076a) Thanks [@jcheese1](https://github.com/jcheese1)! - Fixes select dropdown appearing behind dialog by removing explicit z-index values and adding `isolate` to the admin body for proper stacking context.
- [#647](https://github.com/emdash-cms/emdash/pull/647) [`743b080`](https://github.com/emdash-cms/emdash/commit/743b0807f1a37fdedbcd37632058b557f493f3be) Thanks [@arashackdev](https://github.com/arashackdev)! - Adds Persian (Farsi) locale with full admin translations.
Adds Vazirmatn as the default font family for Farsi.
- [#689](https://github.com/emdash-cms/emdash/pull/689) [`fa8d753`](https://github.com/emdash-cms/emdash/commit/fa8d7533e8ba7e02599372d580399dae88ecd891) Thanks [@edrpls](https://github.com/edrpls)! - Fixes the taxonomy term picker to match across diacritic boundaries.
Typing `Mexico` in the admin picker now surfaces a term labeled `México` instead of prompting a duplicate create. Input and term labels are folded via NFD decomposition + lowercase before substring-matching, so editors who type without diacritics — or with locale keyboards that produce precomposed vs. combining forms — still see the canonical term.
Before this fix, `"mexico"` and `"méxico"` were treated as distinct strings, so the picker showed zero suggestions and the editor had no way to find the existing term except to create a duplicate. Duplicate terms then split the taxonomy and broke public-facing filter pages that group content by slug.
The exact-match check that gates the "Create new term" button uses the same fold, so typing `Mexico` when `México` exists also suppresses Create — closing the duplicate-creation loop.
- Updated dependencies []:
- @emdash-cms/blocks@0.7.0
## 0.6.0
### Minor Changes
- [#565](https://github.com/emdash-cms/emdash/pull/565) [`913cb62`](https://github.com/emdash-cms/emdash/commit/913cb6239510f9959581cb74a70faa53a462a9aa) Thanks [@ophirbucai](https://github.com/ophirbucai)! - Adds full RTL (right-to-left) support to the admin UI by converting all directional Tailwind classes to their direction-aware equivalents.
### Patch Changes
- [#610](https://github.com/emdash-cms/emdash/pull/610) [`dfcb0cd`](https://github.com/emdash-cms/emdash/commit/dfcb0cd4ed65d10212d47622b51a22b0eacf8acb) Thanks [@drudge](https://github.com/drudge)! - Passes plugin block definitions into the `PortableTextEditor` nested inside `WidgetEditor`, so custom plugin-registered block types (image blocks, marker blocks, etc.) can be inserted and rendered inside content-type widgets. The manifest is fetched with react-query in the top-level `Widgets` component, flattened into a `PluginBlockDef[]` list, and threaded through `WidgetAreaPanel``WidgetItem``WidgetEditor`.
- [#568](https://github.com/emdash-cms/emdash/pull/568) [`cf63b02`](https://github.com/emdash-cms/emdash/commit/cf63b0298576d062641cf88f37d6e7e86e4ddb3a) Thanks [@Vallhalen](https://github.com/Vallhalen)! - Fix document outline not showing headings on initial load. The outline now defers initial extraction to next tick (so TipTap finishes hydrating) and also listens for transaction events to catch programmatic content changes.
- [#564](https://github.com/emdash-cms/emdash/pull/564) [`0b32b2f`](https://github.com/emdash-cms/emdash/commit/0b32b2f3906bf5bfed313044af6371480d43edc1) Thanks [@ascorbic](https://github.com/ascorbic)! - Replaces the horizontal language-switch button bar on the admin login page with a dropdown, so the selector stays usable as more locales are added.
- [#592](https://github.com/emdash-cms/emdash/pull/592) [`6c92d58`](https://github.com/emdash-cms/emdash/commit/6c92d58767dc92548136a87cc90c1c6912da6695) Thanks [@asdfgl98](https://github.com/asdfgl98)! - Adds Korean locale support to the admin UI.
- [#559](https://github.com/emdash-cms/emdash/pull/559) [`a2d5afb`](https://github.com/emdash-cms/emdash/commit/a2d5afbb19b5bcaf98464d354322fa737a8b9ba0) Thanks [@ayfl269](https://github.com/ayfl269)! - Adds Chinese (Traditional) translation for the admin UI, including login page, settings page, and locale switching.
- [#604](https://github.com/emdash-cms/emdash/pull/604) [`39d285e`](https://github.com/emdash-cms/emdash/commit/39d285ea3d21b7b6277a554ae9cff011500655e1) Thanks [@all3f0r1](https://github.com/all3f0r1)! - Fixes loading spinner not centered under logo on the login page.
- Updated dependencies []:
- @emdash-cms/blocks@0.6.0
## 0.5.0
### Minor Changes
- [#551](https://github.com/emdash-cms/emdash/pull/551) [`598026c`](https://github.com/emdash-cms/emdash/commit/598026c99083325c281b9e7ab87e9724e11f2c8d) Thanks [@ophirbucai](https://github.com/ophirbucai)! - Adds RTL (right-to-left) language support infrastructure. Enables proper text direction for RTL languages like Arabic, Hebrew, Farsi, and Urdu. Includes LocaleDirectionProvider component that syncs HTML dir/lang attributes with Kumo's DirectionProvider for automatic layout mirroring when locale changes.
### Patch Changes
- [#489](https://github.com/emdash-cms/emdash/pull/489) [`9ea4cf7`](https://github.com/emdash-cms/emdash/commit/9ea4cf7c63cd5a1c45ec569bd72076c935066a1c) Thanks [@all3f0r1](https://github.com/all3f0r1)! - Adds JSON field editor in admin UI content forms
- [#542](https://github.com/emdash-cms/emdash/pull/542) [`64f90d1`](https://github.com/emdash-cms/emdash/commit/64f90d1957af646ca200b9d70e856fa72393f001) Thanks [@mohamedmostafa58](https://github.com/mohamedmostafa58)! - Fixes invite flow: corrects invite URL to point to admin UI page, adds InviteAcceptPage for passkey registration.
- Updated dependencies []:
- @emdash-cms/blocks@0.5.0
## 0.4.0
### Minor Changes
- [#516](https://github.com/emdash-cms/emdash/pull/516) [`20b03b4`](https://github.com/emdash-cms/emdash/commit/20b03b480156a5c901298a1ab9c968c800179215) Thanks [@erral](https://github.com/erral)! - Adds Basque (eu) translation
### Patch Changes
- [#490](https://github.com/emdash-cms/emdash/pull/490) [`3a96aa7`](https://github.com/emdash-cms/emdash/commit/3a96aa7f5671f6c718ab066e02c61fb55b33d901) Thanks [@all3f0r1](https://github.com/all3f0r1)! - Fixes mobile sidebar nav sections not displaying their pages
- [#87](https://github.com/emdash-cms/emdash/pull/87) [`c869df2`](https://github.com/emdash-cms/emdash/commit/c869df2b08decae6dc9c85bdfca83cc6577203cf) Thanks [@txhno](https://github.com/txhno)! - Fixes SEO sidebar text fields firing a PUT on every keystroke by debouncing saves; guards against stale server responses overwriting newer local edits.
- [#302](https://github.com/emdash-cms/emdash/pull/302) [`10ebfe1`](https://github.com/emdash-cms/emdash/commit/10ebfe19b81feacfe99cfaf2daf4976eaac17bd4) Thanks [@ideepakchauhan7](https://github.com/ideepakchauhan7)! - Fixes autosave form reset bug. Autosave no longer invalidates the query cache, preventing form fields from reverting to server state after autosave completes.
- [#36](https://github.com/emdash-cms/emdash/pull/36) [`275a21c`](https://github.com/emdash-cms/emdash/commit/275a21c389c121cbac6daa6be497ae3b6c1bfc6d) Thanks [@scottbuscemi](https://github.com/scottbuscemi)! - Fixes image field removal not persisting after save by sending null instead of undefined, which JSON.stringify was silently dropping.
- [#502](https://github.com/emdash-cms/emdash/pull/502) [`af0647c`](https://github.com/emdash-cms/emdash/commit/af0647c7352922ad63077613771150d8178263ed) Thanks [@pagelab](https://github.com/pagelab)! - Adds Portuguese (Brazil) locale with full pt-BR translations following the WordPress pt-BR glossary standard.
- [#521](https://github.com/emdash-cms/emdash/pull/521) [`b89e7f3`](https://github.com/emdash-cms/emdash/commit/b89e7f3811488ebe8fbe28068baa18f7f25844ad) Thanks [@ascorbic](https://github.com/ascorbic)! - Wraps all user-visible strings in the admin shell and core content screens with Lingui macros so they are translatable. Covers: Sidebar (nav labels, group headings), Header (View Site, Log out, Settings), ThemeToggle, Dashboard (headings, empty states, status indicators), ContentList (table headers, actions, dialogs, status badges), SaveButton, and ContentEditor (publish panel, schedule controls, byline editor, author selector, all dialogs). Runs `locale:extract` to add 116 new message IDs to all catalog files.
- [#528](https://github.com/emdash-cms/emdash/pull/528) [`ba0a5af`](https://github.com/emdash-cms/emdash/commit/ba0a5afccf110465b72916e23db4ff975d81bc2e) Thanks [@ascorbic](https://github.com/ascorbic)! - Wraps all remaining admin UI components with Lingui macros, completing full i18n coverage of the admin interface. Catalog grows from 296 to 1,216 message IDs. Covers media library, menus, sections, redirects, taxonomies, content types, field editor, plugins, marketplace, SEO panels, setup wizard, auth flows, and all settings pages.
- [#504](https://github.com/emdash-cms/emdash/pull/504) [`e2f96aa`](https://github.com/emdash-cms/emdash/commit/e2f96aa74bd936832a3a4d0636e81f948adb51c7) Thanks [@ascorbic](https://github.com/ascorbic)! - Fixes client-side locale switching and replaces toggle buttons with a Select dropdown.
- [#471](https://github.com/emdash-cms/emdash/pull/471) [`4645103`](https://github.com/emdash-cms/emdash/commit/4645103f06ae9481b07dba14af07ac0ff57e32cf) Thanks [@ayfl269](https://github.com/ayfl269)! - Adds Chinese (Simplified) translation for the admin UI, including login page, settings page, and locale switching.
- Updated dependencies []:
- @emdash-cms/blocks@0.4.0
## 0.3.0
### Patch Changes
- [#351](https://github.com/emdash-cms/emdash/pull/351) [`c70f66f`](https://github.com/emdash-cms/emdash/commit/c70f66f7da66311fcf2f5922f23cdf951cdaff5f) Thanks [@CacheMeOwside](https://github.com/CacheMeOwside)! - Fixes redirect loops causing the ERR_TOO_MANY_REDIRECTS error, by detecting circular chains when creating or editing redirects on the admin Redirects page.
- [#499](https://github.com/emdash-cms/emdash/pull/499) [`0b4e61b`](https://github.com/emdash-cms/emdash/commit/0b4e61b059e40d7fc56aceb63d43004c8872005d) Thanks [@ascorbic](https://github.com/ascorbic)! - Fixes admin failing to load when installed from npm due to broken locale catalog resolution.
- Updated dependencies []:
- @emdash-cms/blocks@0.3.0
## 0.2.0
### Minor Changes
- [#111](https://github.com/emdash-cms/emdash/pull/111) [`87b0439`](https://github.com/emdash-cms/emdash/commit/87b0439927454a275833992de4244678b47b9aa3) Thanks [@mvanhorn](https://github.com/mvanhorn)! - Adds repeater field type for structured repeating data
### Patch Changes
- [#467](https://github.com/emdash-cms/emdash/pull/467) [`0966223`](https://github.com/emdash-cms/emdash/commit/09662232bd960e426ca00b10e7d49585aad00f99) Thanks [@sakibmd](https://github.com/sakibmd)! - fix: move useMemo above early returns in ContentListPage
- [#349](https://github.com/emdash-cms/emdash/pull/349) [`53dec88`](https://github.com/emdash-cms/emdash/commit/53dec8822bf486a1748e381087306f6097e6290c) Thanks [@tsikatawill](https://github.com/tsikatawill)! - Fixes menu editor rejecting relative URLs like /about by changing input type from url to text with pattern validation.
- [#99](https://github.com/emdash-cms/emdash/pull/99) [`3b6b75b`](https://github.com/emdash-cms/emdash/commit/3b6b75b01b5674776cb588506d75042d4a2745ea) Thanks [@all3f0r1](https://github.com/all3f0r1)! - Fix content list not fetching beyond the first API page when navigating to the last client-side page
- [#247](https://github.com/emdash-cms/emdash/pull/247) [`a293708`](https://github.com/emdash-cms/emdash/commit/a2937083f8f74e32ad1b0383d9f22b20e18d7237) Thanks [@NaeemHaque](https://github.com/NaeemHaque)! - Fixes email settings page showing empty by registering the missing API route. Adds error state to the admin UI so fetch failures are visible instead of silently swallowed.
- [#316](https://github.com/emdash-cms/emdash/pull/316) [`c9bf640`](https://github.com/emdash-cms/emdash/commit/c9bf64003d161a9517bd78599b3d7f8d0bf93cda) Thanks [@mvanhorn](https://github.com/mvanhorn)! - Allow relative URLs in menu custom links by changing input type from "url" to "text"
- [#377](https://github.com/emdash-cms/emdash/pull/377) [`5eeab91`](https://github.com/emdash-cms/emdash/commit/5eeab918820f680ea8b46903df7d69969af8b8ee) Thanks [@Pouf5](https://github.com/Pouf5)! - Fixes new content always being created with locale `en` regardless of which locale is selected in the collection locale switcher. The "Add New" link now forwards the active locale to the new-content route, and the new-content page passes it through to the create API.
- [#185](https://github.com/emdash-cms/emdash/pull/185) [`e3f7db8`](https://github.com/emdash-cms/emdash/commit/e3f7db8bb670bb7444632ab0cd4e680e4c9029b3) Thanks [@ophirbucai](https://github.com/ophirbucai)! - Fixes field scroll-into-view not triggering when navigating to a field via URL parameter.
- [#93](https://github.com/emdash-cms/emdash/pull/93) [`a5e0603`](https://github.com/emdash-cms/emdash/commit/a5e0603b1910481d042f5a22dd19a60c76da7197) Thanks [@eyupcanakman](https://github.com/eyupcanakman)! - Fix taxonomy links missing from admin sidebar
- Updated dependencies [[`e1349e3`](https://github.com/emdash-cms/emdash/commit/e1349e342f90227c50f253cc2c1fbda0bc288a39)]:
- @emdash-cms/blocks@0.2.0
## 0.1.1
### Patch Changes
- [#328](https://github.com/emdash-cms/emdash/pull/328) [`12d73ff`](https://github.com/emdash-cms/emdash/commit/12d73ff4560551bbe873783e4628bbd80809c449) Thanks [@jdevalk](https://github.com/jdevalk)! - Add OG Image field to content editor
- [#200](https://github.com/emdash-cms/emdash/pull/200) [`422018a`](https://github.com/emdash-cms/emdash/commit/422018aeb227dffe3da7bfc772d86f9ce9c2bcd1) Thanks [@ascorbic](https://github.com/ascorbic)! - Replace placeholder text branding with proper EmDash logo SVGs across admin UI, playground loading page, and preview interstitial
- [#306](https://github.com/emdash-cms/emdash/pull/306) [`71744fb`](https://github.com/emdash-cms/emdash/commit/71744fb8b2bcc7f48acea41f9866878463a4f4f7) Thanks [@JULJERYT](https://github.com/JULJERYT)! - Align back button position in API Tokens section
- [#135](https://github.com/emdash-cms/emdash/pull/135) [`018be7f`](https://github.com/emdash-cms/emdash/commit/018be7f1c3a8b399a9f38d7fa524e6f2908d95c3) Thanks [@fzihak](https://github.com/fzihak)! - Fix content list for large collections by implementing infinite scroll pagination
- [#181](https://github.com/emdash-cms/emdash/pull/181) [`9d10d27`](https://github.com/emdash-cms/emdash/commit/9d10d2791fe16be901d9d138e434bd79cf9335c4) Thanks [@ilicfilip](https://github.com/ilicfilip)! - fix(admin): use collection urlPattern for preview button fallback URL
- [#225](https://github.com/emdash-cms/emdash/pull/225) [`d211452`](https://github.com/emdash-cms/emdash/commit/d2114523a55021f65ee46e44e11157b06334819e) Thanks [@seslly](https://github.com/seslly)! - Adds `passkeyPublicOrigin` on `emdash()` so WebAuthn `origin` and `rpId` match the browser when dev sits behind a TLS-terminating reverse proxy. Validates the value at integration load time and threads it through all passkey-related API routes.
Updates the admin passkey setup and login flows to detect non-secure origins and explain that passkeys need HTTPS or `http://localhost` rather than implying the browser lacks WebAuthn support.
- [#268](https://github.com/emdash-cms/emdash/pull/268) [`ab21f29`](https://github.com/emdash-cms/emdash/commit/ab21f29f713a5aa4c087c535608e1a2cab2ef9e0) Thanks [@doguabaris](https://github.com/doguabaris)! - Fixes passkey login error handling when no credential is returned from the authenticator
- [#221](https://github.com/emdash-cms/emdash/pull/221) [`bfcda12`](https://github.com/emdash-cms/emdash/commit/bfcda121400ee2bbbc35d666cc8bed38e0eba8ea) Thanks [@tohaitrieu](https://github.com/tohaitrieu)! - Fixes form state not updating when switching between taxonomy terms in the editor dialog.
- [#45](https://github.com/emdash-cms/emdash/pull/45) [`5f448d1`](https://github.com/emdash-cms/emdash/commit/5f448d1035073283fd7435d2f320d1f3c94898a0) Thanks [@Flynsarmy](https://github.com/Flynsarmy)! - Adds Back navigation to Security and Domain settings pages
## 0.1.0
### Minor Changes
- [#14](https://github.com/emdash-cms/emdash/pull/14) [`755b501`](https://github.com/emdash-cms/emdash/commit/755b5017906811f97f78f4c0b5a0b62e67b52ec4) Thanks [@ascorbic](https://github.com/ascorbic)! - First beta release
### Patch Changes
- Updated dependencies [[`755b501`](https://github.com/emdash-cms/emdash/commit/755b5017906811f97f78f4c0b5a0b62e67b52ec4)]:
- @emdash-cms/blocks@0.1.0
## 0.0.2
### Patch Changes
- [#8](https://github.com/emdash-cms/emdash/pull/8) [`3c319ed`](https://github.com/emdash-cms/emdash/commit/3c319ed6411a595e6974a86bc58c2a308b91c214) Thanks [@ascorbic](https://github.com/ascorbic)! - Update branding

109
packages/admin/package.json Normal file
View File

@@ -0,0 +1,109 @@
{
"name": "@emdash-cms/admin",
"version": "0.9.0",
"description": "Admin UI for EmDash CMS",
"type": "module",
"main": "dist/index.js",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./styles.css": "./dist/styles.css",
"./locales": {
"types": "./dist/locales/index.d.ts",
"default": "./dist/locales/index.js"
},
"./locales/*": "./dist/locales/*"
},
"scripts": {
"build": "node --run locale:compile && tsdown && node --run locale:copy && npx @tailwindcss/cli -i src/styles.css -o dist/styles.css --minify",
"dev": "tsdown src/index.ts --format esm --dts --watch",
"prepublishOnly": "node --run build",
"check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm --ignore-rules=no-resolution",
"test": "vitest",
"typecheck": "tsgo --noEmit",
"locale:compile": "lingui compile --namespace es",
"locale:copy": "node ./scripts/copy-locales.js",
"locale:extract": "lingui extract --clean"
},
"dependencies": {
"@cloudflare/kumo": "^1.16.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emdash-cms/blocks": "workspace:*",
"@floating-ui/react": "^0.27.16",
"@lingui/core": "catalog:",
"@lingui/react": "catalog:",
"@phosphor-icons/react": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-router": "catalog:",
"@tiptap/core": "catalog:",
"@tiptap/extension-character-count": "catalog:",
"@tiptap/extension-drag-handle": "catalog:",
"@tiptap/extension-drag-handle-react": "catalog:",
"@tiptap/extension-dropcursor": "catalog:",
"@tiptap/extension-focus": "catalog:",
"@tiptap/extension-link": "catalog:",
"@tiptap/extension-node-range": "catalog:",
"@tiptap/extension-placeholder": "catalog:",
"@tiptap/extension-text-align": "catalog:",
"@tiptap/extension-typography": "catalog:",
"@tiptap/extension-underline": "catalog:",
"@tiptap/pm": "catalog:",
"@tiptap/react": "catalog:",
"@tiptap/starter-kit": "catalog:",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.3.2",
"marked": "^17.0.3",
"react-hotkeys-hook": "^5.2.4",
"tailwind-merge": "^3.3.0"
},
"devDependencies": {
"@arethetypeswrong/cli": "catalog:",
"@babel/core": "^7.29.0",
"@lingui/babel-plugin-lingui-macro": "catalog:",
"@lingui/cli": "catalog:",
"@lingui/macro": "catalog:",
"@tailwindcss/cli": "^4.1.10",
"@tailwindcss/typography": "^0.5.19",
"@testing-library/react": "^16.3.0",
"@tiptap/suggestion": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "^4.6.0",
"@vitest/browser-playwright": "^4.0.18",
"jsdom": "^26.1.0",
"playwright": "^1.58.2",
"publint": "catalog:",
"tailwindcss": "^4.1.10",
"tsdown": "catalog:",
"typescript": "catalog:",
"vite": "^7.0.0",
"vitest": "catalog:",
"vitest-browser-react": "^2.0.5"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/emdash-cms/emdash.git",
"directory": "packages/admin"
},
"homepage": "https://github.com/emdash-cms/emdash",
"keywords": [
"astro",
"cms",
"admin",
"react"
],
"author": "Matt Kane",
"license": "MIT"
}

View File

@@ -0,0 +1,18 @@
/**
* Copy compiled locale catalogs (.mjs) from src/locales to dist/locales.
* Run after `lingui compile` to include catalogs in the published package.
*/
import { readdirSync, mkdirSync, copyFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const srcDir = join(__dirname, "..", "src", "locales");
const distDir = join(__dirname, "..", "dist", "locales");
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const destDir = join(distDir, entry.name);
mkdirSync(destDir, { recursive: true });
copyFileSync(join(srcDir, entry.name, "messages.mjs"), join(destDir, "messages.mjs"));
}

View File

@@ -0,0 +1,90 @@
/**
* EmDash Admin React Application
*
* This is the main entry point for the admin SPA.
* Uses TanStack Router for client-side routing and TanStack Query for data fetching.
*
* Plugin admin components are passed via the pluginAdmins prop and made
* available throughout the app via PluginAdminContext.
*/
import { Toasty } from "@cloudflare/kumo";
import { i18n } from "@lingui/core";
import type { Messages } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import * as React from "react";
import { ThemeProvider } from "./components/ThemeProvider";
import { AuthProviderProvider, type AuthProviders } from "./lib/auth-provider-context";
import { PluginAdminProvider, type PluginAdmins } from "./lib/plugin-context";
import { LocaleDirectionProvider } from "./locales/index.js";
import { createAdminRouter } from "./router";
// Create a query client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
retry: 1,
},
},
});
// Create the router with query client context
const router = createAdminRouter(queryClient);
export interface AdminAppProps {
/** Plugin admin modules keyed by plugin ID */
pluginAdmins?: PluginAdmins;
/** Auth provider UI modules keyed by provider ID */
authProviders?: AuthProviders;
/** Active locale code */
locale?: string;
/** Compiled Lingui messages for the active locale */
messages?: Messages;
}
/**
* Main Admin Application
*/
const EMPTY_PLUGINS: PluginAdmins = {};
const EMPTY_AUTH_PROVIDERS: AuthProviders = {};
export function AdminApp({
pluginAdmins = EMPTY_PLUGINS,
authProviders = EMPTY_AUTH_PROVIDERS,
locale = "en",
messages = {},
}: AdminAppProps) {
React.useEffect(() => {
document.getElementById("emdash-boot-loader")?.remove();
}, []);
const i18nInitialized = React.useRef(false);
if (!i18nInitialized.current) {
i18n.loadAndActivate({ locale, messages });
i18nInitialized.current = true;
}
return (
<ThemeProvider>
<I18nProvider i18n={i18n}>
<LocaleDirectionProvider>
<Toasty>
<AuthProviderProvider authProviders={authProviders}>
<PluginAdminProvider pluginAdmins={pluginAdmins}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</PluginAdminProvider>
</AuthProviderProvider>
</Toasty>
</LocaleDirectionProvider>
</I18nProvider>
</ThemeProvider>
);
}
export default AdminApp;

View File

@@ -0,0 +1,476 @@
/**
* Admin Command Palette
*
* Quick navigation and search across the admin interface.
* Opens with Cmd+K (Mac) or Ctrl+K (Windows/Linux).
*/
import { CommandPalette } from "@cloudflare/kumo";
import type { MessageDescriptor } from "@lingui/core";
import { msg } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import {
SquaresFour,
FileText,
Image,
Gear,
PuzzlePiece,
Upload,
Database,
List,
GridFour,
Users,
Stack,
MagnifyingGlass,
} from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import * as React from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { apiFetch, type AdminManifest } from "../lib/api/client.js";
import { useCurrentUser } from "../lib/api/current-user";
/** Subset of manifest fields used by the palette (matches `Shell` props shape). */
type CommandPaletteManifest = {
collections: Record<string, { label: string; labelSingular?: string }>;
plugins: AdminManifest["plugins"];
};
// Role levels (matching @emdash-cms/auth)
const ROLE_ADMIN = 50;
const ROLE_EDITOR = 40;
// Regex for replacing route params like $collection with actual values
const ROUTE_PARAM_REGEX = /\$(\w+)/g;
// Debounce delay for content search (ms)
const SEARCH_DEBOUNCE_MS = 300;
// Detect macOS for keyboard shortcut display
const IS_MAC = typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
/**
* Custom hook for debouncing a value
*/
function useDebouncedValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = React.useState(value);
React.useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
interface SearchResult {
id: string;
collection: string;
title: string;
slug: string;
status: string;
}
interface SearchResponse {
items: SearchResult[];
total: number;
}
interface NavItem {
id: string;
title: string | MessageDescriptor;
to: string;
params?: Record<string, string>;
icon: React.ElementType;
minRole?: number;
keywords?: string[];
}
interface ResultGroup {
id: string;
label: MessageDescriptor;
items: ResultItem[];
}
interface ResultItem {
id: string;
title: string;
to: string;
params?: Record<string, string>;
icon?: React.ReactNode;
description?: string;
collection?: string;
}
interface AdminCommandPaletteProps {
manifest: CommandPaletteManifest;
}
async function searchContent(query: string): Promise<SearchResponse> {
if (!query || query.length < 2) {
return { items: [], total: 0 };
}
const response = await apiFetch(`/_emdash/api/search?q=${encodeURIComponent(query)}&limit=10`);
if (!response.ok) {
return { items: [], total: 0 };
}
const body = (await response.json()) as { data: SearchResponse };
return body.data;
}
function buildNavItems(manifest: CommandPaletteManifest, userRole: number): NavItem[] {
const items: NavItem[] = [
{
id: "dashboard",
title: msg`Dashboard`,
to: "/",
icon: SquaresFour,
keywords: ["home", "overview"],
},
];
// Add collection links
for (const [name, config] of Object.entries(manifest.collections)) {
items.push({
id: `collection-${name}`,
title: config.label,
to: "/content/$collection",
params: { collection: name },
icon: FileText,
keywords: ["content", name],
});
}
// Add core admin links
items.push(
{
id: "media",
title: msg`Media Library`,
to: "/media",
icon: Image,
keywords: ["images", "files", "uploads"],
},
{
id: "menus",
title: msg`Menus`,
to: "/menus",
icon: List,
minRole: ROLE_EDITOR,
keywords: ["navigation"],
},
{
id: "widgets",
title: msg`Widgets`,
to: "/widgets",
icon: GridFour,
minRole: ROLE_EDITOR,
keywords: ["sidebar", "footer"],
},
{
id: "sections",
title: msg`Sections`,
to: "/sections",
icon: Stack,
minRole: ROLE_EDITOR,
keywords: ["page builder", "blocks"],
},
{
id: "content-types",
title: msg`Content Types`,
to: "/content-types",
icon: Database,
minRole: ROLE_ADMIN,
keywords: ["schema", "collections"],
},
{
id: "categories",
title: msg`Categories`,
to: "/taxonomies/$taxonomy",
params: { taxonomy: "category" },
icon: FileText,
minRole: ROLE_EDITOR,
keywords: ["taxonomy"],
},
{
id: "tags",
title: msg`Tags`,
to: "/taxonomies/$taxonomy",
params: { taxonomy: "tag" },
icon: FileText,
minRole: ROLE_EDITOR,
keywords: ["taxonomy"],
},
{
id: "users",
title: msg`Users`,
to: "/users",
icon: Users,
minRole: ROLE_ADMIN,
keywords: ["accounts", "team"],
},
{
id: "plugins",
title: msg`Plugins`,
to: "/plugins-manager",
icon: PuzzlePiece,
minRole: ROLE_ADMIN,
keywords: ["extensions", "add-ons"],
},
{
id: "import",
title: msg`Import`,
to: "/import/wordpress",
icon: Upload,
minRole: ROLE_ADMIN,
keywords: ["wordpress", "migrate"],
},
{
id: "settings",
title: msg`Settings`,
to: "/settings",
icon: Gear,
minRole: ROLE_ADMIN,
keywords: ["configuration", "preferences"],
},
{
id: "security",
title: msg`Security Settings`,
to: "/settings/security",
icon: Gear,
minRole: ROLE_ADMIN,
keywords: ["passkeys", "authentication"],
},
);
// Add plugin pages
for (const [pluginId, config] of Object.entries(manifest.plugins)) {
if (config.enabled === false) continue;
if (config.adminPages && config.adminPages.length > 0) {
for (const page of config.adminPages) {
const label =
page.label ||
pluginId
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
items.push({
id: `plugin-${pluginId}-${page.path}`,
title: label,
to: `/plugins/${pluginId}${page.path}`,
icon: PuzzlePiece,
keywords: ["plugin", pluginId],
});
}
}
}
// Filter by role
return items.filter((item) => !item.minRole || userRole >= item.minRole);
}
function filterNavItems(
items: NavItem[],
query: string,
translate: (d: MessageDescriptor) => string,
): NavItem[] {
if (!query) return items;
const lowerQuery = query.toLowerCase();
return items.filter((item) => {
const titleStr = typeof item.title === "string" ? item.title : translate(item.title);
const titleMatch = titleStr.toLowerCase().includes(lowerQuery);
const keywordMatch = item.keywords?.some((k) => k.toLowerCase().includes(lowerQuery));
return titleMatch || keywordMatch;
});
}
export function AdminCommandPalette({ manifest }: AdminCommandPaletteProps) {
const { t } = useLingui();
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const navigate = useNavigate();
// Debounce the search query to avoid flickering on every keystroke
const debouncedQuery = useDebouncedValue(query, SEARCH_DEBOUNCE_MS);
const { data: user } = useCurrentUser();
const userRole = user?.role ?? 0;
// Search content when debounced query is long enough
const { data: searchResults, isFetching: isSearching } = useQuery({
queryKey: ["command-palette-search", debouncedQuery],
queryFn: () => searchContent(debouncedQuery),
enabled: debouncedQuery.length >= 2,
staleTime: 30 * 1000,
});
// Show loading while waiting for debounce or API response
const isWaitingForDebounce = query.length >= 2 && query !== debouncedQuery;
const isPendingSearch = isWaitingForDebounce || isSearching;
// Build navigation items
const allNavItems = React.useMemo(() => buildNavItems(manifest, userRole), [manifest, userRole]);
// Filter nav items based on query
const filteredNavItems = React.useMemo(
() => filterNavItems(allNavItems, query, t),
[allNavItems, query, t],
);
// Build result groups
const resultGroups = React.useMemo((): ResultGroup[] => {
const groups: ResultGroup[] = [];
// Navigation group
if (filteredNavItems.length > 0) {
groups.push({
id: "navigation",
label: msg`Navigation`,
items: filteredNavItems.map((item) => ({
id: item.id,
title: typeof item.title === "string" ? item.title : t(item.title),
to: item.to,
params: item.params,
icon: <item.icon className="h-4 w-4" />,
})),
});
}
// Content search results
if (searchResults?.items && searchResults.items.length > 0) {
const contentItems = searchResults.items.map((result) => {
const collectionConfig = manifest.collections[result.collection];
const collectionLabel = collectionConfig?.label ?? result.collection;
return {
id: `content-${result.id}`,
title: result.title || result.slug,
to: "/content/$collection/$id",
params: { collection: result.collection, id: result.id },
icon: <FileText className="h-4 w-4" />,
description: collectionLabel,
collection: result.collection,
};
});
groups.push({
id: "content",
label: msg`Content`,
items: contentItems,
});
}
return groups;
}, [filteredNavItems, searchResults, manifest.collections, t]);
// Keyboard shortcut to open (Cmd+K / Ctrl+K)
useHotkeys("mod+k", (e) => {
e.preventDefault();
setOpen(true);
});
// Reset query when closing
React.useEffect(() => {
if (!open) {
setQuery("");
}
}, [open]);
const handleSelect = React.useCallback(
(item: ResultItem, options: { newTab: boolean }) => {
setOpen(false);
if (options.newTab) {
// Build the full URL for new tab
const path = item.params
? item.to.replace(ROUTE_PARAM_REGEX, (_, key) => item.params?.[key] ?? "")
: item.to;
window.open(`/_emdash/admin${path}`, "_blank");
} else {
// Navigate within the app
void navigate({
to: item.to as "/",
params: item.params,
});
}
},
[navigate],
);
const handleItemClick = React.useCallback(
(item: ResultItem, e: React.MouseEvent) => {
handleSelect(item, { newTab: e.metaKey || e.ctrlKey });
},
[handleSelect],
);
return (
<CommandPalette.Root
open={open}
onOpenChange={setOpen}
items={resultGroups}
value={query}
onValueChange={setQuery}
itemToStringValue={(group) => t(group.label)}
onSelect={handleSelect}
getSelectableItems={(groups) => groups.flatMap((g) => g.items)}
>
<CommandPalette.Input
placeholder={t`Search pages and content...`}
leading={<MagnifyingGlass className="h-4 w-4 text-kumo-subtle" weight="bold" />}
/>
<CommandPalette.List>
{isPendingSearch ? (
<CommandPalette.Loading />
) : (
<>
<CommandPalette.Results>
{(group: ResultGroup) => (
<CommandPalette.Group key={group.id} items={group.items}>
<CommandPalette.GroupLabel>{t(group.label)}</CommandPalette.GroupLabel>
<CommandPalette.Items>
{(item: ResultItem) => (
<CommandPalette.ResultItem
key={item.id}
value={item}
title={item.title}
description={item.description}
icon={item.icon}
onClick={(e: React.MouseEvent) => handleItemClick(item, e)}
/>
)}
</CommandPalette.Items>
</CommandPalette.Group>
)}
</CommandPalette.Results>
<CommandPalette.Empty>{t`No results found`}</CommandPalette.Empty>
</>
)}
</CommandPalette.List>
<CommandPalette.Footer>
<div className="flex items-center gap-4 text-kumo-subtle">
<span className="flex items-center gap-1">
<kbd className="rounded bg-kumo-control px-1.5 py-0.5 text-xs">Enter</kbd>
<span>{t`to select`}</span>
</span>
<span className="flex items-center gap-1">
<kbd className="rounded bg-kumo-control px-1.5 py-0.5 text-xs">
{IS_MAC ? "Cmd" : "Ctrl"}+Enter
</kbd>
<span>{t`new tab`}</span>
</span>
<span className="flex items-center gap-1">
<kbd className="rounded bg-kumo-control px-1.5 py-0.5 text-xs">Esc</kbd>
<span>{t`to close`}</span>
</span>
</div>
</CommandPalette.Footer>
</CommandPalette.Root>
);
}

View File

@@ -0,0 +1,23 @@
import { ArrowRightIcon, CaretRightIcon, type IconProps } from "@phosphor-icons/react";
import { cn } from "../lib/utils";
/** Caret pointing in the "forward" direction — right in LTR, left in RTL. */
export function CaretNext({ className, ...props }: IconProps) {
return <CaretRightIcon className={cn("rtl:-scale-x-100", className)} {...props} />;
}
/** Caret pointing in the "backward" direction — left in LTR, right in RTL. */
export function CaretPrev({ className, ...props }: IconProps) {
return <CaretRightIcon className={cn("rotate-180 rtl:rotate-0", className)} {...props} />;
}
/** Arrow pointing in the "forward" direction — right in LTR, left in RTL. */
export function ArrowNext({ className, ...props }: IconProps) {
return <ArrowRightIcon className={cn("rtl:-scale-x-100", className)} {...props} />;
}
/** Arrow pointing in the "backward" direction — left in LTR, right in RTL. */
export function ArrowPrev({ className, ...props }: IconProps) {
return <ArrowRightIcon className={cn("rotate-180 rtl:rotate-0", className)} {...props} />;
}

View File

@@ -0,0 +1,136 @@
import { Input, Switch } from "@cloudflare/kumo";
import type { Element } from "@emdash-cms/blocks";
import * as React from "react";
import { BlockKitMediaPickerField } from "./BlockKitMediaPickerField";
interface BlockKitFieldWidgetProps {
label: string;
elements: Element[];
value: unknown;
onChange: (value: unknown) => void;
}
/**
* Renders Block Kit elements as a field widget for sandboxed plugins.
* Decomposes a JSON value into per-element values keyed by action_id,
* and recomposes on change.
*/
export function BlockKitFieldWidget({
label,
elements,
value,
onChange,
}: BlockKitFieldWidgetProps) {
const obj = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
// Use a ref to avoid stale closure -- rapid changes to different elements
// would otherwise lose updates because each callback spreads from a stale obj.
const objRef = React.useRef(obj);
objRef.current = obj;
const handleElementChange = React.useCallback(
(actionId: string, elementValue: unknown) => {
onChange({ ...objRef.current, [actionId]: elementValue });
},
[onChange],
);
// Filter out elements without action_id -- they can't be mapped to values
const validElements = elements.filter((el) => el.action_id);
return (
<div>
<span className="text-sm font-medium leading-none">{label}</span>
<div className="mt-2 space-y-3">
{validElements.map((el) => (
<BlockKitFieldElement
key={el.action_id}
element={el}
value={obj[el.action_id]}
onChange={handleElementChange}
/>
))}
</div>
</div>
);
}
function BlockKitFieldElement({
element,
value,
onChange,
}: {
element: Element;
value: unknown;
onChange: (actionId: string, value: unknown) => void;
}) {
switch (element.type) {
case "text_input":
return (
<Input
label={element.label}
placeholder={element.placeholder}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(element.action_id, e.target.value)}
/>
);
case "number_input":
return (
<Input
label={element.label}
type="number"
value={typeof value === "number" ? String(value) : ""}
onChange={(e) => {
const n = Number(e.target.value);
onChange(element.action_id, e.target.value && Number.isFinite(n) ? n : undefined);
}}
/>
);
case "toggle":
return (
<Switch
label={element.label}
checked={!!value}
onCheckedChange={(checked) => onChange(element.action_id, checked)}
/>
);
case "select": {
const options = Array.isArray(element.options) ? element.options : [];
return (
<div>
<label className="text-sm font-medium mb-1.5 block">{element.label}</label>
<select
className="flex w-full rounded-md border border-kumo-line bg-transparent px-3 py-2 text-sm"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(element.action_id, e.target.value)}
>
<option value="">Select...</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
case "media_picker":
return (
<BlockKitMediaPickerField
actionId={element.action_id}
label={element.label}
placeholder={element.placeholder}
mimeTypeFilter={element.mime_type_filter}
value={value}
onChange={onChange}
/>
);
default:
return (
<div className="text-sm text-kumo-subtle">
Unsupported widget element type: {(element as { type: string }).type}
</div>
);
}
}

View File

@@ -0,0 +1,122 @@
import { Button } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { Image as ImageIcon, X } from "@phosphor-icons/react";
import * as React from "react";
import type { MediaItem } from "../lib/api";
import { isSafeUrl } from "../lib/url";
import { MediaPickerModal } from "./MediaPickerModal";
export interface BlockKitMediaPickerFieldProps {
actionId: string;
label: string;
placeholder?: string;
mimeTypeFilter?: string;
value: unknown;
onChange: (actionId: string, value: unknown) => void;
}
/**
* Shared media_picker BlockKit element renderer used by `BlockKitFieldWidget`
* (sandboxed plugin field widgets) and the `BlockKitField` switch inside
* `PortableTextEditor` (plugin block forms).
*
* The stored value is the asset URL string, so values are interchangeable
* with `text_input`. Existing arbitrary URLs are tolerated but only previewed
* when they pass scheme/path safety checks.
*/
export function BlockKitMediaPickerField({
actionId,
label,
placeholder,
mimeTypeFilter,
value,
onChange,
}: BlockKitMediaPickerFieldProps) {
const { t } = useLingui();
const [pickerOpen, setPickerOpen] = React.useState(false);
const url = typeof value === "string" && value.length > 0 ? value : "";
const filter = mimeTypeFilter ?? "image/";
const canPreview = isSafePreviewUrl(url);
const handleSelect = (item: MediaItem) => {
// `MediaPickerModal` returns URL-inserted items with `id: ""` and no
// `provider`/`storageKey`, so we cannot infer "local" from absence of
// `provider` alone — that would rewrite the external URL to a broken
// `/_emdash/api/media/file/` path. Detect local explicitly.
const isLocalMedia = item.provider === "local" || !!item.storageKey;
const localKey = item.storageKey || item.id;
const nextUrl = isLocalMedia && localKey ? `/_emdash/api/media/file/${localKey}` : item.url;
if (!nextUrl) return;
onChange(actionId, nextUrl);
};
return (
<div>
<label className="text-sm font-medium mb-1.5 block">{label}</label>
{canPreview ? (
<div className="relative group">
<img
src={url}
alt=""
className="max-h-40 w-full rounded-md border border-kumo-line object-contain bg-kumo-muted"
referrerPolicy="no-referrer"
loading="lazy"
/>
<div className="absolute top-2 end-2 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex gap-1">
<Button type="button" size="sm" variant="secondary" onClick={() => setPickerOpen(true)}>
{t`Change`}
</Button>
<Button
type="button"
shape="square"
variant="destructive"
className="h-8 w-8"
onClick={() => onChange(actionId, "")}
aria-label={t`Remove`}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="outline"
className="w-full h-24 border-dashed"
onClick={() => setPickerOpen(true)}
>
<div className="flex flex-col items-center gap-1.5 text-kumo-subtle">
<ImageIcon className="h-6 w-6" />
<span className="text-sm">{placeholder ?? t`Select media`}</span>
</div>
</Button>
)}
<MediaPickerModal
open={pickerOpen}
onOpenChange={setPickerOpen}
onSelect={handleSelect}
mimeTypeFilter={filter}
title={t`Select ${label}`}
/>
</div>
);
}
const HAS_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
/**
* Returns true when `url` is safe to preview via `<img src={url}>`:
* - Same-origin relative path starting with `/` (but not `//`)
* - External `http://` or `https://` URL
*
* Rejects `javascript:`, `data:`, protocol-relative `//host`, and other
* schemes whose preview could leak credentials or trigger surprises.
*/
function isSafePreviewUrl(url: string): boolean {
if (!url) return false;
if (HAS_SCHEME_RE.test(url)) {
return isSafeUrl(url);
}
return url.startsWith("/") && !url.startsWith("//");
}

View File

@@ -0,0 +1,156 @@
/**
* Capability Consent Dialog
*
* Shown before installing or updating a marketplace plugin.
* Lists each requested capability with a human-readable explanation.
* User must explicitly confirm before the action proceeds.
*/
import { Button } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { ShieldCheck, ShieldWarning, Warning } from "@phosphor-icons/react";
import * as React from "react";
import { describeCapability } from "../lib/api/marketplace.js";
import { cn } from "../lib/utils.js";
import { DialogError } from "./DialogError.js";
export interface CapabilityConsentDialogProps {
/** Dialog mode */
mode?: "install" | "update";
/** Plugin display name */
pluginName: string;
/** Capabilities the plugin requests */
capabilities: string[];
/** Allowed network hosts (for network:fetch capability) */
allowedHosts?: string[];
/** New capabilities added in an update (highlighted differently) */
newCapabilities?: string[];
/** Audit verdict badge */
auditVerdict?: "pass" | "warn" | "fail";
/** Whether the action is in progress */
isPending?: boolean;
/** Error message to display inline */
error?: string | null;
/** Called when user confirms */
onConfirm: () => void;
/** Called when user cancels */
onCancel: () => void;
}
export function CapabilityConsentDialog({
mode,
pluginName,
capabilities,
allowedHosts,
newCapabilities = [],
auditVerdict,
isPending = false,
error,
onConfirm,
onCancel,
}: CapabilityConsentDialogProps) {
const { t } = useLingui();
const newSet = new Set(newCapabilities);
const isUpdate = mode === "update" || newCapabilities.length > 0;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-label={t`Capability consent`}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50" onClick={() => !isPending && onCancel()} />
{/* Dialog */}
<div className="relative w-full max-w-md rounded-lg border bg-kumo-base shadow-lg">
{/* Header */}
<div className="border-b px-6 py-4">
<h2 className="text-lg font-semibold">
{isUpdate ? t`Review New Permissions` : t`Plugin Permissions`}
</h2>
<p className="mt-1 text-sm text-kumo-subtle">
{isUpdate
? t`${pluginName} is requesting additional permissions:`
: t`${pluginName} requires the following permissions:`}
</p>
</div>
{/* Capabilities list */}
<div className="px-6 py-4 space-y-3">
{capabilities.map((cap) => {
const isNew = newSet.has(cap);
return (
<div
key={cap}
className={cn(
"flex items-start gap-3 rounded-md p-2 text-sm",
isNew ? "bg-warning/10 border border-warning/30" : "bg-kumo-tint/50",
)}
>
<ShieldCheck
className={cn(
"mt-0.5 h-4 w-4 shrink-0",
isNew ? "text-warning" : "text-kumo-subtle",
)}
/>
<div>
<span className={cn(isNew && "font-medium")}>
{describeCapability(cap, allowedHosts)}
</span>
{isNew && <span className="ms-2 text-xs text-warning font-medium">{t`NEW`}</span>}
</div>
</div>
);
})}
{/* Audit verdict banner */}
{auditVerdict && auditVerdict !== "pass" && (
<div
className={cn(
"flex items-center gap-2 rounded-md p-3 text-sm mt-2",
auditVerdict === "warn"
? "bg-warning/10 text-warning"
: "bg-kumo-danger/10 text-kumo-danger",
)}
>
{auditVerdict === "warn" ? (
<Warning className="h-4 w-4 shrink-0" />
) : (
<ShieldWarning className="h-4 w-4 shrink-0" />
)}
<span>
{auditVerdict === "warn"
? t`Security audit flagged potential concerns with this plugin.`
: t`Security audit flagged this plugin as potentially unsafe.`}
</span>
</div>
)}
</div>
{/* Error */}
<DialogError message={error} className="mx-6" />
{/* Actions */}
<div className="flex justify-end gap-3 border-t px-6 py-4">
<Button variant="ghost" onClick={onCancel} disabled={isPending}>
{t`Cancel`}
</Button>
<Button onClick={onConfirm} disabled={isPending}>
{isPending
? isUpdate
? t`Updating...`
: t`Installing...`
: isUpdate
? t`Accept & Update`
: t`Accept & Install`}
</Button>
</div>
</div>
</div>
);
}
export default CapabilityConsentDialog;

View File

@@ -0,0 +1,66 @@
/**
* Reusable confirmation dialog with inline error display.
*
* Handles the common pattern: title, description, optional error banner,
* cancel/confirm buttons with pending state. Dialog stays open on error.
*/
import { Button, Dialog } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import * as React from "react";
import { DialogError, getMutationError } from "./DialogError.js";
export interface ConfirmDialogProps {
open: boolean;
onClose: () => void;
title: string;
/** Static description or dynamic JSX content */
description: React.ReactNode;
/** Label for the confirm button (e.g. "Delete", "Disable User") */
confirmLabel: string;
/** Label shown while the action is pending (e.g. "Deleting...") */
pendingLabel: string;
/** Button variant — defaults to "destructive" */
variant?: "destructive" | "primary";
isPending: boolean;
/** Error from a mutation — pass mutation.error directly */
error: unknown;
onConfirm: () => void;
/** Extra content rendered between description and buttons (e.g. a checkbox) */
children?: React.ReactNode;
}
export function ConfirmDialog({
open,
onClose,
title,
description,
confirmLabel,
pendingLabel,
variant = "destructive",
isPending,
error,
onConfirm,
children,
}: ConfirmDialogProps) {
const { t } = useLingui();
return (
<Dialog.Root open={open} onOpenChange={(o) => !o && onClose()} disablePointerDismissal>
<Dialog className="p-6" size="sm">
<Dialog.Title className="text-lg font-semibold">{title}</Dialog.Title>
<Dialog.Description className="text-kumo-subtle">{description}</Dialog.Description>
{children}
<DialogError message={getMutationError(error)} className="mt-3" />
<div className="mt-6 flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
{t`Cancel`}
</Button>
<Button variant={variant} disabled={isPending} onClick={onConfirm}>
{isPending ? pendingLabel : confirmLabel}
</Button>
</div>
</Dialog>
</Dialog.Root>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,694 @@
import { Badge, Button, buttonVariants, Dialog, Input, Loader, Tabs } from "@cloudflare/kumo";
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import {
Plus,
Pencil,
Trash,
ArrowCounterClockwise,
ArrowSquareOut,
Copy,
MagnifyingGlass,
CaretUp,
CaretDown,
CaretUpDown,
} from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import * as React from "react";
import type { ContentItem, TrashedContentItem } from "../lib/api";
import { contentUrl } from "../lib/url.js";
import { cn } from "../lib/utils";
import { CaretNext, CaretPrev } from "./ArrowIcons.js";
import { LocaleSwitcher } from "./LocaleSwitcher";
/** Sortable content list columns. Maps to the server's order field whitelist. */
export type ContentListSortField = "title" | "status" | "locale" | "updatedAt";
export interface ContentListSort {
field: ContentListSortField;
direction: "asc" | "desc";
}
export interface ContentListProps {
collection: string;
collectionLabel: string;
items: ContentItem[];
trashedItems?: TrashedContentItem[];
isLoading?: boolean;
isTrashedLoading?: boolean;
onDelete?: (id: string) => void;
onDuplicate?: (id: string) => void;
onRestore?: (id: string) => void;
onPermanentDelete?: (id: string) => void;
onLoadMore?: () => void;
onLoadMoreTrashed?: () => void;
hasMore?: boolean;
hasMoreTrashed?: boolean;
trashedCount?: number;
/** i18n config — present when multiple locales are configured */
i18n?: { defaultLocale: string; locales: string[] };
/** Currently active locale filter */
activeLocale?: string;
/** Callback when locale filter changes */
onLocaleChange?: (locale: string) => void;
/** URL pattern for published content links (e.g. `/blog/{slug}`) */
urlPattern?: string;
/**
* Controlled sort state. When `onSortChange` is also provided, the column
* headers become sort controls that invoke it. Uncontrolled sort keeps
* the backward-compatible "static headers, server-default ordering"
* behavior for callers that haven't opted in yet.
*/
sort?: ContentListSort;
onSortChange?: (sort: ContentListSort) => void;
}
type ViewTab = "all" | "trash";
const PAGE_SIZE = 20;
function getItemTitle(item: { data: Record<string, unknown>; slug: string | null; id: string }) {
const rawTitle = item.data.title;
const rawName = item.data.name;
return (
(typeof rawTitle === "string" ? rawTitle : "") ||
(typeof rawName === "string" ? rawName : "") ||
item.slug ||
item.id
);
}
/**
* Content list view with table display and trash tab
*/
export function ContentList({
collection,
collectionLabel,
items,
trashedItems = [],
isLoading,
isTrashedLoading,
onDelete,
onDuplicate,
onRestore,
onPermanentDelete,
onLoadMore,
onLoadMoreTrashed,
hasMore,
hasMoreTrashed,
trashedCount = 0,
i18n,
activeLocale,
onLocaleChange,
urlPattern,
sort,
onSortChange,
}: ContentListProps) {
const { t } = useLingui();
const [activeTab, setActiveTab] = React.useState<ViewTab>("all");
const [searchQuery, setSearchQuery] = React.useState("");
const [page, setPage] = React.useState(0);
// Reset page when search changes
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
setPage(0);
};
const filteredItems = React.useMemo(() => {
if (!searchQuery) return items;
const query = searchQuery.toLowerCase();
return items.filter((item) => getItemTitle(item).toLowerCase().includes(query));
}, [items, searchQuery]);
const totalPages = Math.max(1, Math.ceil(filteredItems.length / PAGE_SIZE));
const paginatedItems = filteredItems.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
// Auto-fetch next API page when user reaches the last client-side page.
// skip when a search query is active
// filteredItems shrinking would otherwise collapse totalPages to 1 and trigger a spurious fetch
React.useEffect(() => {
if (page >= totalPages - 1 && hasMore && onLoadMore && !searchQuery) {
onLoadMore();
}
}, [page, totalPages, hasMore, onLoadMore, searchQuery]);
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold">{collectionLabel}</h1>
{i18n && activeLocale && onLocaleChange && (
<LocaleSwitcher
locales={i18n.locales}
defaultLocale={i18n.defaultLocale}
value={activeLocale}
onChange={onLocaleChange}
size="sm"
/>
)}
</div>
<Link
to="/content/$collection/new"
params={{ collection }}
search={{ locale: activeLocale }}
className={buttonVariants()}
>
<Plus className="me-2 h-4 w-4" aria-hidden="true" />
{t`Add New`}
</Link>
</div>
{/* Search */}
{items.length > 0 && (
<div className="relative max-w-sm">
<MagnifyingGlass className="absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
<Input
type="search"
placeholder={t`Search ${collectionLabel.toLowerCase()}...`}
aria-label={t`Search ${collectionLabel.toLowerCase()}`}
value={searchQuery}
onChange={handleSearchChange}
className="ps-9"
/>
</div>
)}
{/* Tabs */}
<Tabs
variant="underline"
value={activeTab}
onValueChange={(v) => {
if (v === "all" || v === "trash") setActiveTab(v);
}}
tabs={[
{ value: "all", label: t`All` },
{
value: "trash",
label: (
<span className="flex items-center gap-2">
<Trash className="h-4 w-4" aria-hidden="true" />
{t`Trash`}
{trashedCount > 0 && <Badge variant="secondary">{trashedCount}</Badge>}
</span>
),
},
]}
/>
{/* Content based on active tab */}
{activeTab === "all" ? (
<>
{/* Table */}
<div className="rounded-md border overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-kumo-tint/50">
<SortableTh
field="title"
sort={sort}
onSortChange={onSortChange}
label={t`Title`}
/>
<SortableTh
field="status"
sort={sort}
onSortChange={onSortChange}
label={t`Status`}
/>
{i18n && (
<SortableTh
field="locale"
sort={sort}
onSortChange={onSortChange}
label={t`Locale`}
/>
)}
<SortableTh
field="updatedAt"
sort={sort}
onSortChange={onSortChange}
label={t`Date`}
/>
<th scope="col" className="px-4 py-3 text-end text-sm font-medium">
{t`Actions`}
</th>
</tr>
</thead>
<tbody>
{isLoading && items.length === 0 ? (
<tr>
<td colSpan={i18n ? 5 : 4} className="px-4 py-8 text-center text-kumo-subtle">
<span className="inline-flex items-center gap-2">
<Loader size="sm" />
{t`Loading...`}
</span>
</td>
</tr>
) : items.length === 0 ? (
<tr>
<td colSpan={i18n ? 5 : 4} className="px-4 py-8 text-center text-kumo-subtle">
{t`No ${collectionLabel.toLowerCase()} yet.`}{" "}
<Link
to="/content/$collection/new"
params={{ collection }}
search={{ locale: activeLocale }}
className="text-kumo-brand underline"
>
{t`Create your first one`}
</Link>
</td>
</tr>
) : paginatedItems.length === 0 ? (
<tr>
<td colSpan={i18n ? 5 : 4} className="px-4 py-8 text-center text-kumo-subtle">
{t`No results for "${searchQuery}"`}
</td>
</tr>
) : (
paginatedItems.map((item) => (
<ContentListItem
key={item.id}
item={item}
collection={collection}
onDelete={onDelete}
onDuplicate={onDuplicate}
showLocale={!!i18n}
urlPattern={urlPattern}
/>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<span className="text-sm text-kumo-subtle">
{searchQuery
? plural(filteredItems.length, {
one: `# item matching "${searchQuery}"`,
other: `# items matching "${searchQuery}"`,
})
: plural(filteredItems.length, {
one: `#${hasMore ? "+" : ""} item`,
other: `#${hasMore ? "+" : ""} items`,
})}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
shape="square"
disabled={page === 0}
onClick={() => setPage(page - 1)}
aria-label={t`Previous page`}
>
<CaretPrev className="h-4 w-4" aria-hidden="true" />
</Button>
<span className="text-sm">
{page + 1} / {totalPages}
</span>
<Button
variant="outline"
shape="square"
disabled={page >= totalPages - 1}
onClick={() => setPage(page + 1)}
aria-label={t`Next page`}
>
<CaretNext className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</div>
)}
{/* Load more */}
{hasMore && (
<div className="flex justify-center">
<Button variant="outline" onClick={onLoadMore} disabled={isLoading}>
{isLoading ? t`Loading...` : t`Load More`}
</Button>
</div>
)}
</>
) : (
<>
{/* Trash Table */}
<div className="rounded-md border overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-kumo-tint/50">
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Title`}
</th>
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Deleted`}
</th>
<th scope="col" className="px-4 py-3 text-end text-sm font-medium">
{t`Actions`}
</th>
</tr>
</thead>
<tbody>
{isTrashedLoading && trashedItems.length === 0 ? (
<tr>
<td colSpan={3} className="px-4 py-8 text-center text-kumo-subtle">
<span className="inline-flex items-center gap-2">
<Loader size="sm" />
{t`Loading...`}
</span>
</td>
</tr>
) : trashedItems.length === 0 ? (
<tr>
<td colSpan={3} className="px-4 py-8 text-center text-kumo-subtle">
{t`Trash is empty`}
</td>
</tr>
) : (
trashedItems.map((item) => (
<TrashedListItem
key={item.id}
item={item}
onRestore={onRestore}
onPermanentDelete={onPermanentDelete}
/>
))
)}
</tbody>
</table>
</div>
{/* Load more trashed */}
{hasMoreTrashed && (
<div className="flex justify-center">
<Button variant="outline" onClick={onLoadMoreTrashed} disabled={isTrashedLoading}>
{isTrashedLoading ? t`Loading...` : t`Load More`}
</Button>
</div>
)}
</>
)}
</div>
);
}
interface SortableThProps {
field: ContentListSortField;
sort: ContentListSort | undefined;
onSortChange: ((sort: ContentListSort) => void) | undefined;
label: string;
}
/**
* Table header that doubles as a sort control when the parent opted in by
* passing `onSortChange`. When no callback is provided we fall back to a
* plain `<th>` so legacy callers (and screen readers) see exactly the same
* markup as before this change.
*
* The button's accessible name is just the column label — the sort state
* is conveyed via `aria-sort` on the <th>, which screen readers announce
* automatically. Adding a verbose aria-label would make each header re-read
* the sort instruction on every focus, which is noisy.
*/
function SortableTh({ field, sort, onSortChange, label }: SortableThProps) {
const isActive = sort?.field === field;
const direction = isActive ? sort?.direction : undefined;
if (!onSortChange) {
return (
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{label}
</th>
);
}
const ariaSort: "ascending" | "descending" | "none" = isActive
? direction === "asc"
? "ascending"
: "descending"
: "none";
const handleClick = () => {
// Default to descending for a new column; toggle direction when
// clicking the already-active one.
if (isActive) {
onSortChange({ field, direction: direction === "asc" ? "desc" : "asc" });
} else {
onSortChange({ field, direction: "desc" });
}
};
const Icon = isActive ? (direction === "asc" ? CaretUp : CaretDown) : CaretUpDown;
return (
<th scope="col" aria-sort={ariaSort} className="px-4 py-3 text-start text-sm font-medium">
<button
type="button"
onClick={handleClick}
className={cn(
"inline-flex items-center gap-1 rounded hover:text-kumo-brand",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-kumo-brand",
isActive ? "text-kumo-fg" : "text-kumo-subtle",
)}
>
<span>{label}</span>
<Icon className="h-3 w-3" aria-hidden="true" />
</button>
</th>
);
}
interface ContentListItemProps {
item: ContentItem;
collection: string;
onDelete?: (id: string) => void;
onDuplicate?: (id: string) => void;
showLocale?: boolean;
urlPattern?: string;
}
function ContentListItem({
item,
collection,
onDelete,
onDuplicate,
showLocale,
urlPattern,
}: ContentListItemProps) {
const { t } = useLingui();
const title = getItemTitle(item);
const date = new Date(item.updatedAt || item.createdAt);
return (
<tr className="border-b hover:bg-kumo-tint/25">
<td className="px-4 py-3">
<Link
to="/content/$collection/$id"
params={{ collection, id: item.id }}
className="font-medium hover:text-kumo-brand"
>
{title}
</Link>
</td>
<td className="px-4 py-3">
<StatusBadge
status={item.status}
hasPendingChanges={!!item.draftRevisionId && item.draftRevisionId !== item.liveRevisionId}
/>
</td>
{showLocale && (
<td className="px-4 py-3">
<span className="bg-kumo-tint rounded px-1.5 py-0.5 text-xs font-semibold uppercase">
{item.locale}
</span>
</td>
)}
<td className="px-4 py-3 text-sm text-kumo-subtle">{date.toLocaleDateString()}</td>
<td className="px-4 py-3 text-end">
<div className="flex items-center justify-end space-x-1">
{item.status === "published" && item.slug && (
<a
href={contentUrl(collection, item.slug, urlPattern)}
target="_blank"
rel="noopener noreferrer"
aria-label={t`View published ${title}`}
className={buttonVariants({ variant: "ghost", shape: "square" })}
>
<ArrowSquareOut className="h-4 w-4" aria-hidden="true" />
</a>
)}
<Link
to="/content/$collection/$id"
params={{ collection, id: item.id }}
aria-label={t`Edit ${title}`}
className={buttonVariants({ variant: "ghost", shape: "square" })}
>
<Pencil className="h-4 w-4" aria-hidden="true" />
</Link>
<Button
variant="ghost"
shape="square"
aria-label={t`Duplicate ${title}`}
onClick={() => onDuplicate?.(item.id)}
>
<Copy className="h-4 w-4" aria-hidden="true" />
</Button>
<Dialog.Root disablePointerDismissal>
<Dialog.Trigger
render={(p) => (
<Button
{...p}
variant="ghost"
shape="square"
aria-label={t`Move ${title} to trash`}
>
<Trash className="h-4 w-4 text-kumo-danger" aria-hidden="true" />
</Button>
)}
/>
<Dialog className="p-6" size="sm">
<Dialog.Title className="text-lg font-semibold">{t`Move to Trash?`}</Dialog.Title>
<Dialog.Description className="text-kumo-subtle">
{t`Move "${title}" to trash? You can restore it later.`}
</Dialog.Description>
<div className="mt-6 flex justify-end gap-2">
<Dialog.Close
render={(p) => (
<Button {...p} variant="secondary">
{t`Cancel`}
</Button>
)}
/>
<Dialog.Close
render={(p) => (
<Button {...p} variant="destructive" onClick={() => onDelete?.(item.id)}>
{t`Move to Trash`}
</Button>
)}
/>
</div>
</Dialog>
</Dialog.Root>
</div>
</td>
</tr>
);
}
interface TrashedListItemProps {
item: TrashedContentItem;
onRestore?: (id: string) => void;
onPermanentDelete?: (id: string) => void;
}
function TrashedListItem({ item, onRestore, onPermanentDelete }: TrashedListItemProps) {
const { t } = useLingui();
const title = getItemTitle(item);
const deletedDate = new Date(item.deletedAt);
return (
<tr className="border-b hover:bg-kumo-tint/25">
<td className="px-4 py-3">
<span className="font-medium text-kumo-subtle">{title}</span>
</td>
<td className="px-4 py-3 text-sm text-kumo-subtle">{deletedDate.toLocaleDateString()}</td>
<td className="px-4 py-3 text-end">
<div className="flex items-center justify-end space-x-1">
<Button
variant="ghost"
shape="square"
aria-label={t`Restore ${title}`}
onClick={() => onRestore?.(item.id)}
>
<ArrowCounterClockwise className="h-4 w-4 text-kumo-brand" aria-hidden="true" />
</Button>
<Dialog.Root disablePointerDismissal>
<Dialog.Trigger
render={(p) => (
<Button
{...p}
variant="ghost"
shape="square"
aria-label={t`Permanently delete ${title}`}
>
<Trash className="h-4 w-4 text-kumo-danger" aria-hidden="true" />
</Button>
)}
/>
<Dialog className="p-6" size="sm">
<Dialog.Title className="text-lg font-semibold">
{t`Delete Permanently?`}
</Dialog.Title>
<Dialog.Description className="text-kumo-subtle">
{t`Permanently delete "${title}"? This cannot be undone.`}
</Dialog.Description>
<div className="mt-6 flex justify-end gap-2">
<Dialog.Close
render={(p) => (
<Button {...p} variant="secondary">
{t`Cancel`}
</Button>
)}
/>
<Dialog.Close
render={(p) => (
<Button
{...p}
variant="destructive"
onClick={() => onPermanentDelete?.(item.id)}
>
{t`Delete Permanently`}
</Button>
)}
/>
</div>
</Dialog>
</Dialog.Root>
</div>
</td>
</tr>
);
}
function StatusBadge({
status,
hasPendingChanges,
}: {
status: string;
hasPendingChanges?: boolean;
}) {
const { t } = useLingui();
const statusLabel =
status === "published"
? t`published`
: status === "draft"
? t`draft`
: status === "scheduled"
? t`scheduled`
: status === "archived"
? t`archived`
: status;
return (
<span className="inline-flex items-center gap-1.5">
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-1 text-xs font-medium",
status === "published" &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
status === "draft" &&
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
status === "scheduled" &&
"bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
status === "archived" && "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200",
)}
>
{statusLabel}
</span>
{hasPendingChanges && <Badge variant="secondary">{t`pending`}</Badge>}
</span>
);
}

View File

@@ -0,0 +1,259 @@
/**
* Content Picker Modal
*
* A modal for browsing and selecting content items to add to menus.
* Uses cursor pagination to allow browsing beyond the initial page.
*/
import { Button, Dialog, Input, Loader } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { MagnifyingGlass, FolderOpen, X } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import * as React from "react";
import { fetchCollections, fetchContentList, getDraftStatus } from "../lib/api";
import type { ContentItem } from "../lib/api";
import { useDebouncedValue } from "../lib/hooks";
import { cn } from "../lib/utils";
interface ContentPickerModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (item: { collection: string; id: string; title: string }) => void;
}
function getItemTitle(item: { data: Record<string, unknown>; slug: string | null; id: string }) {
const rawTitle = item.data.title;
const rawName = item.data.name;
return (
(typeof rawTitle === "string" ? rawTitle : "") ||
(typeof rawName === "string" ? rawName : "") ||
item.slug ||
item.id
);
}
export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPickerModalProps) {
const { t } = useLingui();
const [searchQuery, setSearchQuery] = React.useState("");
const debouncedSearch = useDebouncedValue(searchQuery, 300);
const [selectedCollection, setSelectedCollection] = React.useState<string>("");
const [allItems, setAllItems] = React.useState<ContentItem[]>([]);
const [nextCursor, setNextCursor] = React.useState<string | undefined>();
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
const { data: collections = [] } = useQuery({
queryKey: ["collections"],
queryFn: fetchCollections,
enabled: open,
});
// Default to first collection when collections load
React.useEffect(() => {
if (collections.length > 0 && !selectedCollection) {
setSelectedCollection(collections[0]!.slug);
}
}, [collections, selectedCollection]);
const { data: contentResult, isLoading: contentLoading } = useQuery({
queryKey: ["content-picker", selectedCollection, { limit: 50 }],
queryFn: () => fetchContentList(selectedCollection, { limit: 50 }),
enabled: open && !!selectedCollection,
});
// Sync initial page into accumulated items
React.useEffect(() => {
if (contentResult) {
setAllItems(contentResult.items);
setNextCursor(contentResult.nextCursor);
}
}, [contentResult]);
const handleLoadMore = async () => {
if (!nextCursor || isLoadingMore) return;
setIsLoadingMore(true);
try {
const result = await fetchContentList(selectedCollection, {
limit: 50,
cursor: nextCursor,
});
setAllItems((prev) => [...prev, ...result.items]);
setNextCursor(result.nextCursor);
} finally {
setIsLoadingMore(false);
}
};
const filteredItems = React.useMemo(() => {
if (!debouncedSearch) return allItems;
const query = debouncedSearch.toLowerCase();
return allItems.filter((item) => getItemTitle(item).toLowerCase().includes(query));
}, [allItems, debouncedSearch]);
// Reset state when modal opens or collection changes
React.useEffect(() => {
if (open) {
setSearchQuery("");
setSelectedCollection("");
setAllItems([]);
setNextCursor(undefined);
}
}, [open]);
const handleSelect = (item: ContentItem) => {
onSelect({
collection: selectedCollection,
id: item.id,
title: getItemTitle(item),
});
onOpenChange(false);
};
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog className="p-6 max-w-2xl h-[80vh] flex flex-col" size="lg">
<div className="flex items-start justify-between gap-4 mb-4">
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
{t`Select Content`}
</Dialog.Title>
<Dialog.Close
aria-label={t`Close`}
render={(props) => (
<Button
{...props}
variant="ghost"
shape="square"
aria-label={t`Close`}
className="absolute end-4 top-4"
>
<X className="h-4 w-4" />
<span className="sr-only">{t`Close`}</span>
</Button>
)}
/>
</div>
{/* Search and collection filter */}
<div className="flex items-center gap-4 py-4 border-b">
<div className="relative flex-1">
<MagnifyingGlass className="absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
<Input
placeholder={t`Search content...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="ps-10"
autoFocus
/>
</div>
<select
value={selectedCollection}
onChange={(e) => {
setSelectedCollection(e.target.value);
setAllItems([]);
setNextCursor(undefined);
}}
className="h-10 rounded-md border border-kumo-line bg-kumo-base px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-kumo-ring focus:ring-offset-2"
>
{collections.map((col) => (
<option key={col.slug} value={col.slug}>
{col.label}
</option>
))}
</select>
</div>
{/* Content list */}
<div className="flex-1 overflow-y-auto py-4">
{contentLoading ? (
<div className="flex items-center justify-center h-32">
<div className="text-kumo-subtle">{t`Loading content...`}</div>
</div>
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-center">
{searchQuery ? (
<>
<MagnifyingGlass className="h-8 w-8 text-kumo-subtle mb-2" />
<p className="text-kumo-subtle">{t`No content found`}</p>
<p className="text-sm text-kumo-subtle">{t`Try adjusting your search`}</p>
</>
) : (
<>
<FolderOpen className="h-8 w-8 text-kumo-subtle mb-2" />
<p className="text-kumo-subtle">{t`No content in this collection`}</p>
</>
)}
</div>
) : (
<div className="space-y-1">
{filteredItems.map((item) => {
const status = getDraftStatus(item);
return (
<button
key={item.id}
type="button"
onClick={() => handleSelect(item)}
className={cn(
"w-full text-start rounded-md px-3 py-2 transition-colors",
"hover:bg-kumo-tint/50",
"focus:outline-none focus:ring-2 focus:ring-kumo-ring focus:ring-offset-2",
)}
>
<div className="font-medium">{getItemTitle(item)}</div>
<div className="text-sm text-kumo-subtle flex items-center gap-2">
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
status === "published"
? "bg-green-500"
: status === "published_with_changes"
? "bg-yellow-500"
: "bg-gray-400",
)}
/>
{status === "published"
? t`Published`
: status === "published_with_changes"
? t`Modified`
: t`Draft`}
{item.slug && (
<>
<span className="text-kumo-subtle/50">/</span>
<span>{item.slug}</span>
</>
)}
</div>
</button>
);
})}
{nextCursor && !searchQuery && (
<div className="pt-2 text-center">
<Button
variant="outline"
size="sm"
onClick={handleLoadMore}
disabled={isLoadingMore}
>
{isLoadingMore ? (
<>
<Loader size="sm" /> {t`Loading...`}
</>
) : (
t`Load more`
)}
</Button>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t`Cancel`}
</Button>
</div>
</Dialog>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,786 @@
import { Badge, Button, Input, InputArea, Label, Select, buttonVariants } from "@cloudflare/kumo";
import {
DndContext,
closestCenter,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { MessageDescriptor } from "@lingui/core";
import { msg } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { Plus, DotsSixVertical, Pencil, Trash, Database, FileText } from "@phosphor-icons/react";
import { Link, useNavigate } from "@tanstack/react-router";
import * as React from "react";
import type {
SchemaCollectionWithFields,
SchemaField,
CreateFieldInput,
CreateCollectionInput,
UpdateCollectionInput,
} from "../lib/api";
import { cn } from "../lib/utils";
import { ArrowPrev } from "./ArrowIcons.js";
import { ConfirmDialog } from "./ConfirmDialog";
import { EditorHeader } from "./EditorHeader";
import { FieldEditor } from "./FieldEditor";
import { SaveButton } from "./SaveButton";
// Regex patterns for slug generation
const SLUG_INVALID_CHARS_PATTERN = /[^a-z0-9]+/g;
const SLUG_LEADING_TRAILING_PATTERN = /^_|_$/g;
export interface ContentTypeEditorProps {
collection?: SchemaCollectionWithFields;
isNew?: boolean;
isSaving?: boolean;
onSave: (input: CreateCollectionInput | UpdateCollectionInput) => void;
onAddField?: (input: CreateFieldInput) => void;
onUpdateField?: (fieldSlug: string, input: CreateFieldInput) => void;
onDeleteField?: (fieldSlug: string) => void;
onReorderFields?: (fieldSlugs: string[]) => void;
}
interface SupportOptionDef {
value: string;
label: MessageDescriptor;
description: MessageDescriptor;
}
const SUPPORT_OPTIONS: SupportOptionDef[] = [
{
value: "drafts",
label: msg`Drafts`,
description: msg`Save content as draft before publishing`,
},
{
value: "revisions",
label: msg`Revisions`,
description: msg`Track content history`,
},
{
value: "preview",
label: msg`Preview`,
description: msg`Preview content before publishing`,
},
{
value: "search",
label: msg`Search`,
description: msg`Enable full-text search on this collection`,
},
];
/**
* System fields that exist on every collection
* These are created automatically and cannot be modified
*/
interface SystemFieldDef {
slug: string;
label: MessageDescriptor;
type: string;
description: MessageDescriptor;
}
const SYSTEM_FIELDS: SystemFieldDef[] = [
{
slug: "id",
label: msg`ID`,
type: "text",
description: msg`Unique identifier (ULID)`,
},
{
slug: "slug",
label: msg`Slug`,
type: "text",
description: msg`URL-friendly identifier`,
},
{
slug: "status",
label: msg`Status`,
type: "text",
description: msg`draft, published, or archived`,
},
{
slug: "created_at",
label: msg`Created At`,
type: "datetime",
description: msg`When the entry was created`,
},
{
slug: "updated_at",
label: msg`Updated At`,
type: "datetime",
description: msg`When the entry was last modified`,
},
{
slug: "published_at",
label: msg`Published At`,
type: "datetime",
description: msg`When the entry was published`,
},
];
/**
* Content Type editor for creating/editing collections
*/
export function ContentTypeEditor({
collection,
isNew,
isSaving,
onSave,
onAddField,
onUpdateField,
onDeleteField,
onReorderFields,
}: ContentTypeEditorProps) {
const { t } = useLingui();
const _navigate = useNavigate();
// Form state
const [slug, setSlug] = React.useState(collection?.slug ?? "");
const [label, setLabel] = React.useState(collection?.label ?? "");
const [labelSingular, setLabelSingular] = React.useState(collection?.labelSingular ?? "");
const [description, setDescription] = React.useState(collection?.description ?? "");
const [urlPattern, setUrlPattern] = React.useState(collection?.urlPattern ?? "");
// SEO is managed via the separate `hasSeo` field; strip any legacy "seo" entry
// so it isn't sent back on save (the API enum rejects it).
const [supports, setSupports] = React.useState<string[]>(
(collection?.supports ?? ["drafts", "revisions"]).filter((s) => s !== "seo"),
);
// SEO state
const [hasSeo, setHasSeo] = React.useState(collection?.hasSeo ?? false);
// Comment settings state
const [commentsEnabled, setCommentsEnabled] = React.useState(
collection?.commentsEnabled ?? false,
);
const [commentsModeration, setCommentsModeration] = React.useState<"all" | "first_time" | "none">(
collection?.commentsModeration ?? "first_time",
);
const [commentsClosedAfterDays, setCommentsClosedAfterDays] = React.useState(
collection?.commentsClosedAfterDays ?? 90,
);
const [commentsAutoApproveUsers, setCommentsAutoApproveUsers] = React.useState(
collection?.commentsAutoApproveUsers ?? true,
);
// Field editor state
const [fieldEditorOpen, setFieldEditorOpen] = React.useState(false);
const [editingField, setEditingField] = React.useState<SchemaField | undefined>();
const [fieldSaving, setFieldSaving] = React.useState(false);
const [deleteFieldTarget, setDeleteFieldTarget] = React.useState<SchemaField | null>(null);
const urlPatternValid = !urlPattern || urlPattern.includes("{slug}");
// Track whether form has unsaved changes
const hasChanges = React.useMemo(() => {
if (isNew) return slug && label;
if (!collection) return false;
return (
label !== collection.label ||
labelSingular !== (collection.labelSingular ?? "") ||
description !== (collection.description ?? "") ||
urlPattern !== (collection.urlPattern ?? "") ||
JSON.stringify([...supports].toSorted()) !==
JSON.stringify(collection.supports.filter((s) => s !== "seo").toSorted()) ||
hasSeo !== collection.hasSeo ||
commentsEnabled !== collection.commentsEnabled ||
commentsModeration !== collection.commentsModeration ||
commentsClosedAfterDays !== collection.commentsClosedAfterDays ||
commentsAutoApproveUsers !== collection.commentsAutoApproveUsers
);
}, [
isNew,
collection,
slug,
label,
labelSingular,
description,
urlPattern,
supports,
hasSeo,
commentsEnabled,
commentsModeration,
commentsClosedAfterDays,
commentsAutoApproveUsers,
]);
// Auto-generate slug from plural label
const handleLabelChange = (value: string) => {
setLabel(value);
if (isNew) {
setSlug(
value
.toLowerCase()
.replace(SLUG_INVALID_CHARS_PATTERN, "_")
.replace(SLUG_LEADING_TRAILING_PATTERN, ""),
);
}
};
// Auto-generate plural label (and slug) from singular label
const handleSingularLabelChange = (value: string) => {
setLabelSingular(value);
if (isNew) {
const plural = value ? `${value}s` : "";
handleLabelChange(plural);
}
};
const handleSupportToggle = (value: string) => {
setSupports((prev) =>
prev.includes(value) ? prev.filter((s) => s !== value) : [...prev, value],
);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isNew) {
onSave({
slug,
label,
labelSingular: labelSingular || undefined,
description: description || undefined,
urlPattern: urlPattern || undefined,
supports,
hasSeo,
});
} else {
onSave({
label,
labelSingular: labelSingular || undefined,
description: description || undefined,
urlPattern: urlPattern || undefined,
supports,
hasSeo,
commentsEnabled,
commentsModeration,
commentsClosedAfterDays,
commentsAutoApproveUsers,
});
}
};
const handleFieldSave = async (input: CreateFieldInput) => {
setFieldSaving(true);
try {
if (editingField) {
onUpdateField?.(editingField.slug, input);
} else {
onAddField?.(input);
}
setFieldEditorOpen(false);
setEditingField(undefined);
} finally {
setFieldSaving(false);
}
};
const handleEditField = (field: SchemaField) => {
setEditingField(field);
setFieldEditorOpen(true);
};
const handleAddField = () => {
setEditingField(undefined);
setFieldEditorOpen(true);
};
const isFromCode = collection?.source === "code";
const fields = collection?.fields ?? [];
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = fields.findIndex((f) => f.id === active.id);
const newIndex = fields.findIndex((f) => f.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const reordered = arrayMove(fields, oldIndex, newIndex);
onReorderFields?.(reordered.map((f) => f.slug));
};
return (
<div className="space-y-6">
{/* Sticky header keeps the primary save action in view while users
scroll through the settings + fields panels. The bottom-of-form
save button is preserved below for keyboard / screen-reader users
so DOM order still ends with a submit control. */}
<EditorHeader
leading={
<Link
to="/content-types"
aria-label={t`Back to Content Types`}
className={buttonVariants({ variant: "ghost", shape: "square" })}
>
<ArrowPrev className="h-5 w-5" />
</Link>
}
actions={
!isFromCode && !isNew ? (
<SaveButton
type="submit"
form="content-type-editor-form"
isDirty={!!hasChanges}
isSaving={!!isSaving}
disabled={!urlPatternValid}
/>
) : null
}
>
<h1 className="text-2xl font-bold truncate">
{isNew ? t`New Content Type` : collection?.label}
</h1>
{!isNew && (
<p className="text-kumo-subtle text-sm">
<code className="bg-kumo-tint px-1.5 py-0.5 rounded">{collection?.slug}</code>
{isFromCode && (
<span className="ms-2 text-purple-600 dark:text-purple-400">{t`Defined in code`}</span>
)}
</p>
)}
</EditorHeader>
{isFromCode && (
<div className="rounded-lg border border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-950 p-4">
<div className="flex items-center space-x-2">
<FileText className="h-5 w-5 text-purple-600 dark:text-purple-400" />
<p className="text-sm text-purple-700 dark:text-purple-300">
This collection is defined in code. Some settings cannot be changed here. Edit your
live.config.ts file to modify the schema.
</p>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Settings form */}
<div className="lg:col-span-1">
<form id="content-type-editor-form" onSubmit={handleSubmit} className="space-y-4">
<div className="rounded-lg border p-4 space-y-4">
<h2 className="font-semibold">Settings</h2>
<Input
label="Label (Singular)"
value={labelSingular}
onChange={(e) => handleSingularLabelChange(e.target.value)}
placeholder="Post"
disabled={isFromCode}
/>
<Input
label="Label (Plural)"
value={label}
onChange={(e) => handleLabelChange(e.target.value)}
placeholder="Posts"
disabled={isFromCode}
/>
{isNew && (
<div>
<Input
label="Slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="posts"
disabled={!isNew}
/>
<p className="text-xs text-kumo-subtle mt-2">Used in URLs and API endpoints</p>
</div>
)}
<InputArea
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="A brief description of this content type"
rows={3}
disabled={isFromCode}
/>
<div>
<Input
label="URL Pattern"
value={urlPattern}
onChange={(e) => setUrlPattern(e.target.value)}
placeholder={`/${slug === "pages" ? "" : `${slug}/`}{slug}`}
disabled={isFromCode}
/>
{urlPattern && !urlPattern.includes("{slug}") && (
<p className="text-xs text-kumo-danger mt-2">
Pattern must include a {"{slug}"} placeholder
</p>
)}
<p className="text-xs text-kumo-subtle mt-1">
Pattern for generating URLs, e.g. /blog/{"{slug}"}
</p>
</div>
<div className="space-y-3">
<Label>Features</Label>
{SUPPORT_OPTIONS.map((option) => (
<label
key={option.value}
className={cn(
"flex items-start space-x-3 p-2 rounded-md cursor-pointer hover:bg-kumo-tint/50",
isFromCode && "opacity-60 cursor-not-allowed",
)}
>
<input
type="checkbox"
checked={supports.includes(option.value)}
onChange={() => handleSupportToggle(option.value)}
className="mt-1 rounded border-kumo-line"
disabled={isFromCode}
/>
<div>
<span className="text-sm font-medium">{t(option.label)}</span>
<p className="text-xs text-kumo-subtle">{t(option.description)}</p>
</div>
</label>
))}
</div>
{/* SEO toggle */}
<div className="pt-2 border-t">
<label
className={cn(
"flex items-start space-x-3 p-2 rounded-md cursor-pointer hover:bg-kumo-tint/50",
isFromCode && "opacity-60 cursor-not-allowed",
)}
>
<input
type="checkbox"
checked={hasSeo}
onChange={() => setHasSeo(!hasSeo)}
className="mt-1 rounded border-kumo-line"
disabled={isFromCode}
/>
<div>
<span className="text-sm font-medium">SEO</span>
<p className="text-xs text-kumo-subtle">
Add SEO metadata fields (title, description, image) and include in sitemap
</p>
</div>
</label>
</div>
</div>
{/* Comments settings — only for existing collections */}
{!isNew && (
<div className="rounded-lg border p-4 space-y-4">
<h2 className="font-semibold">Comments</h2>
<label
className={cn(
"flex items-start space-x-3 p-2 rounded-md cursor-pointer hover:bg-kumo-tint/50",
isFromCode && "opacity-60 cursor-not-allowed",
)}
>
<input
type="checkbox"
checked={commentsEnabled}
onChange={() => setCommentsEnabled(!commentsEnabled)}
className="mt-1 rounded border-kumo-line"
disabled={isFromCode}
/>
<div>
<span className="text-sm font-medium">Enable comments</span>
<p className="text-xs text-kumo-subtle">
Allow visitors to leave comments on this collection's content
</p>
</div>
</label>
{commentsEnabled && (
<>
<Select
label="Moderation"
value={commentsModeration}
onValueChange={(v) =>
setCommentsModeration((v as "all" | "first_time" | "none") ?? "first_time")
}
items={{
all: "All comments require approval",
first_time: "First-time commenters only",
none: "No moderation (auto-approve all)",
}}
disabled={isFromCode}
/>
<Input
label="Close comments after (days)"
type="number"
min={0}
value={String(commentsClosedAfterDays)}
onChange={(e) => {
const parsed = Number.parseInt(e.target.value, 10);
setCommentsClosedAfterDays(Number.isNaN(parsed) ? 0 : Math.max(0, parsed));
}}
disabled={isFromCode}
/>
<p className="text-xs text-kumo-subtle -mt-2">
Set to 0 to never close comments automatically.
</p>
<label
className={cn(
"flex items-start space-x-3 p-2 rounded-md cursor-pointer hover:bg-kumo-tint/50",
isFromCode && "opacity-60 cursor-not-allowed",
)}
>
<input
type="checkbox"
checked={commentsAutoApproveUsers}
onChange={() => setCommentsAutoApproveUsers(!commentsAutoApproveUsers)}
className="mt-1 rounded border-kumo-line"
disabled={isFromCode}
/>
<div>
<span className="text-sm font-medium">
Auto-approve authenticated users
</span>
<p className="text-xs text-kumo-subtle">
Comments from logged-in CMS users are approved automatically
</p>
</div>
</label>
</>
)}
</div>
)}
{!isFromCode && (
<Button
type="submit"
disabled={!hasChanges || !urlPatternValid || isSaving}
className="w-full"
>
{isSaving ? "Saving..." : isNew ? "Create Content Type" : "Save Changes"}
</Button>
)}
</form>
</div>
{/* Fields section - only show for existing collections */}
{!isNew && (
<div className="lg:col-span-2">
<div className="rounded-lg border">
<div className="flex items-center justify-between p-4 border-b">
<div>
<h2 className="font-semibold">Fields</h2>
<p className="text-sm text-kumo-subtle">
{SYSTEM_FIELDS.length} system + {fields.length} custom field
{fields.length !== 1 ? "s" : ""}
</p>
</div>
{!isFromCode && (
<Button icon={<Plus />} onClick={handleAddField}>
Add Field
</Button>
)}
</div>
{/* System fields - always shown */}
<div className="border-b bg-kumo-tint/30">
<div className="px-4 py-2 text-xs font-medium text-kumo-subtle uppercase tracking-wider">
System Fields
</div>
<div className="divide-y divide-kumo-line/50">
{SYSTEM_FIELDS.map((field) => (
<SystemFieldRow key={field.slug} field={field} />
))}
</div>
</div>
{/* Custom fields */}
{fields.length === 0 ? (
<div className="p-8 text-center text-kumo-subtle">
<Database className="mx-auto h-12 w-12 mb-4 opacity-50" />
<p className="font-medium">No custom fields yet</p>
<p className="text-sm">Add fields to define the structure of your content</p>
{!isFromCode && (
<Button className="mt-4" icon={<Plus />} onClick={handleAddField}>
Add First Field
</Button>
)}
</div>
) : (
<>
<div className="px-4 py-2 text-xs font-medium text-kumo-subtle uppercase tracking-wider border-b">
Custom Fields
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={fields.map((f) => f.id)}
strategy={verticalListSortingStrategy}
>
<div className="divide-y">
{fields.map((field) => (
<FieldRow
key={field.id}
field={field}
isFromCode={isFromCode}
onEdit={() => handleEditField(field)}
onDelete={() => setDeleteFieldTarget(field)}
/>
))}
</div>
</SortableContext>
</DndContext>
</>
)}
</div>
</div>
)}
</div>
{/* Field editor dialog */}
<FieldEditor
open={fieldEditorOpen}
onOpenChange={setFieldEditorOpen}
field={editingField}
onSave={handleFieldSave}
isSaving={fieldSaving}
/>
<ConfirmDialog
open={!!deleteFieldTarget}
onClose={() => setDeleteFieldTarget(null)}
title="Delete Field?"
description={
deleteFieldTarget
? `Are you sure you want to delete the "${deleteFieldTarget.label}" field?`
: ""
}
confirmLabel="Delete"
pendingLabel="Deleting..."
isPending={false}
error={null}
onConfirm={() => {
if (deleteFieldTarget) {
onDeleteField?.(deleteFieldTarget.slug);
setDeleteFieldTarget(null);
}
}}
/>
</div>
);
}
interface FieldRowProps {
field: SchemaField;
isFromCode?: boolean;
onEdit: () => void;
onDelete: () => void;
}
function FieldRow({ field, isFromCode, onEdit, onDelete }: FieldRowProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: field.id,
disabled: isFromCode,
});
const style = { transform: CSS.Transform.toString(transform), transition };
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"flex items-center px-4 py-3 hover:bg-kumo-tint/25",
isDragging && "opacity-50",
)}
>
{!isFromCode && (
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing me-3"
aria-label={`Drag to reorder ${field.label}`}
>
<DotsSixVertical className="h-5 w-5 text-kumo-subtle" />
</button>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<span className="font-medium">{field.label}</span>
<code className="text-xs bg-kumo-tint px-1.5 py-0.5 rounded text-kumo-subtle">
{field.slug}
</code>
</div>
<div className="flex items-center space-x-2 mt-1">
<span className="text-xs text-kumo-subtle capitalize">{field.type}</span>
{field.required && <Badge variant="secondary">Required</Badge>}
{field.unique && <Badge variant="secondary">Unique</Badge>}
{field.searchable && <Badge variant="secondary">Searchable</Badge>}
</div>
</div>
{!isFromCode && (
<div className="flex items-center space-x-1">
<Button
variant="ghost"
shape="square"
onClick={onEdit}
aria-label={`Edit ${field.label} field`}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
shape="square"
onClick={onDelete}
aria-label={`Delete ${field.label} field`}
>
<Trash className="h-4 w-4 text-kumo-danger" />
</Button>
</div>
)}
</div>
);
}
interface SystemFieldInfo {
slug: string;
label: MessageDescriptor;
type: string;
description: MessageDescriptor;
}
function SystemFieldRow({ field }: { field: SystemFieldInfo }) {
const { t } = useLingui();
return (
<div className="flex items-center px-4 py-2 opacity-75">
<div className="w-8" /> {/* Spacer for alignment with draggable fields */}
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<span className="font-medium text-sm">{t(field.label)}</span>
<code className="text-xs bg-kumo-tint px-1.5 py-0.5 rounded text-kumo-subtle">
{field.slug}
</code>
<Badge variant="secondary">System</Badge>
</div>
<p className="text-xs text-kumo-subtle mt-0.5">{t(field.description)}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,247 @@
import { Badge, Button, buttonVariants } from "@cloudflare/kumo";
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { Plus, Pencil, Trash, Database, FileText, Warning, Check } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import * as React from "react";
import type { SchemaCollection, OrphanedTable } from "../lib/api";
import { cn } from "../lib/utils";
import { ConfirmDialog } from "./ConfirmDialog";
export interface ContentTypeListProps {
collections: SchemaCollection[];
orphanedTables?: OrphanedTable[];
isLoading?: boolean;
onDelete?: (slug: string) => void;
onRegisterOrphan?: (slug: string) => void;
}
/**
* Content Type list view - shows all collections in the schema registry
*/
export function ContentTypeList({
collections,
orphanedTables,
isLoading,
onDelete,
onRegisterOrphan,
}: ContentTypeListProps) {
const { t } = useLingui();
const [deleteTarget, setDeleteTarget] = React.useState<SchemaCollection | null>(null);
const hasOrphans = orphanedTables && orphanedTables.length > 0;
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{t`Content Types`}</h1>
<p className="text-kumo-subtle text-sm">{t`Define the structure of your content`}</p>
</div>
<Link to="/content-types/new" className={buttonVariants()}>
<Plus className="me-2 h-4 w-4" aria-hidden="true" />
{t`New Content Type`}
</Link>
</div>
{/* Orphaned Tables Warning */}
{hasOrphans && (
<div className="rounded-md border border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950 p-4">
<div className="flex items-start gap-3">
<Warning className="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5" />
<div className="flex-1">
<h3 className="font-medium text-amber-800 dark:text-amber-200">
{t`Unregistered Content Tables Found`}
</h3>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
{t`The following tables contain content but aren't registered as collections. Register them to manage this content in the admin.`}
</p>
<div className="mt-3 space-y-2">
{orphanedTables.map((orphan) => (
<div
key={orphan.slug}
className="flex items-center justify-between bg-white dark:bg-amber-900/50 rounded-md px-3 py-2"
>
<div>
<code className="text-sm font-medium">{orphan.slug}</code>
<span className="text-xs text-kumo-subtle ms-2">
{plural(orphan.rowCount, { one: "(# item)", other: "(# items)" })}
</span>
</div>
<Button
size="sm"
variant="outline"
icon={<Check />}
onClick={() => onRegisterOrphan?.(orphan.slug)}
>
{t`Register`}
</Button>
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* Table */}
<div className="rounded-md border overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-kumo-tint/50">
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Name`}
</th>
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Slug`}
</th>
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Source`}
</th>
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Features`}
</th>
<th scope="col" className="px-4 py-3 text-end text-sm font-medium">
{t`Actions`}
</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-kumo-subtle">
{t`Loading collections...`}
</td>
</tr>
) : collections.length === 0 && !hasOrphans ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-kumo-subtle">
{t`No content types yet.`}{" "}
<Link to="/content-types/new" className="text-kumo-brand underline">
{t`Create your first one`}
</Link>
</td>
</tr>
) : (
collections.map((collection) => (
<ContentTypeRow
key={collection.id}
collection={collection}
onRequestDelete={setDeleteTarget}
/>
))
)}
</tbody>
</table>
</div>
<ConfirmDialog
open={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
title={t`Delete Content Type?`}
description={
deleteTarget
? t`Are you sure you want to delete "${deleteTarget.label}"? This will also delete all content in this collection.`
: ""
}
confirmLabel={t`Delete`}
pendingLabel={t`Deleting...`}
isPending={false}
error={null}
onConfirm={() => {
if (deleteTarget) {
onDelete?.(deleteTarget.slug);
setDeleteTarget(null);
}
}}
/>
</div>
);
}
interface ContentTypeRowProps {
collection: SchemaCollection;
onRequestDelete?: (collection: SchemaCollection) => void;
}
function ContentTypeRow({ collection, onRequestDelete }: ContentTypeRowProps) {
const { t } = useLingui();
const isFromCode = collection.source === "code";
return (
<tr className="border-b hover:bg-kumo-tint/25">
<td className="px-4 py-3">
<div className="flex items-center space-x-3">
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-lg",
isFromCode
? "bg-purple-100 text-purple-600 dark:bg-purple-900 dark:text-purple-300"
: "bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-300",
)}
>
{isFromCode ? <FileText className="h-4 w-4" /> : <Database className="h-4 w-4" />}
</div>
<div>
<Link
to="/content-types/$slug"
params={{ slug: collection.slug }}
className="font-medium hover:text-kumo-brand"
>
{collection.label}
</Link>
{collection.description && (
<p className="text-xs text-kumo-subtle">{collection.description}</p>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
<code className="text-sm bg-kumo-tint px-1.5 py-0.5 rounded">{collection.slug}</code>
</td>
<td className="px-4 py-3">
<SourceBadge source={collection.source} />
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{collection.supports.map((feature) => (
<Badge key={feature} variant="secondary">
{feature}
</Badge>
))}
</div>
</td>
<td className="px-4 py-3 text-end">
<div className="flex items-center justify-end space-x-1">
<Link
to="/content-types/$slug"
params={{ slug: collection.slug }}
aria-label={t`Edit ${collection.label}`}
className={buttonVariants({ variant: "ghost", shape: "square" })}
>
<Pencil className="h-4 w-4" aria-hidden="true" />
</Link>
{!isFromCode && (
<Button
variant="ghost"
shape="square"
aria-label={t`Delete ${collection.label}`}
onClick={() => onRequestDelete?.(collection)}
>
<Trash className="h-4 w-4 text-kumo-danger" aria-hidden="true" />
</Button>
)}
</div>
</td>
</tr>
);
}
function SourceBadge({ source }: { source?: string }) {
const { t } = useLingui();
if (source === "code") {
return <Badge variant="secondary">{t`Code`}</Badge>;
}
return <Badge variant="secondary">{t`Dashboard`}</Badge>;
}

View File

@@ -0,0 +1,336 @@
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import {
Plus,
Upload,
ArrowRight,
CircleDashed,
CheckCircle,
PencilSimple,
CalendarBlank,
Image,
Users,
} from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import type { AdminManifest } from "../lib/api";
import type { CollectionStats, DashboardStats, RecentItem } from "../lib/api/dashboard";
import { fetchDashboardStats } from "../lib/api/dashboard";
import { usePluginWidget } from "../lib/plugin-context";
import { formatRelativeTime } from "../lib/utils";
import { SandboxedPluginWidget } from "./SandboxedPluginWidget";
export interface DashboardProps {
manifest: AdminManifest;
}
/**
* Admin dashboard — quick actions, status, collections, recent activity.
*/
export function Dashboard({ manifest }: DashboardProps) {
const { t } = useLingui();
const { data: stats, isLoading } = useQuery({
queryKey: ["dashboard-stats"],
queryFn: fetchDashboardStats,
refetchOnWindowFocus: true,
});
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-3xl font-bold">{t`Dashboard`}</h1>
<QuickActions manifest={manifest} />
</div>
<StatusBar stats={stats} loading={isLoading} />
{/* Collections + Recent activity */}
<div className="grid gap-6 lg:grid-cols-2">
<CollectionList
collections={stats?.collections ?? []}
manifest={manifest}
loading={isLoading}
/>
<RecentActivity items={stats?.recentItems ?? []} loading={isLoading} />
</div>
{/* Plugin widgets */}
<PluginWidgets manifest={manifest} />
</div>
);
}
// --- Quick actions ---
function QuickActions({ manifest }: { manifest: AdminManifest }) {
const { t } = useLingui();
const collections = Object.entries(manifest.collections);
return (
<div className="flex flex-wrap gap-2">
{collections.map(([slug, config]) => (
<Link
key={slug}
to="/content/$collection"
params={{ collection: slug }}
search={{ locale: undefined }}
className="inline-flex items-center gap-1.5 rounded-md border bg-kumo-base px-3 py-1.5 text-sm font-medium transition-colors hover:bg-kumo-tint"
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
{config.labelSingular ?? config.label}
</Link>
))}
<Link
to="/media"
className="inline-flex items-center gap-1.5 rounded-md border bg-kumo-base px-3 py-1.5 text-sm font-medium transition-colors hover:bg-kumo-tint"
>
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
{t`Upload Media`}
</Link>
</div>
);
}
// --- Status bar ---
function StatusBar({ stats, loading }: { stats?: DashboardStats; loading: boolean }) {
if (loading) {
return <div className="flex h-9 animate-pulse rounded-lg border bg-kumo-tint" />;
}
if (!stats) return null;
const totalDrafts = stats.collections.reduce((sum, c) => sum + c.draft, 0);
const totalScheduled = stats.collections.reduce(
(sum, c) => sum + (c.total - c.published - c.draft),
0,
);
const indicators = [
totalDrafts > 0 && {
icon: PencilSimple,
label: plural(totalDrafts, { one: "# draft", other: "# drafts" }),
className: "text-amber-700 dark:text-amber-400",
},
totalScheduled > 0 && {
icon: CalendarBlank,
label: plural(totalScheduled, { one: "# scheduled", other: "# scheduled" }),
className: "text-blue-600 dark:text-blue-400",
},
{
icon: Image,
label: plural(stats.mediaCount, { one: "# media file", other: "# media files" }),
className: "text-kumo-subtle",
},
{
icon: Users,
label: plural(stats.userCount, { one: "# user", other: "# users" }),
className: "text-kumo-subtle",
},
].filter(Boolean) as Array<{
icon: React.ElementType;
label: string;
className: string;
}>;
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 rounded-lg border bg-kumo-base px-4 py-2 text-sm">
{indicators.map((ind) => (
<span key={ind.label} className={`inline-flex items-center gap-1.5 ${ind.className}`}>
<ind.icon className="h-3.5 w-3.5" aria-hidden="true" />
{ind.label}
</span>
))}
</div>
);
}
// --- Collection list with counts ---
function CollectionList({
collections,
manifest,
loading,
}: {
collections: CollectionStats[];
manifest: AdminManifest;
loading: boolean;
}) {
const { t } = useLingui();
return (
<div className="rounded-lg border bg-kumo-base p-4 sm:p-6">
<h2 className="mb-4 text-lg font-semibold">{t`Content`}</h2>
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-10 animate-pulse rounded-md bg-kumo-tint" />
))}
</div>
) : collections.length === 0 ? (
<p className="text-sm text-kumo-subtle">{t`No collections configured`}</p>
) : (
<div className="space-y-1">
{collections.map((col) => {
const config = manifest.collections[col.slug];
return (
<Link
key={col.slug}
to="/content/$collection"
params={{ collection: col.slug }}
search={{ locale: undefined }}
className="group flex items-center justify-between rounded-md px-3 py-2 hover:bg-kumo-tint"
>
<span className="font-medium">{config?.label ?? col.label}</span>
<span className="flex items-center gap-3 text-xs text-kumo-subtle">
<CountBadge icon={CheckCircle} count={col.published} title={t`Published`} />
<CountBadge icon={PencilSimple} count={col.draft} title={t`Drafts`} />
<ArrowRight
className="h-3.5 w-3.5 opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden="true"
/>
</span>
</Link>
);
})}
</div>
)}
</div>
);
}
function CountBadge({
icon: Icon,
count,
title,
}: {
icon: React.ElementType;
count: number;
title: string;
}) {
if (count === 0) return null;
return (
<span className="inline-flex items-center gap-1" title={title}>
<Icon className="h-3 w-3" aria-hidden="true" />
{count}
</span>
);
}
// --- Recent activity ---
function RecentActivity({ items, loading }: { items: RecentItem[]; loading: boolean }) {
const { t } = useLingui();
return (
<div className="rounded-lg border bg-kumo-base p-4 sm:p-6">
<h2 className="mb-4 text-lg font-semibold">{t`Recent Activity`}</h2>
{loading ? (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-10 animate-pulse rounded-md bg-kumo-tint" />
))}
</div>
) : items.length === 0 ? (
<p className="text-sm text-kumo-subtle">{t`No recent activity`}</p>
) : (
<div className="space-y-1">
{items.map((item) => (
<Link
key={`${item.collection}-${item.id}`}
to="/content/$collection/$id"
params={{ collection: item.collection, id: item.id }}
className="group flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-kumo-tint"
>
<div className="flex min-w-0 items-center gap-2">
<StatusDot status={item.status} />
<span className="truncate font-medium">
{item.title || item.slug || t`Untitled`}
</span>
<span className="hidden shrink-0 text-xs text-kumo-subtle sm:inline">
{item.collectionLabel}
</span>
</div>
<span className="shrink-0 text-xs text-kumo-subtle">
{formatRelativeTime(item.updatedAt)}
</span>
</Link>
))}
</div>
)}
</div>
);
}
function StatusDot({ status }: { status: string }) {
const colors: Record<string, string> = {
published: "text-green-500",
draft: "text-amber-500",
scheduled: "text-blue-500",
};
const Icon = status === "published" ? CheckCircle : CircleDashed;
return (
<Icon
className={`h-3.5 w-3.5 shrink-0 ${colors[status] ?? "text-kumo-subtle"}`}
aria-label={status}
/>
);
}
// --- Plugin widgets ---
function PluginWidgets({ manifest }: { manifest: AdminManifest }) {
const widgets: Array<{
id: string;
pluginId: string;
title?: string;
size?: "full" | "half" | "third";
}> = [];
for (const [pluginId, plugin] of Object.entries(manifest.plugins || {})) {
if (plugin.enabled === false) continue;
if ("dashboardWidgets" in plugin && Array.isArray(plugin.dashboardWidgets)) {
for (const widget of plugin.dashboardWidgets) {
widgets.push({
id: widget.id,
pluginId,
title: widget.title,
size: widget.size,
});
}
}
}
if (widgets.length === 0) {
return null;
}
return (
<div className="grid gap-6 lg:grid-cols-2">
{widgets.map((widget) => (
<PluginWidgetCard key={`${widget.pluginId}:${widget.id}`} widget={widget} />
))}
</div>
);
}
function PluginWidgetCard({
widget,
}: {
widget: { id: string; pluginId: string; title?: string; size?: string };
}) {
const WidgetComponent = usePluginWidget(widget.pluginId, widget.id);
return (
<div className="rounded-lg border bg-kumo-base p-4 sm:p-6">
<h2 className="text-lg font-semibold mb-4">{widget.title || widget.id}</h2>
{WidgetComponent ? (
<WidgetComponent />
) : (
<SandboxedPluginWidget pluginId={widget.pluginId} widgetId={widget.id} />
)}
</div>
);
}

View File

@@ -0,0 +1,334 @@
/**
* Device Authorization Page
*
* Standalone page where users enter the code displayed by `emdash login`
* to authorize a CLI or agent to access their account.
*
* Flow:
* 1. User runs `emdash login` → sees a code like ABCD-1234
* 2. User opens this page in their browser (already logged in)
* 3. User enters the code → clicks Authorize
* 4. CLI receives tokens and saves them
*/
import { Button, Input } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { useQuery } from "@tanstack/react-query";
import * as React from "react";
import { apiFetch, API_BASE, parseApiResponse } from "../lib/api";
// ============================================================================
// Types
// ============================================================================
interface UserInfo {
id: string;
email: string;
name: string | null;
role: number;
}
type PageState = "input" | "submitting" | "success" | "denied" | "error";
// ============================================================================
// Constants
// ============================================================================
const ROLE_NAMES: Record<number, string> = {
10: "Subscriber",
20: "Contributor",
30: "Author",
40: "Editor",
50: "Admin",
};
const DEVICE_CODE_INVALID_CHARS_REGEX = /[^A-Z0-9-]/g;
const DEVICE_CODE_HYPHEN_REGEX = /-/g;
// ============================================================================
// Component
// ============================================================================
export function DeviceAuthorizePage() {
const [code, setCode] = React.useState("");
const [pageState, setPageState] = React.useState<PageState>("input");
const [errorMessage, setErrorMessage] = React.useState("");
// Check if user is logged in
const {
data: user,
isLoading,
error: authError,
} = useQuery<UserInfo>({
queryKey: ["auth-me"],
queryFn: async () => {
const res = await apiFetch(`${API_BASE}/auth/me`);
return parseApiResponse<UserInfo>(res, "Not authenticated");
},
retry: false,
});
// Pre-populate from URL query param (?code=ABCD-1234)
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
const urlCode = params.get("code");
if (urlCode) {
setCode(urlCode);
}
}, []);
// Not authenticated — redirect to login
React.useEffect(() => {
if (!isLoading && (authError || !user)) {
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = `/_emdash/admin/login?redirect=${returnUrl}`;
}
}, [isLoading, authError, user]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = code.trim();
if (!trimmed) return;
setPageState("submitting");
setErrorMessage("");
try {
const res = await apiFetch(`${API_BASE}/oauth/device/authorize`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_code: trimmed, action: "approve" }),
});
const data = await parseApiResponse<{ authorized: boolean }>(res, "Authorization failed");
setPageState(data.authorized ? "success" : "denied");
} catch (err) {
setErrorMessage(err instanceof Error ? err.message : "Network error");
setPageState("error");
}
}
async function handleDeny(e: React.FormEvent) {
e.preventDefault();
const trimmed = code.trim();
if (!trimmed) return;
setPageState("submitting");
try {
await apiFetch(`${API_BASE}/oauth/device/authorize`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_code: trimmed, action: "deny" }),
});
setPageState("denied");
} catch {
setPageState("denied");
}
}
// Format code as user types (insert hyphen after 4 chars)
function handleCodeChange(e: React.ChangeEvent<HTMLInputElement>) {
let value = e.target.value.toUpperCase().replace(DEVICE_CODE_INVALID_CHARS_REGEX, "");
// Auto-insert hyphen after 4 chars if not already present
if (value.length === 4 && !value.includes("-")) {
value = value + "-";
}
// Limit to 9 chars (XXXX-XXXX)
if (value.length > 9) {
value = value.slice(0, 9);
}
setCode(value);
}
const { t } = useLingui();
if (isLoading) {
return (
<PageWrapper>
<p className="text-kumo-subtle text-sm">{t`Checking authentication...`}</p>
</PageWrapper>
);
}
if (!user) {
return (
<PageWrapper>
<p className="text-kumo-subtle text-sm">{t`Redirecting to login...`}</p>
</PageWrapper>
);
}
return (
<PageWrapper>
<div className="w-full max-w-sm">
{/* Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-kumo-brand/10 mb-4">
<TerminalIcon className="w-6 h-6 text-kumo-brand" />
</div>
<h1 className="text-xl font-semibold tracking-tight">{t`Authorize Device`}</h1>
<p className="text-kumo-subtle text-sm mt-1.5">{t`Enter the code from your terminal`}</p>
</div>
{/* Success state */}
{pageState === "success" && (
<div className="rounded-lg border border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950/50 p-6 text-center">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/50 mb-3">
<CheckIcon className="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
<h2 className="font-medium text-green-900 dark:text-green-100">{t`Device authorized`}</h2>
<p className="text-sm text-green-700 dark:text-green-300 mt-1">
{t`You can close this page and return to your terminal.`}
</p>
<p className="text-xs text-kumo-subtle mt-3">{t`Signed in as ${user.email}`}</p>
</div>
)}
{/* Denied state */}
{pageState === "denied" && (
<div className="rounded-lg border border-kumo-line p-6 text-center">
<h2 className="font-medium">{t`Authorization denied`}</h2>
<p className="text-sm text-kumo-subtle mt-1">{t`The device will not be granted access.`}</p>
<Button
className="mt-4"
variant="outline"
onClick={() => {
setPageState("input");
setCode("");
}}
>
{t`Try another code`}
</Button>
</div>
)}
{/* Input / Error state */}
{(pageState === "input" || pageState === "submitting" || pageState === "error") && (
<form onSubmit={handleSubmit}>
<div className="rounded-lg border border-kumo-line bg-kumo-base p-6">
{/* User badge */}
<div className="flex items-center gap-2 mb-5 pb-4 border-b border-kumo-line">
<div className="w-8 h-8 rounded-full bg-kumo-tint flex items-center justify-center text-xs font-medium">
{(user.name || user.email).charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{user.name || user.email}</p>
<p className="text-xs text-kumo-subtle">{ROLE_NAMES[user.role] || t`User`}</p>
</div>
</div>
{/* Code input */}
<label className="block text-sm font-medium mb-2" htmlFor="user-code">
{t`Device code`}
</label>
<Input
id="user-code"
type="text"
value={code}
onChange={handleCodeChange}
placeholder="XXXX-XXXX"
className="text-center text-lg font-mono tracking-widest"
autoFocus
autoComplete="off"
spellCheck={false}
disabled={pageState === "submitting"}
/>
{/* Error message */}
{pageState === "error" && errorMessage && (
<p className="text-sm text-kumo-danger mt-2">{errorMessage}</p>
)}
{/* Actions */}
<div className="flex gap-2 mt-4">
<Button
type="submit"
className="flex-1"
disabled={
code.replace(DEVICE_CODE_HYPHEN_REGEX, "").length < 8 ||
pageState === "submitting"
}
>
{pageState === "submitting" ? t`Authorizing...` : t`Authorize`}
</Button>
<Button
type="button"
variant="outline"
onClick={handleDeny}
disabled={
code.replace(DEVICE_CODE_HYPHEN_REGEX, "").length < 8 ||
pageState === "submitting"
}
>
{t`Deny`}
</Button>
</div>
</div>
<p className="text-xs text-kumo-subtle text-center mt-4">
{t`This will grant CLI access with your permissions.`}
<br />
{t`Only authorize codes you recognize.`}
</p>
</form>
)}
</div>
</PageWrapper>
);
}
// ============================================================================
// Layout wrapper
// ============================================================================
function PageWrapper({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen flex items-center justify-center bg-kumo-base p-4">
<div className="w-full max-w-sm">{children}</div>
</div>
);
}
// ============================================================================
// Icons (inline SVG to avoid dependency on icon library for this simple page)
// ============================================================================
function TerminalIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
);
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
);
}

View File

@@ -0,0 +1,34 @@
/**
* Shared error display for dialogs and mutation error extraction.
*/
import { cn } from "../lib/utils.js";
/** Extract a user-facing message from a mutation error value. */
export function getMutationError(error: unknown): string | null {
if (!error) return null;
if (error instanceof Error) return error.message;
return "An error occurred";
}
/** Inline error banner for use inside dialogs. */
export function DialogError({
message,
className,
}: {
message?: string | null;
className?: string;
}) {
if (!message) return null;
const lines = message.split("\n");
return (
<div
role="alert"
className={cn("rounded-md bg-kumo-danger/10 p-3 text-sm text-kumo-danger", className)}
>
{lines.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
);
}

View File

@@ -0,0 +1,93 @@
/**
* EditorHeader
*
* Shared sticky header used by editor pages (Content, Content Type, Section,
* Settings) so the primary save action is always visible at the top of the
* page while users scroll through long forms.
*
* Why sticky:
* The main content area in `Shell` (`<main className="overflow-y-auto">`)
* is the scroll container. `position: sticky; top: 0` pins this header to
* the top of that container so the Save button stays in view.
*
* Accessibility:
* The sticky header is the *primary* save affordance for sighted/pointer
* users, but it doesn't replace a save action at the natural end of the
* form. Editor pages that use this header continue to render their existing
* bottom-of-form save button so keyboard and screen-reader users hit it as
* the last interactive control on the page (DOM order matches logical order).
*
* RTL:
* Avoids physical left/right Tailwind utilities. The component itself uses
* only symmetric horizontal utilities (`-mx-*`, `px-*`), which are
* direction-agnostic. Callers passing directional content into the
* `leading` / `actions` slots should use logical classes (`ms-*`, `me-*`,
* `start-*`, `end-*`) for any side-specific spacing or positioning.
*/
import * as React from "react";
import { cn } from "../lib/utils";
export interface EditorHeaderProps {
/** Optional leading element, typically a back-link or close button. */
leading?: React.ReactNode;
/** Header title content. Pass a heading element so semantics are correct. */
children: React.ReactNode;
/** Right-aligned action area (Save, Publish, etc.). */
actions?: React.ReactNode;
/**
* When `true`, the header sticks to the top of its scroll container.
* Defaults to `true`. Set to `false` to render a static header (e.g. when
* the parent wants to control positioning, or when the page itself is in
* a special mode like distraction-free).
*/
sticky?: boolean;
className?: string;
}
/**
* Sticky editor header with consistent placement of save / primary actions.
*
* Usage:
*
* <EditorHeader
* leading={<BackLink />}
* actions={<SaveButton ... />}
* >
* <h1 className="text-2xl font-bold">{title}</h1>
* </EditorHeader>
*/
export function EditorHeader({
leading,
children,
actions,
sticky = true,
className,
}: EditorHeaderProps) {
return (
<div
data-editor-header
className={cn(
// Negative inline margins + padding cancel out the parent <main>'s
// p-6 so the header background spans edge-to-edge of the scroll
// container while still aligning content to the original gutter.
sticky && "sticky top-0 z-30 -mx-6 -mt-6 px-6 pt-6",
// Solid background so content scrolling behind doesn't bleed through.
sticky && "bg-kumo-base/95 supports-[backdrop-filter]:bg-kumo-base/80 backdrop-blur",
// Subtle separator + bottom padding so it visually detaches from form.
sticky && "pb-3 mb-3 border-b border-kumo-line",
"flex flex-wrap items-center justify-between gap-y-2 gap-x-4",
className,
)}
>
<div className="flex items-center gap-4 min-w-0">
{leading}
<div className="min-w-0">{children}</div>
</div>
{actions && <div className="flex items-center gap-2 flex-wrap">{actions}</div>}
</div>
);
}
export default EditorHeader;

View File

@@ -0,0 +1,663 @@
import { Button, Dialog, Input, InputArea } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import {
TextT,
TextAlignLeft,
Hash,
ToggleLeft,
Calendar,
List,
ListChecks,
FileText,
Image as ImageIcon,
File,
LinkSimple,
BracketsCurly,
Link,
GlobeSimple,
Rows,
Plus,
Trash,
} from "@phosphor-icons/react";
import { X } from "@phosphor-icons/react";
import * as React from "react";
import type { FieldType, CreateFieldInput, SchemaField } from "../lib/api";
import { cn } from "../lib/utils";
// ============================================================================
// Constants
// ============================================================================
const SLUG_INVALID_CHARS_REGEX = /[^a-z0-9]+/g;
const SLUG_LEADING_TRAILING_REGEX = /^_|_$/g;
// ============================================================================
// Types
// ============================================================================
export interface FieldEditorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
field?: SchemaField;
onSave: (input: CreateFieldInput) => void;
isSaving?: boolean;
}
interface FieldTypeConfig {
type: FieldType;
label: string;
description: string;
icon: React.ElementType;
}
interface RepeaterSubFieldState {
slug: string;
type: string;
label: string;
required: boolean;
}
interface FieldFormState {
step: "type" | "config";
selectedType: FieldType | null;
slug: string;
label: string;
required: boolean;
unique: boolean;
searchable: boolean;
minLength: string;
maxLength: string;
min: string;
max: string;
pattern: string;
options: string;
subFields: RepeaterSubFieldState[];
minItems: string;
maxItems: string;
}
function getInitialFormState(field?: SchemaField): FieldFormState {
if (field) {
return {
step: "config",
selectedType: field.type,
slug: field.slug,
label: field.label,
required: field.required,
unique: field.unique,
searchable: field.searchable,
minLength: field.validation?.minLength?.toString() ?? "",
maxLength: field.validation?.maxLength?.toString() ?? "",
min: field.validation?.min?.toString() ?? "",
max: field.validation?.max?.toString() ?? "",
pattern: field.validation?.pattern ?? "",
options: field.validation?.options?.join("\n") ?? "",
subFields: (field.validation as Record<string, unknown>)?.subFields
? ((field.validation as Record<string, unknown>).subFields as RepeaterSubFieldState[])
: [],
minItems: (field.validation as Record<string, unknown>)?.minItems?.toString() ?? "",
maxItems: (field.validation as Record<string, unknown>)?.maxItems?.toString() ?? "",
};
}
return {
step: "type",
selectedType: null,
slug: "",
label: "",
required: false,
unique: false,
searchable: false,
minLength: "",
maxLength: "",
min: "",
max: "",
pattern: "",
options: "",
subFields: [],
minItems: "",
maxItems: "",
};
}
/**
* Field editor dialog for creating/editing fields
*/
export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: FieldEditorProps) {
const { t } = useLingui();
const [formState, setFormState] = React.useState(() => getInitialFormState(field));
// Reset state when dialog opens
React.useEffect(() => {
if (open) {
setFormState(getInitialFormState(field));
}
}, [open, field]);
const { step, selectedType, slug, label, required, unique, searchable } = formState;
const { minLength, maxLength, min, max, pattern, options } = formState;
const setField = <K extends keyof FieldFormState>(key: K, value: FieldFormState[K]) =>
setFormState((prev) => ({ ...prev, [key]: value }));
// Build field types inside the component so t`` works
const FIELD_TYPES: FieldTypeConfig[] = [
{
type: "string",
label: t`Short Text`,
description: t`Single line text input`,
icon: TextT,
},
{
type: "text",
label: t`Long Text`,
description: t`Multi-line plain text`,
icon: TextAlignLeft,
},
{
type: "number",
label: t`Number`,
description: t`Decimal number`,
icon: Hash,
},
{
type: "integer",
label: t`Integer`,
description: t`Whole number`,
icon: Hash,
},
{
type: "boolean",
label: t`Boolean`,
description: t`True/false toggle`,
icon: ToggleLeft,
},
{
type: "datetime",
label: t`Date & Time`,
description: t`Date and time picker`,
icon: Calendar,
},
{
type: "select",
label: t`Select`,
description: t`Single choice from options`,
icon: List,
},
{
type: "multiSelect",
label: t`Multi Select`,
description: t`Multiple choices from options`,
icon: ListChecks,
},
{
type: "portableText",
label: t`Rich Text`,
description: t`Rich text editor`,
icon: FileText,
},
{
type: "image",
label: t`Image`,
description: t`Image from media library`,
icon: ImageIcon,
},
{
type: "file",
label: t`File`,
description: t`File from media library`,
icon: File,
},
{
type: "reference",
label: t`Reference`,
description: t`Link to another content item`,
icon: LinkSimple,
},
{
type: "json",
label: t`JSON`,
description: t`Arbitrary JSON data`,
icon: BracketsCurly,
},
{
type: "slug",
label: t`Slug`,
description: t`URL-friendly identifier`,
icon: Link,
},
{
type: "url",
label: t`URL`,
description: t`Web address`,
icon: GlobeSimple,
},
{
type: "repeater",
label: t`Repeater`,
description: t`Repeating group of fields`,
icon: Rows,
},
];
// Auto-generate slug from label
const handleLabelChange = (value: string) => {
setField("label", value);
if (!field) {
// Only auto-generate for new fields
setField(
"slug",
value
.toLowerCase()
.replace(SLUG_INVALID_CHARS_REGEX, "_")
.replace(SLUG_LEADING_TRAILING_REGEX, ""),
);
}
};
const handleTypeSelect = (type: FieldType) => {
setFormState((prev) => ({ ...prev, selectedType: type, step: "config" }));
};
const handleSave = () => {
if (!selectedType || !slug || !label) return;
const validation: CreateFieldInput["validation"] = {};
// Build validation based on field type
if (selectedType === "string" || selectedType === "text" || selectedType === "slug") {
if (minLength) validation.minLength = parseInt(minLength, 10);
if (maxLength) validation.maxLength = parseInt(maxLength, 10);
if (pattern) validation.pattern = pattern;
}
if (selectedType === "number" || selectedType === "integer") {
if (min) validation.min = parseFloat(min);
if (max) validation.max = parseFloat(max);
}
if (selectedType === "select" || selectedType === "multiSelect") {
const optionList = options
.split("\n")
.map((o) => o.trim())
.filter(Boolean);
if (optionList.length > 0) {
validation.options = optionList;
}
}
if (selectedType === "repeater") {
if (formState.subFields.length > 0) {
(validation as Record<string, unknown>).subFields = formState.subFields.map((sf) => ({
slug: sf.slug,
type: sf.type,
label: sf.label,
required: sf.required || undefined,
}));
}
if (formState.minItems)
(validation as Record<string, unknown>).minItems = parseInt(formState.minItems, 10);
if (formState.maxItems)
(validation as Record<string, unknown>).maxItems = parseInt(formState.maxItems, 10);
}
// Only include searchable for text-based fields
const isSearchableType =
selectedType === "string" ||
selectedType === "text" ||
selectedType === "portableText" ||
selectedType === "slug" ||
selectedType === "url";
const input: CreateFieldInput = {
slug,
label,
type: selectedType,
required,
unique,
searchable: isSearchableType ? searchable : undefined,
validation: Object.keys(validation).length > 0 ? validation : undefined,
};
onSave(input);
};
const typeConfig = FIELD_TYPES.find((fieldType) => fieldType.type === selectedType);
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog className="p-6 max-w-2xl" size="lg">
<div className="flex items-start justify-between gap-4 mb-4">
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
{field ? t`Edit Field` : step === "type" ? t`Add Field` : t`Configure Field`}
</Dialog.Title>
<Dialog.Close
aria-label={t`Close`}
render={(props) => (
<Button
{...props}
variant="ghost"
shape="square"
aria-label={t`Close`}
className="absolute end-4 top-4"
>
<X className="h-4 w-4" />
<span className="sr-only">{t`Close`}</span>
</Button>
)}
/>
</div>
{step === "type" ? (
<div className="grid grid-cols-2 gap-3 max-h-[60vh] overflow-y-auto">
{FIELD_TYPES.map((ft) => {
const Icon = ft.icon;
return (
<button
key={ft.type}
type="button"
onClick={() => handleTypeSelect(ft.type)}
className={cn(
"flex items-start space-x-3 p-4 rounded-lg border text-start transition-colors hover:border-kumo-brand hover:bg-kumo-tint/50",
)}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-kumo-tint">
<Icon className="h-5 w-5" />
</div>
<div>
<p className="font-medium">{ft.label}</p>
<p className="text-sm text-kumo-subtle">{ft.description}</p>
</div>
</button>
);
})}
</div>
) : (
<div className="space-y-6">
{/* Type indicator */}
{typeConfig && (
<div className="flex items-center space-x-3 p-3 bg-kumo-tint/50 rounded-lg">
<typeConfig.icon className="h-5 w-5" />
<div>
<p className="font-medium">{typeConfig.label}</p>
<p className="text-sm text-kumo-subtle">{typeConfig.description}</p>
</div>
{!field && (
<Button
variant="ghost"
size="sm"
className="ms-auto"
onClick={() => setField("step", "type")}
>
{t`Change`}
</Button>
)}
</div>
)}
{/* Basic info */}
<div className="grid grid-cols-2 gap-4">
<Input
label={t`Label`}
value={label}
onChange={(e) => handleLabelChange(e.target.value)}
placeholder={t`Field Label`}
/>
<div>
<Input
label={t`Slug`}
value={slug}
onChange={(e) => setField("slug", e.target.value)}
placeholder="field_slug"
disabled={!!field}
/>
{field && (
<p className="text-xs text-kumo-subtle mt-2">
{t`Field slugs cannot be changed after creation`}
</p>
)}
</div>
</div>
{/* Toggles */}
<div className="flex items-center space-x-6">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={required}
onChange={(e) => setField("required", e.target.checked)}
className="rounded border-kumo-line"
/>
<span className="text-sm">{t`Required`}</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={unique}
onChange={(e) => setField("unique", e.target.checked)}
className="rounded border-kumo-line"
/>
<span className="text-sm">{t`Unique`}</span>
</label>
{(selectedType === "string" ||
selectedType === "text" ||
selectedType === "portableText" ||
selectedType === "slug" ||
selectedType === "url") && (
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={searchable}
onChange={(e) => setField("searchable", e.target.checked)}
className="rounded border-kumo-line"
/>
<span className="text-sm">{t`Searchable`}</span>
</label>
)}
</div>
{/* Type-specific validation */}
{(selectedType === "string" || selectedType === "text" || selectedType === "slug") && (
<div className="space-y-4">
<h4 className="font-medium text-sm">{t`Validation`}</h4>
<div className="grid grid-cols-2 gap-4">
<Input
label={t`Min Length`}
type="number"
value={minLength}
onChange={(e) => setField("minLength", e.target.value)}
placeholder={t`No minimum`}
/>
<Input
label={t`Max Length`}
type="number"
value={maxLength}
onChange={(e) => setField("maxLength", e.target.value)}
placeholder={t`No maximum`}
/>
</div>
{selectedType === "string" && (
<Input
label={t`Pattern (Regex)`}
value={pattern}
onChange={(e) => setField("pattern", e.target.value)}
placeholder="^[a-z]+$"
/>
)}
</div>
)}
{(selectedType === "number" || selectedType === "integer") && (
<div className="space-y-4">
<h4 className="font-medium text-sm">{t`Validation`}</h4>
<div className="grid grid-cols-2 gap-4">
<Input
label={t`Min Value`}
type="number"
value={min}
onChange={(e) => setField("min", e.target.value)}
placeholder={t`No minimum`}
/>
<Input
label={t`Max Value`}
type="number"
value={max}
onChange={(e) => setField("max", e.target.value)}
placeholder={t`No maximum`}
/>
</div>
</div>
)}
{(selectedType === "select" || selectedType === "multiSelect") && (
<InputArea
label={t`Options (one per line)`}
value={options}
onChange={(e) => setField("options", e.target.value)}
placeholder={"Option 1\nOption 2\nOption 3"}
rows={5}
/>
)}
{selectedType === "repeater" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium text-sm">{t`Sub-Fields`}</h4>
<Button
variant="outline"
size="sm"
icon={<Plus />}
onClick={() =>
setFormState((prev) => ({
...prev,
subFields: [
...prev.subFields,
{ slug: "", type: "string", label: "", required: false },
],
}))
}
>
{t`Add Sub-Field`}
</Button>
</div>
{formState.subFields.length === 0 && (
<p className="text-sm text-kumo-subtle text-center py-4">
{t`Add at least one sub-field to define the repeater structure.`}
</p>
)}
{formState.subFields.map((sf, i) => (
<div key={i} className="flex gap-2 items-start border rounded-lg p-3">
<div className="flex-1 space-y-2">
<div className="grid grid-cols-2 gap-2">
<Input
label={t`Label`}
value={sf.label}
onChange={(e) => {
const updated = [...formState.subFields];
updated[i] = {
...sf,
label: e.target.value,
slug: e.target.value
.toLowerCase()
.replace(SLUG_INVALID_CHARS_REGEX, "_")
.replace(SLUG_LEADING_TRAILING_REGEX, ""),
};
setFormState((prev) => ({ ...prev, subFields: updated }));
}}
placeholder={t`Field label`}
/>
<div>
<label className="text-sm font-medium">{t`Type`}</label>
<select
className="w-full mt-1 rounded-md border px-3 py-2 text-sm"
value={sf.type}
onChange={(e) => {
const updated = [...formState.subFields];
updated[i] = { ...sf, type: e.target.value };
setFormState((prev) => ({ ...prev, subFields: updated }));
}}
>
<option value="string">{t`Short Text`}</option>
<option value="text">{t`Long Text`}</option>
<option value="number">{t`Number`}</option>
<option value="integer">{t`Integer`}</option>
<option value="boolean">{t`Boolean`}</option>
<option value="datetime">{t`Date & Time`}</option>
<option value="select">{t`Select`}</option>
<option value="url">{t`URL`}</option>
</select>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={sf.required}
onChange={(e) => {
const updated = [...formState.subFields];
updated[i] = { ...sf, required: e.target.checked };
setFormState((prev) => ({ ...prev, subFields: updated }));
}}
/>
{t`Required`}
</label>
</div>
<Button
variant="ghost"
shape="square"
onClick={() =>
setFormState((prev) => ({
...prev,
subFields: prev.subFields.filter((_, j) => j !== i),
}))
}
aria-label={t`Remove sub-field`}
>
<Trash className="h-4 w-4 text-kumo-danger" />
</Button>
</div>
))}
<div className="grid grid-cols-2 gap-4">
<Input
label={t`Min Items`}
type="number"
value={formState.minItems}
onChange={(e) => setField("minItems", e.target.value)}
placeholder="0"
/>
<Input
label={t`Max Items`}
type="number"
value={formState.maxItems}
onChange={(e) => setField("maxItems", e.target.value)}
placeholder={t`No limit`}
/>
</div>
</div>
)}
</div>
)}
{step === "config" && (
<div className="flex flex-col-reverse gap-2 py-2 sm:flex-row sm:justify-end sm:space-x-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
{t`Cancel`}
</Button>
<Button
onClick={handleSave}
disabled={
!slug ||
!label ||
isSaving ||
(selectedType === "repeater" && formState.subFields.length === 0)
}
>
{isSaving ? t`Saving...` : field ? t`Update Field` : t`Add Field`}
</Button>
</div>
)}
</Dialog>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,109 @@
import { Button, LinkButton, Popover } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { SignOut, Shield, Gear, ArrowSquareOut } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import * as React from "react";
import { apiFetch } from "../lib/api/client";
import { useCurrentUser } from "../lib/api/current-user";
import { Sidebar } from "./Sidebar";
import { ThemeToggle } from "./ThemeToggle";
export type { CurrentUser } from "../lib/api/current-user";
async function handleLogout() {
const res = await apiFetch("/_emdash/api/auth/logout?redirect=/_emdash/admin/login", {
method: "POST",
credentials: "same-origin",
});
if (res.redirected) {
window.location.href = res.url;
} else {
window.location.href = "/_emdash/admin/login";
}
}
/**
* Admin header with mobile menu toggle and user actions.
* Uses useSidebar() hook from kumo Sidebar.Provider context.
*/
export function Header() {
const { t } = useLingui();
const [userMenuOpen, setUserMenuOpen] = React.useState(false);
const { data: user } = useCurrentUser();
// Get display name and initials
const displayName = user?.name || user?.email || t`User`;
const initialsSource = user?.name || user?.email || "U";
const initials = (initialsSource[0] ?? "U").toUpperCase();
return (
<header className="sticky top-0 z-10 flex h-16 items-center justify-between border-b bg-kumo-base px-4">
{/* Sidebar toggle — collapses to icon mode on desktop, opens drawer on mobile */}
<Sidebar.Trigger className="cursor-pointer rtl:rotate-180" />
{/* Right side actions */}
<div className="flex items-center gap-2">
{/* View site link */}
<LinkButton variant="ghost" size="sm" href="/" external>
<ArrowSquareOut className="h-4 w-4 me-1" />
{t`View Site`}
</LinkButton>
{/* Theme toggle */}
<ThemeToggle />
{/* User menu */}
<Popover open={userMenuOpen} onOpenChange={setUserMenuOpen}>
<Popover.Trigger asChild>
<Button variant="ghost" size="sm" className="gap-2 py-1 h-auto">
{user?.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="h-6 w-6 rounded-full object-cover" />
) : (
<div className="h-6 w-6 rounded-full bg-kumo-brand/10 flex items-center justify-center text-xs font-medium">
{initials}
</div>
)}
<span className="hidden sm:inline max-w-[120px] truncate">{displayName}</span>
</Button>
</Popover.Trigger>
<Popover.Content className="w-56 p-2" align="end">
{/* User info */}
<div className="px-3 py-2 border-b mb-1">
<div className="font-medium truncate">{user?.name || t`User`}</div>
<div className="text-xs text-kumo-subtle truncate">{user?.email}</div>
</div>
<div className="grid gap-1">
<Link
to="/settings/security"
onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-kumo-tint"
>
<Shield className="h-4 w-4" />
{t`Security Settings`}
</Link>
<Link
to="/settings"
onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-kumo-tint"
>
<Gear className="h-4 w-4" />
{t`Settings`}
</Link>
<hr className="my-1" />
<button
onClick={handleLogout}
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-kumo-danger hover:bg-kumo-danger/10 w-full text-start"
>
<SignOut className="h-4 w-4" />
{t`Log out`}
</button>
</div>
</Popover.Content>
</Popover>
</div>
</header>
);
}

View File

@@ -0,0 +1,220 @@
/**
* Standalone invite acceptance page (not wrapped in admin Shell).
* Validates an invite token, then registers a passkey to complete signup.
*/
import { Button, Input, Loader } from "@cloudflare/kumo";
import { Link, useSearch } from "@tanstack/react-router";
import * as React from "react";
import { validateInviteToken, type InviteVerifyResult } from "../lib/api";
import { PasskeyRegistration } from "./auth/PasskeyRegistration";
import { LogoLockup } from "./Logo.js";
type InviteStep = "verify" | "register" | "error";
interface RegisterStepProps {
inviteData: InviteVerifyResult;
token: string;
}
function handleInviteSuccess() {
window.location.href = "/_emdash/admin";
}
function RegisterStep({ inviteData, token }: RegisterStepProps) {
const [name, setName] = React.useState("");
return (
<div className="space-y-6">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-kumo-brand/10 mx-auto mb-4">
<svg
className="w-8 h-8 text-kumo-brand"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
/>
</svg>
</div>
<h2 className="text-xl font-semibold">You've been invited!</h2>
<p className="text-kumo-subtle mt-2">
You'll be joining as{" "}
<span className="font-medium text-kumo-default">{inviteData.roleName}</span>
</p>
</div>
<Input label="Email" value={inviteData.email} disabled className="bg-kumo-tint" />
<Input
label="Your name (optional)"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Jane Doe"
autoComplete="name"
autoFocus
/>
<div className="pt-4 border-t">
<h3 className="text-sm font-medium mb-3">Create your passkey</h3>
<p className="text-sm text-kumo-subtle mb-4">
Passkeys are a secure, passwordless way to sign in using your device's biometrics, PIN, or
security key.
</p>
<PasskeyRegistration
optionsEndpoint="/_emdash/api/auth/invite/register-options"
verifyEndpoint="/_emdash/api/auth/invite/complete"
onSuccess={handleInviteSuccess}
buttonText="Create Account"
additionalData={{ token, name: name || undefined }}
/>
</div>
</div>
);
}
interface ErrorStepProps {
message: string;
code?: string;
}
function ErrorStep({ message, code }: ErrorStepProps) {
return (
<div className="space-y-6 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-kumo-danger/10 mx-auto">
<svg
className="w-8 h-8 text-kumo-danger"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div>
<h2 className="text-xl font-semibold text-kumo-danger">
{code === "TOKEN_EXPIRED"
? "Invite expired"
: code === "INVALID_TOKEN"
? "Invalid invite link"
: code === "USER_EXISTS"
? "Account already exists"
: "Something went wrong"}
</h2>
<p className="text-kumo-subtle mt-2">{message}</p>
</div>
<div className="space-y-2">
{code === "USER_EXISTS" ? (
<Link to="/login">
<Button className="w-full">Sign in instead</Button>
</Link>
) : (
<>
<p className="text-sm text-kumo-subtle">
Please ask your administrator to send a new invite.
</p>
<Link to="/login">
<Button variant="ghost" className="w-full">
Back to login
</Button>
</Link>
</>
)}
</div>
</div>
);
}
export function InviteAcceptPage() {
const { token: urlToken } = useSearch({ strict: false });
const [step, setStep] = React.useState<InviteStep>("verify");
const [error, setError] = React.useState<string | undefined>();
const [errorCode, setErrorCode] = React.useState<string | undefined>();
const [isLoading, setIsLoading] = React.useState(true);
const [inviteData, setInviteData] = React.useState<InviteVerifyResult | null>(null);
const [token, setToken] = React.useState<string | null>(null);
React.useEffect(() => {
if (!urlToken) {
setError("No invite token provided");
setStep("error");
setIsLoading(false);
return;
}
setToken(urlToken);
void verifyToken(urlToken);
}, [urlToken]);
const verifyToken = async (tokenToVerify: string) => {
setIsLoading(true);
setError(undefined);
setErrorCode(undefined);
try {
const result = await validateInviteToken(tokenToVerify);
setInviteData(result);
setStep("register");
} catch (err) {
const verifyError = err instanceof Error ? err : new Error(String(err));
const errorWithCode = verifyError as Error & { code?: string };
setError(verifyError.message);
setErrorCode(typeof errorWithCode.code === "string" ? errorWithCode.code : undefined);
setStep("error");
} finally {
setIsLoading(false);
}
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-kumo-base">
<div className="text-center">
<Loader />
<p className="mt-4 text-kumo-subtle">Verifying your invite...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-kumo-base p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<LogoLockup className="h-10 mx-auto mb-2" />
<h1 className="text-2xl font-semibold text-kumo-default">
{step === "register" && "Accept Invite"}
{step === "error" && "Invite Error"}
</h1>
</div>
<div className="bg-kumo-base border rounded-lg shadow-sm p-6">
{step === "register" && inviteData && token && (
<RegisterStep inviteData={inviteData} token={token} />
)}
{step === "error" && (
<ErrorStep message={error ?? "An unknown error occurred"} code={errorCode} />
)}
</div>
</div>
</div>
);
}
export default InviteAcceptPage;

View File

@@ -0,0 +1,136 @@
/**
* Locale switcher component for i18n-enabled sites.
*
* Used in both the content list (to filter by locale) and the content editor
* (to switch between locale versions of a content item).
*
* Only renders when i18n is configured (manifest.i18n is present).
*/
import { useLingui } from "@lingui/react/macro";
import { GlobeSimple } from "@phosphor-icons/react";
import React from "react";
import { cn } from "../lib/utils.js";
interface LocaleSwitcherProps {
locales: string[];
defaultLocale: string;
value: string;
onChange: (locale: string) => void;
/** Show "All locales" option (for list filtering) */
showAll?: boolean;
className?: string;
/** Size variant */
size?: "sm" | "md";
}
/**
* Get a display label for a locale code.
* Uses Intl.DisplayNames when available, falls back to uppercase code.
*/
function getLocaleLabel(code: string): string {
try {
const names = new Intl.DisplayNames(["en"], { type: "language" });
return names.of(code) ?? code.toUpperCase();
} catch {
return code.toUpperCase();
}
}
export function LocaleSwitcher({
locales,
defaultLocale,
value,
onChange,
showAll = false,
className,
size = "md",
}: LocaleSwitcherProps) {
const { t } = useLingui();
return (
<div className={cn("flex items-center gap-1.5", className)}>
<GlobeSimple
className={cn("text-kumo-subtle shrink-0", size === "sm" ? "size-3.5" : "size-4")}
weight="bold"
/>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label={t`Locale`}
className={cn(
"rounded-md border bg-transparent font-medium transition-colors",
"focus:ring-kumo-ring focus:outline-none focus:ring-2 focus:ring-offset-1",
"hover:bg-kumo-tint/50 cursor-pointer",
size === "sm" ? "px-1.5 py-0.5 text-xs" : "px-2 py-1 text-sm",
)}
>
{showAll && <option value="">{t`All locales`}</option>}
{locales.map((locale) => (
<option key={locale} value={locale}>
{locale.toUpperCase()}
{locale === defaultLocale ? t` (default)` : ""}
</option>
))}
</select>
</div>
);
}
/**
* Compact locale badges showing which translations exist for a content item.
* Renders as a row of small locale codes, with existing translations highlighted.
*/
export function LocaleBadges({
locales,
existingLocales,
onLocaleClick,
}: {
locales: string[];
existingLocales: string[];
onLocaleClick?: (locale: string) => void;
}) {
const { t } = useLingui();
const existingSet = new Set(existingLocales);
return (
<div className="flex items-center gap-0.5">
{locales.map((locale) => {
const exists = existingSet.has(locale);
const label = getLocaleLabel(locale);
return (
<button
key={locale}
type="button"
onClick={() => onLocaleClick?.(locale)}
disabled={!onLocaleClick}
title={exists ? t`${label} \u2014 view translation` : t`${label} \u2014 no translation`}
className={cn(
"rounded px-1 py-0.5 text-[10px] font-semibold uppercase leading-none transition-colors",
exists
? "bg-kumo-brand/10 text-kumo-brand hover:bg-kumo-brand/20"
: "bg-kumo-tint text-kumo-subtle/50",
onLocaleClick && exists && "cursor-pointer",
(!onLocaleClick || !exists) && "cursor-default",
)}
>
{locale}
</button>
);
})}
</div>
);
}
/**
* Hook to get i18n config from the manifest query.
* Returns null if i18n is not configured.
*/
export function useI18nConfig(
manifest: { i18n?: { defaultLocale: string; locales: string[] } } | undefined,
) {
return React.useMemo(() => {
if (!manifest?.i18n) return null;
return manifest.i18n;
}, [manifest?.i18n]);
}

View File

@@ -0,0 +1,356 @@
/**
* Login Page - Standalone login page for the admin
*
* This component is NOT wrapped in the admin Shell.
* It's a standalone page for authentication.
*
* Supports:
* - Passkey authentication (always available)
* - Pluggable auth providers (AT Protocol, GitHub, Google, etc.) when configured
* - Magic link (email) when configured
*
* When external auth (e.g., Cloudflare Access) is configured, this page
* redirects to the admin dashboard since authentication is handled externally.
*/
import { Button, Input, Loader, Select } from "@cloudflare/kumo";
import { Trans, useLingui } from "@lingui/react/macro";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as React from "react";
import { apiFetch, fetchAuthMode } from "../lib/api";
import { useAuthProviderList } from "../lib/auth-provider-context";
import { sanitizeRedirectUrl } from "../lib/url";
import { SUPPORTED_LOCALES } from "../locales/index.js";
import { useLocale } from "../locales/useLocale.js";
import { PasskeyLogin } from "./auth/PasskeyLogin";
import { BrandLogo } from "./Logo.js";
// ============================================================================
// Types
// ============================================================================
interface LoginPageProps {
/** URL to redirect to after successful login */
redirectUrl?: string;
}
type LoginMethod = "passkey" | "magic-link";
// ============================================================================
// Components
// ============================================================================
interface MagicLinkFormProps {
onBack: () => void;
}
function MagicLinkForm({ onBack }: MagicLinkFormProps) {
const { t } = useLingui();
const [email, setEmail] = React.useState("");
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [sent, setSent] = React.useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
const response = await apiFetch("/_emdash/api/auth/magic-link/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim().toLowerCase() }),
});
if (!response.ok) {
const body: { error?: { message?: string } } = await response.json().catch(() => ({}));
throw new Error(body?.error?.message || t`Failed to send magic link`);
}
setSent(true);
} catch (err) {
setError(err instanceof Error ? err.message : t`Failed to send magic link`);
} finally {
setIsLoading(false);
}
};
if (sent) {
return (
<div className="space-y-6 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-kumo-brand/10 mx-auto">
<svg
className="w-8 h-8 text-kumo-brand"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<div>
<h2 className="text-xl font-semibold">{t`Check your email`}</h2>
<p className="text-kumo-subtle mt-2">
<Trans>
If an account exists for{" "}
<span className="font-medium text-kumo-default">{email}</span>, we've sent a sign-in
link.
</Trans>
</p>
</div>
<div className="text-sm text-kumo-subtle">
<p>{t`Click the link in the email to sign in.`}</p>
<p className="mt-2">{t`The link will expire in 15 minutes.`}</p>
</div>
<Button variant="outline" onClick={onBack} className="mt-4 w-full justify-center">
{t`Back to login`}
</Button>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label={t`Email address`}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className={error ? "border-kumo-danger" : ""}
disabled={isLoading}
autoComplete="email"
autoFocus
required
/>
{error && (
<div className="rounded-lg bg-kumo-danger/10 p-3 text-sm text-kumo-danger">{error}</div>
)}
<Button
type="submit"
className="w-full justify-center"
variant="primary"
loading={isLoading}
disabled={!email}
>
{isLoading ? t`Sending...` : t`Send magic link`}
</Button>
<Button type="button" variant="ghost" className="w-full justify-center" onClick={onBack}>
{t`Back to login`}
</Button>
</form>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function LoginPage({ redirectUrl = "/_emdash/admin" }: LoginPageProps) {
// Defense-in-depth: sanitize even if the caller already validated
const safeRedirectUrl = sanitizeRedirectUrl(redirectUrl);
const { t } = useLingui();
const { locale, setLocale } = useLocale();
const [method, setMethod] = React.useState<LoginMethod>("passkey");
const [urlError, setUrlError] = React.useState<string | null>(null);
const [activeProvider, setActiveProvider] = React.useState<string | null>(null);
// Auth provider components from virtual module (via context)
const authProviderList = useAuthProviderList();
// Fetch auth mode from public endpoint (works without authentication)
const { data: authInfo, isLoading: authModeLoading } = useQuery({
queryKey: ["authMode"],
queryFn: fetchAuthMode,
});
// Redirect to admin when using external auth (authentication is handled externally)
React.useEffect(() => {
if (authInfo?.authMode && authInfo.authMode !== "passkey") {
window.location.href = safeRedirectUrl;
}
}, [authInfo, safeRedirectUrl]);
// Check for error in URL (from OAuth/provider redirect)
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
const error = params.get("error");
const message = params.get("message");
if (error) {
setUrlError(message || t`Authentication error: ${error}`);
// Clean up URL
window.history.replaceState({}, "", window.location.pathname);
}
}, []);
const handleSuccess = () => {
// Redirect after successful login
window.location.href = safeRedirectUrl;
};
// All providers with a LoginButton show in the button grid
const buttonProviders = authProviderList.filter((p) => p.LoginButton);
// Show loading state while checking auth mode
if (authModeLoading || (authInfo?.authMode && authInfo.authMode !== "passkey")) {
return (
<div className="min-h-screen flex items-center justify-center bg-kumo-base p-4">
<div className="flex flex-col items-center">
<BrandLogo className="h-10 mb-4" />
<Loader />
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-kumo-base p-4">
<div className="w-full max-w-md">
{/* Header */}
<div className="text-center mb-8">
<BrandLogo className="h-10 mx-auto mb-2" />
<h1 className="text-2xl font-semibold text-kumo-default">
{method === "magic-link"
? t`Sign in with email`
: activeProvider
? t`Sign in with ${authProviderList.find((p) => p.id === activeProvider)?.label ?? activeProvider}`
: t`Sign in to your site`}
</h1>
</div>
{/* Error from URL (provider failure) */}
{urlError && (
<div className="mb-6 rounded-lg bg-kumo-danger/10 border border-kumo-danger/20 p-4 text-sm text-kumo-danger">
{urlError}
</div>
)}
{/* Login Card */}
<div className="bg-kumo-base border rounded-lg shadow-sm p-6">
{method === "passkey" && !activeProvider && (
<div className="space-y-6">
{/* Passkey Login */}
<PasskeyLogin
optionsEndpoint="/_emdash/api/auth/passkey/options"
verifyEndpoint="/_emdash/api/auth/passkey/verify"
onSuccess={handleSuccess}
buttonText={t`Sign in with Passkey`}
/>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-kumo-base px-2 text-kumo-subtle">{t`Or continue with`}</span>
</div>
</div>
{/* Auth provider buttons */}
{buttonProviders.length > 0 && (
<div
className={`grid gap-3 ${buttonProviders.length === 1 ? "grid-cols-1" : "grid-cols-2"}`}
>
{buttonProviders.map((provider) => {
const Btn = provider.LoginButton!;
const hasForm = !!provider.LoginForm;
const selectProvider = () => setActiveProvider(provider.id);
return (
<div key={provider.id} onClick={hasForm ? selectProvider : undefined}>
<Btn />
</div>
);
})}
</div>
)}
{/* Magic Link Option */}
<Button
variant="ghost"
className="w-full justify-center"
type="button"
onClick={() => setMethod("magic-link")}
>
{t`Sign in with email link`}
</Button>
</div>
)}
{/* Provider form (full card replacement, like magic link) */}
{method === "passkey" &&
activeProvider &&
(() => {
const provider = authProviderList.find((p) => p.id === activeProvider);
if (!provider?.LoginForm) return null;
const Form = provider.LoginForm;
return (
<div className="space-y-4">
<Form />
<Button
type="button"
variant="ghost"
className="w-full justify-center"
onClick={() => setActiveProvider(null)}
>
{t`Back to login`}
</Button>
</div>
);
})()}
{method === "magic-link" && <MagicLinkForm onBack={() => setMethod("passkey")} />}
</div>
{/* Help text */}
<p className="text-center mt-6 text-sm text-kumo-subtle">
{method === "magic-link"
? t`We'll send you a link to sign in without a password.`
: activeProvider
? t`Enter your handle to sign in.`
: t`Use your registered passkey to sign in securely.`}
</p>
{/* Signup link — only shown when self-signup is enabled */}
{authInfo?.signupEnabled && (
<p className="text-center mt-4 text-sm text-kumo-subtle">
<Trans>
Don't have an account?{" "}
<Link to="/signup" className="text-kumo-brand hover:underline font-medium">
Sign up
</Link>
</Trans>
</p>
)}
{/* Language selector — only shown when multiple locales are available */}
{SUPPORTED_LOCALES.length > 1 && (
<div className="mt-6 flex justify-center">
<Select
aria-label={t`Language`}
className="w-48"
value={locale}
onValueChange={(v) => v && setLocale(v)}
items={Object.fromEntries(SUPPORTED_LOCALES.map((l) => [l.code, l.label]))}
/>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,188 @@
import * as React from "react";
/**
* EmDash icon mark — the rounded-rect em dash symbol.
* Used in the sidebar brand and as favicon.
*/
export function LogoIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 75 75" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<rect
x="3"
y="3"
width="69"
height="69"
rx="10.518"
stroke="url(#emdash-icon-border)"
strokeWidth="6"
/>
<rect x="18" y="34" width="39.3661" height="6.56101" fill="url(#emdash-icon-dash)" />
<defs>
<linearGradient
id="emdash-icon-border"
x1="-42.9996"
y1="124"
x2="92.4233"
y2="-41.7456"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#0F006B" />
<stop offset="0.0833" stopColor="#281A81" />
<stop offset="0.1667" stopColor="#5D0C83" />
<stop offset="0.25" stopColor="#911475" />
<stop offset="0.3333" stopColor="#CE2F55" />
<stop offset="0.4167" stopColor="#FF6633" />
<stop offset="0.5" stopColor="#F6821F" />
<stop offset="0.5833" stopColor="#FBAD41" />
<stop offset="0.6667" stopColor="#FFCD89" />
<stop offset="0.75" stopColor="#FFE9CB" />
<stop offset="0.8333" stopColor="#FFF7EC" />
<stop offset="0.9167" stopColor="#FFF8EE" />
<stop offset="1" stopColor="white" />
</linearGradient>
<linearGradient
id="emdash-icon-dash"
x1="91.4992"
y1="27.4982"
x2="28.1217"
y2="54.1775"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white" />
<stop offset="0.1293" stopColor="#FFF8EE" />
<stop offset="0.6171" stopColor="#FBAD41" />
<stop offset="0.848" stopColor="#F6821F" />
<stop offset="1" stopColor="#FF6633" />
</linearGradient>
</defs>
</svg>
);
}
/**
* Full logo lockup — icon + "EmDash" wordmark.
* Renders both dark-text and light-text variants, switching via CSS `light-dark()`.
*/
export function LogoLockup({ className, ...props }: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 471 118"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
role="img"
aria-label="EmDash"
{...props}
>
{/* Icon mark */}
<path
d="M0.410156 96.5125V21.2097C0.410156 9.48841 9.91245 -0.013916 21.6338 -0.013916V9.40601L21.3291 9.40991C14.9509 9.57133 9.83008 14.7927 9.83008 21.2097V96.5125C9.83008 102.93 14.9509 108.151 21.3291 108.312L21.6338 108.316H96.9365L97.2412 108.312C103.518 108.153 108.577 103.094 108.736 96.8171L108.74 96.5125V21.2097C108.74 14.6909 103.455 9.40601 96.9365 9.40601V-0.013916C108.658 -0.013916 118.16 9.48838 118.16 21.2097V96.5125C118.16 108.234 108.658 117.736 96.9365 117.736H21.6338C9.91248 117.736 0.410156 108.234 0.410156 96.5125ZM96.9365 -0.013916V9.40601H21.6338V-0.013916H96.9365Z"
fill="url(#emdash-lockup-icon)"
/>
<path d="M28.6699 53.366H90.4746V63.6668H28.6699V53.366Z" fill="url(#emdash-lockup-dash)" />
{/* Wordmark — uses currentColor so it adapts to light/dark context */}
<path
d="M154.762 90V27.4834H194.447V35.8449H164.467V54.0844H192.844V62.2293H164.467V81.6385H194.447V90H154.762Z"
fill="currentColor"
/>
<path
d="M204.172 90V44.4231H213.53V51.4849H213.747C215.697 46.7193 220.332 43.5566 226.311 43.5566C232.593 43.5566 237.185 46.8059 239.005 52.5247H239.222C241.561 46.9792 246.933 43.5566 253.432 43.5566C262.443 43.5566 268.335 49.5353 268.335 58.6767V90H258.934V60.9296C258.934 54.9942 255.771 51.5716 250.226 51.5716C244.68 51.5716 240.825 55.7307 240.825 61.4928V90H231.64V60.2364C231.64 54.9508 228.304 51.5716 223.018 51.5716C217.473 51.5716 213.53 55.9473 213.53 61.8394V90H204.172Z"
fill="currentColor"
/>
<path
d="M279.404 90V27.4834H301.456C319.998 27.4834 331.046 38.8776 331.046 58.5467V58.6334C331.046 78.3892 320.085 90 301.456 90H279.404ZM289.108 81.5951H300.546C313.803 81.5951 321.125 73.4935 321.125 58.72V58.6334C321.125 43.9465 313.716 35.8449 300.546 35.8449H289.108V81.5951Z"
fill="currentColor"
/>
<path
d="M353.379 90.8232C344.281 90.8232 338.172 85.2344 338.172 77.0461V76.9595C338.172 69.0312 344.324 64.1789 355.112 63.529L367.502 62.7925V59.3699C367.502 54.3443 364.253 51.3116 358.448 51.3116C353.032 51.3116 349.696 53.8677 348.916 57.507L348.83 57.8969H339.992L340.035 57.4203C340.685 49.5787 347.487 43.5566 358.708 43.5566C369.842 43.5566 376.904 49.4487 376.904 58.5901V90H367.502V82.8082H367.329C364.686 87.7038 359.401 90.8232 353.379 90.8232ZM347.617 76.8295C347.617 80.8153 350.909 83.3281 355.935 83.3281C362.52 83.3281 367.502 78.8657 367.502 72.9303V69.3778L356.368 70.0709C350.736 70.4175 347.617 72.887 347.617 76.7428V76.8295Z"
fill="currentColor"
/>
<path
d="M403.959 90.9098C392.564 90.9098 385.893 85.2777 384.939 76.9595L384.896 76.5695H394.167L394.254 77.0028C395.121 81.2052 398.24 83.6747 404.002 83.6747C409.634 83.6747 413.013 81.3352 413.013 77.6527V77.6093C413.013 74.6633 411.367 72.9737 406.471 71.8039L399.02 70.1143C390.355 68.1214 386.066 63.9623 386.066 57.3337V57.2903C386.066 49.1454 393.171 43.5566 403.655 43.5566C414.443 43.5566 420.942 49.5787 421.418 57.3337L421.462 57.8536H412.667L412.624 57.5503C412.06 53.5645 408.941 50.7917 403.655 50.7917C398.63 50.7917 395.467 53.1746 395.467 56.8138V56.8571C395.467 59.6732 397.33 61.5794 402.226 62.7492L409.634 64.4388C418.949 66.605 422.501 70.2876 422.501 76.8295V76.8728C422.501 85.191 414.703 90.9098 403.959 90.9098Z"
fill="currentColor"
/>
<path
d="M431.014 90V27.4834H440.372V51.9182H440.588C443.014 46.6326 447.91 43.5566 454.712 43.5566C464.46 43.5566 470.872 50.8351 470.872 61.8394V90H461.514V63.6157C461.514 56.0773 457.701 51.5716 451.116 51.5716C444.661 51.5716 440.372 56.5105 440.372 63.6157V90H431.014Z"
fill="currentColor"
/>
<defs>
<linearGradient
id="emdash-lockup-icon"
x1="-67.1002"
y1="194.666"
x2="145.514"
y2="-65.5554"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#0F006B" />
<stop offset="0.0833" stopColor="#281A81" />
<stop offset="0.1667" stopColor="#5D0C83" />
<stop offset="0.25" stopColor="#911475" />
<stop offset="0.3333" stopColor="#CE2F55" />
<stop offset="0.4167" stopColor="#FF6633" />
<stop offset="0.5" stopColor="#F6821F" />
<stop offset="0.5833" stopColor="#FBAD41" />
<stop offset="0.6667" stopColor="#FFCD89" />
<stop offset="0.75" stopColor="#FFE9CB" />
<stop offset="0.8333" stopColor="#FFF7EC" />
<stop offset="0.9167" stopColor="#FFF8EE" />
<stop offset="1" stopColor="white" />
</linearGradient>
<linearGradient
id="emdash-lockup-dash"
x1="144.064"
y1="43.1581"
x2="44.5609"
y2="85.0447"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white" />
<stop offset="0.1293" stopColor="#FFF8EE" />
<stop offset="0.6171" stopColor="#FBAD41" />
<stop offset="0.848" stopColor="#F6821F" />
<stop offset="1" stopColor="#FF6633" />
</linearGradient>
</defs>
</svg>
);
}
interface BrandLogoProps {
logoUrl?: string;
siteName?: string;
className?: string;
}
export function BrandLogo({ logoUrl, siteName, className }: BrandLogoProps) {
if (logoUrl) {
return (
<img
src={logoUrl}
alt={siteName || ""}
className={className}
style={{ objectFit: "contain" }}
/>
);
}
return <LogoLockup className={className} />;
}
interface BrandIconProps {
logoUrl?: string;
siteName?: string;
className?: string;
}
export function BrandIcon({ logoUrl, siteName, className }: BrandIconProps) {
if (logoUrl) {
return (
<img
src={logoUrl}
alt={siteName || ""}
className={className}
style={{ objectFit: "contain" }}
/>
);
}
return <LogoIcon className={className} />;
}

View File

@@ -0,0 +1,356 @@
/**
* Marketplace Browse
*
* Grid of plugin cards with search and sorting.
* Navigates to plugin detail on card click.
*/
import { Badge, Button } from "@cloudflare/kumo";
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import {
MagnifyingGlass,
PuzzlePiece,
DownloadSimple,
ShieldCheck,
ShieldWarning,
Warning,
ArrowsClockwise,
} from "@phosphor-icons/react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import * as React from "react";
import {
CAPABILITY_LABELS,
searchMarketplace,
type MarketplacePluginSummary,
type MarketplaceSearchOpts,
} from "../lib/api/marketplace.js";
import { safeIconUrl } from "../lib/url.js";
type SortOption = "installs" | "updated" | "created" | "name";
const SORT_OPTIONS = new Set<string>(["installs", "updated", "created", "name"]);
function isSortOption(value: string): value is SortOption {
return SORT_OPTIONS.has(value);
}
const SORT_LABELS: Record<SortOption, string> = {
installs: "Most Popular",
updated: "Recently Updated",
created: "Newest",
name: "Name",
};
export interface MarketplaceBrowseProps {
/** IDs of plugins already installed on this site */
installedPluginIds?: Set<string>;
}
export function MarketplaceBrowse({ installedPluginIds = new Set() }: MarketplaceBrowseProps) {
const { t } = useLingui();
const [searchQuery, setSearchQuery] = React.useState("");
const [sort, setSort] = React.useState<SortOption>("installs");
const [capability, setCapability] = React.useState<string>("");
const [debouncedQuery, setDebouncedQuery] = React.useState("");
// Debounce search input
React.useEffect(() => {
const timer = setTimeout(setDebouncedQuery, 300, searchQuery);
return () => clearTimeout(timer);
}, [searchQuery]);
const searchOpts: MarketplaceSearchOpts = {
q: debouncedQuery || undefined,
capability: capability || undefined,
sort,
limit: 20,
};
const { data, isLoading, error, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ["marketplace", "search", searchOpts],
queryFn: ({ pageParam }) => searchMarketplace({ ...searchOpts, cursor: pageParam }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const plugins = data?.pages.flatMap((p) => p.items);
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold">{t`Marketplace`}</h1>
<p className="mt-1 text-kumo-subtle">{t`Browse and install plugins to extend your site.`}</p>
</div>
{/* Search + Sort */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<MagnifyingGlass className="absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-kumo-subtle" />
<input
type="search"
placeholder={t`Search plugins...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-md border bg-kumo-base px-3 py-2 ps-9 text-sm placeholder:text-kumo-subtle focus:outline-none focus:ring-2 focus:ring-kumo-ring"
/>
</div>
<select
value={capability}
onChange={(e) => setCapability(e.target.value)}
className="rounded-md border bg-kumo-base px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-kumo-ring"
aria-label={t`Filter by capability`}
>
<option value="">{t`All capabilities`}</option>
{Object.entries(CAPABILITY_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<select
value={sort}
onChange={(e) => {
const v = e.target.value;
if (isSortOption(v)) setSort(v);
}}
className="rounded-md border bg-kumo-base px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-kumo-ring"
aria-label={t`Sort plugins`}
>
{Object.entries(SORT_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
{/* Error state */}
{error && (
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-6 text-center">
<Warning className="mx-auto h-8 w-8 text-kumo-danger" />
<h3 className="mt-3 font-medium text-kumo-danger">{t`Unable to reach marketplace`}</h3>
<p className="mt-1 text-sm text-kumo-subtle">
{error instanceof Error ? error.message : t`An error occurred`}
</p>
<Button variant="ghost" className="mt-4" onClick={() => void refetch()}>
<ArrowsClockwise className="me-2 h-4 w-4" />
{t`Retry`}
</Button>
</div>
)}
{/* Loading state */}
{isLoading && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse rounded-lg border bg-kumo-base p-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-kumo-tint" />
<div className="flex-1 space-y-2">
<div className="h-4 w-24 rounded bg-kumo-tint" />
<div className="h-3 w-16 rounded bg-kumo-tint" />
</div>
</div>
<div className="mt-3 space-y-2">
<div className="h-3 w-full rounded bg-kumo-tint" />
<div className="h-3 w-2/3 rounded bg-kumo-tint" />
</div>
</div>
))}
</div>
)}
{/* Results grid */}
{plugins && !isLoading && (
<>
{plugins.length === 0 ? (
<div className="rounded-lg border bg-kumo-base p-8 text-center">
<PuzzlePiece className="mx-auto h-12 w-12 text-kumo-subtle" />
<h3 className="mt-4 text-lg font-medium">{t`No plugins found`}</h3>
<p className="mt-2 text-sm text-kumo-subtle">
{debouncedQuery
? t`No results for "${debouncedQuery}". Try a different search term.`
: t`The marketplace is empty. Check back later for new plugins.`}
</p>
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{plugins.map((plugin) => (
<PluginCard
key={plugin.id}
plugin={plugin}
isInstalled={installedPluginIds.has(plugin.id)}
/>
))}
</div>
{hasNextPage && (
<div className="flex justify-center">
<Button
variant="outline"
onClick={() => void fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? t`Loading...` : t`Load more`}
</Button>
</div>
)}
</>
)}
</>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// PluginCard
// ---------------------------------------------------------------------------
interface PluginCardProps {
plugin: MarketplacePluginSummary;
isInstalled: boolean;
}
function PluginCard({ plugin, isInstalled }: PluginCardProps) {
const { t } = useLingui();
const navigate = useNavigate();
const auditVerdict = plugin.latestVersion?.audit?.verdict;
const imageVerdict = plugin.latestVersion?.imageAudit?.verdict;
const isImageFlagged = imageVerdict === "warn" || imageVerdict === "fail";
const iconSrc = plugin.iconUrl ? safeIconUrl(plugin.iconUrl, 64) : null;
return (
<Link
to="/plugins/marketplace/$pluginId"
params={{ pluginId: plugin.id }}
className="group block rounded-lg border bg-kumo-base p-4 transition-colors hover:border-kumo-brand/50 hover:bg-kumo-tint/30"
>
<div className="flex items-start gap-3">
{/* Icon */}
{iconSrc ? (
<img
src={iconSrc}
alt=""
className={`h-10 w-10 rounded-lg object-cover ${isImageFlagged ? "blur-sm" : ""}`}
loading="lazy"
aria-label={isImageFlagged ? t`Icon blurred due to image audit` : undefined}
/>
) : (
<PluginAvatar name={plugin.name} />
)}
{/* Name + meta */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate font-semibold group-hover:text-kumo-brand">{plugin.name}</h3>
{isInstalled && (
<span
role="link"
className="cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void navigate({ to: "/plugins-manager" });
}}
>
<Badge variant="secondary">{t`Installed`}</Badge>
</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-kumo-subtle">
<span>{plugin.author.name}</span>
{plugin.author.verified && <ShieldCheck className="h-3 w-3 text-kumo-brand" />}
{plugin.latestVersion?.version && <span>v{plugin.latestVersion.version}</span>}
</div>
</div>
</div>
{/* Description */}
{plugin.description && (
<p className="mt-2 line-clamp-2 text-sm text-kumo-subtle">{plugin.description}</p>
)}
{/* Footer: install count + audit + capabilities */}
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-kumo-subtle">
<DownloadSimple className="h-3.5 w-3.5" />
<span>{formatInstallCount(plugin.installCount)}</span>
</div>
<div className="flex items-center gap-1">
{auditVerdict && <AuditBadge verdict={auditVerdict} />}
{plugin.capabilities.length > 0 && (
<span className="text-xs text-kumo-subtle">
{plural(plugin.capabilities.length, {
one: "# permission",
other: "# permissions",
})}
</span>
)}
</div>
</div>
</Link>
);
}
// ---------------------------------------------------------------------------
// Shared small components
// ---------------------------------------------------------------------------
function PluginAvatar({ name }: { name: string }) {
const initial = name.charAt(0).toUpperCase();
return (
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-kumo-brand/10 text-kumo-brand font-bold text-lg">
{initial}
</div>
);
}
export function AuditBadge({ verdict }: { verdict: "pass" | "warn" | "fail" }) {
const { t } = useLingui();
if (verdict === "pass") {
return (
<span
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-green-500/10 text-green-600"
title={t`Security audit passed`}
>
<ShieldCheck className="h-3 w-3" />
{t`Pass`}
</span>
);
}
if (verdict === "warn") {
return (
<span
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-warning/10 text-warning"
title={t`Security audit flagged concerns`}
>
<Warning className="h-3 w-3" />
{t`Warn`}
</span>
);
}
return (
<span
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-kumo-danger/10 text-kumo-danger"
title={t`Security audit failed`}
>
<ShieldWarning className="h-3 w-3" />
{t`Fail`}
</span>
);
}
function formatInstallCount(count: number): string {
if (count >= 1000) {
return `${(count / 1000).toFixed(count >= 10000 ? 0 : 1)}k`;
}
return String(count);
}
export default MarketplaceBrowse;

View File

@@ -0,0 +1,562 @@
/**
* Marketplace Plugin Detail
*
* Full detail view for a marketplace plugin:
* - README rendered as markdown
* - Screenshot gallery
* - Capability list
* - Audit summary
* - Version history
* - Install button (with capability consent)
*/
import { Badge, Button } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { DownloadSimple, GithubLogo, Globe, ShieldCheck, Warning, X } from "@phosphor-icons/react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import DOMPurify from "dompurify";
import { Marked, Renderer } from "marked";
import * as React from "react";
import {
fetchMarketplacePlugin,
installMarketplacePlugin,
uninstallMarketplacePlugin,
describeCapability,
} from "../lib/api/marketplace.js";
import { SAFE_URL_RE, isSafeUrl, safeIconUrl } from "../lib/url.js";
import { ArrowPrev, CaretNext, CaretPrev } from "./ArrowIcons.js";
import { CapabilityConsentDialog } from "./CapabilityConsentDialog.js";
import { getMutationError } from "./DialogError.js";
import { AuditBadge } from "./MarketplaceBrowse.js";
import { UninstallConfirmDialog } from "./PluginManager.js";
export interface MarketplacePluginDetailProps {
pluginId: string;
/** IDs of plugins already installed on this site */
installedPluginIds?: Set<string>;
}
export function MarketplacePluginDetail({
pluginId,
installedPluginIds = new Set(),
}: MarketplacePluginDetailProps) {
const { t } = useLingui();
const queryClient = useQueryClient();
const [showConsent, setShowConsent] = React.useState(false);
const [showUninstallConfirm, setShowUninstallConfirm] = React.useState(false);
const [lightboxIndex, setLightboxIndex] = React.useState<number | null>(null);
const {
data: plugin,
isLoading,
error,
} = useQuery({
queryKey: ["marketplace", "plugin", pluginId],
queryFn: () => fetchMarketplacePlugin(pluginId),
});
const installMutation = useMutation({
mutationFn: () =>
installMarketplacePlugin(pluginId, {
version: plugin?.latestVersion?.version,
}),
onSuccess: () => {
setShowConsent(false);
void queryClient.invalidateQueries({ queryKey: ["plugins"] });
void queryClient.invalidateQueries({ queryKey: ["manifest"] });
void queryClient.invalidateQueries({ queryKey: ["marketplace"] });
},
});
const uninstallMutation = useMutation({
mutationFn: (deleteData: boolean) => uninstallMarketplacePlugin(pluginId, { deleteData }),
onSuccess: () => {
setShowUninstallConfirm(false);
void queryClient.invalidateQueries({ queryKey: ["plugins"] });
void queryClient.invalidateQueries({ queryKey: ["manifest"] });
void queryClient.invalidateQueries({ queryKey: ["marketplace"] });
},
});
const isInstalled = installedPluginIds.has(pluginId);
if (isLoading) {
return (
<div className="space-y-6">
<BackLink />
<div className="animate-pulse space-y-4">
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-xl bg-kumo-tint" />
<div className="space-y-2">
<div className="h-6 w-48 rounded bg-kumo-tint" />
<div className="h-4 w-32 rounded bg-kumo-tint" />
</div>
</div>
<div className="h-4 w-full rounded bg-kumo-tint" />
<div className="h-4 w-3/4 rounded bg-kumo-tint" />
<div className="h-64 w-full rounded bg-kumo-tint" />
</div>
</div>
);
}
if (error || !plugin) {
return (
<div className="space-y-6">
<BackLink />
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-6 text-center">
<Warning className="mx-auto h-8 w-8 text-kumo-danger" />
<h3 className="mt-3 font-medium text-kumo-danger">{t`Failed to load plugin`}</h3>
<p className="mt-1 text-sm text-kumo-subtle">
{error instanceof Error ? error.message : t`Plugin not found`}
</p>
<Link to="/plugins/marketplace" className="mt-4 inline-block text-kumo-brand text-sm">
{t`Back to marketplace`}
</Link>
</div>
</div>
);
}
const latest = plugin.latestVersion;
const imageVerdict = latest?.imageAudit?.verdict;
const isImageFlagged = imageVerdict === "warn" || imageVerdict === "fail";
const isAuditFailed = latest?.audit?.verdict === "fail";
const screenshots = (latest?.screenshotUrls ?? []).filter(isSafeUrl);
const iconSrc = plugin.iconUrl ? safeIconUrl(plugin.iconUrl, 128) : null;
return (
<div className="space-y-6">
<BackLink />
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-4">
{/* Icon */}
{iconSrc ? (
<img
src={iconSrc}
alt=""
className={`h-16 w-16 rounded-xl object-cover ${isImageFlagged ? "blur-md" : ""}`}
aria-label={isImageFlagged ? t`Icon blurred due to image audit` : undefined}
/>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-kumo-brand/10 text-kumo-brand text-2xl font-bold">
{plugin.name.charAt(0).toUpperCase()}
</div>
)}
<div>
<h1 className="text-2xl font-bold">{plugin.name}</h1>
<div className="mt-1 flex items-center gap-2 text-sm text-kumo-subtle">
<span>{plugin.author.name}</span>
{plugin.author.verified && <ShieldCheck className="h-4 w-4 text-kumo-brand" />}
{latest && (
<>
<span aria-hidden="true">&middot;</span>
<span>v{latest.version}</span>
</>
)}
</div>
{plugin.description && (
<p className="mt-2 text-sm text-kumo-subtle max-w-lg">{plugin.description}</p>
)}
</div>
</div>
{/* Action button */}
<div className="flex items-center gap-3">
{isInstalled ? (
<>
<Badge variant="secondary" className="text-sm px-3 py-1">
{t`Installed`}
</Badge>
<Button
variant="outline"
className="text-kumo-danger hover:text-kumo-danger"
onClick={() => setShowUninstallConfirm(true)}
>
{t`Uninstall`}
</Button>
</>
) : isAuditFailed ? (
<div className="flex flex-col items-end gap-1">
<Button disabled variant="secondary">
{t`Install blocked`}
</Button>
<span className="text-xs text-kumo-danger">{t`Failed security audit`}</span>
</div>
) : (
<Button onClick={() => setShowConsent(true)}>
<DownloadSimple className="me-2 h-4 w-4" />
{t`Install`}
</Button>
)}
</div>
</div>
{/* Stats bar */}
<div className="flex flex-wrap items-center gap-4 rounded-lg border bg-kumo-tint/30 p-3 text-sm">
<div className="flex items-center gap-1.5">
<DownloadSimple className="h-4 w-4 text-kumo-subtle" />
<span>{t`${plugin.installCount.toLocaleString()} installs`}</span>
</div>
{latest?.audit && <AuditBadge verdict={latest.audit.verdict} />}
{plugin.license && <span className="text-kumo-subtle">{plugin.license}</span>}
{plugin.repositoryUrl && isSafeUrl(plugin.repositoryUrl) && (
<a
href={plugin.repositoryUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-kumo-brand hover:underline"
>
<GithubLogo className="h-4 w-4" />
{t`Source`}
</a>
)}
{plugin.homepageUrl && isSafeUrl(plugin.homepageUrl) && (
<a
href={plugin.homepageUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-kumo-brand hover:underline"
>
<Globe className="h-4 w-4" />
{t`Website`}
</a>
)}
</div>
{/* Screenshots */}
{screenshots.length > 0 && (
<div>
<h2 className="mb-3 text-lg font-semibold">{t`Screenshots`}</h2>
<div className="flex gap-3 overflow-x-auto pb-2">
{screenshots.map((url, i) => (
<button
key={url}
onClick={() => setLightboxIndex(i)}
className="shrink-0 overflow-hidden rounded-lg border hover:ring-2 hover:ring-kumo-brand transition-shadow"
>
<img
src={url}
alt={t`Screenshot ${i + 1}`}
className={`h-40 w-auto object-cover ${isImageFlagged ? "blur-md" : ""}`}
loading="lazy"
aria-label={isImageFlagged ? t`Screenshot blurred due to image audit` : undefined}
/>
</button>
))}
</div>
</div>
)}
{/* Two-column layout: README + sidebar */}
<div className="grid gap-6 lg:grid-cols-[1fr_280px]">
{/* README */}
<div>
{latest?.readme ? (
<div className="prose prose-sm max-w-none rounded-lg border bg-kumo-base p-6">
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(latest.readme) }} />
</div>
) : (
<div className="rounded-lg border bg-kumo-base p-6 text-center text-kumo-subtle">
{t`No detailed description available.`}
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-4">
{/* Capabilities */}
<div className="rounded-lg border bg-kumo-base p-4">
<h3 className="text-sm font-semibold mb-2">{t`Permissions`}</h3>
{plugin.capabilities.length === 0 ? (
<p className="text-xs text-kumo-subtle">
{t`This plugin requires no special permissions.`}
</p>
) : (
<ul className="space-y-1.5">
{plugin.capabilities.map((cap) => (
<li key={cap} className="flex items-start gap-2 text-xs text-kumo-subtle">
<ShieldCheck className="mt-0.5 h-3 w-3 shrink-0 text-kumo-brand" />
<span>{describeCapability(cap)}</span>
</li>
))}
</ul>
)}
</div>
{/* Keywords */}
{plugin.keywords && plugin.keywords.length > 0 && (
<div className="rounded-lg border bg-kumo-base p-4">
<h3 className="text-sm font-semibold mb-2">{t`Keywords`}</h3>
<div className="flex flex-wrap gap-1">
{plugin.keywords.map((kw) => (
<span key={kw} className="rounded-md bg-kumo-tint px-2 py-0.5 text-xs">
{kw}
</span>
))}
</div>
</div>
)}
{/* Audit summary */}
{latest?.audit && (
<div className="rounded-lg border bg-kumo-base p-4">
<h3 className="text-sm font-semibold mb-2">{t`Security Audit`}</h3>
<div className="flex items-center gap-2">
<AuditBadge verdict={latest.audit.verdict} />
<span className="text-xs text-kumo-subtle">
{t`Risk score: ${latest.audit.riskScore}/100`}
</span>
</div>
</div>
)}
{/* Version info */}
{latest && (
<div className="rounded-lg border bg-kumo-base p-4">
<h3 className="text-sm font-semibold mb-2">{t`Version`}</h3>
<div className="space-y-1 text-xs text-kumo-subtle">
<div>v{latest.version}</div>
{latest.minEmDashVersion && (
<div>{t`Requires EmDash ${latest.minEmDashVersion}`}</div>
)}
<div>{t`Published ${new Date(latest.publishedAt).toLocaleDateString()}`}</div>
{latest.bundleSize > 0 && <div>{formatBytes(latest.bundleSize)}</div>}
</div>
</div>
)}
</div>
</div>
{/* Capability consent dialog */}
{showConsent && (
<CapabilityConsentDialog
mode="install"
pluginName={plugin.name}
capabilities={plugin.capabilities}
auditVerdict={latest?.audit?.verdict}
isPending={installMutation.isPending}
error={getMutationError(installMutation.error)}
onConfirm={() => installMutation.mutate()}
onCancel={() => {
setShowConsent(false);
installMutation.reset();
}}
/>
)}
{/* Uninstall confirmation */}
{showUninstallConfirm && (
<UninstallConfirmDialog
pluginName={plugin.name}
isPending={uninstallMutation.isPending}
error={getMutationError(uninstallMutation.error)}
onConfirm={(deleteData) => uninstallMutation.mutate(deleteData)}
onCancel={() => {
setShowUninstallConfirm(false);
uninstallMutation.reset();
}}
/>
)}
{/* Screenshot lightbox */}
{lightboxIndex !== null && lightboxIndex < screenshots.length && (
<ScreenshotLightbox
screenshots={screenshots}
index={lightboxIndex}
isBlurred={isImageFlagged}
onClose={() => setLightboxIndex(null)}
onNavigate={setLightboxIndex}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function BackLink() {
const { t } = useLingui();
return (
<Link
to="/plugins/marketplace"
className="inline-flex items-center gap-1 text-sm text-kumo-subtle hover:text-kumo-default"
>
<ArrowPrev className="h-4 w-4" />
{t`Back to marketplace`}
</Link>
);
}
interface ScreenshotLightboxProps {
screenshots: string[];
index: number;
isBlurred?: boolean;
onClose: () => void;
onNavigate: (index: number) => void;
}
function ScreenshotLightbox({
screenshots,
index,
isBlurred = false,
onClose,
onNavigate,
}: ScreenshotLightboxProps) {
const { t } = useLingui();
const handleKeyDown = React.useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "ArrowLeft" && index > 0) onNavigate(index - 1);
if (e.key === "ArrowRight" && index < screenshots.length - 1) onNavigate(index + 1);
},
[index, screenshots.length, onClose, onNavigate],
);
React.useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
role="dialog"
aria-modal="true"
aria-label={t`Screenshot viewer`}
>
<button
onClick={onClose}
className="absolute end-4 top-4 rounded-full bg-black/50 p-2 text-white hover:bg-black/70"
aria-label={t`Close`}
>
<X className="h-5 w-5" />
</button>
{index > 0 && (
<button
onClick={() => onNavigate(index - 1)}
className="absolute start-4 rounded-full bg-black/50 p-2 text-white hover:bg-black/70"
aria-label={t`Previous screenshot`}
>
<CaretPrev className="h-5 w-5" />
</button>
)}
<img
src={screenshots[index]}
alt={t`Screenshot ${index + 1} of ${screenshots.length}`}
className={`max-h-[85vh] max-w-[90vw] rounded-lg object-contain ${
isBlurred ? "blur-md" : ""
}`}
/>
{index < screenshots.length - 1 && (
<button
onClick={() => onNavigate(index + 1)}
className="absolute end-4 rounded-full bg-black/50 p-2 text-white hover:bg-black/70"
aria-label={t`Next screenshot`}
>
<CaretNext className="h-5 w-5" />
</button>
)}
{/* Counter */}
<div className="absolute bottom-4 start-1/2 -translate-x-1/2 rounded-full bg-black/50 px-3 py-1 text-sm text-white">
{index + 1} / {screenshots.length}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Markdown rendering (via marked, raw HTML blocked, sanitized with DOMPurify)
// ---------------------------------------------------------------------------
const HTML_ESCAPE_MAP: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
const HTML_ESCAPE_RE = /[&<>"']/g;
function escapeHtml(str: string): string {
return str.replace(HTML_ESCAPE_RE, (ch) => HTML_ESCAPE_MAP[ch]!);
}
const renderer = new Renderer();
renderer.link = ({ href, text }) => {
if (!SAFE_URL_RE.test(href)) return escapeHtml(text);
return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(text)}</a>`;
};
renderer.image = ({ text }) => escapeHtml(text);
renderer.html = () => "";
const md = new Marked({ renderer, async: false });
/** Allowed tags and attributes for DOMPurify — only standard markdown output. */
const SANITIZE_CONFIG = {
ALLOWED_TAGS: [
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"p",
"a",
"ul",
"ol",
"li",
"blockquote",
"pre",
"code",
"em",
"strong",
"del",
"br",
"hr",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"details",
"summary",
"sup",
"sub",
],
ALLOWED_ATTR: ["href", "target", "rel"],
};
function renderMarkdown(markdown: string): string {
const result = md.parse(markdown);
const html = typeof result === "string" ? result : "";
return DOMPurify.sanitize(html, SANITIZE_CONFIG);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export default MarketplacePluginDetail;

View File

@@ -0,0 +1,275 @@
/**
* Media Detail Panel
*
* A slide-out panel for viewing and editing media item metadata.
* Opens when clicking an item in the MediaLibrary.
*/
import { Button, Input, InputArea } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { X, Trash, Calendar, HardDrive, Ruler } from "@phosphor-icons/react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { updateMedia, deleteMedia, type MediaItem } from "../lib/api";
import { useStableCallback } from "../lib/hooks";
import { getFileIcon, formatFileSize } from "../lib/media-utils";
import { cn } from "../lib/utils";
import { ConfirmDialog } from "./ConfirmDialog";
export interface MediaDetailPanelProps {
item: MediaItem | null;
onClose: () => void;
onDeleted?: () => void;
}
/**
* Slide-out panel for viewing and editing media metadata
*/
export function MediaDetailPanel({ item, onClose, onDeleted }: MediaDetailPanelProps) {
const { t } = useLingui();
const queryClient = useQueryClient();
// Form state - controlled inputs
const [filename, setFilename] = React.useState(item?.filename ?? "");
const [alt, setAlt] = React.useState(item?.alt ?? "");
const [caption, setCaption] = React.useState(item?.caption ?? "");
// Reset form when item changes
React.useEffect(() => {
if (item) {
setFilename(item.filename);
setAlt(item.alt ?? "");
setCaption(item.caption ?? "");
}
}, [item]);
// Track if form has unsaved changes
const hasChanges = React.useMemo(() => {
if (!item) return false;
return (
filename !== item.filename || alt !== (item.alt ?? "") || caption !== (item.caption ?? "")
);
}, [item, filename, alt, caption]);
// Update mutation
const updateMutation = useMutation({
mutationFn: (data: { alt?: string; caption?: string }) => {
if (!item) throw new Error("No item selected");
return updateMedia(item.id, data);
},
onSuccess: () => {
// Invalidate to refresh the list
void queryClient.invalidateQueries({ queryKey: ["media"] });
},
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: () => {
if (!item) throw new Error("No item selected");
return deleteMedia(item.id);
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["media"] });
onDeleted?.();
onClose();
},
});
const handleSave = () => {
if (!item || !hasChanges) return;
updateMutation.mutate({
alt: alt || undefined,
caption: caption || undefined,
});
};
const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
const handleDelete = () => {
if (!item) return;
setShowDeleteConfirm(true);
};
const stableOnClose = useStableCallback(onClose);
const stableHandleSave = useStableCallback(handleSave);
// Handle keyboard shortcuts
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
stableOnClose();
}
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
stableHandleSave();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [stableOnClose, stableHandleSave]);
if (!item) return null;
const isImage = item.mimeType.startsWith("image/");
const isVideo = item.mimeType.startsWith("video/");
const isAudio = item.mimeType.startsWith("audio/");
return (
<>
<div
className={cn(
"fixed inset-y-0 end-0 w-96 bg-kumo-base border-s shadow-xl z-50",
"flex flex-col",
)}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<h2 className="font-semibold truncate pe-2">{t`Media Details`}</h2>
<Button variant="ghost" shape="square" aria-label={t`Close`} onClick={onClose}>
<X className="h-4 w-4" />
<span className="sr-only">{t`Close`}</span>
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{/* Preview */}
<div className="p-4 border-b">
<div className="aspect-video bg-kumo-tint rounded-lg overflow-hidden flex items-center justify-center">
{isImage ? (
<img
src={item.url}
alt={item.alt || item.filename}
className="max-h-full max-w-full object-contain"
/>
) : isVideo ? (
<video
src={item.url}
controls
preload="metadata"
className="max-h-full max-w-full"
/>
) : isAudio ? (
<audio src={item.url} controls preload="metadata" className="w-full" />
) : (
<div className="text-center p-4">
<span className="text-4xl">{getFileIcon(item.mimeType)}</span>
<p className="mt-2 text-sm text-kumo-subtle">{item.mimeType}</p>
</div>
)}
</div>
</div>
{/* File Info */}
<div className="p-4 border-b space-y-3">
<div className="flex items-center gap-2 text-sm">
<HardDrive className="h-4 w-4 text-kumo-subtle" />
<span className="text-kumo-subtle">{t`Size:`}</span>
<span>{formatFileSize(item.size)}</span>
</div>
{item.width && item.height && (
<div className="flex items-center gap-2 text-sm">
<Ruler className="h-4 w-4 text-kumo-subtle" />
<span className="text-kumo-subtle">{t`Dimensions:`}</span>
<span>
{item.width} × {item.height}
</span>
</div>
)}
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-4 w-4 text-kumo-subtle" />
<span className="text-kumo-subtle">{t`Uploaded:`}</span>
<span>{formatDate(item.createdAt)}</span>
</div>
</div>
{/* Editable Fields */}
<div className="p-4 space-y-4">
<Input
label={t`Filename`}
value={filename}
onChange={(e) => setFilename(e.target.value)}
disabled // Filename editing needs backend support
description={t`Filename cannot be changed after upload`}
/>
{isImage && (
<>
<Input
label={t`Alt Text`}
value={alt}
onChange={(e) => setAlt(e.target.value)}
placeholder={t`Describe this image for accessibility`}
description={t`Used by screen readers and when image fails to load`}
/>
<InputArea
label={t`Caption`}
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder={t`Optional caption for display`}
rows={2}
/>
</>
)}
</div>
</div>
{/* Footer */}
<div className="p-4 border-t flex items-center justify-between gap-2">
<Button
variant="destructive"
size="sm"
icon={<Trash />}
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? t`Deleting...` : t`Delete`}
</Button>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onClose}>
{t`Cancel`}
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || updateMutation.isPending}
>
{updateMutation.isPending ? t`Saving...` : t`Save`}
</Button>
</div>
</div>
</div>
<ConfirmDialog
open={showDeleteConfirm}
onClose={() => {
setShowDeleteConfirm(false);
deleteMutation.reset();
}}
title={t`Delete Media?`}
description={t`Delete "${item.filename}"? This cannot be undone.`}
confirmLabel={t`Delete`}
pendingLabel={t`Deleting...`}
isPending={deleteMutation.isPending}
error={deleteMutation.error}
onConfirm={() => deleteMutation.mutate()}
/>
</>
);
}
function formatDate(isoString: string): string {
return new Date(isoString).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export default MediaDetailPanel;

View File

@@ -0,0 +1,676 @@
import { Button, Input, Loader } from "@cloudflare/kumo";
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { Upload, Image, SquaresFour, List, MagnifyingGlass, Check, X } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import * as React from "react";
import {
type MediaItem,
type MediaProviderInfo,
type MediaProviderItem,
fetchMediaProviders,
fetchProviderMedia,
uploadToProvider,
} from "../lib/api";
import { providerItemToMediaItem, getFileIcon, formatFileSize } from "../lib/media-utils";
import { cn } from "../lib/utils";
import { MediaDetailPanel } from "./MediaDetailPanel";
export interface MediaLibraryProps {
items?: MediaItem[];
isLoading?: boolean;
onUpload?: (file: File) => Promise<void> | void;
onSelect?: (item: MediaItem) => void;
onDelete?: (id: string) => void;
onItemUpdated?: () => void;
}
/**
* Media library component with upload, provider tabs, and grid view
*/
export function MediaLibrary({
items = [],
isLoading,
onUpload,
onDelete,
onItemUpdated,
}: MediaLibraryProps) {
const { t } = useLingui();
const [viewMode, setViewMode] = React.useState<"grid" | "list">("grid");
const [selectedItem, setSelectedItem] = React.useState<MediaItem | null>(null);
const [activeProvider, setActiveProvider] = React.useState<string>("local");
const [searchQuery, setSearchQuery] = React.useState("");
const [uploadState, setUploadState] = React.useState<{
status: "idle" | "uploading" | "success" | "error";
message?: string;
progress?: { current: number; total: number };
}>({ status: "idle" });
const fileInputRef = React.useRef<HTMLInputElement>(null);
// Track loaded image dimensions for providers that don't return them (e.g., CF Images)
const [loadedDimensions, setLoadedDimensions] = React.useState<
Record<string, { width: number; height: number }>
>({});
// Fetch available providers
const { data: providers } = useQuery({
queryKey: ["media-providers"],
queryFn: fetchMediaProviders,
placeholderData: [],
});
// Fetch provider media when a non-local provider is selected
const {
data: providerData,
isLoading: providerLoading,
refetch: refetchProviderMedia,
} = useQuery({
queryKey: ["provider-media", activeProvider, searchQuery],
queryFn: () =>
fetchProviderMedia(activeProvider, {
limit: 50,
query: searchQuery || undefined,
}),
enabled: activeProvider !== "local",
});
// Get active provider info
const activeProviderInfo = React.useMemo(() => {
if (activeProvider === "local") {
return {
id: "local",
name: "Library",
capabilities: { browse: true, search: false, upload: true, delete: true },
} as MediaProviderInfo;
}
return providers?.find((p) => p.id === activeProvider);
}, [activeProvider, providers]);
// Update selected item when items change (e.g., after metadata update)
React.useEffect(() => {
if (selectedItem && activeProvider === "local") {
const updated = items.find((i) => i.id === selectedItem.id);
if (updated) {
setSelectedItem(updated);
} else {
// Item was deleted
setSelectedItem(null);
}
}
}, [items, selectedItem?.id, activeProvider]);
// Clear success/error message after a delay
React.useEffect(() => {
if (uploadState.status === "success" || uploadState.status === "error") {
const timer = setTimeout(() => {
setUploadState({ status: "idle" });
}, 3000);
return () => clearTimeout(timer);
}
}, [uploadState.status]);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
const fileArray = [...files];
const total = fileArray.length;
if (activeProvider === "local") {
setUploadState({ status: "uploading", progress: { current: 0, total } });
let uploaded = 0;
let failed = 0;
for (const file of fileArray) {
try {
await onUpload?.(file);
uploaded++;
} catch (error) {
console.error("Upload failed:", error);
failed++;
}
setUploadState({
status: "uploading",
progress: { current: uploaded + failed, total },
});
}
if (failed === 0) {
setUploadState({
status: "success",
message: plural(total, { one: "File uploaded", other: "# files uploaded" }),
});
} else if (uploaded === 0) {
setUploadState({
status: "error",
message: plural(total, { one: "Upload failed", other: "All # uploads failed" }),
});
} else {
setUploadState({
status: "error",
message: t`${uploaded} uploaded, ${failed} failed`,
});
}
} else if (activeProviderInfo?.capabilities.upload) {
// Upload to external provider
setUploadState({ status: "uploading", progress: { current: 0, total } });
let uploaded = 0;
let failed = 0;
for (const file of fileArray) {
try {
await uploadToProvider(activeProvider, file);
uploaded++;
} catch (error) {
console.error("Upload failed:", error);
failed++;
}
setUploadState({
status: "uploading",
progress: { current: uploaded + failed, total },
});
}
if (failed === 0) {
setUploadState({
status: "success",
message: plural(total, { one: "File uploaded", other: "# files uploaded" }),
});
} else if (uploaded === 0) {
setUploadState({
status: "error",
message: plural(total, { one: "Upload failed", other: "All # uploads failed" }),
});
} else {
setUploadState({
status: "error",
message: t`${uploaded} uploaded, ${failed} failed`,
});
}
void refetchProviderMedia();
}
}
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
// Build provider tabs
const providerTabs = React.useMemo(() => {
const tabs: Array<{ id: string; name: string; icon?: string }> = [
{ id: "local", name: "Library", icon: undefined },
];
if (providers) {
for (const p of providers) {
if (p.id !== "local") {
tabs.push({ id: p.id, name: p.name, icon: p.icon });
}
}
}
return tabs;
}, [providers]);
// Get current items based on active provider
const currentItems = activeProvider === "local" ? items : [];
const currentProviderItems = activeProvider !== "local" ? providerData?.items || [] : [];
const currentLoading = activeProvider === "local" ? isLoading : providerLoading;
const canUpload = activeProviderInfo?.capabilities.upload ?? false;
const canSearch = activeProviderInfo?.capabilities.search ?? false;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t`Media Library`}</h1>
<div className="flex rounded-md border" role="group" aria-label={t`View mode`}>
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
shape="square"
onClick={() => setViewMode("grid")}
aria-label={t`Grid view`}
aria-pressed={viewMode === "grid"}
>
<SquaresFour className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
variant={viewMode === "list" ? "secondary" : "ghost"}
shape="square"
onClick={() => setViewMode("list")}
aria-label={t`List view`}
aria-pressed={viewMode === "list"}
>
<List className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</div>
{/* Provider Tabs + Upload */}
<div className="flex items-center justify-between gap-4 border-b pb-3">
{providerTabs.length > 1 && (
<div className="flex gap-2 overflow-x-auto">
{providerTabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => {
setActiveProvider(tab.id);
setSelectedItem(null);
setSearchQuery("");
}}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors whitespace-nowrap",
activeProvider === tab.id
? "bg-kumo-brand text-white"
: "bg-kumo-tint hover:bg-kumo-tint/80 text-kumo-subtle",
)}
>
{tab.icon &&
(tab.icon.startsWith("data:") ? (
<img src={tab.icon} alt="" className="h-4 w-4" aria-hidden="true" />
) : (
<span aria-hidden="true">{tab.icon}</span>
))}
{tab.name}
</button>
))}
</div>
)}
{/* Upload button + status */}
<div className="flex items-center gap-3 flex-shrink-0">
{/* Upload status feedback */}
{uploadState.status === "uploading" && (
<div className="flex items-center gap-2 text-sm text-kumo-subtle">
<Loader size="sm" />
<span>
{uploadState.progress && uploadState.progress.total > 1
? t`Uploading ${uploadState.progress.current}/${uploadState.progress.total}...`
: t`Uploading...`}
</span>
</div>
)}
{uploadState.status === "success" && (
<div className="flex items-center gap-2 text-sm text-green-600">
<Check className="h-4 w-4" />
<span>{uploadState.message}</span>
</div>
)}
{uploadState.status === "error" && (
<div className="flex items-center gap-2 text-sm text-kumo-danger">
<X className="h-4 w-4" />
<span>{uploadState.message}</span>
</div>
)}
{canUpload && (
<>
<Button
onClick={() => fileInputRef.current?.click()}
disabled={uploadState.status === "uploading"}
icon={uploadState.status === "uploading" ? <Loader size="sm" /> : <Upload />}
>
{t`Upload to ${activeProviderInfo?.name || t`Library`}`}
</Button>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx"
className="sr-only"
onChange={handleFileSelect}
aria-label={t`Upload files`}
/>
</>
)}
</div>
</div>
{/* Search (for providers that support it) */}
{canSearch && (
<div className="relative max-w-sm">
<MagnifyingGlass className="absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
<Input
type="search"
placeholder={t`Search...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="ps-9"
/>
</div>
)}
{/* Content */}
{currentLoading ? (
<div className="flex items-center justify-center py-12">
<Loader />
</div>
) : activeProvider === "local" && currentItems.length === 0 ? (
<div className="rounded-lg border bg-kumo-base p-12 text-center">
<Image className="mx-auto h-12 w-12 text-kumo-subtle" aria-hidden="true" />
<h2 className="mt-4 text-lg font-medium">{t`No media yet`}</h2>
<p className="mt-2 text-sm text-kumo-subtle">
{t`Upload images, videos, and documents to get started.`}
</p>
<Button className="mt-4" onClick={() => fileInputRef.current?.click()} icon={<Upload />}>
{t`Upload Files`}
</Button>
</div>
) : activeProvider !== "local" && currentProviderItems.length === 0 ? (
<div className="rounded-lg border bg-kumo-base p-12 text-center">
<Image className="mx-auto h-12 w-12 text-kumo-subtle" aria-hidden="true" />
<h2 className="mt-4 text-lg font-medium">{t`No media found`}</h2>
<p className="mt-2 text-sm text-kumo-subtle">
{canSearch && searchQuery
? t`Try a different search term`
: canUpload
? t`Upload media to get started`
: t`No media available from this provider`}
</p>
</div>
) : viewMode === "grid" ? (
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(160px,1fr))]">
{activeProvider === "local"
? currentItems.map((item) => (
<MediaGridItem
key={item.id}
item={item}
selected={selectedItem?.id === item.id}
onClick={() => setSelectedItem(item)}
onDelete={() => onDelete?.(item.id)}
/>
))
: currentProviderItems.map((item) => (
<ProviderGridItem
key={item.id}
item={item}
selected={selectedItem?.id === item.id}
onClick={() => {
// Merge loaded dimensions if provider didn't return them
const dims = loadedDimensions[item.id];
const itemWithDims = dims
? {
...item,
width: item.width ?? dims.width,
height: item.height ?? dims.height,
}
: item;
setSelectedItem(providerItemToMediaItem(activeProvider, itemWithDims));
}}
onDimensionsLoaded={(width, height) => {
setLoadedDimensions((prev) => ({
...prev,
[item.id]: { width, height },
}));
}}
/>
))}
</div>
) : (
<div className="rounded-md border overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-kumo-tint/50">
<th className="px-4 py-3 text-start text-sm font-medium">{t`Preview`}</th>
<th className="px-4 py-3 text-start text-sm font-medium">{t`Filename`}</th>
<th className="px-4 py-3 text-start text-sm font-medium">{t`Type`}</th>
<th className="px-4 py-3 text-start text-sm font-medium">{t`Size`}</th>
<th className="px-4 py-3 text-end text-sm font-medium">{t`Actions`}</th>
</tr>
</thead>
<tbody>
{activeProvider === "local"
? currentItems.map((item) => (
<MediaListItem
key={item.id}
item={item}
selected={selectedItem?.id === item.id}
onClick={() => setSelectedItem(item)}
onDelete={() => onDelete?.(item.id)}
/>
))
: currentProviderItems.map((item) => (
<ProviderListItem
key={item.id}
item={item}
selected={selectedItem?.id === item.id}
onClick={() => {
const dims = loadedDimensions[item.id];
const itemWithDims = dims
? {
...item,
width: item.width ?? dims.width,
height: item.height ?? dims.height,
}
: item;
setSelectedItem(providerItemToMediaItem(activeProvider, itemWithDims));
}}
onDimensionsLoaded={(width, height) => {
setLoadedDimensions((prev) => ({
...prev,
[item.id]: { width, height },
}));
}}
/>
))}
</tbody>
</table>
</div>
)}
{/* Detail Panel */}
{selectedItem && (
<MediaDetailPanel
item={selectedItem}
onClose={() => setSelectedItem(null)}
onDeleted={() => {
if (activeProvider === "local") {
onDelete?.(selectedItem.id);
onItemUpdated?.();
} else {
void refetchProviderMedia();
}
}}
/>
)}
</div>
);
}
interface MediaGridItemProps {
item: MediaItem;
selected?: boolean;
onClick?: () => void;
onDelete: () => void;
}
function MediaGridItem({ item, selected, onClick }: MediaGridItemProps) {
const isImage = item.mimeType.startsWith("image/");
return (
<button
type="button"
onClick={onClick}
className={cn(
"group relative overflow-hidden rounded-lg border bg-kumo-base text-start transition-all max-w-[200px]",
selected ? "ring-2 ring-kumo-brand border-kumo-brand" : "hover:border-kumo-brand/50",
)}
>
<div className="aspect-square">
{isImage ? (
<img
src={item.url}
alt={item.alt || item.filename}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-kumo-tint">
<span className="text-4xl">{getFileIcon(item.mimeType)}</span>
</div>
)}
</div>
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 to-transparent opacity-0 transition-opacity group-hover:opacity-100">
<div className="w-full p-3">
<p className="truncate text-sm font-medium text-white">{item.filename}</p>
</div>
</div>
</button>
);
}
interface ProviderGridItemProps {
item: MediaProviderItem;
selected?: boolean;
onClick?: () => void;
/** Callback when image dimensions are loaded (for providers that don't return dimensions) */
onDimensionsLoaded?: (width: number, height: number) => void;
}
function ProviderGridItem({ item, selected, onClick, onDimensionsLoaded }: ProviderGridItemProps) {
const isImage = item.mimeType.startsWith("image/");
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
// Only report if we don't already have dimensions
if (onDimensionsLoaded && (!item.width || !item.height)) {
onDimensionsLoaded(img.naturalWidth, img.naturalHeight);
}
};
return (
<button
type="button"
onClick={onClick}
className={cn(
"group relative overflow-hidden rounded-lg border bg-kumo-base text-start transition-all max-w-[200px]",
selected ? "ring-2 ring-kumo-brand border-kumo-brand" : "hover:border-kumo-brand/50",
)}
>
<div className="aspect-square">
{isImage && item.previewUrl ? (
<img
src={item.previewUrl}
alt={item.alt || item.filename}
className="h-full w-full object-cover"
onLoad={handleImageLoad}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-kumo-tint">
<span className="text-4xl">{getFileIcon(item.mimeType)}</span>
</div>
)}
</div>
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 to-transparent opacity-0 transition-opacity group-hover:opacity-100">
<div className="w-full p-3">
<p className="truncate text-sm font-medium text-white">{item.filename}</p>
</div>
</div>
</button>
);
}
interface MediaListItemProps {
item: MediaItem;
selected?: boolean;
onClick?: () => void;
onDelete: () => void;
}
function MediaListItem({ item, selected, onClick }: MediaListItemProps) {
const { t } = useLingui();
const isImage = item.mimeType.startsWith("image/");
return (
<tr
className={cn(
"border-b cursor-pointer transition-colors",
selected ? "bg-kumo-brand/10" : "hover:bg-kumo-tint/25",
)}
onClick={onClick}
>
<td className="px-4 py-3">
<div className="h-10 w-10 overflow-hidden rounded">
{isImage ? (
<img
src={item.url}
alt={item.alt || item.filename}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-kumo-tint text-xl">
{getFileIcon(item.mimeType)}
</div>
)}
</div>
</td>
<td className="px-4 py-3 font-medium">{item.filename}</td>
<td className="px-4 py-3 text-sm text-kumo-subtle">{item.mimeType}</td>
<td className="px-4 py-3 text-sm text-kumo-subtle">{formatFileSize(item.size)}</td>
<td className="px-4 py-3 text-end">
<span className="text-sm text-kumo-subtle">
{item.alt ? t`Alt text set` : t`No alt text`}
</span>
</td>
</tr>
);
}
interface ProviderListItemProps {
item: MediaProviderItem;
selected?: boolean;
onClick?: () => void;
/** Callback when image dimensions are loaded (for providers that don't return dimensions) */
onDimensionsLoaded?: (width: number, height: number) => void;
}
function ProviderListItem({ item, selected, onClick, onDimensionsLoaded }: ProviderListItemProps) {
const { t } = useLingui();
const isImage = item.mimeType.startsWith("image/");
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
if (onDimensionsLoaded && (!item.width || !item.height)) {
onDimensionsLoaded(img.naturalWidth, img.naturalHeight);
}
};
return (
<tr
className={cn(
"border-b cursor-pointer transition-colors",
selected ? "bg-kumo-brand/10" : "hover:bg-kumo-tint/25",
)}
onClick={onClick}
>
<td className="px-4 py-3">
<div className="h-10 w-10 overflow-hidden rounded">
{isImage && item.previewUrl ? (
<img
src={item.previewUrl}
alt={item.alt || item.filename}
className="h-full w-full object-cover"
onLoad={handleImageLoad}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-kumo-tint text-xl">
{getFileIcon(item.mimeType)}
</div>
)}
</div>
</td>
<td className="px-4 py-3 font-medium">{item.filename}</td>
<td className="px-4 py-3 text-sm text-kumo-subtle">{item.mimeType}</td>
<td className="px-4 py-3 text-sm text-kumo-subtle">
{item.size ? formatFileSize(item.size) : "—"}
</td>
<td className="px-4 py-3 text-end">
<span className="text-sm text-kumo-subtle">
{item.alt ? t`Alt text set` : t`No alt text`}
</span>
</td>
</tr>
);
}
export default MediaLibrary;

View File

@@ -0,0 +1,755 @@
/**
* Media Picker Modal
*
* A modal dialog for selecting media from the library or uploading new files.
* Supports multiple media providers with tabbed navigation.
* Used by the rich text editor and image field components.
*/
import { Button, Dialog, Input, Label, Loader } from "@cloudflare/kumo";
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { Upload, Image, Check, Globe, MagnifyingGlass } from "@phosphor-icons/react";
import { X } from "@phosphor-icons/react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import {
fetchMediaList,
fetchMediaProviders,
fetchProviderMedia,
uploadMedia,
uploadToProvider,
updateMedia,
type MediaItem,
type MediaProviderInfo,
type MediaProviderItem,
} from "../lib/api";
import { providerItemToMediaItem, getFileIcon } from "../lib/media-utils";
import { cn } from "../lib/utils";
import { DialogError } from "./DialogError.js";
/** Selected item can be either a local MediaItem or a provider item with provider context */
interface SelectedMedia {
providerId: string;
item: MediaItem | MediaProviderItem;
}
export interface MediaPickerModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (item: MediaItem) => void;
/** Filter by mime type prefix, e.g. "image/" */
mimeTypeFilter?: string;
title?: string;
}
/**
* Probe image URL to get dimensions
*/
function probeImageDimensions(url: string): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const img = new window.Image();
img.onload = () => {
resolve({ width: img.naturalWidth, height: img.naturalHeight });
};
img.onerror = () => {
reject(new Error("Failed to load image"));
};
img.src = url;
});
}
export function MediaPickerModal({
open,
onOpenChange,
onSelect,
mimeTypeFilter = "image/",
title: providedTitle,
}: MediaPickerModalProps) {
const { t } = useLingui();
const title = providedTitle ?? t`Select Image`;
const queryClient = useQueryClient();
const [selectedItem, setSelectedItem] = React.useState<SelectedMedia | null>(null);
const [activeProvider, setActiveProvider] = React.useState<string>("local");
const [searchQuery, setSearchQuery] = React.useState("");
const fileInputRef = React.useRef<HTMLInputElement>(null);
// URL input state
const [imageUrl, setImageUrl] = React.useState("");
const [isProbing, setIsProbing] = React.useState(false);
const [urlError, setUrlError] = React.useState<string | null>(null);
// Track loaded image dimensions for providers that don't return them (e.g., CF Images)
const [providerDimensions, setProviderDimensions] = React.useState<
Record<string, { width: number; height: number }>
>({});
// Reset state when modal opens
React.useEffect(() => {
if (open) {
setSelectedItem(null);
setActiveProvider("local");
setSearchQuery("");
setImageUrl("");
setUrlError(null);
setUploadError(null);
setProviderDimensions({});
}
}, [open]);
// Fetch available providers
const { data: providers } = useQuery({
queryKey: ["media-providers"],
queryFn: fetchMediaProviders,
enabled: open,
// Default to just local if fetch fails
placeholderData: [],
});
// Get active provider info
const activeProviderInfo = React.useMemo(() => {
if (activeProvider === "local") {
return {
id: "local",
name: "Library",
icon: undefined,
capabilities: { browse: true, search: false, upload: true, delete: true },
} as MediaProviderInfo;
}
return providers?.find((p) => p.id === activeProvider);
}, [activeProvider, providers]);
// Fetch local media list
const { data: localData, isLoading: localLoading } = useQuery({
queryKey: ["media", mimeTypeFilter],
queryFn: () =>
fetchMediaList({
mimeType: mimeTypeFilter,
limit: 50,
}),
enabled: open && activeProvider === "local",
});
// Fetch provider media list
const { data: providerData, isLoading: providerLoading } = useQuery({
queryKey: ["provider-media", activeProvider, mimeTypeFilter, searchQuery],
queryFn: () =>
fetchProviderMedia(activeProvider, {
mimeType: mimeTypeFilter,
limit: 50,
query: searchQuery || undefined,
}),
enabled: open && activeProvider !== "local",
});
const isLoading = activeProvider === "local" ? localLoading : providerLoading;
const [uploadError, setUploadError] = React.useState<string | null>(null);
// Upload mutation for local provider
const uploadLocalMutation = useMutation({
mutationFn: (file: File) => uploadMedia(file),
onSuccess: (item) => {
void queryClient.invalidateQueries({ queryKey: ["media"] });
setSelectedItem({ providerId: "local", item });
setUploadError(null);
},
onError: (err: Error) => {
setUploadError(err.message);
},
});
// Upload mutation for external providers
const uploadProviderMutation = useMutation({
mutationFn: ({ providerId, file }: { providerId: string; file: File }) =>
uploadToProvider(providerId, file),
onSuccess: (item, { providerId }) => {
void queryClient.invalidateQueries({ queryKey: ["provider-media", providerId] });
setSelectedItem({ providerId, item });
setUploadError(null);
},
onError: (err: Error) => {
setUploadError(err.message);
},
});
const isUploading = uploadLocalMutation.isPending || uploadProviderMutation.isPending;
// Track which items we've already updated dimensions for
const updatedDimensionsRef = React.useRef<Set<string>>(new Set());
// Mutation for updating media dimensions
const dimensionsMutation = useMutation({
mutationFn: ({ id, width, height }: { id: string; width: number; height: number }) =>
updateMedia(id, { width, height }),
onSuccess: (_updated, { id, width, height }) => {
queryClient.setQueryData(
["media", mimeTypeFilter],
(old: { items: MediaItem[]; nextCursor?: string } | undefined) => {
if (!old) return old;
return {
...old,
items: old.items.map((item) => (item.id === id ? { ...item, width, height } : item)),
};
},
);
if (selectedItem?.providerId === "local" && selectedItem.item.id === id) {
setSelectedItem({
providerId: "local",
item: { ...selectedItem.item, width, height },
});
}
},
onError: (error) => {
console.warn("Failed to update media dimensions:", error);
},
});
// Handle dimensions detected for local images missing them
const handleDimensionsDetected = React.useCallback(
(id: string, width: number, height: number) => {
if (updatedDimensionsRef.current.has(id)) return;
updatedDimensionsRef.current.add(id);
dimensionsMutation.mutate({ id, width, height });
},
[dimensionsMutation],
);
// Get items for current view
const items = React.useMemo(() => {
if (activeProvider === "local") {
const localItems = localData?.items || [];
if (!mimeTypeFilter) return localItems;
return localItems.filter((item) => item.mimeType.startsWith(mimeTypeFilter));
}
return providerData?.items || [];
}, [activeProvider, localData?.items, providerData?.items, mimeTypeFilter]);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
const file = files?.[0];
if (file) {
if (activeProvider === "local") {
uploadLocalMutation.mutate(file);
} else if (activeProviderInfo?.capabilities.upload) {
uploadProviderMutation.mutate({ providerId: activeProvider, file });
}
}
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleConfirm = () => {
if (selectedItem) {
if (selectedItem.providerId === "local") {
// When providerId is "local", item is always MediaItem
onSelect(selectedItem.item as MediaItem);
} else {
// When providerId is not "local", item is always MediaProviderItem
const providerItem = selectedItem.item as MediaProviderItem;
const dims = providerDimensions[providerItem.id];
const itemWithDims = dims
? {
...providerItem,
width: providerItem.width ?? dims.width,
height: providerItem.height ?? dims.height,
}
: providerItem;
const mediaItem = providerItemToMediaItem(selectedItem.providerId, itemWithDims);
onSelect(mediaItem);
}
onOpenChange(false);
setSelectedItem(null);
setImageUrl("");
}
};
const handleClose = () => {
onOpenChange(false);
setSelectedItem(null);
setImageUrl("");
setUrlError(null);
};
const handleUrlSubmit = async () => {
if (!imageUrl.trim()) return;
let url: URL;
try {
url = new URL(imageUrl.trim());
} catch {
setUrlError(t`Please enter a valid URL`);
return;
}
setIsProbing(true);
setUrlError(null);
try {
const dimensions = await probeImageDimensions(url.href);
const externalItem: MediaItem = {
id: "",
filename: url.pathname.split("/").pop() || "external-image",
mimeType: "image/unknown",
url: url.href,
size: 0,
width: dimensions.width,
height: dimensions.height,
createdAt: new Date().toISOString(),
};
onSelect(externalItem);
onOpenChange(false);
setImageUrl("");
} catch {
setUrlError(t`Could not load image from URL`);
} finally {
setIsProbing(false);
}
};
const handleUrlKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
void handleUrlSubmit();
}
};
const canUpload =
activeProvider === "local" || (activeProviderInfo?.capabilities.upload ?? false);
const canSearch = activeProviderInfo?.capabilities.search ?? false;
// Build provider tabs - always show local first, then add external providers
// Filter out "local" from API response since we add it manually
const providerTabs = React.useMemo(() => {
const tabs: Array<{ id: string; name: string; icon?: string }> = [
{ id: "local", name: "Library", icon: undefined },
];
if (providers) {
for (const p of providers) {
if (p.id !== "local") {
tabs.push({ id: p.id, name: p.name, icon: p.icon });
}
}
}
return tabs;
}, [providers]);
return (
<Dialog.Root open={open} onOpenChange={handleClose}>
<Dialog className="p-6 max-w-4xl max-h-[80vh] flex flex-col overflow-hidden" size="xl">
<div className="flex items-start justify-between gap-4 mb-4">
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
{title}
</Dialog.Title>
<Dialog.Close
aria-label={t`Close`}
render={(props) => (
<Button
{...props}
variant="ghost"
shape="square"
aria-label={t`Close`}
className="absolute end-4 top-4"
>
<X className="h-4 w-4" />
<span className="sr-only">{t`Close`}</span>
</Button>
)}
/>
</div>
{/* URL Input */}
<div className="border-b pb-4">
<Label>{t`Insert from URL`}</Label>
<div className="flex gap-2 mt-1.5">
<div className="flex-1 relative">
<Globe className="absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
<Input
type="url"
placeholder="https://example.com/image.jpg"
aria-label={t`Image URL`}
value={imageUrl}
onChange={(e) => {
setImageUrl(e.target.value);
setUrlError(null);
}}
onKeyDown={handleUrlKeyDown}
className="ps-9"
/>
</div>
<Button onClick={handleUrlSubmit} disabled={!imageUrl.trim() || isProbing}>
{isProbing ? <Loader size="sm" /> : t`Insert`}
</Button>
</div>
{urlError && <p className="text-sm text-kumo-danger mt-1">{urlError}</p>}
</div>
{/* Divider with "or" */}
<div className="relative py-2">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-kumo-base px-2 text-kumo-subtle">{t`or choose from library`}</span>
</div>
</div>
{/* Provider Tabs */}
{providerTabs.length > 1 && (
<div className="flex gap-2 border-b pb-3 flex-wrap">
{providerTabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => {
setActiveProvider(tab.id);
setSelectedItem(null);
setSearchQuery("");
}}
className={cn(
"flex items-center gap-2 px-4 h-9 text-sm font-medium rounded-md transition-colors whitespace-nowrap",
activeProvider === tab.id
? "bg-kumo-brand text-white"
: "bg-kumo-tint hover:bg-kumo-tint/80 text-kumo-subtle",
)}
>
{tab.icon &&
(tab.icon.startsWith("data:") ? (
<img src={tab.icon} alt="" className="h-4 w-4" aria-hidden="true" />
) : (
<span aria-hidden="true">{tab.icon}</span>
))}
{tab.name}
</button>
))}
</div>
)}
{/* Toolbar */}
<div className="flex items-center justify-between pb-3 gap-4">
{/* Search (if provider supports it) */}
{canSearch ? (
<div className="relative flex-1 max-w-xs">
<MagnifyingGlass className="absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
<Input
type="search"
placeholder={t`Search...`}
aria-label={t`Search media`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="ps-9"
/>
</div>
) : (
<p className="text-sm text-kumo-subtle">
{plural(items.length, { one: "# item", other: "# items" })}
</p>
)}
{/* Upload button (if provider supports it) */}
{canUpload && (
<>
<Button
size="sm"
icon={<Upload />}
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
{isUploading ? t`Uploading...` : t`Upload`}
</Button>
<input
ref={fileInputRef}
type="file"
accept={mimeTypeFilter ? `${mimeTypeFilter}*` : undefined}
className="sr-only"
onChange={handleFileSelect}
aria-label="Upload file"
/>
</>
)}
</div>
{/* Upload error */}
<DialogError
message={uploadError ? t`Upload failed: ${uploadError}` : null}
className="mb-3"
/>
{/* Media Grid */}
<div className="flex-1 overflow-y-auto min-h-0">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<Loader />
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<Image className="h-12 w-12 text-kumo-subtle mb-4" aria-hidden="true" />
<h3 className="text-lg font-medium">{t`No media found`}</h3>
<p className="text-sm text-kumo-subtle mt-1">
{canSearch && searchQuery
? t`Try a different search term`
: canUpload
? t`Upload an image to get started`
: t`No media available from this provider`}
</p>
{canUpload && !searchQuery && (
<Button
className="mt-4"
icon={<Upload />}
onClick={() => fileInputRef.current?.click()}
>
{t`Upload Image`}
</Button>
)}
</div>
) : (
<ul
className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-3 p-1"
role="listbox"
aria-label={t`Available media`}
>
{activeProvider === "local"
? (items as MediaItem[]).map((item) => (
<MediaPickerItem
key={item.id}
item={item}
selected={
selectedItem?.providerId === "local" && selectedItem.item.id === item.id
}
onClick={() => setSelectedItem({ providerId: "local", item })}
onDoubleClick={() => {
onSelect(item);
onOpenChange(false);
}}
onDimensionsDetected={handleDimensionsDetected}
/>
))
: (items as MediaProviderItem[]).map((item) => (
<ProviderMediaItem
key={item.id}
item={item}
selected={
selectedItem?.providerId === activeProvider &&
selectedItem.item.id === item.id
}
onClick={() => setSelectedItem({ providerId: activeProvider, item })}
onDoubleClick={() => {
// Merge loaded dimensions for double-click select
const dims = providerDimensions[item.id];
const itemWithDims = dims
? {
...item,
width: item.width ?? dims.width,
height: item.height ?? dims.height,
}
: item;
const mediaItem = providerItemToMediaItem(activeProvider, itemWithDims);
onSelect(mediaItem);
onOpenChange(false);
}}
onDimensionsLoaded={(width, height) => {
setProviderDimensions((prev) => ({
...prev,
[item.id]: { width, height },
}));
}}
/>
))}
</ul>
)}
</div>
{/* Footer */}
<div className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 border-t pt-4">
<div className="flex-1 text-sm text-kumo-subtle">
{selectedItem && (
<span>
{t`Selected:`} <strong>{selectedItem.item.filename}</strong>
{selectedItem.providerId !== "local" && (
<span className="ms-2 text-xs">
{t`(from ${providers?.find((p) => p.id === selectedItem.providerId)?.name})`}
</span>
)}
</span>
)}
</div>
<Button variant="outline" onClick={handleClose}>
{t`Cancel`}
</Button>
<Button onClick={handleConfirm} disabled={!selectedItem}>
{t`Insert`}
</Button>
</div>
</Dialog>
</Dialog.Root>
);
}
interface MediaPickerItemProps {
item: MediaItem;
selected: boolean;
onClick: () => void;
onDoubleClick: () => void;
onDimensionsDetected?: (id: string, width: number, height: number) => void;
}
function MediaPickerItem({
item,
selected,
onClick,
onDoubleClick,
onDimensionsDetected,
}: MediaPickerItemProps) {
const { t } = useLingui();
const isImage = item.mimeType.startsWith("image/");
const needsDimensions = isImage && (!item.width || !item.height);
const handleImageLoad = React.useCallback(
(e: React.SyntheticEvent<HTMLImageElement>) => {
if (needsDimensions && onDimensionsDetected) {
const img = e.currentTarget;
if (img.naturalWidth && img.naturalHeight) {
onDimensionsDetected(item.id, img.naturalWidth, img.naturalHeight);
}
}
},
[needsDimensions, onDimensionsDetected, item.id],
);
return (
<li role="option" aria-selected={selected}>
<button
type="button"
className={cn(
"relative aspect-square w-full rounded-lg border-2 overflow-hidden transition-all",
"hover:border-kumo-brand/50 focus:outline-none focus:ring-2 focus:ring-kumo-ring",
selected ? "border-kumo-brand ring-2 ring-kumo-brand/20" : "border-transparent",
)}
onClick={onClick}
onDoubleClick={onDoubleClick}
aria-label={t`${item.filename}${selected ? t` (selected)` : ""}`}
>
{isImage ? (
<img
src={item.url}
alt=""
className="h-full w-full object-cover"
onLoad={handleImageLoad}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-kumo-tint">
<span className="text-3xl" aria-hidden="true">
{getFileIcon(item.mimeType)}
</span>
</div>
)}
{selected && (
<div
className="absolute inset-0 bg-kumo-brand/20 flex items-center justify-center"
aria-hidden="true"
>
<div className="bg-kumo-brand text-white rounded-full p-1">
<Check className="h-4 w-4" />
</div>
</div>
)}
<div
className="absolute bottom-0 start-0 end-0 bg-gradient-to-t from-black/60 to-transparent p-2"
aria-hidden="true"
>
<p className="text-xs text-white truncate">{item.filename}</p>
</div>
</button>
</li>
);
}
interface ProviderMediaItemProps {
item: MediaProviderItem;
selected: boolean;
onClick: () => void;
onDoubleClick: () => void;
/** Callback when image dimensions are loaded (for providers that don't return dimensions) */
onDimensionsLoaded?: (width: number, height: number) => void;
}
function ProviderMediaItem({
item,
selected,
onClick,
onDoubleClick,
onDimensionsLoaded,
}: ProviderMediaItemProps) {
const { t } = useLingui();
const isImage = item.mimeType.startsWith("image/");
const needsDimensions = isImage && (!item.width || !item.height);
const handleImageLoad = React.useCallback(
(e: React.SyntheticEvent<HTMLImageElement>) => {
if (needsDimensions && onDimensionsLoaded) {
const img = e.currentTarget;
if (img.naturalWidth && img.naturalHeight) {
onDimensionsLoaded(img.naturalWidth, img.naturalHeight);
}
}
},
[needsDimensions, onDimensionsLoaded],
);
return (
<li role="option" aria-selected={selected}>
<button
type="button"
className={cn(
"relative aspect-square w-full rounded-lg border-2 overflow-hidden transition-all",
"hover:border-kumo-brand/50 focus:outline-none focus:ring-2 focus:ring-kumo-ring",
selected ? "border-kumo-brand ring-2 ring-kumo-brand/20" : "border-transparent",
)}
onClick={onClick}
onDoubleClick={onDoubleClick}
aria-label={t`${item.filename}${selected ? t` (selected)` : ""}`}
>
{isImage && item.previewUrl ? (
<img
src={item.previewUrl}
alt=""
className="h-full w-full object-cover"
onLoad={handleImageLoad}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-kumo-tint">
<span className="text-3xl" aria-hidden="true">
{getFileIcon(item.mimeType)}
</span>
</div>
)}
{selected && (
<div
className="absolute inset-0 bg-kumo-brand/20 flex items-center justify-center"
aria-hidden="true"
>
<div className="bg-kumo-brand text-white rounded-full p-1">
<Check className="h-4 w-4" />
</div>
</div>
)}
<div
className="absolute bottom-0 start-0 end-0 bg-gradient-to-t from-black/60 to-transparent p-2"
aria-hidden="true"
>
<p className="text-xs text-white truncate">{item.filename}</p>
</div>
</button>
</li>
);
}
export default MediaPickerModal;

View File

@@ -0,0 +1,451 @@
/**
* Menu Editor component
*
* Edit menu items with basic reordering (simplified version without drag-and-drop)
*/
import { Button, Dialog, Input, Select, Toast } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import {
Plus,
Trash,
CaretUp,
CaretDown,
Link as LinkIcon,
X,
File as FileIcon,
} from "@phosphor-icons/react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useNavigate } from "@tanstack/react-router";
import * as React from "react";
import {
fetchMenu,
createMenuItem,
deleteMenuItem,
updateMenuItem,
reorderMenuItems,
type MenuItem,
} from "../lib/api";
import { ArrowPrev } from "./ArrowIcons.js";
import { ContentPickerModal } from "./ContentPickerModal";
import { DialogError, getMutationError } from "./DialogError.js";
export function MenuEditor() {
const { t } = useLingui();
const { name } = useParams({ from: "/_admin/menus/$name" });
const navigate = useNavigate();
const queryClient = useQueryClient();
const toastManager = Toast.useToastManager();
const [isAddOpen, setIsAddOpen] = React.useState(false);
const [isContentPickerOpen, setIsContentPickerOpen] = React.useState(false);
const [editingItem, setEditingItem] = React.useState<MenuItem | null>(null);
const [localItems, setLocalItems] = React.useState<MenuItem[]>([]);
const [addError, setAddError] = React.useState<string | null>(null);
const [editError, setEditError] = React.useState<string | null>(null);
const { data: menu, isLoading } = useQuery({
queryKey: ["menu", name],
queryFn: () => fetchMenu(name),
staleTime: Infinity,
});
// Sync local items with fetched data
React.useEffect(() => {
if (menu?.items) {
setLocalItems(menu.items);
}
}, [menu]);
const createMutation = useMutation({
mutationFn: (input: Parameters<typeof createMenuItem>[1]) => createMenuItem(name, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["menu", name] });
setIsAddOpen(false);
toastManager.add({ title: t`Item added`, description: t`Menu item has been added.` });
},
onError: (error: Error) => {
setAddError(error.message);
},
});
const deleteMutation = useMutation({
mutationFn: (itemId: string) => deleteMenuItem(name, itemId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["menu", name] });
toastManager.add({
title: t`Item deleted`,
description: t`Menu item has been deleted.`,
});
},
onError: (error: Error) => {
toastManager.add({
title: t`Error`,
description: error.message,
type: "error",
});
},
});
const updateMutation = useMutation({
mutationFn: ({
itemId,
input,
}: {
itemId: string;
input: Parameters<typeof updateMenuItem>[2];
}) => updateMenuItem(name, itemId, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["menu", name] });
setEditingItem(null);
toastManager.add({
title: t`Item updated`,
description: t`Menu item has been updated.`,
});
},
onError: (error: Error) => {
setEditError(error.message);
},
});
const reorderMutation = useMutation({
mutationFn: (input: Parameters<typeof reorderMenuItems>[1]) => reorderMenuItems(name, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["menu", name] });
toastManager.add({
title: t`Order saved`,
description: t`Menu order has been updated.`,
});
},
onError: (error: Error) => {
toastManager.add({
title: t`Error`,
description: error.message,
type: "error",
});
},
});
const handleAddCustomLink = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setAddError(null);
const formData = new FormData(e.currentTarget);
const labelVal = formData.get("label");
const urlVal = formData.get("url");
const targetVal = formData.get("target");
createMutation.mutate({
type: "custom",
label: typeof labelVal === "string" ? labelVal : "",
customUrl: typeof urlVal === "string" ? urlVal : "",
target: (typeof targetVal === "string" ? targetVal : "") || undefined,
});
};
const handleAddContent = (item: { collection: string; id: string; title: string }) => {
createMutation.mutate({
type: item.collection,
label: item.title,
referenceCollection: item.collection,
referenceId: item.id,
});
};
const handleUpdateItem = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setEditError(null);
if (!editingItem) return;
const formData = new FormData(e.currentTarget);
const uLabelVal = formData.get("label");
const uUrlVal = formData.get("url");
const uTargetVal = formData.get("target");
updateMutation.mutate({
itemId: editingItem.id,
input: {
label: typeof uLabelVal === "string" ? uLabelVal : "",
customUrl:
editingItem.type === "custom" ? (typeof uUrlVal === "string" ? uUrlVal : "") : undefined,
target: (typeof uTargetVal === "string" ? uTargetVal : "") || undefined,
},
});
};
const moveItem = (index: number, direction: "up" | "down") => {
const newItems = [...localItems];
const targetIndex = direction === "up" ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= newItems.length) return;
const currentItem = newItems[index];
const targetItem = newItems[targetIndex];
if (!currentItem || !targetItem) return;
newItems[index] = targetItem;
newItems[targetIndex] = currentItem;
// Update sort orders
const reorderedItems = newItems.map((item, i) => ({
id: item.id,
parentId: item.parent_id,
sortOrder: i,
}));
setLocalItems(newItems);
reorderMutation.mutate({ items: reorderedItems });
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-kumo-subtle">{t`Loading menu...`}</div>
</div>
);
}
if (!menu) {
return (
<div className="text-center py-12">
<p className="text-kumo-subtle">{t`Menu not found`}</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
aria-label={t`Back`}
onClick={() => navigate({ to: "/menus" })}
>
<ArrowPrev className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">{menu.label}</h1>
<p className="text-kumo-subtle">{t`Edit menu items`}</p>
</div>
</div>
<div className="flex gap-2">
<Button
icon={<FileIcon />}
variant="outline"
onClick={() => setIsContentPickerOpen(true)}
>
{t`Add Content`}
</Button>
<Dialog.Root
open={isAddOpen}
onOpenChange={(open) => {
setIsAddOpen(open);
if (!open) setAddError(null);
}}
>
<Dialog.Trigger
render={(props) => (
<Button {...props} icon={<Plus />}>
{t`Add Custom Link`}
</Button>
)}
/>
<Dialog className="p-6" size="lg">
<div className="flex items-start justify-between gap-4 mb-4">
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
{t`Add Custom Link`}
</Dialog.Title>
<Dialog.Close
aria-label={t`Close`}
render={(props) => (
<Button
{...props}
variant="ghost"
shape="square"
aria-label={t`Close`}
className="absolute end-4 top-4"
>
<X className="h-4 w-4" />
<span className="sr-only">{t`Close`}</span>
</Button>
)}
/>
</div>
<form onSubmit={handleAddCustomLink} className="space-y-4">
<Input label={t`Label`} name="label" required placeholder={t`Home`} />
<Input
label={t`URL`}
name="url"
type="text"
required
pattern="(https?://.+|/.*)"
title={t`Enter a URL (https://…) or a relative path (/…)`}
placeholder={t`https://example.com or /about`}
/>
<Select
label={t`Target`}
name="target"
defaultValue=""
items={{ "": t`Same window`, _blank: t`New window` }}
>
<Select.Option value="">{t`Same window`}</Select.Option>
<Select.Option value="_blank">{t`New window`}</Select.Option>
</Select>
<DialogError message={addError || getMutationError(createMutation.error)} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setIsAddOpen(false)}>
{t`Cancel`}
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? t`Adding...` : t`Add`}
</Button>
</div>
</form>
</Dialog>
</Dialog.Root>
</div>
</div>
<ContentPickerModal
open={isContentPickerOpen}
onOpenChange={setIsContentPickerOpen}
onSelect={handleAddContent}
/>
{localItems.length === 0 ? (
<div className="border rounded-lg p-12 text-center">
<LinkIcon className="mx-auto h-12 w-12 text-kumo-subtle mb-4" />
<h3 className="text-lg font-semibold mb-2">{t`No menu items yet`}</h3>
<p className="text-kumo-subtle mb-4">{t`Add links to build your navigation menu`}</p>
<div className="flex justify-center gap-2">
<Button
icon={<FileIcon />}
variant="outline"
onClick={() => setIsContentPickerOpen(true)}
>
{t`Add Content`}
</Button>
<Button icon={<Plus />} onClick={() => setIsAddOpen(true)}>
{t`Add Custom Link`}
</Button>
</div>
</div>
) : (
<div className="space-y-2">
{localItems.map((item, index) => (
<div key={item.id} className="border rounded-lg p-4 flex items-center justify-between">
<div className="flex-1">
<div className="font-medium">{item.label}</div>
<div className="text-sm text-kumo-subtle">
{item.type === "custom" ? (
item.custom_url
) : (
<span className="inline-flex items-center rounded-full bg-kumo-brand/10 px-2 py-0.5 text-xs font-medium text-kumo-brand">
{item.reference_collection ?? item.type}
</span>
)}
{item.target === "_blank" && t` (opens in new window)`}
</div>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
aria-label={t`Move up`}
onClick={() => moveItem(index, "up")}
disabled={index === 0}
>
<CaretUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
aria-label={t`Move down`}
onClick={() => moveItem(index, "down")}
disabled={index === localItems.length - 1}
>
<CaretDown className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => setEditingItem(item)}>
{t`Edit`}
</Button>
<Button
variant="outline"
size="sm"
aria-label={t`Delete`}
onClick={() => deleteMutation.mutate(item.id)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
<Dialog.Root
open={editingItem !== null}
onOpenChange={(open: boolean) => {
if (!open) {
setEditingItem(null);
setEditError(null);
}
}}
>
<Dialog className="p-6" size="lg">
<div className="flex items-start justify-between gap-4 mb-4">
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
{t`Edit Menu Item`}
</Dialog.Title>
<Dialog.Close
aria-label={t`Close`}
render={(props) => (
<Button
{...props}
variant="ghost"
shape="square"
aria-label={t`Close`}
className="absolute end-4 top-4"
>
<X className="h-4 w-4" />
<span className="sr-only">{t`Close`}</span>
</Button>
)}
/>
</div>
{editingItem && (
<form onSubmit={handleUpdateItem} className="space-y-4">
<Input label={t`Label`} name="label" required defaultValue={editingItem.label} />
{editingItem.type === "custom" && (
<Input
label={t`URL`}
name="url"
type="text"
required
pattern="(https?://.+|/.*)"
title={t`Enter a URL (https://…) or a relative path (/…)`}
defaultValue={editingItem.custom_url || ""}
/>
)}
<Select
label={t`Target`}
name="target"
defaultValue={editingItem.target || ""}
items={{ "": t`Same window`, _blank: t`New window` }}
>
<Select.Option value="">{t`Same window`}</Select.Option>
<Select.Option value="_blank">{t`New window`}</Select.Option>
</Select>
<DialogError message={editError || getMutationError(updateMutation.error)} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setEditingItem(null)}>
{t`Cancel`}
</Button>
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? t`Saving...` : t`Save`}
</Button>
</div>
</form>
)}
</Dialog>
</Dialog.Root>
</div>
);
}

View File

@@ -0,0 +1,217 @@
/**
* Menu List component
*
* Displays all menus with ability to create, edit, and delete.
*/
import { Button, Dialog, Input, Toast, buttonVariants } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { Plus, Pencil, Trash, List as ListIcon } from "@phosphor-icons/react";
import { X } from "@phosphor-icons/react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import * as React from "react";
import { fetchMenus, createMenu, deleteMenu } from "../lib/api";
import { ConfirmDialog } from "./ConfirmDialog.js";
import { DialogError, getMutationError } from "./DialogError.js";
export function MenuList() {
const { t } = useLingui();
const queryClient = useQueryClient();
const navigate = useNavigate();
const toastManager = Toast.useToastManager();
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
const [deleteMenuName, setDeleteMenuName] = React.useState<string | null>(null);
const [createError, setCreateError] = React.useState<string | null>(null);
const { data: menus, isLoading } = useQuery({
queryKey: ["menus"],
queryFn: fetchMenus,
});
const createMutation = useMutation({
mutationFn: createMenu,
onSuccess: (menu) => {
void queryClient.invalidateQueries({ queryKey: ["menus"] });
setIsCreateOpen(false);
toastManager.add({
title: t`Menu created`,
description: t`Menu "${menu.label}" has been created.`,
});
void navigate({ to: "/menus/$name", params: { name: menu.name } });
},
onError: (error: Error) => {
setCreateError(error.message);
},
});
const deleteMutation = useMutation({
mutationFn: deleteMenu,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["menus"] });
setDeleteMenuName(null);
toastManager.add({
title: t`Menu deleted`,
description: t`The menu has been deleted.`,
});
},
});
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setCreateError(null);
const formData = new FormData(e.currentTarget);
const nameVal = formData.get("name");
const name = typeof nameVal === "string" ? nameVal : "";
const labelVal = formData.get("label");
const label = typeof labelVal === "string" ? labelVal : "";
createMutation.mutate({ name, label });
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-kumo-subtle">{t`Loading menus...`}</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">{t`Menus`}</h1>
<p className="text-kumo-subtle">{t`Manage navigation menus for your site`}</p>
</div>
<Dialog.Root
open={isCreateOpen}
onOpenChange={(open) => {
setIsCreateOpen(open);
if (!open) setCreateError(null);
}}
>
<Dialog.Trigger
render={(props) => (
<Button {...props} icon={<Plus />}>
{t`Create Menu`}
</Button>
)}
/>
<Dialog className="p-6" size="lg">
<div className="flex items-start justify-between gap-4 mb-4">
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
{t`Create New Menu`}
</Dialog.Title>
<Dialog.Close
aria-label={t`Close`}
render={(props) => (
<Button
{...props}
variant="ghost"
shape="square"
aria-label={t`Close`}
className="absolute end-4 top-4"
>
<X className="h-4 w-4" />
<span className="sr-only">{t`Close`}</span>
</Button>
)}
/>
</div>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<Input
label={t`Name`}
name="name"
required
placeholder="primary"
pattern="[a-z0-9\-]+"
title={t`Only lowercase letters, numbers, and hyphens`}
/>
<p className="text-sm text-kumo-subtle mt-1">
{t`URL-friendly identifier (e.g., "primary", "footer")`}
</p>
</div>
<div>
<Input label={t`Label`} name="label" required placeholder={t`Primary Navigation`} />
<p className="text-sm text-kumo-subtle mt-1">{t`Display name for admin interface`}</p>
</div>
<DialogError message={createError || getMutationError(createMutation.error)} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setIsCreateOpen(false)}>
{t`Cancel`}
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? t`Creating...` : t`Create`}
</Button>
</div>
</form>
</Dialog>
</Dialog.Root>
</div>
{!menus || menus.length === 0 ? (
<div className="border rounded-lg p-12 text-center">
<ListIcon className="mx-auto h-12 w-12 text-kumo-subtle mb-4" />
<h3 className="text-lg font-semibold mb-2">{t`No menus yet`}</h3>
<p className="text-kumo-subtle mb-4">{t`Create your first navigation menu to get started`}</p>
<Button icon={<Plus />} onClick={() => setIsCreateOpen(true)}>
{t`Create Menu`}
</Button>
</div>
) : (
<div className="grid gap-4">
{menus.map((menu) => (
<div
key={menu.id}
className="border rounded-lg p-6 flex items-center justify-between hover:bg-kumo-tint transition-colors"
>
<Link to="/menus/$name" params={{ name: menu.name }} className="flex-1">
<div>
<h3 className="font-semibold text-lg">{menu.label}</h3>
<p className="text-sm text-kumo-subtle">
{menu.name} {menu.itemCount || 0} items
</p>
</div>
</Link>
<div className="flex gap-2">
<Link
to="/menus/$name"
params={{ name: menu.name }}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<Pencil className="h-4 w-4 me-2" />
{t`Edit`}
</Link>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteMenuName(menu.name)}
aria-label={t`Delete ${menu.name} menu`}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
<ConfirmDialog
open={deleteMenuName !== null}
onClose={() => {
setDeleteMenuName(null);
deleteMutation.reset();
}}
title={t`Delete Menu`}
description={t`Are you sure you want to delete this menu? This will also delete all menu items. This action cannot be undone.`}
confirmLabel={t`Delete`}
pendingLabel={t`Deleting...`}
isPending={deleteMutation.isPending}
error={deleteMutation.error}
onConfirm={() => deleteMenuName && deleteMutation.mutate(deleteMenuName)}
/>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import * as React from "react";
interface Props {
children: React.ReactNode;
/** The underlying field kind to show in the error message */
fieldKind: string;
}
interface State {
hasError: boolean;
error?: Error;
}
/**
* Error boundary that wraps trusted plugin field widgets.
* On render error, shows a warning with a retry button instead of crashing the editor.
*/
export class PluginFieldErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
override render() {
if (this.state.hasError) {
return (
<div className="rounded-md border border-kumo-danger/50 bg-kumo-danger/5 p-3">
<p className="text-sm font-medium text-kumo-danger">Plugin widget error</p>
<p className="mt-1 text-xs text-kumo-subtle">
{this.state.error?.message || "The plugin field widget failed to render."}
</p>
<button
type="button"
className="mt-2 text-xs font-medium text-kumo-brand underline"
onClick={() => this.setState({ hasError: false, error: undefined })}
>
Retry
</button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,586 @@
/**
* Plugin Manager Component
*
* Displays list of configured plugins with enable/disable controls.
* Extended with marketplace features: source badges, update checking,
* update/uninstall for marketplace-installed plugins.
*/
import { Badge, Button, Switch, Toast } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import {
PuzzlePiece,
Gear,
FileText,
SquaresFour,
WebhooksLogo,
CaretDown,
ArrowsClockwise,
Storefront,
Trash,
ShieldCheck,
} from "@phosphor-icons/react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as React from "react";
import {
fetchPlugins,
enablePlugin,
disablePlugin,
type PluginInfo,
type AdminManifest,
CAPABILITY_LABELS,
} from "../lib/api";
import {
checkPluginUpdates,
updateMarketplacePlugin,
uninstallMarketplacePlugin,
type PluginUpdateInfo,
} from "../lib/api/marketplace.js";
import { safeIconUrl } from "../lib/url.js";
import { cn } from "../lib/utils";
import { CaretNext } from "./ArrowIcons.js";
import { CapabilityConsentDialog } from "./CapabilityConsentDialog.js";
import { DialogError, getMutationError } from "./DialogError.js";
export interface PluginManagerProps {
/** Admin manifest — used to check if marketplace is configured */
manifest?: AdminManifest;
}
export function PluginManager({ manifest }: PluginManagerProps) {
const { t } = useLingui();
const queryClient = useQueryClient();
const toastManager = Toast.useToastManager();
const hasMarketplace = !!manifest?.marketplace;
const {
data: plugins,
isLoading,
error,
} = useQuery({
queryKey: ["plugins"],
queryFn: fetchPlugins,
});
const {
data: updates,
refetch: refetchUpdates,
isFetching: isCheckingUpdates,
} = useQuery({
queryKey: ["plugin-updates"],
queryFn: checkPluginUpdates,
enabled: false, // Only fetch on demand
});
const enableMutation = useMutation({
mutationFn: enablePlugin,
onSuccess: (plugin) => {
void queryClient.invalidateQueries({ queryKey: ["plugins"] });
void queryClient.invalidateQueries({ queryKey: ["manifest"] });
toastManager.add({
title: t`Plugin enabled`,
description: t`${plugin.name} is now active`,
});
},
onError: (err) => {
toastManager.add({
title: t`Failed to enable plugin`,
description: err instanceof Error ? err.message : t`An error occurred`,
type: "error",
});
},
});
const disableMutation = useMutation({
mutationFn: disablePlugin,
onSuccess: (plugin) => {
void queryClient.invalidateQueries({ queryKey: ["plugins"] });
void queryClient.invalidateQueries({ queryKey: ["manifest"] });
toastManager.add({
title: t`Plugin disabled`,
description: t`${plugin.name} has been deactivated`,
});
},
onError: (err) => {
toastManager.add({
title: t`Failed to disable plugin`,
description: err instanceof Error ? err.message : t`An error occurred`,
type: "error",
});
},
});
const updateMap = React.useMemo(() => {
if (!updates) return new Map<string, PluginUpdateInfo>();
return new Map(updates.map((u) => [u.pluginId, u]));
}, [updates]);
const hasMarketplacePlugins = plugins?.some((p) => p.source === "marketplace");
if (isLoading) {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">{t`Plugins`}</h1>
<div className="text-kumo-subtle">{t`Loading plugins...`}</div>
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">{t`Plugins`}</h1>
<div className="text-kumo-danger">{t`Failed to load plugins: ${error.message}`}</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">{t`Plugins`}</h1>
<div className="flex items-center gap-3">
{hasMarketplacePlugins && (
<Button
variant="ghost"
onClick={() => void refetchUpdates()}
disabled={isCheckingUpdates}
>
<ArrowsClockwise
className={cn("me-2 h-4 w-4", isCheckingUpdates && "animate-spin")}
/>
{t`Check for updates`}
</Button>
)}
{hasMarketplace && (
<Link to="/plugins/marketplace">
<Button variant="ghost">
<Storefront className="me-2 h-4 w-4" />
{t`Marketplace`}
</Button>
</Link>
)}
<span className="text-sm text-kumo-subtle">{t`${plugins?.length ?? 0} plugins`}</span>
</div>
</div>
<p className="text-kumo-subtle">
{t`Manage installed plugins. Enable or disable plugins to control their functionality.`}
</p>
<div className="grid gap-4">
{plugins?.map((plugin) => (
<PluginCard
key={plugin.id}
plugin={plugin}
updateInfo={updateMap.get(plugin.id)}
onEnable={() => enableMutation.mutate(plugin.id)}
onDisable={() => disableMutation.mutate(plugin.id)}
isToggling={enableMutation.isPending || disableMutation.isPending}
hasMarketplace={hasMarketplace}
/>
))}
</div>
{plugins?.length === 0 && (
<div className="rounded-lg border bg-kumo-base p-8 text-center">
<PuzzlePiece className="mx-auto h-12 w-12 text-kumo-subtle" />
<h3 className="mt-4 text-lg font-medium">{t`No plugins configured`}</h3>
<p className="mt-2 text-sm text-kumo-subtle">
{hasMarketplace ? (
<>
{t`Browse the`}{" "}
<Link to="/plugins/marketplace" className="text-kumo-brand hover:underline">
{t`marketplace`}
</Link>{" "}
{t`to install plugins, or add them to your astro.config.mjs.`}
</>
) : (
t`Add plugins to your astro.config.mjs to extend EmDash functionality.`
)}
</p>
</div>
)}
</div>
);
}
interface PluginCardProps {
plugin: PluginInfo;
updateInfo?: PluginUpdateInfo;
onEnable: () => void;
onDisable: () => void;
isToggling: boolean;
/** Whether the marketplace is configured (controls "View in Marketplace" link) */
hasMarketplace: boolean;
}
function PluginCard({
plugin,
updateInfo,
onEnable,
onDisable,
isToggling,
hasMarketplace,
}: PluginCardProps) {
const { t } = useLingui();
const [expanded, setExpanded] = React.useState(false);
const [showUpdateConsent, setShowUpdateConsent] = React.useState(false);
const [showUninstallConfirm, setShowUninstallConfirm] = React.useState(false);
const queryClient = useQueryClient();
const toastManager = Toast.useToastManager();
const isMarketplace = plugin.source === "marketplace";
const hasUpdate = !!updateInfo && updateInfo.installed !== updateInfo.latest;
const updateMutation = useMutation({
mutationFn: () => updateMarketplacePlugin(plugin.id, { confirmCapabilities: true }),
onSuccess: () => {
setShowUpdateConsent(false);
void queryClient.invalidateQueries({ queryKey: ["plugins"] });
void queryClient.invalidateQueries({ queryKey: ["plugin-updates"] });
void queryClient.invalidateQueries({ queryKey: ["manifest"] });
toastManager.add({
title: t`Plugin updated`,
description: t`${plugin.name} updated to v${updateInfo?.latest}`,
});
},
});
const uninstallMutation = useMutation({
mutationFn: (deleteData: boolean) => uninstallMarketplacePlugin(plugin.id, { deleteData }),
onSuccess: () => {
setShowUninstallConfirm(false);
void queryClient.invalidateQueries({ queryKey: ["plugins"] });
void queryClient.invalidateQueries({ queryKey: ["manifest"] });
toastManager.add({
title: t`Plugin uninstalled`,
description: t`${plugin.name} has been removed`,
});
},
});
const handleToggle = () => {
if (plugin.enabled) {
onDisable();
} else {
onEnable();
}
};
return (
<>
<div
className={cn(
"rounded-lg border bg-kumo-base transition-colors",
!plugin.enabled && "opacity-75",
)}
>
<div className="flex items-center gap-4 p-4">
{/* Plugin icon */}
{plugin.iconUrl ? (
<img
src={safeIconUrl(plugin.iconUrl, 80) ?? undefined}
alt=""
className="h-10 w-10 rounded-lg object-cover"
loading="lazy"
/>
) : (
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg",
plugin.enabled ? "bg-kumo-brand/10" : "bg-kumo-tint",
)}
>
<PuzzlePiece
className={cn("h-5 w-5", plugin.enabled ? "text-kumo-brand" : "text-kumo-subtle")}
/>
</div>
)}
{/* Plugin info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold truncate">{plugin.name}</h3>
<span className="text-xs text-kumo-subtle">v{plugin.version}</span>
{!plugin.enabled && <Badge variant="secondary">{t`Disabled`}</Badge>}
{isMarketplace && <Badge variant="secondary">{t`Marketplace`}</Badge>}
{hasUpdate && (
<Badge variant="outline" className="border-kumo-brand text-kumo-brand">
{t`v${updateInfo.latest} available`}
</Badge>
)}
</div>
{/* Description */}
{plugin.description && (
<p className="mt-0.5 text-sm text-kumo-subtle line-clamp-1">{plugin.description}</p>
)}
{/* Feature indicators + inline capabilities */}
<div className="flex items-center gap-3 mt-1 text-sm text-kumo-subtle">
{plugin.hasAdminPages && (
<span className="flex items-center gap-1">
<FileText className="h-3 w-3" />
{t`Pages`}
</span>
)}
{plugin.hasDashboardWidgets && (
<span className="flex items-center gap-1">
<SquaresFour className="h-3 w-3" />
{t`Widgets`}
</span>
)}
{plugin.hasHooks && (
<span className="flex items-center gap-1">
<WebhooksLogo className="h-3 w-3" />
{t`Hooks`}
</span>
)}
{plugin.capabilities.length > 0 && (
<span
className="flex items-center gap-1"
title={plugin.capabilities.map((c) => CAPABILITY_LABELS[c] ?? c).join(", ")}
>
<ShieldCheck className="h-3 w-3" />
{t`${plugin.capabilities.length} permission${plugin.capabilities.length !== 1 ? "s" : ""}`}
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{hasUpdate && (
<Button
variant="outline"
size="sm"
onClick={() => setShowUpdateConsent(true)}
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? t`Updating...` : t`Update to v${updateInfo.latest}`}
</Button>
)}
{isMarketplace && hasMarketplace && (
<Link to="/plugins/marketplace/$pluginId" params={{ pluginId: plugin.id }}>
<Button variant="ghost" size="sm">
<Storefront className="me-1.5 h-3.5 w-3.5" />
{t`View in Marketplace`}
</Button>
</Link>
)}
{plugin.hasAdminPages && plugin.enabled && (
<Link to="/plugins/$pluginId/$" params={{ pluginId: plugin.id, _splat: "" }}>
<Button variant="ghost" shape="square" aria-label={t`Settings`}>
<Gear className="h-4 w-4" />
<span className="sr-only">{t`Settings`}</span>
</Button>
</Link>
)}
<Switch
checked={plugin.enabled}
onCheckedChange={handleToggle}
disabled={isToggling}
aria-label={plugin.enabled ? t`Disable plugin` : t`Enable plugin`}
/>
<Button
variant="ghost"
shape="square"
aria-label={expanded ? t`Collapse details` : t`Expand details`}
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
>
{expanded ? <CaretDown className="h-4 w-4" /> : <CaretNext className="h-4 w-4" />}
<span className="sr-only">
{expanded ? t`Collapse` : t`Expand`} {t`details`}
</span>
</Button>
</div>
</div>
{/* Expanded details */}
{expanded && (
<div className="border-t px-4 py-3 space-y-3">
{/* Capabilities */}
{plugin.capabilities.length > 0 && (
<div>
<h4 className="text-xs font-medium text-kumo-subtle uppercase tracking-wider mb-1">
{t`Capabilities`}
</h4>
<div className="flex flex-wrap gap-1">
{plugin.capabilities.map((cap) => (
<span
key={cap}
className="inline-flex items-center rounded-md bg-kumo-tint px-2 py-0.5 text-xs"
title={CAPABILITY_LABELS[cap]}
>
{CAPABILITY_LABELS[cap] ?? cap}
</span>
))}
</div>
</div>
)}
{/* Source */}
{isMarketplace && (
<div>
<h4 className="text-xs font-medium text-kumo-subtle uppercase tracking-wider mb-1">
{t`Source`}
</h4>
<span className="text-xs text-kumo-subtle">
{t`Installed from marketplace (v${plugin.marketplaceVersion || plugin.version})`}
</span>
</div>
)}
{/* Package */}
{plugin.package && (
<div>
<h4 className="text-xs font-medium text-kumo-subtle uppercase tracking-wider mb-1">
{t`Package`}
</h4>
<code className="text-xs bg-kumo-tint px-2 py-0.5 rounded">{plugin.package}</code>
</div>
)}
{/* Timestamps */}
<div className="grid grid-cols-2 gap-4 text-xs">
{plugin.installedAt && (
<div>
<span className="text-kumo-subtle">{t`Installed:`}</span>{" "}
{new Date(plugin.installedAt).toLocaleDateString()}
</div>
)}
{plugin.activatedAt && (
<div>
<span className="text-kumo-subtle">{t`Last enabled:`}</span>{" "}
{new Date(plugin.activatedAt).toLocaleDateString()}
</div>
)}
{plugin.deactivatedAt && !plugin.enabled && (
<div>
<span className="text-kumo-subtle">{t`Disabled:`}</span>{" "}
{new Date(plugin.deactivatedAt).toLocaleDateString()}
</div>
)}
</div>
{/* Uninstall button for marketplace plugins */}
{isMarketplace && (
<div className="pt-2 border-t">
<Button
variant="ghost"
className="text-kumo-danger hover:text-kumo-danger"
onClick={() => setShowUninstallConfirm(true)}
disabled={uninstallMutation.isPending}
>
<Trash className="me-2 h-4 w-4" />
{t`Uninstall`}
</Button>
</div>
)}
</div>
)}
</div>
{/* Update consent dialog */}
{showUpdateConsent && updateInfo && (
<CapabilityConsentDialog
mode="update"
pluginName={plugin.name}
capabilities={plugin.capabilities}
newCapabilities={[]} // WS3 will populate this from the diff
isPending={updateMutation.isPending}
error={getMutationError(updateMutation.error)}
onConfirm={() => updateMutation.mutate()}
onCancel={() => {
setShowUpdateConsent(false);
updateMutation.reset();
}}
/>
)}
{/* Uninstall confirmation */}
{showUninstallConfirm && (
<UninstallConfirmDialog
pluginName={plugin.name}
isPending={uninstallMutation.isPending}
error={getMutationError(uninstallMutation.error)}
onConfirm={(deleteData) => uninstallMutation.mutate(deleteData)}
onCancel={() => {
setShowUninstallConfirm(false);
uninstallMutation.reset();
}}
/>
)}
</>
);
}
// ---------------------------------------------------------------------------
// Uninstall confirmation dialog
// ---------------------------------------------------------------------------
interface UninstallConfirmDialogProps {
pluginName: string;
isPending: boolean;
error?: string | null;
onConfirm: (deleteData: boolean) => void;
onCancel: () => void;
}
export function UninstallConfirmDialog({
pluginName,
isPending,
error,
onConfirm,
onCancel,
}: UninstallConfirmDialogProps) {
const { t } = useLingui();
const [deleteData, setDeleteData] = React.useState(false);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-label={t`Uninstall confirmation`}
>
<div className="absolute inset-0 bg-black/50" onClick={() => !isPending && onCancel()} />
<div className="relative w-full max-w-sm rounded-lg border bg-kumo-base shadow-lg">
<div className="p-6 space-y-4">
<h2 className="text-lg font-semibold">{t`Uninstall ${pluginName}?`}</h2>
<p className="text-sm text-kumo-subtle">
{t`This will remove the plugin and its bundle from your site.`}
</p>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={deleteData}
onChange={(e) => setDeleteData(e.target.checked)}
className="rounded border"
/>
{t`Also delete plugin storage data`}
</label>
<DialogError message={error} />
</div>
<div className="flex justify-end gap-3 border-t px-6 py-4">
<Button variant="ghost" onClick={onCancel} disabled={isPending}>
{t`Cancel`}
</Button>
<Button variant="destructive" onClick={() => onConfirm(deleteData)} disabled={isPending}>
{isPending ? t`Uninstalling...` : t`Uninstall`}
</Button>
</div>
</div>
</div>
);
}
export default PluginManager;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,567 @@
import { Badge, Button, Dialog, Input, Label, Switch } from "@cloudflare/kumo";
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import {
MagnifyingGlass,
Plus,
ArrowsLeftRight,
Trash,
PencilSimple,
WarningCircle,
X,
} from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import {
createRedirect,
deleteRedirect,
fetch404Summary,
fetchRedirects,
updateRedirect,
} from "../lib/api/redirects.js";
import type {
CreateRedirectInput,
NotFoundSummary,
Redirect,
UpdateRedirectInput,
} from "../lib/api/redirects.js";
import { cn } from "../lib/utils.js";
import { ArrowNext } from "./ArrowIcons.js";
import { ConfirmDialog } from "./ConfirmDialog.js";
import { DialogError, getMutationError } from "./DialogError.js";
// ---------------------------------------------------------------------------
// Redirect form dialog (create + edit)
// ---------------------------------------------------------------------------
function RedirectFormDialog({
open,
onClose,
redirect,
defaultSource,
}: {
open: boolean;
onClose: () => void;
/** Pass for edit mode */
redirect?: Redirect;
/** Pre-fill source for create mode (e.g. from 404 list) */
defaultSource?: string;
}) {
const { t } = useLingui();
const queryClient = useQueryClient();
const isEdit = !!redirect;
const [source, setSource] = useState(redirect?.source ?? defaultSource ?? "");
const [destination, setDestination] = useState(redirect?.destination ?? "");
const [type, setType] = useState(String(redirect?.type ?? 301));
const [enabled, setEnabled] = useState(redirect?.enabled ?? true);
const [groupName, setGroupName] = useState(redirect?.groupName ?? "");
const createMutation = useMutation({
mutationFn: (input: CreateRedirectInput) => createRedirect(input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["redirects"] });
onClose();
},
});
const updateMutation = useMutation({
mutationFn: (input: UpdateRedirectInput) => updateRedirect(redirect!.id, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["redirects"] });
onClose();
},
});
const mutation = isEdit ? updateMutation : createMutation;
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const input = {
source: source.trim(),
destination: destination.trim(),
type: Number(type),
enabled,
groupName: groupName.trim() || null,
};
if (isEdit) {
updateMutation.mutate(input);
} else {
createMutation.mutate(input);
}
}
return (
<Dialog.Root open={open} onOpenChange={(o) => !o && onClose()}>
<Dialog className="p-6" size="lg">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
{isEdit ? t`Edit Redirect` : t`New Redirect`}
</Dialog.Title>
<p className="text-sm text-kumo-subtle mt-1">
{isEdit
? t`Update this redirect rule.`
: t`Use [param] or [...rest] in paths for pattern matching.`}
</p>
</div>
<Dialog.Close
aria-label={t`Close`}
render={(props) => (
<Button
{...props}
variant="ghost"
shape="square"
aria-label={t`Close`}
className="absolute end-4 top-4"
>
<X className="h-4 w-4" />
</Button>
)}
/>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label={t`Source path`}
placeholder="/old-page or /blog/[slug]"
value={source}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSource(e.target.value)}
required
/>
<Input
label={t`Destination path`}
placeholder="/new-page or /articles/[slug]"
value={destination}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDestination(e.target.value)}
required
/>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="redirect-type">{t`Status code`}</Label>
<select
id="redirect-type"
value={type}
onChange={(e) => setType(e.target.value)}
className="flex h-10 w-full rounded-md border border-kumo-line bg-kumo-base px-3 py-2 text-sm"
>
<option value="301">{t`301 Permanent`}</option>
<option value="302">{t`302 Temporary`}</option>
<option value="307">{t`307 Temporary (Strict)`}</option>
<option value="308">{t`308 Permanent (Strict)`}</option>
</select>
</div>
<Input
label={t`Group (optional)`}
placeholder="e.g. import, blog"
value={groupName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setGroupName(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<Switch checked={enabled} onCheckedChange={setEnabled} id="redirect-enabled" />
<Label htmlFor="redirect-enabled">{t`Enabled`}</Label>
</div>
<DialogError message={getMutationError(mutation.error)} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose}>
{t`Cancel`}
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending
? isEdit
? t`Saving...`
: t`Creating...`
: isEdit
? t`Save`
: t`Create`}
</Button>
</div>
</form>
</Dialog>
</Dialog.Root>
);
}
// ---------------------------------------------------------------------------
// 404 Summary panel
// ---------------------------------------------------------------------------
function NotFoundPanel({
items,
onCreateRedirect,
}: {
items: NotFoundSummary[];
onCreateRedirect: (path: string) => void;
}) {
const { t } = useLingui();
if (items.length === 0) {
return (
<p className="text-sm text-kumo-subtle py-4 text-center">{t`No 404 errors recorded yet.`}</p>
);
}
return (
<div className="border rounded-lg">
<div className="flex items-center gap-4 py-2 px-4 border-b bg-kumo-tint/50 text-sm font-medium text-kumo-subtle">
<div className="flex-1">{t`Path`}</div>
<div className="w-16 text-end">{t`Hits`}</div>
<div className="w-32">{t`Last seen`}</div>
<div className="w-8" />
</div>
{items.map((item) => (
<div
key={item.path}
className="flex items-center gap-4 py-2 px-4 border-b last:border-0 text-sm"
>
<div className="flex-1 font-mono text-xs truncate">{item.path}</div>
<div className="w-16 text-end tabular-nums">{item.count}</div>
<div className="w-32 text-kumo-subtle text-xs">
{(() => {
const d = new Date(item.lastSeen);
return Number.isNaN(d.getTime()) ? item.lastSeen : d.toLocaleDateString();
})()}
</div>
<div className="w-8">
<button
onClick={() => onCreateRedirect(item.path)}
className="text-kumo-subtle hover:text-kumo-default"
title={t`Create redirect for this path`}
aria-label={t`Create redirect for ${item.path}`}
>
<ArrowsLeftRight size={14} />
</button>
</div>
</div>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Main Redirects page
// ---------------------------------------------------------------------------
type TabKey = "redirects" | "404s";
export function Redirects() {
const { t } = useLingui();
const queryClient = useQueryClient();
const [tab, setTab] = useState<TabKey>("redirects");
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [filterEnabled, setFilterEnabled] = useState<string>("all");
const [filterAuto, setFilterAuto] = useState<string>("all");
// Debounce search input
useEffect(() => {
const timer = setTimeout(setDebouncedSearch, 300, search);
return () => clearTimeout(timer);
}, [search]);
// Dialog state
const [showCreate, setShowCreate] = useState(false);
const [editRedirect, setEditRedirect] = useState<Redirect | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [prefillSource, setPrefillSource] = useState("");
// Queries
const enabledFilter = filterEnabled === "all" ? undefined : filterEnabled === "true";
const autoFilter = filterAuto === "all" ? undefined : filterAuto === "true";
const redirectsQuery = useQuery({
queryKey: ["redirects", debouncedSearch, enabledFilter, autoFilter],
queryFn: () =>
fetchRedirects({
search: debouncedSearch || undefined,
enabled: enabledFilter,
auto: autoFilter,
limit: 100,
}),
});
const notFoundQuery = useQuery({
queryKey: ["redirects", "404-summary"],
queryFn: () => fetch404Summary(50),
enabled: tab === "404s",
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteRedirect(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["redirects"] });
setDeleteId(null);
},
});
// Toggle enabled mutation
const toggleMutation = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
updateRedirect(id, { enabled }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["redirects"] });
},
onError: () => {
void queryClient.invalidateQueries({ queryKey: ["redirects"] });
},
});
function handleCreateFrom404(path: string) {
setPrefillSource(path);
setShowCreate(true);
setTab("redirects");
}
const redirects = redirectsQuery.data?.items ?? [];
const loopRedirectIds = new Set(redirectsQuery.data?.loopRedirectIds ?? []);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">{t`Redirects`}</h1>
<p className="text-kumo-subtle">{t`Manage URL redirects and view 404 errors.`}</p>
</div>
<Button icon={<Plus />} onClick={() => setShowCreate(true)}>
{t`New Redirect`}
</Button>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b">
<button
onClick={() => setTab("redirects")}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
tab === "redirects"
? "border-kumo-brand text-kumo-brand"
: "border-transparent text-kumo-subtle hover:text-kumo-default",
)}
>
{t`Redirects`}
{redirectsQuery.data && (
<Badge variant="secondary" className="ms-2">
{redirectsQuery.data.items.length}
{redirectsQuery.data.nextCursor ? "+" : ""}
</Badge>
)}
</button>
<button
onClick={() => setTab("404s")}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
tab === "404s"
? "border-kumo-brand text-kumo-brand"
: "border-transparent text-kumo-subtle hover:text-kumo-default",
)}
>
{t`404 Errors`}
</button>
</div>
{/* Tab content */}
{tab === "redirects" && (
<>
{/* Filters */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<MagnifyingGlass
className="absolute start-3 top-1/2 -translate-y-1/2 text-kumo-subtle"
size={16}
/>
<Input
placeholder={t`Search source or destination...`}
className="ps-10"
value={search}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
/>
</div>
<select
value={filterEnabled}
onChange={(e) => setFilterEnabled(e.target.value)}
className="h-10 rounded-md border border-kumo-line bg-kumo-base px-3 text-sm"
>
<option value="all">{t`All statuses`}</option>
<option value="true">{t`Enabled`}</option>
<option value="false">{t`Disabled`}</option>
</select>
<select
value={filterAuto}
onChange={(e) => setFilterAuto(e.target.value)}
className="h-10 rounded-md border border-kumo-line bg-kumo-base px-3 text-sm"
>
<option value="all">{t`All types`}</option>
<option value="false">{t`Manual`}</option>
<option value="true">{t`Auto (slug change)`}</option>
</select>
</div>
{/* Loop warning banner */}
{loopRedirectIds.size > 0 && (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-kumo-warning/50 bg-kumo-warning-tint p-4"
>
<WarningCircle
size={20}
className="mt-0.5 shrink-0 text-kumo-warning"
weight="fill"
aria-hidden="true"
/>
<div>
<p className="text-sm font-medium text-kumo-warning">{t`Redirect loop detected`}</p>
<p className="mt-1 text-sm text-kumo-subtle">
{plural(loopRedirectIds.size, {
one: "# redirect is part of a loop.",
other: "# redirects are part of a loop.",
})}{" "}
{t`Visitors hitting these paths will see an error.`}
</p>
</div>
</div>
)}
{/* Redirect list */}
{redirectsQuery.isLoading ? (
<div className="py-12 text-center text-kumo-subtle">{t`Loading redirects...`}</div>
) : redirects.length === 0 ? (
<div className="py-12 text-center text-kumo-subtle">
<ArrowsLeftRight size={48} className="mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium">{t`No redirects yet`}</p>
<p className="text-sm mt-1">{t`Create redirect rules to manage URL changes.`}</p>
</div>
) : (
<div className="border rounded-lg">
<div className="flex items-center gap-4 py-2 px-4 border-b bg-kumo-tint/50 text-sm font-medium text-kumo-subtle">
<div className="flex-1">{t`Source`}</div>
<div className="w-8 text-center" />
<div className="flex-1">{t`Destination`}</div>
<div className="w-14 text-center">{t`Code`}</div>
<div className="w-16 text-end">{t`Hits`}</div>
<div className="w-20 text-center">{t`Status`}</div>
<div className="w-20" />
</div>
{redirects.map((r) => (
<div
key={r.id}
className={cn(
"flex items-center gap-4 py-2 px-4 border-b last:border-0 text-sm",
!r.enabled && "opacity-50",
)}
>
<div className="flex-1 font-mono text-xs truncate" title={r.source}>
{r.source}
</div>
<div className="w-8 text-center text-kumo-subtle">
<ArrowNext size={14} />
</div>
<div className="flex-1 font-mono text-xs truncate" title={r.destination}>
{r.destination}
</div>
<div className="w-14 text-center">
<Badge variant="secondary">{r.type}</Badge>
</div>
<div className="w-16 text-end tabular-nums text-kumo-subtle">{r.hits}</div>
<div className="w-20 text-center">
<Switch
checked={r.enabled}
onCheckedChange={(checked) =>
toggleMutation.mutate({
id: r.id,
enabled: checked,
})
}
aria-label={r.enabled ? t`Disable redirect` : t`Enable redirect`}
/>
</div>
<div className="w-20 flex items-center justify-end gap-1">
{loopRedirectIds.has(r.id) && (
<span title={t`Part of a redirect loop`} className="me-1 inline-flex">
<WarningCircle
size={14}
weight="fill"
className="text-kumo-warning"
role="img"
aria-label={t`Part of a redirect loop`}
/>
</span>
)}
{r.auto && (
<Badge variant="outline" className="me-1 text-xs">
{t`auto`}
</Badge>
)}
<button
onClick={() => setEditRedirect(r)}
className="p-1 text-kumo-subtle hover:text-kumo-default"
title={t`Edit redirect`}
aria-label={t`Edit redirect ${r.source}`}
>
<PencilSimple size={14} />
</button>
<button
onClick={() => setDeleteId(r.id)}
className="p-1 text-kumo-subtle hover:text-kumo-danger"
title={t`Delete redirect`}
aria-label={t`Delete redirect ${r.source}`}
>
<Trash size={14} />
</button>
</div>
</div>
))}
</div>
)}
</>
)}
{tab === "404s" && (
<NotFoundPanel items={notFoundQuery.data ?? []} onCreateRedirect={handleCreateFrom404} />
)}
{/* Create dialog */}
{showCreate && (
<RedirectFormDialog
open
onClose={() => {
setShowCreate(false);
setPrefillSource("");
}}
defaultSource={prefillSource || undefined}
/>
)}
{/* Edit dialog */}
{editRedirect && (
<RedirectFormDialog open onClose={() => setEditRedirect(null)} redirect={editRedirect} />
)}
{/* Delete confirmation */}
<ConfirmDialog
open={!!deleteId}
onClose={() => {
setDeleteId(null);
deleteMutation.reset();
}}
title={t`Delete Redirect?`}
description={t`This redirect rule will be permanently removed.`}
confirmLabel={t`Delete`}
pendingLabel={t`Deleting...`}
isPending={deleteMutation.isPending}
error={deleteMutation.error}
onConfirm={() => deleteId && deleteMutation.mutate(deleteId)}
/>
</div>
);
}

View File

@@ -0,0 +1,385 @@
/**
* RepeaterField — renders a list of repeating sub-field groups in the content editor.
*
* Each item is a collapsible card containing the defined sub-fields.
* Items can be added, removed, and reordered via drag-and-drop.
*/
import { Button, Input, InputArea } from "@cloudflare/kumo";
import { DndContext, closestCenter } from "@dnd-kit/core";
import type { DragEndEvent } from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { Plus, Trash, DotsSixVertical, CaretDown } from "@phosphor-icons/react";
import * as React from "react";
import { fromDatetimeLocalInputValue, toDatetimeLocalInputValue } from "../lib/datetime-local.js";
import { cn } from "../lib/utils.js";
import { CaretNext } from "./ArrowIcons.js";
interface RepeaterSubFieldDef {
slug: string;
type: string;
label: string;
required?: boolean;
options?: string[];
}
export interface RepeaterFieldProps {
label: string;
id: string;
value: unknown;
onChange: (value: unknown[]) => void;
required?: boolean;
subFields: RepeaterSubFieldDef[];
minItems?: number;
maxItems?: number;
}
type RepeaterItem = Record<string, unknown> & { _key: string };
function ensureKeys(items: unknown[]): RepeaterItem[] {
return items.map((item, i) => {
const obj = (typeof item === "object" && item !== null ? item : {}) as Record<string, unknown>;
return { ...obj, _key: (obj._key as string) || `item-${i}-${Date.now()}` };
});
}
function stripKeys(items: RepeaterItem[]): Record<string, unknown>[] {
return items.map(({ _key, ...rest }) => rest);
}
export function RepeaterField({
label,
id,
value,
onChange,
subFields,
minItems = 0,
maxItems,
}: RepeaterFieldProps) {
const { t } = useLingui();
const rawItems = Array.isArray(value) ? value : [];
const [items, setItems] = React.useState<RepeaterItem[]>(() => ensureKeys(rawItems));
const [collapsedItems, setCollapsedItems] = React.useState<Set<string>>(new Set());
// Sync from external value changes.
// Preserve each item's _key by position so round-trips through onChange
// (which strips _key) don't remount children on every keystroke.
React.useEffect(() => {
const incoming = Array.isArray(value) ? value : [];
setItems((prev) =>
incoming.map((item, i) => {
const obj = (typeof item === "object" && item !== null ? item : {}) as Record<
string,
unknown
>;
const existingKey = (obj._key as string) || prev[i]?._key;
return {
...obj,
_key: existingKey || `item-${i}-${Date.now()}`,
};
}),
);
}, [value]);
const emitChange = (updated: RepeaterItem[]) => {
setItems(updated);
onChange(stripKeys(updated));
};
const handleAdd = () => {
if (maxItems && items.length >= maxItems) return;
const newItem: RepeaterItem = { _key: `item-${Date.now()}` };
for (const sf of subFields) {
newItem[sf.slug] =
sf.type === "boolean" ? false : sf.type === "number" || sf.type === "integer" ? null : "";
}
emitChange([...items, newItem]);
};
const handleRemove = (key: string) => {
if (items.length <= minItems) return;
emitChange(items.filter((item) => item._key !== key));
};
const handleItemChange = (key: string, fieldSlug: string, fieldValue: unknown) => {
emitChange(
items.map((item) => (item._key === key ? { ...item, [fieldSlug]: fieldValue } : item)),
);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = items.findIndex((item) => item._key === active.id);
const newIndex = items.findIndex((item) => item._key === over.id);
if (oldIndex === -1 || newIndex === -1) return;
emitChange(arrayMove(items, oldIndex, newIndex));
};
const toggleCollapse = (key: string) => {
setCollapsedItems((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const canAdd = !maxItems || items.length < maxItems;
const canRemove = items.length > minItems;
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label htmlFor={id} className="text-sm font-medium">
{label}
{items.length > 0 && (
<span className="ms-2 text-kumo-subtle font-normal">
{plural(items.length, { one: "(# item)", other: "(# items)" })}
</span>
)}
</label>
{canAdd && (
<Button variant="outline" size="sm" icon={<Plus />} onClick={handleAdd}>
{t`Add Item`}
</Button>
)}
</div>
{items.length === 0 ? (
<div className="border-2 border-dashed rounded-lg p-6 text-center text-kumo-subtle">
<p className="text-sm">{t`No items yet`}</p>
{canAdd && (
<Button
variant="outline"
size="sm"
className="mt-2"
icon={<Plus />}
onClick={handleAdd}
>
{t`Add First Item`}
</Button>
)}
</div>
) : (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext
items={items.map((item) => item._key)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{items.map((item, index) => (
<SortableRepeaterItem
key={item._key}
item={item}
index={index}
subFields={subFields}
isCollapsed={collapsedItems.has(item._key)}
onToggleCollapse={() => toggleCollapse(item._key)}
onRemove={canRemove ? () => handleRemove(item._key) : undefined}
onChange={(fieldSlug, fieldValue) =>
handleItemChange(item._key, fieldSlug, fieldValue)
}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
);
}
interface SortableRepeaterItemProps {
item: RepeaterItem;
index: number;
subFields: RepeaterSubFieldDef[];
isCollapsed: boolean;
onToggleCollapse: () => void;
onRemove?: () => void;
onChange: (fieldSlug: string, value: unknown) => void;
}
function SortableRepeaterItem({
item,
index,
subFields,
isCollapsed,
onToggleCollapse,
onRemove,
onChange,
}: SortableRepeaterItemProps) {
const { t } = useLingui();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: item._key,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
// Use the first text sub-field as the item summary label
const summaryField = subFields.find((sf) => sf.type === "string" || sf.type === "text");
const summaryValue = summaryField ? (item[summaryField.slug] as string) || "" : "";
const summaryLabel = summaryValue || t`Item ${index + 1}`;
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"border rounded-lg bg-kumo-base",
isDragging && "opacity-50 ring-2 ring-kumo-brand",
)}
>
{/* Header */}
<div
className="flex items-center gap-2 px-3 py-2 border-b cursor-pointer"
onClick={onToggleCollapse}
>
<DotsSixVertical
className="h-4 w-4 text-kumo-subtle cursor-grab shrink-0"
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
/>
{isCollapsed ? (
<CaretNext className="h-4 w-4 text-kumo-subtle shrink-0" />
) : (
<CaretDown className="h-4 w-4 text-kumo-subtle shrink-0" />
)}
<span className="text-sm font-medium flex-1 truncate">{summaryLabel}</span>
{onRemove && (
<Button
variant="ghost"
shape="square"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
aria-label={t`Remove item ${index + 1}`}
>
<Trash className="h-3.5 w-3.5 text-kumo-danger" />
</Button>
)}
</div>
{/* Sub-fields */}
{!isCollapsed && (
<div className="p-3 space-y-3">
{subFields.map((sf) => (
<SubFieldInput
key={sf.slug}
subField={sf}
value={item[sf.slug]}
onChange={(v) => onChange(sf.slug, v)}
/>
))}
</div>
)}
</div>
);
}
interface SubFieldInputProps {
subField: RepeaterSubFieldDef;
value: unknown;
onChange: (value: unknown) => void;
}
function SubFieldInput({ subField, value, onChange }: SubFieldInputProps) {
const { t } = useLingui();
switch (subField.type) {
case "string":
return (
<Input
label={subField.label}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
required={subField.required}
dir="auto"
/>
);
case "text":
return (
<InputArea
label={subField.label}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
required={subField.required}
rows={3}
dir="auto"
/>
);
case "number":
case "integer":
return (
<Input
label={subField.label}
type="number"
value={typeof value === "number" ? String(value) : ""}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
required={subField.required}
step={subField.type === "integer" ? "1" : "any"}
/>
);
case "boolean":
return (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(value)}
onChange={(e) => onChange(e.target.checked)}
/>
<span className="text-sm">{subField.label}</span>
</label>
);
case "datetime":
return (
<Input
label={subField.label}
type="datetime-local"
value={toDatetimeLocalInputValue(value)}
onChange={(e) => onChange(fromDatetimeLocalInputValue(e.target.value))}
required={subField.required}
/>
);
case "select":
return (
<div>
<label className="text-sm font-medium">{subField.label}</label>
<select
className="w-full mt-1 rounded-md border px-3 py-2 text-sm"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
required={subField.required}
>
<option value="">{t`Select...`}</option>
{subField.options?.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
);
default:
return (
<Input
label={subField.label}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
/>
);
}
}

View File

@@ -0,0 +1,435 @@
import { Badge, Button, Loader, Toast } from "@cloudflare/kumo";
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import {
ClockCounterClockwise,
ArrowCounterClockwise,
CaretDown,
CaretUp,
Plus,
Minus,
PencilSimple,
} from "@phosphor-icons/react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { fetchRevisions, restoreRevision, type Revision } from "../lib/api";
import { formatRelativeTime } from "../lib/utils";
import { ConfirmDialog } from "./ConfirmDialog";
// =============================================================================
// Diff utilities
// =============================================================================
type DiffKind = "added" | "removed" | "changed" | "unchanged";
interface FieldDiff {
field: string;
kind: DiffKind;
oldValue?: unknown;
newValue?: unknown;
}
/**
* Compute field-level diff between two revision data snapshots.
* `older` is the revision being viewed, `newer` is the next revision after it.
*/
function computeFieldDiff(
older: Record<string, unknown>,
newer: Record<string, unknown>,
): FieldDiff[] {
const allKeys = new Set([...Object.keys(older), ...Object.keys(newer)]);
const diffs: FieldDiff[] = [];
for (const key of allKeys) {
const inOlder = key in older;
const inNewer = key in newer;
if (inOlder && !inNewer) {
diffs.push({ field: key, kind: "removed", oldValue: older[key] });
} else if (!inOlder && inNewer) {
diffs.push({ field: key, kind: "added", newValue: newer[key] });
} else {
const oldJson = JSON.stringify(older[key]);
const newJson = JSON.stringify(newer[key]);
if (oldJson !== newJson) {
diffs.push({ field: key, kind: "changed", oldValue: older[key], newValue: newer[key] });
} else {
diffs.push({ field: key, kind: "unchanged", oldValue: older[key], newValue: newer[key] });
}
}
}
// Sort: changes first, then added, removed, unchanged
const kindOrder: Record<DiffKind, number> = { changed: 0, added: 1, removed: 2, unchanged: 3 };
diffs.sort((a, b) => kindOrder[a.kind] - kindOrder[b.kind]);
return diffs;
}
/** Format a value for display in the diff view */
function formatDiffValue(value: unknown): string {
if (value === null || value === undefined) return "—";
if (typeof value === "string") return value;
return JSON.stringify(value, null, 2);
}
interface RevisionHistoryProps {
collection: string;
entryId: string;
/** Called when a revision is successfully restored */
onRestored?: () => void;
}
/**
* Format a date as a full timestamp
*/
function formatFullDate(dateString: string): string {
return new Date(dateString).toLocaleString(undefined, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* RevisionHistory component - displays revision history for a content item
* with ability to restore previous versions.
*/
export function RevisionHistory({ collection, entryId, onRestored }: RevisionHistoryProps) {
const { t } = useLingui();
const [isExpanded, setIsExpanded] = React.useState(false);
const [selectedRevision, setSelectedRevision] = React.useState<Revision | null>(null);
const [restoreTarget, setRestoreTarget] = React.useState<Revision | null>(null);
const queryClient = useQueryClient();
const toastManager = Toast.useToastManager();
const { data, isLoading, error } = useQuery({
queryKey: ["revisions", collection, entryId],
queryFn: () => fetchRevisions(collection, entryId, { limit: 20 }),
enabled: isExpanded, // Only fetch when expanded
});
const restoreMutation = useMutation({
mutationFn: (revisionId: string) => restoreRevision(revisionId),
onSuccess: () => {
// Invalidate content and revisions queries
void queryClient.invalidateQueries({
queryKey: ["content", collection, entryId],
});
void queryClient.invalidateQueries({
queryKey: ["revisions", collection, entryId],
});
setSelectedRevision(null);
setRestoreTarget(null);
onRestored?.();
toastManager.add({
title: t`Revision restored`,
description: t`Content has been updated to the selected revision.`,
});
},
onError: (err: Error) => {
toastManager.add({
title: t`Restore failed`,
description: err.message,
type: "error",
});
},
});
const handleRestore = (revision: Revision) => {
setRestoreTarget(revision);
};
const revisions = data?.items ?? [];
const total = data?.total ?? 0;
return (
<>
<div className="rounded-lg border bg-kumo-base">
{/* Header - always visible */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex w-full items-center justify-between p-4 text-start hover:bg-kumo-tint/50 transition-colors"
>
<div className="flex items-center gap-2">
<ClockCounterClockwise className="h-4 w-4 text-kumo-subtle" />
<span className="font-semibold">{t`Revisions`}</span>
{total > 0 && <span className="text-xs text-kumo-subtle">({total})</span>}
</div>
{isExpanded ? (
<CaretUp className="h-4 w-4 text-kumo-subtle" />
) : (
<CaretDown className="h-4 w-4 text-kumo-subtle" />
)}
</button>
{/* Content - shown when expanded */}
{isExpanded && (
<div className="border-t px-4 pb-4">
{isLoading ? (
<div className="flex items-center justify-center py-6">
<Loader />
</div>
) : error ? (
<div className="py-4 text-center text-sm text-kumo-danger">
{t`Failed to load revisions`}
</div>
) : revisions.length === 0 ? (
<div className="py-4 text-center text-sm text-kumo-subtle">{t`No revisions yet`}</div>
) : (
<div className="space-y-1 pt-2">
{revisions.map((revision, index) => (
<RevisionItem
key={revision.id}
revision={revision}
compareRevision={index > 0 ? revisions[index - 1] : undefined}
isLatest={index === 0}
isRestoring={
restoreMutation.isPending && restoreMutation.variables === revision.id
}
onRestore={() => handleRestore(revision)}
onSelect={() =>
setSelectedRevision(selectedRevision?.id === revision.id ? null : revision)
}
isSelected={selectedRevision?.id === revision.id}
/>
))}
</div>
)}
</div>
)}
</div>
<ConfirmDialog
open={!!restoreTarget}
onClose={() => {
setRestoreTarget(null);
restoreMutation.reset();
}}
title={t`Restore Revision?`}
description={
restoreTarget
? t`Restore this version from ${formatFullDate(restoreTarget.createdAt)}? This will update the current content to this revision's data.`
: ""
}
confirmLabel={t`Restore`}
pendingLabel={t`Restoring...`}
variant="primary"
isPending={restoreMutation.isPending}
error={restoreMutation.error}
onConfirm={() => {
if (restoreTarget) restoreMutation.mutate(restoreTarget.id);
}}
/>
</>
);
}
interface RevisionItemProps {
revision: Revision;
/** The next newer revision to compare against (undefined for the latest) */
compareRevision?: Revision;
isLatest: boolean;
isRestoring: boolean;
isSelected: boolean;
onRestore: () => void;
onSelect: () => void;
}
function RevisionItem({
revision,
compareRevision,
isLatest,
isRestoring,
isSelected,
onRestore,
onSelect,
}: RevisionItemProps) {
const { t } = useLingui();
return (
<div
className={`rounded-md border p-3 transition-colors ${
isSelected ? "border-kumo-brand bg-kumo-brand/5" : "hover:bg-kumo-tint/50"
}`}
>
<div className="flex items-start justify-between gap-2">
<button type="button" onClick={onSelect} className="flex-1 text-start">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{formatRelativeTime(revision.createdAt)}</span>
{isLatest && <Badge variant="outline">{t`Current`}</Badge>}
</div>
<div className="text-xs text-kumo-subtle mt-0.5">
{formatFullDate(revision.createdAt)}
</div>
</button>
{!isLatest && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onRestore();
}}
disabled={isRestoring}
className="shrink-0"
title={t`Restore this version`}
aria-label={t`Restore this version`}
>
{isRestoring ? <Loader size="sm" /> : <ArrowCounterClockwise className="h-4 w-4" />}
</Button>
)}
</div>
{/* Diff view or snapshot - shown when selected */}
{isSelected && (
<div className="mt-3 pt-3 border-t">
{compareRevision ? (
<RevisionDiffView older={revision.data} newer={compareRevision.data} />
) : (
<>
<div className="text-xs font-medium text-kumo-subtle mb-2">{t`Content snapshot:`}</div>
<pre className="text-xs bg-kumo-tint p-2 rounded overflow-auto max-h-48">
{JSON.stringify(revision.data, null, 2)}
</pre>
</>
)}
</div>
)}
</div>
);
}
// =============================================================================
// Diff view component
// =============================================================================
interface RevisionDiffViewProps {
older: Record<string, unknown>;
newer: Record<string, unknown>;
}
function RevisionDiffView({ older, newer }: RevisionDiffViewProps) {
const { t } = useLingui();
const [showUnchanged, setShowUnchanged] = React.useState(false);
const diffs = React.useMemo(() => computeFieldDiff(older, newer), [older, newer]);
const changedCount = diffs.filter((d) => d.kind !== "unchanged").length;
const unchangedCount = diffs.length - changedCount;
if (diffs.length === 0) {
return (
<div className="text-xs text-kumo-subtle text-center py-2">{t`No fields to compare`}</div>
);
}
const visibleDiffs = showUnchanged ? diffs : diffs.filter((d) => d.kind !== "unchanged");
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-kumo-subtle">
{plural(changedCount, {
one: "# change from next revision",
other: "# changes from next revision",
})}
</div>
{unchangedCount > 0 && (
<button
type="button"
onClick={() => setShowUnchanged(!showUnchanged)}
className="text-xs text-kumo-brand hover:underline"
>
{showUnchanged
? plural(unchangedCount, { one: "Hide # unchanged", other: "Hide # unchanged" })
: plural(unchangedCount, { one: "Show # unchanged", other: "Show # unchanged" })}
</button>
)}
</div>
<div className="space-y-1.5">
{visibleDiffs.map((diff) => (
<DiffFieldRow key={diff.field} diff={diff} />
))}
</div>
</div>
);
}
const DIFF_STYLES: Record<DiffKind, { bg: string; icon: React.ReactNode; label: string }> = {
added: {
bg: "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800",
icon: <Plus className="h-3 w-3 text-green-600 dark:text-green-400" aria-hidden="true" />,
label: "Added",
},
removed: {
bg: "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800",
icon: <Minus className="h-3 w-3 text-red-600 dark:text-red-400" aria-hidden="true" />,
label: "Removed",
},
changed: {
bg: "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800",
icon: (
<PencilSimple className="h-3 w-3 text-amber-600 dark:text-amber-400" aria-hidden="true" />
),
label: "Changed",
},
unchanged: {
bg: "bg-kumo-tint/50 border-kumo-line",
icon: null,
label: "Unchanged",
},
};
function DiffFieldRow({ diff }: { diff: FieldDiff }) {
const style = DIFF_STYLES[diff.kind];
return (
<div className={`rounded border px-3 py-2 text-xs ${style.bg}`}>
<div className="flex items-center gap-1.5 mb-1">
{style.icon}
<span className="font-medium">{diff.field}</span>
</div>
{diff.kind === "changed" && (
<div className="space-y-1 mt-1.5">
<div className="flex gap-2">
<span className="text-red-600 dark:text-red-400 shrink-0"></span>
<pre className="whitespace-pre-wrap break-all font-mono">
{formatDiffValue(diff.oldValue)}
</pre>
</div>
<div className="flex gap-2">
<span className="text-green-600 dark:text-green-400 shrink-0">+</span>
<pre className="whitespace-pre-wrap break-all font-mono">
{formatDiffValue(diff.newValue)}
</pre>
</div>
</div>
)}
{diff.kind === "added" && (
<pre className="whitespace-pre-wrap break-all font-mono mt-1">
{formatDiffValue(diff.newValue)}
</pre>
)}
{diff.kind === "removed" && (
<pre className="whitespace-pre-wrap break-all font-mono mt-1">
{formatDiffValue(diff.oldValue)}
</pre>
)}
{diff.kind === "unchanged" && (
<pre className="whitespace-pre-wrap break-all font-mono mt-1 text-kumo-subtle">
{formatDiffValue(diff.oldValue)}
</pre>
)}
</div>
);
}

View File

@@ -0,0 +1,115 @@
/**
* SandboxedPluginPage
*
* Renders a plugin's admin page using Block Kit. Sends page_load/block_action/form_submit
* interactions to the plugin's admin route and renders the returned blocks.
*/
import { BlockRenderer } from "@emdash-cms/blocks";
import type { Block, BlockInteraction, BlockResponse } from "@emdash-cms/blocks";
import { CircleNotch, WarningCircle } from "@phosphor-icons/react";
import { useCallback, useEffect, useState } from "react";
import { apiFetch, API_BASE } from "../lib/api/client.js";
interface SandboxedPluginPageProps {
pluginId: string;
page: string;
}
export function SandboxedPluginPage({ pluginId, page }: SandboxedPluginPageProps) {
const [blocks, setBlocks] = useState<Block[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [toast, setToast] = useState<BlockResponse["toast"] | null>(null);
// Send an interaction to the plugin admin route
const sendInteraction = useCallback(
async (interaction: BlockInteraction) => {
try {
const response = await apiFetch(`${API_BASE}/plugins/${pluginId}/admin`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(interaction),
});
if (!response.ok) {
const text = await response.text();
setError(`Plugin responded with ${response.status}: ${text}`);
return;
}
const body = (await response.json()) as { data: BlockResponse };
const data = body.data;
setBlocks(data.blocks);
setError(null);
if (data.toast) {
setToast(data.toast);
setTimeout(setToast, 4000, null);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to communicate with plugin");
}
},
[pluginId],
);
// Initial page load
useEffect(() => {
setLoading(true);
setError(null);
void sendInteraction({ type: "page_load", page }).finally(() => setLoading(false));
}, [sendInteraction, page]);
// Handle block actions
const handleAction = useCallback(
(interaction: BlockInteraction) => {
void sendInteraction(interaction);
},
[sendInteraction],
);
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<CircleNotch className="h-6 w-6 animate-spin text-kumo-subtle" />
</div>
);
}
if (error) {
return (
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/5 p-6">
<div className="flex items-start gap-3">
<WarningCircle className="h-5 w-5 shrink-0 text-kumo-danger" />
<div>
<h3 className="font-semibold text-kumo-danger">Plugin Error</h3>
<p className="mt-1 text-sm text-kumo-subtle">{error}</p>
</div>
</div>
</div>
);
}
return (
<div className="relative">
{/* Toast notification */}
{toast && (
<div
className={`fixed end-4 top-4 z-50 rounded-lg border px-4 py-3 text-sm shadow-lg ${
toast.type === "success"
? "border-green-200 bg-green-50 text-green-800"
: toast.type === "error"
? "border-red-200 bg-red-50 text-red-800"
: "border-blue-200 bg-blue-50 text-blue-800"
}`}
>
{toast.message}
</div>
)}
<BlockRenderer blocks={blocks} onAction={handleAction} />
</div>
);
}

View File

@@ -0,0 +1,82 @@
/**
* SandboxedPluginWidget
*
* Renders a plugin's dashboard widget using Block Kit. Sends a page_load
* interaction with page="widget:<widgetId>" to the plugin's admin route.
*/
import { BlockRenderer } from "@emdash-cms/blocks";
import type { Block, BlockInteraction, BlockResponse } from "@emdash-cms/blocks";
import { CircleNotch } from "@phosphor-icons/react";
import { useCallback, useEffect, useState } from "react";
import { apiFetch, API_BASE } from "../lib/api/client.js";
interface SandboxedPluginWidgetProps {
pluginId: string;
widgetId: string;
}
export function SandboxedPluginWidget({ pluginId, widgetId }: SandboxedPluginWidgetProps) {
const [blocks, setBlocks] = useState<Block[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const sendInteraction = useCallback(
async (interaction: BlockInteraction) => {
try {
const response = await apiFetch(`${API_BASE}/plugins/${pluginId}/admin`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(interaction),
});
if (!response.ok) {
setError(`Plugin error (${response.status})`);
return;
}
const body = (await response.json()) as { data: BlockResponse };
const data = body.data;
setBlocks(data.blocks);
setError(null);
} catch {
setError("Failed to load widget");
}
},
[pluginId],
);
// Initial widget load
useEffect(() => {
setLoading(true);
void sendInteraction({ type: "page_load", page: `widget:${widgetId}` }).finally(() =>
setLoading(false),
);
}, [sendInteraction, widgetId]);
const handleAction = useCallback(
(interaction: BlockInteraction) => {
void sendInteraction(interaction);
},
[sendInteraction],
);
if (loading) {
return (
<div className="flex items-center justify-center py-6">
<CircleNotch className="h-5 w-5 animate-spin text-kumo-subtle" />
</div>
);
}
if (error) {
return <p className="text-sm text-kumo-subtle">{error}</p>;
}
if (blocks.length === 0) {
return <p className="text-sm text-kumo-subtle">No content</p>;
}
return <BlockRenderer blocks={blocks} onAction={handleAction} />;
}

View File

@@ -0,0 +1,47 @@
/**
* Save Button with inline feedback
*
* Shows state based on whether there are unsaved changes:
* - "Saved" when clean (no unsaved changes)
* - "Save" when dirty (has unsaved changes)
* - "Saving..." while saving
*/
import { Button, Loader } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { FloppyDisk, Check } from "@phosphor-icons/react";
import type { ComponentProps } from "react";
import * as React from "react";
import { cn } from "../lib/utils";
export interface SaveButtonProps extends Omit<ComponentProps<typeof Button>, "children" | "shape"> {
/** Whether there are unsaved changes */
isDirty: boolean;
/** Whether currently saving */
isSaving: boolean;
}
/**
* Button that reflects save state
*/
export function SaveButton({ isDirty, isSaving, className, disabled, ...props }: SaveButtonProps) {
const { t } = useLingui();
const isSaved = !isDirty && !isSaving;
return (
<Button
className={cn("min-w-[100px] transition-all", className)}
disabled={disabled || isSaving || isSaved}
variant={isSaved ? "secondary" : "primary"}
icon={isSaving ? <Loader size="sm" /> : isSaved ? <Check /> : <FloppyDisk />}
aria-live="polite"
aria-busy={isSaving}
{...props}
>
{isSaving ? t`Saving...` : isSaved ? t`Saved` : t`Save`}
</Button>
);
}
export default SaveButton;

View File

@@ -0,0 +1,291 @@
/**
* Section editor page component
*
* Edit a section's content and metadata.
*/
import { Button, Input, InputArea, Label, Loader, Toast } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link, useParams, useNavigate } from "@tanstack/react-router";
import * as React from "react";
import { fetchSection, updateSection, type Section, type UpdateSectionInput } from "../lib/api";
import { slugify } from "../lib/utils";
import { ArrowPrev } from "./ArrowIcons.js";
import { ImageDetailPanel, type ImageAttributes } from "./editor/ImageDetailPanel";
import { EditorHeader } from "./EditorHeader";
import { PortableTextEditor, type BlockSidebarPanel } from "./PortableTextEditor";
import { SaveButton } from "./SaveButton";
export function SectionEditor() {
const { t } = useLingui();
const { slug } = useParams({ from: "/_admin/sections/$slug" });
const navigate = useNavigate();
const queryClient = useQueryClient();
const toastManager = Toast.useToastManager();
const {
data: section,
isLoading,
error,
} = useQuery({
queryKey: ["sections", slug],
queryFn: () => fetchSection(slug),
staleTime: Infinity,
});
const updateMutation = useMutation({
mutationFn: (input: UpdateSectionInput) => updateSection(slug, input),
onSuccess: (updated) => {
void queryClient.invalidateQueries({ queryKey: ["sections"] });
void queryClient.invalidateQueries({ queryKey: ["sections", slug] });
toastManager.add({ title: t`Section saved` });
// If slug changed, navigate to new URL
if (updated.slug !== slug) {
void navigate({ to: "/sections/$slug", params: { slug: updated.slug } });
}
},
onError: (mutationError: Error) => {
toastManager.add({
title: t`Error saving section`,
description: mutationError.message,
type: "error",
});
},
});
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader />
</div>
);
}
if (error || !section) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link to="/sections">
<Button variant="ghost" shape="square" aria-label={t`Back to sections`}>
<ArrowPrev className="h-5 w-5" />
</Button>
</Link>
<h1 className="text-2xl font-bold">{t`Section Not Found`}</h1>
</div>
<div className="rounded-lg border bg-kumo-base p-6">
<p className="text-kumo-subtle">
{error ? error.message : t`Section "${slug}" could not be found.`}
</p>
</div>
</div>
);
}
return (
<SectionEditorForm
key={section.updatedAt}
section={section}
isSaving={updateMutation.isPending}
onSave={(input) => updateMutation.mutate(input)}
/>
);
}
interface SectionEditorFormProps {
section: Section;
isSaving: boolean;
onSave: (input: UpdateSectionInput) => void;
}
function SectionEditorForm({ section, isSaving, onSave }: SectionEditorFormProps) {
const { t } = useLingui();
const [title, setTitle] = React.useState(section.title);
const [sectionSlug, setSectionSlug] = React.useState(section.slug);
const [slugTouched, setSlugTouched] = React.useState(true); // Existing sections have touched slugs
const [description, setDescription] = React.useState(section.description || "");
const [keywords, setKeywords] = React.useState(section.keywords.join(", "));
const [content, setContent] = React.useState<unknown[]>(section.content);
// Track initial state for dirty checking
const [lastSavedData] = React.useState(() =>
JSON.stringify({
title: section.title,
slug: section.slug,
description: section.description || "",
keywords: section.keywords.join(", "),
content: section.content,
}),
);
// Auto-generate slug from title if editing title and slug hasn't been manually changed
React.useEffect(() => {
if (!slugTouched && title && title !== section.title) {
setSectionSlug(slugify(title));
}
}, [title, slugTouched, section.title]);
const currentData = React.useMemo(
() => JSON.stringify({ title, slug: sectionSlug, description, keywords, content }),
[title, sectionSlug, description, keywords, content],
);
const isDirty = currentData !== lastSavedData;
// Block sidebar state populated when a node view (e.g. ImageNode) requests
// sidebar space.
const [blockSidebarPanel, setBlockSidebarPanel] = React.useState<BlockSidebarPanel | null>(null);
const handleBlockSidebarOpen = React.useCallback((panel: BlockSidebarPanel) => {
setBlockSidebarPanel(panel);
}, []);
const handleBlockSidebarClose = React.useCallback(() => {
setBlockSidebarPanel((prev) => {
prev?.onClose();
return null;
});
}, []);
const handleSave = () => {
const keywordsArray = keywords
.split(",")
.map((k) => k.trim())
.filter(Boolean);
onSave({
title,
slug: sectionSlug,
description: description || undefined,
keywords: keywordsArray,
content,
});
};
return (
<div className="space-y-6">
{/* Sticky header — keeps the Save action visible while scrolling
long PortableText content. */}
<EditorHeader
leading={
<Link to="/sections">
<Button variant="ghost" shape="square" aria-label={t`Back to sections`}>
<ArrowPrev className="h-5 w-5" />
</Button>
</Link>
}
actions={<SaveButton isSaving={isSaving} isDirty={isDirty} onClick={handleSave} />}
>
<h1 className="text-2xl font-bold truncate">{section.title}</h1>
<p className="text-sm text-kumo-subtle">
{section.source === "theme" ? t`Theme Section` : t`Custom Section`} &middot;{" "}
{section.slug}
</p>
</EditorHeader>
<div className="grid grid-cols-12 gap-6">
{/* Main content */}
<div className="col-span-8 space-y-6">
{/* Content editor */}
<div className="rounded-lg border bg-kumo-base p-6">
<Label className="text-lg font-semibold mb-4 block">{t`Content`}</Label>
<PortableTextEditor
value={content as Parameters<typeof PortableTextEditor>[0]["value"]}
onChange={(value) => setContent(value as unknown[])}
onBlockSidebarOpen={handleBlockSidebarOpen}
onBlockSidebarClose={handleBlockSidebarClose}
/>
</div>
</div>
{/* Sidebar */}
<div className="col-span-4 space-y-6">
{blockSidebarPanel?.type === "image" ? (
<ImageDetailPanel
attributes={blockSidebarPanel.attrs as unknown as ImageAttributes}
onUpdate={(attrs) =>
blockSidebarPanel.onUpdate(attrs as unknown as Record<string, unknown>)
}
onReplace={(attrs) =>
blockSidebarPanel.onReplace(attrs as unknown as Record<string, unknown>)
}
onDelete={() => {
blockSidebarPanel.onDelete();
setBlockSidebarPanel(null);
}}
onClose={handleBlockSidebarClose}
inline
/>
) : (
<>
{/* Metadata */}
<div className="rounded-lg border bg-kumo-base p-6 space-y-4">
<h2 className="text-lg font-semibold">{t`Section Details`}</h2>
<Input
label={t`Title`}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t`Section title`}
/>
<div>
<Input
label={t`Slug`}
value={sectionSlug}
onChange={(e) => {
setSectionSlug(e.target.value);
setSlugTouched(true);
}}
placeholder="section-slug"
pattern="[a-z0-9\-]+"
/>
<p className="text-xs text-kumo-subtle mt-1">
{t`Used to identify this section. Lowercase letters, numbers, and hyphens only.`}
</p>
</div>
<InputArea
label={t`Description`}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t`Describe what this section is for...`}
rows={3}
/>
<div>
<Input
label={t`Keywords`}
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
placeholder="hero, banner, cta"
/>
<p className="text-xs text-kumo-subtle mt-1">{t`Comma-separated keywords for search.`}</p>
</div>
</div>
{/* Source info */}
<div className="rounded-lg border bg-kumo-base p-6">
<h2 className="text-lg font-semibold mb-2">{t`Source`}</h2>
<p className="text-sm text-kumo-subtle">
{section.source === "theme" && (
<>
{t`This section is provided by the theme. Editing will create a custom copy that overrides the theme version.`}
</>
)}
{section.source === "user" && <>{t`This is a custom section.`}</>}
{section.source === "import" && (
<>{t`This section was imported from another system.`}</>
)}
</p>
{section.themeId && (
<p className="text-xs text-kumo-subtle mt-2">{t`Theme ID: ${section.themeId}`}</p>
)}
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,171 @@
/**
* Section Picker Modal
*
* A modal for selecting and inserting sections into content.
*/
import { Button, Dialog, Input } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { MagnifyingGlass, Stack, FolderOpen } from "@phosphor-icons/react";
import { X } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import * as React from "react";
import { fetchSections, type Section } from "../lib/api";
import { useDebouncedValue } from "../lib/hooks";
import { cn } from "../lib/utils";
interface SectionPickerModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (section: Section) => void;
}
export function SectionPickerModal({ open, onOpenChange, onSelect }: SectionPickerModalProps) {
const { t } = useLingui();
const [searchQuery, setSearchQuery] = React.useState("");
const debouncedSearch = useDebouncedValue(searchQuery, 300);
const { data: sectionsData, isLoading: sectionsLoading } = useQuery({
queryKey: ["sections", { search: debouncedSearch }],
queryFn: () =>
fetchSections({
search: debouncedSearch || undefined,
}),
enabled: open,
});
const sections = sectionsData?.items ?? [];
// Reset search when modal opens
React.useEffect(() => {
if (open) {
setSearchQuery("");
}
}, [open]);
const handleSelect = (section: Section) => {
onSelect(section);
onOpenChange(false);
};
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog className="p-6 max-w-3xl max-h-[80vh] flex flex-col" size="lg">
<div className="flex items-start justify-between gap-4 mb-4">
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight flex items-center gap-2">
<Stack className="h-5 w-5" />
{t`Insert Section`}
</Dialog.Title>
<Dialog.Close
aria-label={t`Close`}
render={(props) => (
<Button
{...props}
variant="ghost"
shape="square"
aria-label={t`Close`}
className="absolute end-4 top-4"
>
<X className="h-4 w-4" />
<span className="sr-only">{t`Close`}</span>
</Button>
)}
/>
</div>
{/* Search */}
<div className="flex items-center gap-4 py-4 border-b">
<div className="relative flex-1">
<MagnifyingGlass className="absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
<Input
placeholder={t`Search sections...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="ps-10"
autoFocus
/>
</div>
</div>
{/* Section grid */}
<div className="flex-1 overflow-y-auto py-4">
{sectionsLoading ? (
<div className="flex items-center justify-center h-32">
<div className="text-kumo-subtle">{t`Loading sections...`}</div>
</div>
) : sections.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-center">
{searchQuery ? (
<>
<MagnifyingGlass className="h-8 w-8 text-kumo-subtle mb-2" />
<p className="text-kumo-subtle">{t`No sections found`}</p>
<p className="text-sm text-kumo-subtle">{t`Try adjusting your search`}</p>
</>
) : (
<>
<FolderOpen className="h-8 w-8 text-kumo-subtle mb-2" />
<p className="text-kumo-subtle">{t`No sections available`}</p>
<p className="text-sm text-kumo-subtle">
{t`Create sections in the Sections library to use them here`}
</p>
</>
)}
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{sections.map((section) => (
<SectionCard
key={section.id}
section={section}
onSelect={() => handleSelect(section)}
/>
))}
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t`Cancel`}
</Button>
</div>
</Dialog>
</Dialog.Root>
);
}
function SectionCard({ section, onSelect }: { section: Section; onSelect: () => void }) {
return (
<button
type="button"
onClick={onSelect}
className={cn(
"text-start rounded-lg border bg-kumo-base overflow-hidden transition-colors",
"hover:border-kumo-brand hover:bg-kumo-tint/50",
"focus:outline-none focus:ring-2 focus:ring-kumo-ring focus:ring-offset-2",
)}
>
{/* Preview */}
<div className="aspect-video bg-kumo-tint flex items-center justify-center">
{section.previewUrl ? (
<img
src={section.previewUrl}
alt={section.title}
className="w-full h-full object-cover"
/>
) : (
<Stack className="h-8 w-8 text-kumo-subtle" />
)}
</div>
{/* Content */}
<div className="p-3">
<h4 className="font-medium truncate">{section.title}</h4>
{section.description && (
<p className="text-xs text-kumo-subtle line-clamp-2 mt-1">{section.description}</p>
)}
</div>
</button>
);
}

View File

@@ -0,0 +1,415 @@
/**
* Sections library page component
*
* Browse, create, and manage reusable content sections (block patterns).
*/
import { Button, Dialog, Input, InputArea, Toast } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import {
Plus,
MagnifyingGlass,
Trash,
PencilSimple,
Copy,
FolderOpen,
Globe,
User,
FileArrowDown,
} from "@phosphor-icons/react";
import { X } from "@phosphor-icons/react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import * as React from "react";
import {
fetchSections,
createSection,
deleteSection,
type Section,
type SectionSource,
} from "../lib/api";
import { slugify } from "../lib/utils";
import { ConfirmDialog } from "./ConfirmDialog.js";
import { DialogError, getMutationError } from "./DialogError.js";
const sourceIcons: Record<SectionSource, React.ElementType> = {
theme: Globe,
user: User,
import: FileArrowDown,
};
const sourceLabels: Record<SectionSource, string> = {
theme: "Theme",
user: "Custom",
import: "Imported",
};
export function Sections() {
const { t } = useLingui();
const navigate = useNavigate();
const queryClient = useQueryClient();
const toastManager = Toast.useToastManager();
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
const [deleteSlug, setDeleteSlug] = React.useState<string | null>(null);
const [searchQuery, setSearchQuery] = React.useState("");
const [selectedSource, setSelectedSource] = React.useState<SectionSource | null>(null);
// Create form state
const [createTitle, setCreateTitle] = React.useState("");
const [createSlug, setCreateSlug] = React.useState("");
const [createDescription, setCreateDescription] = React.useState("");
const [slugTouched, setSlugTouched] = React.useState(false);
const [createError, setCreateError] = React.useState<string | null>(null);
// Reset form when dialog closes
React.useEffect(() => {
if (!isCreateOpen) {
setCreateTitle("");
setCreateSlug("");
setCreateDescription("");
setSlugTouched(false);
setCreateError(null);
}
}, [isCreateOpen]);
const { data: sectionsData, isLoading: sectionsLoading } = useQuery({
queryKey: ["sections", { source: selectedSource, search: searchQuery }],
queryFn: () =>
fetchSections({
source: selectedSource || undefined,
search: searchQuery || undefined,
}),
});
const sections = sectionsData?.items ?? [];
const createMutation = useMutation({
mutationFn: createSection,
onSuccess: (section) => {
void queryClient.invalidateQueries({ queryKey: ["sections"] });
setIsCreateOpen(false);
toastManager.add({ title: t`Section created` });
// Navigate to edit the new section
void navigate({ to: "/sections/$slug", params: { slug: section.slug } });
},
onError: (error: Error) => {
setCreateError(error.message);
},
});
const deleteMutation = useMutation({
mutationFn: deleteSection,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["sections"] });
setDeleteSlug(null);
toastManager.add({ title: t`Section deleted` });
},
});
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setCreateError(null);
createMutation.mutate({
slug: createSlug,
title: createTitle,
description: createDescription || undefined,
content: [], // Start with empty content
});
};
const handleCopySlug = (slug: string) => {
void navigator.clipboard.writeText(slug);
toastManager.add({ title: t`Slug copied to clipboard` });
};
const sectionToDelete = sections.find((s) => s.slug === deleteSlug);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">{t`Sections`}</h1>
<p className="text-kumo-subtle">
{t`Reusable content blocks you can insert into any content`}
</p>
</div>
<Dialog.Root open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<Dialog.Trigger
render={(props) => (
<Button {...props} icon={<Plus />}>
{t`New Section`}
</Button>
)}
/>
<Dialog className="p-6" size="lg">
<div className="flex items-start justify-between gap-4 mb-4">
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
{t`Create Section`}
</Dialog.Title>
<Dialog.Close
aria-label={t`Close`}
render={(props) => (
<Button
{...props}
variant="ghost"
shape="square"
aria-label={t`Close`}
className="absolute end-4 top-4"
>
<X className="h-4 w-4" />
<span className="sr-only">{t`Close`}</span>
</Button>
)}
/>
</div>
<form onSubmit={handleCreate} className="space-y-4">
<Input
label={t`Title`}
value={createTitle}
onChange={(e) => {
const title = e.target.value;
setCreateTitle(title);
if (!slugTouched && title) {
setCreateSlug(slugify(title));
}
}}
required
placeholder="Hero Banner"
/>
<div>
<Input
label={t`Slug`}
value={createSlug}
onChange={(e) => {
setCreateSlug(e.target.value);
setSlugTouched(true);
}}
required
placeholder="hero-banner"
pattern="[a-z0-9\-]+"
title={t`Lowercase letters, numbers, and hyphens only`}
/>
<p className="text-xs text-kumo-subtle mt-1">
{t`Used to identify this section. Lowercase letters, numbers, and hyphens only.`}
</p>
</div>
<InputArea
label={t`Description`}
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="A full-width hero banner with heading, text, and CTA button"
rows={3}
/>
<DialogError message={createError || getMutationError(createMutation.error)} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setIsCreateOpen(false)}>
{t`Cancel`}
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? t`Creating...` : t`Create`}
</Button>
</div>
</form>
</Dialog>
</Dialog.Root>
</div>
{/* Filters */}
<div className="flex items-center gap-4">
{/* Search */}
<div className="relative flex-1 max-w-md">
<MagnifyingGlass className="absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
<Input
placeholder={t`Search sections...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="ps-10"
/>
</div>
{/* Source filter */}
<select
value={selectedSource || ""}
onChange={(e) => {
const val = e.target.value;
setSelectedSource(val === "theme" || val === "user" || val === "import" ? val : null);
}}
className="h-10 rounded-md border border-kumo-line bg-kumo-base px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-kumo-ring focus:ring-offset-2"
>
<option value="">{t`All Sources`}</option>
<option value="theme">{t`Theme`}</option>
<option value="user">{t`Custom`}</option>
<option value="import">{t`Imported`}</option>
</select>
</div>
{/* Section Grid */}
{sectionsLoading ? (
<div className="flex items-center justify-center h-64">
<div className="text-kumo-subtle">{t`Loading sections...`}</div>
</div>
) : sections.length === 0 ? (
<div className="rounded-lg border bg-kumo-base p-12 text-center">
{searchQuery || selectedSource ? (
<>
<MagnifyingGlass className="mx-auto h-12 w-12 text-kumo-subtle" />
<h3 className="mt-4 text-lg font-semibold">{t`No sections found`}</h3>
<p className="mt-2 text-kumo-subtle">{t`Try adjusting your search or filters.`}</p>
</>
) : (
<>
<FolderOpen className="mx-auto h-12 w-12 text-kumo-subtle" />
<h3 className="mt-4 text-lg font-semibold">{t`No sections yet`}</h3>
<p className="mt-2 text-kumo-subtle">
{t`Create your first reusable content section to get started.`}
</p>
<Button className="mt-4" icon={<Plus />} onClick={() => setIsCreateOpen(true)}>
{t`Create Section`}
</Button>
</>
)}
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{sections.map((section) => (
<SectionCard
key={section.id}
section={section}
onEdit={() => navigate({ to: "/sections/$slug", params: { slug: section.slug } })}
onDelete={() => setDeleteSlug(section.slug)}
onCopySlug={() => handleCopySlug(section.slug)}
/>
))}
</div>
)}
{/* Delete confirmation */}
<ConfirmDialog
open={!!deleteSlug}
onClose={() => {
setDeleteSlug(null);
deleteMutation.reset();
}}
title={t`Delete Section?`}
description={
sectionToDelete?.source === "theme" ? (
<>
{t`Theme-provided sections cannot be deleted. Edit the section to create a custom copy, then delete that.`}
</>
) : (
<>
{t`This will permanently delete "${sectionToDelete?.title}". This action cannot be undone.`}
</>
)
}
confirmLabel={t`Delete`}
pendingLabel={t`Deleting...`}
isPending={deleteMutation.isPending}
error={deleteMutation.error}
onConfirm={() => deleteSlug && deleteMutation.mutate(deleteSlug)}
/>
</div>
);
}
function SectionCard({
section,
onEdit,
onDelete,
onCopySlug,
}: {
section: Section;
onEdit: () => void;
onDelete: () => void;
onCopySlug: () => void;
}) {
const { t } = useLingui();
const SourceIcon = sourceIcons[section.source];
return (
<div className="rounded-lg border bg-kumo-base overflow-hidden">
{/* Preview area */}
<div className="aspect-video bg-kumo-tint flex items-center justify-center">
{section.previewUrl ? (
<img
src={section.previewUrl}
alt={section.title}
className="w-full h-full object-cover"
/>
) : (
<div className="text-kumo-subtle text-sm">{t`No preview`}</div>
)}
</div>
{/* Content */}
<div className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{section.title}</h3>
<p className="text-sm text-kumo-subtle truncate">{section.slug}</p>
</div>
<div
className="flex items-center gap-1 text-xs text-kumo-subtle"
title={sourceLabels[section.source]}
>
<SourceIcon className="h-3 w-3" />
<span>{sourceLabels[section.source]}</span>
</div>
</div>
{section.description && (
<p className="mt-2 text-sm text-kumo-subtle line-clamp-2">{section.description}</p>
)}
{section.keywords.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{section.keywords.slice(0, 3).map((keyword) => (
<span
key={keyword}
className="inline-flex items-center rounded bg-kumo-tint px-1.5 py-0.5 text-xs text-kumo-subtle"
>
{keyword}
</span>
))}
{section.keywords.length > 3 && (
<span className="text-xs text-kumo-subtle">+{section.keywords.length - 3} more</span>
)}
</div>
)}
{/* Actions */}
<div className="mt-4 flex items-center gap-2">
<Button
variant="outline"
size="sm"
icon={<PencilSimple />}
onClick={onEdit}
className="flex-1"
>
{t`Edit`}
</Button>
<Button
variant="ghost"
size="sm"
onClick={onCopySlug}
title={t`Copy slug`}
aria-label={t`Copy ${section.slug} to clipboard`}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onDelete}
title={section.source === "theme" ? t`Cannot delete theme sections` : t`Delete`}
aria-label={t`Delete ${section.title}`}
disabled={section.source === "theme"}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
/**
* SEO OG Image field for the content editor.
*
* Renders an image picker (reusing MediaPickerModal) that stores the
* selected image URL in `seo.image`. Designed to sit next to the
* Featured Image field in a two-column grid.
*/
import { Button, Label } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { Image as ImageIcon, X } from "@phosphor-icons/react";
import * as React from "react";
import type { ContentSeo, ContentSeoInput, MediaItem } from "../lib/api";
import { MediaPickerModal } from "./MediaPickerModal";
export interface SeoImageFieldProps {
seo?: ContentSeo;
onChange: (seo: ContentSeoInput) => void;
}
export function SeoImageField({ seo, onChange }: SeoImageFieldProps) {
const { t } = useLingui();
const [pickerOpen, setPickerOpen] = React.useState(false);
const imageUrl = seo?.image || null;
const handleSelect = (item: MediaItem) => {
const isLocalProvider = !item.provider || item.provider === "local";
const url = isLocalProvider
? `/_emdash/api/media/file/${item.storageKey || item.id}`
: item.url;
onChange({ image: url });
};
const handleRemove = () => {
onChange({ image: null });
};
return (
<div>
<Label>{t`OG Image`}</Label>
{imageUrl ? (
<div className="mt-2 relative group">
<img src={imageUrl} alt="" className="max-h-48 rounded-lg border object-cover" />
<div className="absolute top-2 end-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<Button type="button" size="sm" variant="secondary" onClick={() => setPickerOpen(true)}>
{t`Change`}
</Button>
<Button
type="button"
shape="square"
variant="destructive"
className="h-8 w-8"
onClick={handleRemove}
aria-label={t`Remove image`}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="outline"
className="mt-2 w-full h-32 border-dashed"
onClick={() => setPickerOpen(true)}
>
<div className="flex flex-col items-center gap-2 text-kumo-subtle">
<ImageIcon className="h-8 w-8" />
<span>{t`Select OG image`}</span>
</div>
</Button>
)}
<p className="text-xs text-kumo-subtle mt-1">
{t`Image shown when this page is shared on social media`}
</p>
<MediaPickerModal
open={pickerOpen}
onOpenChange={setPickerOpen}
onSelect={handleSelect}
mimeTypeFilter="image/"
title={t`Select OG Image`}
/>
</div>
);
}

View File

@@ -0,0 +1,200 @@
/**
* SEO Panel for Content Editor Sidebar
*
* Shows SEO metadata fields (title, description, OG image, canonical URL,
* noIndex) when the collection has `hasSeo` enabled. Changes are sent
* alongside content updates via the `seo` field on the update body.
*/
import { Input, InputArea, Label, Switch } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import * as React from "react";
import type { ContentSeo, ContentSeoInput } from "../lib/api";
export interface SeoPanelProps {
contentKey: string;
seo?: ContentSeo;
onChange: (seo: ContentSeoInput) => void;
}
const SEO_TEXT_DEBOUNCE_MS = 500;
interface SeoDraft {
title: string;
description: string;
canonical: string;
noIndex: boolean;
}
function toDraft(seo?: ContentSeo): SeoDraft {
return {
title: seo?.title ?? "",
description: seo?.description ?? "",
canonical: seo?.canonical ?? "",
noIndex: seo?.noIndex ?? false,
};
}
function toInput(draft: SeoDraft): ContentSeoInput {
return {
title: draft.title || null,
description: draft.description || null,
canonical: draft.canonical || null,
noIndex: draft.noIndex,
};
}
function serializeDraft(draft: SeoDraft): string {
return JSON.stringify(draft);
}
export function SeoPanel({ contentKey, seo, onChange }: SeoPanelProps) {
const { t } = useLingui();
const propDraft = React.useMemo(() => toDraft(seo), [seo]);
const propSnapshot = React.useMemo(() => serializeDraft(propDraft), [propDraft]);
const [draft, setDraft] = React.useState<SeoDraft>(propDraft);
const currentDraftRef = React.useRef(draft);
currentDraftRef.current = draft;
const lastPropSnapshotRef = React.useRef(propSnapshot);
const lastEmittedSnapshotRef = React.useRef(propSnapshot);
const activeContentKeyRef = React.useRef(contentKey);
const activeOnChangeRef = React.useRef(onChange);
const pendingTextFlushTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const emitChange = React.useCallback((nextDraft: SeoDraft) => {
const nextSnapshot = serializeDraft(nextDraft);
if (nextSnapshot === lastEmittedSnapshotRef.current) {
return;
}
lastEmittedSnapshotRef.current = nextSnapshot;
activeOnChangeRef.current(toInput(nextDraft));
}, []);
const clearPendingTextFlush = React.useCallback(() => {
if (pendingTextFlushTimerRef.current) {
clearTimeout(pendingTextFlushTimerRef.current);
pendingTextFlushTimerRef.current = null;
}
}, []);
const flushPendingDraft = React.useCallback(() => {
clearPendingTextFlush();
emitChange(currentDraftRef.current);
}, [clearPendingTextFlush, emitChange]);
React.useEffect(() => {
if (activeContentKeyRef.current === contentKey) {
activeOnChangeRef.current = onChange;
return;
}
flushPendingDraft();
activeContentKeyRef.current = contentKey;
activeOnChangeRef.current = onChange;
setDraft(propDraft);
currentDraftRef.current = propDraft;
lastPropSnapshotRef.current = propSnapshot;
lastEmittedSnapshotRef.current = propSnapshot;
}, [contentKey, flushPendingDraft, onChange, propDraft, propSnapshot]);
React.useEffect(() => {
return () => {
flushPendingDraft();
};
}, [flushPendingDraft]);
React.useEffect(() => {
const previousPropSnapshot = lastPropSnapshotRef.current;
if (propSnapshot === previousPropSnapshot) {
return;
}
const currentDraftSnapshot = serializeDraft(currentDraftRef.current);
const shouldSync =
currentDraftSnapshot === previousPropSnapshot || currentDraftSnapshot === propSnapshot;
if (shouldSync) {
setDraft(propDraft);
currentDraftRef.current = propDraft;
lastEmittedSnapshotRef.current = propSnapshot;
}
lastPropSnapshotRef.current = propSnapshot;
}, [propDraft, propSnapshot]);
React.useEffect(() => {
clearPendingTextFlush();
const nextSnapshot = serializeDraft(currentDraftRef.current);
if (nextSnapshot === lastEmittedSnapshotRef.current) {
return;
}
pendingTextFlushTimerRef.current = setTimeout(() => {
pendingTextFlushTimerRef.current = null;
emitChange(currentDraftRef.current);
}, SEO_TEXT_DEBOUNCE_MS);
return clearPendingTextFlush;
}, [clearPendingTextFlush, draft.canonical, draft.description, draft.title, emitChange]);
const updateDraft = (patch: Partial<SeoDraft>) => {
const nextDraft = { ...currentDraftRef.current, ...patch };
currentDraftRef.current = nextDraft;
setDraft(nextDraft);
return nextDraft;
};
return (
<div className="space-y-3">
<Input
label={t`SEO Title`}
description={t`Overrides the page title in search engine results`}
value={draft.title}
onChange={(e) => {
updateDraft({ title: e.target.value });
}}
dir="auto"
/>
<div>
<InputArea
label={t`Meta Description`}
description={
draft.description
? t`${draft.description.length}/160 characters`
: t`Brief summary shown below the title in search results`
}
value={draft.description}
onChange={(e) => {
updateDraft({ description: e.target.value });
}}
rows={3}
dir="auto"
/>
</div>
<Input
label={t`Canonical URL`}
description={t`Points search engines to the original version of this page, if it's duplicated from another URL`}
value={draft.canonical}
onChange={(e) => {
updateDraft({ canonical: e.target.value });
}}
/>
<div className="flex items-center justify-between pt-1">
<div>
<Label>{t`Hide from search engines`}</Label>
<p className="text-xs text-kumo-subtle">{t`Add noindex meta tag`}</p>
</div>
<Switch
checked={draft.noIndex}
onCheckedChange={(checked) => {
emitChange(updateDraft({ noIndex: checked }));
}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,147 @@
import { Select } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import {
Gear,
ShareNetwork,
MagnifyingGlass,
Shield,
Globe,
GlobeSimple,
Key,
Envelope,
} from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as React from "react";
import { fetchManifest } from "../lib/api";
import { SUPPORTED_LOCALES } from "../locales/index.js";
import { useLocale } from "../locales/useLocale.js";
import { CaretNext } from "./ArrowIcons.js";
interface SettingsLinkProps {
to: string;
icon: React.ReactNode;
title: string;
description: string;
}
function SettingsLink({ to, icon, title, description }: SettingsLinkProps) {
return (
<Link
to={to}
className="flex items-center justify-between p-4 rounded-lg border bg-kumo-base hover:bg-kumo-tint transition-colors"
>
<div className="flex items-center gap-3">
<div className="text-kumo-subtle">{icon}</div>
<div>
<div className="font-medium">{title}</div>
<div className="text-sm text-kumo-subtle">{description}</div>
</div>
</div>
<CaretNext className="h-5 w-5 text-kumo-subtle" />
</Link>
);
}
/**
* Settings hub page — links to all settings sub-pages.
*/
export function Settings() {
const { data: manifest } = useQuery({
queryKey: ["manifest"],
queryFn: fetchManifest,
});
const { t } = useLingui();
const { locale, setLocale } = useLocale();
const showSecuritySettings = manifest?.authMode === "passkey";
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t`Settings`}</h1>
{/* Site settings */}
<div className="space-y-2">
<SettingsLink
to="/settings/general"
icon={<Gear className="h-5 w-5" />}
title={t`General`}
description={t`Site identity, logo, favicon, and reading preferences`}
/>
<SettingsLink
to="/settings/social"
icon={<ShareNetwork className="h-5 w-5" />}
title={t`Social Links`}
description={t`Social media profile links`}
/>
<SettingsLink
to="/settings/seo"
icon={<MagnifyingGlass className="h-5 w-5" />}
title={t`SEO`}
description={t`Search engine optimization and verification`}
/>
</div>
{/* Security & access — only for passkey auth */}
{showSecuritySettings && (
<div className="space-y-2">
<SettingsLink
to="/settings/security"
icon={<Shield className="h-5 w-5" />}
title={t`Security`}
description={t`Manage your passkeys and authentication`}
/>
<SettingsLink
to="/settings/allowed-domains"
icon={<Globe className="h-5 w-5" />}
title={t`Self-Signup Domains`}
description={t`Allow users from specific domains to sign up`}
/>
</div>
)}
{/* Always visible for admins */}
<div className="space-y-2">
<SettingsLink
to="/settings/api-tokens"
icon={<Key className="h-5 w-5" />}
title={t`API Tokens`}
description={t`Create personal access tokens for programmatic API access`}
/>
<SettingsLink
to="/settings/email"
icon={<Envelope className="h-5 w-5" />}
title={t`Email`}
description={t`View email provider status and send test emails`}
/>
</div>
{/* Language */}
{SUPPORTED_LOCALES.length > 1 && (
<div className="space-y-2">
<div className="flex items-center justify-between p-4 rounded-lg border bg-kumo-base">
<div className="flex items-center gap-3">
<div className="text-kumo-subtle">
<GlobeSimple className="h-5 w-5" />
</div>
<div>
<div className="font-medium">{t`Language`}</div>
<div className="text-sm text-kumo-subtle">{t`Choose your preferred admin language`}</div>
</div>
</div>
<Select
aria-label={t`Language`}
className="w-45"
value={locale}
onValueChange={(v) => v && setLocale(v)}
items={Object.fromEntries(SUPPORTED_LOCALES.map((l) => [l.code, l.label]))}
/>
</div>
</div>
)}
</div>
);
}
export default Settings;

View File

@@ -0,0 +1,628 @@
/**
* Setup Wizard - Multi-step first-run setup page
*
* This component is NOT wrapped in the admin Shell.
* It's a standalone page for initial site configuration.
*
* Steps:
* 1. Site Configuration (title, tagline, sample content)
* 2. Create admin account — user picks any available auth method:
* - Passkey (always available)
* - Any configured auth provider (AT Protocol, GitHub, Google, etc.)
*/
import { Button, Checkbox, Input, Loader } from "@cloudflare/kumo";
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { useMutation, useQuery } from "@tanstack/react-query";
import * as React from "react";
import { apiFetch, fetchManifest, parseApiResponse } from "../lib/api/client";
import { useAuthProviderList, type AuthProviderModule } from "../lib/auth-provider-context";
import { PasskeyRegistration } from "./auth/PasskeyRegistration";
import { BrandLogo } from "./Logo.js";
// ============================================================================
// Types
// ============================================================================
interface SetupStatusResponse {
needsSetup: boolean;
step?: "start" | "site" | "admin" | "complete";
seedInfo?: {
name: string;
description: string;
collections: number;
hasContent: boolean;
title?: string;
tagline?: string;
};
/** Auth mode - "cloudflare-access" or "passkey" */
authMode?: "cloudflare-access" | "passkey";
}
interface SetupSiteRequest {
title: string;
tagline?: string;
includeContent: boolean;
}
interface SetupSiteResponse {
success: boolean;
error?: string;
/** In Access mode, setup is complete after site config */
setupComplete?: boolean;
result?: {
collections: { created: number; skipped: number };
fields: { created: number; skipped: number };
taxonomies: { created: number; terms: number };
menus: { created: number; items: number };
widgetAreas: { created: number; widgets: number };
settings: { applied: number };
content: { created: number; skipped: number };
};
}
interface SetupAdminRequest {
email: string;
name?: string;
}
interface SetupAdminResponse {
success: boolean;
error?: string;
options?: unknown; // WebAuthn registration options
}
type WizardStep = "site" | "admin" | "passkey";
// ============================================================================
// API Functions
// ============================================================================
async function fetchSetupStatus(): Promise<SetupStatusResponse> {
const response = await apiFetch("/_emdash/api/setup/status");
return parseApiResponse<SetupStatusResponse>(response, "Failed to fetch setup status");
}
async function executeSiteSetup(data: SetupSiteRequest): Promise<SetupSiteResponse> {
const response = await apiFetch("/_emdash/api/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return parseApiResponse<SetupSiteResponse>(response, "Setup failed");
}
async function executeAdminSetup(data: SetupAdminRequest): Promise<SetupAdminResponse> {
const response = await apiFetch("/_emdash/api/setup/admin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return parseApiResponse<SetupAdminResponse>(response, "Failed to create admin");
}
// ============================================================================
// Step Components
// ============================================================================
interface SiteStepProps {
seedInfo?: SetupStatusResponse["seedInfo"];
onNext: (data: SetupSiteRequest) => void;
isLoading: boolean;
error?: string;
}
function SiteStep({ seedInfo, onNext, isLoading, error }: SiteStepProps) {
const { t } = useLingui();
const [title, setTitle] = React.useState(seedInfo?.title ?? "");
const [tagline, setTagline] = React.useState(seedInfo?.tagline ?? "");
const [includeContent, setIncludeContent] = React.useState(true);
const [errors, setErrors] = React.useState<Record<string, string>>({});
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!title.trim()) {
newErrors.title = t`Site title is required`;
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
onNext({ title, tagline, includeContent });
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-4">
<Input
label={t`Site Title`}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t`My Awesome Blog`}
className={errors.title ? "border-kumo-danger" : ""}
disabled={isLoading}
/>
{errors.title && <p className="text-sm text-kumo-danger mt-1">{errors.title}</p>}
<Input
label={t`Tagline`}
type="text"
value={tagline}
onChange={(e) => setTagline(e.target.value)}
placeholder={t`Thoughts, tutorials, and more`}
disabled={isLoading}
/>
</div>
{seedInfo?.hasContent && (
<Checkbox
label={t`Include sample content (recommended for new sites)`}
checked={includeContent}
onCheckedChange={(checked) => setIncludeContent(checked)}
disabled={isLoading}
/>
)}
{error && (
<div className="rounded-lg bg-kumo-danger/10 p-4 text-sm text-kumo-danger">{error}</div>
)}
<Button type="submit" className="w-full justify-center" loading={isLoading} variant="primary">
{isLoading ? <>{t`Setting up...`}</> : t`Continue →`}
</Button>
{seedInfo && (
<p className="text-xs text-kumo-subtle text-center">
{t`Template:`} {seedInfo.name} (
{plural(seedInfo.collections, { one: "# collection", other: "# collections" })})
</p>
)}
</form>
);
}
interface AdminStepProps {
onNext: (data: SetupAdminRequest) => void;
onBack: () => void;
isLoading: boolean;
error?: string;
}
function AdminStep({ onNext, onBack, isLoading, error }: AdminStepProps) {
const { t } = useLingui();
const [email, setEmail] = React.useState("");
const [name, setName] = React.useState("");
const [errors, setErrors] = React.useState<Record<string, string>>({});
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!email.trim()) {
newErrors.email = t`Email is required`;
} else if (!email.includes("@")) {
newErrors.email = t`Please enter a valid email`;
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
onNext({ email, name: name || undefined });
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-4">
<Input
label={t`Your Email`}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t`you@example.com`}
className={errors.email ? "border-kumo-danger" : ""}
disabled={isLoading}
autoComplete="email"
/>
{errors.email && <p className="text-sm text-kumo-danger mt-1">{errors.email}</p>}
<Input
label={t`Your Name`}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t`Jane Doe`}
disabled={isLoading}
autoComplete="name"
/>
</div>
{error && (
<div className="rounded-lg bg-kumo-danger/10 p-4 text-sm text-kumo-danger">{error}</div>
)}
<div className="flex gap-3">
<Button type="button" variant="outline" onClick={onBack} disabled={isLoading}>
{t`← Back`}
</Button>
<Button
type="submit"
className="flex-1 justify-center"
loading={isLoading}
variant="primary"
>
{isLoading ? <>{t`Preparing...`}</> : t`Continue →`}
</Button>
</div>
</form>
);
}
function handleSetupSuccess() {
window.location.href = "/_emdash/admin";
}
interface AuthMethodStepProps {
adminData: SetupAdminRequest;
providers: AuthProviderModule[];
onBack: () => void;
}
function AuthMethodStep({ adminData, providers, onBack }: AuthMethodStepProps) {
const { t } = useLingui();
const [activeProvider, setActiveProvider] = React.useState<string | null>(null);
const buttonProviders = providers.filter((p) => p.LoginButton);
const hasProviders = buttonProviders.length > 0;
// Show provider form (full card replacement)
if (activeProvider) {
const provider = providers.find((p) => p.id === activeProvider);
if (provider && (provider.SetupStep || provider.LoginForm)) {
return (
<div className="space-y-4">
<div className="text-center mb-2">
<h3 className="text-lg font-medium">{t`Sign in with ${provider.label}`}</h3>
</div>
{provider.SetupStep ? (
<provider.SetupStep onComplete={handleSetupSuccess} />
) : provider.LoginForm ? (
<provider.LoginForm />
) : null}
<Button
type="button"
variant="ghost"
className="w-full justify-center"
onClick={() => setActiveProvider(null)}
>
{t`← Back`}
</Button>
</div>
);
}
}
return (
<div className="space-y-6">
{/* Passkey option */}
<div className="text-center">
<h3 className="text-lg font-medium">{t`Choose how to sign in`}</h3>
<p className="text-sm text-kumo-subtle mt-1">
{t`Pick any method to create your admin account.`}
</p>
</div>
<PasskeyRegistration
optionsEndpoint="/_emdash/api/setup/admin"
verifyEndpoint="/_emdash/api/setup/admin/verify"
onSuccess={handleSetupSuccess}
buttonText={t`Create Passkey`}
additionalData={{ ...adminData }}
/>
{/* Auth provider options */}
{hasProviders && (
<>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-kumo-base px-2 text-kumo-subtle">Or continue with</span>
</div>
</div>
<div
className={`grid gap-3 ${buttonProviders.length === 1 ? "grid-cols-1" : "grid-cols-2"}`}
>
{buttonProviders.map((provider) => {
const Btn = provider.LoginButton!;
const hasForm = !!provider.LoginForm || !!provider.SetupStep;
const selectProvider = () => setActiveProvider(provider.id);
return (
<div key={provider.id} onClick={hasForm ? selectProvider : undefined}>
<Btn />
</div>
);
})}
</div>
</>
)}
<Button type="button" variant="ghost" onClick={onBack} className="w-full">
{t`← Back`}
</Button>
</div>
);
}
// ============================================================================
// Progress Indicator
// ============================================================================
interface StepIndicatorProps {
currentStep: WizardStep;
useAccessAuth?: boolean;
}
function StepIndicator({ currentStep, useAccessAuth }: StepIndicatorProps) {
const { t } = useLingui();
// In Access mode, only show the site step
const steps = useAccessAuth
? ([{ key: "site", label: t`Site Settings` }] as const)
: ([
{ key: "site", label: t`Site` },
{ key: "admin", label: t`Account` },
{ key: "passkey", label: t`Sign In` },
] as const);
const currentIndex = steps.findIndex((s) => s.key === currentStep);
return (
<div className="flex items-center justify-center mb-8">
{steps.map((step, index) => (
<React.Fragment key={step.key}>
<div className="flex items-center">
<div
className={`
w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium
${
index < currentIndex
? "bg-kumo-brand text-white"
: index === currentIndex
? "bg-kumo-brand text-white"
: "bg-kumo-tint text-kumo-subtle"
}
`}
>
{index < currentIndex ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
) : (
index + 1
)}
</div>
<span
className={`ms-2 text-sm ${
index <= currentIndex ? "text-kumo-default" : "text-kumo-subtle"
}`}
>
{step.label}
</span>
</div>
{index < steps.length - 1 && (
<div
className={`w-12 h-0.5 mx-2 ${index < currentIndex ? "bg-kumo-brand" : "bg-kumo-tint"}`}
/>
)}
</React.Fragment>
))}
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function SetupWizard() {
const [currentStep, setCurrentStep] = React.useState<WizardStep>("site");
const [_siteData, setSiteData] = React.useState<SetupSiteRequest | null>(null);
const [adminData, setAdminData] = React.useState<SetupAdminRequest | null>(null);
const [error, setError] = React.useState<string | undefined>();
const [urlError, setUrlError] = React.useState<string | null>(null);
// Auth provider components from virtual module (via context)
const authProviderList = useAuthProviderList();
// Check for error in URL (from OAuth/provider redirect)
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
const errorParam = params.get("error");
const message = params.get("message");
if (errorParam) {
setUrlError(message || `Authentication error: ${errorParam}`);
// Clean up URL
window.history.replaceState({}, "", window.location.pathname);
}
}, []);
// Check setup status
const {
data: status,
isLoading: statusLoading,
error: statusError,
} = useQuery({
queryKey: ["setup", "status"],
queryFn: fetchSetupStatus,
retry: false,
});
// Fetch manifest for admin branding
const { data: manifest } = useQuery({
queryKey: ["manifest"],
queryFn: fetchManifest,
});
// Check if using Cloudflare Access auth
const useAccessAuth = status?.authMode === "cloudflare-access";
// Site setup mutation
const siteMutation = useMutation({
mutationFn: executeSiteSetup,
onSuccess: (data) => {
setError(undefined);
// In Access mode, setup is complete - redirect to admin
if (data.setupComplete) {
window.location.href = "/_emdash/admin";
return;
}
// Continue to admin account creation
setCurrentStep("admin");
},
onError: (err: Error) => {
setError(err.message);
},
});
// Admin setup mutation
const adminMutation = useMutation({
mutationFn: executeAdminSetup,
onSuccess: () => {
setError(undefined);
setCurrentStep("passkey");
},
onError: (err: Error) => {
setError(err.message);
},
});
// Handle site step completion
const handleSiteNext = (data: SetupSiteRequest) => {
setSiteData(data);
siteMutation.mutate(data);
};
// Handle admin step completion
const handleAdminNext = (data: SetupAdminRequest) => {
setAdminData(data);
adminMutation.mutate(data);
};
// Redirect if setup already complete
if (!statusLoading && status && !status.needsSetup) {
window.location.href = "/_emdash/admin";
return null;
}
const { t } = useLingui();
// Loading state
if (statusLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-kumo-base">
<div className="text-center">
<Loader />
<p className="mt-4 text-kumo-subtle">{t`Loading setup...`}</p>
</div>
</div>
);
}
// Error state
if (statusError) {
return (
<div className="min-h-screen flex items-center justify-center bg-kumo-base">
<div className="text-center">
<h1 className="text-xl font-bold text-kumo-danger">{t`Error`}</h1>
<p className="mt-2 text-kumo-subtle">
{statusError instanceof Error ? statusError.message : t`Failed to load setup`}
</p>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-kumo-base p-4">
<div className="w-full max-w-lg">
{/* Header */}
<div className="text-center mb-6">
<BrandLogo
logoUrl={manifest?.admin?.logo}
siteName={manifest?.admin?.siteName}
className="h-10 mx-auto mb-2"
/>
<h1 className="text-2xl font-semibold text-kumo-default">
{currentStep === "site" && t`Set up your site`}
{currentStep === "admin" && t`Create your account`}
{currentStep === "passkey" && t`Secure your account`}
</h1>
{useAccessAuth && currentStep === "site" && (
<p className="text-sm text-kumo-subtle mt-2">{t`You're signed in via Cloudflare Access`}</p>
)}
</div>
{/* Error from URL (provider failure) */}
{urlError && (
<div className="mb-6 rounded-lg bg-kumo-danger/10 border border-kumo-danger/20 p-4 text-sm text-kumo-danger">
{urlError}
</div>
)}
{/* Progress */}
<StepIndicator currentStep={currentStep} useAccessAuth={useAccessAuth} />
{/* Form Card */}
<div className="bg-kumo-base border rounded-lg shadow-sm p-6">
{currentStep === "site" && (
<SiteStep
seedInfo={status?.seedInfo}
onNext={handleSiteNext}
isLoading={siteMutation.isPending}
error={error}
/>
)}
{currentStep === "admin" && (
<AdminStep
onNext={handleAdminNext}
onBack={() => {
setError(undefined);
setCurrentStep("site");
}}
isLoading={adminMutation.isPending}
error={error}
/>
)}
{currentStep === "passkey" && adminData && (
<AuthMethodStep
adminData={adminData}
providers={authProviderList}
onBack={() => {
setError(undefined);
setCurrentStep("admin");
}}
/>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import * as React from "react";
import { useCurrentUser } from "../lib/api/current-user";
import { AdminCommandPalette } from "./AdminCommandPalette";
import { Header } from "./Header";
import { Sidebar, SidebarNav } from "./Sidebar";
import { WelcomeModal } from "./WelcomeModal";
export interface ShellProps {
children: React.ReactNode;
manifest: {
collections: Record<string, { label: string }>;
plugins: Record<
string,
{
package?: string;
adminPages?: Array<{ path: string; label?: string; icon?: string }>;
}
>;
taxonomies: Array<{
name: string;
label: string;
}>;
version?: string;
};
}
/**
* Admin shell layout with kumo Sidebar component.
*
* Sidebar.Provider wraps both the sidebar and main content area,
* handling collapse state, mobile detection, and layout transitions.
*/
export function Shell({ children, manifest }: ShellProps) {
const [welcomeModalOpen, setWelcomeModalOpen] = React.useState(false);
const { data: user } = useCurrentUser();
// Show welcome modal on first login
React.useEffect(() => {
if (user?.isFirstLogin) {
setWelcomeModalOpen(true);
}
}, [user?.isFirstLogin]);
return (
<Sidebar.Provider
defaultOpen
style={
{
height: "100svh",
minHeight: "0",
overflow: "hidden",
"--sidebar-width-icon": "53px",
} as React.CSSProperties
}
>
{/* Sidebar navigation */}
<SidebarNav manifest={manifest} />
{/* Main content area — scrolls independently so sidebar stays full height */}
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6">{children}</main>
</div>
{/* Welcome modal for first-time users */}
{user && (
<WelcomeModal
open={welcomeModalOpen}
onClose={() => setWelcomeModalOpen(false)}
userName={user.name}
userRole={user.role}
/>
)}
{/* Command palette for quick navigation */}
<AdminCommandPalette manifest={manifest} />
</Sidebar.Provider>
);
}

View File

@@ -0,0 +1,468 @@
import { Sidebar as KumoSidebar, Tooltip, useSidebar } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import {
SquaresFour,
FileText,
Image,
ChatCircle,
Gear,
PuzzlePiece,
Storefront,
Palette,
Upload,
Database,
List,
GridFour,
Users,
Stack,
ArrowsLeftRight,
} from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { Link, useLocation } from "@tanstack/react-router";
import * as React from "react";
import { fetchCommentCounts } from "../lib/api/comments";
import { useCurrentUser } from "../lib/api/current-user";
import { usePluginAdmins } from "../lib/plugin-context";
import { cn } from "../lib/utils";
import { BrandIcon } from "./Logo.js";
// Re-export for Shell.tsx and Header.tsx
export { KumoSidebar as Sidebar, useSidebar };
// Role levels (matching @emdash-cms/auth)
const ROLE_ADMIN = 50;
const ROLE_EDITOR = 40;
export interface SidebarNavProps {
manifest: {
collections: Record<string, { label: string }>;
plugins: Record<
string,
{
package?: string;
enabled?: boolean;
adminMode?: "react" | "blocks" | "none";
adminPages?: Array<{
path: string;
label?: string;
icon?: string;
}>;
dashboardWidgets?: Array<{ id: string; title?: string }>;
version?: string;
}
>;
taxonomies: Array<{
name: string;
label: string;
}>;
version?: string;
commit?: string;
marketplace?: string;
admin?: {
logo?: string;
siteName?: string;
favicon?: string;
};
};
}
interface NavItem {
to: string;
label: string;
icon: React.ElementType;
params?: Record<string, string>;
/** Minimum role level required to see this item */
minRole?: number;
/** Optional badge count (e.g., pending comments) */
badge?: number;
}
/**
* Navigation item rendered as a TanStack Router <Link> inside kumo's
* Sidebar.MenuItem. Styled to match kumo MenuButton appearance.
* This approach guarantees client-side navigation works correctly.
*/
function NavMenuLink({ item, isActive }: { item: NavItem; isActive: boolean }) {
const { state } = useSidebar();
const Icon = item.icon;
const link = (
<Link
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- TanStack Router requires literal route types
to={item.to as "/"}
params={item.params}
aria-current={isActive ? "page" : undefined}
data-active={isActive || undefined}
data-sidebar="menu-button"
className={cn(
"emdash-nav-link group/menu-button flex w-full min-w-0 items-center gap-2.5 rounded-md no-underline outline-none cursor-pointer",
"min-h-[36px] px-3 py-1.5 text-[13px]",
"transition-all duration-200 ease-out",
isActive ? "bg-kumo-brand text-white" : "text-white/70 hover:text-white hover:bg-white/8",
"focus-visible:ring-2 focus-visible:ring-kumo-brand/50",
)}
>
<Icon
className={cn(
"emdash-nav-icon size-[18px] shrink-0 transition-colors duration-200",
isActive ? "text-white" : "text-white/60 group-hover/menu-button:text-white/90",
)}
aria-hidden="true"
/>
<span className="emdash-nav-label flex flex-1 items-center min-w-0 text-start overflow-hidden">
{item.label}
{item.badge != null && item.badge > 0 && (
<KumoSidebar.MenuBadge>{item.badge}</KumoSidebar.MenuBadge>
)}
</span>
</Link>
);
return (
<KumoSidebar.MenuItem>
{state === "collapsed" ? (
<Tooltip content={item.label} side="right" asChild>
{link}
</Tooltip>
) : (
link
)}
</KumoSidebar.MenuItem>
);
}
/** Resolves a nav item's route path by substituting $param placeholders. */
function resolveItemPath(item: NavItem): string {
let path = item.to;
if (item.params) {
for (const [key, value] of Object.entries(item.params)) {
path = path.replace(`$${key}`, value);
}
}
return path;
}
/** Checks if a nav item is active based on the current router path. */
function isItemActive(itemPath: string, currentPath: string): boolean {
return itemPath === "/"
? currentPath === "/"
: currentPath === itemPath || currentPath.startsWith(`${itemPath}/`);
}
/**
* Admin sidebar navigation using kumo's Sidebar compound component.
*/
export function SidebarNav({ manifest }: SidebarNavProps) {
const { t } = useLingui();
const location = useLocation();
const currentPath = location.pathname;
const pluginAdmins = usePluginAdmins();
const { data: user } = useCurrentUser();
const userRole = user?.role ?? 0;
// Fetch pending comment count for badge
const { data: commentCounts } = useQuery({
queryKey: ["commentCounts"],
queryFn: fetchCommentCounts,
staleTime: 60 * 1000,
retry: false,
enabled: userRole >= ROLE_EDITOR,
});
// --- Build nav item groups ---
const contentItems: NavItem[] = [{ to: "/", label: t`Dashboard`, icon: SquaresFour }];
for (const [name, config] of Object.entries(manifest.collections)) {
contentItems.push({
to: "/content/$collection",
label: config.label,
icon: FileText,
params: { collection: name },
});
}
contentItems.push({ to: "/media", label: t`Media`, icon: Image });
const manageItems: NavItem[] = [
{
to: "/comments",
label: t`Comments`,
icon: ChatCircle,
minRole: ROLE_EDITOR,
badge: commentCounts?.pending,
},
{ to: "/menus", label: t`Menus`, icon: List, minRole: ROLE_EDITOR },
{ to: "/redirects", label: t`Redirects`, icon: ArrowsLeftRight, minRole: ROLE_ADMIN },
{ to: "/widgets", label: t`Widgets`, icon: GridFour, minRole: ROLE_EDITOR },
{ to: "/sections", label: t`Sections`, icon: Stack, minRole: ROLE_EDITOR },
...manifest.taxonomies.map((tax) => ({
to: "/taxonomies/$taxonomy" as const,
label: tax.label,
icon: FileText,
params: { taxonomy: tax.name },
minRole: ROLE_EDITOR,
})),
{ to: "/bylines", label: t`Bylines`, icon: FileText, minRole: ROLE_EDITOR },
];
const adminItems: NavItem[] = [
{ to: "/content-types", label: t`Content Types`, icon: Database, minRole: ROLE_ADMIN },
{ to: "/users", label: t`Users`, icon: Users, minRole: ROLE_ADMIN },
{ to: "/plugins-manager", label: t`Plugins`, icon: PuzzlePiece, minRole: ROLE_ADMIN },
];
if (manifest.marketplace) {
adminItems.push(
{
to: "/plugins/marketplace",
label: t`Marketplace`,
icon: Storefront,
minRole: ROLE_ADMIN,
},
{ to: "/themes/marketplace", label: t`Themes`, icon: Palette, minRole: ROLE_ADMIN },
);
}
adminItems.push(
{ to: "/import/wordpress", label: t`Import`, icon: Upload, minRole: ROLE_ADMIN },
{ to: "/settings", label: t`Settings`, icon: Gear, minRole: ROLE_ADMIN },
);
const pluginItems: NavItem[] = [];
for (const [pluginId, config] of Object.entries(manifest.plugins)) {
if (config.enabled === false) continue;
if (config.adminPages && config.adminPages.length > 0) {
const pluginPages = pluginAdmins[pluginId]?.pages;
const isBlocksMode = config.adminMode === "blocks";
for (const page of config.adminPages) {
if (!isBlocksMode && !pluginPages?.[page.path]) continue;
const label =
page.label ||
pluginId
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
pluginItems.push({ to: `/plugins/${pluginId}${page.path}`, label, icon: PuzzlePiece });
}
}
}
const filterByRole = (items: NavItem[]) =>
items.filter((item) => !item.minRole || userRole >= item.minRole);
const visibleContent = filterByRole(contentItems);
const visibleManage = filterByRole(manageItems);
const visibleAdmin = filterByRole(adminItems);
const visiblePlugins = filterByRole(pluginItems);
function renderNavItems(items: NavItem[]) {
return items.map((item, index) => {
const itemPath = resolveItemPath(item);
const active = isItemActive(itemPath, currentPath);
return <NavMenuLink key={`${item.to}-${index}`} item={item} isActive={active} />;
});
}
return (
<>
{/* Injected styles — Tailwind 4 strips [data-sidebar] attribute selectors from CSS files.
All sidebar-specific overrides go here to avoid conflicting with kumo's inline styles. */}
<style
dangerouslySetInnerHTML={{
__html: `
/* Classic dark chrome — override kumo tokens within the sidebar */
.emdash-sidebar {
--color-kumo-base: #1d2327;
--color-kumo-tint: rgba(255,255,255,0.1);
--color-kumo-line: rgba(255,255,255,0.08);
--color-kumo-brand: #2271b1;
--text-color-kumo-default: #fff;
--text-color-kumo-subtle: rgba(255,255,255,0.7);
--text-color-kumo-strong: #fff;
background-color: #1d2327 !important;
color: #fff !important;
border-color: rgba(255,255,255,0.08) !important;
}
/* Group labels — uppercase muted style */
.emdash-sidebar [data-sidebar="group-label"] {
color: rgba(255,255,255,0.45) !important;
font-size: 11px !important;
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.emdash-sidebar [data-sidebar="group-label"] svg {
color: rgba(255,255,255,0.3);
}
.emdash-sidebar [data-sidebar="group-label"]:hover svg {
color: rgba(255,255,255,0.6);
}
/* Separators */
.emdash-sidebar [data-sidebar="separator"] {
border-color: rgba(255,255,255,0.06) !important;
margin: 0.5rem 0.75rem;
}
/* Header/footer borders */
.emdash-sidebar [data-sidebar="header"] {
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.emdash-sidebar [data-sidebar="footer"] {
border-top: 1px solid rgba(255,255,255,0.08);
}
/* Keep all nav icons visible when sidebar collapses to icon mode */
.emdash-sidebar[data-state="collapsed"] [data-sidebar="group-content"] {
grid-template-rows: 1fr !important;
}
/* Mobile drawer: kumo's Sheet has no data-state attribute, so group-content
stays at grid-rows-[0fr] (hidden). Force it open in the mobile sidebar. */
.emdash-sidebar[data-mobile="true"] [data-sidebar="group-content"] {
grid-template-rows: 1fr !important;
}
/* Collapsed separators — thin centered line */
.emdash-sidebar[data-state="collapsed"] [data-sidebar="separator"] {
margin: 0.375rem 0.625rem;
}
/* Collapsed: tighten group spacing */
.emdash-sidebar[data-state="collapsed"] [data-sidebar="group"] {
gap: 0.125rem;
}
.emdash-sidebar[data-state="collapsed"] [data-sidebar="menu"] {
gap: 0.125rem;
}
/* Collapsed: nav links — center icon, hide text */
.emdash-sidebar[data-state="collapsed"] .emdash-nav-link {
justify-content: center;
padding: 0.5rem 0;
gap: 0;
min-height: 36px;
}
.emdash-sidebar[data-state="collapsed"] .emdash-nav-label {
display: none !important;
}
/* Collapsed: brand link */
.emdash-sidebar[data-state="collapsed"] .emdash-brand-link {
justify-content: center;
padding-left: 0;
padding-right: 0;
}
.emdash-sidebar[data-state="collapsed"] .emdash-brand-text {
display: none !important;
}
/* Mobile drawer slide animation from left (LTR) */
[data-starting-style]:has(> .emdash-sidebar[data-mobile="true"]),
[data-ending-style]:has(> .emdash-sidebar[data-mobile="true"]) {
transform: translateX(-100%);
}
/* Mobile drawer slide animation from right (RTL) */
[dir="rtl"] [data-starting-style]:has(> .emdash-sidebar[data-mobile="true"]),
[dir="rtl"] [data-ending-style]:has(> .emdash-sidebar[data-mobile="true"]) {
transform: translateX(100%);
--tw-translate-x: 100%;
}
/* RTL: Position drawer on right side */
[dir="rtl"] :has(> .emdash-sidebar[data-mobile="true"]) {
left: auto;
right: 0;
}
`,
}}
/>
<KumoSidebar className="emdash-sidebar" aria-label={t`Admin navigation`}>
<KumoSidebar.Header>
<Link
to="/"
className="emdash-brand-link flex w-full min-w-0 items-center gap-2 px-3 py-1"
>
<BrandIcon
logoUrl={manifest.admin?.logo}
siteName={manifest.admin?.siteName}
className="size-5 shrink-0"
aria-hidden="true"
/>
<span className="emdash-brand-text font-semibold truncate">
{manifest.admin?.siteName || "EmDash"}
</span>
</Link>
</KumoSidebar.Header>
<KumoSidebar.Content>
{/* Dashboard — standalone */}
<KumoSidebar.Group>
<KumoSidebar.Menu>
<NavMenuLink
item={{ to: "/", label: t`Dashboard`, icon: SquaresFour }}
isActive={isItemActive("/", currentPath)}
/>
</KumoSidebar.Menu>
</KumoSidebar.Group>
<KumoSidebar.Separator />
{/* Content — collections + media (collapsible) */}
{visibleContent.length > 1 && (
<KumoSidebar.Group collapsible defaultOpen>
<KumoSidebar.GroupLabel className="[&>span]:text-start [&_svg]:rtl:-scale-x-100 [&_svg]:rtl:-scale-y-100">{t`Content`}</KumoSidebar.GroupLabel>
<KumoSidebar.GroupContent>
<KumoSidebar.Menu>
{renderNavItems(visibleContent.filter((i) => i.to !== "/"))}
</KumoSidebar.Menu>
</KumoSidebar.GroupContent>
</KumoSidebar.Group>
)}
<KumoSidebar.Separator />
{/* Manage — comments, menus, taxonomies, etc. (collapsible) */}
{visibleManage.length > 0 && (
<KumoSidebar.Group collapsible defaultOpen>
<KumoSidebar.GroupLabel className="[&>span]:text-start [&_svg]:rtl:-scale-x-100 [&_svg]:rtl:-scale-y-100">{t`Manage`}</KumoSidebar.GroupLabel>
<KumoSidebar.GroupContent>
<KumoSidebar.Menu>{renderNavItems(visibleManage)}</KumoSidebar.Menu>
</KumoSidebar.GroupContent>
</KumoSidebar.Group>
)}
<KumoSidebar.Separator />
{/* Admin — content types, users, plugins, import (collapsible) */}
{visibleAdmin.length > 0 && (
<KumoSidebar.Group collapsible defaultOpen>
<KumoSidebar.GroupLabel className="[&>span]:text-start [&_svg]:rtl:-scale-x-100 [&_svg]:rtl:-scale-y-100">{t`Admin`}</KumoSidebar.GroupLabel>
<KumoSidebar.GroupContent>
<KumoSidebar.Menu>{renderNavItems(visibleAdmin)}</KumoSidebar.Menu>
</KumoSidebar.GroupContent>
</KumoSidebar.Group>
)}
{/* Plugin pages (collapsible) */}
{visiblePlugins.length > 0 && (
<>
<KumoSidebar.Separator />
<KumoSidebar.Group collapsible defaultOpen>
<KumoSidebar.GroupLabel className="[&>span]:text-start [&_svg]:rtl:-scale-x-100 [&_svg]:rtl:-scale-y-100">{t`Plugins`}</KumoSidebar.GroupLabel>
<KumoSidebar.GroupContent>
<KumoSidebar.Menu>{renderNavItems(visiblePlugins)}</KumoSidebar.Menu>
</KumoSidebar.GroupContent>
</KumoSidebar.Group>
</>
)}
</KumoSidebar.Content>
<KumoSidebar.Footer>
<p className="emdash-nav-label px-3 py-2 text-[11px] text-white/30">
{manifest.admin?.siteName || "EmDash CMS"} v{manifest.version || "0.0.0"}
{manifest.commit && ` (${manifest.commit})`}
</p>
</KumoSidebar.Footer>
</KumoSidebar>
</>
);
}

View File

@@ -0,0 +1,449 @@
/**
* Signup Page - Self-signup for allowed domains
*
* This component is NOT wrapped in the admin Shell.
* It's a standalone public page for self-signup.
*
* Flow:
* 1. Email input form
* 2. "Check your email" confirmation
* 3. After clicking email link: Passkey registration
*/
import { Button, Input, Loader } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { Link } from "@tanstack/react-router";
import * as React from "react";
import { requestSignup, verifySignupToken, type SignupVerifyResult } from "../lib/api";
import { PasskeyRegistration } from "./auth/PasskeyRegistration";
import { LogoLockup } from "./Logo.js";
// ============================================================================
// Types
// ============================================================================
type SignupStep = "email" | "check-email" | "verify" | "complete" | "error";
// ============================================================================
// Step Components
// ============================================================================
interface EmailStepProps {
onSubmit: (email: string) => void;
isLoading: boolean;
error?: string;
}
function EmailStep({ onSubmit, isLoading, error }: EmailStepProps) {
const { t } = useLingui();
const [email, setEmail] = React.useState("");
const [validationError, setValidationError] = React.useState<string | null>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setValidationError(null);
if (!email.trim()) {
setValidationError(t`Email is required`);
return;
}
if (!email.includes("@") || !email.includes(".")) {
setValidationError(t`Please enter a valid email address`);
return;
}
onSubmit(email.trim().toLowerCase());
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-4">
<div>
<Input
label={t`Email address`}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t`you@company.com`}
className={validationError ? "border-kumo-danger" : ""}
disabled={isLoading}
autoComplete="email"
autoFocus
/>
{validationError && <p className="text-sm text-kumo-danger mt-1">{validationError}</p>}
</div>
</div>
{error && (
<div className="rounded-lg bg-kumo-danger/10 p-4 text-sm text-kumo-danger">{error}</div>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader size="sm" />
{t`Sending...`}
</>
) : (
t`Continue`
)}
</Button>
<p className="text-xs text-kumo-subtle text-center">
{t`Only email addresses from allowed domains can sign up.`}
</p>
</form>
);
}
interface CheckEmailStepProps {
email: string;
onResend: () => void;
isResending: boolean;
resendCooldown: number;
}
function CheckEmailStep({ email, onResend, isResending, resendCooldown }: CheckEmailStepProps) {
const { t } = useLingui();
return (
<div className="space-y-6 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-kumo-brand/10 mx-auto">
<svg
className="w-8 h-8 text-kumo-brand"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<div>
<h2 className="text-xl font-semibold">{t`Check your email`}</h2>
<p className="text-kumo-subtle mt-2">
{t`We've sent a verification link to`}{" "}
<span className="font-medium text-kumo-default">{email}</span>
</p>
</div>
<div className="text-sm text-kumo-subtle">
<p>{t`Click the link in the email to continue setting up your account.`}</p>
<p className="mt-2">{t`The link will expire in 15 minutes.`}</p>
</div>
<div className="pt-4 border-t">
<p className="text-sm text-kumo-subtle mb-2">{t`Didn't receive the email?`}</p>
<Button
variant="outline"
size="sm"
onClick={onResend}
disabled={isResending || resendCooldown > 0}
>
{isResending
? t`Sending...`
: resendCooldown > 0
? t`Resend in ${resendCooldown}s`
: t`Resend email`}
</Button>
</div>
</div>
);
}
interface VerifyStepProps {
verifyResult: SignupVerifyResult;
token: string;
onBack: () => void;
}
function handleSignupSuccess() {
// Redirect to admin dashboard after successful signup
window.location.href = "/_emdash/admin";
}
function VerifyStep({ verifyResult, token, onBack: _onBack }: VerifyStepProps) {
const { t } = useLingui();
const [name, setName] = React.useState("");
return (
<div className="space-y-6">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-500/10 mx-auto mb-4">
<svg
className="w-8 h-8 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-semibold">{t`Email verified!`}</h2>
<p className="text-kumo-subtle mt-2">
{t`You'll be signing up as`}{" "}
<span className="font-medium text-kumo-default">{verifyResult.roleName}</span>
</p>
</div>
{/* Email display (read-only) */}
<Input label={t`Email`} value={verifyResult.email} disabled className="bg-kumo-tint" />
{/* Name input (optional) */}
<Input
label={t`Your name (optional)`}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t`Jane Doe`}
autoComplete="name"
/>
{/* Passkey registration */}
<div className="pt-4 border-t">
<h3 className="text-sm font-medium mb-3">{t`Create your passkey`}</h3>
<p className="text-sm text-kumo-subtle mb-4">
{t`Passkeys are a secure, passwordless way to sign in using your device's biometrics, PIN, or security key.`}
</p>
<PasskeyRegistration
optionsEndpoint="/_emdash/api/setup/admin"
verifyEndpoint="/_emdash/api/auth/signup/complete"
onSuccess={handleSignupSuccess}
buttonText={t`Create Account`}
additionalData={{ token, name: name || undefined }}
/>
</div>
</div>
);
}
interface ErrorStepProps {
message: string;
code?: string;
onRetry?: () => void;
}
function ErrorStep({ message, code, onRetry }: ErrorStepProps) {
const { t } = useLingui();
return (
<div className="space-y-6 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-kumo-danger/10 mx-auto">
<svg
className="w-8 h-8 text-kumo-danger"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div>
<h2 className="text-xl font-semibold text-kumo-danger">
{code === "token_expired"
? t`Link expired`
: code === "invalid_token"
? t`Invalid link`
: code === "user_exists"
? t`Account exists`
: t`Something went wrong`}
</h2>
<p className="text-kumo-subtle mt-2">{message}</p>
</div>
<div className="space-y-2">
{code === "user_exists" ? (
<Link to="/login">
<Button className="w-full">{t`Sign in instead`}</Button>
</Link>
) : (
onRetry && (
<Button onClick={onRetry} className="w-full">
{t`Request a new link`}
</Button>
)
)}
<Link to="/login">
<Button variant="ghost" className="w-full">
{t`Back to login`}
</Button>
</Link>
</div>
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function SignupPage() {
const [step, setStep] = React.useState<SignupStep>("email");
const [email, setEmail] = React.useState("");
const [error, setError] = React.useState<string | undefined>();
const [errorCode, setErrorCode] = React.useState<string | undefined>();
const [isLoading, setIsLoading] = React.useState(false);
const [verifyResult, setVerifyResult] = React.useState<SignupVerifyResult | null>(null);
const [token, setToken] = React.useState<string | null>(null);
const [resendCooldown, setResendCooldown] = React.useState(0);
// Check for token in URL on mount
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
const urlToken = params.get("token");
if (urlToken) {
setToken(urlToken);
void verifyToken(urlToken);
}
}, []);
// Resend cooldown timer
React.useEffect(() => {
if (resendCooldown > 0) {
const timer = setTimeout(() => setResendCooldown((c) => c - 1), 1000);
return () => clearTimeout(timer);
}
}, [resendCooldown]);
const verifyToken = async (tokenToVerify: string) => {
setIsLoading(true);
setError(undefined);
setErrorCode(undefined);
try {
const result = await verifySignupToken(tokenToVerify);
setVerifyResult(result);
setStep("verify");
} catch (err) {
const verifyError = err instanceof Error ? err : new Error(String(err));
const errorWithCode = verifyError as Error & { code?: string };
setError(verifyError.message);
setErrorCode(typeof errorWithCode.code === "string" ? errorWithCode.code : undefined);
setStep("error");
} finally {
setIsLoading(false);
}
};
const handleEmailSubmit = async (submittedEmail: string) => {
setIsLoading(true);
setError(undefined);
setEmail(submittedEmail);
try {
await requestSignup(submittedEmail);
setStep("check-email");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send verification email");
} finally {
setIsLoading(false);
}
};
const handleResend = async () => {
if (!email || resendCooldown > 0) return;
setIsLoading(true);
try {
await requestSignup(email);
setResendCooldown(60); // 60 second cooldown
} catch {
// Silently fail - don't reveal if email exists
} finally {
setIsLoading(false);
}
};
const handleRetry = () => {
setStep("email");
setError(undefined);
setErrorCode(undefined);
setToken(null);
// Clear token from URL
window.history.replaceState({}, "", window.location.pathname);
};
const { t } = useLingui();
// Loading state for token verification
if (isLoading && token) {
return (
<div className="min-h-screen flex items-center justify-center bg-kumo-base">
<div className="text-center">
<Loader />
<p className="mt-4 text-kumo-subtle">{t`Verifying your link...`}</p>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-kumo-base p-4">
<div className="w-full max-w-md">
{/* Header */}
<div className="text-center mb-8">
<LogoLockup className="h-10 mx-auto mb-2" />
<h1 className="text-2xl font-semibold text-kumo-default">
{step === "email" && t`Create an account`}
{step === "check-email" && t`Check your email`}
{step === "verify" && t`Complete signup`}
{step === "error" && t`Oops!`}
</h1>
</div>
{/* Form Card */}
<div className="bg-kumo-base border rounded-lg shadow-sm p-6">
{step === "email" && (
<EmailStep onSubmit={handleEmailSubmit} isLoading={isLoading} error={error} />
)}
{step === "check-email" && (
<CheckEmailStep
email={email}
onResend={handleResend}
isResending={isLoading}
resendCooldown={resendCooldown}
/>
)}
{step === "verify" && verifyResult && token && (
<VerifyStep verifyResult={verifyResult} token={token} onBack={handleRetry} />
)}
{step === "error" && (
<ErrorStep
message={error ?? "An unknown error occurred"}
code={errorCode}
onRetry={handleRetry}
/>
)}
</div>
{/* Login link */}
{step === "email" && (
<p className="text-center mt-6 text-sm text-kumo-subtle">
{t`Already have an account?`}{" "}
<Link to="/login" className="text-kumo-brand hover:underline font-medium">
{t`Sign in`}
</Link>
</p>
)}
</div>
</div>
);
}
export default SignupPage;

View File

@@ -0,0 +1,673 @@
/**
* Taxonomy Terms Manager
*
* Provides UI for managing taxonomy terms (categories, tags, custom taxonomies).
* Shows hierarchical structure for categories, flat list for tags.
*/
import { Button, Checkbox, Dialog, Input, InputArea, Select, Toast } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { Plus, Pencil, Trash, X } from "@phosphor-icons/react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { fetchManifest } from "../lib/api/client.js";
import type { TaxonomyTerm, TaxonomyDef, CreateTaxonomyInput } from "../lib/api/taxonomies.js";
import {
fetchTaxonomyDef,
fetchTerms,
createTaxonomy,
createTerm,
updateTerm,
deleteTerm,
} from "../lib/api/taxonomies.js";
import { slugify } from "../lib/utils";
import { ConfirmDialog } from "./ConfirmDialog.js";
import { DialogError, getMutationError } from "./DialogError.js";
interface TaxonomyManagerProps {
taxonomyName: string;
}
// Regex patterns for taxonomy name generation and validation (module-scoped per lint rules)
const NON_ALPHANUMERIC_PATTERN = /[^a-z0-9]+/g;
const LEADING_TRAILING_UNDERSCORE_PATTERN = /^_|_$/g;
const TAXONOMY_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
/**
* Flatten tree to get all terms
*/
function flattenTerms(terms: TaxonomyTerm[]): TaxonomyTerm[] {
return terms.flatMap((t) => [t, ...flattenTerms(t.children)]);
}
/**
* Term row component (recursive for hierarchy)
*/
function TermRow({
term,
level = 0,
onEdit,
onDelete,
}: {
term: TaxonomyTerm;
level?: number;
onEdit: (term: TaxonomyTerm) => void;
onDelete: (term: TaxonomyTerm) => void;
}) {
const { t } = useLingui();
return (
<>
<div className="flex items-center gap-4 py-2 px-4 border-b hover:bg-kumo-tint/50">
<div style={{ marginInlineStart: `${level * 1.5}rem` }} className="flex-1">
<span className="font-medium">{term.label}</span>
<span className="text-sm text-kumo-subtle ms-2">({term.slug})</span>
</div>
<div className="text-sm text-kumo-subtle">{term.count || 0}</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
aria-label={t`Edit ${term.label}`}
onClick={() => onEdit(term)}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
aria-label={t`Delete ${term.label}`}
onClick={() => onDelete(term)}
>
<Trash className="w-4 h-4" />
</Button>
</div>
</div>
{term.children.map((child) => (
<TermRow
key={child.id}
term={child}
level={level + 1}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</>
);
}
/**
* Term form dialog
*/
function TermFormDialog({
open,
onClose,
taxonomyName,
taxonomyDef,
term,
allTerms,
}: {
open: boolean;
onClose: () => void;
taxonomyName: string;
taxonomyDef: TaxonomyDef;
term?: TaxonomyTerm;
allTerms: TaxonomyTerm[];
}) {
const { t } = useLingui();
const queryClient = useQueryClient();
const [label, setLabel] = React.useState(term?.label || "");
const [slug, setSlug] = React.useState(term?.slug || "");
const [parentId, setParentId] = React.useState(term?.parentId || "");
const [description, setDescription] = React.useState(term?.description || "");
const [autoSlug, setAutoSlug] = React.useState(!term);
const [error, setError] = React.useState<string | null>(null);
// Sync form state when term prop changes (for edit mode)
React.useEffect(() => {
setLabel(term?.label || "");
setSlug(term?.slug || "");
setParentId(term?.parentId || "");
setDescription(term?.description || "");
setAutoSlug(!term);
setError(null);
}, [term]);
// Auto-generate slug from label
React.useEffect(() => {
if (autoSlug && label) {
setSlug(slugify(label));
}
}, [label, autoSlug]);
const createMutation = useMutation({
mutationFn: () =>
createTerm(taxonomyName, {
slug,
label,
parentId: parentId || undefined,
description: description || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["taxonomy-terms", taxonomyName],
});
onClose();
},
onError: (err: Error) => {
setError(err.message);
},
});
const updateMutation = useMutation({
mutationFn: () => {
if (!term) throw new Error("No term to update");
return updateTerm(taxonomyName, term.slug, {
slug,
label,
parentId: parentId || undefined,
description: description || undefined,
});
},
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["taxonomy-terms", taxonomyName],
});
onClose();
},
onError: (err: Error) => {
setError(err.message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (term) {
updateMutation.mutate();
} else {
createMutation.mutate();
}
};
// Flatten terms for parent selector (exclude current term and its children)
const flatTerms = flattenTerms(allTerms);
const availableParents = term
? flatTerms.filter((item) => item.id !== term.id && item.parentId !== term.id)
: flatTerms;
return (
<Dialog.Root
open={open}
onOpenChange={(isOpen: boolean) => {
if (!isOpen) {
setError(null);
onClose();
}
}}
>
<Dialog className="p-6" size="lg">
<form onSubmit={handleSubmit}>
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex flex-col space-y-1.5">
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
{term ? t`Edit` : t`Add`} {taxonomyDef.labelSingular || t`Term`}
</Dialog.Title>
<Dialog.Description className="text-sm text-kumo-subtle">
{term
? t`Update the ${taxonomyDef.labelSingular?.toLowerCase() || "term"} details`
: t`Create a new ${taxonomyDef.labelSingular?.toLowerCase() || "term"}`}
</Dialog.Description>
</div>
<Dialog.Close
aria-label={t`Close`}
render={(props) => (
<Button
{...props}
variant="ghost"
shape="square"
aria-label={t`Close`}
className="absolute end-4 top-4"
>
<X className="h-4 w-4" />
<span className="sr-only">{t`Close`}</span>
</Button>
)}
/>
</div>
<div className="space-y-4 py-4">
<Input
label={t`Name`}
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="News"
required
/>
<div>
<Input
label={t`Slug`}
value={slug}
onChange={(e) => {
setSlug(e.target.value);
setAutoSlug(false);
}}
placeholder="news"
required
/>
<p className="text-sm text-kumo-subtle mt-1">
{t`Auto-generated from name (you can edit)`}
</p>
</div>
{taxonomyDef.hierarchical && (
<Select
label={t`Parent`}
value={parentId}
onValueChange={(v) => setParentId(v ?? "")}
items={{
"": t`None (top level)`,
...Object.fromEntries(
availableParents.map((parentTerm) => [parentTerm.id, parentTerm.label]),
),
}}
>
<Select.Option value="">{t`None (top level)`}</Select.Option>
{availableParents.map((parentTerm) => (
<Select.Option key={parentTerm.id} value={parentTerm.id}>
{parentTerm.label}
</Select.Option>
))}
</Select>
)}
<InputArea
label={t`Description (optional)`}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t`Optional description`}
rows={3}
/>
<DialogError
message={
error ||
getMutationError(createMutation.error) ||
getMutationError(updateMutation.error)
}
/>
</div>
<div className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
<Button type="button" variant="outline" onClick={onClose}>
{t`Cancel`}
</Button>
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
{createMutation.isPending || updateMutation.isPending
? t`Saving...`
: term
? t`Update`
: t`Create`}
</Button>
</div>
</form>
</Dialog>
</Dialog.Root>
);
}
/**
* Create Taxonomy dialog
*/
function CreateTaxonomyDialog({
open,
onClose,
onCreated,
}: {
open: boolean;
onClose: () => void;
onCreated: () => void;
}) {
const { t } = useLingui();
const queryClient = useQueryClient();
const [name, setName] = React.useState("");
const [label, setLabel] = React.useState("");
const [hierarchical, setHierarchical] = React.useState(false);
const [selectedCollections, setSelectedCollections] = React.useState<string[]>([]);
const [autoName, setAutoName] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const { data: manifest } = useQuery({
queryKey: ["manifest"],
queryFn: fetchManifest,
});
const collectionEntries = manifest
? Object.entries(manifest.collections).map(([slug, config]) => ({
slug,
label: config.label,
}))
: [];
// Auto-generate name from label
React.useEffect(() => {
if (autoName && label) {
setName(
label
.toLowerCase()
.replace(NON_ALPHANUMERIC_PATTERN, "_")
.replace(LEADING_TRAILING_UNDERSCORE_PATTERN, ""),
);
}
}, [label, autoName]);
const createMutation = useMutation({
mutationFn: (input: CreateTaxonomyInput) => createTaxonomy(input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["taxonomy-defs"] });
void queryClient.invalidateQueries({ queryKey: ["taxonomy-def"] });
onCreated();
resetForm();
},
});
const resetForm = () => {
setName("");
setLabel("");
setHierarchical(false);
setSelectedCollections([]);
setAutoName(true);
setError(null);
createMutation.reset();
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!name || !label) {
setError(t`Name and label are required`);
return;
}
if (!TAXONOMY_NAME_PATTERN.test(name)) {
setError(
t`Name must start with a letter and contain only lowercase letters, numbers, and underscores`,
);
return;
}
createMutation.mutate({
name,
label,
hierarchical,
collections: selectedCollections,
});
};
const toggleCollection = (slug: string) => {
setSelectedCollections((prev) =>
prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug],
);
};
return (
<Dialog.Root
open={open}
onOpenChange={(isOpen: boolean) => {
if (!isOpen) {
resetForm();
onClose();
}
}}
>
<Dialog className="p-6" size="lg">
<form onSubmit={handleSubmit}>
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex flex-col space-y-1.5">
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
{t`Create Taxonomy`}
</Dialog.Title>
<Dialog.Description className="text-sm text-kumo-subtle">
{t`Define a new taxonomy for classifying content`}
</Dialog.Description>
</div>
<Dialog.Close
aria-label={t`Close`}
render={(props) => (
<Button
{...props}
variant="ghost"
shape="square"
aria-label={t`Close`}
className="absolute end-4 top-4"
>
<X className="h-4 w-4" />
<span className="sr-only">{t`Close`}</span>
</Button>
)}
/>
</div>
<div className="space-y-4 py-4">
<Input
label={t`Label`}
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Genres"
required
/>
<div>
<Input
label={t`Name`}
value={name}
onChange={(e) => {
setName(e.target.value);
setAutoName(false);
}}
placeholder="genre"
required
pattern="[a-z][a-z0-9_]*"
title={t`Lowercase letters, numbers, and underscores only, starting with a letter`}
/>
<p className="text-xs text-kumo-subtle mt-1">
{t`Used as the identifier. Lowercase letters, numbers, and underscores only.`}
</p>
</div>
<Checkbox
label={t`Hierarchical (like categories, with parent/child relationships)`}
checked={hierarchical}
onCheckedChange={(checked) => setHierarchical(checked)}
/>
{collectionEntries.length > 0 && (
<div>
<label className="text-sm font-medium">{t`Collections`}</label>
<p className="text-xs text-kumo-subtle mb-2">
{t`Which content types can use this taxonomy`}
</p>
<div className="border rounded-md p-2 space-y-1">
{collectionEntries.map(({ slug, label: collLabel }) => (
<label
key={slug}
className="flex items-center gap-2 py-1 px-2 cursor-pointer hover:bg-kumo-tint/50 rounded"
>
<input
type="checkbox"
checked={selectedCollections.includes(slug)}
onChange={() => toggleCollection(slug)}
className="rounded"
/>
<span className="text-sm">{collLabel}</span>
</label>
))}
</div>
</div>
)}
<DialogError message={error || getMutationError(createMutation.error)} />
</div>
<div className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
<Button
type="button"
variant="outline"
onClick={() => {
resetForm();
onClose();
}}
>
{t`Cancel`}
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? t`Creating...` : t`Create Taxonomy`}
</Button>
</div>
</form>
</Dialog>
</Dialog.Root>
);
}
/**
* Main TaxonomyManager component
*/
export function TaxonomyManager({ taxonomyName }: TaxonomyManagerProps) {
const { t } = useLingui();
const queryClient = useQueryClient();
const toastManager = Toast.useToastManager();
const [formOpen, setFormOpen] = React.useState(false);
const [editingTerm, setEditingTerm] = React.useState<TaxonomyTerm | undefined>();
const [deleteTarget, setDeleteTarget] = React.useState<TaxonomyTerm | null>(null);
const [createTaxonomyOpen, setCreateTaxonomyOpen] = React.useState(false);
const { data: taxonomyDef, isLoading: defLoading } = useQuery({
queryKey: ["taxonomy-def", taxonomyName],
queryFn: () => fetchTaxonomyDef(taxonomyName),
});
const { data: terms = [], isLoading: termsLoading } = useQuery({
queryKey: ["taxonomy-terms", taxonomyName],
queryFn: () => fetchTerms(taxonomyName),
});
const deleteMutation = useMutation({
mutationFn: (term: TaxonomyTerm) => deleteTerm(taxonomyName, term.slug),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["taxonomy-terms", taxonomyName],
});
setDeleteTarget(null);
toastManager.add({ title: t`Term deleted` });
},
});
const handleEdit = (term: TaxonomyTerm) => {
setEditingTerm(term);
setFormOpen(true);
};
const handleDelete = (term: TaxonomyTerm) => {
setDeleteTarget(term);
};
const handleCloseForm = () => {
setFormOpen(false);
setEditingTerm(undefined);
};
if (defLoading) {
return <div>{t`Loading...`}</div>;
}
if (!taxonomyDef) {
return (
<div>
{t`Taxonomy not found:`} {taxonomyName}
</div>
);
}
const flatTerms = flattenTerms(terms);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">{taxonomyDef.label}</h1>
<p className="text-kumo-subtle mt-1">
{t`Manage ${taxonomyDef.label.toLowerCase()} for ${taxonomyDef.collections.join(", ")}`}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" icon={<Plus />} onClick={() => setCreateTaxonomyOpen(true)}>
{t`New Taxonomy`}
</Button>
<Button icon={<Plus />} onClick={() => setFormOpen(true)}>
{t`Add`} {taxonomyDef.labelSingular || t`Term`}
</Button>
</div>
</div>
<div className="border rounded-lg">
<div className="flex items-center gap-4 py-2 px-4 border-b bg-kumo-tint/50 font-medium">
<div className="flex-1">{t`Name`}</div>
<div className="w-16 text-center">{t`Count`}</div>
<div className="w-24 text-center">{t`Actions`}</div>
</div>
{termsLoading ? (
<div className="p-8 text-center text-kumo-subtle">{t`Loading terms...`}</div>
) : terms.length === 0 ? (
<div className="p-8 text-center text-kumo-subtle">
{t`No ${taxonomyDef.label.toLowerCase()} yet. Create one to get started.`}
</div>
) : (
<div>
{terms.map((term) => (
<TermRow key={term.id} term={term} onEdit={handleEdit} onDelete={handleDelete} />
))}
</div>
)}
</div>
<TermFormDialog
open={formOpen}
onClose={handleCloseForm}
taxonomyName={taxonomyName}
taxonomyDef={taxonomyDef}
term={editingTerm}
allTerms={flatTerms}
/>
<ConfirmDialog
open={!!deleteTarget}
onClose={() => {
setDeleteTarget(null);
deleteMutation.reset();
}}
title={t`Delete ${taxonomyDef.labelSingular || "Term"}?`}
description={
<>{t`This will permanently delete "${deleteTarget?.label}" and remove it from all content.`}</>
}
confirmLabel={t`Delete`}
pendingLabel={t`Deleting...`}
isPending={deleteMutation.isPending}
error={deleteMutation.error}
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
/>
<CreateTaxonomyDialog
open={createTaxonomyOpen}
onClose={() => setCreateTaxonomyOpen(false)}
onCreated={() => {
setCreateTaxonomyOpen(false);
toastManager.add({ title: t`Taxonomy created` });
}}
/>
</div>
);
}

View File

@@ -0,0 +1,507 @@
/**
* Taxonomy Sidebar for Content Editor
*
* Shows taxonomy selection UI in the content editor sidebar.
* - Checkbox tree for hierarchical taxonomies (categories)
* - Tag input for flat taxonomies (tags)
*/
import { Button, Input, Label, Toast } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { Plus, X } from "@phosphor-icons/react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { apiFetch, parseApiResponse, throwResponseError } from "../lib/api/client.js";
import { createTerm } from "../lib/api/taxonomies.js";
import { termExactMatches, termMatches } from "../lib/taxonomy-match.js";
import { slugify } from "../lib/utils.js";
interface TaxonomyTerm {
id: string;
name: string;
slug: string;
label: string;
parentId?: string;
children: TaxonomyTerm[];
}
interface TaxonomyDef {
id: string;
name: string;
label: string;
labelSingular?: string;
hierarchical: boolean;
collections: string[];
}
interface TaxonomySidebarProps {
collection: string;
entryId?: string;
onChange?: (taxonomyName: string, termIds: string[]) => void;
}
/**
* Fetch taxonomy definitions
*/
async function fetchTaxonomyDefs(): Promise<TaxonomyDef[]> {
const res = await apiFetch(`/_emdash/api/taxonomies`);
const data = await parseApiResponse<{ taxonomies: TaxonomyDef[] }>(
res,
"Failed to fetch taxonomies",
);
return data.taxonomies;
}
/**
* Fetch terms for a taxonomy
*/
async function fetchTerms(taxonomyName: string): Promise<TaxonomyTerm[]> {
const res = await apiFetch(`/_emdash/api/taxonomies/${taxonomyName}/terms`);
const data = await parseApiResponse<{ terms: TaxonomyTerm[] }>(res, "Failed to fetch terms");
return data.terms;
}
/**
* Fetch entry terms
*/
async function fetchEntryTerms(
collection: string,
entryId: string,
taxonomy: string,
): Promise<TaxonomyTerm[]> {
const res = await apiFetch(`/_emdash/api/content/${collection}/${entryId}/terms/${taxonomy}`);
const data = await parseApiResponse<{ terms: TaxonomyTerm[] }>(
res,
"Failed to fetch entry terms",
);
return data.terms;
}
/**
* Set entry terms
*/
async function setEntryTerms(
collection: string,
entryId: string,
taxonomy: string,
termIds: string[],
): Promise<void> {
const res = await apiFetch(`/_emdash/api/content/${collection}/${entryId}/terms/${taxonomy}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ termIds }),
});
if (!res.ok) await throwResponseError(res, "Failed to set entry terms");
}
/**
* Checkbox tree for hierarchical taxonomies
*/
function CategoryCheckboxTree({
term,
level = 0,
selectedIds,
onToggle,
}: {
term: TaxonomyTerm;
level?: number;
selectedIds: Set<string>;
onToggle: (termId: string) => void;
}) {
const isChecked = selectedIds.has(term.id);
return (
<div>
<label
className="flex items-center py-1 cursor-pointer hover:bg-kumo-tint/50 rounded px-2"
style={{ marginInlineStart: `${level}rem` }}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => onToggle(term.id)}
className="me-2"
/>
<span className="text-sm">{term.label}</span>
</label>
{term.children.map((child) => (
<CategoryCheckboxTree
key={child.id}
term={child}
level={level + 1}
selectedIds={selectedIds}
onToggle={onToggle}
/>
))}
</div>
);
}
/**
* Tag input for flat taxonomies
*/
function TagInput({
terms,
selectedIds,
onAdd,
onRemove,
onCreate,
isCreating,
label,
}: {
terms: TaxonomyTerm[];
selectedIds: Set<string>;
onAdd: (termId: string) => void;
onRemove: (termId: string) => void;
onCreate: (label: string) => void;
isCreating: boolean;
label: string;
}) {
const { t } = useLingui();
const [input, setInput] = React.useState("");
const selectedTerms = terms.filter((term) => selectedIds.has(term.id));
const trimmedInput = input.trim();
const suggestions = React.useMemo(() => {
if (!trimmedInput) return [];
return terms
.filter((term) => !selectedIds.has(term.id) && termMatches(term, trimmedInput))
.slice(0, 5);
}, [trimmedInput, terms, selectedIds]);
const hasExactMatch = React.useMemo(() => {
if (!trimmedInput) return false;
return terms.some((term) => termExactMatches(term, trimmedInput));
}, [trimmedInput, terms]);
const showCreateOption = trimmedInput.length > 0 && !hasExactMatch;
const handleSelect = (term: TaxonomyTerm) => {
onAdd(term.id);
setInput("");
};
const handleCreate = () => {
if (!trimmedInput || isCreating) return;
onCreate(trimmedInput);
setInput("");
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
if (suggestions.length === 1 && !showCreateOption) {
handleSelect(suggestions[0]!);
} else if (showCreateOption && suggestions.length === 0) {
handleCreate();
}
}
};
return (
<div className="space-y-2">
{/* Selected tags */}
{selectedTerms.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedTerms.map((term) => (
<span
key={term.id}
className="inline-flex items-center gap-1 px-2 py-1 text-sm bg-kumo-tint rounded"
>
{term.label}
<button
type="button"
onClick={() => onRemove(term.id)}
className="hover:text-kumo-danger"
aria-label={t`Remove ${term.label}`}
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
{/* Input with autocomplete */}
<div className="relative">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t`Add tags...`}
aria-label={t`Add ${label}`}
className="text-sm"
/>
{/* Suggestions dropdown */}
{(suggestions.length > 0 || showCreateOption) && (
<div className="absolute top-full start-0 end-0 mt-1 bg-kumo-overlay border rounded-md shadow-lg z-10">
{suggestions.map((term) => (
<button
key={term.id}
type="button"
onClick={() => handleSelect(term)}
className="w-full text-start px-3 py-2 text-sm hover:bg-kumo-tint"
>
{term.label}
</button>
))}
{showCreateOption && (
<button
type="button"
onClick={handleCreate}
disabled={isCreating}
className="w-full text-start px-3 py-2 text-sm hover:bg-kumo-tint text-kumo-accent flex items-center gap-1 border-t"
>
<Plus className="w-3 h-3" />
{isCreating ? "Creating..." : `Create "${trimmedInput}"`}
</button>
)}
</div>
)}
</div>
</div>
);
}
/**
* Single taxonomy section
*/
function TaxonomySection({
taxonomy,
collection,
entryId,
onChange,
}: {
taxonomy: TaxonomyDef;
collection: string;
entryId?: string;
onChange?: (termIds: string[]) => void;
}) {
const { t } = useLingui();
const queryClient = useQueryClient();
const toastManager = Toast.useToastManager();
const [newCategoryLabel, setNewCategoryLabel] = React.useState("");
const [showCategoryInput, setShowCategoryInput] = React.useState(false);
const { data: terms = [] } = useQuery({
queryKey: ["taxonomy-terms", taxonomy.name],
queryFn: () => fetchTerms(taxonomy.name),
});
const { data: entryTerms = [] } = useQuery({
queryKey: ["entry-terms", collection, entryId, taxonomy.name],
queryFn: () => {
if (!entryId) return [];
return fetchEntryTerms(collection, entryId, taxonomy.name);
},
enabled: !!entryId,
});
const saveMutation = useMutation({
mutationFn: (termIds: string[]) => {
if (!entryId) throw new Error("No entry ID");
return setEntryTerms(collection, entryId, taxonomy.name, termIds);
},
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["entry-terms", collection, entryId, taxonomy.name],
});
toastManager.add({ title: t`${taxonomy.label} updated` });
},
onError: (error) => {
toastManager.add({
title: t`Failed to update ${taxonomy.label.toLowerCase()}`,
description: error instanceof Error ? error.message : t`An error occurred`,
type: "error",
});
},
});
const createTermMutation = useMutation({
mutationFn: (label: string) => createTerm(taxonomy.name, { slug: slugify(label), label }),
onSuccess: (newTerm) => {
void queryClient.invalidateQueries({ queryKey: ["taxonomy-terms", taxonomy.name] });
// Auto-select the newly created term
const newSelected = new Set(selectedIds);
newSelected.add(newTerm.id);
setSelectedIds(newSelected);
const termIdsArray = [...newSelected];
onChange?.(termIdsArray);
if (entryId) {
saveMutation.mutate(termIdsArray);
}
// Reset category input
setNewCategoryLabel("");
setShowCategoryInput(false);
},
});
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
// Sync selected IDs from entry terms
React.useEffect(() => {
setSelectedIds(new Set(entryTerms.map((term) => term.id)));
}, [entryTerms]);
const handleToggle = (termId: string) => {
const newSelected = new Set(selectedIds);
if (newSelected.has(termId)) {
newSelected.delete(termId);
} else {
newSelected.add(termId);
}
setSelectedIds(newSelected);
// Notify parent of change
const termIdsArray = [...newSelected];
onChange?.(termIdsArray);
// Auto-save if entry exists
if (entryId) {
saveMutation.mutate(termIdsArray);
}
};
const handleAdd = (termId: string) => {
handleToggle(termId);
};
const handleRemove = (termId: string) => {
handleToggle(termId);
};
const handleCreateCategory = () => {
const label = newCategoryLabel.trim();
if (!label || createTermMutation.isPending) return;
createTermMutation.mutate(label);
};
return (
<div className="space-y-2">
<Label className="text-sm font-medium">{taxonomy.label}</Label>
{taxonomy.hierarchical ? (
<>
{terms.length === 0 ? (
<p className="text-sm text-kumo-subtle">
{t`No ${taxonomy.label.toLowerCase()} available.`}
</p>
) : (
<div className="border rounded-md p-2 max-h-64 overflow-y-auto">
{terms.map((term) => (
<CategoryCheckboxTree
key={term.id}
term={term}
selectedIds={selectedIds}
onToggle={handleToggle}
/>
))}
</div>
)}
{/* Add new category inline */}
{showCategoryInput ? (
<div className="flex gap-1">
<Input
value={newCategoryLabel}
onChange={(e) => setNewCategoryLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleCreateCategory();
} else if (e.key === "Escape") {
setShowCategoryInput(false);
setNewCategoryLabel("");
}
}}
placeholder={t`New ${(taxonomy.labelSingular || taxonomy.label).toLowerCase()}`}
className="text-sm flex-1"
autoFocus
disabled={createTermMutation.isPending}
/>
<Button
type="button"
onClick={handleCreateCategory}
disabled={!newCategoryLabel.trim()}
loading={createTermMutation.isPending}
variant="primary"
>
{t`Add`}
</Button>
</div>
) : (
<button
type="button"
onClick={() => setShowCategoryInput(true)}
className="text-sm text-kumo-accent hover:underline flex items-center gap-1"
>
<Plus className="w-3 h-3" />
{t`Add new ${(taxonomy.labelSingular || taxonomy.label).toLowerCase()}`}
</button>
)}
{createTermMutation.error && (
<p className="text-sm text-kumo-danger">
{createTermMutation.error instanceof Error
? createTermMutation.error.message
: t`Failed to create term`}
</p>
)}
</>
) : (
<TagInput
terms={terms}
selectedIds={selectedIds}
onAdd={handleAdd}
onRemove={handleRemove}
onCreate={(label) => createTermMutation.mutate(label)}
isCreating={createTermMutation.isPending}
label={taxonomy.label}
/>
)}
</div>
);
}
/**
* Main TaxonomySidebar component
*/
export function TaxonomySidebar({ collection, entryId, onChange }: TaxonomySidebarProps) {
const { t } = useLingui();
const { data: taxonomies = [] } = useQuery({
queryKey: ["taxonomy-defs"],
queryFn: fetchTaxonomyDefs,
});
// Filter to taxonomies that apply to this collection
const applicableTaxonomies = taxonomies.filter((tax) => tax.collections.includes(collection));
if (applicableTaxonomies.length === 0) {
return null;
}
return (
<div className="space-y-6">
<div>
<h3 className="font-semibold mb-4">{t`Taxonomies`}</h3>
<div className="space-y-4">
{applicableTaxonomies.map((taxonomy) => (
<TaxonomySection
key={taxonomy.name}
taxonomy={taxonomy}
collection={collection}
entryId={entryId}
onChange={(termIds) => onChange?.(taxonomy.name, termIds)}
/>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,277 @@
/**
* Theme Marketplace Browse
*
* Visual-first grid of theme cards with large thumbnails.
* Navigates to theme detail on card click.
*/
import { Button } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import {
MagnifyingGlass,
Palette,
Warning,
ArrowsClockwise,
ArrowSquareOut,
Eye,
ShieldCheck,
} from "@phosphor-icons/react";
import { useInfiniteQuery, useMutation } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as React from "react";
import {
searchThemes,
generatePreviewUrl,
type ThemeSummary,
type ThemeSearchOpts,
} from "../lib/api/theme-marketplace.js";
type SortOption = "updated" | "created" | "name";
const SORT_LABELS: Record<SortOption, string> = {
updated: "Recently Updated",
created: "Newest",
name: "Name",
};
const VALID_SORTS = new Set<string>(["updated", "created", "name"]);
function isSortOption(value: string): value is SortOption {
return VALID_SORTS.has(value);
}
export function ThemeMarketplaceBrowse() {
const { t } = useLingui();
const [searchQuery, setSearchQuery] = React.useState("");
const [sort, setSort] = React.useState<SortOption>("updated");
const [debouncedQuery, setDebouncedQuery] = React.useState("");
React.useEffect(() => {
const timer = setTimeout(setDebouncedQuery, 300, searchQuery);
return () => clearTimeout(timer);
}, [searchQuery]);
const searchOpts: ThemeSearchOpts = {
q: debouncedQuery || undefined,
sort,
limit: 12,
};
const { data, isLoading, error, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ["themes", "search", searchOpts],
queryFn: ({ pageParam }) => searchThemes({ ...searchOpts, cursor: pageParam }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const themes = data?.pages.flatMap((p) => p.items);
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold">{t`Themes`}</h1>
<p className="mt-1 text-kumo-subtle">
{t`Browse themes and preview them with your own content.`}
</p>
</div>
{/* Search + Sort */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<MagnifyingGlass className="absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-kumo-subtle" />
<input
type="search"
placeholder={t`Search themes...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-md border bg-kumo-base px-3 py-2 ps-9 text-sm placeholder:text-kumo-subtle focus:outline-none focus:ring-2 focus:ring-kumo-ring"
/>
</div>
<select
value={sort}
onChange={(e) => {
const v = e.target.value;
if (isSortOption(v)) setSort(v);
}}
className="rounded-md border bg-kumo-base px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-kumo-ring"
aria-label={t`Sort themes`}
>
{Object.entries(SORT_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
{/* Error state */}
{error && (
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-6 text-center">
<Warning className="mx-auto h-8 w-8 text-kumo-danger" />
<h3 className="mt-3 font-medium text-kumo-danger">{t`Unable to reach marketplace`}</h3>
<p className="mt-1 text-sm text-kumo-subtle">
{error instanceof Error ? error.message : t`An error occurred`}
</p>
<Button variant="ghost" className="mt-4" onClick={() => void refetch()}>
<ArrowsClockwise className="me-2 h-4 w-4" />
{t`Retry`}
</Button>
</div>
)}
{/* Loading state — skeleton cards with thumbnail aspect ratio */}
{isLoading && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse rounded-lg border bg-kumo-base overflow-hidden">
<div className="aspect-video bg-kumo-tint" />
<div className="p-4 space-y-2">
<div className="h-4 w-32 rounded bg-kumo-tint" />
<div className="h-3 w-48 rounded bg-kumo-tint" />
<div className="h-3 w-20 rounded bg-kumo-tint" />
</div>
</div>
))}
</div>
)}
{/* Results grid */}
{themes && !isLoading && (
<>
{themes.length === 0 ? (
<div className="rounded-lg border bg-kumo-base p-8 text-center">
<Palette className="mx-auto h-12 w-12 text-kumo-subtle" />
<h3 className="mt-4 text-lg font-medium">{t`No themes found`}</h3>
<p className="mt-2 text-sm text-kumo-subtle">
{debouncedQuery
? t`No results for "${debouncedQuery}". Try a different search term.`
: t`The theme marketplace is empty. Check back later.`}
</p>
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{themes.map((theme) => (
<ThemeCard key={theme.id} theme={theme} />
))}
</div>
{hasNextPage && (
<div className="flex justify-center">
<Button
variant="outline"
onClick={() => void fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? t`Loading...` : t`Load more`}
</Button>
</div>
)}
</>
)}
</>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// ThemeCard
// ---------------------------------------------------------------------------
function ThemeCard({ theme }: { theme: ThemeSummary }) {
const { t } = useLingui();
const thumbnailUrl = theme.thumbnailUrl
? `/_emdash/api/admin/themes/marketplace/${encodeURIComponent(theme.id)}/thumbnail`
: null;
const previewMutation = useMutation({
mutationFn: () => generatePreviewUrl(theme.previewUrl),
onSuccess: (url) => {
window.open(url, "_blank", "noopener");
},
});
return (
<div className="group rounded-lg border bg-kumo-base overflow-hidden transition-colors hover:border-kumo-brand/50">
{/* Thumbnail */}
<Link
to={"/themes/marketplace/$themeId" as "/"}
params={{ themeId: theme.id }}
className="block"
>
{thumbnailUrl ? (
<img
src={thumbnailUrl}
alt={t`${theme.name} preview`}
className="aspect-video w-full object-cover bg-kumo-tint"
loading="lazy"
/>
) : (
<div className="aspect-video w-full bg-kumo-tint flex items-center justify-center">
<Palette className="h-12 w-12 text-kumo-subtle/40" />
</div>
)}
</Link>
{/* Info */}
<div className="p-4">
<Link
to={"/themes/marketplace/$themeId" as "/"}
params={{ themeId: theme.id }}
className="block"
>
<h3 className="font-semibold group-hover:text-kumo-brand truncate">{theme.name}</h3>
</Link>
<div className="flex items-center gap-2 mt-1 text-xs text-kumo-subtle">
<span>{theme.author.name}</span>
{theme.author.verified && <ShieldCheck className="h-3 w-3 text-kumo-brand" />}
</div>
{theme.description && (
<p className="mt-2 text-sm text-kumo-subtle line-clamp-2">{theme.description}</p>
)}
{/* Action buttons */}
<div className="mt-3 flex items-center gap-2">
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.preventDefault();
previewMutation.mutate();
}}
disabled={previewMutation.isPending}
>
<Eye className="me-1.5 h-3.5 w-3.5" />
{previewMutation.isPending ? t`Loading...` : t`Try with my data`}
</Button>
{theme.demoUrl && (
<Button
variant="ghost"
size="sm"
onClick={() => window.open(theme.demoUrl!, "_blank", "noopener")}
>
<ArrowSquareOut className="me-1.5 h-3.5 w-3.5" />
{t`Demo`}
</Button>
)}
</div>
{previewMutation.error && (
<p className="mt-2 text-xs text-kumo-danger">
{previewMutation.error instanceof Error
? previewMutation.error.message
: t`Failed to generate preview`}
</p>
)}
</div>
</div>
);
}
export default ThemeMarketplaceBrowse;

View File

@@ -0,0 +1,334 @@
/**
* Theme Marketplace Detail
*
* Full detail view for a marketplace theme:
* - Screenshot gallery
* - Description, author, license
* - "Try with my data" button
* - Demo + repository links
*/
import { Badge, Button } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import {
ArrowSquareOut,
Eye,
GithubLogo,
Globe,
Palette,
ShieldCheck,
X,
} from "@phosphor-icons/react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as React from "react";
import { fetchTheme, generatePreviewUrl } from "../lib/api/theme-marketplace.js";
import { ArrowPrev, CaretNext, CaretPrev } from "./ArrowIcons.js";
/** Only allow safe URL protocols for external links */
function isSafeUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "https:" || parsed.protocol === "http:";
} catch {
return false;
}
}
export interface ThemeMarketplaceDetailProps {
themeId: string;
}
export function ThemeMarketplaceDetail({ themeId }: ThemeMarketplaceDetailProps) {
const { t } = useLingui();
const [lightboxIndex, setLightboxIndex] = React.useState<number | null>(null);
const {
data: theme,
isLoading,
error,
} = useQuery({
queryKey: ["themes", "detail", themeId],
queryFn: () => fetchTheme(themeId),
});
const previewMutation = useMutation({
mutationFn: () => generatePreviewUrl(theme!.previewUrl),
onSuccess: (url) => {
window.open(url, "_blank", "noopener");
},
});
// Loading
if (isLoading) {
return (
<div className="space-y-6 animate-pulse">
<div className="h-6 w-48 rounded bg-kumo-tint" />
<div className="aspect-video max-w-2xl rounded-lg bg-kumo-tint" />
<div className="space-y-3">
<div className="h-4 w-64 rounded bg-kumo-tint" />
<div className="h-4 w-96 rounded bg-kumo-tint" />
</div>
</div>
);
}
// Error
if (error || !theme) {
return (
<div className="space-y-4">
<Link
to={"/themes/marketplace" as "/"}
className="inline-flex items-center gap-1 text-sm text-kumo-subtle hover:text-kumo-default"
>
<ArrowPrev className="h-4 w-4" />
{t`Back to Themes`}
</Link>
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-6 text-center">
<h3 className="font-medium text-kumo-danger">{t`Failed to load theme`}</h3>
<p className="mt-1 text-sm text-kumo-subtle">
{error instanceof Error ? error.message : t`Theme not found`}
</p>
</div>
</div>
);
}
const thumbnailUrl = theme.hasThumbnail
? `/_emdash/api/admin/themes/marketplace/${encodeURIComponent(theme.id)}/thumbnail`
: null;
return (
<div className="space-y-6">
{/* Back link */}
<Link
to={"/themes/marketplace" as "/"}
className="inline-flex items-center gap-1 text-sm text-kumo-subtle hover:text-kumo-default"
>
<ArrowPrev className="h-4 w-4" />
{t`Back to Themes`}
</Link>
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-4">
{thumbnailUrl ? (
<img src={thumbnailUrl} alt="" className="h-16 w-16 rounded-lg object-cover" />
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-kumo-brand/10">
<Palette className="h-8 w-8 text-kumo-brand" />
</div>
)}
<div>
<h1 className="text-2xl font-bold">{theme.name}</h1>
<div className="mt-1 flex items-center gap-2 text-sm text-kumo-subtle">
<span>{theme.author.name}</span>
{theme.author.verified && <ShieldCheck className="h-4 w-4 text-kumo-brand" />}
</div>
{theme.description && (
<p className="mt-2 text-sm text-kumo-subtle max-w-xl">{theme.description}</p>
)}
</div>
</div>
{/* Actions */}
<div className="flex gap-2 shrink-0">
<Button
variant="primary"
onClick={() => previewMutation.mutate()}
disabled={previewMutation.isPending}
>
<Eye className="me-2 h-4 w-4" />
{previewMutation.isPending ? t`Loading...` : t`Try with my data`}
</Button>
{theme.demoUrl && isSafeUrl(theme.demoUrl) && (
<Button
variant="outline"
onClick={() => window.open(theme.demoUrl!, "_blank", "noopener")}
>
<ArrowSquareOut className="me-2 h-4 w-4" />
{t`Demo`}
</Button>
)}
</div>
</div>
{previewMutation.error && (
<div className="rounded-md border border-kumo-danger/50 bg-kumo-danger/10 p-3 text-sm text-kumo-danger">
{previewMutation.error instanceof Error
? previewMutation.error.message
: t`Failed to generate preview URL`}
</div>
)}
{/* Screenshot gallery */}
{theme.screenshotCount > 0 && (
<div>
<h2 className="text-lg font-semibold mb-3">{t`Screenshots`}</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{theme.screenshotUrls.map((url, i) => (
<button
key={i}
className="rounded-lg border overflow-hidden hover:border-kumo-brand/50 transition-colors cursor-pointer"
onClick={() => setLightboxIndex(i)}
>
<img
src={url}
alt={t`Screenshot ${i + 1}`}
className="aspect-video w-full object-cover"
loading="lazy"
/>
</button>
))}
</div>
</div>
)}
{/* Details */}
<div className="grid gap-6 sm:grid-cols-2">
{/* Keywords */}
{theme.keywords.length > 0 && (
<div>
<h3 className="text-sm font-medium text-kumo-subtle mb-2">{t`Keywords`}</h3>
<div className="flex flex-wrap gap-1">
{theme.keywords.map((kw) => (
<Badge key={kw} variant="secondary">
{kw}
</Badge>
))}
</div>
</div>
)}
{/* License */}
{theme.license && (
<div>
<h3 className="text-sm font-medium text-kumo-subtle mb-2">{t`License`}</h3>
<p className="text-sm">{theme.license}</p>
</div>
)}
{/* Links */}
<div>
<h3 className="text-sm font-medium text-kumo-subtle mb-2">{t`Links`}</h3>
<div className="flex flex-col gap-1.5">
{theme.repositoryUrl && isSafeUrl(theme.repositoryUrl) && (
<a
href={theme.repositoryUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-kumo-brand hover:underline"
>
<GithubLogo className="h-4 w-4" />
{t`Repository`}
</a>
)}
{theme.homepageUrl && isSafeUrl(theme.homepageUrl) && (
<a
href={theme.homepageUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-kumo-brand hover:underline"
>
<Globe className="h-4 w-4" />
{t`Homepage`}
</a>
)}
</div>
</div>
</div>
{/* Lightbox */}
{lightboxIndex !== null && (
<Lightbox
urls={theme.screenshotUrls}
index={lightboxIndex}
onClose={() => setLightboxIndex(null)}
onPrev={() =>
setLightboxIndex((i) => (i !== null && i > 0 ? i - 1 : theme.screenshotUrls.length - 1))
}
onNext={() =>
setLightboxIndex((i) => (i !== null && i < theme.screenshotUrls.length - 1 ? i + 1 : 0))
}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Lightbox
// ---------------------------------------------------------------------------
function Lightbox({
urls,
index,
onClose,
onPrev,
onNext,
}: {
urls: string[];
index: number;
onClose: () => void;
onPrev: () => void;
onNext: () => void;
}) {
const { t } = useLingui();
React.useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
if (e.key === "ArrowLeft") onPrev();
if (e.key === "ArrowRight") onNext();
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [onClose, onPrev, onNext]);
const url = urls[index];
if (!url) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
onClick={onClose}
>
<div className="relative max-h-[90vh] max-w-[90vw]" onClick={(e) => e.stopPropagation()}>
<img src={url} alt={t`Screenshot ${index + 1}`} className="max-h-[85vh] rounded-lg" />
<button
onClick={onClose}
className="absolute -top-3 -end-3 rounded-full bg-kumo-base p-1.5 shadow-lg hover:bg-kumo-tint"
aria-label={t`Close`}
>
<X className="h-4 w-4" />
</button>
{urls.length > 1 && (
<>
<button
onClick={onPrev}
className="absolute start-2 top-1/2 -translate-y-1/2 rounded-full bg-kumo-base/80 p-2 shadow hover:bg-kumo-base"
aria-label={t`Previous`}
>
<CaretPrev className="h-5 w-5" />
</button>
<button
onClick={onNext}
className="absolute end-2 top-1/2 -translate-y-1/2 rounded-full bg-kumo-base/80 p-2 shadow hover:bg-kumo-base"
aria-label={t`Next`}
>
<CaretNext className="h-5 w-5" />
</button>
</>
)}
<div className="absolute bottom-2 start-1/2 -translate-x-1/2 rounded-full bg-kumo-base/80 px-3 py-1 text-xs">
{index + 1} / {urls.length}
</div>
</div>
</div>
);
}
export default ThemeMarketplaceDetail;

View File

@@ -0,0 +1,99 @@
import * as React from "react";
type Theme = "light" | "dark" | "system";
interface ThemeContextValue {
theme: Theme;
setTheme: (theme: Theme) => void;
/** The resolved theme (always "light" or "dark") */
resolvedTheme: "light" | "dark";
}
const ThemeContext = React.createContext<ThemeContextValue | undefined>(undefined);
const STORAGE_KEY = "emdash-theme";
function getSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function getStoredTheme(): Theme {
if (typeof window === "undefined") return "system";
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark" || stored === "system") {
return stored;
}
return "system";
}
export interface ThemeProviderProps {
children: React.ReactNode;
/** Default theme if none stored. Defaults to "system" */
defaultTheme?: Theme;
}
export function ThemeProvider({ children, defaultTheme = "system" }: ThemeProviderProps) {
const [theme, setThemeState] = React.useState<Theme>(() => {
const stored = getStoredTheme();
return stored === "system" ? defaultTheme : stored;
});
const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => {
if (theme === "system") return getSystemTheme();
return theme;
});
// Resolve the effective theme whenever the user preference changes
React.useEffect(() => {
if (theme === "system") {
setResolvedTheme(getSystemTheme());
} else {
setResolvedTheme(theme);
}
}, [theme]);
// Listen for OS preference changes when in system mode
React.useEffect(() => {
if (theme !== "system") return;
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => {
setResolvedTheme(e.matches ? "dark" : "light");
};
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}, [theme]);
// Sync DOM attributes with the resolved theme.
// data-mode drives Tailwind dark: utilities via @custom-variant.
// data-theme is reserved for visual identity overrides (e.g. "classic").
// Always set data-mode explicitly — relying on its absence + color-scheme
// does not activate Tailwind dark: utilities which require [data-mode="dark"].
React.useEffect(() => {
const root = document.documentElement;
root.setAttribute("data-theme", "classic");
root.setAttribute("data-mode", resolvedTheme);
}, [resolvedTheme]);
const setTheme = React.useCallback((newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem(STORAGE_KEY, newTheme);
}, []);
const value = React.useMemo(
() => ({ theme, setTheme, resolvedTheme }),
[theme, setTheme, resolvedTheme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const context = React.useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}

View File

@@ -0,0 +1,44 @@
import { Button } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { Sun, Moon, Monitor } from "@phosphor-icons/react";
import * as React from "react";
import { useTheme } from "./ThemeProvider";
/**
* Theme toggle button that cycles through: system -> light -> dark
*/
export function ThemeToggle() {
const { t } = useLingui();
const { theme, setTheme, resolvedTheme } = useTheme();
const cycleTheme = () => {
const order: ["system", "light", "dark"] = ["system", "light", "dark"];
const currentIndex = order.indexOf(theme);
const nextIndex = (currentIndex + 1) % order.length;
setTheme(order[nextIndex]!);
};
const resolvedLabel = resolvedTheme === "light" ? t`light` : t`dark`;
const label =
theme === "system" ? t`System (${resolvedLabel})` : theme === "light" ? t`Light` : t`Dark`;
return (
<Button
variant="ghost"
shape="square"
aria-label={t`Toggle theme (current: ${label})`}
onClick={cycleTheme}
title={t`Theme: ${label}`}
>
{theme === "system" ? (
<Monitor className="h-5 w-5" />
) : theme === "light" ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
<span className="sr-only">{t`Toggle theme (current: ${label})`}</span>
</Button>
);
}

View File

@@ -0,0 +1,149 @@
/**
* Welcome Modal
*
* Shown to new users on their first login to welcome them to EmDash.
*/
import { Button, Dialog } from "@cloudflare/kumo";
import type { MessageDescriptor } from "@lingui/core";
import { msg } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { X } from "@phosphor-icons/react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { apiFetch, throwResponseError } from "../lib/api/client";
import { LogoIcon } from "./Logo.js";
interface WelcomeModalProps {
open: boolean;
onClose: () => void;
userName?: string;
userRole: number;
}
const MSG_ROLE_ADMINISTRATOR = msg`Administrator`;
const MSG_ROLE_EDITOR = msg`Editor`;
const MSG_ROLE_AUTHOR = msg`Author`;
const MSG_ROLE_CONTRIBUTOR = msg`Contributor`;
const MSG_ROLE_SUBSCRIBER = msg`Subscriber`;
function roleDescriptor(role: number): MessageDescriptor {
if (role >= 50) return MSG_ROLE_ADMINISTRATOR;
if (role >= 40) return MSG_ROLE_EDITOR;
if (role >= 30) return MSG_ROLE_AUTHOR;
if (role >= 20) return MSG_ROLE_CONTRIBUTOR;
return MSG_ROLE_SUBSCRIBER;
}
const MSG_ACCOUNT_CREATED = msg`Your account has been created successfully.`;
const MSG_YOUR_ROLE = msg`Your Role`;
const MSG_SCOPE_ADMIN = msg`You have full access to manage this site, including users, settings, and all content.`;
const MSG_SCOPE_EDITOR = msg`You can manage content, media, menus, and taxonomies.`;
const MSG_SCOPE_AUTHOR = msg`You can create and edit your own content.`;
const MSG_SCOPE_CONTRIBUTOR = msg`You can view and contribute to the site.`;
function scopeDescriptor(isAdmin: boolean, userRole: number): MessageDescriptor {
if (isAdmin) return MSG_SCOPE_ADMIN;
if (userRole >= 40) return MSG_SCOPE_EDITOR;
if (userRole >= 30) return MSG_SCOPE_AUTHOR;
return MSG_SCOPE_CONTRIBUTOR;
}
const MSG_ADMIN_INVITE = msg`As an administrator, you can invite other users from the Users section.`;
const MSG_CLOSE = msg`Close`;
async function dismissWelcome(): Promise<void> {
const response = await apiFetch("/_emdash/api/auth/me", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "dismissWelcome" }),
});
if (!response.ok) await throwResponseError(response, "Failed to dismiss welcome");
}
export function WelcomeModal({ open, onClose, userName, userRole }: WelcomeModalProps) {
const { t } = useLingui();
const queryClient = useQueryClient();
const dismissMutation = useMutation({
mutationFn: dismissWelcome,
onSuccess: () => {
// Update the cached user data to reflect that they've seen the welcome
queryClient.setQueryData(["currentUser"], (old: unknown) => {
if (old && typeof old === "object") {
return { ...old, isFirstLogin: false };
}
return old;
});
onClose();
},
onError: () => {
// Still close on error - don't block the user
onClose();
},
});
const handleGetStarted = () => {
dismissMutation.mutate();
};
const roleLabel = t(roleDescriptor(userRole));
const isAdmin = userRole >= 50;
const firstName = userName?.split(" ")?.[0]?.trim() ?? "";
const titleDescriptor =
firstName.length > 0 ? msg`Welcome to EmDash, ${firstName}!` : msg`Welcome to EmDash!`;
return (
<Dialog.Root open={open} onOpenChange={(isOpen: boolean) => !isOpen && handleGetStarted()}>
<Dialog className="p-6 sm:max-w-md">
<div className="flex items-start justify-between gap-4">
<div className="flex-1" />
<Dialog.Close
aria-label={t(MSG_CLOSE)}
render={(props) => (
<Button
{...props}
variant="ghost"
shape="square"
aria-label={t(MSG_CLOSE)}
className="absolute end-4 top-4"
>
<X className="h-4 w-4" />
<span className="sr-only">{t(MSG_CLOSE)}</span>
</Button>
)}
/>
</div>
<div className="flex flex-col space-y-1.5 text-center sm:text-center">
<div className="mx-auto mb-4">
<LogoIcon className="h-16 w-16" />
</div>
<Dialog.Title className="text-2xl font-semibold leading-none tracking-tight">
{t(titleDescriptor)}
</Dialog.Title>
<Dialog.Description className="text-base text-kumo-subtle">
{t(MSG_ACCOUNT_CREATED)}
</Dialog.Description>
</div>
<div className="space-y-4 py-4">
<div className="rounded-lg bg-kumo-tint p-4">
<div className="text-sm font-medium">{t(MSG_YOUR_ROLE)}</div>
<div className="text-lg font-semibold text-kumo-brand">{roleLabel}</div>
<p className="text-sm text-kumo-subtle mt-1">{t(scopeDescriptor(isAdmin, userRole))}</p>
</div>
{isAdmin && <p className="text-sm text-kumo-subtle">{t(MSG_ADMIN_INVITE)}</p>}
</div>
<div className="flex flex-col-reverse sm:flex-row sm:justify-center sm:space-x-2">
<Button onClick={handleGetStarted} disabled={dismissMutation.isPending} size="lg">
{dismissMutation.isPending ? t`Loading...` : t`Get Started`}
</Button>
</div>
</Dialog>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,958 @@
/**
* Widgets page component
*
* Manage widget areas and widgets with drag-and-drop support.
* Available widgets can be dragged from the palette into widget areas.
* Widgets within an area can be reordered via drag-and-drop.
*/
import { Button, Dialog, Input, Label, Select, Switch, Toast } from "@cloudflare/kumo";
import {
DndContext,
DragOverlay,
type CollisionDetection,
type DragEndEvent,
type DragStartEvent,
KeyboardSensor,
closestCenter,
rectIntersection,
useSensor,
useSensors,
useDraggable,
useDroppable,
PointerSensor,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { MessageDescriptor } from "@lingui/core";
import { msg } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { Plus, DotsSixVertical, Trash, CaretDown } from "@phosphor-icons/react";
import { X } from "@phosphor-icons/react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import {
fetchManifest,
fetchWidgetAreas,
fetchWidgetComponents,
fetchMenus,
createWidgetArea,
createWidget,
updateWidget,
deleteWidget,
deleteWidgetArea,
reorderWidgets,
type WidgetArea,
type Widget,
type WidgetComponent,
type CreateWidgetInput,
type UpdateWidgetInput,
} from "../lib/api";
import { getPluginBlocks } from "../lib/pluginBlocks";
import { CaretNext } from "./ArrowIcons.js";
import { ConfirmDialog } from "./ConfirmDialog.js";
import { DialogError, getMutationError } from "./DialogError.js";
import { ImageDetailPanel, type ImageAttributes } from "./editor/ImageDetailPanel";
import {
PortableTextEditor,
type BlockSidebarPanel,
type PluginBlockDef,
} from "./PortableTextEditor";
/** Palette item types that can be dragged into areas */
interface PaletteItemData {
source: "palette";
widgetInput: CreateWidgetInput;
label: string;
}
/** Identifies an existing widget being reordered */
interface ExistingWidgetData {
source: "area";
areaName: string;
}
type DragItemData = PaletteItemData | ExistingWidgetData;
function isPaletteItem(data: DragItemData): data is PaletteItemData {
return data.source === "palette";
}
/** Built-in widget types available in the palette */
const BUILTIN_WIDGETS: Array<{
id: string;
label: MessageDescriptor;
description: MessageDescriptor;
input: CreateWidgetInput;
}> = [
{
id: "palette-content",
label: msg`Content Block`,
description: msg`Rich text content`,
input: { type: "content" },
},
{
id: "palette-menu",
label: msg`Menu`,
description: msg`Display a navigation menu`,
input: { type: "menu" },
},
];
export function Widgets() {
const { t } = useLingui();
const queryClient = useQueryClient();
const toastManager = Toast.useToastManager();
const [isCreateAreaOpen, setIsCreateAreaOpen] = React.useState(false);
const [createAreaError, setCreateAreaError] = React.useState<string | null>(null);
const [activeId, setActiveId] = React.useState<string | null>(null);
const [activeDragData, setActiveDragData] = React.useState<DragItemData | null>(null);
const [expandedWidgets, setExpandedWidgets] = React.useState<Set<string>>(new Set());
const [blockSidebarPanel, setBlockSidebarPanel] = React.useState<BlockSidebarPanel | null>(null);
// Track palette drag source across the full drag lifecycle (including drop animation)
const draggingFromPaletteRef = React.useRef(false);
const handleBlockSidebarOpen = React.useCallback((panel: BlockSidebarPanel) => {
setBlockSidebarPanel((prev) => {
// Close any existing panel before opening a new one so only one is ever active
prev?.onClose();
return panel;
});
}, []);
const handleBlockSidebarClose = React.useCallback(() => {
setBlockSidebarPanel((prev) => {
prev?.onClose();
return null;
});
}, []);
const { data: areas = [], isLoading } = useQuery({
queryKey: ["widget-areas"],
queryFn: fetchWidgetAreas,
});
const { data: components = [] } = useQuery({
queryKey: ["widget-components"],
queryFn: fetchWidgetComponents,
});
const { data: manifest } = useQuery({
queryKey: ["manifest"],
queryFn: fetchManifest,
});
const pluginBlocks = React.useMemo(() => (manifest ? getPluginBlocks(manifest) : []), [manifest]);
const createAreaMutation = useMutation({
mutationFn: createWidgetArea,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["widget-areas"] });
setIsCreateAreaOpen(false);
toastManager.add({ title: "Widget area created" });
},
onError: (error: Error) => {
setCreateAreaError(error.message);
},
});
const createWidgetMutation = useMutation({
mutationFn: ({ areaName, input }: { areaName: string; input: CreateWidgetInput }) =>
createWidget(areaName, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["widget-areas"] });
toastManager.add({ title: "Widget added" });
},
onError: (error: Error) => {
toastManager.add({
title: "Error adding widget",
description: error.message,
type: "error",
});
},
});
const handleCreateArea = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setCreateAreaError(null);
const formData = new FormData(e.currentTarget);
const nameVal = formData.get("name");
const labelVal = formData.get("label");
const descVal = formData.get("description");
createAreaMutation.mutate({
name: typeof nameVal === "string" ? nameVal : "",
label: typeof labelVal === "string" ? labelVal : "",
description: typeof descVal === "string" ? descVal : "",
});
};
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// Custom collision detection: palette items use rectIntersection (anywhere
// over the area counts) and only match area:* droppables. Existing widgets
// use closestCenter for precise reorder positioning.
const collisionDetection: CollisionDetection = React.useCallback((args) => {
const dragData = args.active.data.current as DragItemData | undefined;
if (dragData && isPaletteItem(dragData)) {
// Only consider area droppables, use generous rect intersection
const areaContainers = args.droppableContainers.filter((c) =>
String(c.id).startsWith("area:"),
);
return rectIntersection({ ...args, droppableContainers: areaContainers });
}
return closestCenter(args);
}, []);
const handleDragStart = (event: DragStartEvent) => {
const id = String(event.active.id);
const data = (event.active.data.current as DragItemData) ?? null;
setActiveId(id);
setActiveDragData(data);
draggingFromPaletteRef.current = data !== null && isPaletteItem(data);
};
const reorderMutation = useMutation({
mutationFn: ({ areaName, widgetIds }: { areaName: string; widgetIds: string[] }) =>
reorderWidgets(areaName, widgetIds),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["widget-areas"] });
},
onError: (error: Error) => {
toastManager.add({
title: "Error reordering widgets",
description: error.message,
type: "error",
});
},
});
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
const dragData = active.data.current as DragItemData | undefined;
setActiveId(null);
setActiveDragData(null);
if (!over || !dragData) return;
// Case 1: Dragging from palette into an area
if (isPaletteItem(dragData)) {
const overId = String(over.id);
// The drop target is a widget area (droppable id = "area:{name}")
if (overId.startsWith("area:")) {
const areaName = overId.slice(5);
createWidgetMutation.mutate({
areaName,
input: dragData.widgetInput,
});
}
return;
}
// Case 2: Reordering within an area
if (active.id === over.id) return;
const sourceArea = areas.find((area) => area.widgets?.some((w) => w.id === active.id));
if (!sourceArea?.widgets) return;
const oldIndex = sourceArea.widgets.findIndex((w) => w.id === active.id);
const newIndex = sourceArea.widgets.findIndex((w) => w.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const newWidgets = [...sourceArea.widgets];
const [movedWidget] = newWidgets.splice(oldIndex, 1);
if (!movedWidget) return;
newWidgets.splice(newIndex, 0, movedWidget);
reorderMutation.mutate({
areaName: sourceArea.name,
widgetIds: newWidgets.map((w) => w.id),
});
};
const toggleWidget = (widgetId: string) => {
setExpandedWidgets((prev) => {
const next = new Set(prev);
if (next.has(widgetId)) {
next.delete(widgetId);
} else {
next.add(widgetId);
}
return next;
});
};
// Build the palette label for the drag overlay
const activePaletteLabel =
activeDragData && isPaletteItem(activeDragData) ? activeDragData.label : null;
// Find the existing widget being dragged for overlay
const activeWidget =
activeId && activeDragData && !isPaletteItem(activeDragData)
? areas.flatMap((a) => a.widgets ?? []).find((w) => w.id === activeId)
: null;
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-kumo-subtle">Loading widgets...</div>
</div>
);
}
return (
<DndContext
sensors={sensors}
collisionDetection={collisionDetection}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Widgets</h1>
<p className="text-kumo-subtle">Manage content widgets in your widget areas</p>
</div>
<Dialog.Root
open={isCreateAreaOpen}
onOpenChange={(open) => {
setIsCreateAreaOpen(open);
if (!open) setCreateAreaError(null);
}}
>
<Dialog.Trigger
render={(props) => (
<Button {...props} icon={<Plus />}>
Add Widget Area
</Button>
)}
/>
<Dialog className="p-6" size="lg">
<div className="flex items-start justify-between gap-4 mb-4">
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
Create Widget Area
</Dialog.Title>
<Dialog.Close
aria-label="Close"
render={(props) => (
<Button
{...props}
variant="ghost"
shape="square"
aria-label="Close"
className="absolute end-4 top-4"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
)}
/>
</div>
<form onSubmit={handleCreateArea} className="space-y-4">
<Input
label="Name"
name="name"
required
placeholder="sidebar"
pattern="[a-z0-9\-]+"
/>
<Input label="Label" name="label" required placeholder="Main Sidebar" />
<Input
label="Description"
name="description"
placeholder="Appears on posts and pages"
/>
<DialogError
message={createAreaError || getMutationError(createAreaMutation.error)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setIsCreateAreaOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={createAreaMutation.isPending}>
Create
</Button>
</div>
</form>
</Dialog>
</Dialog.Root>
</div>
<div className="grid grid-cols-12 gap-6">
{/* Available Widgets (draggable palette) */}
<div className="col-span-4">
<div className="rounded-lg border bg-kumo-base p-6 space-y-4">
<h2 className="text-xl font-semibold">Available Widgets</h2>
<p className="text-sm text-kumo-subtle">Drag widgets into an area to add them</p>
<div className="space-y-2">
{BUILTIN_WIDGETS.map((item) => (
<DraggablePaletteItem
key={item.id}
id={item.id}
label={t(item.label)}
description={t(item.description)}
widgetInput={{ ...item.input, title: t(item.label) }}
/>
))}
{components.map((comp) => (
<DraggablePaletteItem
key={`palette-comp-${comp.id}`}
id={`palette-comp-${comp.id}`}
label={comp.label}
description={comp.description}
widgetInput={{
type: "component",
title: comp.label,
componentId: comp.id,
}}
/>
))}
</div>
</div>
</div>
{/* Widget Areas (droppable + sortable) */}
<div className="col-span-8 space-y-4">
{areas.length === 0 ? (
<div className="rounded-lg border bg-kumo-base p-12 text-center">
<p className="text-kumo-subtle">No widget areas yet. Create one to get started.</p>
</div>
) : (
areas.map((area) => (
<WidgetAreaPanel
key={area.id}
area={area}
expandedWidgets={expandedWidgets}
onToggleWidget={toggleWidget}
isDraggingPalette={activeDragData !== null && isPaletteItem(activeDragData)}
components={components}
pluginBlocks={pluginBlocks}
onBlockSidebarOpen={handleBlockSidebarOpen}
onBlockSidebarClose={handleBlockSidebarClose}
/>
))
)}
</div>
</div>
</div>
{/* Drag overlay — no drop animation for palette items (source stays in place).
Use ref because state is cleared in handleDragEnd before animation runs. */}
<DragOverlay dropAnimation={draggingFromPaletteRef.current ? null : undefined}>
{activePaletteLabel ? (
<div className="rounded border bg-kumo-base p-3 shadow-lg opacity-90">
<div className="font-medium">{activePaletteLabel}</div>
</div>
) : activeWidget ? (
<div className="rounded border bg-kumo-base p-3 shadow-lg opacity-90">
<div className="flex items-center gap-2">
<DotsSixVertical className="h-4 w-4 text-kumo-subtle" />
<span className="font-medium">{activeWidget.title || "Untitled Widget"}</span>
<span className="text-xs text-kumo-subtle">({activeWidget.type})</span>
</div>
</div>
) : null}
</DragOverlay>
{/* A single block-sidebar panel for the whole page — ensures only one is ever
open at a time, preventing stacked fixed overlays and duplicated window listeners. */}
{blockSidebarPanel?.type === "image" && (
<ImageDetailPanel
attributes={blockSidebarPanel.attrs as unknown as ImageAttributes}
onUpdate={(attrs) =>
blockSidebarPanel.onUpdate(attrs as unknown as Record<string, unknown>)
}
onReplace={(attrs) =>
blockSidebarPanel.onReplace(attrs as unknown as Record<string, unknown>)
}
onDelete={() => {
blockSidebarPanel.onDelete();
setBlockSidebarPanel(null);
}}
onClose={handleBlockSidebarClose}
/>
)}
</DndContext>
);
}
/** A draggable item in the available widgets palette */
function DraggablePaletteItem({
id,
label,
description,
widgetInput,
}: {
id: string;
label: string;
description?: string;
widgetInput: CreateWidgetInput;
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id,
data: {
source: "palette",
widgetInput,
label,
} satisfies PaletteItemData,
});
return (
<div
ref={setNodeRef}
{...attributes}
{...listeners}
className={`p-3 rounded border cursor-grab active:cursor-grabbing select-none ${
isDragging ? "opacity-50" : "hover:bg-kumo-tint"
}`}
>
<div className="font-medium">{label}</div>
{description && <div className="text-sm text-kumo-subtle">{description}</div>}
</div>
);
}
function WidgetAreaPanel({
area,
expandedWidgets,
onToggleWidget,
isDraggingPalette,
components,
pluginBlocks,
onBlockSidebarOpen,
onBlockSidebarClose,
}: {
area: WidgetArea;
expandedWidgets: Set<string>;
onToggleWidget: (id: string) => void;
isDraggingPalette: boolean;
components: WidgetComponent[];
pluginBlocks: PluginBlockDef[];
onBlockSidebarOpen: (panel: BlockSidebarPanel) => void;
onBlockSidebarClose: () => void;
}) {
const queryClient = useQueryClient();
const toastManager = Toast.useToastManager();
const [deleteAreaName, setDeleteAreaName] = React.useState<string | null>(null);
// Make the area a droppable target for palette items
const { setNodeRef: setDropRef, isOver } = useDroppable({
id: `area:${area.name}`,
});
const deleteAreaMutation = useMutation({
mutationFn: deleteWidgetArea,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["widget-areas"] });
setDeleteAreaName(null);
toastManager.add({ title: "Widget area deleted" });
},
});
const hasWidgets = area.widgets && area.widgets.length > 0;
return (
<div
className={`rounded-lg border bg-kumo-base transition-colors ${isOver ? "ring-2 ring-kumo-brand" : ""}`}
>
<div className="p-4 border-b flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">{area.label}</h3>
{area.description && <p className="text-sm text-kumo-subtle">{area.description}</p>}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteAreaName(area.name)}
aria-label={`Delete ${area.label} widget area`}
>
<Trash className="h-4 w-4" />
</Button>
</div>
<div ref={setDropRef} className="p-4 space-y-2 min-h-[80px]">
{hasWidgets ? (
<SortableContext
items={area.widgets!.map((w) => w.id)}
strategy={verticalListSortingStrategy}
>
{area.widgets!.map((widget) => (
<WidgetItem
key={widget.id}
widget={widget}
areaName={area.name}
isExpanded={expandedWidgets.has(widget.id)}
onToggle={() => onToggleWidget(widget.id)}
components={components}
pluginBlocks={pluginBlocks}
onBlockSidebarOpen={onBlockSidebarOpen}
onBlockSidebarClose={onBlockSidebarClose}
/>
))}
</SortableContext>
) : null}
{/* Drop zone hint — shown when dragging a palette item */}
{isDraggingPalette && (
<div
className={`text-center py-4 rounded border-2 border-dashed transition-colors ${
isOver
? "border-kumo-brand bg-kumo-brand/5 text-kumo-brand"
: "border-kumo-subtle/30 text-kumo-subtle"
}`}
>
{isOver ? "Drop to add widget" : "Drag here to add"}
</div>
)}
{!hasWidgets && !isDraggingPalette && (
<div className="text-center py-8 text-kumo-subtle">Drag widgets here to add them</div>
)}
</div>
<ConfirmDialog
open={deleteAreaName === area.name}
onClose={() => {
setDeleteAreaName(null);
deleteAreaMutation.reset();
}}
title="Delete Widget Area?"
description="This will delete the widget area and all its widgets. This action cannot be undone."
confirmLabel="Delete"
pendingLabel="Deleting..."
isPending={deleteAreaMutation.isPending}
error={deleteAreaMutation.error}
onConfirm={() => deleteAreaMutation.mutate(area.name)}
/>
</div>
);
}
function WidgetItem({
widget,
areaName,
isExpanded,
onToggle,
components,
pluginBlocks,
onBlockSidebarOpen,
onBlockSidebarClose,
}: {
widget: Widget;
areaName: string;
isExpanded: boolean;
onToggle: () => void;
components: WidgetComponent[];
pluginBlocks: PluginBlockDef[];
onBlockSidebarOpen: (panel: BlockSidebarPanel) => void;
onBlockSidebarClose: () => void;
}) {
const queryClient = useQueryClient();
const toastManager = Toast.useToastManager();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: widget.id,
data: {
source: "area",
areaName,
} satisfies ExistingWidgetData,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const deleteMutation = useMutation({
mutationFn: () => deleteWidget(areaName, widget.id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["widget-areas"] });
toastManager.add({ title: "Widget deleted" });
},
onError: (error: Error) => {
toastManager.add({
title: "Error",
description: error.message,
type: "error",
});
},
});
const updateMutation = useMutation({
mutationFn: (input: UpdateWidgetInput) => updateWidget(areaName, widget.id, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["widget-areas"] });
toastManager.add({ title: "Widget updated" });
},
onError: (error: Error) => {
toastManager.add({
title: "Error updating widget",
description: error.message,
type: "error",
});
},
});
return (
<div
ref={setNodeRef}
style={style}
className={`rounded border bg-kumo-base p-3 ${isDragging ? "opacity-50" : ""}`}
>
<div className="flex items-center gap-2">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing"
aria-label={`Drag to reorder ${widget.title || "widget"}`}
>
<DotsSixVertical className="h-4 w-4 text-kumo-subtle" />
</button>
<button onClick={onToggle} className="flex-1 text-start" aria-expanded={isExpanded}>
<div className="flex items-center gap-2">
{isExpanded ? <CaretDown className="h-4 w-4" /> : <CaretNext className="h-4 w-4" />}
<span className="font-medium">{widget.title || "Untitled Widget"}</span>
<span className="text-xs text-kumo-subtle">({widget.type})</span>
</div>
</button>
<Button
variant="ghost"
size="sm"
onClick={() => deleteMutation.mutate()}
aria-label={`Delete ${widget.title || "widget"}`}
>
<Trash className="h-4 w-4" />
</Button>
</div>
{isExpanded && (
<WidgetEditor
widget={widget}
components={components}
pluginBlocks={pluginBlocks}
onSave={(input) => updateMutation.mutate(input)}
isSaving={updateMutation.isPending}
onBlockSidebarOpen={onBlockSidebarOpen}
onBlockSidebarClose={onBlockSidebarClose}
/>
)}
</div>
);
}
/** Inline editor form for a widget, rendered when the widget is expanded */
function WidgetEditor({
widget,
components,
pluginBlocks,
onSave,
isSaving,
onBlockSidebarOpen,
onBlockSidebarClose,
}: {
widget: Widget;
components: WidgetComponent[];
pluginBlocks: PluginBlockDef[];
onSave: (input: UpdateWidgetInput) => void;
isSaving: boolean;
onBlockSidebarOpen: (panel: BlockSidebarPanel) => void;
onBlockSidebarClose: () => void;
}) {
const [title, setTitle] = React.useState(widget.title ?? "");
const [content, setContent] = React.useState<unknown[]>(
Array.isArray(widget.content) ? widget.content : [],
);
const [menuName, setMenuName] = React.useState(widget.menuName ?? "");
const [componentId, setComponentId] = React.useState(widget.componentId ?? "");
const [componentProps, setComponentProps] = React.useState<Record<string, unknown>>(
widget.componentProps ?? {},
);
const { data: menus = [] } = useQuery({
queryKey: ["menus"],
queryFn: fetchMenus,
enabled: widget.type === "menu",
});
const selectedComponent = components.find((c) => c.id === componentId);
const handleSave = () => {
const input: UpdateWidgetInput = { title };
if (widget.type === "content") {
input.content = content;
} else if (widget.type === "menu") {
input.menuName = menuName;
} else if (widget.type === "component") {
input.componentId = componentId;
input.componentProps = componentProps;
}
onSave(input);
};
return (
<div className="mt-3 p-3 bg-kumo-tint rounded space-y-4">
<Input
label="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Widget title"
/>
{widget.type === "content" && (
<div>
<Label className="text-sm font-medium mb-2 block">Content</Label>
<PortableTextEditor
value={content as Parameters<typeof PortableTextEditor>[0]["value"]}
onChange={(value) => setContent(value as unknown[])}
minimal
placeholder="Write widget content..."
pluginBlocks={pluginBlocks}
onBlockSidebarOpen={onBlockSidebarOpen}
onBlockSidebarClose={onBlockSidebarClose}
/>
</div>
)}
{widget.type === "menu" && (
<Select
label="Menu"
value={menuName}
onValueChange={(v) => setMenuName(v ?? "")}
items={Object.fromEntries(menus.map((m) => [m.name, m.label || m.name]))}
>
<Select.Option value="">Select a menu...</Select.Option>
{menus.map((m) => (
<Select.Option key={m.name} value={m.name}>
{m.label || m.name}
</Select.Option>
))}
</Select>
)}
{widget.type === "component" && (
<>
<Select
label="Component"
value={componentId}
onValueChange={(v) => {
setComponentId(v ?? "");
// Reset props when component changes
if (v !== componentId) {
const comp = components.find((c) => c.id === v);
if (comp) {
const defaults: Record<string, unknown> = {};
for (const [key, def] of Object.entries(comp.props)) {
defaults[key] = def.default ?? "";
}
setComponentProps(defaults);
} else {
setComponentProps({});
}
}
}}
items={Object.fromEntries(components.map((c) => [c.id, c.label]))}
>
<Select.Option value="">Select a component...</Select.Option>
{components.map((c) => (
<Select.Option key={c.id} value={c.id}>
{c.label}
</Select.Option>
))}
</Select>
{selectedComponent &&
Object.entries(selectedComponent.props).map(([key, def]) => (
<ComponentPropField
key={key}
propKey={key}
def={def}
value={componentProps[key] ?? def.default ?? ""}
onChange={(v) => setComponentProps((prev) => ({ ...prev, [key]: v }))}
/>
))}
</>
)}
<div className="flex justify-end">
<Button size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save"}
</Button>
</div>
</div>
);
}
/** Renders a single prop field for a component widget based on PropDef type */
function ComponentPropField({
def,
value,
onChange,
}: {
propKey: string;
def: WidgetComponent["props"][string];
value: unknown;
onChange: (value: unknown) => void;
}) {
switch (def.type) {
case "string":
return (
<Input
label={def.label}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
/>
);
case "number":
return (
<Input
label={def.label}
type="number"
value={typeof value === "number" ? value : ""}
onChange={(e) => onChange(Number(e.target.value))}
/>
);
case "boolean":
return <Switch label={def.label} checked={Boolean(value)} onCheckedChange={onChange} />;
case "select": {
const items: Record<string, string> = {};
for (const opt of def.options ?? []) {
items[opt.value] = opt.label;
}
return (
<Select
label={def.label}
value={typeof value === "string" ? value : ""}
onValueChange={(v) => onChange(v ?? "")}
items={items}
>
{def.options?.map((opt) => (
<Select.Option key={opt.value} value={opt.value}>
{opt.label}
</Select.Option>
))}
</Select>
);
}
default:
return (
<Input
label={def.label}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
/>
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,370 @@
/**
* PasskeyLogin - WebAuthn authentication component
*
* Handles the passkey login flow:
* 1. Fetches authentication options from server
* 2. Triggers browser's WebAuthn credential assertion
* 3. Sends assertion back to server for verification
*
* Supports:
* - Discoverable credentials (passkey autofill)
* - Non-discoverable credentials (email-first flow)
*/
import { Button, Input } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import * as React from "react";
import { apiFetch, parseApiResponse } from "../../lib/api/client";
import {
isPasskeyEnvironmentUsable,
isWebAuthnSecureContext,
} from "../../lib/webauthn-environment";
// ============================================================================
// Constants
// ============================================================================
const BASE64URL_DASH_REGEX = /-/g;
const BASE64URL_UNDERSCORE_REGEX = /_/g;
const BASE64_PLUS_REGEX = /\+/g;
const BASE64_SLASH_REGEX = /\//g;
// ============================================================================
// WebAuthn types
// ============================================================================
interface PublicKeyCredentialRequestOptionsJSON {
challenge: string;
rpId: string;
timeout?: number;
userVerification?: "discouraged" | "preferred" | "required";
allowCredentials?: Array<{
type: "public-key";
id: string;
transports?: AuthenticatorTransport[];
}>;
}
interface AuthenticationResponse {
id: string;
rawId: string;
type: "public-key";
response: {
clientDataJSON: string;
authenticatorData: string;
signature: string;
userHandle?: string;
};
authenticatorAttachment?: "platform" | "cross-platform";
}
export interface PasskeyLoginProps {
/** Endpoint to get authentication options */
optionsEndpoint: string;
/** Endpoint to verify authentication */
verifyEndpoint: string;
/** Called on successful authentication */
onSuccess: (response: unknown) => void;
/** Called on error */
onError?: (error: Error) => void;
/** Show email input for non-discoverable flow */
showEmailInput?: boolean;
/** Button text */
buttonText?: string;
}
type LoginState =
| { status: "idle" }
| { status: "loading"; message: string }
| { status: "error"; message: string }
| { status: "success" };
/**
* Check if conditional mediation (autofill) is supported
*/
async function isConditionalMediationSupported(): Promise<boolean> {
if (!isPasskeyEnvironmentUsable()) return false;
try {
return (await PublicKeyCredential.isConditionalMediationAvailable?.()) ?? false;
} catch {
return false;
}
}
/**
* Convert base64url to ArrayBuffer
*/
function base64urlToBuffer(base64url: string): ArrayBuffer {
const base64 = base64url
.replace(BASE64URL_DASH_REGEX, "+")
.replace(BASE64URL_UNDERSCORE_REGEX, "/");
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
const binary = atob(base64 + padding);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
/**
* Convert ArrayBuffer to base64url (with padding for @oslojs/encoding compatibility)
*/
function bufferToBase64url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]!);
}
const base64 = btoa(binary);
// Convert to base64url but keep padding (required by @oslojs/encoding)
return base64.replace(BASE64_PLUS_REGEX, "-").replace(BASE64_SLASH_REGEX, "_");
}
/**
* PasskeyLogin Component
*/
export function PasskeyLogin({
optionsEndpoint,
verifyEndpoint,
onSuccess,
onError,
showEmailInput = false,
buttonText,
}: PasskeyLoginProps) {
const { t } = useLingui();
const resolvedButtonText = buttonText ?? t`Sign in with Passkey`;
const [state, setState] = React.useState<LoginState>({ status: "idle" });
const [email, setEmail] = React.useState("");
const [supportsConditional, setSupportsConditional] = React.useState(false);
const isSupported = React.useMemo(() => isPasskeyEnvironmentUsable(), []);
const insecureContext = React.useMemo(
() => typeof window !== "undefined" && !isWebAuthnSecureContext(),
[],
);
// Check conditional mediation support
React.useEffect(() => {
void isConditionalMediationSupported().then(setSupportsConditional);
}, []);
const handleLogin = React.useCallback(
async (useConditional = false) => {
if (!isSupported) {
setState({
status: "error",
message: insecureContext
? t`Passkeys require HTTPS or http://localhost (with your port); this hostname is not a secure browser context.`
: t`WebAuthn is not supported in this browser`,
});
return;
}
try {
// Step 1: Get authentication options from server
setState({ status: "loading", message: t`Preparing...` });
const optionsResponse = await apiFetch(optionsEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email || undefined }),
});
const optionsData = await parseApiResponse<{
options: PublicKeyCredentialRequestOptionsJSON;
}>(optionsResponse, "Failed to get authentication options");
const { options } = optionsData;
// Step 2: Get assertion from browser
setState({ status: "loading", message: t`Waiting for passkey...` });
// Convert options to the format expected by the browser
const publicKeyOptions: PublicKeyCredentialRequestOptions = {
challenge: base64urlToBuffer(options.challenge),
rpId: options.rpId,
timeout: options.timeout,
userVerification: options.userVerification,
allowCredentials: options.allowCredentials?.map((cred) => ({
type: cred.type,
id: base64urlToBuffer(cred.id),
transports: cred.transports,
})),
};
const credentialOptions: CredentialRequestOptions = {
publicKey: publicKeyOptions,
// Use conditional mediation if supported and requested
...(useConditional && supportsConditional
? { mediation: "conditional" as CredentialMediationRequirement }
: {}),
};
const rawCredential = await navigator.credentials.get(credentialOptions);
if (!rawCredential) {
const message = "No credential returned from authenticator";
setState({ status: "error", message });
onError?.(new Error(message));
return;
}
// Step 3: Send credential to server for verification
setState({ status: "loading", message: t`Verifying...` });
// navigator.credentials.get() with publicKey returns PublicKeyCredential
const credential = rawCredential as PublicKeyCredential;
const assertionResponse = credential.response as AuthenticatorAssertionResponse;
// authenticatorAttachment exists at runtime on PublicKeyCredential but isn't in the base type definition
const rawAttachment =
"authenticatorAttachment" in credential ? credential.authenticatorAttachment : undefined;
const authenticatorAttachment =
rawAttachment === "platform" || rawAttachment === "cross-platform"
? rawAttachment
: undefined;
const authenticationResponse: AuthenticationResponse = {
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
type: "public-key",
response: {
clientDataJSON: bufferToBase64url(assertionResponse.clientDataJSON),
authenticatorData: bufferToBase64url(assertionResponse.authenticatorData),
signature: bufferToBase64url(assertionResponse.signature),
userHandle: assertionResponse.userHandle
? bufferToBase64url(assertionResponse.userHandle)
: undefined,
},
authenticatorAttachment,
};
const verifyResponse = await apiFetch(verifyEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ credential: authenticationResponse }),
});
const result = await parseApiResponse<unknown>(
verifyResponse,
"Failed to verify authentication",
);
setState({ status: "success" });
onSuccess(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Authentication failed";
// Handle specific WebAuthn errors
let userMessage = message;
if (error instanceof DOMException) {
switch (error.name) {
case "NotAllowedError":
userMessage = t`Authentication was cancelled or timed out. Please try again.`;
break;
case "InvalidStateError":
userMessage = t`No matching passkey found for this account.`;
break;
case "NotSupportedError":
userMessage = t`Your device doesn't support the required security features.`;
break;
case "SecurityError":
userMessage = t`Security error. Make sure you're on a secure connection.`;
break;
case "AbortError":
// User cancelled - don't show error
setState({ status: "idle" });
return;
default:
userMessage = t`Authentication error: ${error.message}`;
}
}
setState({ status: "error", message: userMessage });
onError?.(new Error(userMessage));
}
},
[
isSupported,
insecureContext,
optionsEndpoint,
verifyEndpoint,
email,
supportsConditional,
onSuccess,
onError,
t,
],
);
if (!isSupported) {
return (
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-4">
<h3 className="font-medium text-kumo-danger">{t`Passkeys Not Available Here`}</h3>
<p className="mt-1 text-sm text-kumo-subtle">
{insecureContext ? (
<>
{t`Passkeys require a`}{" "}
<strong className="text-kumo-default">{t`secure context`}</strong>
{t`: use`} <strong className="text-kumo-default">HTTPS</strong>
{t`, or open the admin at`}{" "}
<strong className="text-kumo-default">http://localhost</strong>{" "}
{t`(with your dev port).`}
{t`Plain`} <code className="text-xs">http://</code>{" "}
{t`on a custom hostname is not treated as secure, even on loopback.`}
</>
) : (
<>
{t`Your browser doesn't support passkeys. Please use a modern browser like Chrome, Safari, Firefox, or Edge.`}
</>
)}
</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Email input (optional - for non-discoverable credentials) */}
{showEmailInput && (
<div>
<Input
label={t`Email (optional)`}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t`you@example.com`}
disabled={state.status === "loading"}
autoComplete="username webauthn"
/>
<p className="mt-1 text-xs text-kumo-subtle">
{t`Leave blank to use a discoverable passkey.`}
</p>
</div>
)}
{/* Error message */}
{state.status === "error" && (
<div className="rounded-lg bg-kumo-danger/10 p-4 text-sm text-kumo-danger">
{state.message}
</div>
)}
{/* Login button */}
<Button
type="button"
onClick={() => handleLogin(false)}
loading={state.status === "loading"}
className="w-full justify-center"
variant="primary"
>
{state.status === "loading" ? <>{state.message}</> : resolvedButtonText}
</Button>
{/* Help text */}
<p className="text-xs text-kumo-subtle text-center">
{t`Use your device's biometric authentication, security key, or PIN to sign in.`}
</p>
</div>
);
}

View File

@@ -0,0 +1,373 @@
/**
* PasskeyRegistration - WebAuthn credential registration component
*
* Handles the passkey registration flow:
* 1. Fetches registration options from server
* 2. Triggers browser's WebAuthn credential creation
* 3. Sends attestation back to server for verification
*
* Used in:
* - Setup wizard (first admin creation)
* - User settings (adding additional passkeys)
*/
import { Button, Input } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import * as React from "react";
import { apiFetch, parseApiResponse } from "../../lib/api/client";
import {
isPasskeyEnvironmentUsable,
isWebAuthnSecureContext,
} from "../../lib/webauthn-environment";
// ============================================================================
// Constants
// ============================================================================
const BASE64URL_DASH_REGEX = /-/g;
const BASE64URL_UNDERSCORE_REGEX = /_/g;
const BASE64_PLUS_REGEX = /\+/g;
const BASE64_SLASH_REGEX = /\//g;
// ============================================================================
// WebAuthn types
// ============================================================================
interface PublicKeyCredentialCreationOptionsJSON {
challenge: string;
rp: {
name: string;
id: string;
};
user: {
id: string;
name: string;
displayName: string;
};
pubKeyCredParams: Array<{
type: "public-key";
alg: number;
}>;
timeout?: number;
attestation?: "none" | "indirect" | "direct";
authenticatorSelection?: {
authenticatorAttachment?: "platform" | "cross-platform";
residentKey?: "discouraged" | "preferred" | "required";
requireResidentKey?: boolean;
userVerification?: "discouraged" | "preferred" | "required";
};
excludeCredentials?: Array<{
type: "public-key";
id: string;
transports?: AuthenticatorTransport[];
}>;
}
interface RegistrationResponse {
id: string;
rawId: string;
type: "public-key";
response: {
clientDataJSON: string;
attestationObject: string;
transports?: AuthenticatorTransport[];
};
authenticatorAttachment?: "platform" | "cross-platform";
}
export interface PasskeyRegistrationProps {
/** Endpoint to get registration options */
optionsEndpoint: string;
/** Endpoint to verify registration */
verifyEndpoint: string;
/** Called on successful registration */
onSuccess: (response: unknown) => void;
/** Called on error */
onError?: (error: Error) => void;
/** Button text */
buttonText?: string;
/** Show passkey name input */
showNameInput?: boolean;
/** Additional data to send with requests */
additionalData?: Record<string, unknown>;
}
const EMPTY_DATA: Record<string, unknown> = {};
type RegistrationState =
| { status: "idle" }
| { status: "loading"; message: string }
| { status: "error"; message: string }
| { status: "success" };
/**
* Convert base64url to ArrayBuffer
*/
function base64urlToBuffer(base64url: string): ArrayBuffer {
const base64 = base64url
.replace(BASE64URL_DASH_REGEX, "+")
.replace(BASE64URL_UNDERSCORE_REGEX, "/");
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
const binary = atob(base64 + padding);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
/**
* Convert ArrayBuffer to base64url (with padding for @oslojs/encoding compatibility)
*/
function bufferToBase64url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]!);
}
const base64 = btoa(binary);
// Convert to base64url but keep padding (required by @oslojs/encoding)
return base64.replace(BASE64_PLUS_REGEX, "-").replace(BASE64_SLASH_REGEX, "_");
}
/**
* PasskeyRegistration Component
*/
export function PasskeyRegistration({
optionsEndpoint,
verifyEndpoint,
onSuccess,
onError,
buttonText,
showNameInput = false,
additionalData = EMPTY_DATA,
}: PasskeyRegistrationProps) {
const { t } = useLingui();
const resolvedButtonText = buttonText ?? t`Register Passkey`;
const [state, setState] = React.useState<RegistrationState>({
status: "idle",
});
const [passkeyName, setPasskeyName] = React.useState("");
// Secure context (HTTPS or http://localhost) + PublicKeyCredential
const isSupported = React.useMemo(() => isPasskeyEnvironmentUsable(), []);
const insecureContext = React.useMemo(
() => typeof window !== "undefined" && !isWebAuthnSecureContext(),
[],
);
const handleRegister = React.useCallback(async () => {
if (!isSupported) {
setState({
status: "error",
message: t`WebAuthn is not supported in this browser`,
});
return;
}
try {
// Step 1: Get registration options from server
setState({ status: "loading", message: t`Preparing registration...` });
const optionsResponse = await apiFetch(optionsEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(additionalData),
});
const optionsData = await parseApiResponse<{
options: PublicKeyCredentialCreationOptionsJSON;
}>(optionsResponse, "Failed to get registration options");
const { options } = optionsData;
// Step 2: Create credential with browser
setState({ status: "loading", message: t`Waiting for passkey...` });
// Convert options to the format expected by the browser
const publicKeyOptions: PublicKeyCredentialCreationOptions = {
challenge: base64urlToBuffer(options.challenge),
rp: options.rp,
user: {
id: base64urlToBuffer(options.user.id),
name: options.user.name,
displayName: options.user.displayName,
},
pubKeyCredParams: options.pubKeyCredParams,
timeout: options.timeout,
attestation: options.attestation,
authenticatorSelection: options.authenticatorSelection,
excludeCredentials: options.excludeCredentials?.map((cred) => ({
type: cred.type,
id: base64urlToBuffer(cred.id),
transports: cred.transports,
})),
};
const rawCredential = await navigator.credentials.create({
publicKey: publicKeyOptions,
});
if (!rawCredential) {
throw new Error("No credential returned from authenticator");
}
// Step 3: Send credential to server for verification
setState({ status: "loading", message: t`Verifying...` });
// navigator.credentials.create() with publicKey returns PublicKeyCredential
const credential = rawCredential as PublicKeyCredential;
const attestationResponse = credential.response as AuthenticatorAttestationResponse;
// authenticatorAttachment exists at runtime on PublicKeyCredential but isn't in the base type definition
const rawAttachment =
"authenticatorAttachment" in credential ? credential.authenticatorAttachment : undefined;
const authenticatorAttachment =
rawAttachment === "platform" || rawAttachment === "cross-platform"
? rawAttachment
: undefined;
const registrationResponse: RegistrationResponse = {
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
type: "public-key",
response: {
clientDataJSON: bufferToBase64url(attestationResponse.clientDataJSON),
attestationObject: bufferToBase64url(attestationResponse.attestationObject),
transports: attestationResponse.getTransports?.() as AuthenticatorTransport[] | undefined,
},
authenticatorAttachment,
};
const verifyResponse = await apiFetch(verifyEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
credential: registrationResponse,
name: passkeyName || undefined,
...additionalData,
}),
});
const result = await parseApiResponse<unknown>(
verifyResponse,
"Failed to verify registration",
);
setState({ status: "success" });
onSuccess(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Registration failed";
// Handle specific WebAuthn errors
let userMessage = message;
if (error instanceof DOMException) {
switch (error.name) {
case "NotAllowedError":
userMessage = t`Registration was cancelled or timed out. Please try again.`;
break;
case "InvalidStateError":
userMessage = t`This passkey is already registered on this device.`;
break;
case "NotSupportedError":
userMessage = t`Your device doesn't support the required security features.`;
break;
case "SecurityError":
userMessage = t`Security error. Make sure you're on a secure connection.`;
break;
default:
userMessage = t`Authentication error: ${error.message}`;
}
}
setState({ status: "error", message: userMessage });
onError?.(new Error(userMessage));
}
}, [
isSupported,
optionsEndpoint,
verifyEndpoint,
additionalData,
passkeyName,
onSuccess,
onError,
t,
]);
// Not usable (insecure origin vs missing API — browser hides WebAuthn the same way)
if (!isSupported) {
return (
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-4">
<h3 className="font-medium text-kumo-danger">{t`Passkeys Not Available Here`}</h3>
<p className="mt-1 text-sm text-kumo-subtle">
{insecureContext ? (
<>
{t`Passkeys require a`}{" "}
<strong className="text-kumo-default">{t`secure context`}</strong>
{t`: use`} <strong className="text-kumo-default">HTTPS</strong>
{t`, or open the admin at`}{" "}
<strong className="text-kumo-default">http://localhost</strong>{" "}
{t`(with your dev port).`}
{t`Plain`} <code className="text-xs">http://</code>{" "}
{t`on a custom hostname is not treated as secure, even on loopback.`}
</>
) : (
<>
{t`Your browser doesn't support passkeys. Please use a modern browser like Chrome, Safari, Firefox, or Edge.`}
</>
)}
</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Passkey name input (optional) */}
{showNameInput && (
<div>
<Input
label={t`Passkey Name (optional)`}
type="text"
value={passkeyName}
onChange={(e) => setPasskeyName(e.target.value)}
placeholder={t`e.g., MacBook Pro, iPhone`}
disabled={state.status === "loading"}
/>
<p className="mt-1 text-xs text-kumo-subtle">
{t`Give this passkey a name to help you identify it later.`}
</p>
</div>
)}
{/* Error message */}
{state.status === "error" && (
<div className="rounded-lg bg-kumo-danger/10 p-4 text-sm text-kumo-danger">
{state.message}
</div>
)}
{/* Success message */}
{state.status === "success" && (
<div className="rounded-lg bg-green-500/10 p-4 text-sm text-green-700 dark:text-green-400">
{t`Passkey registered successfully!`}
</div>
)}
{/* Register button */}
<Button
type="button"
onClick={handleRegister}
loading={state.status === "loading"}
className="w-full justify-center"
variant="primary"
>
{state.status === "loading" ? <>{state.message}</> : resolvedButtonText}
</Button>
{/* Help text */}
<p className="text-xs text-kumo-subtle text-center">
{t`You'll be prompted to use your device's biometric authentication, security key, or PIN.`}
</p>
</div>
);
}

View File

@@ -0,0 +1,9 @@
/**
* Auth components for EmDash Admin
*/
export { PasskeyRegistration } from "./PasskeyRegistration";
export type { PasskeyRegistrationProps } from "./PasskeyRegistration";
export { PasskeyLogin } from "./PasskeyLogin";
export type { PasskeyLoginProps } from "./PasskeyLogin";

View File

@@ -0,0 +1,216 @@
/**
* Comment detail slide-over panel.
*
* Shows full comment body, author details, moderation metadata,
* and status change buttons.
*/
import { Badge, Button } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { X, Check, Trash, Warning, UserCircle, EnvelopeSimple } from "@phosphor-icons/react";
import * as React from "react";
import type { AdminComment, CommentStatus } from "../../lib/api/comments.js";
import { cn } from "../../lib/utils.js";
export interface CommentDetailProps {
comment: AdminComment;
onClose: () => void;
onStatusChange: (id: string, status: CommentStatus) => void;
onDelete: (id: string) => void;
isAdmin: boolean;
isStatusPending: boolean;
}
export function CommentDetail({
comment,
onClose,
onStatusChange,
onDelete,
isAdmin,
isStatusPending,
}: CommentDetailProps) {
const { t } = useLingui();
// Close on Escape
React.useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" && !e.defaultPrevented) {
e.preventDefault();
onClose();
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
const date = new Date(comment.createdAt);
return (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-40 bg-black/30" onClick={onClose} aria-hidden="true" />
{/* Panel */}
<div className="fixed inset-y-0 end-0 z-50 w-full max-w-lg overflow-y-auto bg-kumo-base border-s shadow-lg">
{/* Header */}
<div className="flex items-center justify-between border-b px-6 py-4">
<h2 className="text-lg font-semibold">{t`Comment Detail`}</h2>
<Button variant="ghost" shape="square" onClick={onClose} aria-label={t`Close`}>
<X className="h-5 w-5" />
</Button>
</div>
{/* Content */}
<div className="space-y-6 p-6">
{/* Status */}
<div className="flex items-center justify-between">
<CommentStatusBadge status={comment.status} />
<span className="text-sm text-kumo-subtle">
{date.toLocaleDateString()} {date.toLocaleTimeString()}
</span>
</div>
{/* Author info */}
<div className="rounded-lg border p-4 space-y-3">
<h3 className="text-sm font-semibold text-kumo-subtle uppercase tracking-wider">
{t`Author`}
</h3>
<div className="space-y-2">
<div className="flex items-center gap-2">
<UserCircle className="h-4 w-4 text-kumo-subtle" />
<span className="font-medium">{comment.authorName}</span>
{comment.authorUserId && <Badge variant="secondary">{t`Registered user`}</Badge>}
</div>
<div className="flex items-center gap-2">
<EnvelopeSimple className="h-4 w-4 text-kumo-subtle" />
<span className="text-sm text-kumo-subtle">{comment.authorEmail}</span>
</div>
</div>
</div>
{/* Comment body */}
<div className="rounded-lg border p-4 space-y-3">
<h3 className="text-sm font-semibold text-kumo-subtle uppercase tracking-wider">
{t`Comment`}
</h3>
<p className="text-sm whitespace-pre-wrap break-words">{comment.body}</p>
</div>
{/* Content reference */}
<div className="rounded-lg border p-4 space-y-2">
<h3 className="text-sm font-semibold text-kumo-subtle uppercase tracking-wider">
{t`Content`}
</h3>
<p className="text-sm">
<span className="text-kumo-subtle">{t`Collection:`}</span>{" "}
<span className="font-medium">{comment.collection}</span>
</p>
<p className="text-sm">
<span className="text-kumo-subtle">{t`Content ID:`}</span>{" "}
<code className="bg-kumo-tint px-1.5 py-0.5 rounded text-xs">
{comment.contentId}
</code>
</p>
{comment.parentId && (
<p className="text-sm">
<span className="text-kumo-subtle">{t`Reply to:`}</span>{" "}
<code className="bg-kumo-tint px-1.5 py-0.5 rounded text-xs">
{comment.parentId}
</code>
</p>
)}
</div>
{/* Moderation metadata */}
{comment.moderationMetadata && Object.keys(comment.moderationMetadata).length > 0 && (
<div className="rounded-lg border p-4 space-y-3">
<h3 className="text-sm font-semibold text-kumo-subtle uppercase tracking-wider">
{t`Moderation Signals`}
</h3>
<pre className="text-xs bg-kumo-tint rounded p-3 overflow-x-auto">
{JSON.stringify(comment.moderationMetadata, null, 2)}
</pre>
</div>
)}
</div>
{/* Footer actions */}
<div className="border-t px-6 py-4 space-y-3">
<div className="flex gap-2">
{comment.status !== "approved" && (
<Button
icon={<Check />}
onClick={() => onStatusChange(comment.id, "approved")}
disabled={isStatusPending}
className="flex-1"
>
{t`Approve`}
</Button>
)}
{comment.status !== "spam" && (
<Button
variant="outline"
icon={<Warning />}
onClick={() => onStatusChange(comment.id, "spam")}
disabled={isStatusPending}
className="flex-1"
>
{t`Spam`}
</Button>
)}
{comment.status !== "trash" && (
<Button
variant="outline"
icon={<Trash />}
onClick={() => onStatusChange(comment.id, "trash")}
disabled={isStatusPending}
className="flex-1"
>
{t`Trash`}
</Button>
)}
</div>
{isAdmin && (
<Button
variant="destructive"
icon={<Trash />}
onClick={() => onDelete(comment.id)}
disabled={isStatusPending}
className="w-full"
>
{t`Delete Permanently`}
</Button>
)}
</div>
</div>
</>
);
}
export function CommentStatusBadge({ status }: { status: CommentStatus }) {
const { t } = useLingui();
const labels: Record<CommentStatus, string> = {
approved: t`approved`,
pending: t`pending`,
spam: t`spam`,
trash: t`trash`,
};
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium",
status === "approved" &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
status === "pending" &&
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
status === "spam" && "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
status === "trash" && "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200",
)}
>
{labels[status]}
</span>
);
}

View File

@@ -0,0 +1,553 @@
/**
* Comment moderation inbox.
*
* Status tabs (Pending, Approved, Spam, Trash), search, collection filter,
* table with row actions, bulk selection, and detail slide-over.
*/
import { Badge, Button, Checkbox, Input, Select, Tabs } from "@cloudflare/kumo";
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { MagnifyingGlass, Check, Trash, Warning, ChatCircle } from "@phosphor-icons/react";
import * as React from "react";
import type {
AdminComment,
CommentCounts,
CommentStatus,
BulkAction,
} from "../../lib/api/comments.js";
import { cn } from "../../lib/utils.js";
import { CaretNext, CaretPrev } from "../ArrowIcons.js";
import { ConfirmDialog } from "../ConfirmDialog.js";
import { CommentDetail } from "./CommentDetail.js";
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
export interface CommentInboxProps {
comments: AdminComment[];
counts: CommentCounts;
isLoading: boolean;
nextCursor?: string;
collections: Record<string, { label: string }>;
activeStatus: CommentStatus;
onStatusChange: (status: CommentStatus) => void;
collectionFilter: string;
onCollectionFilterChange: (collection: string) => void;
searchQuery: string;
onSearchChange: (query: string) => void;
onCommentStatusChange: (id: string, status: CommentStatus) => Promise<unknown>;
onCommentDelete: (id: string) => Promise<unknown>;
onBulkAction: (ids: string[], action: BulkAction) => Promise<unknown>;
onLoadMore: () => void;
isAdmin: boolean;
isStatusPending: boolean;
deleteError: unknown;
onDeleteErrorReset: () => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const PAGE_SIZE = 20;
export function CommentInbox({
comments,
counts,
isLoading,
nextCursor,
collections,
activeStatus,
onStatusChange,
collectionFilter,
onCollectionFilterChange,
searchQuery,
onSearchChange,
onCommentStatusChange,
onCommentDelete,
onBulkAction,
onLoadMore,
isAdmin,
isStatusPending,
deleteError,
onDeleteErrorReset,
}: CommentInboxProps) {
const { t } = useLingui();
// Selection state
const [selected, setSelected] = React.useState<Set<string>>(new Set());
const [detailComment, setDetailComment] = React.useState<AdminComment | null>(null);
const [deleteId, setDeleteId] = React.useState<string | null>(null);
// Pagination (client-side within loaded data)
const [page, setPage] = React.useState(0);
// Reset selection and page when status tab or filters change
React.useEffect(() => {
setSelected(new Set());
setPage(0);
}, [activeStatus, collectionFilter, searchQuery]);
const clearSelection = React.useCallback(() => setSelected(new Set()), []);
const totalPages = Math.max(1, Math.ceil(comments.length / PAGE_SIZE));
const paginatedComments = comments.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
// Bulk select
const allOnPageSelected =
paginatedComments.length > 0 && paginatedComments.every((c) => selected.has(c.id));
const toggleAll = () => {
setSelected((prev) => {
const next = new Set(prev);
if (allOnPageSelected) {
for (const c of paginatedComments) next.delete(c.id);
} else {
for (const c of paginatedComments) next.add(c.id);
}
return next;
});
};
const toggleOne = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const handleBulk = (action: BulkAction) => {
if (selected.size === 0) return;
void onBulkAction([...selected], action).then(clearSelection);
};
// Collection filter items
const collectionItems: Record<string, string> = { "": t`All collections` };
for (const [slug, config] of Object.entries(collections)) {
collectionItems[slug] = config.label;
}
const total = counts.pending + counts.approved + counts.spam + counts.trash;
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ChatCircle className="h-6 w-6" />
<h1 className="text-2xl font-bold">{t`Comments`}</h1>
{total > 0 && (
<span className="text-sm text-kumo-subtle">
{plural(total, { one: "# total", other: "# total" })}
</span>
)}
</div>
</div>
{/* Filters row */}
<div className="flex items-center gap-3 flex-wrap">
{/* Search */}
<div className="relative max-w-xs flex-1 min-w-[200px]">
<MagnifyingGlass className="absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
<Input
type="search"
placeholder={t`Search comments...`}
aria-label={t`Search comments`}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="ps-9"
/>
</div>
{/* Collection filter */}
{Object.keys(collections).length > 1 && (
<div className="w-48">
<Select
value={collectionFilter}
onValueChange={(v) => onCollectionFilterChange(v ?? "")}
items={collectionItems}
aria-label={t`Filter by collection`}
/>
</div>
)}
</div>
{/* Tabs */}
<Tabs
variant="underline"
value={activeStatus}
onValueChange={(v) => {
if (v === "pending" || v === "approved" || v === "spam" || v === "trash") {
onStatusChange(v);
}
}}
tabs={[
{
value: "pending",
label: (
<span className="flex items-center gap-2">
{t`Pending`}
{counts.pending > 0 && <Badge variant="secondary">{counts.pending}</Badge>}
</span>
),
},
{ value: "approved", label: t`Approved` },
{
value: "spam",
label: (
<span className="flex items-center gap-2">
{t`Spam`}
{counts.spam > 0 && <Badge variant="secondary">{counts.spam}</Badge>}
</span>
),
},
{
value: "trash",
label: (
<span className="flex items-center gap-2">
{t`Trash`}
{counts.trash > 0 && <Badge variant="secondary">{counts.trash}</Badge>}
</span>
),
},
]}
/>
{/* Bulk action bar */}
{selected.size > 0 && (
<div className="flex items-center gap-3 rounded-lg border bg-kumo-tint/50 px-4 py-2">
<span className="text-sm font-medium">
{plural(selected.size, { one: "# selected", other: "# selected" })}
</span>
<div className="flex gap-2 ms-auto">
{activeStatus !== "approved" && (
<Button
size="sm"
icon={<Check className="h-3.5 w-3.5" />}
onClick={() => handleBulk("approve")}
>
{t`Approve`}
</Button>
)}
{activeStatus !== "spam" && (
<Button
size="sm"
variant="outline"
icon={<Warning className="h-3.5 w-3.5" />}
onClick={() => handleBulk("spam")}
>
{t`Spam`}
</Button>
)}
{activeStatus !== "trash" && (
<Button
size="sm"
variant="outline"
icon={<Trash className="h-3.5 w-3.5" />}
onClick={() => handleBulk("trash")}
>
{t`Trash`}
</Button>
)}
{isAdmin && (
<Button
size="sm"
variant="destructive"
icon={<Trash className="h-3.5 w-3.5" />}
onClick={() => handleBulk("delete")}
>
{t`Delete`}
</Button>
)}
</div>
</div>
)}
{/* Table */}
<div className="rounded-md border overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-kumo-tint/50">
<th scope="col" className="w-10 px-3 py-3">
<Checkbox
checked={allOnPageSelected}
onChange={toggleAll}
aria-label={t`Select all`}
/>
</th>
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Author`}
</th>
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Comment`}
</th>
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Content`}
</th>
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Date`}
</th>
<th scope="col" className="px-4 py-3 text-end text-sm font-medium">
{t`Actions`}
</th>
</tr>
</thead>
<tbody>
{isLoading && comments.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-kumo-subtle">
{t`Loading comments...`}
</td>
</tr>
) : paginatedComments.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-kumo-subtle">
<EmptyState status={activeStatus} hasSearch={!!searchQuery} />
</td>
</tr>
) : (
paginatedComments.map((comment) => (
<CommentRow
key={comment.id}
comment={comment}
isSelected={selected.has(comment.id)}
onToggle={() => toggleOne(comment.id)}
onRowClick={() => setDetailComment(comment)}
onStatusChange={(id, status) => {
void onCommentStatusChange(id, status).then(clearSelection);
}}
onDelete={(id) => {
setDeleteId(id);
onDeleteErrorReset();
}}
isAdmin={isAdmin}
isStatusPending={isStatusPending}
/>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{(totalPages > 1 || nextCursor) && (
<div className="flex items-center justify-between">
<span className="text-sm text-kumo-subtle">
{plural(comments.length, { one: "# comment", other: "# comments" })}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
shape="square"
disabled={page === 0}
onClick={() => setPage(page - 1)}
aria-label={t`Previous page`}
>
<CaretPrev className="h-4 w-4" />
</Button>
<span className="text-sm">
{page + 1} / {totalPages}
</span>
<Button
variant="outline"
shape="square"
disabled={page >= totalPages - 1 && !nextCursor}
onClick={() => {
if (page >= totalPages - 1 && nextCursor) {
onLoadMore();
setPage(page + 1);
} else {
setPage(page + 1);
}
}}
aria-label={t`Next page`}
>
<CaretNext className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Detail slide-over */}
{detailComment && (
<CommentDetail
comment={detailComment}
onClose={() => setDetailComment(null)}
onStatusChange={(id, status) => {
void onCommentStatusChange(id, status).then(clearSelection);
setDetailComment(null);
}}
onDelete={(id) => {
setDeleteId(id);
onDeleteErrorReset();
setDetailComment(null);
}}
isAdmin={isAdmin}
isStatusPending={isStatusPending}
/>
)}
{/* Delete confirmation */}
<ConfirmDialog
open={!!deleteId}
onClose={() => {
setDeleteId(null);
onDeleteErrorReset();
}}
title={t`Delete Comment?`}
description={t`This will permanently delete this comment. This action cannot be undone.`}
confirmLabel={t`Delete`}
pendingLabel={t`Deleting...`}
isPending={isStatusPending}
error={deleteError}
onConfirm={() => {
if (deleteId) {
void onCommentDelete(deleteId).then(() => setDeleteId(null));
}
}}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
interface CommentRowProps {
comment: AdminComment;
isSelected: boolean;
onToggle: () => void;
onRowClick: () => void;
onStatusChange: (id: string, status: CommentStatus) => void;
onDelete: (id: string) => void;
isAdmin: boolean;
isStatusPending: boolean;
}
function CommentRow({
comment,
isSelected,
onToggle,
onRowClick,
onStatusChange,
onDelete,
isAdmin,
isStatusPending,
}: CommentRowProps) {
const { t } = useLingui();
const date = new Date(comment.createdAt);
const excerpt = comment.body.length > 120 ? comment.body.slice(0, 120) + "..." : comment.body;
return (
<tr className={cn("border-b hover:bg-kumo-tint/25", isSelected && "bg-kumo-tint/40")}>
<td className="w-10 px-3 py-3">
<Checkbox
checked={isSelected}
onChange={onToggle}
aria-label={t`Select comment by ${comment.authorName}`}
/>
</td>
<td className="px-4 py-3">
<button type="button" onClick={onRowClick} className="text-start">
<div className="font-medium text-sm">{comment.authorName}</div>
<div className="text-xs text-kumo-subtle">{comment.authorEmail}</div>
</button>
</td>
<td className="px-4 py-3 max-w-xs">
<button
type="button"
onClick={onRowClick}
className="text-start text-sm text-kumo-subtle hover:text-kumo-default line-clamp-2"
>
{excerpt}
</button>
</td>
<td className="px-4 py-3">
<div className="text-xs">
<span className="font-medium">{comment.collection}</span>
</div>
</td>
<td className="px-4 py-3 text-sm text-kumo-subtle whitespace-nowrap">
{date.toLocaleDateString()}
</td>
<td className="px-4 py-3 text-end">
<div className="flex items-center justify-end gap-1">
{comment.status !== "approved" && (
<Button
variant="ghost"
shape="square"
size="sm"
aria-label={t`Approve`}
onClick={() => onStatusChange(comment.id, "approved")}
disabled={isStatusPending}
>
<Check className="h-4 w-4 text-green-600" />
</Button>
)}
{comment.status !== "spam" && (
<Button
variant="ghost"
shape="square"
size="sm"
aria-label={t`Mark as spam`}
onClick={() => onStatusChange(comment.id, "spam")}
disabled={isStatusPending}
>
<Warning className="h-4 w-4 text-orange-500" />
</Button>
)}
{comment.status !== "trash" && (
<Button
variant="ghost"
shape="square"
size="sm"
aria-label={t`Trash`}
onClick={() => onStatusChange(comment.id, "trash")}
disabled={isStatusPending}
>
<Trash className="h-4 w-4 text-kumo-subtle" />
</Button>
)}
{isAdmin && (
<Button
variant="ghost"
shape="square"
size="sm"
aria-label={t`Delete permanently`}
onClick={() => onDelete(comment.id)}
disabled={isStatusPending}
>
<Trash className="h-4 w-4 text-kumo-danger" />
</Button>
)}
</div>
</td>
</tr>
);
}
function EmptyState({ status, hasSearch }: { status: CommentStatus; hasSearch: boolean }) {
const { t } = useLingui();
if (hasSearch) {
return <p>{t`No comments match your search.`}</p>;
}
const messages: Record<CommentStatus, string> = {
pending: t`No comments awaiting moderation.`,
approved: t`No approved comments yet.`,
spam: t`No spam comments.`,
trash: t`Trash is empty.`,
};
return <p>{messages[status]}</p>;
}

View File

@@ -0,0 +1,342 @@
/**
* Block Menu Component
*
* Floating menu that appears when a block is selected via drag handle click.
* Provides block actions:
* - Turn into (transform to different block type)
* - Duplicate
* - Delete
*
* Uses Floating UI for positioning relative to the selected block.
*/
import { Button } from "@cloudflare/kumo";
import { useFloating, offset, flip, shift, autoUpdate } from "@floating-ui/react";
import type { MessageDescriptor } from "@lingui/core";
import { msg } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import {
DotsSixVertical,
Paragraph,
TextHOne,
TextHTwo,
TextHThree,
Quotes,
Code,
List,
ListNumbers,
Copy,
Trash,
type Icon as PhosphorIcon,
} from "@phosphor-icons/react";
import type { Editor } from "@tiptap/react";
import * as React from "react";
import { createPortal } from "react-dom";
import { useStableCallback } from "../../lib/hooks";
import { cn } from "../../lib/utils";
import { CaretNext, CaretPrev } from "../ArrowIcons.js";
/**
* Block transform options
*/
interface BlockTransform {
id: string;
label: MessageDescriptor;
icon: PhosphorIcon;
transform: (editor: Editor) => void;
}
const blockTransforms: BlockTransform[] = [
{
id: "paragraph",
label: msg`Paragraph`,
icon: Paragraph,
transform: (editor) => {
editor.chain().focus().setNode("paragraph").run();
},
},
{
id: "heading1",
label: msg`Heading 1`,
icon: TextHOne,
transform: (editor) => {
editor.chain().focus().setNode("heading", { level: 1 }).run();
},
},
{
id: "heading2",
label: msg`Heading 2`,
icon: TextHTwo,
transform: (editor) => {
editor.chain().focus().setNode("heading", { level: 2 }).run();
},
},
{
id: "heading3",
label: msg`Heading 3`,
icon: TextHThree,
transform: (editor) => {
editor.chain().focus().setNode("heading", { level: 3 }).run();
},
},
{
id: "blockquote",
label: msg`Quote`,
icon: Quotes,
transform: (editor) => {
editor.chain().focus().toggleBlockquote().run();
},
},
{
id: "codeBlock",
label: msg`Code Block`,
icon: Code,
transform: (editor) => {
editor.chain().focus().toggleCodeBlock().run();
},
},
{
id: "bulletList",
label: msg`Bullet List`,
icon: List,
transform: (editor) => {
editor.chain().focus().toggleBulletList().run();
},
},
{
id: "orderedList",
label: msg`Numbered List`,
icon: ListNumbers,
transform: (editor) => {
editor.chain().focus().toggleOrderedList().run();
},
},
];
interface BlockMenuProps {
editor: Editor;
/** The DOM element of the selected block (for positioning) */
anchorElement: HTMLElement | null;
/** Whether the menu is open */
isOpen: boolean;
/** Callback to close the menu */
onClose: () => void;
}
/**
* Block Menu - floating menu for block-level actions
*/
export function BlockMenu({ editor, anchorElement, isOpen, onClose }: BlockMenuProps) {
const { t } = useLingui();
const [showTransforms, setShowTransforms] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
const stableOnClose = useStableCallback(onClose);
const { refs, floatingStyles } = useFloating({
open: isOpen,
placement: "left-start",
middleware: [offset({ mainAxis: 8, crossAxis: 0 }), flip(), shift({ padding: 8 })],
whileElementsMounted: autoUpdate,
});
// Sync the anchor element
React.useEffect(() => {
if (anchorElement) {
refs.setReference(anchorElement);
}
}, [anchorElement, refs]);
// Close on escape
React.useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
if (showTransforms) {
setShowTransforms(false);
} else {
stableOnClose();
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, stableOnClose, showTransforms]);
// Close on click outside
React.useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target;
// Don't close if clicking on the drag handle or menu itself
if (target instanceof Node && menuRef.current?.contains(target)) return;
if (target instanceof Element && target.closest("[data-block-handle]")) return;
stableOnClose();
};
// Delay to avoid immediate close from the click that opened it
const timer = setTimeout(() => {
document.addEventListener("mousedown", handleClickOutside);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen, stableOnClose]);
// Reset submenu state when menu closes
React.useEffect(() => {
if (!isOpen) {
setShowTransforms(false);
}
}, [isOpen]);
const handleDuplicate = () => {
const { selection } = editor.state;
const { $from, $to } = selection;
// Get the block node at current position
const blockStart = $from.start($from.depth);
const blockEnd = $to.end($to.depth);
// Get the content to duplicate
const slice = editor.state.doc.slice(blockStart, blockEnd);
// Insert after current block
editor
.chain()
.focus()
.command(({ tr }) => {
tr.insert(blockEnd + 1, slice.content);
return true;
})
.run();
onClose();
};
const handleDelete = () => {
editor.chain().focus().deleteNode(editor.state.selection.$from.parent.type.name).run();
onClose();
};
const handleTransform = (transform: BlockTransform) => {
transform.transform(editor);
onClose();
};
if (!isOpen) return null;
return createPortal(
<div
ref={(node) => {
menuRef.current = node;
refs.setFloating(node);
}}
style={floatingStyles}
className="z-[100] rounded-lg border bg-kumo-overlay shadow-lg min-w-[180px] overflow-hidden"
>
{showTransforms ? (
// Transform submenu
<div className="py-1">
<button
type="button"
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-kumo-tint text-start"
onClick={() => setShowTransforms(false)}
>
<CaretPrev className="h-4 w-4" />
<span>Back</span>
</button>
<div className="h-px bg-kumo-line my-1" />
{blockTransforms.map((transform) => (
<button
key={transform.id}
type="button"
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-kumo-tint text-start"
onClick={() => handleTransform(transform)}
>
<transform.icon className="h-4 w-4 text-kumo-subtle" />
<span>{t(transform.label)}</span>
</button>
))}
</div>
) : (
// Main menu
<div className="py-1">
<button
type="button"
className="flex items-center justify-between w-full px-3 py-2 text-sm hover:bg-kumo-tint text-start"
onClick={() => setShowTransforms(true)}
>
<span className="flex items-center gap-2">
<Paragraph className="h-4 w-4 text-kumo-subtle" />
<span>Turn into</span>
</span>
<CaretNext className="h-4 w-4 text-kumo-subtle" />
</button>
<button
type="button"
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-kumo-tint text-start"
onClick={handleDuplicate}
>
<Copy className="h-4 w-4 text-kumo-subtle" />
<span>Duplicate</span>
</button>
<div className="h-px bg-kumo-line my-1" />
<button
type="button"
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-kumo-tint text-start text-kumo-danger"
onClick={handleDelete}
>
<Trash className="h-4 w-4" />
<span>Delete</span>
</button>
</div>
)}
</div>,
document.body,
);
}
/**
* Block Drag Handle Component
*
* Shown in the left gutter of each block. Clicking opens the block menu,
* dragging reorders blocks.
*/
interface BlockHandleProps {
onClick: (e: React.MouseEvent) => void;
onDragStart?: (e: React.DragEvent) => void;
selected?: boolean;
}
export function BlockHandle({ onClick, onDragStart, selected }: BlockHandleProps) {
return (
<Button
type="button"
variant="ghost"
shape="square"
className={cn(
"h-6 w-6 cursor-grab active:cursor-grabbing",
"text-kumo-subtle/50 hover:text-kumo-subtle",
selected && "text-kumo-subtle",
)}
onClick={onClick}
onDragStart={onDragStart}
draggable
data-block-handle
aria-label="Drag to reorder block"
>
<DotsSixVertical className="h-4 w-4" />
</Button>
);
}
export { blockTransforms };
export type { BlockTransform };

View File

@@ -0,0 +1,217 @@
/**
* Document Outline
*
* Displays a tree structure of headings from the TipTap editor.
* - Shows H1 at root, H2 indented, H3 further indented
* - Click-to-navigate to heading position
* - Highlights the current section based on cursor position
*/
import { Button } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { CaretDown, List } from "@phosphor-icons/react";
import type { Editor } from "@tiptap/react";
import * as React from "react";
import { cn } from "../../lib/utils";
import { CaretNext } from "../ArrowIcons.js";
function getIndentClass(level: number) {
switch (level) {
case 1:
return "ps-0";
case 2:
return "ps-4";
case 3:
return "ps-8";
default:
return "ps-0";
}
}
function getTextClass(level: number) {
switch (level) {
case 1:
return "font-medium";
case 2:
return "font-normal";
case 3:
return "font-normal text-kumo-subtle";
default:
return "font-normal";
}
}
/**
* Heading item extracted from editor document
*/
export interface HeadingItem {
/** Heading level (1-3) */
level: number;
/** Heading text content */
text: string;
/** Position in document for navigation */
pos: number;
/** Unique key for React */
key: string;
}
/**
* Extract headings from the TipTap editor document
*/
export function extractHeadings(editor: Editor | null): HeadingItem[] {
if (!editor) return [];
const headings: HeadingItem[] = [];
const doc = editor.state.doc;
let key = 0;
doc.descendants((node, pos) => {
if (node.type.name === "heading") {
const rawLevel = node.attrs.level;
const level = typeof rawLevel === "number" ? rawLevel : 1;
const text = node.textContent || "";
if (text.trim()) {
headings.push({
level,
text,
pos,
key: `heading-${key++}`,
});
}
}
});
return headings;
}
/**
* Find the current heading based on cursor position
*/
export function findCurrentHeading(headings: HeadingItem[], cursorPos: number): HeadingItem | null {
if (headings.length === 0) return null;
// Find the heading that contains or precedes the cursor
let current: HeadingItem | null = null;
for (const heading of headings) {
if (heading.pos <= cursorPos) {
current = heading;
} else {
break;
}
}
return current;
}
export interface DocumentOutlineProps {
/** TipTap editor instance */
editor: Editor | null;
/** Additional CSS classes */
className?: string;
}
/**
* Document outline component showing heading tree structure
*/
export function DocumentOutline({ editor, className }: DocumentOutlineProps) {
const { t } = useLingui();
const [isExpanded, setIsExpanded] = React.useState(true);
const [headings, setHeadings] = React.useState<HeadingItem[]>([]);
const [currentPos, setCurrentPos] = React.useState(0);
// Extract headings when editor content changes
React.useEffect(() => {
if (!editor) return;
const updateHeadings = () => {
setHeadings(extractHeadings(editor));
};
// Initial extraction
updateHeadings();
// Update on content changes
editor.on("update", updateHeadings);
return () => {
editor.off("update", updateHeadings);
};
}, [editor]);
// Track cursor position for current section highlight
React.useEffect(() => {
if (!editor) return;
const updatePosition = () => {
const { from } = editor.state.selection;
setCurrentPos(from);
};
// Initial position
updatePosition();
// Update on selection changes
editor.on("selectionUpdate", updatePosition);
return () => {
editor.off("selectionUpdate", updatePosition);
};
}, [editor]);
const currentHeading = findCurrentHeading(headings, currentPos);
const handleHeadingClick = (heading: HeadingItem) => {
if (!editor) return;
// Navigate to heading and scroll into view
editor.chain().focus().setTextSelection(heading.pos).scrollIntoView().run();
};
return (
<div className={cn("space-y-2", className)}>
<Button
variant="ghost"
size="sm"
className="w-full justify-between px-2 h-8"
onClick={() => setIsExpanded(!isExpanded)}
>
<span className="flex items-center gap-2">
<List className="h-4 w-4" />
<span className="font-semibold">{t`Outline`}</span>
</span>
{isExpanded ? <CaretDown className="h-4 w-4" /> : <CaretNext className="h-4 w-4" />}
</Button>
{isExpanded && (
<div className="space-y-0.5">
{headings.length === 0 ? (
<p className="text-sm text-kumo-subtle px-2 py-1">{t`No headings in document`}</p>
) : (
headings.map((heading) => {
const isCurrent = currentHeading?.key === heading.key;
return (
<button
key={heading.key}
type="button"
onClick={() => handleHeadingClick(heading)}
className={cn(
"w-full text-start px-2 py-1 text-sm rounded transition-colors",
"hover:bg-kumo-tint/50 cursor-pointer",
"truncate",
getIndentClass(heading.level),
getTextClass(heading.level),
isCurrent && "bg-kumo-tint text-kumo-default",
)}
title={heading.text}
>
{heading.text}
</button>
);
})
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,138 @@
/**
* Drag Handle Wrapper Component
*
* Wraps TipTap's official DragHandle React component with our BlockMenu.
* This component provides:
* - Drag handles that appear on block hover
* - Actual drag-and-drop block reordering (handled by TipTap)
* - Block menu integration for transforms, duplicate, delete
*/
import { DotsSixVertical } from "@phosphor-icons/react";
import type { Editor } from "@tiptap/core";
import { DragHandle } from "@tiptap/extension-drag-handle-react";
import type { Node as PMNode } from "@tiptap/pm/model";
import * as React from "react";
import { cn } from "../../lib/utils";
import { BlockMenu } from "./BlockMenu";
interface DragHandleWrapperProps {
editor: Editor;
}
interface HoveredNode {
node: PMNode;
pos: number;
}
// Extend Editor commands type to include DragHandle commands
declare module "@tiptap/core" {
interface Commands<ReturnType> {
dragHandle: {
lockDragHandle: () => ReturnType;
unlockDragHandle: () => ReturnType;
toggleDragHandle: () => ReturnType;
};
}
}
/**
* DragHandleWrapper - Official TipTap drag handle with BlockMenu integration
*/
export function DragHandleWrapper({ editor }: DragHandleWrapperProps) {
const [hoveredNode, setHoveredNode] = React.useState<HoveredNode | null>(null);
const [menuOpen, setMenuOpen] = React.useState(false);
const [menuAnchor, setMenuAnchor] = React.useState<HTMLElement | null>(null);
const handleRef = React.useRef<HTMLButtonElement>(null);
// Handle click on drag handle to open menu
const handleClick = React.useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!hoveredNode) return;
// Select the block in the editor
editor.chain().setNodeSelection(hoveredNode.pos).run();
// Open the menu
setMenuAnchor(handleRef.current);
setMenuOpen(true);
// Lock the drag handle so it stays visible while menu is open
editor.commands.lockDragHandle();
},
[editor, hoveredNode],
);
// Close the menu
const handleCloseMenu = React.useCallback(() => {
setMenuOpen(false);
setMenuAnchor(null);
editor.commands.unlockDragHandle();
}, [editor]);
// Handle node change from drag handle
const handleNodeChange = React.useCallback(
(data: { node: PMNode | null; editor: Editor; pos: number }) => {
if (data.node) {
setHoveredNode({ node: data.node, pos: data.pos });
} else {
// Only clear if menu is not open
if (!menuOpen) {
setHoveredNode(null);
}
}
},
[menuOpen],
);
// Stable reference — DragHandle's useEffect depends on this by reference.
// An inline object causes plugin unregister/register every render, which
// tears down the Suggestion plugin view (calling onExit → setState → loop).
const computePositionConfig = React.useMemo(
() => ({
placement: "left-start" as const,
strategy: "absolute" as const,
}),
[],
);
return (
<>
<DragHandle
editor={editor}
onNodeChange={handleNodeChange}
computePositionConfig={computePositionConfig}
>
<button
ref={handleRef}
type="button"
className={cn(
"flex items-center justify-center",
"w-6 h-6 rounded select-none",
"text-kumo-subtle/50 hover:text-kumo-subtle",
"hover:bg-kumo-tint/80 cursor-grab active:cursor-grabbing",
"transition-colors duration-100",
menuOpen && "text-kumo-subtle bg-kumo-tint",
)}
onClick={handleClick}
data-block-handle
aria-label="Block actions - drag to reorder, click for menu"
>
<DotsSixVertical className="h-4 w-4" />
</button>
</DragHandle>
{/* Block menu */}
<BlockMenu
editor={editor}
anchorElement={menuAnchor}
isOpen={menuOpen}
onClose={handleCloseMenu}
/>
</>
);
}

View File

@@ -0,0 +1,529 @@
/**
* Image Detail Panel for Editor
*
* A slide-out panel for editing image properties in the rich text editor.
* Shows preview and allows editing alt text, caption, and link settings.
*/
import { Button, Input, InputArea, Label, LinkButton } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import {
X,
ArrowSquareOut,
Ruler,
SlidersHorizontal,
ImageSquare,
LinkSimple,
LinkBreak,
} from "@phosphor-icons/react";
import * as React from "react";
import type { MediaItem } from "../../lib/api";
import { useStableCallback } from "../../lib/hooks";
import { ConfirmDialog } from "../ConfirmDialog";
import { MediaPickerModal } from "../MediaPickerModal";
export interface ImageAttributes {
src: string;
alt?: string;
title?: string;
caption?: string;
mediaId?: string;
/** Original image width */
width?: number;
/** Original image height */
height?: number;
/** Display width for this instance (defaults to original) */
displayWidth?: number;
/** Display height for this instance (defaults to original) */
displayHeight?: number;
}
export interface ImageDetailPanelProps {
attributes: ImageAttributes;
onUpdate: (attrs: Partial<ImageAttributes>) => void;
onReplace: (attrs: ImageAttributes) => void;
onDelete: () => void;
onClose: () => void;
/** When true, renders inline within the sidebar column instead of as a fixed overlay */
inline?: boolean;
}
/**
* Panel for editing image properties in the editor.
* Renders as a fixed slide-out overlay by default, or inline within
* the content sidebar when `inline` is true.
*/
export function ImageDetailPanel({
attributes,
onUpdate,
onReplace,
onDelete,
onClose,
inline = false,
}: ImageDetailPanelProps) {
const { t } = useLingui();
// Form state
const [alt, setAlt] = React.useState(attributes.alt ?? "");
const [caption, setCaption] = React.useState(attributes.caption ?? "");
const [title, setTitle] = React.useState(attributes.title ?? "");
const [showMediaPicker, setShowMediaPicker] = React.useState(false);
// Dimension state - default to display dimensions, fall back to original
const [displayWidth, setDisplayWidth] = React.useState<number | undefined>(
attributes.displayWidth ?? attributes.width,
);
const [displayHeight, setDisplayHeight] = React.useState<number | undefined>(
attributes.displayHeight ?? attributes.height,
);
const [lockAspectRatio, setLockAspectRatio] = React.useState(true);
// Calculate aspect ratio from original dimensions
const aspectRatio =
attributes.width && attributes.height ? attributes.width / attributes.height : undefined;
const handleWidthChange = (value: string) => {
const newWidth = value ? parseInt(value, 10) : undefined;
setDisplayWidth(newWidth);
if (lockAspectRatio && aspectRatio && newWidth) {
setDisplayHeight(Math.round(newWidth / aspectRatio));
}
};
const handleHeightChange = (value: string) => {
const newHeight = value ? parseInt(value, 10) : undefined;
setDisplayHeight(newHeight);
if (lockAspectRatio && aspectRatio && newHeight) {
setDisplayWidth(Math.round(newHeight * aspectRatio));
}
};
const handleResetDimensions = () => {
setDisplayWidth(attributes.width);
setDisplayHeight(attributes.height);
};
const handleMediaSelect = (item: MediaItem) => {
onReplace({
src: item.url,
alt: item.alt || item.filename,
mediaId: item.id,
width: item.width,
height: item.height,
// Clear caption/title since it's a new image
caption: undefined,
title: undefined,
});
setShowMediaPicker(false);
onClose();
};
// Track if form has unsaved changes
const hasChanges = React.useMemo(() => {
const originalDisplayWidth = attributes.displayWidth ?? attributes.width;
const originalDisplayHeight = attributes.displayHeight ?? attributes.height;
return (
alt !== (attributes.alt ?? "") ||
caption !== (attributes.caption ?? "") ||
title !== (attributes.title ?? "") ||
displayWidth !== originalDisplayWidth ||
displayHeight !== originalDisplayHeight
);
}, [attributes, alt, caption, title, displayWidth, displayHeight]);
const handleSave = () => {
onUpdate({
alt: alt || undefined,
caption: caption || undefined,
title: title || undefined,
displayWidth,
displayHeight,
});
onClose();
};
const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
const handleDelete = () => {
setShowDeleteConfirm(true);
};
const stableOnClose = useStableCallback(onClose);
const stableHandleSave = useStableCallback(handleSave);
// Handle keyboard shortcuts
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
stableOnClose();
}
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
stableHandleSave();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [stableOnClose, stableHandleSave]);
const dialogs = (
<>
<ConfirmDialog
open={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
title={t`Remove Image?`}
description={t`Remove this image from the document?`}
confirmLabel={t`Remove`}
pendingLabel={t`Removing...`}
isPending={false}
error={null}
onConfirm={() => {
onDelete();
onClose();
}}
/>
<MediaPickerModal
open={showMediaPicker}
onOpenChange={setShowMediaPicker}
onSelect={handleMediaSelect}
mimeTypeFilter="image/"
title={t`Replace Image`}
/>
</>
);
if (inline) {
return (
<div className="rounded-lg border bg-kumo-base flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-2">
<SlidersHorizontal className="h-4 w-4 text-kumo-subtle" />
<h3 className="text-sm font-semibold">{t`Image Settings`}</h3>
</div>
<Button variant="ghost" shape="square" aria-label={t`Close`} onClick={onClose}>
<X className="h-4 w-4" />
<span className="sr-only">{t`Close`}</span>
</Button>
</div>
{/* Preview */}
<div className="p-4 border-b">
<div className="aspect-video bg-kumo-tint rounded-lg overflow-hidden flex items-center justify-center relative group">
<img
src={attributes.src}
alt={attributes.alt || ""}
className="max-h-full max-w-full object-contain"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Button
variant="secondary"
size="sm"
icon={<ImageSquare />}
onClick={() => setShowMediaPicker(true)}
>
{t`Replace Image`}
</Button>
</div>
</div>
{/* Original dimensions */}
{(attributes.width || attributes.height) && (
<div className="flex items-center gap-2 text-sm mt-3">
<Ruler className="h-4 w-4 text-kumo-subtle" />
<span className="text-kumo-subtle">{t`Original:`}</span>
<span>
{attributes.width} × {attributes.height}
</span>
</div>
)}
</div>
{/* Display Size */}
{attributes.width && attributes.height && (
<div className="p-4 border-b space-y-3">
<div className="flex items-center justify-between">
<Label>{t`Display Size`}</Label>
<Button
variant="ghost"
size="sm"
onClick={handleResetDimensions}
className="h-auto py-1 px-2 text-xs"
>
{t`Reset to original`}
</Button>
</div>
<div className="flex items-center gap-2">
<div className="flex-1">
<Input
label={t`Width`}
type="number"
value={displayWidth ?? ""}
onChange={(e) => handleWidthChange(e.target.value)}
/>
</div>
<Button
variant="ghost"
shape="square"
className="mt-5"
onClick={() => setLockAspectRatio(!lockAspectRatio)}
title={lockAspectRatio ? t`Unlock aspect ratio` : t`Lock aspect ratio`}
aria-label={lockAspectRatio ? t`Unlock aspect ratio` : t`Lock aspect ratio`}
>
{lockAspectRatio ? (
<LinkSimple className="h-4 w-4" />
) : (
<LinkBreak className="h-4 w-4 text-kumo-subtle" />
)}
</Button>
<div className="flex-1">
<Input
label={t`Height`}
type="number"
value={displayHeight ?? ""}
onChange={(e) => handleHeightChange(e.target.value)}
/>
</div>
</div>
<p className="text-xs text-kumo-subtle">
{t`Set a custom display size for this image instance.`}
</p>
</div>
)}
{/* Editable Fields */}
<div className="p-4 space-y-4">
<Input
label={t`Alt Text`}
value={alt}
onChange={(e) => setAlt(e.target.value)}
placeholder={t`Describe this image for accessibility`}
description={t`Required for accessibility. Describes the image for screen readers.`}
/>
<InputArea
label={t`Caption`}
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder={t`Optional caption displayed below the image`}
description={t`Displayed below the image as a visible caption.`}
rows={2}
/>
<Input
label={t`Title (Tooltip)`}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t`Optional tooltip on hover`}
description={t`Shown when hovering over the image.`}
/>
{/* Source URL - only show for external images (no mediaId) */}
{!attributes.mediaId && attributes.src && (
<div>
<Label>{t`Source`}</Label>
<div className="mt-1.5 flex gap-2">
<Input value={attributes.src} readOnly className="text-xs font-mono flex-1" />
<LinkButton
variant="outline"
shape="square"
href={attributes.src}
external
title={t`Open in new tab`}
aria-label={t`Open in new tab`}
>
<ArrowSquareOut className="h-4 w-4" />
</LinkButton>
</div>
</div>
)}
</div>
{/* Actions */}
<div className="p-4 border-t flex items-center justify-between gap-2">
<Button variant="destructive" size="sm" onClick={handleDelete}>
{t`Remove Image`}
</Button>
<Button size="sm" onClick={handleSave} disabled={!hasChanges}>
{t`Save`}
</Button>
</div>
{dialogs}
</div>
);
}
return (
<div className="fixed inset-y-0 end-0 w-96 bg-kumo-base border-s shadow-xl z-50 flex flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b p-4">
<div className="flex items-center gap-2">
<SlidersHorizontal className="h-4 w-4 text-kumo-subtle" />
<h2 className="font-semibold">{t`Image Settings`}</h2>
</div>
<Button variant="ghost" shape="square" aria-label={t`Close`} onClick={onClose}>
<X className="h-4 w-4" />
<span className="sr-only">{t`Close`}</span>
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{/* Preview */}
<div className="p-4 border-b">
<div className="aspect-video bg-kumo-tint rounded-lg overflow-hidden flex items-center justify-center relative group">
<img
src={attributes.src}
alt={attributes.alt || ""}
className="max-h-full max-w-full object-contain"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Button
variant="secondary"
size="sm"
icon={<ImageSquare />}
onClick={() => setShowMediaPicker(true)}
>
{t`Replace Image`}
</Button>
</div>
</div>
</div>
{/* Image Info - original dimensions */}
{(attributes.width || attributes.height) && (
<div className="p-4 border-b">
<div className="flex items-center gap-2 text-sm">
<Ruler className="h-4 w-4 text-kumo-subtle" />
<span className="text-kumo-subtle">{t`Original:`}</span>
<span>
{attributes.width} × {attributes.height}
</span>
</div>
</div>
)}
{/* Display Size */}
{attributes.width && attributes.height && (
<div className="p-4 border-b space-y-3">
<div className="flex items-center justify-between">
<Label>{t`Display Size`}</Label>
<Button
variant="ghost"
size="sm"
onClick={handleResetDimensions}
className="h-auto py-1 px-2 text-xs"
>
{t`Reset to original`}
</Button>
</div>
<div className="flex items-center gap-2">
<div className="flex-1">
<Input
label={t`Width`}
type="number"
value={displayWidth ?? ""}
onChange={(e) => handleWidthChange(e.target.value)}
/>
</div>
<Button
variant="ghost"
shape="square"
className="mt-5"
onClick={() => setLockAspectRatio(!lockAspectRatio)}
title={lockAspectRatio ? t`Unlock aspect ratio` : t`Lock aspect ratio`}
aria-label={lockAspectRatio ? t`Unlock aspect ratio` : t`Lock aspect ratio`}
>
{lockAspectRatio ? (
<LinkSimple className="h-4 w-4" />
) : (
<LinkBreak className="h-4 w-4 text-kumo-subtle" />
)}
</Button>
<div className="flex-1">
<Input
label={t`Height`}
type="number"
value={displayHeight ?? ""}
onChange={(e) => handleHeightChange(e.target.value)}
/>
</div>
</div>
<p className="text-xs text-kumo-subtle">
{t`Set a custom display size for this image instance.`}
</p>
</div>
)}
{/* Editable Fields */}
<div className="p-4 space-y-4">
<Input
label={t`Alt Text`}
value={alt}
onChange={(e) => setAlt(e.target.value)}
placeholder={t`Describe this image for accessibility`}
description={t`Required for accessibility. Describes the image for screen readers.`}
/>
<InputArea
label={t`Caption`}
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder={t`Optional caption displayed below the image`}
description={t`Displayed below the image as a visible caption.`}
rows={2}
/>
<Input
label={t`Title (Tooltip)`}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t`Optional tooltip on hover`}
description={t`Shown when hovering over the image.`}
/>
{/* Source URL - only show for external images (no mediaId) */}
{!attributes.mediaId && attributes.src && (
<div>
<Label>{t`Source`}</Label>
<div className="mt-1.5 flex gap-2">
<Input value={attributes.src} readOnly className="text-xs font-mono flex-1" />
<LinkButton
variant="outline"
shape="square"
href={attributes.src}
external
title={t`Open in new tab`}
aria-label={t`Open in new tab`}
>
<ArrowSquareOut className="h-4 w-4" />
</LinkButton>
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="p-4 border-t flex items-center justify-between gap-2">
<Button variant="destructive" size="sm" onClick={handleDelete}>
{t`Remove Image`}
</Button>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onClose}>
{t`Cancel`}
</Button>
<Button size="sm" onClick={handleSave} disabled={!hasChanges}>
{t`Save`}
</Button>
</div>
</div>
{dialogs}
</div>
);
}
export default ImageDetailPanel;

View File

@@ -0,0 +1,369 @@
/**
* Custom Image Node for TipTap
*
* Provides a selectable, editable image with:
* - Click to select
* - Visual selection indicator
* - Quick inline alt text editing
* - Full detail panel for advanced settings
* - Delete/replace options
*/
import { Button, Input } from "@cloudflare/kumo";
import { Trash, Pencil, X, Check, SlidersHorizontal } from "@phosphor-icons/react";
import type { NodeViewProps } from "@tiptap/react";
import { Node, mergeAttributes } from "@tiptap/react";
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
import * as React from "react";
import { cn } from "../../lib/utils";
import type { ImageAttributes } from "./ImageDetailPanel";
// Extend the Commands interface to include setImage
declare module "@tiptap/react" {
interface Commands<ReturnType> {
image: {
setImage: (options: {
src: string;
alt?: string;
title?: string;
caption?: string;
mediaId?: string;
/** Provider ID for external media (e.g., "cloudflare-images") */
provider?: string;
width?: number;
height?: number;
displayWidth?: number;
displayHeight?: number;
}) => ReturnType;
};
}
}
// React component for the image node view
function ImageNodeView({ node, updateAttributes, selected, deleteNode, editor }: NodeViewProps) {
const [isEditingAlt, setIsEditingAlt] = React.useState(false);
const [altText, setAltText] = React.useState(node.attrs.alt || "");
/** Whether this node currently has its sidebar panel open */
const sidebarOpenRef = React.useRef(false);
const handleSaveAlt = () => {
updateAttributes({ alt: altText });
setIsEditingAlt(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSaveAlt();
} else if (e.key === "Escape") {
setAltText(node.attrs.alt || "");
setIsEditingAlt(false);
}
};
// Sync local alt text state when node attributes change
React.useEffect(() => {
setAltText(node.attrs.alt || "");
}, [node.attrs.alt]);
const getImageAttrs = (): ImageAttributes => ({
src: node.attrs.src,
alt: node.attrs.alt,
title: node.attrs.title,
caption: node.attrs.caption,
mediaId: node.attrs.mediaId,
width: node.attrs.width,
height: node.attrs.height,
displayWidth: node.attrs.displayWidth,
displayHeight: node.attrs.displayHeight,
});
const openSidebar = () => {
const storage = (editor.storage as unknown as Record<string, Record<string, unknown>>).image;
const onOpen = storage?.onOpenBlockSidebar as
| ((panel: {
type: "image";
attrs: ImageAttributes;
onUpdate: (attrs: Partial<ImageAttributes>) => void;
onReplace: (attrs: ImageAttributes) => void;
onDelete: () => void;
onClose: () => void;
}) => void)
| null;
if (onOpen) {
sidebarOpenRef.current = true;
onOpen({
type: "image",
attrs: getImageAttrs(),
onUpdate: (attrs: Partial<ImageAttributes>) => updateAttributes(attrs),
onReplace: (attrs: ImageAttributes) => updateAttributes(attrs),
onDelete: () => deleteNode(),
onClose: () => {
sidebarOpenRef.current = false;
},
});
}
};
const closeSidebar = () => {
if (!sidebarOpenRef.current) return;
const storage = (editor.storage as unknown as Record<string, Record<string, unknown>>).image;
const onClose = storage?.onCloseBlockSidebar as (() => void) | null;
if (onClose) {
onClose();
sidebarOpenRef.current = false;
}
};
const toggleSidebar = () => {
if (sidebarOpenRef.current) {
closeSidebar();
} else {
openSidebar();
}
};
// Close sidebar when this node is deselected
React.useEffect(() => {
if (!selected) {
closeSidebar();
}
}, [selected]);
return (
<NodeViewWrapper
className={cn(
"relative my-4 group",
selected && "ring-2 ring-kumo-brand ring-offset-2 rounded-lg",
)}
>
<figure className="relative">
<img
src={node.attrs.src}
alt={node.attrs.alt || ""}
title={node.attrs.title || ""}
className="rounded-lg max-w-full mx-auto"
style={{
width: node.attrs.displayWidth ? `${node.attrs.displayWidth}px` : undefined,
height: node.attrs.displayHeight ? `${node.attrs.displayHeight}px` : undefined,
}}
draggable={false}
/>
{/* Selection overlay with actions */}
{selected && (
<div className="absolute top-2 end-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
type="button"
variant="secondary"
shape="square"
className="h-8 w-8"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setIsEditingAlt(true)}
title="Quick edit alt text"
aria-label="Quick edit alt text"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
type="button"
variant="secondary"
shape="square"
className="h-8 w-8"
onMouseDown={(e) => e.preventDefault()}
onClick={toggleSidebar}
title="Image settings"
aria-label="Image settings"
>
<SlidersHorizontal className="h-4 w-4" />
</Button>
<Button
type="button"
variant="destructive"
shape="square"
className="h-8 w-8"
onMouseDown={(e) => e.preventDefault()}
onClick={() => deleteNode()}
title="Delete image"
aria-label="Delete image"
>
<Trash className="h-4 w-4" />
</Button>
</div>
)}
{/* Quick alt text editor (inline) */}
{isEditingAlt && (
<div className="absolute bottom-0 start-0 end-0 bg-kumo-base/95 backdrop-blur p-3 rounded-b-lg border-t">
<label className="text-xs font-medium text-kumo-subtle mb-1 block">Alt text</label>
<div className="flex gap-2">
<Input
type="text"
value={altText}
onChange={(e) => setAltText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Describe the image..."
className="flex-1 h-8 text-sm"
autoFocus
/>
<Button
type="button"
variant="ghost"
shape="square"
className="h-8 w-8"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setAltText(node.attrs.alt || "");
setIsEditingAlt(false);
}}
title="Cancel"
aria-label="Cancel"
>
<X className="h-4 w-4" />
</Button>
<Button
type="button"
variant="primary"
shape="square"
className="h-8 w-8"
onMouseDown={(e) => e.preventDefault()}
onClick={handleSaveAlt}
title="Save"
aria-label="Save alt text"
>
<Check className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Caption display (shows caption if set, falls back to alt) */}
{!isEditingAlt && (node.attrs.caption || node.attrs.alt) && (
<figcaption className="text-center text-sm text-kumo-subtle mt-2">
{node.attrs.caption || node.attrs.alt}
</figcaption>
)}
</figure>
</NodeViewWrapper>
);
}
// Custom Image extension with React NodeView
export const ImageExtension = Node.create({
name: "image",
addOptions() {
return {
inline: false,
allowBase64: false,
HTMLAttributes: {},
};
},
addStorage() {
return {
/** Callback set by PortableTextEditor to open image settings in the content sidebar */
onOpenBlockSidebar: null as
| ((panel: {
type: "image";
attrs: import("./ImageDetailPanel").ImageAttributes;
onUpdate: (attrs: Partial<import("./ImageDetailPanel").ImageAttributes>) => void;
onReplace: (attrs: import("./ImageDetailPanel").ImageAttributes) => void;
onDelete: () => void;
onClose: () => void;
}) => void)
| null,
/** Callback set by PortableTextEditor to close the sidebar */
onCloseBlockSidebar: null as (() => void) | null,
};
},
inline() {
return this.options.inline;
},
group() {
return this.options.inline ? "inline" : "block";
},
draggable: true,
addAttributes() {
return {
src: {
default: null,
},
alt: {
default: null,
},
title: {
default: null,
},
caption: {
default: null,
},
mediaId: {
default: null,
},
/** Provider ID for external media (e.g., "cloudflare-images") */
provider: {
default: null,
},
width: {
default: null,
},
height: {
default: null,
},
displayWidth: {
default: null,
},
displayHeight: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "img[src]",
},
];
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ["img", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
},
addNodeView() {
return ReactNodeViewRenderer(ImageNodeView);
},
addCommands() {
return {
setImage:
(options: {
src: string;
alt?: string;
title?: string;
caption?: string;
mediaId?: string;
provider?: string;
width?: number;
height?: number;
displayWidth?: number;
displayHeight?: number;
}) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
({ commands }: any) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
});

View File

@@ -0,0 +1,88 @@
/**
* Markdown Link Extension for TipTap
*
* Converts markdown link syntax into proper link marks:
* - Typing `[text](url)` converts on closing paren
* - Pasting text containing `[text](url)` converts inline
* - Rejects disallowed protocols (e.g. `javascript:`) via Link's allowlist
*
* Augments the existing Link mark from StarterKit — no new marks added.
*/
import { Extension, InputRule, PasteRule } from "@tiptap/core";
import { isAllowedUri } from "@tiptap/extension-link";
import type { EditorState } from "@tiptap/pm/state";
// Matches [link text](https://url.com) — typed (input rule, end-anchored)
// match[1] = link text, match[2] = href
const MARKDOWN_LINK_INPUT_REGEX = /\[([^\]]+)\]\(([^)]+)\)$/;
// Matches [link text](https://url.com) — pasted (paste rule, global)
// match[1] = link text, match[2] = href
const MARKDOWN_LINK_PASTE_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g;
/** Shared handler context — InputRule and PasteRule use the same shape. */
interface RuleMatch {
state: EditorState;
range: { from: number; to: number };
match: RegExpMatchArray;
}
/**
* Replace a `[text](url)` match with `text` carrying the link mark.
* Returns null (no-op) if the URL fails the protocol allowlist.
*
* Shared by both the input rule and paste rule — the handler signature
* for InputRule and PasteRule is identical.
*/
function handleMarkdownLink({ state, range, match }: RuleMatch): null | void {
const linkType = state.schema.marks["link"];
const linkText = match[1];
const href = match[2]?.trim();
if (!linkType || !linkText || !href || !isAllowedUri(href)) return null;
const { tr } = state;
const mark = linkType.create({ href });
tr.replaceWith(range.from, range.to, state.schema.text(linkText, [mark]));
tr.removeStoredMark(linkType);
}
/**
* Adds markdown link syntax support to the TipTap editor.
*
* Typing `[text](url)` and completing the closing `)` converts the syntax
* into a proper link mark. Pasting text containing `[text](url)` patterns
* also converts them. URLs that fail the protocol allowlist (e.g. `javascript:`)
* are silently ignored, leaving the markdown syntax as literal text.
*
* Uses raw InputRule/PasteRule rather than the markInputRule/markPasteRule
* helpers because those helpers unconditionally use the last capture group as
* the replacement text — we need group 1 (text) as content and group 2 (href)
* as the attribute, so we write the transaction by hand.
*
* This augments the Link mark already provided by StarterKit — no new
* dependencies required.
*/
export const MarkdownLinkExtension = Extension.create({
name: "markdownLink",
addInputRules() {
return [
new InputRule({
find: MARKDOWN_LINK_INPUT_REGEX,
handler: handleMarkdownLink,
}),
];
},
addPasteRules() {
return [
new PasteRule({
find: MARKDOWN_LINK_PASTE_REGEX,
handler: handleMarkdownLink,
}),
];
},
});

View File

@@ -0,0 +1,506 @@
/**
* Plugin Block Node for TipTap
*
* Renders embed blocks (YouTube, Vimeo, tweets, etc.) with:
* - Selection indicator with ring
* - Inline URL editing via popover
* - Drag handle in left gutter
* - Action buttons on hover/selection
* - Keyboard support
*/
import { Button, Input } from "@cloudflare/kumo";
import type { Element } from "@emdash-cms/blocks";
import {
DotsSixVertical,
Trash,
Pencil,
X,
Check,
ArrowSquareOut,
YoutubeLogo,
LinkSimple,
Code,
Copy,
Cube,
ListBullets,
} from "@phosphor-icons/react";
import { Node, mergeAttributes } from "@tiptap/core";
import type { NodeViewProps } from "@tiptap/react";
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
import * as React from "react";
import { cn } from "../../lib/utils";
/**
* Plugin block definition for slash commands
*/
export interface PluginBlockDef {
type: string;
pluginId: string;
label: string;
icon?: string;
description?: string;
placeholder?: string;
/** Block Kit form fields. If declared, replaces the simple URL input. */
fields?: Element[];
/**
* Optional display category in the slash menu. Defaults to "Embeds" when omitted.
*/
category?: string;
}
// =============================================================================
// Plugin Block Registry (stored per-editor instance via TipTap extension storage)
// =============================================================================
/** Register plugin block definitions into editor storage so the node view can look up metadata */
export function registerPluginBlocks(
editor: { storage: Record<string, Record<string, unknown>> },
blocks: PluginBlockDef[],
): void {
const registry = new Map<string, PluginBlockDef>();
for (const block of blocks) {
registry.set(block.type, block);
}
const storage = editor.storage.pluginBlock as Record<string, unknown> | undefined;
if (storage) {
storage.registry = registry;
}
}
/** Read the registry from editor storage */
function getRegistry(editor: {
storage: Record<string, Record<string, unknown>>;
}): Map<string, PluginBlockDef> {
const storage = editor.storage.pluginBlock as Record<string, unknown> | undefined;
return (storage?.registry as Map<string, PluginBlockDef>) ?? new Map();
}
/** Named icon map: icon key → React component */
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
video: YoutubeLogo,
code: Code,
link: LinkSimple,
"link-external": ArrowSquareOut,
form: ListBullets,
};
/** Resolve an icon key to a React component */
function resolveIcon(iconKey?: string): React.ComponentType<{ className?: string }> {
if (iconKey && ICON_MAP[iconKey]) {
return ICON_MAP[iconKey];
}
return Cube;
}
/**
* Get icon component and metadata for embed block types.
* Reads from the plugin block registry in editor storage.
*/
function getEmbedMeta(
blockType: string,
registry: Map<string, PluginBlockDef>,
): {
Icon: React.ComponentType<{ className?: string }>;
label: string;
color: string;
placeholder: string;
} {
const def = registry.get(blockType);
if (def) {
return {
Icon: resolveIcon(def.icon),
label: def.label,
color: "text-kumo-subtle",
placeholder: def.placeholder || "Enter URL...",
};
}
// Fallback for unregistered block types
return {
Icon: Cube,
label: blockType.charAt(0).toUpperCase() + blockType.slice(1),
color: "text-kumo-subtle",
placeholder: "Enter URL...",
};
}
/**
* Extract display ID from URL for cleaner presentation
*/
function getDisplayId(id: string, blockType: string): string {
try {
const url = new URL(id);
switch (blockType) {
case "youtube": {
// youtube.com/watch?v=VIDEO_ID or youtu.be/VIDEO_ID
const videoId = url.searchParams.get("v") || url.pathname.split("/").pop();
return videoId || id;
}
case "vimeo": {
// vimeo.com/VIDEO_ID
return url.pathname.split("/").find(Boolean) || id;
}
case "tweet": {
// twitter.com/user/status/TWEET_ID
const parts = url.pathname.split("/");
const statusIndex = parts.indexOf("status");
const tweetId = parts[statusIndex + 1];
if (statusIndex !== -1 && tweetId) {
return `@${parts[1]}/${tweetId.slice(0, 8)}...`;
}
return id;
}
case "gist": {
// gist.github.com/user/GIST_ID
const parts = url.pathname.split("/").filter(Boolean);
if (parts.length >= 2 && parts[0] && parts[1]) {
return `${parts[0]}/${parts[1].slice(0, 8)}...`;
}
return id;
}
default:
// Show hostname + truncated path
return url.hostname + (url.pathname.length > 20 ? "..." : url.pathname);
}
} catch {
// Not a valid URL, show as-is but truncated
return id.length > 30 ? id.slice(0, 27) + "..." : id;
}
}
/**
* React component for the plugin block node view
*/
function PluginBlockNodeView({
node,
updateAttributes,
selected,
deleteNode,
editor,
getPos,
}: NodeViewProps) {
const blockType = typeof node.attrs.blockType === "string" ? node.attrs.blockType : "";
const id = typeof node.attrs.id === "string" ? node.attrs.id : "";
const data =
typeof node.attrs.data === "object" && node.attrs.data !== null
? (node.attrs.data as Record<string, unknown>)
: {};
const registry = getRegistry(
editor as unknown as { storage: Record<string, Record<string, unknown>> },
);
const { Icon, label, color, placeholder } = getEmbedMeta(blockType, registry);
// Check if this block type has fields defined in the registry
const blockDef = registry.get(blockType);
const hasFields = blockDef?.fields && blockDef.fields.length > 0;
const [isEditing, setIsEditing] = React.useState(false);
const [editValue, setEditValue] = React.useState(id || "");
const inputRef = React.useRef<HTMLInputElement>(null);
// Focus input when editing starts
React.useEffect(() => {
if (isEditing) {
setEditValue(id || "");
setTimeout(() => inputRef.current?.focus(), 0);
}
}, [isEditing, id]);
const handleSave = () => {
if (editValue.trim()) {
updateAttributes({ id: editValue.trim() });
}
setIsEditing(false);
};
const handleCancel = () => {
setEditValue(id || "");
setIsEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
e.preventDefault();
handleCancel();
}
};
const handleCopyUrl = () => {
void navigator.clipboard.writeText(id);
};
const handleOpenExternal = () => {
window.open(id, "_blank", "noopener,noreferrer");
};
const displayId = id
? getDisplayId(id, blockType)
: Object.values(data)
.filter((v) => typeof v === "string" && v.length > 0)
.join(", ") || blockType;
return (
<NodeViewWrapper
className={cn(
"plugin-block relative my-3",
selected && "ring-2 ring-kumo-brand ring-offset-2 rounded-lg",
)}
contentEditable={false}
data-drag-handle
>
<div className="relative group">
{/* Drag handle - appears in left gutter */}
<div
className={cn(
"absolute -start-8 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing",
selected && "opacity-100",
)}
data-drag-handle
>
<DotsSixVertical className="h-5 w-5 text-kumo-subtle/50" />
</div>
{/* Main block content */}
<div
className={cn(
"rounded-lg border bg-kumo-base transition-colors",
selected ? "border-kumo-brand/50 bg-kumo-tint/30" : "hover:border-kumo-line",
)}
>
{/* Header with icon, label, and actions */}
<div className="flex items-center gap-3 px-4 py-3">
{/* Icon */}
<div
className={cn(
"flex-shrink-0 w-10 h-10 rounded-lg bg-kumo-tint flex items-center justify-center",
color,
)}
>
<Icon className="h-5 w-5" />
</div>
{/* Label and ID */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{label}</div>
{!isEditing && (
<div className="text-xs text-kumo-subtle truncate font-mono">{displayId}</div>
)}
</div>
{/* Action buttons - visible on hover or when selected */}
<div
className={cn(
"flex items-center gap-1 transition-opacity",
selected ? "opacity-100" : "opacity-0 group-hover:opacity-100",
)}
>
{id && (
<>
<Button
type="button"
variant="ghost"
shape="square"
className="h-8 w-8"
onClick={handleCopyUrl}
title="Copy URL"
aria-label="Copy URL"
>
<Copy className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
shape="square"
className="h-8 w-8"
onClick={handleOpenExternal}
title="Open in new tab"
aria-label="Open in new tab"
>
<ArrowSquareOut className="h-4 w-4" />
</Button>
</>
)}
<Button
type="button"
variant="ghost"
shape="square"
className="h-8 w-8"
onClick={() => {
if (hasFields) {
// Open Block Kit modal via editor storage callback
const storage = (
editor.storage as unknown as Record<string, Record<string, unknown>>
).pluginBlock;
const onEdit = storage?.onEditBlock as
| ((attrs: {
blockType: string;
id: string;
data: Record<string, unknown>;
pos: number;
}) => void)
| null;
if (onEdit) {
const pos = (typeof getPos === "function" ? getPos() : 0) ?? 0;
onEdit({ blockType, id, data, pos });
}
} else {
setIsEditing(true);
}
}}
title={hasFields ? "Edit" : "Edit URL"}
aria-label={hasFields ? "Edit" : "Edit URL"}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
shape="square"
className="h-8 w-8 text-kumo-danger hover:text-kumo-danger hover:bg-kumo-danger/10"
onClick={() => deleteNode()}
title="Delete"
aria-label="Delete embed"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
{/* Inline URL editor - slides down when editing */}
{isEditing && (
<div className="px-4 pb-3 pt-0">
<div className="flex gap-2">
<Input
ref={inputRef}
type="url"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="flex-1 h-9 text-sm font-mono"
/>
<Button
type="button"
variant="ghost"
shape="square"
className="h-9 w-9"
onClick={handleCancel}
title="Cancel (Esc)"
aria-label="Cancel"
>
<X className="h-4 w-4" />
</Button>
<Button
type="button"
variant="primary"
shape="square"
className="h-9 w-9"
onClick={handleSave}
title="Save (Enter)"
aria-label="Save"
>
<Check className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
</div>
</NodeViewWrapper>
);
}
/**
* TipTap Node extension for plugin blocks (embeds)
*/
export const PluginBlockExtension = Node.create({
name: "pluginBlock",
group: "block",
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
blockType: {
default: null,
},
id: {
default: null,
},
data: {
default: {},
parseHTML: (el: HTMLElement) => JSON.parse(el.getAttribute("data-plugin-data") || "{}"),
renderHTML: (attrs: Record<string, unknown>) => ({
"data-plugin-data": JSON.stringify(attrs.data),
}),
},
};
},
addStorage() {
return {
/** Per-editor registry of plugin block definitions */
registry: new Map<string, PluginBlockDef>(),
/** Callback set by PortableTextEditor to open the Block Kit modal for editing */
onEditBlock: null as
| ((attrs: {
blockType: string;
id: string;
data: Record<string, unknown>;
pos: number;
}) => void)
| null,
};
},
parseHTML() {
return [
{
tag: "div[data-plugin-block]",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes(HTMLAttributes, { "data-plugin-block": "" })];
},
addNodeView() {
return ReactNodeViewRenderer(PluginBlockNodeView);
},
addKeyboardShortcuts() {
return {
// Delete block on backspace when selected (not editing)
Backspace: () => {
const { selection } = this.editor.state;
const node = this.editor.state.doc.nodeAt(selection.from);
if (node?.type.name === "pluginBlock") {
this.editor.commands.deleteSelection();
return true;
}
return false;
},
// Also handle Delete key
Delete: () => {
const { selection } = this.editor.state;
const node = this.editor.state.doc.nodeAt(selection.from);
if (node?.type.name === "pluginBlock") {
this.editor.commands.deleteSelection();
return true;
}
return false;
},
};
},
});
// Re-export helpers for use elsewhere
export { getEmbedMeta, resolveIcon };

View File

@@ -0,0 +1,26 @@
// Layout components
export { Shell, type ShellProps } from "./Shell";
export { Sidebar, SidebarNav, type SidebarNavProps } from "./Sidebar";
export { Header } from "./Header";
// Page components
export { Dashboard, type DashboardProps } from "./Dashboard";
export { ContentList, type ContentListProps } from "./ContentList";
export { ContentEditor, type ContentEditorProps, type FieldDescriptor } from "./ContentEditor";
export { MediaLibrary, type MediaLibraryProps } from "./MediaLibrary";
export { MediaPickerModal, type MediaPickerModalProps } from "./MediaPickerModal";
export { Settings } from "./Settings";
// Rich text editor
export { PortableTextEditor, type PortableTextEditorProps } from "./PortableTextEditor";
// Buttons
export { SaveButton, type SaveButtonProps } from "./SaveButton";
// Layout primitives shared by editor pages
export { EditorHeader, type EditorHeaderProps } from "./EditorHeader";
// Auth components
export * from "./auth";
export { LoginPage } from "./LoginPage";
export { SetupWizard } from "./SetupWizard";

View File

@@ -0,0 +1,446 @@
/**
* Allowed Domains Settings - Self-signup domain management
*
* Only available when using passkey auth. When external auth (e.g., Cloudflare Access)
* is configured, this page shows an informational message instead.
*/
import { Button, Dialog, Input, Select, Switch } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import {
Globe,
Plus,
CheckCircle,
WarningCircle,
Trash,
Pencil,
Info,
} from "@phosphor-icons/react";
import { X } from "@phosphor-icons/react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as React from "react";
import {
fetchAllowedDomains,
createAllowedDomain,
updateAllowedDomain,
deleteAllowedDomain,
fetchManifest,
type AllowedDomain,
} from "../../lib/api";
import { ArrowPrev } from "../ArrowIcons.js";
import { useAllowedDomainsRolesConfig } from "./useAllowedDomainsRolesConfig.js";
export function AllowedDomainsSettings() {
const { t } = useLingui();
const { getRoleLabel, signupRoles, signupRoleItems } = useAllowedDomainsRolesConfig();
const queryClient = useQueryClient();
const [isAddingDomain, setIsAddingDomain] = React.useState(false);
const [editingDomain, setEditingDomain] = React.useState<AllowedDomain | null>(null);
const [deletingDomain, setDeletingDomain] = React.useState<string | null>(null);
const [saveStatus, setSaveStatus] = React.useState<{
type: "success" | "error";
message: string;
} | null>(null);
// Form state
const [newDomain, setNewDomain] = React.useState("");
const [newRole, setNewRole] = React.useState<number>(30); // Default to Author
// Fetch manifest for auth mode
const { data: manifest, isLoading: manifestLoading } = useQuery({
queryKey: ["manifest"],
queryFn: fetchManifest,
});
const isExternalAuth = manifest?.authMode && manifest.authMode !== "passkey";
// Fetch domains (only when using passkey auth)
const {
data: domains,
isLoading,
error,
} = useQuery({
queryKey: ["allowed-domains"],
queryFn: fetchAllowedDomains,
enabled: !isExternalAuth && !manifestLoading,
});
// Clear status message after 3 seconds
React.useEffect(() => {
if (saveStatus) {
const timer = setTimeout(setSaveStatus, 3000, null);
return () => clearTimeout(timer);
}
}, [saveStatus]);
// Create mutation
const createMutation = useMutation({
mutationFn: createAllowedDomain,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["allowed-domains"] });
setIsAddingDomain(false);
setNewDomain("");
setNewRole(30);
setSaveStatus({ type: "success", message: t`Domain added successfully` });
},
onError: (mutationError) => {
setSaveStatus({
type: "error",
message: mutationError instanceof Error ? mutationError.message : t`Failed to add domain`,
});
},
});
// Update mutation
const updateMutation = useMutation({
mutationFn: ({
domain,
data,
}: {
domain: string;
data: { enabled?: boolean; defaultRole?: number };
}) => updateAllowedDomain(domain, data),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["allowed-domains"] });
setEditingDomain(null);
setSaveStatus({ type: "success", message: t`Domain updated` });
},
onError: (mutationError) => {
setSaveStatus({
type: "error",
message:
mutationError instanceof Error ? mutationError.message : t`Failed to update domain`,
});
},
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: deleteAllowedDomain,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["allowed-domains"] });
setDeletingDomain(null);
setSaveStatus({ type: "success", message: t`Domain removed` });
},
onError: (mutationError) => {
setSaveStatus({
type: "error",
message:
mutationError instanceof Error ? mutationError.message : t`Failed to remove domain`,
});
},
});
const handleAddDomain = () => {
if (!newDomain.trim()) return;
createMutation.mutate({
domain: newDomain.trim().toLowerCase(),
defaultRole: newRole,
});
};
const handleToggleEnabled = (domain: AllowedDomain) => {
updateMutation.mutate({
domain: domain.domain,
data: { enabled: !domain.enabled },
});
};
const handleUpdateRole = (domain: string, role: number) => {
updateMutation.mutate({
domain,
data: { defaultRole: role },
});
setEditingDomain(null);
};
const handleDelete = () => {
if (deletingDomain) {
deleteMutation.mutate(deletingDomain);
}
};
const settingsHeader = (
<div className="flex items-center gap-3">
<Link to="/settings">
<Button variant="ghost" shape="square" aria-label={t`Back to settings`}>
<ArrowPrev className="h-4 w-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold">{t`Self-Signup Domains`}</h1>
</div>
);
if (manifestLoading || isLoading) {
return (
<div className="space-y-6">
{settingsHeader}
<div className="rounded-lg border bg-kumo-base p-6">
<p className="text-kumo-subtle">{t`Loading...`}</p>
</div>
</div>
);
}
// Show message when external auth is configured
if (isExternalAuth) {
return (
<div className="space-y-6">
{settingsHeader}
<div className="rounded-lg border bg-kumo-base p-6">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-kumo-subtle mt-0.5 flex-shrink-0" />
<div className="space-y-2">
<p className="text-kumo-subtle">
{t`User access is managed by an external provider (${manifest?.authMode}). Self-signup domain settings are not available when using external authentication.`}
</p>
</div>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-6">
{settingsHeader}
<div className="rounded-lg border bg-kumo-base p-6">
<p className="text-kumo-danger">
{error instanceof Error ? error.message : t`Failed to load allowed domains`}
</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{settingsHeader}
{/* Status message */}
{saveStatus && (
<div
className={`flex items-center gap-2 rounded-lg border p-3 text-sm ${
saveStatus.type === "success"
? "border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-200"
: "border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950/30 dark:text-red-200"
}`}
>
{saveStatus.type === "success" ? (
<CheckCircle className="h-4 w-4 flex-shrink-0" />
) : (
<WarningCircle className="h-4 w-4 flex-shrink-0" />
)}
{saveStatus.message}
</div>
)}
{/* Domains Section */}
<div className="rounded-lg border bg-kumo-base p-6">
<div className="flex items-center gap-2 mb-4">
<Globe className="h-5 w-5 text-kumo-subtle" />
<h2 className="text-lg font-semibold">{t`Allowed Domains`}</h2>
</div>
<p className="text-sm text-kumo-subtle mb-6">
{t`Users with email addresses from these domains can sign up without an invite. They will be assigned the specified role automatically.`}
</p>
{/* Domain list */}
{domains && domains.length > 0 ? (
<div className="space-y-2">
{domains.map((domain) => (
<div
key={domain.domain}
className={`flex items-center justify-between p-4 rounded-lg border ${
domain.enabled ? "bg-kumo-base" : "bg-kumo-tint/50 opacity-60"
}`}
>
<div className="flex items-center gap-4">
<Switch
checked={domain.enabled}
onCheckedChange={() => handleToggleEnabled(domain)}
disabled={updateMutation.isPending}
/>
<div>
<div className="font-medium">{domain.domain}</div>
<div className="text-sm text-kumo-subtle">
{t`Default role:`} {getRoleLabel(domain.defaultRole)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
shape="square"
onClick={() => setEditingDomain(domain)}
disabled={updateMutation.isPending}
aria-label={t`Edit ${domain.domain}`}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
shape="square"
onClick={() => setDeletingDomain(domain.domain)}
disabled={deleteMutation.isPending}
aria-label={t`Delete ${domain.domain}`}
>
<Trash className="h-4 w-4 text-kumo-danger" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-dashed p-6 text-center text-kumo-subtle">
{t`No domains configured. Users must be invited individually.`}
</div>
)}
{/* Add domain section */}
<div className="mt-6 pt-6 border-t">
{isAddingDomain ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">{t`Add an allowed domain`}</h3>
<Button
variant="ghost"
size="sm"
onClick={() => {
setIsAddingDomain(false);
setNewDomain("");
}}
>
{t`Cancel`}
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Input
label={t`Domain`}
placeholder="example.com"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
/>
</div>
<div className="space-y-2">
<Select
label={t`Default Role`}
value={String(newRole)}
onValueChange={(v) => v !== null && setNewRole(Number(v))}
items={signupRoleItems}
>
{signupRoles.map((role) => (
<Select.Option key={role.value} value={String(role.value)}>
{role.label}
</Select.Option>
))}
</Select>
</div>
</div>
<Button
onClick={handleAddDomain}
disabled={!newDomain.trim() || createMutation.isPending}
>
{createMutation.isPending ? t`Adding...` : t`Add Domain`}
</Button>
</div>
) : (
<Button onClick={() => setIsAddingDomain(true)} icon={<Plus />}>
{t`Add Domain`}
</Button>
)}
</div>
</div>
{/* Edit Domain Dialog */}
<Dialog.Root
open={!!editingDomain}
onOpenChange={(open: boolean) => !open && setEditingDomain(null)}
>
<Dialog className="p-6" size="lg">
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex flex-col space-y-1.5">
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
{t`Edit Domain`}
</Dialog.Title>
<Dialog.Description className="text-sm text-kumo-subtle">
{t`Update settings for ${editingDomain?.domain}`}
</Dialog.Description>
</div>
<Dialog.Close
aria-label={t`Close`}
render={(props) => (
<Button
{...props}
variant="ghost"
shape="square"
aria-label={t`Close`}
className="absolute end-4 top-4"
>
<X className="h-4 w-4" />
<span className="sr-only">{t`Close`}</span>
</Button>
)}
/>
</div>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Select
label={t`Default Role`}
value={String(editingDomain?.defaultRole ?? 30)}
onValueChange={(v) =>
v !== null && editingDomain && handleUpdateRole(editingDomain.domain, Number(v))
}
items={signupRoleItems}
>
{signupRoles.map((role) => (
<Select.Option key={role.value} value={String(role.value)}>
{role.label}
</Select.Option>
))}
</Select>
</div>
</div>
</Dialog>
</Dialog.Root>
{/* Delete Confirmation */}
<Dialog.Root
open={!!deletingDomain}
onOpenChange={(open) => !open && setDeletingDomain(null)}
disablePointerDismissal
>
<Dialog className="p-6" size="sm">
<Dialog.Title className="text-lg font-semibold">{t`Remove Domain?`}</Dialog.Title>
<Dialog.Description className="text-kumo-subtle">
{t`Users from`} <strong>{deletingDomain}</strong>{" "}
{t`will no longer be able to sign up without an invite. Existing users are not affected.`}
</Dialog.Description>
<div className="mt-6 flex justify-end gap-2">
<Dialog.Close
render={(p) => (
<Button {...p} variant="secondary">
{t`Cancel`}
</Button>
)}
/>
<Dialog.Close
render={(p) => (
<Button {...p} variant="destructive" onClick={handleDelete}>
{t`Remove Domain`}
</Button>
)}
/>
</div>
</Dialog>
</Dialog.Root>
</div>
);
}
export default AllowedDomainsSettings;

View File

@@ -0,0 +1,464 @@
/**
* API Tokens settings page
*
* Allows admins to list, create, and revoke Personal Access Tokens.
*/
import { Button, Checkbox, Input, Loader, Select } from "@cloudflare/kumo";
import type { MessageDescriptor } from "@lingui/core";
import { msg } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { Copy, Eye, EyeSlash, Key, Plus, Trash, WarningCircle } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as React from "react";
import {
fetchApiTokens,
createApiToken,
revokeApiToken,
API_TOKEN_SCOPES,
type ApiTokenCreateResult,
type ApiTokenScopeValue,
} from "../../lib/api/api-tokens.js";
import { ArrowPrev } from "../ArrowIcons.js";
import { getMutationError } from "../DialogError.js";
// =============================================================================
// Expiry options
// =============================================================================
const EXPIRY_OPTIONS = [
{ value: "none", label: msg`No expiry` },
{ value: "7d", label: msg`7 days` },
{ value: "30d", label: msg`30 days` },
{ value: "90d", label: msg`90 days` },
{ value: "365d", label: msg`1 year` },
] as const;
const API_TOKEN_SCOPE_VALUES: {
scope: ApiTokenScopeValue;
label: MessageDescriptor;
description: MessageDescriptor;
}[] = [
{
scope: API_TOKEN_SCOPES.ContentRead,
label: msg`Content Read`,
description: msg`Read content entries`,
},
{
scope: API_TOKEN_SCOPES.ContentWrite,
label: msg`Content Write`,
description: msg`Create, update, delete content`,
},
{
scope: API_TOKEN_SCOPES.MediaRead,
label: msg`Media Read`,
description: msg`Read media files`,
},
{
scope: API_TOKEN_SCOPES.MediaWrite,
label: msg`Media Write`,
description: msg`Upload and delete media`,
},
{
scope: API_TOKEN_SCOPES.SchemaRead,
label: msg`Schema Read`,
description: msg`Read collection schemas`,
},
{
scope: API_TOKEN_SCOPES.SchemaWrite,
label: msg`Schema Write`,
description: msg`Modify collection schemas`,
},
{
scope: API_TOKEN_SCOPES.TaxonomiesManage,
label: msg`Taxonomies Manage`,
description: msg`Create, update, and delete taxonomy terms`,
},
{
scope: API_TOKEN_SCOPES.MenusManage,
label: msg`Menus Manage`,
description: msg`Create, update, and delete navigation menus`,
},
{
scope: API_TOKEN_SCOPES.SettingsRead,
label: msg`Settings Read`,
description: msg`Read site settings`,
},
{
scope: API_TOKEN_SCOPES.SettingsManage,
label: msg`Settings Manage`,
description: msg`Update site settings`,
},
{
scope: API_TOKEN_SCOPES.Admin,
label: msg`Admin`,
description: msg`Full admin access`,
},
];
/** Wire scopes shown on the create-token form (contract-tested vs `API_TOKEN_SCOPES` and `@emdash-cms/auth`). */
export const API_TOKEN_SCOPE_FORM_SCOPES: readonly ApiTokenScopeValue[] =
API_TOKEN_SCOPE_VALUES.map((row) => row.scope);
function computeExpiryDate(option: string): string | undefined {
if (option === "none") return undefined;
const days = parseInt(option, 10);
if (Number.isNaN(days)) return undefined;
const date = new Date();
date.setDate(date.getDate() + days);
return date.toISOString();
}
// =============================================================================
// Main component
// =============================================================================
export function ApiTokenSettings() {
const { t } = useLingui();
const queryClient = useQueryClient();
const [showCreateForm, setShowCreateForm] = React.useState(false);
const [newToken, setNewToken] = React.useState<ApiTokenCreateResult | null>(null);
const [tokenVisible, setTokenVisible] = React.useState(false);
const [copied, setCopied] = React.useState(false);
const [revokeConfirmId, setRevokeConfirmId] = React.useState<string | null>(null);
// Queries
const { data: tokens, isLoading } = useQuery({
queryKey: ["api-tokens"],
queryFn: fetchApiTokens,
});
// Create mutation
const createMutation = useMutation({
mutationFn: createApiToken,
onSuccess: (result) => {
setNewToken(result);
setShowCreateForm(false);
setTokenVisible(false);
setCopied(false);
void queryClient.invalidateQueries({ queryKey: ["api-tokens"] });
},
});
// Revoke mutation
const revokeMutation = useMutation({
mutationFn: revokeApiToken,
onSuccess: () => {
setRevokeConfirmId(null);
void queryClient.invalidateQueries({ queryKey: ["api-tokens"] });
},
});
// Clean up copy feedback timeout on unmount
const copyTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
React.useEffect(() => {
return () => {
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
};
}, []);
const handleCopyToken = async () => {
if (!newToken) return;
try {
await navigator.clipboard.writeText(newToken.token);
setCopied(true);
copyTimeoutRef.current = setTimeout(setCopied, 2000, false);
} catch {
// Clipboard API can fail in insecure contexts or when denied
}
};
const expirySelectItems = React.useMemo(
() => Object.fromEntries(EXPIRY_OPTIONS.map((o) => [o.value, t(o.label)])),
[t],
);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Link to="/settings">
<Button variant="ghost" shape="square" aria-label={t(msg`Back to settings`)}>
<ArrowPrev className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">{t(msg`API Tokens`)}</h1>
<p className="text-sm text-kumo-subtle">
{t(msg`Create personal access tokens for programmatic API access`)}
</p>
</div>
</div>
{/* New token banner */}
{newToken && (
<div className="rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-950/30 p-4">
<div className="flex items-start gap-3">
<Key className="h-5 w-5 text-green-600 dark:text-green-400 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium text-green-800 dark:text-green-200">
{t(msg`Token created: ${newToken.info.name}`)}
</p>
<p className="text-sm text-green-700 dark:text-green-300 mt-1">
{t(msg`Copy this token now — it won't be shown again.`)}
</p>
<div className="mt-3 flex items-center gap-2">
<code className="flex-1 rounded bg-white dark:bg-black/30 px-3 py-2 text-sm font-mono border truncate">
{tokenVisible ? newToken.token : "••••••••••••••••••••••••••••"}
</code>
<Button
variant="ghost"
shape="square"
onClick={() => setTokenVisible(!tokenVisible)}
aria-label={tokenVisible ? t(msg`Hide token`) : t(msg`Show token`)}
>
{tokenVisible ? <EyeSlash /> : <Eye />}
</Button>
<Button
variant="ghost"
shape="square"
onClick={handleCopyToken}
aria-label={t(msg`Copy token`)}
>
<Copy />
</Button>
</div>
{copied && (
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
{t(msg`Copied to clipboard`)}
</p>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setNewToken(null)}
aria-label={t(msg`Dismiss`)}
>
{t(msg`Dismiss`)}
</Button>
</div>
</div>
)}
{/* Create form */}
{showCreateForm ? (
<CreateTokenForm
expirySelectItems={expirySelectItems}
isCreating={createMutation.isPending}
error={createMutation.error?.message ?? null}
onSubmit={(input) =>
createMutation.mutate({
name: input.name,
scopes: input.scopes,
expiresAt: input.expiresAt,
})
}
onCancel={() => setShowCreateForm(false)}
/>
) : (
<Button icon={<Plus />} onClick={() => setShowCreateForm(true)}>
{t(msg`Create Token`)}
</Button>
)}
{/* Token list */}
<div className="rounded-lg border bg-kumo-base">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader />
</div>
) : !tokens || tokens.length === 0 ? (
<div className="py-8 text-center text-sm text-kumo-subtle">
{t(msg`No API tokens yet. Create one to get started.`)}
</div>
) : (
<div className="divide-y">
{tokens.map((token) => (
<div key={token.id} className="flex items-center justify-between p-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{token.name}</span>
<code className="text-xs text-kumo-subtle bg-kumo-tint px-1.5 py-0.5 rounded">
{token.prefix}...
</code>
</div>
<div className="flex gap-3 mt-1 text-xs text-kumo-subtle">
<span>{t(msg`Scopes: ${token.scopes.join(", ")}`)}</span>
{token.expiresAt && (
<span>
{t(msg`Expires ${new Date(token.expiresAt).toLocaleDateString()}`)}
</span>
)}
{token.lastUsedAt && (
<span>
{t(msg`Last used ${new Date(token.lastUsedAt).toLocaleDateString()}`)}
</span>
)}
</div>
<div className="text-xs text-kumo-subtle mt-0.5">
{t(msg`Created ${new Date(token.createdAt).toLocaleDateString()}`)}
</div>
</div>
{revokeConfirmId === token.id ? (
<div className="flex items-center gap-2 shrink-0">
{revokeMutation.error && (
<span className="text-sm text-kumo-danger">
{getMutationError(revokeMutation.error)}
</span>
)}
<span className="text-sm text-kumo-danger">{t(msg`Revoke?`)}</span>
<Button
variant="destructive"
size="sm"
disabled={revokeMutation.isPending}
onClick={() => revokeMutation.mutate(token.id)}
>
{revokeMutation.isPending ? t(msg`Revoking...`) : t(msg`Confirm`)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setRevokeConfirmId(null);
revokeMutation.reset();
}}
>
{t(msg`Cancel`)}
</Button>
</div>
) : (
<Button
variant="ghost"
shape="square"
onClick={() => setRevokeConfirmId(token.id)}
aria-label={t(msg`Revoke token`)}
>
<Trash className="h-4 w-4 text-kumo-subtle hover:text-kumo-danger" />
</Button>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
// =============================================================================
// Create token form
// =============================================================================
interface CreateTokenFormProps {
expirySelectItems: Record<string, string>;
isCreating: boolean;
error: string | null;
onSubmit: (input: { name: string; scopes: string[]; expiresAt?: string }) => void;
onCancel: () => void;
}
function CreateTokenForm({
expirySelectItems,
isCreating,
error,
onSubmit,
onCancel,
}: CreateTokenFormProps) {
const { t } = useLingui();
const [name, setName] = React.useState("");
const [selectedScopes, setSelectedScopes] = React.useState<Set<string>>(new Set());
const [expiry, setExpiry] = React.useState("30d");
const toggleScope = (scope: string) => {
setSelectedScopes((prev) => {
const next = new Set(prev);
if (next.has(scope)) {
next.delete(scope);
} else {
next.add(scope);
}
return next;
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({
name: name.trim(),
scopes: [...selectedScopes],
expiresAt: computeExpiryDate(expiry),
});
};
const isValid = name.trim().length > 0 && selectedScopes.size > 0;
return (
<div className="rounded-lg border bg-kumo-base p-6">
<h2 className="text-lg font-semibold mb-4">{t(msg`Create New Token`)}</h2>
{error && (
<div className="mb-4 rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-3 flex items-center gap-2 text-sm text-kumo-danger">
<WarningCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label={t(msg`Token Name`)}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t(msg`e.g., CI/CD Pipeline`)}
required
autoFocus
/>
<div>
<div className="text-sm font-medium mb-2">{t(msg`Scopes`)}</div>
<div className="space-y-2">
{API_TOKEN_SCOPE_VALUES.map(({ scope, label, description }) => {
return (
<label key={scope} className="flex items-start gap-2 cursor-pointer">
<Checkbox
checked={selectedScopes.has(scope)}
onCheckedChange={() => toggleScope(scope)}
/>
<div>
<div className="text-sm font-medium">{t(label)}</div>
<div className="text-xs text-kumo-subtle">{t(description)}</div>
</div>
</label>
);
})}
</div>
</div>
<Select
label={t(msg`Expiry`)}
value={expiry}
onValueChange={(v) => v !== null && setExpiry(v)}
items={expirySelectItems}
>
{EXPIRY_OPTIONS.map((option) => (
<Select.Option key={option.value} value={option.value}>
{t(option.label)}
</Select.Option>
))}
</Select>
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={!isValid || isCreating}>
{isCreating ? t(msg`Creating...`) : t(msg`Create Token`)}
</Button>
<Button type="button" variant="outline" onClick={onCancel}>
{t(msg`Cancel`)}
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,250 @@
/**
* Email settings page
*
* Shows current email pipeline status, provider info, and allows
* sending a test email through the full pipeline.
*/
import { Button, Input, Loader } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import {
CheckCircle,
Envelope,
PaperPlaneTilt,
PlugsConnected,
WarningCircle,
} from "@phosphor-icons/react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as React from "react";
import {
fetchEmailSettings,
sendTestEmail,
type EmailSettings as EmailSettingsData,
} from "../../lib/api/email-settings.js";
import { ArrowPrev } from "../ArrowIcons.js";
import { getMutationError } from "../DialogError.js";
export function EmailSettings() {
const { t } = useLingui();
const [testEmail, setTestEmail] = React.useState("");
const [status, setStatus] = React.useState<{
type: "success" | "error";
message: string;
} | null>(null);
// Clear status after 5 seconds
React.useEffect(() => {
if (!status) return;
const timer = setTimeout(setStatus, 5000, null);
return () => clearTimeout(timer);
}, [status]);
const {
data: settings,
isLoading,
error: fetchError,
} = useQuery({
queryKey: ["email-settings"],
queryFn: fetchEmailSettings,
});
const testMutation = useMutation({
mutationFn: (to: string) => sendTestEmail(to),
onSuccess: (result) => {
setStatus({ type: "success", message: result.message });
setTestEmail("");
},
onError: (error) => {
setStatus({
type: "error",
message: getMutationError(error) || t`Failed to send test email`,
});
},
});
const handleTestSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!testEmail) return;
testMutation.mutate(testEmail);
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader size="lg" />
</div>
);
}
if (fetchError) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link to="/settings">
<Button variant="ghost" shape="square" aria-label={t`Back to settings`}>
<ArrowPrev className="h-4 w-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold">{t`Email Settings`}</h1>
</div>
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950/30 dark:text-red-200">
<WarningCircle className="h-4 w-4 flex-shrink-0" />
{getMutationError(fetchError) || t`Failed to load email settings`}
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Link to="/settings">
<Button variant="ghost" shape="square" aria-label={t`Back to settings`}>
<ArrowPrev className="h-4 w-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold">{t`Email Settings`}</h1>
</div>
{/* Status banner */}
{status && (
<div
className={`flex items-center gap-2 rounded-lg border p-3 text-sm ${
status.type === "success"
? "border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-200"
: "border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950/30 dark:text-red-200"
}`}
>
{status.type === "success" ? (
<CheckCircle className="h-4 w-4 flex-shrink-0" />
) : (
<WarningCircle className="h-4 w-4 flex-shrink-0" />
)}
{status.message}
</div>
)}
{/* Pipeline status */}
<div className="rounded-lg border bg-kumo-base p-6">
<div className="flex items-center gap-2 mb-4">
<Envelope className="h-5 w-5 text-kumo-subtle" />
<h2 className="text-lg font-semibold">{t`Email Pipeline`}</h2>
</div>
<PipelineStatus settings={settings} />
</div>
{/* Test email */}
{settings?.available && (
<div className="rounded-lg border bg-kumo-base p-6">
<div className="flex items-center gap-2 mb-4">
<PaperPlaneTilt className="h-5 w-5 text-kumo-subtle" />
<h2 className="text-lg font-semibold">{t`Send Test Email`}</h2>
</div>
<p className="text-sm text-kumo-subtle mb-4">
{t`Send a test email through the full pipeline to verify your email configuration.`}
</p>
<form onSubmit={handleTestSubmit} className="flex items-end gap-3">
<div className="flex-1">
<Input
label={t`Recipient email`}
type="email"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
placeholder={t`test@example.com`}
required
/>
</div>
<Button type="submit" disabled={testMutation.isPending || !testEmail}>
{testMutation.isPending ? t`Sending...` : t`Send Test`}
</Button>
</form>
</div>
)}
</div>
);
}
// =============================================================================
// Pipeline status display
// =============================================================================
function PipelineStatus({ settings }: { settings: EmailSettingsData | undefined }) {
const { t } = useLingui();
if (!settings) return null;
if (!settings.available) {
return (
<div className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/30 p-4">
<div className="flex items-start gap-3">
<WarningCircle className="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
{t`No email provider configured`}
</p>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
{t`Install and activate an email provider plugin to enable email features like invitations, magic links, and password recovery.`}
</p>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-2">
{t`Without an email provider, invite links must be shared manually.`}
</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* Provider */}
<div className="flex items-center gap-3 p-3 rounded-md bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800">
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-green-800 dark:text-green-200">
{t`Email provider active`}
</p>
<p className="text-sm text-green-700 dark:text-green-300">
{t`Provider:`}{" "}
<code className="rounded bg-green-100 dark:bg-green-900/40 px-1.5 py-0.5 text-xs">
{settings.selectedProviderId || "default"}
</code>
</p>
</div>
</div>
{/* Middleware */}
{(settings.middleware.beforeSend.length > 0 || settings.middleware.afterSend.length > 0) && (
<div className="p-3 rounded-md bg-kumo-tint/50 border">
<div className="flex items-center gap-2 mb-2">
<PlugsConnected className="h-4 w-4 text-kumo-subtle" />
<p className="text-sm font-medium">{t`Email Middleware`}</p>
</div>
{settings.middleware.beforeSend.length > 0 && (
<p className="text-sm text-kumo-subtle">
{t`Before send:`} {settings.middleware.beforeSend.join(", ")}
</p>
)}
{settings.middleware.afterSend.length > 0 && (
<p className="text-sm text-kumo-subtle">
{t`After send:`} {settings.middleware.afterSend.join(", ")}
</p>
)}
</div>
)}
{/* Available providers (if multiple) */}
{settings.providers.length > 1 && (
<div className="p-3 rounded-md bg-kumo-tint/50 border">
<p className="text-sm font-medium mb-1">{t`Available Providers`}</p>
<p className="text-sm text-kumo-subtle">
{settings.providers.map((p) => p.pluginId).join(", ")}
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,331 @@
/**
* General Settings sub-page
*
* Site Identity (title, tagline, URL, logo, favicon) and Reading settings
* (posts per page, date format, timezone).
*/
import { Button, Input, Label } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { FloppyDisk, CheckCircle, WarningCircle, Upload, X } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as React from "react";
import { fetchSettings, updateSettings, type SiteSettings, type MediaItem } from "../../lib/api";
import { ArrowPrev } from "../ArrowIcons.js";
import { EditorHeader } from "../EditorHeader";
import { MediaPickerModal } from "../MediaPickerModal";
export function GeneralSettings() {
const { t } = useLingui();
const queryClient = useQueryClient();
const { data: settings, isLoading } = useQuery({
queryKey: ["settings"],
queryFn: fetchSettings,
staleTime: Infinity,
});
const [formData, setFormData] = React.useState<Partial<SiteSettings>>({});
const [saveStatus, setSaveStatus] = React.useState<{
type: "success" | "error";
message: string;
} | null>(null);
const [logoPickerOpen, setLogoPickerOpen] = React.useState(false);
const [faviconPickerOpen, setFaviconPickerOpen] = React.useState(false);
React.useEffect(() => {
if (settings) setFormData(settings);
}, [settings]);
React.useEffect(() => {
if (saveStatus) {
const timer = setTimeout(setSaveStatus, 3000, null);
return () => clearTimeout(timer);
}
}, [saveStatus]);
const saveMutation = useMutation({
mutationFn: (data: Partial<SiteSettings>) => updateSettings(data),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["settings"] });
setSaveStatus({ type: "success", message: t`Settings saved successfully` });
},
onError: (error) => {
setSaveStatus({
type: "error",
message: error instanceof Error ? error.message : t`Failed to save settings`,
});
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
saveMutation.mutate(formData);
};
const handleChange = (key: keyof SiteSettings, value: unknown) => {
setFormData((prev) => ({ ...prev, [key]: value }));
};
const handleLogoSelect = (media: MediaItem) => {
setFormData((prev) => ({
...prev,
logo: { mediaId: media.id, alt: media.alt || "", url: media.url },
}));
setLogoPickerOpen(false);
};
const handleFaviconSelect = (media: MediaItem) => {
setFormData((prev) => ({
...prev,
favicon: { mediaId: media.id, url: media.url },
}));
setFaviconPickerOpen(false);
};
const handleLogoRemove = () => {
setFormData((prev) => ({ ...prev, logo: undefined }));
};
const handleFaviconRemove = () => {
setFormData((prev) => ({ ...prev, favicon: undefined }));
};
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link to="/settings">
<Button variant="ghost" shape="square" aria-label={t`Back to settings`}>
<ArrowPrev className="h-4 w-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold">{t`General Settings`}</h1>
</div>
<div className="rounded-lg border bg-kumo-base p-6">
<p className="text-kumo-subtle">{t`Loading settings...`}</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Sticky header — keeps Save in view while users scroll a long
settings form. The bottom "Save Settings" button is preserved
below so the natural last-control DOM order works for keyboard
and screen-reader users. */}
<EditorHeader
leading={
<Link to="/settings">
<Button variant="ghost" shape="square" aria-label={t`Back to settings`}>
<ArrowPrev className="h-4 w-4" />
</Button>
</Link>
}
actions={
<Button
type="submit"
form="general-settings-form"
disabled={saveMutation.isPending}
icon={<FloppyDisk />}
>
{saveMutation.isPending ? t`Saving...` : t`Save Settings`}
</Button>
}
>
<h1 className="text-2xl font-bold truncate">{t`General Settings`}</h1>
</EditorHeader>
{/* Status banner */}
{saveStatus && (
<div
className={`flex items-center gap-2 rounded-lg border p-3 text-sm ${
saveStatus.type === "success"
? "border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-200"
: "border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950/30 dark:text-red-200"
}`}
>
{saveStatus.type === "success" ? (
<CheckCircle className="h-4 w-4 flex-shrink-0" />
) : (
<WarningCircle className="h-4 w-4 flex-shrink-0" />
)}
{saveStatus.message}
</div>
)}
<form id="general-settings-form" onSubmit={handleSubmit} className="space-y-6">
{/* Site Identity */}
<div className="rounded-lg border bg-kumo-base p-6">
<h2 className="mb-4 text-lg font-semibold">{t`Site Identity`}</h2>
<div className="space-y-4">
<Input
label={t`Site Title`}
value={formData.title || ""}
onChange={(e) => handleChange("title", e.target.value)}
description={t`The name of your site, used in the header and metadata`}
/>
<Input
label={t`Tagline`}
value={formData.tagline || ""}
onChange={(e) => handleChange("tagline", e.target.value)}
description={t`A short description of your site`}
/>
<Input
label={t`Site URL`}
type="url"
value={formData.url || ""}
onChange={(e) => handleChange("url", e.target.value)}
description={t`The public URL of your site (used for canonical links and sitemaps)`}
/>
{/* Logo Picker */}
<div>
<Label>{t`Logo`}</Label>
{formData.logo?.url ? (
<div className="mt-2 space-y-2">
<img
src={formData.logo.url}
alt={formData.logo.alt || t`Logo`}
className="h-16 rounded border bg-kumo-tint object-contain p-2"
/>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
icon={<Upload />}
onClick={() => setLogoPickerOpen(true)}
>
{t`Change Logo`}
</Button>
<Button
type="button"
variant="outline"
size="sm"
icon={<X />}
onClick={handleLogoRemove}
>
{t`Remove`}
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="outline"
icon={<Upload />}
onClick={() => setLogoPickerOpen(true)}
className="mt-2"
>
{t`Select Logo`}
</Button>
)}
</div>
{/* Favicon Picker */}
<div>
<Label>{t`Favicon`}</Label>
{formData.favicon?.url ? (
<div className="mt-2 space-y-2">
<img
src={formData.favicon.url}
alt={t`Favicon`}
className="h-8 w-8 rounded border bg-kumo-tint object-contain p-1"
/>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
icon={<Upload />}
onClick={() => setFaviconPickerOpen(true)}
>
{t`Change Favicon`}
</Button>
<Button
type="button"
variant="outline"
size="sm"
icon={<X />}
onClick={handleFaviconRemove}
>
{t`Remove`}
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="outline"
icon={<Upload />}
onClick={() => setFaviconPickerOpen(true)}
className="mt-2"
>
{t`Select Favicon`}
</Button>
)}
</div>
</div>
</div>
{/* Reading Settings */}
<div className="rounded-lg border bg-kumo-base p-6">
<h2 className="mb-4 text-lg font-semibold">{t`Reading`}</h2>
<div className="space-y-4">
<Input
label={t`Posts Per Page`}
type="number"
value={formData.postsPerPage || 10}
onChange={(e) => handleChange("postsPerPage", parseInt(e.target.value, 10))}
min={1}
max={100}
description={t`Number of posts to show per page on list views`}
/>
<Input
label={t`Date Format`}
value={formData.dateFormat || "MMMM d, yyyy"}
onChange={(e) => handleChange("dateFormat", e.target.value)}
description={`Example: ${formData.dateFormat || "MMMM d, yyyy"} → January 23, 2026`}
/>
<Input
label={t`Timezone`}
value={formData.timezone || "UTC"}
onChange={(e) => handleChange("timezone", e.target.value)}
description={t`Timezone for displaying dates (e.g., America/New_York)`}
/>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<Button type="submit" disabled={saveMutation.isPending} icon={<FloppyDisk />}>
{saveMutation.isPending ? t`Saving...` : t`Save Settings`}
</Button>
</div>
</form>
{/* Media Picker Modals */}
<MediaPickerModal
open={logoPickerOpen}
onOpenChange={setLogoPickerOpen}
onSelect={handleLogoSelect}
mimeTypeFilter="image/"
title={t`Select Logo`}
/>
<MediaPickerModal
open={faviconPickerOpen}
onOpenChange={setFaviconPickerOpen}
onSelect={handleFaviconSelect}
mimeTypeFilter="image/"
title={t`Select Favicon`}
/>
</div>
);
}
export default GeneralSettings;

View File

@@ -0,0 +1,217 @@
/**
* PasskeyItem - Individual passkey display with rename and delete actions
*/
import { Button, Input } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { Pencil, Trash, Check, X, DeviceMobile, Cloud } from "@phosphor-icons/react";
import * as React from "react";
import type { PasskeyInfo } from "../../lib/api";
import { ConfirmDialog } from "../ConfirmDialog.js";
export interface PasskeyItemProps {
passkey: PasskeyInfo;
canDelete: boolean;
onRename: (id: string, name: string) => Promise<void>;
onDelete: (id: string) => Promise<void>;
isDeleting?: boolean;
isRenaming?: boolean;
}
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) {
return "just now";
} else if (diffMins < 60) {
return `${diffMins} minute${diffMins === 1 ? "" : "s"} ago`;
} else if (diffHours < 24) {
return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`;
} else if (diffDays < 7) {
return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
} else {
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
}
export function PasskeyItem({
passkey,
canDelete,
onRename,
onDelete,
isDeleting,
isRenaming,
}: PasskeyItemProps) {
const { t } = useLingui();
const [isEditing, setIsEditing] = React.useState(false);
const [editName, setEditName] = React.useState(passkey.name || "");
const [showDeleteDialog, setShowDeleteDialog] = React.useState(false);
const [deleteError, setDeleteError] = React.useState<string | null>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
// Focus input when editing starts
React.useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const handleSave = async () => {
try {
await onRename(passkey.id, editName.trim());
setIsEditing(false);
} catch {
// Error handled by parent
}
};
const handleCancel = () => {
setEditName(passkey.name || "");
setIsEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
void handleSave();
} else if (e.key === "Escape") {
handleCancel();
}
};
const handleDelete = async () => {
try {
setDeleteError(null);
await onDelete(passkey.id);
setShowDeleteDialog(false);
} catch (err) {
setDeleteError(err instanceof Error ? err.message : t`Failed to remove passkey`);
}
};
const deviceTypeLabel =
passkey.deviceType === "multiDevice" ? t`Synced passkey` : t`Device-bound passkey`;
return (
<li className="flex items-center justify-between p-4 border rounded-lg bg-kumo-base">
<div className="flex items-start gap-3">
{/* Icon */}
<div className="mt-0.5 p-2 rounded-md bg-kumo-tint">
{passkey.deviceType === "multiDevice" ? (
<Cloud className="h-4 w-4 text-kumo-subtle" />
) : (
<DeviceMobile className="h-4 w-4 text-kumo-subtle" />
)}
</div>
{/* Info */}
<div>
{isEditing ? (
<div className="flex items-center gap-2">
<Input
ref={inputRef}
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={handleKeyDown}
className="h-8 w-48"
placeholder={t`Passkey name`}
disabled={isRenaming}
/>
<Button
size="sm"
variant="ghost"
onClick={handleSave}
disabled={isRenaming}
aria-label={t`Save name`}
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleCancel}
disabled={isRenaming}
aria-label={t`Cancel rename`}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div className="font-medium">{passkey.name || t`Unnamed passkey`}</div>
)}
<div className="text-sm text-kumo-subtle">
{deviceTypeLabel}
{passkey.backedUp && (
<span className="text-green-600 dark:text-green-400"> {t`(synced)`}</span>
)}
</div>
<div className="text-xs text-kumo-subtle mt-1">
{t`Last used`} {formatRelativeTime(passkey.lastUsedAt)}
</div>
</div>
</div>
{/* Actions */}
{!isEditing && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditName(passkey.name || "");
setIsEditing(true);
}}
title={t`Rename`}
aria-label={passkey.name ? t`Rename ${passkey.name}` : t`Rename passkey`}
>
<Pencil className="h-4 w-4" />
</Button>
{canDelete && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowDeleteDialog(true)}
className="text-kumo-danger hover:text-kumo-danger"
title={t`Remove`}
aria-label={passkey.name ? t`Remove ${passkey.name}` : t`Remove passkey`}
>
<Trash className="h-4 w-4" />
</Button>
)}
</div>
)}
{/* Delete confirmation dialog */}
<ConfirmDialog
open={showDeleteDialog}
onClose={() => {
setShowDeleteDialog(false);
setDeleteError(null);
}}
title={t`Remove passkey?`}
description={
passkey.name
? t`You won't be able to use "${passkey.name}" to sign in anymore. This action cannot be undone.`
: t`You won't be able to use this passkey to sign in anymore. This action cannot be undone.`
}
confirmLabel={t`Remove`}
pendingLabel={t`Removing...`}
isPending={!!isDeleting}
error={deleteError}
onConfirm={handleDelete}
/>
</li>
);
}

View File

@@ -0,0 +1,40 @@
/**
* PasskeyList - Displays a list of passkeys with actions
*/
import * as React from "react";
import type { PasskeyInfo } from "../../lib/api";
import { PasskeyItem } from "./PasskeyItem";
export interface PasskeyListProps {
passkeys: PasskeyInfo[];
onRename: (id: string, name: string) => Promise<void>;
onDelete: (id: string) => Promise<void>;
isDeleting?: boolean;
isRenaming?: boolean;
}
export function PasskeyList({
passkeys,
onRename,
onDelete,
isDeleting,
isRenaming,
}: PasskeyListProps) {
return (
<ul className="space-y-3">
{passkeys.map((passkey) => (
<PasskeyItem
key={passkey.id}
passkey={passkey}
canDelete={passkeys.length > 1}
onRename={onRename}
onDelete={onDelete}
isDeleting={isDeleting}
isRenaming={isRenaming}
/>
))}
</ul>
);
}

View File

@@ -0,0 +1,239 @@
/**
* Security Settings page - Passkey management
*
* Only available when using passkey auth. When external auth (e.g., Cloudflare Access)
* is configured, this page shows an informational message instead.
*/
import { Button } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { Shield, Plus, CheckCircle, WarningCircle, Info } from "@phosphor-icons/react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as React from "react";
import { fetchPasskeys, renamePasskey, deletePasskey, fetchManifest } from "../../lib/api";
import { ArrowPrev } from "../ArrowIcons.js";
import { PasskeyRegistration } from "../auth/PasskeyRegistration";
import { PasskeyList } from "./PasskeyList";
export function SecuritySettings() {
const { t } = useLingui();
const queryClient = useQueryClient();
const [isAdding, setIsAdding] = React.useState(false);
const [saveStatus, setSaveStatus] = React.useState<{
type: "success" | "error";
message: string;
} | null>(null);
// Fetch manifest for auth mode
const { data: manifest, isLoading: manifestLoading } = useQuery({
queryKey: ["manifest"],
queryFn: fetchManifest,
});
const isExternalAuth = manifest?.authMode && manifest.authMode !== "passkey";
// Fetch passkeys (only when using passkey auth)
const {
data: passkeys,
isLoading,
error,
} = useQuery({
queryKey: ["passkeys"],
queryFn: fetchPasskeys,
enabled: !isExternalAuth && !manifestLoading,
});
// Clear status message after 3 seconds
React.useEffect(() => {
if (saveStatus) {
const timer = setTimeout(setSaveStatus, 3000, null);
return () => clearTimeout(timer);
}
}, [saveStatus]);
// Rename mutation
const renameMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => renamePasskey(id, name),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["passkeys"] });
setSaveStatus({ type: "success", message: t`Passkey renamed` });
},
onError: (mutationError) => {
setSaveStatus({
type: "error",
message:
mutationError instanceof Error ? mutationError.message : t`Failed to rename passkey`,
});
},
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: (id: string) => deletePasskey(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["passkeys"] });
setSaveStatus({ type: "success", message: t`Passkey removed` });
},
onError: (mutationError) => {
setSaveStatus({
type: "error",
message:
mutationError instanceof Error ? mutationError.message : t`Failed to remove passkey`,
});
},
});
const handleRename = async (id: string, name: string) => {
await renameMutation.mutateAsync({ id, name });
};
const handleDelete = async (id: string) => {
await deleteMutation.mutateAsync(id);
};
const handleAddSuccess = () => {
void queryClient.invalidateQueries({ queryKey: ["passkeys"] });
setIsAdding(false);
setSaveStatus({ type: "success", message: t`Passkey added successfully` });
};
const settingsHeader = (
<div className="flex items-center gap-3">
<Link to="/settings">
<Button variant="ghost" shape="square" aria-label={t`Back to settings`}>
<ArrowPrev className="h-4 w-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold">{t`Security Settings`}</h1>
</div>
);
if (manifestLoading || isLoading) {
return (
<div className="space-y-6">
{settingsHeader}
<div className="rounded-lg border bg-kumo-base p-6">
<p className="text-kumo-subtle">{t`Loading...`}</p>
</div>
</div>
);
}
// Show message when external auth is configured
if (isExternalAuth) {
return (
<div className="space-y-6">
{settingsHeader}
<div className="rounded-lg border bg-kumo-base p-6">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-kumo-subtle mt-0.5 flex-shrink-0" />
<div className="space-y-2">
<p className="text-kumo-subtle">
{t`Authentication is managed by an external provider (${manifest?.authMode}). Passkey settings are not available when using external authentication.`}
</p>
</div>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-6">
{settingsHeader}
<div className="rounded-lg border bg-kumo-base p-6">
<p className="text-kumo-danger">
{error instanceof Error ? error.message : t`Failed to load passkeys`}
</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{settingsHeader}
{/* Status message */}
{saveStatus && (
<div
className={`flex items-center gap-2 rounded-lg border p-3 text-sm ${
saveStatus.type === "success"
? "border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-200"
: "border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950/30 dark:text-red-200"
}`}
>
{saveStatus.type === "success" ? (
<CheckCircle className="h-4 w-4 flex-shrink-0" />
) : (
<WarningCircle className="h-4 w-4 flex-shrink-0" />
)}
{saveStatus.message}
</div>
)}
{/* Passkeys Section */}
<div className="rounded-lg border bg-kumo-base p-6">
<div className="flex items-center gap-2 mb-4">
<Shield className="h-5 w-5 text-kumo-subtle" />
<h2 className="text-lg font-semibold">{t`Passkeys`}</h2>
</div>
<p className="text-sm text-kumo-subtle mb-6">
{t`Passkeys are a secure, passwordless way to sign in to your account. You can register multiple passkeys for different devices.`}
</p>
{/* Passkey list */}
{passkeys && passkeys.length > 0 ? (
<PasskeyList
passkeys={passkeys}
onRename={handleRename}
onDelete={handleDelete}
isDeleting={deleteMutation.isPending}
isRenaming={renameMutation.isPending}
/>
) : (
<div className="rounded-lg border border-dashed p-6 text-center text-kumo-subtle">
{t`No passkeys registered yet.`}
</div>
)}
{/* Add passkey section */}
<div className="mt-6 pt-6 border-t">
{isAdding ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">{t`Add a new passkey`}</h3>
<Button variant="ghost" size="sm" onClick={() => setIsAdding(false)}>
{t`Cancel`}
</Button>
</div>
<PasskeyRegistration
optionsEndpoint="/_emdash/api/auth/passkey/register/options"
verifyEndpoint="/_emdash/api/auth/passkey/register/verify"
onSuccess={handleAddSuccess}
onError={(registrationError) =>
setSaveStatus({
type: "error",
message: registrationError.message,
})
}
showNameInput
buttonText={t`Register Passkey`}
/>
</div>
) : (
<Button onClick={() => setIsAdding(true)} icon={<Plus />}>
{t`Add Passkey`}
</Button>
)}
</div>
</div>
</div>
);
}
export default SecuritySettings;

View File

@@ -0,0 +1,181 @@
/**
* SEO Settings sub-page
*
* Title separator, search engine verification codes, and robots.txt.
*/
import { Button, Input, InputArea } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { FloppyDisk, CheckCircle, WarningCircle, MagnifyingGlass } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as React from "react";
import { fetchSettings, updateSettings, type SiteSettings } from "../../lib/api";
import { ArrowPrev } from "../ArrowIcons.js";
import { EditorHeader } from "../EditorHeader";
export function SeoSettings() {
const { t } = useLingui();
const queryClient = useQueryClient();
const { data: settings, isLoading } = useQuery({
queryKey: ["settings"],
queryFn: fetchSettings,
staleTime: Infinity,
});
const [formData, setFormData] = React.useState<Partial<SiteSettings>>({});
const [saveStatus, setSaveStatus] = React.useState<{
type: "success" | "error";
message: string;
} | null>(null);
React.useEffect(() => {
if (settings) setFormData(settings);
}, [settings]);
React.useEffect(() => {
if (saveStatus) {
const timer = setTimeout(setSaveStatus, 3000, null);
return () => clearTimeout(timer);
}
}, [saveStatus]);
const saveMutation = useMutation({
mutationFn: (data: Partial<SiteSettings>) => updateSettings(data),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["settings"] });
setSaveStatus({ type: "success", message: t`SEO settings saved` });
},
onError: (error) => {
setSaveStatus({
type: "error",
message: error instanceof Error ? error.message : t`Failed to save settings`,
});
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
saveMutation.mutate(formData);
};
const handleSeoChange = (key: string, value: unknown) => {
setFormData((prev) => ({
...prev,
seo: {
...prev.seo,
[key]: value,
},
}));
};
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link to="/settings">
<Button variant="ghost" shape="square" aria-label={t`Back to settings`}>
<ArrowPrev className="h-4 w-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold">{t`SEO Settings`}</h1>
</div>
<div className="rounded-lg border bg-kumo-base p-6">
<p className="text-kumo-subtle">{t`Loading settings...`}</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Sticky header — see GeneralSettings for the same pattern. */}
<EditorHeader
leading={
<Link to="/settings">
<Button variant="ghost" shape="square" aria-label={t`Back to settings`}>
<ArrowPrev className="h-4 w-4" />
</Button>
</Link>
}
actions={
<Button
type="submit"
form="seo-settings-form"
disabled={saveMutation.isPending}
icon={<FloppyDisk />}
>
{saveMutation.isPending ? t`Saving...` : t`Save SEO Settings`}
</Button>
}
>
<h1 className="text-2xl font-bold truncate">{t`SEO Settings`}</h1>
</EditorHeader>
{/* Status banner */}
{saveStatus && (
<div
className={`flex items-center gap-2 rounded-lg border p-3 text-sm ${
saveStatus.type === "success"
? "border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-200"
: "border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950/30 dark:text-red-200"
}`}
>
{saveStatus.type === "success" ? (
<CheckCircle className="h-4 w-4 flex-shrink-0" />
) : (
<WarningCircle className="h-4 w-4 flex-shrink-0" />
)}
{saveStatus.message}
</div>
)}
<form id="seo-settings-form" onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-lg border bg-kumo-base p-6">
<div className="flex items-center gap-2 mb-4">
<MagnifyingGlass className="h-5 w-5 text-kumo-subtle" />
<h2 className="text-lg font-semibold">{t`Search Engine Optimization`}</h2>
</div>
<div className="space-y-4">
<Input
label={t`Title Separator`}
value={formData.seo?.titleSeparator || "|"}
onChange={(e) => handleSeoChange("titleSeparator", e.target.value)}
description={t`Character between page title and site name (e.g., "My Post | My Site")`}
/>
<Input
label={t`Google Verification`}
value={formData.seo?.googleVerification || ""}
onChange={(e) => handleSeoChange("googleVerification", e.target.value)}
description={t`Meta tag content for Google Search Console verification`}
/>
<Input
label={t`Bing Verification`}
value={formData.seo?.bingVerification || ""}
onChange={(e) => handleSeoChange("bingVerification", e.target.value)}
description={t`Meta tag content for Bing Webmaster Tools verification`}
/>
<InputArea
label="robots.txt"
value={formData.seo?.robotsTxt || ""}
onChange={(e) => handleSeoChange("robotsTxt", e.target.value)}
rows={5}
description={t`Custom robots.txt content. Leave empty to use the default.`}
/>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<Button type="submit" disabled={saveMutation.isPending} icon={<FloppyDisk />}>
{saveMutation.isPending ? t`Saving...` : t`Save SEO Settings`}
</Button>
</div>
</form>
</div>
);
}
export default SeoSettings;

View File

@@ -0,0 +1,192 @@
/**
* Social Settings sub-page
*
* Social media profile links (Twitter, GitHub, Facebook, Instagram, LinkedIn, YouTube).
*/
import { Button, Input } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { FloppyDisk, CheckCircle, WarningCircle } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as React from "react";
import { fetchSettings, updateSettings, type SiteSettings } from "../../lib/api";
import { ArrowPrev } from "../ArrowIcons.js";
import { EditorHeader } from "../EditorHeader";
export function SocialSettings() {
const { t } = useLingui();
const queryClient = useQueryClient();
const { data: settings, isLoading } = useQuery({
queryKey: ["settings"],
queryFn: fetchSettings,
staleTime: Infinity,
});
const [formData, setFormData] = React.useState<Partial<SiteSettings>>({});
const [saveStatus, setSaveStatus] = React.useState<{
type: "success" | "error";
message: string;
} | null>(null);
React.useEffect(() => {
if (settings) setFormData(settings);
}, [settings]);
React.useEffect(() => {
if (saveStatus) {
const timer = setTimeout(setSaveStatus, 3000, null);
return () => clearTimeout(timer);
}
}, [saveStatus]);
const saveMutation = useMutation({
mutationFn: (data: Partial<SiteSettings>) => updateSettings(data),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["settings"] });
setSaveStatus({ type: "success", message: t`Social links saved` });
},
onError: (error) => {
setSaveStatus({
type: "error",
message: error instanceof Error ? error.message : t`Failed to save settings`,
});
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
saveMutation.mutate(formData);
};
const handleSocialChange = (key: string, value: string) => {
setFormData((prev) => ({
...prev,
social: {
...prev.social,
[key]: value,
},
}));
};
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link to="/settings">
<Button variant="ghost" shape="square" aria-label={t`Back to settings`}>
<ArrowPrev className="h-4 w-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold">{t`Social Links`}</h1>
</div>
<div className="rounded-lg border bg-kumo-base p-6">
<p className="text-kumo-subtle">{t`Loading settings...`}</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Sticky header — see GeneralSettings for the same pattern. */}
<EditorHeader
leading={
<Link to="/settings">
<Button variant="ghost" shape="square" aria-label={t`Back to settings`}>
<ArrowPrev className="h-4 w-4" />
</Button>
</Link>
}
actions={
<Button
type="submit"
form="social-settings-form"
disabled={saveMutation.isPending}
icon={<FloppyDisk />}
>
{saveMutation.isPending ? t`Saving...` : t`Save Social Links`}
</Button>
}
>
<h1 className="text-2xl font-bold truncate">{t`Social Links`}</h1>
</EditorHeader>
{/* Status banner */}
{saveStatus && (
<div
className={`flex items-center gap-2 rounded-lg border p-3 text-sm ${
saveStatus.type === "success"
? "border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-200"
: "border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950/30 dark:text-red-200"
}`}
>
{saveStatus.type === "success" ? (
<CheckCircle className="h-4 w-4 flex-shrink-0" />
) : (
<WarningCircle className="h-4 w-4 flex-shrink-0" />
)}
{saveStatus.message}
</div>
)}
<form id="social-settings-form" onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-lg border bg-kumo-base p-6">
<h2 className="mb-4 text-lg font-semibold">{t`Social Profiles`}</h2>
<p className="text-sm text-kumo-subtle mb-6">
{t`Add your social media profiles. These are available to your site's theme and can be displayed in headers, footers, or author bios.`}
</p>
<div className="space-y-4">
<Input
label={t`Twitter`}
value={formData.social?.twitter || ""}
onChange={(e) => handleSocialChange("twitter", e.target.value)}
description={t`Your Twitter/X handle (e.g., @username)`}
/>
<Input
label={t`GitHub`}
value={formData.social?.github || ""}
onChange={(e) => handleSocialChange("github", e.target.value)}
description={t`Your GitHub username`}
/>
<Input
label={t`Facebook`}
value={formData.social?.facebook || ""}
onChange={(e) => handleSocialChange("facebook", e.target.value)}
description={t`Your Facebook page or profile username`}
/>
<Input
label={t`Instagram`}
value={formData.social?.instagram || ""}
onChange={(e) => handleSocialChange("instagram", e.target.value)}
description={t`Your Instagram username`}
/>
<Input
label={t`LinkedIn`}
value={formData.social?.linkedin || ""}
onChange={(e) => handleSocialChange("linkedin", e.target.value)}
description={t`Your LinkedIn profile username`}
/>
<Input
label={t`YouTube`}
value={formData.social?.youtube || ""}
onChange={(e) => handleSocialChange("youtube", e.target.value)}
description={t`Your YouTube channel ID or handle`}
/>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<Button type="submit" disabled={saveMutation.isPending} icon={<FloppyDisk />}>
{saveMutation.isPending ? t`Saving...` : t`Save Social Links`}
</Button>
</div>
</form>
</div>
);
}
export default SocialSettings;

View File

@@ -0,0 +1,34 @@
import * as React from "react";
import type { RolesSelectRow } from "../users/useRolesConfig.js";
import { useRolesConfig } from "../users/useRolesConfig.js";
/** Self-signup default role must not be Admin (API / product rule). */
const MAX_SELF_SIGNUP_DEFAULT_ROLE = 40;
/**
* Role labels and selects for Allowed Domains (SubscriberEditor only for defaults).
* Built on {@link useRolesConfig}; keeps the filter + `Select` `items` shape in one place.
*/
export function useAllowedDomainsRolesConfig(): {
getRoleLabel: (level: number) => string;
signupRoles: readonly RolesSelectRow[];
signupRoleItems: Record<string, string>;
} {
const { roleLabels, getRoleLabel, roles } = useRolesConfig();
const signupRoles = React.useMemo(
() => roles.filter((r) => r.value <= MAX_SELF_SIGNUP_DEFAULT_ROLE),
[roles],
);
const signupRoleItems = React.useMemo(() => {
const entries: [string, string][] = signupRoles.map((r) => {
const label = roleLabels[String(r.value)];
return [String(r.value), label ?? getRoleLabel(r.value)];
});
return Object.fromEntries(entries) as Record<string, string>;
}, [signupRoles, roleLabels, getRoleLabel]);
return { getRoleLabel, signupRoles, signupRoleItems };
}

View File

@@ -0,0 +1,211 @@
import { Button, Dialog, Input, Select } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { Check, Copy, X } from "@phosphor-icons/react";
import * as React from "react";
import { useRolesConfig } from "./useRolesConfig.js";
export interface InviteUserModalProps {
open: boolean;
isSending?: boolean;
error?: string | null;
/** When set, shows a copy-link view instead of the form (no email provider) */
inviteUrl?: string | null;
onOpenChange: (open: boolean) => void;
onInvite: (email: string, role: number) => void;
}
/**
* Invite user modal — sends invite email or shows copy-link fallback
*/
export function InviteUserModal({
open,
isSending,
error,
inviteUrl,
onOpenChange,
onInvite,
}: InviteUserModalProps) {
const { t } = useLingui();
const { roles, roleLabels } = useRolesConfig();
const [email, setEmail] = React.useState("");
const [role, setRole] = React.useState(30); // Default to Author
const [copied, setCopied] = React.useState(false);
const [copyError, setCopyError] = React.useState(false);
const copyTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Reset form when modal opens
React.useEffect(() => {
if (open) {
setEmail("");
setRole(30);
setCopied(false);
setCopyError(false);
}
}, [open]);
// Clean up timeout on unmount
React.useEffect(() => {
return () => {
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
};
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onInvite(email, role);
};
const handleCopyUrl = async () => {
if (!inviteUrl) return;
try {
await navigator.clipboard.writeText(inviteUrl);
setCopied(true);
setCopyError(false);
copyTimeoutRef.current = setTimeout(setCopied, 2000, false);
} catch {
// Clipboard API can fail in insecure contexts
setCopyError(true);
}
};
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog className="p-6 max-w-md" size="lg">
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex flex-col space-y-1.5">
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
{inviteUrl ? t`Invite Link Created` : t`Invite User`}
</Dialog.Title>
<Dialog.Description className="text-sm text-kumo-subtle">
{inviteUrl
? t`No email provider configured. Share this link manually.`
: t`Send an invitation email to a new team member.`}
</Dialog.Description>
</div>
<Dialog.Close
aria-label={t`Close`}
render={(props) => (
<Button
{...props}
variant="ghost"
shape="square"
aria-label={t`Close`}
className="absolute end-4 top-4"
>
<X className="h-4 w-4" />
<span className="sr-only">{t`Close`}</span>
</Button>
)}
/>
</div>
{inviteUrl ? (
/* Copy-link view — shown when no email provider is configured */
<div className="py-4 space-y-4">
<div className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/30 p-4">
<p className="text-sm text-amber-800 dark:text-amber-200 font-medium">
{t`Share this link with the invited user`}
</p>
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
{t`This link expires in 7 days and can only be used once.`}
</p>
</div>
<div className="flex items-center gap-2">
<code className="flex-1 rounded bg-kumo-tint px-3 py-2 text-sm font-mono border truncate">
{inviteUrl}
</code>
<Button
variant="ghost"
shape="square"
onClick={handleCopyUrl}
aria-label={t`Copy invite link`}
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
{copied && (
<p className="text-xs text-green-600 dark:text-green-400">{t`Copied to clipboard`}</p>
)}
{copyError && (
<p className="text-xs text-amber-600 dark:text-amber-400">
{t`Could not copy automatically. Please select the URL above and copy manually.`}
</p>
)}
<div className="flex justify-end">
<Button type="button" onClick={() => onOpenChange(false)}>
{t`Done`}
</Button>
</div>
</div>
) : (
/* Standard invite form */
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
{/* Email */}
<Input
label={t`Email address`}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t`colleague@example.com`}
required
autoComplete="off"
/>
{/* Role */}
<div className="grid gap-2">
<Select
label={t`Role`}
value={role.toString()}
onValueChange={(v) => v !== null && setRole(parseInt(v, 10))}
items={roleLabels}
>
{roles.map((r) => (
<Select.Option key={r.value} value={r.value.toString()}>
<div>
<div>{r.label}</div>
<div className="text-xs text-kumo-subtle">{r.description}</div>
</div>
</Select.Option>
))}
</Select>
<p className="text-xs text-kumo-subtle">
{t`The invited user will have this role once they complete registration.`}
</p>
</div>
{/* Error message */}
{error && (
<div className="rounded-md bg-kumo-danger/10 p-3 text-sm text-kumo-danger">
{error}
</div>
)}
</div>
<div className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSending}
>
{t`Cancel`}
</Button>
<Button type="submit" disabled={isSending || !email}>
{isSending ? t`Sending...` : t`Send Invite`}
</Button>
</div>
</form>
)}
</Dialog>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,54 @@
import { useLingui } from "@lingui/react/macro";
import { cn } from "../../lib/utils";
import { getRoleConfig } from "./roleDefinitions.js";
export type { RoleLevelConfig } from "./roleDefinitions.js";
export interface RoleBadgeProps {
role: number;
size?: "sm" | "md";
showDescription?: boolean;
className?: string;
}
/**
* Role badge component with semantic colors
*/
export function RoleBadge({
role,
size = "sm",
showDescription = false,
className,
}: RoleBadgeProps) {
const { t } = useLingui();
const config = getRoleConfig(role);
const colorClasses: Record<string, string> = {
gray: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200",
blue: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
green: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
purple: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
red: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
};
const sizeClasses = {
sm: "px-2 py-0.5 text-xs",
md: "px-2.5 py-1 text-sm",
};
return (
<span
className={cn(
"inline-flex items-center rounded-full font-medium",
sizeClasses[size],
colorClasses[config.color],
className,
)}
title={showDescription ? undefined : t(config.description)}
>
{t(config.label)}
{showDescription && <span className="ms-1 opacity-75">- {t(config.description)}</span>}
</span>
);
}

View File

@@ -0,0 +1,379 @@
import { Button, Input, Select } from "@cloudflare/kumo";
import { Dialog } from "@cloudflare/kumo/primitives";
import { useLingui } from "@lingui/react/macro";
import {
X,
Key,
Prohibit,
CheckCircle,
ArrowSquareOut,
FloppyDisk,
Envelope,
} from "@phosphor-icons/react";
import * as React from "react";
import type { UserDetail as UserDetailType, UpdateUserInput } from "../../lib/api";
import { useStableCallback } from "../../lib/hooks";
import { cn } from "../../lib/utils";
import { useRolesConfig } from "./useRolesConfig.js";
export interface UserDetailProps {
user: UserDetailType | null;
isLoading?: boolean;
isOpen: boolean;
isSaving?: boolean;
isSendingRecovery?: boolean;
recoverySent?: boolean;
recoveryError?: string | null;
currentUserId?: string;
onClose: () => void;
onSave: (data: UpdateUserInput) => void;
onDisable: () => void;
onEnable: () => void;
onSendRecovery?: () => void;
}
/**
* User detail slide-over panel with inline editing
*/
export function UserDetail({
user,
isLoading,
isOpen,
isSaving,
isSendingRecovery,
recoverySent,
recoveryError,
currentUserId,
onClose,
onSave,
onDisable,
onEnable,
onSendRecovery,
}: UserDetailProps) {
const { t } = useLingui();
const { roles, roleLabels, getRoleLabel } = useRolesConfig();
const [name, setName] = React.useState(user?.name ?? "");
const [email, setEmail] = React.useState(user?.email ?? "");
const [role, setRole] = React.useState(user?.role ?? 30);
// Reset form when viewing a different user
const userIdRef = React.useRef(user?.id);
if (user?.id !== userIdRef.current) {
userIdRef.current = user?.id;
if (user) {
setName(user.name ?? "");
setEmail(user.email ?? "");
setRole(user.role);
}
}
const stableOnClose = useStableCallback(onClose);
const isSelf = user && currentUserId && user.id === currentUserId;
const isDirty =
user && (name !== (user.name ?? "") || email !== user.email || role !== user.role);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
const data: UpdateUserInput = {};
if (name !== (user.name ?? "")) {
data.name = name || undefined;
}
if (email !== user.email) {
data.email = email;
}
if (role !== user.role && !isSelf) {
data.role = role;
}
onSave(data);
};
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && stableOnClose()}>
<Dialog.Portal>
<Dialog.Backdrop
className={cn(
"fixed inset-0 bg-black/50 transition-opacity duration-200",
"data-starting-style:opacity-0 data-ending-style:opacity-0",
)}
/>
<Dialog.Popup
className={cn(
"fixed top-0 end-0 flex h-full w-full max-w-md flex-col bg-kumo-base shadow-xl outline-none",
"transform transition-transform duration-200 ease-out",
"data-starting-style:ltr:translate-x-full data-starting-style:rtl:-translate-x-full",
"data-ending-style:ltr:translate-x-full data-ending-style:rtl:-translate-x-full",
)}
>
{/* Header */}
<div className="flex items-center justify-between border-b px-6 py-4">
<Dialog.Title className="text-lg font-semibold">{t`User Details`}</Dialog.Title>
<Button
variant="ghost"
shape="square"
onClick={stableOnClose}
aria-label={t`Close panel`}
>
<X className="h-5 w-5" aria-hidden="true" />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<UserDetailSkeleton />
) : user ? (
<form id="user-edit-form" onSubmit={handleSubmit} className="space-y-6">
{/* Avatar + editable fields */}
<div className="flex items-start gap-4">
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt=""
className="h-16 w-16 shrink-0 rounded-full object-cover"
/>
) : (
<div className="h-16 w-16 shrink-0 rounded-full bg-kumo-tint flex items-center justify-center text-2xl font-medium">
{(name || email)?.[0]?.toUpperCase() ?? "?"}
</div>
)}
<div className="flex-1 min-w-0 space-y-3">
<Input
label={t`Name`}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t`Enter name`}
/>
<Input
label={t`Email`}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t`Enter email`}
required
/>
</div>
</div>
{/* Role + status */}
<div className="flex items-end gap-3">
{isSelf ? (
<div className="flex-1">
<Input
label={t`Role`}
value={getRoleLabel(role)}
disabled
className="cursor-not-allowed"
/>
<p className="text-xs text-kumo-subtle mt-1">
{t`You cannot change your own role`}
</p>
</div>
) : (
<div className="flex-1">
<Select
label={t`Role`}
value={role.toString()}
onValueChange={(v) => v !== null && setRole(parseInt(v, 10))}
items={roleLabels}
>
{roles.map((r) => (
<Select.Option key={r.value} value={r.value.toString()}>
<div>
<div>{r.label}</div>
<div className="text-xs text-kumo-subtle">{r.description}</div>
</div>
</Select.Option>
))}
</Select>
</div>
)}
<div className="pb-1">
{user.disabled ? (
<span className="inline-flex items-center gap-1 text-sm text-kumo-danger">
<Prohibit className="h-3.5 w-3.5" aria-hidden="true" />
{t`Disabled`}
</span>
) : (
<span className="inline-flex items-center gap-1 text-sm text-green-600 dark:text-green-400">
<CheckCircle className="h-3.5 w-3.5" aria-hidden="true" />
{t`Active`}
</span>
)}
</div>
</div>
{/* Info cards */}
<div className="grid gap-4">
{/* Timestamps */}
<div className="rounded-lg border p-4">
<h4 className="text-sm font-medium text-kumo-subtle mb-3">{t`Account Info`}</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-kumo-subtle">{t`Created`}</span>
<span>{new Date(user.createdAt).toLocaleDateString()}</span>
</div>
<div className="flex justify-between">
<span className="text-kumo-subtle">{t`Last updated`}</span>
<span>{new Date(user.updatedAt).toLocaleDateString()}</span>
</div>
<div className="flex justify-between">
<span className="text-kumo-subtle">{t`Last login`}</span>
<span>
{user.lastLogin
? new Date(user.lastLogin).toLocaleDateString()
: t`Never`}
</span>
</div>
<div className="flex justify-between">
<span className="text-kumo-subtle">{t`Email verified`}</span>
<span>{user.emailVerified ? t`Yes` : t`No`}</span>
</div>
</div>
</div>
{/* Passkeys */}
<div className="rounded-lg border p-4">
<h4 className="text-sm font-medium text-kumo-subtle mb-3 flex items-center gap-2">
<Key className="h-4 w-4" aria-hidden="true" />
{t`Passkeys (${user.credentials.length})`}
</h4>
{user.credentials.length === 0 ? (
<p className="text-sm text-kumo-subtle">{t`No passkeys registered`}</p>
) : (
<div className="space-y-2">
{user.credentials.map((cred) => (
<div key={cred.id} className="flex justify-between text-sm">
<div>
<div>{cred.name || t`Unnamed passkey`}</div>
<div className="text-xs text-kumo-subtle">
{cred.deviceType === "multiDevice" ? t`Synced` : t`Device-bound`}
</div>
</div>
<div className="text-end text-kumo-subtle">
<div>{t`Created ${new Date(cred.createdAt).toLocaleDateString()}`}</div>
<div className="text-xs">
{t`Last used ${new Date(cred.lastUsedAt).toLocaleDateString()}`}
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* OAuth accounts */}
{user.oauthAccounts.length > 0 && (
<div className="rounded-lg border p-4">
<h4 className="text-sm font-medium text-kumo-subtle mb-3 flex items-center gap-2">
<ArrowSquareOut className="h-4 w-4" aria-hidden="true" />
{t`Linked Accounts (${user.oauthAccounts.length})`}
</h4>
<div className="space-y-2">
{user.oauthAccounts.map((account, i) => (
<div
key={`${account.provider}-${i}`}
className="flex justify-between text-sm"
>
<span className="capitalize">{account.provider}</span>
<span className="text-kumo-subtle">
{t`Connected ${new Date(account.createdAt).toLocaleDateString()}`}
</span>
</div>
))}
</div>
</div>
)}
</div>
</form>
) : (
<div className="text-center text-kumo-subtle py-8">{t`User not found`}</div>
)}
</div>
{/* Footer actions */}
{user && (
<div className="border-t px-6 py-4 space-y-2">
<div className="flex gap-2">
<Button
type="submit"
form="user-edit-form"
className="flex-1"
disabled={!isDirty || isSaving}
icon={<FloppyDisk />}
>
{isSaving ? t`Saving...` : t`Save Changes`}
</Button>
{!isSelf && (
<Button
variant={user.disabled ? "outline" : "destructive"}
onClick={user.disabled ? onEnable : onDisable}
icon={user.disabled ? <CheckCircle /> : <Prohibit />}
>
{user.disabled ? t`Enable` : t`Disable`}
</Button>
)}
</div>
{!isSelf && onSendRecovery && (
<div className="space-y-1">
<Button
variant="outline"
className="w-full"
onClick={onSendRecovery}
disabled={isSendingRecovery}
icon={<Envelope />}
>
{isSendingRecovery ? t`Sending...` : t`Send Recovery Link`}
</Button>
{recoverySent && (
<p className="text-xs text-green-600 dark:text-green-400 text-center">
{t`Recovery link sent to ${user.email}`}
</p>
)}
{recoveryError && (
<p className="text-xs text-kumo-danger text-center">{recoveryError}</p>
)}
</div>
)}
</div>
)}
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
}
/** Loading skeleton for user detail */
function UserDetailSkeleton() {
return (
<div className="space-y-6 animate-pulse">
{/* Profile skeleton */}
<div className="flex items-start gap-4">
<div className="h-16 w-16 rounded-full bg-kumo-tint" />
<div className="flex-1 space-y-2">
<div className="h-6 w-48 bg-kumo-tint rounded" />
<div className="h-4 w-36 bg-kumo-tint rounded" />
<div className="h-5 w-24 bg-kumo-tint rounded" />
</div>
</div>
{/* Cards skeleton */}
{Array.from({ length: 2 }, (_, i) => (
<div key={i} className="rounded-lg border p-4 space-y-3">
<div className="h-4 w-24 bg-kumo-tint rounded" />
<div className="space-y-2">
<div className="h-4 w-full bg-kumo-tint rounded" />
<div className="h-4 w-full bg-kumo-tint rounded" />
<div className="h-4 w-3/4 bg-kumo-tint rounded" />
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,254 @@
import { Button, Input, Loader, Select } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { MagnifyingGlass, UserPlus, Prohibit, CheckCircle } from "@phosphor-icons/react";
import * as React from "react";
import type { UserListItem } from "../../lib/api";
import { cn } from "../../lib/utils";
import { RoleBadge } from "./RoleBadge";
import { useRolesConfig } from "./useRolesConfig.js";
export interface UserListProps {
users: UserListItem[];
isLoading?: boolean;
hasMore?: boolean;
searchQuery: string;
roleFilter: number | undefined;
onSearchChange: (query: string) => void;
onRoleFilterChange: (role: number | undefined) => void;
onSelectUser: (id: string) => void;
onInviteUser: () => void;
onLoadMore?: () => void;
}
/**
* User list component with search, filter, and table display
*/
export function UserList({
users,
isLoading,
hasMore,
searchQuery,
roleFilter,
onSearchChange,
onRoleFilterChange,
onSelectUser,
onInviteUser,
onLoadMore,
}: UserListProps) {
const { t } = useLingui();
const { roles, roleLabels } = useRolesConfig();
const roleFilterSelectItems = React.useMemo(
() => ({ all: t`All roles`, ...roleLabels }),
[t, roleLabels],
);
const roleFilterSelectOptions = React.useMemo(
() => [{ value: "all", label: t`All roles` }, ...roles],
[t, roles],
);
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Users</h1>
<Button onClick={onInviteUser} icon={<UserPlus />}>
Invite User
</Button>
</div>
{/* Filters */}
<div className="flex gap-4">
<div className="relative flex-1 max-w-sm">
<MagnifyingGlass
className="absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-kumo-subtle"
aria-hidden="true"
/>
<Input
type="search"
placeholder="Search by name or email..."
className="ps-10"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
aria-label="Search users"
/>
</div>
<Select
value={roleFilter?.toString() ?? "all"}
onValueChange={(value) =>
onRoleFilterChange(value === "all" || value === null ? undefined : parseInt(value, 10))
}
items={roleFilterSelectItems}
aria-label="Filter by role"
>
{roleFilterSelectOptions.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
</div>
{/* Table */}
<div className="rounded-md border overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-kumo-tint/50">
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
User
</th>
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
Role
</th>
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
Status
</th>
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
Last Login
</th>
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
Passkeys
</th>
</tr>
</thead>
<tbody>
{users.length === 0 && !isLoading ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-kumo-subtle">
{searchQuery || roleFilter !== undefined ? (
<>
No users found matching your filters.{" "}
<button
className="text-kumo-brand underline"
onClick={() => {
onSearchChange("");
onRoleFilterChange(undefined);
}}
>
Clear filters
</button>
</>
) : (
<>
No users yet.{" "}
<button className="text-kumo-brand underline" onClick={onInviteUser}>
Invite your first team member
</button>
</>
)}
</td>
</tr>
) : (
users.map((user) => (
<UserListRow key={user.id} user={user} onSelect={() => onSelectUser(user.id)} />
))
)}
{isLoading && (
<tr>
<td colSpan={5} className="px-4 py-4">
<div className="flex items-center justify-center gap-2 text-kumo-subtle">
<Loader size="sm" />
Loading...
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Load more */}
{hasMore && !isLoading && (
<div className="flex justify-center">
<Button variant="outline" onClick={onLoadMore}>
Load More
</Button>
</div>
)}
</div>
);
}
interface UserListRowProps {
user: UserListItem;
onSelect: () => void;
}
function UserListRow({ user, onSelect }: UserListRowProps) {
const displayName = user.name || user.email;
const lastLogin = user.lastLogin ? new Date(user.lastLogin).toLocaleDateString() : "Never";
return (
<tr className="border-b hover:bg-kumo-tint/25 cursor-pointer" onClick={onSelect}>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{/* Avatar */}
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="h-8 w-8 rounded-full object-cover" />
) : (
<div className="h-8 w-8 rounded-full bg-kumo-tint flex items-center justify-center text-sm font-medium">
{(user.name || user.email)?.[0]?.toUpperCase() ?? "?"}
</div>
)}
<div>
<div className="font-medium">{displayName}</div>
{user.name && <div className="text-sm text-kumo-subtle">{user.email}</div>}
</div>
</div>
</td>
<td className="px-4 py-3">
<RoleBadge role={user.role} />
</td>
<td className="px-4 py-3">
{user.disabled ? (
<span className="inline-flex items-center gap-1 text-sm text-kumo-danger">
<Prohibit className="h-3.5 w-3.5" aria-hidden="true" />
Disabled
</span>
) : (
<span className="inline-flex items-center gap-1 text-sm text-green-600 dark:text-green-400">
<CheckCircle className="h-3.5 w-3.5" aria-hidden="true" />
Active
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-kumo-subtle">{lastLogin}</td>
<td className="px-4 py-3">
<span className={cn("text-sm", user.credentialCount === 0 && "text-kumo-subtle")}>
{user.credentialCount}
</span>
</td>
</tr>
);
}
/** Loading skeleton for user list */
export function UserListSkeleton() {
return (
<div className="space-y-4">
{/* Header skeleton */}
<div className="flex items-center justify-between">
<div className="h-8 w-24 bg-kumo-tint animate-pulse rounded" />
<div className="h-10 w-32 bg-kumo-tint animate-pulse rounded" />
</div>
{/* Filters skeleton */}
<div className="flex gap-4">
<div className="h-10 w-64 bg-kumo-tint animate-pulse rounded" />
<div className="h-10 w-44 bg-kumo-tint animate-pulse rounded" />
</div>
{/* Table skeleton */}
<div className="rounded-md border">
<div className="border-b bg-kumo-tint/50 px-4 py-3">
<div className="h-4 w-full bg-kumo-tint animate-pulse rounded" />
</div>
{Array.from({ length: 5 }, (_, i) => (
<div key={i} className="border-b px-4 py-4">
<div className="h-8 w-full bg-kumo-tint animate-pulse rounded" />
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
export { RoleBadge } from "./RoleBadge";
export { getRoleConfig } from "./roleDefinitions.js";
export { useRolesConfig } from "./useRolesConfig.js";
export { UserList, UserListSkeleton } from "./UserList";
export { UserDetail } from "./UserDetail";
export { InviteUserModal } from "./InviteUserModal";

View File

@@ -0,0 +1,70 @@
import type { MessageDescriptor } from "@lingui/core";
import { msg } from "@lingui/core/macro";
export type RoleLevelConfig = {
label: MessageDescriptor;
description: MessageDescriptor;
color: string;
};
/**
* Canonical role levels for admin UI (badge colors, selects, labels).
* Allowed Domains UI only offers default roles up to Editor (40), not Admin (50).
*/
export const ROLE_ENTRIES = [
{
value: 10,
color: "gray",
label: msg`Subscriber`,
description: msg`Can view content`,
},
{
value: 20,
color: "blue",
label: msg`Contributor`,
description: msg`Can create content`,
},
{
value: 30,
color: "green",
label: msg`Author`,
description: msg`Can publish own content`,
},
{
value: 40,
color: "purple",
label: msg`Editor`,
description: msg`Can manage all content`,
},
{
value: 50,
color: "red",
label: msg`Admin`,
description: msg`Full access`,
},
] as const satisfies readonly {
value: number;
color: string;
label: MessageDescriptor;
description: MessageDescriptor;
}[];
const ROLE_CONFIG: Record<number, RoleLevelConfig> = Object.fromEntries(
ROLE_ENTRIES.map((e) => [
e.value,
{ label: e.label, description: e.description, color: e.color },
]),
);
function unknownRoleConfig(role: number): RoleLevelConfig {
return {
label: msg`Role ${role}`,
description: msg`Unknown role`,
color: "gray",
};
}
/** Badge / display config for a numeric role level */
export function getRoleConfig(role: number): RoleLevelConfig {
return ROLE_CONFIG[role] ?? unknownRoleConfig(role);
}

View File

@@ -0,0 +1,46 @@
import { msg } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import * as React from "react";
import { ROLE_ENTRIES } from "./roleDefinitions.js";
const MSG_ROLE_UNKNOWN = msg`Unknown`;
export type RolesSelectRow = {
value: number;
label: string;
description: string;
};
/**
* Shared resolved role strings + descriptor rows for selects (after `i18n` is active).
*/
export function useRolesConfig(): {
roleLabels: Record<string, string>;
getRoleLabel: (level: number) => string;
roles: readonly RolesSelectRow[];
} {
const { t } = useLingui();
const roles = React.useMemo(
() =>
ROLE_ENTRIES.map(({ value, label, description }) => ({
value,
label: t(label),
description: t(description),
})),
[t],
);
const roleLabels = React.useMemo(
() => Object.fromEntries(ROLE_ENTRIES.map((r) => [String(r.value), t(r.label)])),
[t],
);
const getRoleLabel = React.useCallback(
(level: number) => roleLabels[String(level)] ?? t(MSG_ROLE_UNKNOWN),
[roleLabels, t],
);
return { roleLabels, getRoleLabel, roles };
}

View File

@@ -0,0 +1,46 @@
// Main App
export { AdminApp, default as App } from "./App";
// Router
export { createAdminRouter, Link, useNavigate, useParams } from "./router";
// Components
export * from "./components";
// API client
export * from "./lib/api";
// Utilities
export { cn } from "./lib/utils";
// Plugin admin context (for accessing plugin components)
export {
PluginAdminProvider,
usePluginAdmins,
usePluginWidget,
usePluginPage,
usePluginField,
usePluginHasPages,
usePluginHasWidgets,
type PluginAdminModule,
type PluginAdmins,
} from "./lib/plugin-context";
// Auth provider context (for accessing pluggable auth provider components)
export {
AuthProviderProvider,
useAuthProviders,
useAuthProviderList,
type AuthProviderModule,
type AuthProviders,
} from "./lib/auth-provider-context";
// Locales
export {
useLocale,
SUPPORTED_LOCALES,
SUPPORTED_LOCALE_CODES,
DEFAULT_LOCALE,
resolveLocale,
} from "./locales/index.js";
export type { SupportedLocale } from "./locales/index.js";

View File

@@ -0,0 +1,8 @@
/**
* API client for EmDash admin
*
* This file re-exports from the split API modules for backwards compatibility.
* New code should import directly from the specific modules in ./api/
*/
export * from "./api/index.js";

View File

@@ -0,0 +1,96 @@
/**
* API token management client functions
*/
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
// =============================================================================
// Types
// =============================================================================
/** API token info returned from the server */
export interface ApiTokenInfo {
id: string;
name: string;
prefix: string;
scopes: string[];
userId: string;
expiresAt: string | null;
lastUsedAt: string | null;
createdAt: string;
}
/** Result from creating a new token */
export interface ApiTokenCreateResult {
/** Raw token — shown once, never stored */
token: string;
/** Token metadata */
info: ApiTokenInfo;
}
/** Input for creating a new token */
export interface CreateApiTokenInput {
name: string;
scopes: string[];
expiresAt?: string;
}
/**
* Scope strings for personal API tokens (wire + UI iteration order).
* Human-readable copy lives in `ApiTokenSettings` (`SCOPE_UI` + Lingui).
*/
export const API_TOKEN_SCOPES = {
ContentRead: "content:read",
ContentWrite: "content:write",
MediaRead: "media:read",
MediaWrite: "media:write",
SchemaRead: "schema:read",
SchemaWrite: "schema:write",
TaxonomiesManage: "taxonomies:manage",
MenusManage: "menus:manage",
SettingsRead: "settings:read",
SettingsManage: "settings:manage",
Admin: "admin",
} as const;
export type ApiTokenScopeValue = (typeof API_TOKEN_SCOPES)[keyof typeof API_TOKEN_SCOPES];
// =============================================================================
// API Functions
// =============================================================================
/**
* Fetch all API tokens for the current user
*/
export async function fetchApiTokens(): Promise<ApiTokenInfo[]> {
const response = await apiFetch(`${API_BASE}/admin/api-tokens`);
const result = await parseApiResponse<{ items: ApiTokenInfo[] }>(
response,
"Failed to fetch API tokens",
);
return result.items;
}
/**
* Create a new API token
*/
export async function createApiToken(input: CreateApiTokenInput): Promise<ApiTokenCreateResult> {
const response = await apiFetch(`${API_BASE}/admin/api-tokens`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<ApiTokenCreateResult>(response, "Failed to create API token");
}
/**
* Revoke (delete) an API token
*/
export async function revokeApiToken(id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/admin/api-tokens/${id}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to revoke API token");
}

View File

@@ -0,0 +1,87 @@
import {
API_BASE,
apiFetch,
parseApiResponse,
throwResponseError,
type FindManyResult,
} from "./client.js";
export interface BylineSummary {
id: string;
slug: string;
displayName: string;
bio: string | null;
avatarMediaId: string | null;
websiteUrl: string | null;
userId: string | null;
isGuest: boolean;
createdAt: string;
updatedAt: string;
}
export interface BylineInput {
slug: string;
displayName: string;
bio?: string | null;
avatarMediaId?: string | null;
websiteUrl?: string | null;
userId?: string | null;
isGuest?: boolean;
}
export interface BylineCreditInput {
bylineId: string;
roleLabel?: string | null;
}
export async function fetchBylines(options?: {
search?: string;
isGuest?: boolean;
userId?: string;
cursor?: string;
limit?: number;
}): Promise<FindManyResult<BylineSummary>> {
const params = new URLSearchParams();
if (options?.search) params.set("search", options.search);
if (options?.isGuest !== undefined) params.set("isGuest", String(options.isGuest));
if (options?.userId) params.set("userId", options.userId);
if (options?.cursor) params.set("cursor", options.cursor);
if (options?.limit) params.set("limit", String(options.limit));
const url = `${API_BASE}/admin/bylines${params.toString() ? `?${params}` : ""}`;
const response = await apiFetch(url);
return parseApiResponse<FindManyResult<BylineSummary>>(response, "Failed to fetch bylines");
}
export async function fetchByline(id: string): Promise<BylineSummary> {
const response = await apiFetch(`${API_BASE}/admin/bylines/${id}`);
return parseApiResponse<BylineSummary>(response, "Failed to fetch byline");
}
export async function createByline(input: BylineInput): Promise<BylineSummary> {
const response = await apiFetch(`${API_BASE}/admin/bylines`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<BylineSummary>(response, "Failed to create byline");
}
export async function updateByline(
id: string,
input: Partial<BylineInput>,
): Promise<BylineSummary> {
const response = await apiFetch(`${API_BASE}/admin/bylines/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<BylineSummary>(response, "Failed to update byline");
}
export async function deleteByline(id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/admin/bylines/${id}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to delete byline");
}

View File

@@ -0,0 +1,203 @@
/**
* Base API client configuration and shared types
*/
import type { Element } from "@emdash-cms/blocks";
export const API_BASE = "/_emdash/api";
/**
* Fetch wrapper that adds the X-EmDash-Request CSRF protection header
* to all requests. All API calls should use this instead of raw fetch().
*/
export function apiFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
const headers = new Headers(init?.headers);
headers.set("X-EmDash-Request", "1");
return fetch(input, { ...init, headers });
}
/**
* Throw an error with the message from the API response body if available,
* falling back to a generic message. All API error responses use the shape
* `{ error: { code, message } }`.
*/
export async function throwResponseError(res: Response, fallback: string): Promise<never> {
const body: unknown = await res.json().catch(() => ({}));
let message: string | undefined;
if (typeof body === "object" && body !== null && "error" in body) {
const { error } = body;
if (typeof error === "object" && error !== null && "message" in error) {
const { message: msg } = error;
if (typeof msg === "string") message = msg;
}
}
throw new Error(message || `${fallback}: ${res.statusText}`);
}
/**
* Generic paginated result
*/
export interface FindManyResult<T> {
items: T[];
nextCursor?: string;
}
/**
* Admin manifest describing available collections and plugins
*/
export interface AdminManifest {
version: string;
hash: string;
collections: Record<
string,
{
label: string;
labelSingular: string;
supports: string[];
hasSeo: boolean;
urlPattern?: string;
fields: Record<
string,
{
kind: string;
label?: string;
required?: boolean;
widget?: string;
/**
* For `select` / `multiSelect`: the list of enum choices.
* For `json` fields driven by a plugin `widget`: arbitrary widget config.
*/
options?: Array<{ value: string; label: string }> | Record<string, unknown>;
validation?: Record<string, unknown>;
}
>;
}
>;
plugins: Record<
string,
{
name?: string;
version?: string;
/** Package name for dynamic import (e.g., "@emdash-cms/plugin-audit-log") */
package?: string;
/** Whether the plugin is enabled */
enabled?: boolean;
/**
* How this plugin renders its admin UI:
* - "react": Trusted plugin with React components
* - "blocks": Declarative Block Kit UI via admin route handler
* - "none": No admin UI
*/
adminMode?: "react" | "blocks" | "none";
adminPages?: Array<{
path: string;
label?: string;
icon?: string;
}>;
dashboardWidgets?: Array<{
id: string;
title?: string;
size?: "full" | "half" | "third";
}>;
fieldWidgets?: Array<{
name: string;
label: string;
fieldTypes: string[];
elements?: import("@emdash-cms/blocks").Element[];
}>;
/** Block types for Portable Text editor */
portableTextBlocks?: Array<{
type: string;
label: string;
icon?: string;
description?: string;
placeholder?: string;
fields?: Element[];
category?: string;
}>;
}
>;
/**
* Auth mode for the admin UI. When "passkey", the security settings
* (passkey management, self-signup domains) are shown. When using
* external auth (e.g., "cloudflare-access"), these are hidden since
* authentication is handled externally.
*/
authMode: string;
/**
* Whether self-signup is enabled (at least one allowed domain is active).
* Used by the login page to conditionally show the "Sign up" link.
*/
signupEnabled?: boolean;
/**
* i18n configuration. Present when multiple locales are configured.
*/
i18n?: {
defaultLocale: string;
locales: string[];
};
/**
* Taxonomy definitions for the admin sidebar.
*/
taxonomies: Array<{
name: string;
label: string;
labelSingular?: string;
hierarchical: boolean;
collections: string[];
}>;
/**
* Marketplace registry URL. Present when `marketplace` is configured
* in the EmDash integration. Enables marketplace features in the UI.
*/
marketplace?: string;
/**
* Admin branding overrides for white-labeling.
* Set via the `admin` config in `astro.config.mjs`.
*/
admin?: {
logo?: string;
siteName?: string;
favicon?: string;
};
}
/**
* Parse an API response with the { data: T } envelope.
*
* Handles error responses via throwResponseError, then unwraps the data envelope.
* Replaces both bare `response.json()` and field-unwrap patterns.
*/
export async function parseApiResponse<T>(
response: Response,
fallbackMessage = "Request failed",
): Promise<T> {
if (!response.ok) await throwResponseError(response, fallbackMessage);
const body: { data: T } = await response.json();
return body.data;
}
/**
* Fetch admin manifest
*/
export async function fetchManifest(): Promise<AdminManifest> {
const response = await apiFetch(`${API_BASE}/manifest`);
return parseApiResponse<AdminManifest>(response, "Failed to fetch manifest");
}
/**
* Fetch auth mode (public endpoint — works without authentication).
* Used by the login page to determine which login UI to render.
*/
export async function fetchAuthMode(): Promise<{
authMode: string;
signupEnabled?: boolean;
providers?: Array<{ id: string; label: string }>;
}> {
const response = await apiFetch(`${API_BASE}/auth/mode`);
return parseApiResponse<{
authMode: string;
signupEnabled?: boolean;
providers?: Array<{ id: string; label: string }>;
}>(response, "Failed to fetch auth mode");
}

View File

@@ -0,0 +1,124 @@
/**
* Comment moderation API client
*/
import {
API_BASE,
apiFetch,
parseApiResponse,
throwResponseError,
type FindManyResult,
} from "./client.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type CommentStatus = "pending" | "approved" | "spam" | "trash";
export interface AdminComment {
id: string;
collection: string;
contentId: string;
parentId: string | null;
authorName: string;
authorEmail: string;
authorUserId: string | null;
body: string;
status: CommentStatus;
ipHash: string | null;
userAgent: string | null;
moderationMetadata: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
}
export type CommentCounts = Record<CommentStatus, number>;
export type BulkAction = "approve" | "spam" | "trash" | "delete";
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
/**
* Fetch comments for the moderation inbox
*/
export async function fetchComments(options?: {
status?: CommentStatus;
collection?: string;
search?: string;
limit?: number;
cursor?: string;
}): Promise<FindManyResult<AdminComment>> {
const params = new URLSearchParams();
if (options?.status) params.set("status", options.status);
if (options?.collection) params.set("collection", options.collection);
if (options?.search) params.set("search", options.search);
if (options?.limit) params.set("limit", String(options.limit));
if (options?.cursor) params.set("cursor", options.cursor);
const url = `${API_BASE}/admin/comments${params.toString() ? `?${params}` : ""}`;
const response = await apiFetch(url);
return parseApiResponse<FindManyResult<AdminComment>>(response, "Failed to fetch comments");
}
/**
* Fetch comment status counts for inbox badges
*/
export async function fetchCommentCounts(): Promise<CommentCounts> {
const response = await apiFetch(`${API_BASE}/admin/comments/counts`);
return parseApiResponse<CommentCounts>(response, "Failed to fetch comment counts");
}
/**
* Fetch a single comment by ID
*/
export async function fetchComment(id: string): Promise<AdminComment> {
const response = await apiFetch(`${API_BASE}/admin/comments/${id}`);
return parseApiResponse<AdminComment>(response, "Failed to fetch comment");
}
// ---------------------------------------------------------------------------
// Mutations
// ---------------------------------------------------------------------------
/**
* Update a comment's status
*/
export async function updateCommentStatus(
id: string,
status: CommentStatus,
): Promise<AdminComment> {
const response = await apiFetch(`${API_BASE}/admin/comments/${id}/status`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
return parseApiResponse<AdminComment>(response, "Failed to update comment status");
}
/**
* Hard delete a comment (ADMIN only)
*/
export async function deleteComment(id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/admin/comments/${id}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to delete comment");
}
/**
* Bulk status change or delete
*/
export async function bulkCommentAction(
ids: string[],
action: BulkAction,
): Promise<{ affected: number }> {
const response = await apiFetch(`${API_BASE}/admin/comments/bulk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids, action }),
});
return parseApiResponse<{ affected: number }>(response, "Failed to perform bulk action");
}

View File

@@ -0,0 +1,485 @@
/**
* Content CRUD and revision APIs
*/
import type { BylineCreditInput, BylineSummary } from "./bylines.js";
import {
API_BASE,
apiFetch,
parseApiResponse,
throwResponseError,
type FindManyResult,
} from "./client.js";
/**
* Derive draft status from a content item's revision pointers
*/
export function getDraftStatus(
item: ContentItem,
): "unpublished" | "published" | "published_with_changes" {
if (!item.liveRevisionId) return "unpublished";
if (item.draftRevisionId && item.draftRevisionId !== item.liveRevisionId)
return "published_with_changes";
return "published";
}
/** SEO metadata for a content item */
export interface ContentSeo {
title: string | null;
description: string | null;
image: string | null;
canonical: string | null;
noIndex: boolean;
}
export interface ContentItem {
id: string;
type: string;
slug: string | null;
status: string;
locale: string;
translationGroup: string | null;
data: Record<string, unknown>;
authorId: string | null;
primaryBylineId: string | null;
byline?: BylineSummary | null;
bylines?: Array<{
byline: BylineSummary;
sortOrder: number;
roleLabel: string | null;
}>;
createdAt: string;
updatedAt: string;
publishedAt: string | null;
scheduledAt: string | null;
liveRevisionId: string | null;
draftRevisionId: string | null;
seo?: ContentSeo;
}
export interface CreateContentInput {
type: string;
slug?: string;
data: Record<string, unknown>;
status?: string;
bylines?: BylineCreditInput[];
locale?: string;
translationOf?: string;
}
export interface TranslationSummary {
id: string;
locale: string;
slug: string | null;
status: string;
updatedAt: string;
}
export interface TranslationsResponse {
translationGroup: string;
translations: TranslationSummary[];
}
/**
* Fetch translations for a content item
*/
export async function fetchTranslations(
collection: string,
id: string,
): Promise<TranslationsResponse> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/translations`);
return parseApiResponse<TranslationsResponse>(response, "Failed to fetch translations");
}
/** Input for updating SEO fields on content */
export interface ContentSeoInput {
title?: string | null;
description?: string | null;
image?: string | null;
canonical?: string | null;
noIndex?: boolean;
}
export interface UpdateContentInput {
data?: Record<string, unknown>;
slug?: string;
status?: string;
authorId?: string | null;
bylines?: BylineCreditInput[];
/** Skip revision creation (used by autosave) */
skipRevision?: boolean;
seo?: ContentSeoInput;
}
/**
* Trashed content item with deletion timestamp
*/
export interface TrashedContentItem extends ContentItem {
deletedAt: string;
}
/**
* Preview URL response
*/
export interface PreviewUrlResponse {
url: string;
expiresAt: number;
}
/**
* Fetch content list
*/
export async function fetchContentList(
collection: string,
options?: {
cursor?: string;
limit?: number;
status?: string;
locale?: string;
/** Field name to order by, matching the server's whitelist. */
orderBy?: string;
/** Sort direction; defaults to "desc" on the server. */
order?: "asc" | "desc";
},
): Promise<FindManyResult<ContentItem>> {
const params = new URLSearchParams();
if (options?.cursor) params.set("cursor", options.cursor);
if (options?.limit) params.set("limit", String(options.limit));
if (options?.status) params.set("status", options.status);
if (options?.locale) params.set("locale", options.locale);
if (options?.orderBy) params.set("orderBy", options.orderBy);
if (options?.order) params.set("order", options.order);
const url = `${API_BASE}/content/${collection}${params.toString() ? `?${params}` : ""}`;
const response = await apiFetch(url);
return parseApiResponse<FindManyResult<ContentItem>>(response, "Failed to fetch content");
}
/**
* Fetch single content item
*/
export async function fetchContent(collection: string, id: string): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}`);
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to fetch content");
return data.item;
}
/**
* Create content
*/
export async function createContent(
collection: string,
input: Omit<CreateContentInput, "type">,
): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
data: input.data,
slug: input.slug,
status: input.status,
bylines: input.bylines,
locale: input.locale,
translationOf: input.translationOf,
}),
});
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to create content");
return data.item;
}
/**
* Update content
*/
export async function updateContent(
collection: string,
id: string,
input: UpdateContentInput,
): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to update content");
return data.item;
}
/**
* Delete content (moves to trash)
*/
export async function deleteContent(collection: string, id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to delete content");
}
/**
* Fetch trashed content list
*/
export async function fetchTrashedContent(
collection: string,
options?: {
cursor?: string;
limit?: number;
},
): Promise<FindManyResult<TrashedContentItem>> {
const params = new URLSearchParams();
if (options?.cursor) params.set("cursor", options.cursor);
if (options?.limit) params.set("limit", String(options.limit));
const url = `${API_BASE}/content/${collection}/trash${params.toString() ? `?${params}` : ""}`;
const response = await apiFetch(url);
return parseApiResponse<FindManyResult<TrashedContentItem>>(
response,
"Failed to fetch trashed content",
);
}
/**
* Restore content from trash
*/
export async function restoreContent(collection: string, id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/restore`, {
method: "POST",
});
if (!response.ok) await throwResponseError(response, "Failed to restore content");
}
/**
* Permanently delete content (cannot be undone)
*/
export async function permanentDeleteContent(collection: string, id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/permanent`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to permanently delete content");
}
/**
* Duplicate content (creates a draft copy)
*/
export async function duplicateContent(collection: string, id: string): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/duplicate`, {
method: "POST",
});
const data = await parseApiResponse<{ item: ContentItem }>(
response,
"Failed to duplicate content",
);
return data.item;
}
/**
* Schedule content for future publishing
*/
export async function scheduleContent(
collection: string,
id: string,
scheduledAt: string,
): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/schedule`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scheduledAt }),
});
const data = await parseApiResponse<{ item: ContentItem }>(
response,
"Failed to schedule content",
);
return data.item;
}
/**
* Unschedule content (revert to draft)
*/
export async function unscheduleContent(collection: string, id: string): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/schedule`, {
method: "DELETE",
});
const data = await parseApiResponse<{ item: ContentItem }>(
response,
"Failed to unschedule content",
);
return data.item;
}
/**
* Get a preview URL for content
*
* Returns a signed URL that allows viewing draft content.
* Returns null if the EmDash runtime isn't initialized on the server
* (responds with NOT_CONFIGURED). The preview secret itself no longer
* needs to be set explicitly — it auto-generates on first use.
*/
export async function getPreviewUrl(
collection: string,
id: string,
options?: {
expiresIn?: string;
pathPattern?: string;
},
): Promise<PreviewUrlResponse | null> {
try {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/preview-url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(options || {}),
});
if (response.status === 500) {
// Preview not configured — check error code without consuming body for parseApiResponse
const body: unknown = await response.json().catch(() => ({}));
if (
typeof body === "object" &&
body !== null &&
"error" in body &&
typeof body.error === "object" &&
body.error !== null &&
"code" in body.error &&
body.error.code === "NOT_CONFIGURED"
) {
return null;
}
// Some other 500 error
throw new Error("Failed to get preview URL");
}
return parseApiResponse<PreviewUrlResponse>(response, "Failed to get preview URL");
} catch {
// If preview endpoint doesn't exist or fails, return null
return null;
}
}
// =============================================================================
// Publishing (Draft Revisions)
// =============================================================================
/**
* Publish content - promotes current draft to live
*/
export async function publishContent(collection: string, id: string): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/publish`, {
method: "POST",
});
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to publish content");
return data.item;
}
/**
* Unpublish content - removes from public, preserves draft
*/
export async function unpublishContent(collection: string, id: string): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/unpublish`, {
method: "POST",
});
const data = await parseApiResponse<{ item: ContentItem }>(
response,
"Failed to unpublish content",
);
return data.item;
}
/**
* Discard draft changes - reverts to live version
*/
export async function discardDraft(collection: string, id: string): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/discard-draft`, {
method: "POST",
});
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to discard draft");
return data.item;
}
/**
* Compare live and draft revisions
*/
export async function compareRevisions(
collection: string,
id: string,
): Promise<{
hasChanges: boolean;
live: Record<string, unknown> | null;
draft: Record<string, unknown> | null;
}> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/compare`);
return parseApiResponse<{
hasChanges: boolean;
live: Record<string, unknown> | null;
draft: Record<string, unknown> | null;
}>(response, "Failed to compare revisions");
}
// =============================================================================
// Revision API
// =============================================================================
export interface Revision {
id: string;
collection: string;
entryId: string;
data: Record<string, unknown>;
authorId: string | null;
createdAt: string;
}
export interface RevisionListResponse {
items: Revision[];
total: number;
}
/**
* Fetch revisions for a content item
*/
export async function fetchRevisions(
collection: string,
entryId: string,
options?: { limit?: number },
): Promise<RevisionListResponse> {
const params = new URLSearchParams();
if (options?.limit) params.set("limit", String(options.limit));
const url = `${API_BASE}/content/${collection}/${entryId}/revisions${params.toString() ? `?${params}` : ""}`;
const response = await apiFetch(url);
return parseApiResponse<RevisionListResponse>(response, "Failed to fetch revisions");
}
/**
* Get a specific revision
*/
export async function fetchRevision(revisionId: string): Promise<Revision> {
const response = await apiFetch(`${API_BASE}/revisions/${revisionId}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Revision not found: ${revisionId}`);
}
await throwResponseError(response, "Failed to fetch revision");
}
const data = await parseApiResponse<{ item: Revision }>(response, "Failed to fetch revision");
return data.item;
}
/**
* Restore a revision (updates content to this revision's data)
*/
export async function restoreRevision(revisionId: string): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/revisions/${revisionId}/restore`, {
method: "POST",
});
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Revision not found: ${revisionId}`);
}
await throwResponseError(response, "Failed to restore revision");
}
const data = await parseApiResponse<{ item: ContentItem }>(
response,
"Failed to restore revision",
);
return data.item;
}

View File

@@ -0,0 +1,30 @@
/**
* Current user query — shared across Shell, Header, Sidebar, and CommandPalette.
*/
import { useQuery } from "@tanstack/react-query";
import { apiFetch, parseApiResponse } from "./client.js";
export interface CurrentUser {
id: string;
email: string;
name?: string;
role: number;
avatarUrl?: string;
isFirstLogin?: boolean;
}
async function fetchCurrentUser(): Promise<CurrentUser> {
const response = await apiFetch("/_emdash/api/auth/me");
return parseApiResponse<CurrentUser>(response, "Failed to fetch user");
}
export function useCurrentUser() {
return useQuery({
queryKey: ["currentUser"],
queryFn: fetchCurrentUser,
staleTime: 5 * 60 * 1000,
retry: false,
});
}

View File

@@ -0,0 +1,39 @@
/**
* Dashboard stats API
*/
import { API_BASE, apiFetch, parseApiResponse } from "./client.js";
export interface CollectionStats {
slug: string;
label: string;
total: number;
published: number;
draft: number;
}
export interface RecentItem {
id: string;
collection: string;
collectionLabel: string;
title: string;
slug: string | null;
status: string;
updatedAt: string;
authorId: string | null;
}
export interface DashboardStats {
collections: CollectionStats[];
mediaCount: number;
userCount: number;
recentItems: RecentItem[];
}
/**
* Fetch dashboard statistics
*/
export async function fetchDashboardStats(): Promise<DashboardStats> {
const response = await apiFetch(`${API_BASE}/dashboard`);
return parseApiResponse<DashboardStats>(response, "Failed to fetch dashboard stats");
}

View File

@@ -0,0 +1,41 @@
/**
* Email settings API client functions
*/
import { API_BASE, apiFetch, parseApiResponse } from "./client.js";
// =============================================================================
// Types
// =============================================================================
export interface EmailProvider {
pluginId: string;
}
export interface EmailSettings {
available: boolean;
providers: EmailProvider[];
selectedProviderId: string | null;
middleware: {
beforeSend: string[];
afterSend: string[];
};
}
// =============================================================================
// API functions
// =============================================================================
export async function fetchEmailSettings(): Promise<EmailSettings> {
const res = await apiFetch(`${API_BASE}/settings/email`);
return parseApiResponse<EmailSettings>(res, "Failed to fetch email settings");
}
export async function sendTestEmail(to: string): Promise<{ success: boolean; message: string }> {
const res = await apiFetch(`${API_BASE}/settings/email`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ to }),
});
return parseApiResponse<{ success: boolean; message: string }>(res, "Failed to send test email");
}

Some files were not shown because too many files have changed in this diff Show More