@emdash-cms/admin 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/{LocaleDirectionProvider-cjRhNz57.js → LocaleDirectionProvider-D32XwRVp.js} +17 -17
  2. package/dist/{LocaleDirectionProvider-cjRhNz57.js.map → LocaleDirectionProvider-D32XwRVp.js.map} +1 -1
  3. package/dist/index.d.ts +49 -16
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1170 -63
  6. package/dist/index.js.map +1 -1
  7. package/dist/locales/ar/messages.mjs +1 -1
  8. package/dist/locales/de/messages.mjs +1 -1
  9. package/dist/locales/en/messages.mjs +1 -1
  10. package/dist/locales/es-419/messages.mjs +1 -1
  11. package/dist/locales/es-ES/messages.mjs +1 -1
  12. package/dist/locales/eu/messages.mjs +1 -1
  13. package/dist/locales/fa/messages.mjs +1 -1
  14. package/dist/locales/fr/messages.mjs +1 -1
  15. package/dist/locales/id/messages.mjs +1 -1
  16. package/dist/locales/index.js +1 -1
  17. package/dist/locales/ja/messages.mjs +1 -1
  18. package/dist/locales/ko/messages.mjs +1 -1
  19. package/dist/locales/pl/messages.mjs +1 -1
  20. package/dist/locales/pseudo/messages.mjs +1 -1
  21. package/dist/locales/pt-BR/messages.mjs +1 -1
  22. package/dist/locales/zh-CN/messages.mjs +1 -1
  23. package/dist/locales/zh-TW/messages.mjs +1 -1
  24. package/dist/messages-35lkat0c.js +6 -0
  25. package/dist/messages-35lkat0c.js.map +1 -0
  26. package/dist/messages-BSSaBU23.js +6 -0
  27. package/dist/messages-BSSaBU23.js.map +1 -0
  28. package/dist/messages-Boy6iMLn.js +6 -0
  29. package/dist/messages-Boy6iMLn.js.map +1 -0
  30. package/dist/messages-BwIV9n3i.js +6 -0
  31. package/dist/messages-BwIV9n3i.js.map +1 -0
  32. package/dist/messages-CGrAqIHP.js +6 -0
  33. package/dist/messages-CGrAqIHP.js.map +1 -0
  34. package/dist/messages-CgLmZ-Xe.js +6 -0
  35. package/dist/messages-CgLmZ-Xe.js.map +1 -0
  36. package/dist/messages-Ckz9OJjP.js +6 -0
  37. package/dist/messages-Ckz9OJjP.js.map +1 -0
  38. package/dist/messages-CrY9gw3p.js +6 -0
  39. package/dist/messages-CrY9gw3p.js.map +1 -0
  40. package/dist/messages-CtrOZaXa.js +6 -0
  41. package/dist/messages-CtrOZaXa.js.map +1 -0
  42. package/dist/messages-CxW3nwfb.js +6 -0
  43. package/dist/messages-CxW3nwfb.js.map +1 -0
  44. package/dist/messages-D7CyjcVC.js +6 -0
  45. package/dist/messages-D7CyjcVC.js.map +1 -0
  46. package/dist/messages-Dhy7Stmm.js +6 -0
  47. package/dist/messages-Dhy7Stmm.js.map +1 -0
  48. package/dist/messages-FyJi9vss.js +6 -0
  49. package/dist/messages-FyJi9vss.js.map +1 -0
  50. package/dist/messages-JMBMiRyI.js +6 -0
  51. package/dist/messages-JMBMiRyI.js.map +1 -0
  52. package/dist/messages-pPTbyAIO.js +6 -0
  53. package/dist/messages-pPTbyAIO.js.map +1 -0
  54. package/dist/messages-zl21KaEb.js +6 -0
  55. package/dist/messages-zl21KaEb.js.map +1 -0
  56. package/dist/plugins-qrjvPK4g.js.map +1 -1
  57. package/dist/styles.css +1 -1
  58. package/package.json +7 -3
  59. package/dist/messages-9_R3aCM9.js +0 -6
  60. package/dist/messages-9_R3aCM9.js.map +0 -1
  61. package/dist/messages-A-cROb0f.js +0 -6
  62. package/dist/messages-A-cROb0f.js.map +0 -1
  63. package/dist/messages-B3-g6YaO.js +0 -6
  64. package/dist/messages-B3-g6YaO.js.map +0 -1
  65. package/dist/messages-B5UEpxLj.js +0 -6
  66. package/dist/messages-B5UEpxLj.js.map +0 -1
  67. package/dist/messages-BAEa2eLw.js +0 -6
  68. package/dist/messages-BAEa2eLw.js.map +0 -1
  69. package/dist/messages-BHndsjBE.js +0 -6
  70. package/dist/messages-BHndsjBE.js.map +0 -1
  71. package/dist/messages-BNPbaoQs.js +0 -6
  72. package/dist/messages-BNPbaoQs.js.map +0 -1
  73. package/dist/messages-BieStAB8.js +0 -6
  74. package/dist/messages-BieStAB8.js.map +0 -1
  75. package/dist/messages-COU-tgjA.js +0 -6
  76. package/dist/messages-COU-tgjA.js.map +0 -1
  77. package/dist/messages-CXVvqhvr.js +0 -6
  78. package/dist/messages-CXVvqhvr.js.map +0 -1
  79. package/dist/messages-CgTwIPDv.js +0 -6
  80. package/dist/messages-CgTwIPDv.js.map +0 -1
  81. package/dist/messages-CtPGWm7g.js +0 -6
  82. package/dist/messages-CtPGWm7g.js.map +0 -1
  83. package/dist/messages-DES9CrLv.js +0 -6
  84. package/dist/messages-DES9CrLv.js.map +0 -1
  85. package/dist/messages-DQTZQuDt.js +0 -6
  86. package/dist/messages-DQTZQuDt.js.map +0 -1
  87. package/dist/messages-Dw1e-SNm.js +0 -6
  88. package/dist/messages-Dw1e-SNm.js.map +0 -1
  89. package/dist/messages-Dzvc57yv.js +0 -6
  90. package/dist/messages-Dzvc57yv.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { c as fetchAuthMode, d as throwResponseError, i as fetchPlugins, l as fetchManifest, n as enablePlugin, o as API_BASE, r as fetchPlugin, s as apiFetch, t as disablePlugin, u as parseApiResponse } from "./plugins-qrjvPK4g.js";
2
- import { a as SUPPORTED_LOCALES, i as DEFAULT_LOCALE, l as resolveLocale, n as useLocale, o as SUPPORTED_LOCALE_CODES, t as LocaleDirectionProvider } from "./LocaleDirectionProvider-cjRhNz57.js";
2
+ import { a as SUPPORTED_LOCALES, i as DEFAULT_LOCALE, l as resolveLocale, n as useLocale, o as SUPPORTED_LOCALE_CODES, t as LocaleDirectionProvider } from "./LocaleDirectionProvider-D32XwRVp.js";
3
3
  import "./locales/index.js";
4
4
  import { Badge, Button, Checkbox, CommandPalette, Dialog, Input, InputArea, Label, LinkButton, Loader, Popover, Select, Sidebar as KumoSidebar, Switch, Tabs, Toast, Toasty, Tooltip, buttonVariants, useSidebar } from "@cloudflare/kumo";
5
5
  import { i18n } from "@lingui/core";
@@ -10,7 +10,7 @@ import * as React$1 from "react";
10
10
  import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
11
11
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
12
12
  import { Dialog as Dialog$1 } from "@cloudflare/kumo/primitives";
13
- import { ArrowCounterClockwise, ArrowRight, ArrowRightIcon, ArrowSquareOut, ArrowUUpLeft, ArrowUUpRight, ArrowsClockwise, ArrowsInSimple, ArrowsLeftRight, ArrowsOutSimple, BracketsCurly, Calendar, CalendarBlank, CaretDown, CaretRightIcon, CaretUp, CaretUpDown, ChatCircle, Check, CheckCircle, CircleDashed, CircleNotch, ClockCounterClockwise, Cloud, Code, CodeBlock, Columns, Copy, Cube, Database, DeviceMobile, DotsSixVertical, DownloadSimple, Envelope, EnvelopeSimple, Eye, EyeSlash, File, FileArrowDown, FileText, FloppyDisk, FolderOpen, Gear, GithubLogo, Globe, GlobeSimple, GridFour, HardDrive, Hash, Image as Image$1, ImageSquare, Info, Key, Link as Link$2, LinkBreak, LinkSimple, List, ListBullets, ListChecks, ListNumbers, MagnifyingGlass, Minus, Monitor, Moon, Palette, PaperPlaneTilt, Paperclip, Paragraph, Pencil, PencilSimple, PlugsConnected, Plus, Prohibit, PuzzlePiece, Quotes, Rows, Ruler, ShareNetwork, Shield, ShieldCheck, ShieldWarning, SignOut, SlidersHorizontal, Sparkle, SquaresFour, Stack, Storefront, Sun, Table, TextAlignCenter, TextAlignLeft, TextAlignRight, TextB, TextHOne, TextHThree, TextHTwo, TextItalic, TextStrikethrough, TextT, TextUnderline, ToggleLeft, Trash, Upload, User, UserCircle, UserPlus, Users, Warning, WarningCircle, WebhooksLogo, X, YoutubeLogo } from "@phosphor-icons/react";
13
+ import { ArrowCounterClockwise, ArrowRight, ArrowRightIcon, ArrowSquareOut, ArrowUUpLeft, ArrowUUpRight, ArrowsClockwise, ArrowsInSimple, ArrowsLeftRight, ArrowsOutSimple, BracketsCurly, Calendar, CalendarBlank, CaretDown, CaretRightIcon, CaretUp, CaretUpDown, ChatCircle, Check, CheckCircle, CircleDashed, CircleNotch, ClockCounterClockwise, Cloud, Code, CodeBlock, Columns, Copy, Cube, Database, DeviceMobile, DotsSixVertical, DownloadSimple, Envelope, EnvelopeSimple, Eye, EyeSlash, File, FileArrowDown, FileText, FloppyDisk, FolderOpen, Gear, GithubLogo, Globe, GlobeSimple, GridFour, HardDrive, Hash, Image as Image$1, ImageBroken, ImageSquare, Info, Key, Link as Link$2, LinkBreak, LinkSimple, List, ListBullets, ListChecks, ListNumbers, MagnifyingGlass, Minus, Monitor, Moon, Palette, PaperPlaneTilt, Paperclip, Paragraph, Pencil, PencilSimple, PlugsConnected, Plus, Prohibit, PuzzlePiece, Quotes, Rows, Ruler, ShareNetwork, Shield, ShieldCheck, ShieldWarning, SignOut, SlidersHorizontal, Sparkle, SquaresFour, Stack, Storefront, Sun, Table, TextAlignCenter, TextAlignLeft, TextAlignRight, TextB, TextHOne, TextHThree, TextHTwo, TextItalic, TextStrikethrough, TextT, TextUnderline, ToggleLeft, Trash, Upload, User, UserCircle, UserPlus, Users, Warning, WarningCircle, WebhooksLogo, X, YoutubeLogo } from "@phosphor-icons/react";
14
14
  import { clsx } from "clsx";
15
15
  import { twMerge } from "tailwind-merge";
16
16
  import { DndContext, DragOverlay, KeyboardSensor, PointerSensor, closestCenter, rectIntersection, useDraggable, useDroppable, useSensor, useSensors } from "@dnd-kit/core";
