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:
266
packages/admin/CHANGELOG.md
Normal file
266
packages/admin/CHANGELOG.md
Normal 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
109
packages/admin/package.json
Normal 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"
|
||||
}
|
||||
18
packages/admin/scripts/copy-locales.js
Normal file
18
packages/admin/scripts/copy-locales.js
Normal 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"));
|
||||
}
|
||||
90
packages/admin/src/App.tsx
Normal file
90
packages/admin/src/App.tsx
Normal 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;
|
||||
476
packages/admin/src/components/AdminCommandPalette.tsx
Normal file
476
packages/admin/src/components/AdminCommandPalette.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
packages/admin/src/components/ArrowIcons.tsx
Normal file
23
packages/admin/src/components/ArrowIcons.tsx
Normal 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} />;
|
||||
}
|
||||
136
packages/admin/src/components/BlockKitFieldWidget.tsx
Normal file
136
packages/admin/src/components/BlockKitFieldWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
122
packages/admin/src/components/BlockKitMediaPickerField.tsx
Normal file
122
packages/admin/src/components/BlockKitMediaPickerField.tsx
Normal 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("//");
|
||||
}
|
||||
156
packages/admin/src/components/CapabilityConsentDialog.tsx
Normal file
156
packages/admin/src/components/CapabilityConsentDialog.tsx
Normal 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;
|
||||
66
packages/admin/src/components/ConfirmDialog.tsx
Normal file
66
packages/admin/src/components/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1987
packages/admin/src/components/ContentEditor.tsx
Normal file
1987
packages/admin/src/components/ContentEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
694
packages/admin/src/components/ContentList.tsx
Normal file
694
packages/admin/src/components/ContentList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
259
packages/admin/src/components/ContentPickerModal.tsx
Normal file
259
packages/admin/src/components/ContentPickerModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
786
packages/admin/src/components/ContentTypeEditor.tsx
Normal file
786
packages/admin/src/components/ContentTypeEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
247
packages/admin/src/components/ContentTypeList.tsx
Normal file
247
packages/admin/src/components/ContentTypeList.tsx
Normal 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>;
|
||||
}
|
||||
336
packages/admin/src/components/Dashboard.tsx
Normal file
336
packages/admin/src/components/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
334
packages/admin/src/components/DeviceAuthorizePage.tsx
Normal file
334
packages/admin/src/components/DeviceAuthorizePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
packages/admin/src/components/DialogError.tsx
Normal file
34
packages/admin/src/components/DialogError.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
packages/admin/src/components/EditorHeader.tsx
Normal file
93
packages/admin/src/components/EditorHeader.tsx
Normal 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;
|
||||
663
packages/admin/src/components/FieldEditor.tsx
Normal file
663
packages/admin/src/components/FieldEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
packages/admin/src/components/Header.tsx
Normal file
109
packages/admin/src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
220
packages/admin/src/components/InviteAcceptPage.tsx
Normal file
220
packages/admin/src/components/InviteAcceptPage.tsx
Normal 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;
|
||||
136
packages/admin/src/components/LocaleSwitcher.tsx
Normal file
136
packages/admin/src/components/LocaleSwitcher.tsx
Normal 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]);
|
||||
}
|
||||
356
packages/admin/src/components/LoginPage.tsx
Normal file
356
packages/admin/src/components/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
packages/admin/src/components/Logo.tsx
Normal file
188
packages/admin/src/components/Logo.tsx
Normal 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} />;
|
||||
}
|
||||
356
packages/admin/src/components/MarketplaceBrowse.tsx
Normal file
356
packages/admin/src/components/MarketplaceBrowse.tsx
Normal 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;
|
||||
562
packages/admin/src/components/MarketplacePluginDetail.tsx
Normal file
562
packages/admin/src/components/MarketplacePluginDetail.tsx
Normal 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">·</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> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
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;
|
||||
275
packages/admin/src/components/MediaDetailPanel.tsx
Normal file
275
packages/admin/src/components/MediaDetailPanel.tsx
Normal 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;
|
||||
676
packages/admin/src/components/MediaLibrary.tsx
Normal file
676
packages/admin/src/components/MediaLibrary.tsx
Normal 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;
|
||||
755
packages/admin/src/components/MediaPickerModal.tsx
Normal file
755
packages/admin/src/components/MediaPickerModal.tsx
Normal 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;
|
||||
451
packages/admin/src/components/MenuEditor.tsx
Normal file
451
packages/admin/src/components/MenuEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
217
packages/admin/src/components/MenuList.tsx
Normal file
217
packages/admin/src/components/MenuList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
packages/admin/src/components/PluginFieldErrorBoundary.tsx
Normal file
48
packages/admin/src/components/PluginFieldErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
586
packages/admin/src/components/PluginManager.tsx
Normal file
586
packages/admin/src/components/PluginManager.tsx
Normal 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;
|
||||
2786
packages/admin/src/components/PortableTextEditor.tsx
Normal file
2786
packages/admin/src/components/PortableTextEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
567
packages/admin/src/components/Redirects.tsx
Normal file
567
packages/admin/src/components/Redirects.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
385
packages/admin/src/components/RepeaterField.tsx
Normal file
385
packages/admin/src/components/RepeaterField.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
435
packages/admin/src/components/RevisionHistory.tsx
Normal file
435
packages/admin/src/components/RevisionHistory.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
packages/admin/src/components/SandboxedPluginPage.tsx
Normal file
115
packages/admin/src/components/SandboxedPluginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
packages/admin/src/components/SandboxedPluginWidget.tsx
Normal file
82
packages/admin/src/components/SandboxedPluginWidget.tsx
Normal 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} />;
|
||||
}
|
||||
47
packages/admin/src/components/SaveButton.tsx
Normal file
47
packages/admin/src/components/SaveButton.tsx
Normal 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;
|
||||
291
packages/admin/src/components/SectionEditor.tsx
Normal file
291
packages/admin/src/components/SectionEditor.tsx
Normal 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`} ·{" "}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
171
packages/admin/src/components/SectionPickerModal.tsx
Normal file
171
packages/admin/src/components/SectionPickerModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
415
packages/admin/src/components/Sections.tsx
Normal file
415
packages/admin/src/components/Sections.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
packages/admin/src/components/SeoImageField.tsx
Normal file
86
packages/admin/src/components/SeoImageField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
packages/admin/src/components/SeoPanel.tsx
Normal file
200
packages/admin/src/components/SeoPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
packages/admin/src/components/Settings.tsx
Normal file
147
packages/admin/src/components/Settings.tsx
Normal 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;
|
||||
628
packages/admin/src/components/SetupWizard.tsx
Normal file
628
packages/admin/src/components/SetupWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
packages/admin/src/components/Shell.tsx
Normal file
81
packages/admin/src/components/Shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
468
packages/admin/src/components/Sidebar.tsx
Normal file
468
packages/admin/src/components/Sidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
449
packages/admin/src/components/SignupPage.tsx
Normal file
449
packages/admin/src/components/SignupPage.tsx
Normal 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;
|
||||
673
packages/admin/src/components/TaxonomyManager.tsx
Normal file
673
packages/admin/src/components/TaxonomyManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
507
packages/admin/src/components/TaxonomySidebar.tsx
Normal file
507
packages/admin/src/components/TaxonomySidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
277
packages/admin/src/components/ThemeMarketplaceBrowse.tsx
Normal file
277
packages/admin/src/components/ThemeMarketplaceBrowse.tsx
Normal 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;
|
||||
334
packages/admin/src/components/ThemeMarketplaceDetail.tsx
Normal file
334
packages/admin/src/components/ThemeMarketplaceDetail.tsx
Normal 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;
|
||||
99
packages/admin/src/components/ThemeProvider.tsx
Normal file
99
packages/admin/src/components/ThemeProvider.tsx
Normal 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;
|
||||
}
|
||||
44
packages/admin/src/components/ThemeToggle.tsx
Normal file
44
packages/admin/src/components/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
packages/admin/src/components/WelcomeModal.tsx
Normal file
149
packages/admin/src/components/WelcomeModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
958
packages/admin/src/components/Widgets.tsx
Normal file
958
packages/admin/src/components/Widgets.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
2366
packages/admin/src/components/WordPressImport.tsx
Normal file
2366
packages/admin/src/components/WordPressImport.tsx
Normal file
File diff suppressed because it is too large
Load Diff
370
packages/admin/src/components/auth/PasskeyLogin.tsx
Normal file
370
packages/admin/src/components/auth/PasskeyLogin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
373
packages/admin/src/components/auth/PasskeyRegistration.tsx
Normal file
373
packages/admin/src/components/auth/PasskeyRegistration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
packages/admin/src/components/auth/index.ts
Normal file
9
packages/admin/src/components/auth/index.ts
Normal 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";
|
||||
216
packages/admin/src/components/comments/CommentDetail.tsx
Normal file
216
packages/admin/src/components/comments/CommentDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
553
packages/admin/src/components/comments/CommentInbox.tsx
Normal file
553
packages/admin/src/components/comments/CommentInbox.tsx
Normal 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>;
|
||||
}
|
||||
342
packages/admin/src/components/editor/BlockMenu.tsx
Normal file
342
packages/admin/src/components/editor/BlockMenu.tsx
Normal 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 };
|
||||
217
packages/admin/src/components/editor/DocumentOutline.tsx
Normal file
217
packages/admin/src/components/editor/DocumentOutline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
packages/admin/src/components/editor/DragHandleWrapper.tsx
Normal file
138
packages/admin/src/components/editor/DragHandleWrapper.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
529
packages/admin/src/components/editor/ImageDetailPanel.tsx
Normal file
529
packages/admin/src/components/editor/ImageDetailPanel.tsx
Normal 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;
|
||||
369
packages/admin/src/components/editor/ImageNode.tsx
Normal file
369
packages/admin/src/components/editor/ImageNode.tsx
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
506
packages/admin/src/components/editor/PluginBlockNode.tsx
Normal file
506
packages/admin/src/components/editor/PluginBlockNode.tsx
Normal 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 };
|
||||
26
packages/admin/src/components/index.ts
Normal file
26
packages/admin/src/components/index.ts
Normal 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";
|
||||
@@ -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;
|
||||
464
packages/admin/src/components/settings/ApiTokenSettings.tsx
Normal file
464
packages/admin/src/components/settings/ApiTokenSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
250
packages/admin/src/components/settings/EmailSettings.tsx
Normal file
250
packages/admin/src/components/settings/EmailSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
331
packages/admin/src/components/settings/GeneralSettings.tsx
Normal file
331
packages/admin/src/components/settings/GeneralSettings.tsx
Normal 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;
|
||||
217
packages/admin/src/components/settings/PasskeyItem.tsx
Normal file
217
packages/admin/src/components/settings/PasskeyItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
packages/admin/src/components/settings/PasskeyList.tsx
Normal file
40
packages/admin/src/components/settings/PasskeyList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
239
packages/admin/src/components/settings/SecuritySettings.tsx
Normal file
239
packages/admin/src/components/settings/SecuritySettings.tsx
Normal 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;
|
||||
181
packages/admin/src/components/settings/SeoSettings.tsx
Normal file
181
packages/admin/src/components/settings/SeoSettings.tsx
Normal 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;
|
||||
192
packages/admin/src/components/settings/SocialSettings.tsx
Normal file
192
packages/admin/src/components/settings/SocialSettings.tsx
Normal 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;
|
||||
@@ -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 (Subscriber–Editor 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 };
|
||||
}
|
||||
211
packages/admin/src/components/users/InviteUserModal.tsx
Normal file
211
packages/admin/src/components/users/InviteUserModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
packages/admin/src/components/users/RoleBadge.tsx
Normal file
54
packages/admin/src/components/users/RoleBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
379
packages/admin/src/components/users/UserDetail.tsx
Normal file
379
packages/admin/src/components/users/UserDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
254
packages/admin/src/components/users/UserList.tsx
Normal file
254
packages/admin/src/components/users/UserList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
packages/admin/src/components/users/index.ts
Normal file
6
packages/admin/src/components/users/index.ts
Normal 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";
|
||||
70
packages/admin/src/components/users/roleDefinitions.ts
Normal file
70
packages/admin/src/components/users/roleDefinitions.ts
Normal 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);
|
||||
}
|
||||
46
packages/admin/src/components/users/useRolesConfig.ts
Normal file
46
packages/admin/src/components/users/useRolesConfig.ts
Normal 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 };
|
||||
}
|
||||
46
packages/admin/src/index.ts
Normal file
46
packages/admin/src/index.ts
Normal 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";
|
||||
8
packages/admin/src/lib/api.ts
Normal file
8
packages/admin/src/lib/api.ts
Normal 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";
|
||||
96
packages/admin/src/lib/api/api-tokens.ts
Normal file
96
packages/admin/src/lib/api/api-tokens.ts
Normal 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");
|
||||
}
|
||||
87
packages/admin/src/lib/api/bylines.ts
Normal file
87
packages/admin/src/lib/api/bylines.ts
Normal 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");
|
||||
}
|
||||
203
packages/admin/src/lib/api/client.ts
Normal file
203
packages/admin/src/lib/api/client.ts
Normal 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");
|
||||
}
|
||||
124
packages/admin/src/lib/api/comments.ts
Normal file
124
packages/admin/src/lib/api/comments.ts
Normal 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");
|
||||
}
|
||||
485
packages/admin/src/lib/api/content.ts
Normal file
485
packages/admin/src/lib/api/content.ts
Normal 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;
|
||||
}
|
||||
30
packages/admin/src/lib/api/current-user.ts
Normal file
30
packages/admin/src/lib/api/current-user.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
39
packages/admin/src/lib/api/dashboard.ts
Normal file
39
packages/admin/src/lib/api/dashboard.ts
Normal 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");
|
||||
}
|
||||
41
packages/admin/src/lib/api/email-settings.ts
Normal file
41
packages/admin/src/lib/api/email-settings.ts
Normal 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
Reference in New Issue
Block a user