@@ -2053,7 +2053,7 @@ async function createMenuItem(menuName, input, options = {}) {
2053
2053
  * Update a menu item
2054
2054
  */
2055
2055
  async function updateMenuItem(menuName, itemId, input, options = {}) {
2056
- return parseApiResponse(await apiFetch(withLocale$1(`${API_BASE}/menus/${menuName}/items?id=${itemId}`, options.locale), {
2056
+ return parseApiResponse(await apiFetch(withLocale$1(`${API_BASE}/menus/${menuName}/items/${itemId}`, options.locale), {
2057
2057
  method: "PUT",
2058
2058
  headers: { "Content-Type": "application/json" },
2059
2059
  body: JSON.stringify(input)
@@ -2063,7 +2063,7 @@ async function updateMenuItem(menuName, itemId, input, options = {}) {
2063
2063
  * Delete a menu item
2064
2064
  */
2065
2065
  async function deleteMenuItem(menuName, itemId, options = {}) {
2066
- const response = await apiFetch(withLocale$1(`${API_BASE}/menus/${menuName}/items?id=${itemId}`, options.locale), { method: "DELETE" });
2066
+ const response = await apiFetch(withLocale$1(`${API_BASE}/menus/${menuName}/items/${itemId}`, options.locale), { method: "DELETE" });
2067
2067
  if (!response.ok) await throwResponseError(response, i18n._({
2068
2068
  id: "HNaiZF",
2069
2069
  message: "Failed to delete menu item"
@@ -3874,7 +3874,11 @@ function ProviderMediaItem({ item, selected, onClick, onDoubleClick, onDimension
3874
3874
  function BlockKitMediaPickerField({ actionId, label, placeholder, mimeTypeFilter, value, onChange }) {
3875
3875
  const { _: _t } = useLingui();
3876
3876
  const [pickerOpen, setPickerOpen] = React$1.useState(false);
3877
+ const [imageBroken, setImageBroken] = React$1.useState(false);
3877
3878
  const url = typeof value === "string" && value.length > 0 ? value : "";
3879
+ React$1.useEffect(() => {
3880
+ setImageBroken(false);
3881
+ }, [url]);
3878
3882
  const filter = mimeTypeFilter ?? "image/";
3879
3883
  const canPreview = isSafePreviewUrl(url);
3880
3884
  const handleSelect = (item) => {
@@ -3889,14 +3893,50 @@ function BlockKitMediaPickerField({ actionId, label, placeholder, mimeTypeFilter
3889
3893
  className: "text-sm font-medium mb-1.5 block",
3890
3894
  children: label
3891
3895
  }),
3892
- canPreview ? /* @__PURE__ */ jsxs("div", {
3896
+ canPreview ? imageBroken ? /* @__PURE__ */ jsxs("div", {
3897
+ className: "relative group min-h-20",
3898
+ children: [/* @__PURE__ */ jsxs("div", {
3899
+ className: "min-h-20 w-full rounded-md border border-kumo-line bg-kumo-muted flex items-center justify-center gap-2 text-kumo-subtle",
3900
+ children: [/* @__PURE__ */ jsx(ImageBroken, { className: "h-5 w-5" }), /* @__PURE__ */ jsx("span", {
3901
+ className: "text-sm",
3902
+ children: _t({
3903
+ id: "1pdLQw",
3904
+ message: "Image not found"
3905
+ })
3906
+ })]
3907
+ }), /* @__PURE__ */ jsxs("div", {
3908
+ 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",
3909
+ children: [/* @__PURE__ */ jsx(Button, {
3910
+ type: "button",
3911
+ size: "sm",
3912
+ variant: "secondary",
3913
+ onClick: () => setPickerOpen(true),
3914
+ children: _t({
3915
+ id: "o+XJ9D",
3916
+ message: "Change"
3917
+ })
3918
+ }), /* @__PURE__ */ jsx(Button, {
3919
+ type: "button",
3920
+ shape: "square",
3921
+ variant: "destructive",
3922
+ className: "h-8 w-8",
3923
+ onClick: () => onChange(actionId, ""),
3924
+ "aria-label": _t({
3925
+ id: "t/YqKh",
3926
+ message: "Remove"
3927
+ }),
3928
+ children: /* @__PURE__ */ jsx(X, { className: "h-4 w-4" })
3929
+ })]
3930
+ })]
3931
+ }) : /* @__PURE__ */ jsxs("div", {
3893
3932
  className: "relative group",
3894
3933
  children: [/* @__PURE__ */ jsx("img", {
3895
3934
  src: url,
3896
3935
  alt: "",
3897
- className: "max-h-40 w-full rounded-md border border-kumo-line object-contain bg-kumo-muted",
3936
+ className: "max-h-40 min-h-20 w-full rounded-md border border-kumo-line object-contain bg-kumo-muted",
3898
3937
  referrerPolicy: "no-referrer",
3899
- loading: "lazy"
3938
+ loading: "lazy",
3939
+ onError: () => setImageBroken(true)
3900
3940
  }), /* @__PURE__ */ jsxs("div", {
3901
3941
  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",
3902
3942
  children: [/* @__PURE__ */ jsx(Button, {
@@ -7402,6 +7442,10 @@ function SlashCommandMenu({ state, onCommand, onClose: _onClose, setSelectedInde
7402
7442
  const selected = container.querySelector(`[data-index="${state.selectedIndex}"]`);
7403
7443
  if (selected) selected.scrollIntoView({ block: "nearest" });
7404
7444
  }, [state.selectedIndex, state.isOpen]);
7445
+ const hasMouseMovedRef = React$1.useRef(false);
7446
+ React$1.useEffect(() => {
7447
+ if (!state.isOpen) hasMouseMovedRef.current = false;
7448
+ }, [state.isOpen]);
7405
7449
  if (!state.isOpen) return null;
7406
7450
  return createPortal(/* @__PURE__ */ jsx("div", {
7407
7451
  ref: (node) => {
@@ -7410,6 +7454,9 @@ function SlashCommandMenu({ state, onCommand, onClose: _onClose, setSelectedInde
7410
7454
  },
7411
7455
  style: floatingStyles,
7412
7456
  className: "z-[100] rounded-lg border bg-kumo-overlay p-1 shadow-lg min-w-[220px] max-h-[300px] overflow-y-auto",
7457
+ onPointerMove: () => {
7458
+ hasMouseMovedRef.current = true;
7459
+ },
7413
7460
  children: state.items.length === 0 ? /* @__PURE__ */ jsx("p", {
7414
7461
  className: "text-sm text-kumo-subtle px-3 py-2",
7415
7462
  children: _t({
@@ -7421,7 +7468,9 @@ function SlashCommandMenu({ state, onCommand, onClose: _onClose, setSelectedInde
7421
7468
  "data-index": index,
7422
7469
  className: cn("flex items-center gap-3 w-full px-3 py-2 text-sm rounded text-start", index === state.selectedIndex ? "bg-kumo-tint text-kumo-default" : "hover:bg-kumo-tint/50"),
7423
7470
  onClick: () => onCommand(item),
7424
- onMouseEnter: () => setSelectedIndex(index),
7471
+ onMouseEnter: () => {
7472
+ if (hasMouseMovedRef.current) setSelectedIndex(index);
7473
+ },
7425
7474
  children: [/* @__PURE__ */ jsx(item.icon, { className: "h-4 w-4 text-kumo-subtle flex-shrink-0" }), /* @__PURE__ */ jsxs("div", {
7426
7475
  className: "flex flex-col",
7427
7476
  children: [/* @__PURE__ */ jsx("span", {
@@ -11397,7 +11446,11 @@ function JsonFieldEditor({ label, id, value, onChange, required }) {
11397
11446
  function ImageFieldRenderer({ id, label, description, value, onChange, required, allowedMimeTypes, fieldId }) {
11398
11447
  const { _: _t5 } = useLingui();
11399
11448
  const [pickerOpen, setPickerOpen] = React$1.useState(false);
11449
+ const [imageBroken, setImageBroken] = React$1.useState(false);
11400
11450
  const displayUrl = typeof value === "string" ? value : value?.previewUrl || value?.src || (value && (!value.provider || value.provider === "local") ? `/_emdash/api/media/file/${typeof value.meta?.storageKey === "string" ? value.meta.storageKey : value.id}` : void 0);
11451
+ React$1.useEffect(() => {
11452
+ setImageBroken(false);
11453
+ }, [displayUrl]);
11401
11454
  const handleSelect = (item) => {
11402
11455
  const isLocalProvider = !item.provider || item.provider === "local";
11403
11456
  onChange({
@@ -11420,12 +11473,48 @@ function ImageFieldRenderer({ id, label, description, value, onChange, required,
11420
11473
  id,
11421
11474
  children: [
11422
11475
  /* @__PURE__ */ jsx(Label, { children: label }),
11423
- displayUrl ? /* @__PURE__ */ jsxs("div", {
11476
+ displayUrl ? imageBroken ? /* @__PURE__ */ jsxs("div", {
11477
+ className: "mt-2 relative group",
11478
+ children: [/* @__PURE__ */ jsxs("div", {
11479
+ className: "min-h-20 rounded-lg border bg-kumo-muted flex items-center justify-center gap-2 text-kumo-subtle",
11480
+ children: [/* @__PURE__ */ jsx(ImageBroken, { className: "h-5 w-5" }), /* @__PURE__ */ jsx("span", {
11481
+ className: "text-sm",
11482
+ children: _t5({
11483
+ id: "1pdLQw",
11484
+ message: "Image not found"
11485
+ })
11486
+ })]
11487
+ }), /* @__PURE__ */ jsxs("div", {
11488
+ className: "absolute top-2 end-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1",
11489
+ children: [/* @__PURE__ */ jsx(Button, {
11490
+ type: "button",
11491
+ size: "sm",
11492
+ variant: "secondary",
11493
+ onClick: () => setPickerOpen(true),
11494
+ children: _t5({
11495
+ id: "o+XJ9D",
11496
+ message: "Change"
11497
+ })
11498
+ }), /* @__PURE__ */ jsx(Button, {
11499
+ type: "button",
11500
+ shape: "square",
11501
+ variant: "destructive",
11502
+ className: "h-8 w-8",
11503
+ onClick: handleRemove,
11504
+ "aria-label": _t5({
11505
+ id: "5ywtDz",
11506
+ message: "Remove image"
11507
+ }),
11508
+ children: /* @__PURE__ */ jsx(X, { className: "h-4 w-4" })
11509
+ })]
11510
+ })]
11511
+ }) : /* @__PURE__ */ jsxs("div", {
11424
11512
  className: "mt-2 relative group",
11425
11513
  children: [/* @__PURE__ */ jsx("img", {
11426
11514
  src: displayUrl,
11427
11515
  alt: "",
11428
- className: "max-h-48 rounded-lg border object-cover"
11516
+ className: "max-h-48 min-h-20 rounded-lg border object-cover",
11517
+ onError: () => setImageBroken(true)
11429
11518
  }), /* @__PURE__ */ jsxs("div", {
11430
11519
  className: "absolute top-2 end-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1",
11431
11520
  children: [/* @__PURE__ */ jsx(Button, {
@@ -12091,7 +12180,7 @@ function getItemTitle$1(item) {
12091
12180
  /**
12092
12181
  * Content list view with table display and trash tab
12093
12182
  */
12094
- function ContentList({ collection, collectionLabel, items, trashedItems = [], isLoading, isTrashedLoading, onDelete, onDuplicate, onRestore, onPermanentDelete, onLoadMore, onLoadMoreTrashed, hasMore, hasMoreTrashed, trashedCount = 0, i18n: i18n$2, activeLocale, onLocaleChange, urlPattern, sort, onSortChange }) {
12183
+ function ContentList({ collection, collectionLabel, items, trashedItems = [], isLoading, isTrashedLoading, onDelete, onDuplicate, onRestore, onPermanentDelete, onLoadMore, onLoadMoreTrashed, hasMore, hasMoreTrashed, trashedCount = 0, i18n, activeLocale, onLocaleChange, urlPattern, sort, onSortChange, total }) {
12095
12184
  const { _: _t } = useLingui();
12096
12185
  const [activeTab, setActiveTab] = React$1.useState("all");
12097
12186
  const [searchQuery, setSearchQuery] = React$1.useState("");
@@ -12105,13 +12194,16 @@ function ContentList({ collection, collectionLabel, items, trashedItems = [], is
12105
12194
  const query = searchQuery.toLowerCase();
12106
12195
  return items.filter((item) => getItemTitle$1(item).toLowerCase().includes(query));
12107
12196
  }, [items, searchQuery]);
12108
- const totalPages = Math.max(1, Math.ceil(filteredItems.length / PAGE_SIZE));
12109
- const paginatedItems = filteredItems.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
12197
+ const effectiveTotal = typeof total === "number" && !searchQuery ? total : filteredItems.length;
12198
+ const totalPages = Math.max(1, Math.ceil(effectiveTotal / PAGE_SIZE));
12199
+ const clampedPage = Math.min(page, totalPages - 1);
12200
+ const paginatedItems = filteredItems.slice(clampedPage * PAGE_SIZE, (clampedPage + 1) * PAGE_SIZE);
12110
12201
  React$1.useEffect(() => {
12111
- if (page >= totalPages - 1 && hasMore && onLoadMore && !searchQuery) onLoadMore();
12202
+ if (!hasMore || !onLoadMore || searchQuery) return;
12203
+ if (clampedPage >= Math.ceil(filteredItems.length / PAGE_SIZE) - 1) onLoadMore();
12112
12204
  }, [
12113
- page,
12114
- totalPages,
12205
+ clampedPage,
12206
+ filteredItems.length,
12115
12207
  hasMore,
12116
12208
  onLoadMore,
12117
12209
  searchQuery
@@ -12126,9 +12218,9 @@ function ContentList({ collection, collectionLabel, items, trashedItems = [], is
12126
12218
  children: [/* @__PURE__ */ jsx("h1", {
12127
12219
  className: "text-2xl font-bold",
12128
12220
  children: collectionLabel
12129
- }), i18n$2 && activeLocale && onLocaleChange && /* @__PURE__ */ jsx(LocaleSwitcher, {
12130
- locales: i18n$2.locales,
12131
- defaultLocale: i18n$2.defaultLocale,
12221
+ }), i18n && activeLocale && onLocaleChange && /* @__PURE__ */ jsx(LocaleSwitcher, {
12222
+ locales: i18n.locales,
12223
+ defaultLocale: i18n.defaultLocale,
12132
12224
  value: activeLocale,
12133
12225
  onChange: onLocaleChange,
12134
12226
  size: "sm"
@@ -12222,7 +12314,7 @@ function ContentList({ collection, collectionLabel, items, trashedItems = [], is
12222
12314
  message: "Status"
12223
12315
  })
12224
12316
  }),
12225
- i18n$2 && /* @__PURE__ */ jsx(SortableTh, {
12317
+ i18n && /* @__PURE__ */ jsx(SortableTh, {
12226
12318
  field: "locale",
12227
12319
  sort,
12228
12320
  onSortChange,
@@ -12252,7 +12344,7 @@ function ContentList({ collection, collectionLabel, items, trashedItems = [], is
12252
12344
  }) }), /* @__PURE__ */ jsx("tbody", {
12253
12345
  className: "divide-y divide-kumo-line",
12254
12346
  children: isLoading && items.length === 0 ? /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx("td", {
12255
- colSpan: i18n$2 ? 5 : 4,
12347
+ colSpan: i18n ? 5 : 4,
12256
12348
  className: "px-4 py-8 text-center text-kumo-subtle",
12257
12349
  children: /* @__PURE__ */ jsxs("span", {
12258
12350
  className: "inline-flex items-center gap-2",
@@ -12262,7 +12354,7 @@ function ContentList({ collection, collectionLabel, items, trashedItems = [], is
12262
12354
  })]
12263
12355
  })
12264
12356
  }) }) : items.length === 0 ? /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsxs("td", {
12265
- colSpan: i18n$2 ? 5 : 4,
12357
+ colSpan: i18n ? 5 : 4,
12266
12358
  className: "px-4 py-8 text-center text-kumo-subtle",
12267
12359
  children: [
12268
12360
  _t({
@@ -12283,7 +12375,7 @@ function ContentList({ collection, collectionLabel, items, trashedItems = [], is
12283
12375
  })
12284
12376
  ]
12285
12377
  }) }) : paginatedItems.length === 0 ? /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx("td", {
12286
- colSpan: i18n$2 ? 5 : 4,
12378
+ colSpan: i18n ? 5 : 4,
12287
12379
  className: "px-4 py-8 text-center text-kumo-subtle",
12288
12380
  children: _t({
12289
12381
  id: "huTRDC",
@@ -12295,7 +12387,7 @@ function ContentList({ collection, collectionLabel, items, trashedItems = [], is
12295
12387
  collection,
12296
12388
  onDelete,
12297
12389
  onDuplicate,
12298
- showLocale: !!i18n$2,
12390
+ showLocale: !!i18n,
12299
12391
  urlPattern
12300
12392
  }, item.id))
12301
12393
  })]
@@ -12305,21 +12397,11 @@ function ContentList({ collection, collectionLabel, items, trashedItems = [], is
12305
12397
  className: "flex items-center justify-between",
12306
12398
  children: [/* @__PURE__ */ jsx("span", {
12307
12399
  className: "text-sm text-kumo-subtle",
12308
- children: searchQuery ? i18n._({
12309
- id: "Zzkfda",
12310
- message: "{0, plural, one {# item matching \"{searchQuery}\"} other {# items matching \"{searchQuery}\"}}",
12311
- values: {
12312
- 0: filteredItems.length,
12313
- searchQuery
12314
- }
12315
- }) : i18n._({
12316
- id: "Ufffi8",
12317
- message: "{0, plural, one {#{1} item} other {#{2} items}}",
12318
- values: {
12319
- 0: filteredItems.length,
12320
- 1: hasMore ? "+" : "",
12321
- 2: hasMore ? "+" : ""
12322
- }
12400
+ children: renderItemCount({
12401
+ searchQuery,
12402
+ filteredCount: filteredItems.length,
12403
+ total,
12404
+ hasMore
12323
12405
  })
12324
12406
  }), /* @__PURE__ */ jsxs("div", {
12325
12407
  className: "flex items-center gap-2",
@@ -12327,8 +12409,8 @@ function ContentList({ collection, collectionLabel, items, trashedItems = [], is
12327
12409
  /* @__PURE__ */ jsx(Button, {
12328
12410
  variant: "outline",
12329
12411
  shape: "square",
12330
- disabled: page === 0,
12331
- onClick: () => setPage(page - 1),
12412
+ disabled: clampedPage === 0,
12413
+ onClick: () => setPage(clampedPage - 1),
12332
12414
  "aria-label": _t({
12333
12415
  id: "aHKcKc",
12334
12416
  message: "Previous page"
@@ -12341,7 +12423,7 @@ function ContentList({ collection, collectionLabel, items, trashedItems = [], is
12341
12423
  /* @__PURE__ */ jsxs("span", {
12342
12424
  className: "text-sm",
12343
12425
  children: [
12344
- page + 1,
12426
+ clampedPage + 1,
12345
12427
  " / ",
12346
12428
  totalPages
12347
12429
  ]
@@ -12349,8 +12431,8 @@ function ContentList({ collection, collectionLabel, items, trashedItems = [], is
12349
12431
  /* @__PURE__ */ jsx(Button, {
12350
12432
  variant: "outline",
12351
12433
  shape: "square",
12352
- disabled: page >= totalPages - 1,
12353
- onClick: () => setPage(page + 1),
12434
+ disabled: clampedPage >= totalPages - 1,
12435
+ onClick: () => setPage(clampedPage + 1),
12354
12436
  "aria-label": _t({
12355
12437
  id: "/dqduX",
12356
12438
  message: "Next page"
@@ -12500,6 +12582,38 @@ function SortableTh({ field, sort, onSortChange, label }) {
12500
12582
  })
12501
12583
  });
12502
12584
  }
12585
+ /**
12586
+ * Render the row-count line above pagination. The rules are:
12587
+ * - A search query always wins — say how many matches there are.
12588
+ * - When the server reported a total, use it (no `+` suffix needed —
12589
+ * we know the count).
12590
+ * - Otherwise fall back to the pre-refactor behavior: loaded count,
12591
+ * with `+` when there are more pages the user hasn't fetched yet.
12592
+ */
12593
+ function renderItemCount({ searchQuery, filteredCount, total, hasMore }) {
12594
+ if (searchQuery) return i18n._({
12595
+ id: "WqhW35",
12596
+ message: "{filteredCount, plural, one {# item matching \"{searchQuery}\"} other {# items matching \"{searchQuery}\"}}",
12597
+ values: {
12598
+ filteredCount,
12599
+ searchQuery
12600
+ }
12601
+ });
12602
+ if (typeof total === "number") return i18n._({
12603
+ id: "Q/6//K",
12604
+ message: "{total, plural, one {# item} other {# items}}",
12605
+ values: { total }
12606
+ });
12607
+ return i18n._({
12608
+ id: "FWXiie",
12609
+ message: "{filteredCount, plural, one {#{0} item} other {#{1} items}}",
12610
+ values: {
12611
+ 0: hasMore ? "+" : "",
12612
+ 1: hasMore ? "+" : "",
12613
+ filteredCount
12614
+ }
12615
+ });
12616
+ }
12503
12617
  function ContentListItem({ item, collection, onDelete, onDuplicate, showLocale, urlPattern }) {
12504
12618
  const { _: _t2 } = useLingui();
12505
12619
  const title = getItemTitle$1(item);
@@ -17828,6 +17942,7 @@ function PluginCard({ plugin, updateInfo, onEnable, onDisable, isToggling, hasMa
17828
17942
  const queryClient = useQueryClient();
17829
17943
  const toastManager = Toast.useToastManager();
17830
17944
  const isMarketplace = plugin.source === "marketplace";
17945
+ const isRegistry = plugin.source === "registry";
17831
17946
  const hasUpdate = !!updateInfo && updateInfo.installed !== updateInfo.latest;
17832
17947
  const updateMutation = useMutation({
17833
17948
  mutationFn: () => updateMarketplacePlugin(plugin.id, { confirmCapabilities: true }),
@@ -18159,6 +18274,13 @@ function PluginCard({ plugin, updateInfo, onEnable, onDisable, isToggling, hasMa
18159
18274
  message: "Uninstall"
18160
18275
  })
18161
18276
  })
18277
+ }),
18278
+ isRegistry && /* @__PURE__ */ jsx("div", {
18279
+ className: "pt-2 border-t text-xs text-kumo-subtle",
18280
+ children: _t2({
18281
+ id: "YvXVtr",
18282
+ message: "Uninstall is not yet available for registry plugins. Disable the plugin to stop it from running; full uninstall will land in a follow-up."
18283
+ })
18162
18284
  })
18163
18285
  ]
18164
18286
  })]
@@ -18307,7 +18429,7 @@ function MarketplacePluginDetail({ pluginId, installedPluginIds = /* @__PURE__ *
18307
18429
  const isInstalled = installedPluginIds.has(pluginId);
18308
18430
  if (isLoading) return /* @__PURE__ */ jsxs("div", {
18309
18431
  className: "space-y-6",
18310
- children: [/* @__PURE__ */ jsx(BackLink, {}), /* @__PURE__ */ jsxs("div", {
18432
+ children: [/* @__PURE__ */ jsx(BackLink$1, {}), /* @__PURE__ */ jsxs("div", {
18311
18433
  className: "animate-pulse space-y-4",
18312
18434
  children: [
18313
18435
  /* @__PURE__ */ jsxs("div", {
@@ -18325,7 +18447,7 @@ function MarketplacePluginDetail({ pluginId, installedPluginIds = /* @__PURE__ *
18325
18447
  });
18326
18448
  if (error || !plugin) return /* @__PURE__ */ jsxs("div", {
18327
18449
  className: "space-y-6",
18328
- children: [/* @__PURE__ */ jsx(BackLink, {}), /* @__PURE__ */ jsxs("div", {
18450
+ children: [/* @__PURE__ */ jsx(BackLink$1, {}), /* @__PURE__ */ jsxs("div", {
18329
18451
  className: "rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-6 text-center",
18330
18452
  children: [
18331
18453
  /* @__PURE__ */ jsx(Warning, { className: "mx-auto h-8 w-8 text-kumo-danger" }),
@@ -18363,7 +18485,7 @@ function MarketplacePluginDetail({ pluginId, installedPluginIds = /* @__PURE__ *
18363
18485
  return /* @__PURE__ */ jsxs("div", {
18364
18486
  className: "space-y-6",
18365
18487
  children: [
18366
- /* @__PURE__ */ jsx(BackLink, {}),
18488
+ /* @__PURE__ */ jsx(BackLink$1, {}),
18367
18489
  /* @__PURE__ */ jsxs("div", {
18368
18490
  className: "flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between",
18369
18491
  children: [/* @__PURE__ */ jsxs("div", {
@@ -18642,7 +18764,7 @@ function MarketplacePluginDetail({ pluginId, installedPluginIds = /* @__PURE__ *
18642
18764
  ]
18643
18765
  });
18644
18766
  }
18645
- function BackLink() {
18767
+ function BackLink$1() {
18646
18768
  const { _: _t2 } = useLingui();
18647
18769
  return /* @__PURE__ */ jsxs(Link$1, {
18648
18770
  to: "/plugins/marketplace",
@@ -18989,7 +19111,7 @@ function MediaDetailPanel({ item, onClose, onDeleted }) {
18989
19111
  message: "Uploaded:"
18990
19112
  })
18991
19113
  }),
18992
- /* @__PURE__ */ jsx("span", { children: formatDate(item.createdAt) })
19114
+ /* @__PURE__ */ jsx("span", { children: formatDate$1(item.createdAt) })
18993
19115
  ]
18994
19116
  })
18995
19117
  ]
@@ -19107,7 +19229,7 @@ function MediaDetailPanel({ item, onClose, onDeleted }) {
19107
19229
  onConfirm: () => deleteMutation.mutate()
19108
19230
  })] });
19109
19231
  }
19110
- function formatDate(isoString) {
19232
+ function formatDate$1(isoString) {
19111
19233
  return new Date(isoString).toLocaleDateString(void 0, {
19112
19234
  year: "numeric",
19113
19235
  month: "short",
@@ -20304,7 +20426,7 @@ function MenuEditor() {
20304
20426
  newItems[targetIndex] = currentItem;
20305
20427
  const reorderedItems = newItems.map((item, i) => ({
20306
20428
  id: item.id,
20307
- parentId: item.parent_id,
20429
+ parentId: item.parentId,
20308
20430
  sortOrder: i
20309
20431
  }));
20310
20432
  setLocalItems(newItems);
@@ -20583,9 +20705,9 @@ function MenuEditor() {
20583
20705
  children: item.label
20584
20706
  }), /* @__PURE__ */ jsxs("div", {
20585
20707
  className: "text-sm text-kumo-subtle",
20586
- children: [item.type === "custom" ? item.custom_url : /* @__PURE__ */ jsx("span", {
20708
+ children: [item.type === "custom" ? item.customUrl : /* @__PURE__ */ jsx("span", {
20587
20709
  className: "inline-flex items-center rounded-full bg-kumo-brand/10 px-2 py-0.5 text-xs font-medium text-kumo-brand",
20588
- children: item.reference_collection ?? item.type
20710
+ children: item.referenceCollection ?? item.type
20589
20711
  }), item.target === "_blank" && _t({
20590
20712
  id: "+Lmv8u",
20591
20713
  message: " (opens in new window)"
@@ -20707,7 +20829,7 @@ function MenuEditor() {
20707
20829
  id: "TQJouj",
20708
20830
  message: "Enter a URL (https://…) or a relative path (/…)"
20709
20831
  }),
20710
- defaultValue: editingItem.custom_url || ""
20832
+ defaultValue: editingItem.customUrl || ""
20711
20833
  }),
20712
20834
  /* @__PURE__ */ jsxs(Select, {
20713
20835
  label: _t({
@@ -21829,6 +21951,957 @@ function Redirects() {
21829
21951
  });
21830
21952
  }
21831
21953
 
21954
+ //#endregion
21955
+ //#region src/lib/api/registry.ts
21956
+ let cachedDiscovery = null;
21957
+ async function getDiscoveryClient(config) {
21958
+ if (cachedDiscovery && cachedDiscovery.config.aggregatorUrl === config.aggregatorUrl && cachedDiscovery.config.acceptLabelers === config.acceptLabelers) return cachedDiscovery.client;
21959
+ const DiscoveryClient = (await import("@emdash-cms/registry-client/discovery")).DiscoveryClient;
21960
+ const discovery = new DiscoveryClient({
21961
+ aggregatorUrl: config.aggregatorUrl,
21962
+ acceptLabelers: config.acceptLabelers
21963
+ });
21964
+ const wrapped = {
21965
+ async searchPackages(opts) {
21966
+ return discovery.searchPackages({
21967
+ q: opts.q,
21968
+ cursor: opts.cursor,
21969
+ limit: opts.limit
21970
+ });
21971
+ },
21972
+ async resolvePackage(handle, slug) {
21973
+ return discovery.resolvePackage({
21974
+ handle,
21975
+ slug
21976
+ });
21977
+ },
21978
+ async getPackage(did, slug) {
21979
+ return discovery.getPackage({
21980
+ did,
21981
+ slug
21982
+ });
21983
+ },
21984
+ async getLatestRelease(did, slug) {
21985
+ return discovery.getLatestRelease({
21986
+ did,
21987
+ package: slug
21988
+ });
21989
+ },
21990
+ async listReleases(did, slug, cursor) {
21991
+ return discovery.listReleases({
21992
+ did,
21993
+ package: slug,
21994
+ cursor
21995
+ });
21996
+ }
21997
+ };
21998
+ cachedDiscovery = {
21999
+ config,
22000
+ client: wrapped
22001
+ };
22002
+ return wrapped;
22003
+ }
22004
+ /**
22005
+ * Returns whether a release should be considered installable given the
22006
+ * configured policy. Currently implements the minimum-release-age check
22007
+ * described in RFC 0001's "Pre-label gap and launch tempo" section,
22008
+ * plus the `minimumReleaseAgeExclude` allowlist.
22009
+ *
22010
+ * Returns `false` (release blocked) when the policy is configured but
22011
+ * the release is missing a valid `indexedAt` -- we fail closed rather
22012
+ * than silently letting unbounded-age releases through.
22013
+ */
22014
+ function releasePassesPolicy(release, pkg, policy, now = Date.now()) {
22015
+ if (!policy?.minimumReleaseAgeSeconds) return true;
22016
+ if (releaseExemptFromMinimumAge(policy.minimumReleaseAgeExclude, pkg.did, pkg.slug)) return true;
22017
+ const indexedAt = Date.parse(release.indexedAt);
22018
+ if (!Number.isFinite(indexedAt)) return false;
22019
+ return (now - indexedAt) / 1e3 >= policy.minimumReleaseAgeSeconds;
22020
+ }
22021
+ /**
22022
+ * Canonicalize a capabilities list for set-style comparison. Mirrors
22023
+ * the server-side helper `canonicalCapabilitiesForDriftCheck` in
22024
+ * `packages/core/src/registry/config.ts` -- both sides must produce
22025
+ * the same canonical shape so the install handler's drift check is
22026
+ * stable across reorderings, duplicates, and junk entries.
22027
+ *
22028
+ * Filters non-strings, deduplicates, and sorts lexically.
22029
+ */
22030
+ function canonicalCapabilitiesForDriftCheck(value) {
22031
+ if (!Array.isArray(value)) return [];
22032
+ const seen = /* @__PURE__ */ new Set();
22033
+ for (const entry of value) if (typeof entry === "string" && entry.length > 0) seen.add(entry);
22034
+ return [...seen].toSorted();
22035
+ }
22036
+ /**
22037
+ * Matches a `(publisher_did, slug)` against the
22038
+ * `minimumReleaseAgeExclude` allowlist. Mirrors the server-side helper
22039
+ * of the same name in `packages/core/src/registry/config.ts`.
22040
+ *
22041
+ * DID-only on purpose: handles are aggregator-supplied envelope data
22042
+ * and accepting them as a trust input would let a compromised
22043
+ * aggregator bypass the holdback by claiming any handle for any
22044
+ * package. DIDs are tied to the AT URI of the record itself.
22045
+ *
22046
+ * Entries from the config list have already been lowercased at
22047
+ * manifest build time, so this only needs to lowercase the runtime
22048
+ * values for comparison.
22049
+ */
22050
+ function releaseExemptFromMinimumAge(exclude, publisherDid, slug) {
22051
+ if (!exclude || exclude.length === 0) return false;
22052
+ const didLower = publisherDid.toLowerCase();
22053
+ const fullDid = `${didLower}/${slug.toLowerCase()}`;
22054
+ for (const entry of exclude) {
22055
+ if (entry === didLower) return true;
22056
+ if (entry === fullDid) return true;
22057
+ }
22058
+ return false;
22059
+ }
22060
+ async function searchRegistryPackages(config, opts) {
22061
+ return (await getDiscoveryClient(config)).searchPackages(opts);
22062
+ }
22063
+ async function resolveRegistryPackage(config, handle, slug) {
22064
+ return (await getDiscoveryClient(config)).resolvePackage(handle, slug);
22065
+ }
22066
+ async function getRegistryPackage(config, did, slug) {
22067
+ return (await getDiscoveryClient(config)).getPackage(did, slug);
22068
+ }
22069
+ async function getLatestRegistryRelease(config, did, slug) {
22070
+ return (await getDiscoveryClient(config)).getLatestRelease(did, slug);
22071
+ }
22072
+ /**
22073
+ * Resolve a publisher DID to its claimed handle using the same
22074
+ * `LocalActorResolver` pattern as `@emdash-cms/plugin-cli` and
22075
+ * `@emdash-cms/auth-atproto`. Bidirectional verification (handle's
22076
+ * domain points back to the same DID) is part of the resolver --
22077
+ * `LocalActorResolver` returns the sentinel `"handle.invalid"` when
22078
+ * the `alsoKnownAs` handle is present but doesn't round-trip.
22079
+ *
22080
+ * Three distinct outcomes the UI can render:
22081
+ *
22082
+ * - `{ status: "ok", handle }` — verified handle, round-trip OK.
22083
+ * - `{ status: "invalid" }` — DID claims a handle but it doesn't
22084
+ * resolve back. The publisher's handle setup is broken; the admin
22085
+ * should see a clear "Invalid handle" indicator rather than the
22086
+ * raw DID.
22087
+ * - `{ status: "missing" }` — no handle claimed at all (no
22088
+ * `alsoKnownAs`), or the DID document couldn't be fetched (network
22089
+ * error, unsupported DID method).
22090
+ */
22091
+ let actorResolver = null;
22092
+ async function getActorResolver() {
22093
+ if (actorResolver) return actorResolver;
22094
+ const { CompositeDidDocumentResolver, CompositeHandleResolver, DohJsonHandleResolver, LocalActorResolver, PlcDidDocumentResolver, WebDidDocumentResolver, WellKnownHandleResolver } = await import("@atcute/identity-resolver");
22095
+ actorResolver = new LocalActorResolver({
22096
+ handleResolver: new CompositeHandleResolver({ methods: {
22097
+ dns: new DohJsonHandleResolver({ dohUrl: "https://cloudflare-dns.com/dns-query" }),
22098
+ http: new WellKnownHandleResolver()
22099
+ } }),
22100
+ didDocumentResolver: new CompositeDidDocumentResolver({ methods: {
22101
+ plc: new PlcDidDocumentResolver(),
22102
+ web: new WebDidDocumentResolver()
22103
+ } })
22104
+ });
22105
+ return actorResolver;
22106
+ }
22107
+ /**
22108
+ * localStorage-backed cache for DID→handle resolutions. Handles are
22109
+ * stable for hours-to-days in practice, but bound the cache so a
22110
+ * compromised handle eventually flips back to "invalid" without a
22111
+ * forced refresh. 24h matches the typical atproto handle TTL.
22112
+ *
22113
+ * Failures (network errors, unsupported DID method) are *not* cached --
22114
+ * those should retry on the next render.
22115
+ */
22116
+ const HANDLE_CACHE_TTL_MS = 1440 * 60 * 1e3;
22117
+ const HANDLE_CACHE_KEY_PREFIX = "emdash:did-handle:";
22118
+ function readHandleCache(did) {
22119
+ if (typeof localStorage === "undefined") return null;
22120
+ try {
22121
+ const raw = localStorage.getItem(`${HANDLE_CACHE_KEY_PREFIX}${did}`);
22122
+ if (!raw) return null;
22123
+ const parsed = JSON.parse(raw);
22124
+ if (!parsed || typeof parsed.expiresAt !== "number" || parsed.expiresAt < Date.now()) return null;
22125
+ return parsed.resolution;
22126
+ } catch {
22127
+ return null;
22128
+ }
22129
+ }
22130
+ function writeHandleCache(did, resolution) {
22131
+ if (typeof localStorage === "undefined") return;
22132
+ try {
22133
+ const entry = {
22134
+ resolution,
22135
+ expiresAt: Date.now() + HANDLE_CACHE_TTL_MS
22136
+ };
22137
+ localStorage.setItem(`${HANDLE_CACHE_KEY_PREFIX}${did}`, JSON.stringify(entry));
22138
+ } catch {}
22139
+ }
22140
+ async function resolveDidToHandle(did) {
22141
+ const cached = readHandleCache(did);
22142
+ if (cached) return cached;
22143
+ let result;
22144
+ try {
22145
+ const resolved = await (await getActorResolver()).resolve(did);
22146
+ if (resolved.handle === "handle.invalid") result = { status: "invalid" };
22147
+ else if (resolved.handle) result = {
22148
+ status: "ok",
22149
+ handle: resolved.handle
22150
+ };
22151
+ else result = { status: "missing" };
22152
+ } catch (err) {
22153
+ console.warn(`[registry] DID->handle resolution failed for ${did}:`, err);
22154
+ return { status: "missing" };
22155
+ }
22156
+ writeHandleCache(did, result);
22157
+ return result;
22158
+ }
22159
+ const INSTALL_ENDPOINT = `${API_BASE}/admin/plugins/registry/install`;
22160
+ /**
22161
+ * Install a plugin from the registry.
22162
+ *
22163
+ * Posts to the EmDash server, which re-resolves the same `(handle,
22164
+ * slug)` against the aggregator, re-verifies the bundle's checksum
22165
+ * against the signed release record, and writes the install. Surfaces
22166
+ * structured error codes (`RELEASE_YANKED`, `CHECKSUM_MISMATCH`,
22167
+ * `DECLARED_ACCESS_DRIFT`, etc.) that callers map to localized
22168
+ * messages.
22169
+ */
22170
+ async function installRegistryPlugin(body) {
22171
+ const response = await apiFetch(INSTALL_ENDPOINT, {
22172
+ method: "POST",
22173
+ headers: { "Content-Type": "application/json" },
22174
+ body: JSON.stringify(body)
22175
+ });
22176
+ if (!response.ok) await throwResponseError(response, i18n._({
22177
+ id: "/LWKyx",
22178
+ message: "Failed to install plugin"
22179
+ }));
22180
+ return (await response.json()).data;
22181
+ }
22182
+
22183
+ //#endregion
22184
+ //#region src/components/PublisherHandle.tsx
22185
+ /**
22186
+ * Renders an atproto publisher's identity, with three branches:
22187
+ *
22188
+ * - **Verified handle**: shows `@handle`. Our local
22189
+ * `LocalActorResolver` round-tripped the DID document's
22190
+ * `alsoKnownAs` back to the same DID (verified by DNS TXT or
22191
+ * `.well-known`, not by the aggregator).
22192
+ * - **Unverified publisher**: DID document claims a handle but the
22193
+ * handle's domain doesn't point back to the same DID, OR the
22194
+ * aggregator's claimed handle doesn't match the bidirectionally
22195
+ * verified one. Treat as untrusted -- the publisher might be
22196
+ * impersonating someone else, or the aggregator might be lying
22197
+ * about a handle. Surface as `Unverified publisher` in error
22198
+ * styling. Callers should also disable destructive actions
22199
+ * (install, etc.).
22200
+ * - **Missing handle**: no handle claimed in the DID document (no
22201
+ * `alsoKnownAs`), or the DID document couldn't be fetched
22202
+ * (network error, unsupported DID method).
22203
+ *
22204
+ * `aggregatorHandle` is what the registry's `searchPackages` /
22205
+ * `resolvePackage` endpoint returned for this DID. It is NEVER trusted
22206
+ * on its own -- the aggregator is an untrusted indexer that could be
22207
+ * compromised or buggy. We always run our own DID->handle round-trip
22208
+ * via `LocalActorResolver` (cached in localStorage for 24h) and use
22209
+ * the aggregator's value only to *cross-check*: if the aggregator
22210
+ * claims a handle that differs from what the DID document
22211
+ * bidirectionally verifies, the publisher is marked invalid.
22212
+ */
22213
+ /** Trailing dot(s) on an FQDN, stripped before handle comparison. */
22214
+ const TRAILING_DOT = /\.+$/;
22215
+ /**
22216
+ * Hook form: returns the same tri-state result without rendering. Use
22217
+ * when a parent needs to coordinate UI (e.g. disable install) based on
22218
+ * the resolution.
22219
+ */
22220
+ function usePublisherHandle(did, aggregatorHandle) {
22221
+ const { data: didHandleResolution, isPending } = useQuery({
22222
+ queryKey: [
22223
+ "registry",
22224
+ "did-handle",
22225
+ did
22226
+ ],
22227
+ queryFn: () => resolveDidToHandle(did),
22228
+ enabled: Boolean(did),
22229
+ staleTime: 300 * 1e3
22230
+ });
22231
+ if (isPending || !didHandleResolution) return { status: "missing" };
22232
+ if (didHandleResolution.status === "missing") return { status: "missing" };
22233
+ if (didHandleResolution.status === "invalid") return { status: "invalid" };
22234
+ const verifiedHandle = didHandleResolution.handle.toLowerCase();
22235
+ if (aggregatorHandle) {
22236
+ if (aggregatorHandle.toLowerCase().replace(TRAILING_DOT, "") !== verifiedHandle) return { status: "invalid" };
22237
+ }
22238
+ return {
22239
+ status: "ok",
22240
+ handle: didHandleResolution.handle
22241
+ };
22242
+ }
22243
+ function PublisherHandle({ did, aggregatorHandle, onResolved, variant = "card", className }) {
22244
+ const { _: _t } = useLingui();
22245
+ const result = usePublisherHandle(did, aggregatorHandle);
22246
+ const onResolvedRef = React$1.useRef(onResolved);
22247
+ onResolvedRef.current = onResolved;
22248
+ React$1.useEffect(() => {
22249
+ onResolvedRef.current?.(result);
22250
+ }, [result.status, result.handle]);
22251
+ const textClass = variant === "card" ? "text-xs" : "text-sm";
22252
+ if (result.status === "ok" && result.handle) return /* @__PURE__ */ jsxs("span", {
22253
+ className: `truncate ${textClass} text-kumo-subtle ${className ?? ""}`,
22254
+ children: ["@", result.handle]
22255
+ });
22256
+ if (result.status === "invalid") return /* @__PURE__ */ jsx("span", {
22257
+ className: `truncate ${textClass} font-medium text-kumo-error ${className ?? ""}`,
22258
+ children: _t({
22259
+ id: "N3r0DC",
22260
+ message: "Unverified publisher"
22261
+ })
22262
+ });
22263
+ return /* @__PURE__ */ jsx("span", {
22264
+ className: `truncate ${textClass} text-kumo-subtle ${className ?? ""}`,
22265
+ children: did
22266
+ });
22267
+ }
22268
+
22269
+ //#endregion
22270
+ //#region src/components/RegistryBrowse.tsx
22271
+ /**
22272
+ * Registry Browse
22273
+ *
22274
+ * Grid of plugin cards backed by the experimental decentralized plugin
22275
+ * registry's aggregator. Search box debounces directly into the
22276
+ * aggregator's `searchPackages` XRPC -- the aggregator is a public,
22277
+ * read-only service, so no server proxy is involved.
22278
+ *
22279
+ * Cards navigate to `/plugins/marketplace/$pluginId` (the same path the
22280
+ * marketplace browse uses); the router branches to the registry detail
22281
+ * component when `manifest.registry` is configured.
22282
+ */
22283
+ function RegistryBrowse({ config, installedRegistryUris = /* @__PURE__ */ new Set() }) {
22284
+ const { _: _t } = useLingui();
22285
+ const [searchQuery, setSearchQuery] = React$1.useState("");
22286
+ const [debouncedQuery, setDebouncedQuery] = React$1.useState("");
22287
+ React$1.useEffect(() => {
22288
+ const timer = setTimeout(setDebouncedQuery, 300, searchQuery);
22289
+ return () => clearTimeout(timer);
22290
+ }, [searchQuery]);
22291
+ const { data, isLoading, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
22292
+ queryKey: [
22293
+ "registry",
22294
+ "search",
22295
+ config.aggregatorUrl,
22296
+ debouncedQuery
22297
+ ],
22298
+ queryFn: ({ pageParam }) => searchRegistryPackages(config, {
22299
+ q: debouncedQuery || void 0,
22300
+ cursor: pageParam,
22301
+ limit: 20
22302
+ }),
22303
+ initialPageParam: void 0,
22304
+ getNextPageParam: (lastPage) => lastPage.cursor
22305
+ });
22306
+ const packages = data?.pages.flatMap((p) => p.packages);
22307
+ return /* @__PURE__ */ jsxs("div", {
22308
+ className: "space-y-6",
22309
+ children: [
22310
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h1", {
22311
+ className: "text-3xl font-bold",
22312
+ children: _t({
22313
+ id: "jNlCXv",
22314
+ message: "Plugin Registry"
22315
+ })
22316
+ }), /* @__PURE__ */ jsx("p", {
22317
+ className: "mt-1 text-kumo-subtle",
22318
+ children: _t({
22319
+ id: "ceMA7u",
22320
+ message: "Browse and install plugins published to the decentralized registry."
22321
+ })
22322
+ })] }),
22323
+ /* @__PURE__ */ jsx("div", {
22324
+ className: "flex flex-col gap-3 sm:flex-row sm:items-center",
22325
+ children: /* @__PURE__ */ jsxs("div", {
22326
+ className: "relative flex-1",
22327
+ children: [/* @__PURE__ */ jsx(MagnifyingGlass, { className: "absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-kumo-subtle" }), /* @__PURE__ */ jsx(Input, {
22328
+ type: "search",
22329
+ placeholder: _t({
22330
+ id: "slq1pb",
22331
+ message: "Search plugins..."
22332
+ }),
22333
+ value: searchQuery,
22334
+ onChange: (e) => setSearchQuery(e.target.value),
22335
+ className: "ps-9",
22336
+ "aria-label": _t({
22337
+ id: "S4qZX0",
22338
+ message: "Search plugins"
22339
+ })
22340
+ })]
22341
+ })
22342
+ }),
22343
+ error ? /* @__PURE__ */ jsx("div", {
22344
+ className: "rounded-md border border-kumo-error bg-kumo-error/10 p-4 text-kumo-error",
22345
+ role: "alert",
22346
+ children: _t({
22347
+ id: "obuqKa",
22348
+ message: "Failed to load plugins. The registry aggregator may be unreachable."
22349
+ })
22350
+ }) : null,
22351
+ isLoading ? /* @__PURE__ */ jsx("div", {
22352
+ className: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3",
22353
+ children: Array.from({ length: 6 }).map((_, i) => /* @__PURE__ */ jsx("div", { className: "h-32 animate-pulse rounded-md border border-kumo-border bg-kumo-subtle" }, i))
22354
+ }) : null,
22355
+ packages && packages.length === 0 ? /* @__PURE__ */ jsx("div", {
22356
+ className: "rounded-md border border-kumo-border bg-kumo-subtle p-8 text-center text-kumo-subtle",
22357
+ children: debouncedQuery ? _t({
22358
+ id: "5ZLwXV",
22359
+ message: "No plugins match \"{debouncedQuery}\".",
22360
+ values: { debouncedQuery }
22361
+ }) : _t({
22362
+ id: "+ugpTI",
22363
+ message: "No plugins have been published to this registry yet."
22364
+ })
22365
+ }) : null,
22366
+ packages && packages.length > 0 ? /* @__PURE__ */ jsx("div", {
22367
+ className: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3",
22368
+ children: packages.map((pkg) => /* @__PURE__ */ jsx(RegistryPackageCard, {
22369
+ pkg,
22370
+ installed: installedRegistryUris.has(pkg.uri)
22371
+ }, pkg.uri))
22372
+ }) : null,
22373
+ hasNextPage ? /* @__PURE__ */ jsx("div", {
22374
+ className: "flex justify-center",
22375
+ children: /* @__PURE__ */ jsx("button", {
22376
+ type: "button",
22377
+ className: "rounded-md border border-kumo-border bg-kumo-surface px-4 py-2 text-sm hover:bg-kumo-subtle disabled:opacity-50",
22378
+ onClick: () => fetchNextPage(),
22379
+ disabled: isFetchingNextPage,
22380
+ children: isFetchingNextPage ? _t({
22381
+ id: "Z3FXyt",
22382
+ message: "Loading..."
22383
+ }) : _t({
22384
+ id: "yQ2kGp",
22385
+ message: "Load more"
22386
+ })
22387
+ })
22388
+ }) : null
22389
+ ]
22390
+ });
22391
+ }
22392
+ function RegistryPackageCard({ pkg, installed }) {
22393
+ const { _: _t2 } = useLingui();
22394
+ const linkSegment = usePublisherHandle(pkg.did, pkg.handle).handle ?? pkg.did;
22395
+ const name = pkg.profile?.name;
22396
+ const description = pkg.profile?.description;
22397
+ const license = pkg.profile?.license;
22398
+ const verified = (pkg.labels ?? []).some((l) => l.val === "verified");
22399
+ return /* @__PURE__ */ jsx(Link$1, {
22400
+ to: "/plugins/marketplace/$pluginId",
22401
+ params: { pluginId: `${linkSegment}/${pkg.slug}` },
22402
+ className: "block rounded-md border border-kumo-border bg-kumo-surface p-4 transition-colors hover:bg-kumo-subtle focus:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand",
22403
+ children: /* @__PURE__ */ jsxs("div", {
22404
+ className: "flex items-start gap-3",
22405
+ children: [/* @__PURE__ */ jsx("div", {
22406
+ className: "mt-1 rounded-md bg-kumo-subtle p-2 text-kumo-subtle",
22407
+ children: /* @__PURE__ */ jsx(PuzzlePiece, { className: "h-5 w-5" })
22408
+ }), /* @__PURE__ */ jsxs("div", {
22409
+ className: "min-w-0 flex-1",
22410
+ children: [
22411
+ /* @__PURE__ */ jsxs("div", {
22412
+ className: "flex items-center gap-2",
22413
+ children: [/* @__PURE__ */ jsx("h2", {
22414
+ className: "truncate font-semibold",
22415
+ children: name ?? pkg.slug
22416
+ }), verified ? /* @__PURE__ */ jsx(ShieldCheck, {
22417
+ className: "h-4 w-4 shrink-0 text-kumo-brand",
22418
+ "aria-label": _t2({
22419
+ id: "70QfpA",
22420
+ message: "Verified publisher"
22421
+ })
22422
+ }) : null]
22423
+ }),
22424
+ /* @__PURE__ */ jsx(PublisherHandle, {
22425
+ did: pkg.did,
22426
+ aggregatorHandle: pkg.handle,
22427
+ variant: "card"
22428
+ }),
22429
+ description ? /* @__PURE__ */ jsx("p", {
22430
+ className: "mt-2 line-clamp-2 text-sm text-kumo-default",
22431
+ children: description
22432
+ }) : null,
22433
+ license ? /* @__PURE__ */ jsx("p", {
22434
+ className: "mt-2 text-xs text-kumo-subtle",
22435
+ children: license
22436
+ }) : null,
22437
+ installed ? /* @__PURE__ */ jsx("div", {
22438
+ className: "mt-3",
22439
+ children: /* @__PURE__ */ jsx(Badge, {
22440
+ variant: "success",
22441
+ children: _t2({
22442
+ id: "eQkgKV",
22443
+ message: "Installed"
22444
+ })
22445
+ })
22446
+ }) : null
22447
+ ]
22448
+ })]
22449
+ })
22450
+ });
22451
+ }
22452
+
22453
+ //#endregion
22454
+ //#region src/components/RegistryPluginDetail.tsx
22455
+ /**
22456
+ * Registry Plugin Detail
22457
+ *
22458
+ * Detail view for a plugin from the experimental decentralized plugin
22459
+ * registry. Resolves `(handle, slug)` directly against the configured
22460
+ * aggregator; install routes through the EmDash server's
22461
+ * `/_emdash/api/admin/plugins/registry/install` endpoint, which
22462
+ * re-resolves and re-verifies before writing the install.
22463
+ *
22464
+ * Identified in the URL by a `pluginId` that is `${handle}/${slug}`.
22465
+ * The router wraps this component when `manifest.registry` is set on
22466
+ * the same route the marketplace detail uses, so existing bookmarks /
22467
+ * sidebar entries stay stable.
22468
+ */
22469
+ function RegistryPluginDetail({ pluginId, config }) {
22470
+ const { _: _t } = useLingui();
22471
+ const queryClient = useQueryClient();
22472
+ const [showConsent, setShowConsent] = React$1.useState(false);
22473
+ const { data: installedPlugins } = useQuery({
22474
+ queryKey: ["plugins"],
22475
+ queryFn: async () => {
22476
+ const { fetchPlugins } = await import("./plugins-qrjvPK4g.js").then((n) => n.a);
22477
+ return fetchPlugins();
22478
+ }
22479
+ });
22480
+ const slashIdx = pluginId.lastIndexOf("/");
22481
+ const publisher = slashIdx > 0 ? pluginId.slice(0, slashIdx) : "";
22482
+ const slug = slashIdx > 0 ? pluginId.slice(slashIdx + 1) : "";
22483
+ const isDid = publisher.startsWith("did:");
22484
+ const { data: pkg, isLoading: isLoadingPkg } = useQuery({
22485
+ queryKey: [
22486
+ "registry",
22487
+ "package",
22488
+ config.aggregatorUrl,
22489
+ publisher,
22490
+ slug,
22491
+ isDid
22492
+ ],
22493
+ queryFn: () => isDid ? getRegistryPackage(config, publisher, slug) : resolveRegistryPackage(config, publisher, slug),
22494
+ enabled: Boolean(publisher && slug)
22495
+ });
22496
+ const handleResult = usePublisherHandle(pkg?.did ?? "", pkg?.handle);
22497
+ const { data: release } = useQuery({
22498
+ queryKey: [
22499
+ "registry",
22500
+ "latest-release",
22501
+ config.aggregatorUrl,
22502
+ pkg?.did,
22503
+ slug
22504
+ ],
22505
+ queryFn: () => getLatestRegistryRelease(config, pkg.did, slug),
22506
+ enabled: Boolean(pkg?.did && slug)
22507
+ });
22508
+ const ext = (release?.release?.extensions)?.["com.emdashcms.experimental.package.releaseExtension"];
22509
+ const capabilities = Array.isArray(ext?.capabilities) ? canonicalCapabilitiesForDriftCheck(ext?.capabilities) : canonicalCapabilitiesForDriftCheck(declaredAccessToCapabilityList(ext?.declaredAccess));
22510
+ const pkgProfile = pkg?.profile ?? null;
22511
+ const displayName = pkgProfile?.name;
22512
+ const description = pkgProfile?.description;
22513
+ const licenseText = pkgProfile?.license;
22514
+ const keywordList = pkgProfile?.keywords ?? [];
22515
+ const authorList = (pkgProfile?.authors ?? []).map((a) => ({
22516
+ name: a.name,
22517
+ url: safeExternalHref(a.url),
22518
+ email: safeEmail(a.email)
22519
+ }));
22520
+ const securityList = (pkgProfile?.security ?? []).flatMap((c) => {
22521
+ const url = safeExternalHref(c.url);
22522
+ const email = safeEmail(c.email);
22523
+ return url || email ? [{
22524
+ url,
22525
+ email
22526
+ }] : [];
22527
+ });
22528
+ const repoHref = safeExternalHref(release?.release?.repo);
22529
+ const verified = (pkg?.labels ?? []).some((l) => l.val === "verified");
22530
+ const policyOk = release && pkg ? releasePassesPolicy(release, {
22531
+ did: pkg.did,
22532
+ slug
22533
+ }, config.policy) : true;
22534
+ const installedEntry = React$1.useMemo(() => {
22535
+ if (!pkg || !installedPlugins) return void 0;
22536
+ return installedPlugins.find((p) => p.source === "registry" && p.registryPublisherDid === pkg.did && p.registrySlug === slug);
22537
+ }, [
22538
+ pkg,
22539
+ installedPlugins,
22540
+ slug
22541
+ ]);
22542
+ const isInstalled = Boolean(installedEntry);
22543
+ const installMutation = useMutation({
22544
+ mutationFn: () => {
22545
+ if (!pkg) throw new Error("Package not loaded");
22546
+ return installRegistryPlugin({
22547
+ did: pkg.did,
22548
+ slug,
22549
+ version: release?.version,
22550
+ acknowledgedDeclaredAccess: capabilities
22551
+ });
22552
+ },
22553
+ onSuccess: () => {
22554
+ setShowConsent(false);
22555
+ queryClient.invalidateQueries({ queryKey: ["plugins"] });
22556
+ queryClient.invalidateQueries({ queryKey: ["manifest"] });
22557
+ queryClient.invalidateQueries({ queryKey: ["registry"] });
22558
+ }
22559
+ });
22560
+ if (isLoadingPkg) return /* @__PURE__ */ jsxs("div", {
22561
+ className: "space-y-6",
22562
+ children: [/* @__PURE__ */ jsx(BackLink, {}), /* @__PURE__ */ jsxs("div", {
22563
+ className: "animate-pulse space-y-4",
22564
+ children: [
22565
+ /* @__PURE__ */ jsxs("div", {
22566
+ className: "flex items-center gap-4",
22567
+ children: [/* @__PURE__ */ jsx("div", { className: "h-16 w-16 rounded-xl bg-kumo-subtle" }), /* @__PURE__ */ jsxs("div", {
22568
+ className: "space-y-2",
22569
+ children: [/* @__PURE__ */ jsx("div", { className: "h-6 w-48 rounded bg-kumo-subtle" }), /* @__PURE__ */ jsx("div", { className: "h-4 w-32 rounded bg-kumo-subtle" })]
22570
+ })]
22571
+ }),
22572
+ /* @__PURE__ */ jsx("div", { className: "h-4 w-full rounded bg-kumo-subtle" }),
22573
+ /* @__PURE__ */ jsx("div", { className: "h-4 w-3/4 rounded bg-kumo-subtle" })
22574
+ ]
22575
+ })]
22576
+ });
22577
+ if (!pkg) return /* @__PURE__ */ jsxs("div", {
22578
+ className: "space-y-6",
22579
+ children: [/* @__PURE__ */ jsx(BackLink, {}), /* @__PURE__ */ jsx("div", {
22580
+ className: "rounded-md border border-kumo-error bg-kumo-error/10 p-4 text-kumo-error",
22581
+ role: "alert",
22582
+ children: _t({
22583
+ id: "wZ5IqF",
22584
+ message: "Plugin not found. The publisher handle or slug may be incorrect."
22585
+ })
22586
+ })]
22587
+ });
22588
+ return /* @__PURE__ */ jsxs("div", {
22589
+ className: "space-y-6",
22590
+ children: [
22591
+ /* @__PURE__ */ jsx(BackLink, {}),
22592
+ /* @__PURE__ */ jsxs("div", {
22593
+ className: "flex items-start gap-4",
22594
+ children: [
22595
+ /* @__PURE__ */ jsx("div", {
22596
+ className: "rounded-xl bg-kumo-subtle p-3 text-kumo-subtle",
22597
+ children: /* @__PURE__ */ jsx("span", {
22598
+ "aria-hidden": true,
22599
+ className: "block h-10 w-10"
22600
+ })
22601
+ }),
22602
+ /* @__PURE__ */ jsxs("div", {
22603
+ className: "min-w-0 flex-1",
22604
+ children: [
22605
+ /* @__PURE__ */ jsxs("div", {
22606
+ className: "flex items-center gap-2",
22607
+ children: [/* @__PURE__ */ jsx("h1", {
22608
+ className: "truncate text-3xl font-bold",
22609
+ children: displayName ?? slug
22610
+ }), verified ? /* @__PURE__ */ jsx(ShieldCheck, {
22611
+ className: "h-5 w-5 shrink-0 text-kumo-brand",
22612
+ "aria-label": _t({
22613
+ id: "70QfpA",
22614
+ message: "Verified publisher"
22615
+ })
22616
+ }) : null]
22617
+ }),
22618
+ /* @__PURE__ */ jsxs("p", {
22619
+ className: "text-sm text-kumo-subtle",
22620
+ children: [
22621
+ _t({
22622
+ id: "D3P+jw",
22623
+ message: "Published by"
22624
+ }),
22625
+ " ",
22626
+ /* @__PURE__ */ jsx(PublisherHandle, {
22627
+ did: pkg.did,
22628
+ aggregatorHandle: pkg.handle,
22629
+ variant: "detail"
22630
+ })
22631
+ ]
22632
+ }),
22633
+ release ? /* @__PURE__ */ jsxs("p", {
22634
+ className: "text-xs text-kumo-subtle",
22635
+ children: [
22636
+ _t({
22637
+ id: "YYdC3A",
22638
+ message: "Version {0}",
22639
+ values: { 0: release.version }
22640
+ }),
22641
+ " · ",
22642
+ _t({
22643
+ id: "294VT5",
22644
+ message: "indexed {0}",
22645
+ values: { 0: formatDate(release.indexedAt) }
22646
+ })
22647
+ ]
22648
+ }) : null
22649
+ ]
22650
+ }),
22651
+ /* @__PURE__ */ jsx("div", { children: isInstalled ? /* @__PURE__ */ jsx(Button, {
22652
+ variant: "secondary",
22653
+ disabled: true,
22654
+ children: _t({
22655
+ id: "eQkgKV",
22656
+ message: "Installed"
22657
+ })
22658
+ }) : /* @__PURE__ */ jsx(Button, {
22659
+ variant: "primary",
22660
+ disabled: !release || !policyOk || handleResult.status === "invalid",
22661
+ onClick: () => setShowConsent(true),
22662
+ children: _t({
22663
+ id: "TKQ7K+",
22664
+ message: "Install"
22665
+ })
22666
+ }) })
22667
+ ]
22668
+ }),
22669
+ handleResult.status === "invalid" ? /* @__PURE__ */ jsxs("div", {
22670
+ className: "flex items-start gap-3 rounded-md border border-kumo-error bg-kumo-error/10 p-4 text-kumo-error",
22671
+ role: "alert",
22672
+ children: [/* @__PURE__ */ jsx(Warning, { className: "mt-0.5 h-5 w-5 shrink-0" }), /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("p", {
22673
+ className: "font-medium",
22674
+ children: _t({
22675
+ id: "R9fBA+",
22676
+ message: "We couldn't verify this publisher's identity"
22677
+ })
22678
+ }), /* @__PURE__ */ jsx("p", {
22679
+ className: "mt-1 text-sm text-kumo-default",
22680
+ children: _t({
22681
+ id: "Iu2xLX",
22682
+ message: "This publisher claims a name they couldn't prove they own — possibly impersonating someone else. Install is disabled. If you know the publisher and trust them, ask them to fix their identity setup before retrying."
22683
+ })
22684
+ })] })]
22685
+ }) : null,
22686
+ release && !policyOk ? /* @__PURE__ */ jsxs("div", {
22687
+ className: "flex items-start gap-3 rounded-md border border-kumo-warning bg-kumo-warning/10 p-4 text-kumo-warning",
22688
+ role: "status",
22689
+ children: [/* @__PURE__ */ jsx(Warning, { className: "mt-0.5 h-5 w-5 shrink-0" }), /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("p", {
22690
+ className: "font-medium",
22691
+ children: _t({
22692
+ id: "+2GRS6",
22693
+ message: "Release is too new to install"
22694
+ })
22695
+ }), /* @__PURE__ */ jsx("p", {
22696
+ className: "mt-1 text-sm text-kumo-default",
22697
+ children: _t({
22698
+ id: "77FdWO",
22699
+ message: "Your site requires releases to be at least {0} old before they can be installed. This release will become installable later.",
22700
+ values: { 0: formatHoldback(config.policy?.minimumReleaseAgeSeconds ?? 0) }
22701
+ })
22702
+ })] })]
22703
+ }) : null,
22704
+ description ? /* @__PURE__ */ jsx("p", {
22705
+ className: "text-base text-kumo-default",
22706
+ children: description
22707
+ }) : null,
22708
+ licenseText || repoHref || keywordList.length > 0 ? /* @__PURE__ */ jsxs("section", {
22709
+ className: "flex flex-wrap items-center gap-2",
22710
+ children: [
22711
+ licenseText ? /* @__PURE__ */ jsx(LicenseBadge, { license: licenseText }) : null,
22712
+ keywordList.map((k) => /* @__PURE__ */ jsx(Badge, { children: k }, k)),
22713
+ repoHref ? /* @__PURE__ */ jsx(LinkButton, {
22714
+ href: repoHref,
22715
+ external: true,
22716
+ variant: "secondary",
22717
+ children: _t({
22718
+ id: "n/I+da",
22719
+ message: "View source"
22720
+ })
22721
+ }) : null
22722
+ ]
22723
+ }) : null,
22724
+ authorList.length > 0 ? /* @__PURE__ */ jsxs("section", { children: [/* @__PURE__ */ jsx("h2", {
22725
+ className: "text-sm font-semibold text-kumo-subtle",
22726
+ children: _t({
22727
+ id: "dpqPBg",
22728
+ message: "Authors"
22729
+ })
22730
+ }), /* @__PURE__ */ jsx("ul", {
22731
+ className: "mt-2 space-y-1",
22732
+ children: authorList.map((a, i) => /* @__PURE__ */ jsxs("li", {
22733
+ className: "flex flex-wrap items-center gap-x-3 gap-y-1 text-sm",
22734
+ children: [
22735
+ /* @__PURE__ */ jsx("span", {
22736
+ className: "font-medium text-kumo-default",
22737
+ children: a.name
22738
+ }),
22739
+ a.url ? /* @__PURE__ */ jsx("a", {
22740
+ href: a.url,
22741
+ target: "_blank",
22742
+ rel: "noreferrer",
22743
+ className: "text-kumo-brand hover:underline",
22744
+ children: _t({
22745
+ id: "On0aF2",
22746
+ message: "Website"
22747
+ })
22748
+ }) : null,
22749
+ a.email ? /* @__PURE__ */ jsx("a", {
22750
+ href: `mailto:${a.email}`,
22751
+ className: "text-kumo-brand hover:underline",
22752
+ children: a.email
22753
+ }) : null
22754
+ ]
22755
+ }, `${a.name}-${i}`))
22756
+ })] }) : null,
22757
+ securityList.length > 0 ? /* @__PURE__ */ jsxs("section", { children: [/* @__PURE__ */ jsx("h2", {
22758
+ className: "text-sm font-semibold text-kumo-subtle",
22759
+ children: _t({
22760
+ id: "FDZXmz",
22761
+ message: "Security contacts"
22762
+ })
22763
+ }), /* @__PURE__ */ jsx("ul", {
22764
+ className: "mt-2 space-y-1",
22765
+ children: securityList.map((c, i) => /* @__PURE__ */ jsxs("li", {
22766
+ className: "flex flex-wrap items-center gap-x-3 gap-y-1 text-sm",
22767
+ children: [c.email ? /* @__PURE__ */ jsx("a", {
22768
+ href: `mailto:${c.email}`,
22769
+ className: "text-kumo-brand hover:underline",
22770
+ children: c.email
22771
+ }) : null, c.url ? /* @__PURE__ */ jsx("a", {
22772
+ href: c.url,
22773
+ target: "_blank",
22774
+ rel: "noreferrer",
22775
+ className: "text-kumo-brand hover:underline",
22776
+ children: c.url
22777
+ }) : null]
22778
+ }, `${c.email ?? c.url ?? "contact"}-${i}`))
22779
+ })] }) : null,
22780
+ capabilities.length > 0 ? /* @__PURE__ */ jsxs("section", { children: [/* @__PURE__ */ jsx("h2", {
22781
+ className: "text-sm font-semibold text-kumo-subtle",
22782
+ children: _t({
22783
+ id: "FENDVB",
22784
+ message: "Declared permissions"
22785
+ })
22786
+ }), /* @__PURE__ */ jsx("div", {
22787
+ className: "mt-2 flex flex-wrap gap-2",
22788
+ children: capabilities.map((c) => /* @__PURE__ */ jsx(Badge, { children: c }, c))
22789
+ })] }) : null,
22790
+ showConsent && release ? /* @__PURE__ */ jsx(CapabilityConsentDialog, {
22791
+ mode: "install",
22792
+ pluginName: displayName ?? slug,
22793
+ capabilities,
22794
+ isPending: installMutation.isPending,
22795
+ error: getMutationError(installMutation.error),
22796
+ onConfirm: () => installMutation.mutate(),
22797
+ onCancel: () => {
22798
+ setShowConsent(false);
22799
+ installMutation.reset();
22800
+ }
22801
+ }) : null
22802
+ ]
22803
+ });
22804
+ }
22805
+ function BackLink() {
22806
+ const { _: _t2 } = useLingui();
22807
+ return /* @__PURE__ */ jsxs(Link$1, {
22808
+ to: "/plugins/marketplace",
22809
+ className: "inline-flex items-center gap-1 text-sm text-kumo-subtle hover:text-kumo-default",
22810
+ children: [/* @__PURE__ */ jsx(ArrowPrev, { className: "h-4 w-4" }), _t2({
22811
+ id: "fnGsOh",
22812
+ message: "Back to plugins"
22813
+ })]
22814
+ });
22815
+ }
22816
+ /**
22817
+ * Flatten an RFC-0001 `declaredAccess` block (`{ content: { read: true },
22818
+ * email: { send: { allowedHosts: [...] } }, ... }`) into the legacy
22819
+ * `capabilities: string[]` shape that the existing sandbox runtime
22820
+ * enforces today. One entry per declared operation under each
22821
+ * category. Unknown values are skipped silently -- the consent dialog
22822
+ * shows only what the current runtime recognises.
22823
+ */
22824
+ function declaredAccessToCapabilityList(declaredAccess) {
22825
+ if (!declaredAccess || typeof declaredAccess !== "object") return [];
22826
+ const out = [];
22827
+ for (const [category, value] of Object.entries(declaredAccess)) {
22828
+ if (!value || typeof value !== "object") continue;
22829
+ for (const [operation, opValue] of Object.entries(value)) {
22830
+ if (opValue === false) continue;
22831
+ out.push(`${category}:${operation}`);
22832
+ }
22833
+ }
22834
+ return out;
22835
+ }
22836
+ function formatDate(iso) {
22837
+ try {
22838
+ return new Date(iso).toLocaleDateString();
22839
+ } catch {
22840
+ return iso;
22841
+ }
22842
+ }
22843
+ /**
22844
+ * SPDX page URL for a license, or `null` when the value isn't a single
22845
+ * SPDX identifier (compound expressions like "MIT OR Apache-2.0" and the
22846
+ * literal "proprietary" have no canonical spdx.org page).
22847
+ */
22848
+ /**
22849
+ * Validate an untrusted aggregator-supplied URL for use in an `href`.
22850
+ * Returns the normalised URL only when it is an absolute `http(s)` URL;
22851
+ * everything else (relative, `javascript:`, `data:`, garbage, non-string)
22852
+ * returns `null`. The profile/release records are pass-throughs from a
22853
+ * remote service, so an unsanitised `href` is stored XSS in the
22854
+ * authenticated admin origin.
22855
+ */
22856
+ function safeExternalHref(value) {
22857
+ if (typeof value !== "string" || value.length === 0) return null;
22858
+ let parsed;
22859
+ try {
22860
+ parsed = new URL(value);
22861
+ } catch {
22862
+ return null;
22863
+ }
22864
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
22865
+ return parsed.href;
22866
+ }
22867
+ const EMAIL_RE = /^[^\s@<>()[\]\\,;:"?&=%/]+@[^\s@<>()[\]\\,;:"?&=%/]+\.[^\s@<>()[\]\\,;:"?&=%/]+$/;
22868
+ function safeEmail(value) {
22869
+ if (typeof value !== "string") return null;
22870
+ const email = value.trim();
22871
+ if (email.length === 0 || email.length > 320) return null;
22872
+ return EMAIL_RE.test(email) ? email : null;
22873
+ }
22874
+ const SPDX_SINGLE_ID_RE = /^[A-Za-z0-9.+-]+$/;
22875
+ function spdxLicenseHref(license) {
22876
+ const id = license.trim();
22877
+ if (!SPDX_SINGLE_ID_RE.test(id)) return null;
22878
+ if (id.toLowerCase() === "proprietary") return null;
22879
+ return `https://spdx.org/licenses/${id}.html`;
22880
+ }
22881
+ function LicenseBadge({ license }) {
22882
+ const { _: _t3 } = useLingui();
22883
+ const href = spdxLicenseHref(license);
22884
+ if (!href) return /* @__PURE__ */ jsx(Badge, { children: license });
22885
+ return /* @__PURE__ */ jsx("a", {
22886
+ href,
22887
+ target: "_blank",
22888
+ rel: "noreferrer",
22889
+ "aria-label": _t3({
22890
+ id: "4Q1BOX",
22891
+ message: "View the {license} license on spdx.org",
22892
+ values: { license }
22893
+ }),
22894
+ className: "hover:opacity-80",
22895
+ children: /* @__PURE__ */ jsx(Badge, { children: license })
22896
+ });
22897
+ }
22898
+ function formatHoldback(seconds) {
22899
+ if (seconds <= 0) return "0s";
22900
+ if (seconds < 3600) return `${Math.round(seconds / 60)} min`;
22901
+ if (seconds < 1440 * 60) return `${Math.round(seconds / 60 / 60)} h`;
22902
+ return `${Math.round(seconds / 60 / 60 / 24)} d`;
22903
+ }
22904
+
21832
22905
  //#endregion
21833
22906
  //#region src/components/SandboxedPluginPage.tsx
21834
22907
  /**
@@ -26957,7 +28030,16 @@ function SidebarNav({ manifest }) {
26957
28030
  minRole: ROLE_ADMIN$1
26958
28031
  }
26959
28032
  ];
26960
- if (manifest.marketplace) adminItems.push({
28033
+ if (manifest.registry) adminItems.push({
28034
+ to: "/plugins/marketplace",
28035
+ label: _t({
28036
+ id: "kVxO/O",
28037
+ message: "Registry"
28038
+ }),
28039
+ icon: Storefront,
28040
+ minRole: ROLE_ADMIN$1
28041
+ });
28042
+ else if (manifest.marketplace) adminItems.push({
26961
28043
  to: "/plugins/marketplace",
26962
28044
  label: _t({
26963
28045
  id: "Zt5PUS",
@@ -26965,7 +28047,8 @@ function SidebarNav({ manifest }) {
26965
28047
  }),
26966
28048
  icon: Storefront,
26967
28049
  minRole: ROLE_ADMIN$1
26968
- }, {
28050
+ });
28051
+ if (manifest.marketplace) adminItems.push({
26969
28052
  to: "/themes/marketplace",
26970
28053
  label: _t({
26971
28054
  id: "fJ3EbT",
@@ -35006,6 +36089,7 @@ function ContentListPage() {
35006
36089
  const items = React$1.useMemo(() => {
35007
36090
  return data?.pages.flatMap((page) => page.items) || [];
35008
36091
  }, [data]);
36092
+ const total = data?.pages[0]?.total ?? items.length;
35009
36093
  if (!manifest) return /* @__PURE__ */ jsx(LoadingScreen, {});
35010
36094
  const collectionConfig = manifest.collections[collection];
35011
36095
  if (!collectionConfig) return /* @__PURE__ */ jsx(NotFoundPage, { message: `Collection "${collection}" not found` });
@@ -35036,7 +36120,8 @@ function ContentListPage() {
35036
36120
  onLocaleChange: handleLocaleChange,
35037
36121
  urlPattern: collectionConfig.urlPattern,
35038
36122
  sort,
35039
- onSortChange: setSort
36123
+ onSortChange: setSort,
36124
+ total
35040
36125
  });
35041
36126
  }
35042
36127
  const contentNewRoute = createRoute({
@@ -35893,6 +36978,10 @@ const marketplaceBrowseRoute = createRoute({
35893
36978
  component: MarketplaceBrowsePage
35894
36979
  });
35895
36980
  function MarketplaceBrowsePage() {
36981
+ const { data: manifest } = useQuery({
36982
+ queryKey: ["manifest"],
36983
+ queryFn: fetchManifest
36984
+ });
35896
36985
  const { data: plugins } = useQuery({
35897
36986
  queryKey: ["plugins"],
35898
36987
  queryFn: async () => {
@@ -35900,10 +36989,18 @@ function MarketplaceBrowsePage() {
35900
36989
  return fetchPlugins();
35901
36990
  }
35902
36991
  });
35903
- return /* @__PURE__ */ jsx(MarketplaceBrowse, { installedPluginIds: React$1.useMemo(() => {
36992
+ const installedIds = React$1.useMemo(() => {
35904
36993
  if (!plugins) return /* @__PURE__ */ new Set();
35905
36994
  return new Set(plugins.map((p) => p.id));
35906
- }, [plugins]) });
36995
+ }, [plugins]);
36996
+ if (manifest?.registry) {
36997
+ const installedRegistryUris = new Set((plugins ?? []).filter((p) => p.source === "registry" && p.registryPublisherDid && p.registrySlug).map((p) => `at://${p.registryPublisherDid}/com.emdashcms.experimental.package.profile/${p.registrySlug}`));
36998
+ return /* @__PURE__ */ jsx(RegistryBrowse, {
36999
+ config: manifest.registry,
37000
+ installedRegistryUris
37001
+ });
37002
+ }
37003
+ return /* @__PURE__ */ jsx(MarketplaceBrowse, { installedPluginIds: installedIds });
35907
37004
  }
35908
37005
  const marketplaceDetailRoute = createRoute({
35909
37006
  getParentRoute: () => adminLayoutRoute,
@@ -35912,6 +37009,10 @@ const marketplaceDetailRoute = createRoute({
35912
37009
  });
35913
37010
  function MarketplaceDetailPage() {
35914
37011
  const { pluginId } = useParams({ from: "/_admin/plugins/marketplace/$pluginId" });
37012
+ const { data: manifest } = useQuery({
37013
+ queryKey: ["manifest"],
37014
+ queryFn: fetchManifest
37015
+ });
35915
37016
  const { data: plugins } = useQuery({
35916
37017
  queryKey: ["plugins"],
35917
37018
  queryFn: async () => {
@@ -35919,12 +37020,18 @@ function MarketplaceDetailPage() {
35919
37020
  return fetchPlugins();
35920
37021
  }
35921
37022
  });
37023
+ const installedIds = React$1.useMemo(() => {
37024
+ if (!plugins) return /* @__PURE__ */ new Set();
37025
+ return new Set(plugins.map((p) => p.id));
37026
+ }, [plugins]);
37027
+ const looksLikeRegistryId = pluginId.includes("/");
37028
+ if (manifest?.registry && looksLikeRegistryId) return /* @__PURE__ */ jsx(RegistryPluginDetail, {
37029
+ pluginId,
37030
+ config: manifest.registry
37031
+ });
35922
37032
  return /* @__PURE__ */ jsx(MarketplacePluginDetail, {
35923
37033
  pluginId,
35924
- installedPluginIds: React$1.useMemo(() => {
35925
- if (!plugins) return /* @__PURE__ */ new Set();
35926
- return new Set(plugins.map((p) => p.id));
35927
- }, [plugins])
37034
+ installedPluginIds: installedIds
35928
37035
  });
35929
37036
  }
35930
37037
  const themeMarketplaceBrowseRoute = createRoute({