smoodly 0.0.51 → 0.0.53
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.
- package/README.md +14 -13
- package/dist/admin/auth.d.ts +3 -11
- package/dist/admin/auth.js +1 -30
- package/dist/admin/client-ops.d.ts +5 -4
- package/dist/admin/client-ops.js +15 -12
- package/dist/admin/delivery-mode.d.ts +1 -1
- package/dist/admin/delivery-mode.js +2 -2
- package/dist/admin/drift.d.ts +0 -2
- package/dist/admin/drift.js +1 -1
- package/dist/admin/editor/EditorView.d.ts +0 -3
- package/dist/admin/editor/EditorView.js +11 -16
- package/dist/admin/editor/FloatingToolbar.d.ts +0 -1
- package/dist/admin/editor/FloatingToolbar.js +1 -1
- package/dist/admin/editor/SeoSettings.d.ts +0 -2
- package/dist/admin/editor/SeoSettings.js +2 -2
- package/dist/admin/editor/canvas-address.d.ts +8 -0
- package/dist/admin/editor/canvas-address.js +18 -2
- package/dist/admin/editor/canvas-diagnosis.d.ts +2 -0
- package/dist/admin/editor/canvas-diagnosis.js +3 -0
- package/dist/admin/editor/inline-dom.d.ts +0 -4
- package/dist/admin/editor/inline-dom.js +1 -1
- package/dist/admin/editor/preview-grant.d.ts +6 -0
- package/dist/admin/editor/preview-grant.js +25 -0
- package/dist/admin/editor/useCanvasDiagnosis.js +2 -2
- package/dist/admin/files.generated.js +1 -1
- package/dist/admin/fixed-nodes.d.ts +0 -6
- package/dist/admin/fixed-nodes.js +1 -81
- package/dist/admin/forms/CropEditor.d.ts +4 -2
- package/dist/admin/forms/CropEditor.js +58 -5
- package/dist/admin/forms/ImagePreview.d.ts +3 -1
- package/dist/admin/forms/ImagePreview.js +9 -3
- package/dist/admin/forms/MediaGrid.js +2 -1
- package/dist/admin/forms/MediaPicker.js +9 -6
- package/dist/admin/forms/MediaWidget.js +11 -9
- package/dist/admin/forms/assets-context.d.ts +7 -3
- package/dist/admin/forms/assets-context.js +5 -3
- package/dist/admin/forms/crop-editor.d.ts +24 -2
- package/dist/admin/forms/crop-editor.js +36 -2
- package/dist/admin/forms/date-picker.d.ts +0 -1
- package/dist/admin/forms/date-picker.js +1 -1
- package/dist/admin/forms/tabs.d.ts +0 -1
- package/dist/admin/forms/tabs.js +1 -1
- package/dist/admin/forms/upload.d.ts +18 -11
- package/dist/admin/forms/upload.js +62 -24
- package/dist/admin/global-items.d.ts +0 -3
- package/dist/admin/global-items.js +0 -54
- package/dist/admin/handler.d.ts +22 -28
- package/dist/admin/handler.js +146 -47
- package/dist/admin/health.d.ts +0 -4
- package/dist/admin/index.d.ts +6 -9
- package/dist/admin/index.js +3 -6
- package/dist/admin/ops.d.ts +40 -32
- package/dist/admin/page-data.d.ts +14 -6
- package/dist/admin/page-data.js +1 -1
- package/dist/admin/session-flow.d.ts +65 -0
- package/dist/admin/session-flow.js +121 -0
- package/dist/admin/shell/AdminApp.d.ts +8 -3
- package/dist/admin/shell/AdminApp.js +37 -58
- package/dist/admin/shell/ContentHealthPage.js +1 -3
- package/dist/admin/shell/GlobalFields.d.ts +0 -3
- package/dist/admin/shell/GlobalFields.js +1 -1
- package/dist/admin/shell/IconRail.js +10 -8
- package/dist/admin/shell/MediaScreen.js +9 -6
- package/dist/admin/shell/MembersPage.d.ts +1 -2
- package/dist/admin/shell/MembersPage.js +19 -90
- package/dist/admin/shell/SettingsView.d.ts +1 -2
- package/dist/admin/shell/SettingsView.js +2 -2
- package/dist/admin/shell/SignedOutView.d.ts +5 -0
- package/dist/admin/shell/SignedOutView.js +9 -0
- package/dist/admin/shell/console-links.d.ts +5 -0
- package/dist/admin/shell/console-links.js +11 -0
- package/dist/admin/shell/members-list.d.ts +1 -14
- package/dist/admin/shell/members-list.js +2 -23
- package/dist/admin/shell/pages-tree.d.ts +0 -2
- package/dist/admin/shell/pages-tree.js +1 -1
- package/dist/admin/shell/presence-socket.d.ts +10 -0
- package/dist/admin/shell/presence-socket.js +191 -0
- package/dist/admin/shell/presence.d.ts +6 -18
- package/dist/admin/shell/presence.js +4 -11
- package/dist/admin/shell/session.d.ts +19 -12
- package/dist/admin/shell/session.js +114 -47
- package/dist/admin/ui/LocaleSwitcher.d.ts +0 -4
- package/dist/admin/ui/LocaleSwitcher.js +2 -2
- package/dist/admin/ui/primitives.d.ts +4 -14
- package/dist/admin/ui/primitives.js +7 -9
- package/dist/admin/ui/theme.js +13 -2
- package/dist/admin/validate.js +10 -1
- package/dist/api-client.d.ts +26 -0
- package/dist/api-client.js +107 -0
- package/dist/assemble.d.ts +4 -41
- package/dist/assemble.js +5 -303
- package/dist/asset-store.d.ts +28 -30
- package/dist/asset-store.js +26 -121
- package/dist/astro/cache.d.ts +1 -1
- package/dist/astro/components/Head.astro +4 -1
- package/dist/astro/components/Image.astro +12 -17
- package/dist/astro/components/Video.astro +5 -2
- package/dist/astro/head.d.ts +3 -0
- package/dist/astro/head.js +24 -0
- package/dist/astro/index.js +61 -27
- package/dist/astro/locale-paths.d.ts +5 -2
- package/dist/astro/locale-paths.js +10 -2
- package/dist/astro/routes/admin.js +9 -7
- package/dist/astro/runtime.d.ts +3 -2
- package/dist/astro/runtime.js +5 -4
- package/dist/astro/server.d.ts +7 -11
- package/dist/astro/server.js +22 -28
- package/dist/astro/shell.d.ts +3 -3
- package/dist/astro/shell.js +1 -1
- package/dist/astro/static-media-node.d.ts +4 -0
- package/dist/astro/static-media-node.js +25 -0
- package/dist/astro/static-media.d.ts +17 -0
- package/dist/astro/static-media.js +55 -0
- package/dist/astro/static.d.ts +5 -10
- package/dist/astro/static.js +13 -29
- package/dist/canvas-marker.d.ts +14 -0
- package/dist/canvas-marker.js +36 -0
- package/dist/cli/api.d.ts +14 -0
- package/dist/cli/api.js +48 -0
- package/dist/cli/app-env.d.ts +2 -0
- package/dist/cli/app-env.js +20 -0
- package/dist/cli/export.d.ts +16 -2
- package/dist/cli/export.js +52 -35
- package/dist/cli/import.d.ts +7 -11
- package/dist/cli/import.js +50 -54
- package/dist/cli/load-config.d.ts +0 -1
- package/dist/cli/load-config.js +1 -1
- package/dist/cli/main.d.ts +4 -12
- package/dist/cli/main.js +26 -72
- package/dist/cli/push.d.ts +6 -0
- package/dist/cli/push.js +31 -0
- package/dist/collections.d.ts +0 -1
- package/dist/collections.js +1 -1
- package/dist/config.d.ts +20 -5
- package/dist/config.js +18 -1
- package/dist/content/content-api.d.ts +23 -23
- package/dist/content/content-api.js +48 -56
- package/dist/content/mime.d.ts +1 -0
- package/dist/content/mime.js +6 -0
- package/dist/css.js +1 -1
- package/dist/deploy-store.d.ts +19 -0
- package/dist/entry-store.d.ts +8 -94
- package/dist/entry-store.js +9 -557
- package/dist/env.d.ts +27 -20
- package/dist/env.js +38 -50
- package/dist/fields.d.ts +11 -3
- package/dist/fields.js +33 -2
- package/dist/global-id.d.ts +2 -0
- package/dist/global-id.js +4 -2
- package/dist/image/SmoodlyImage.d.ts +9 -5
- package/dist/image/SmoodlyImage.js +29 -15
- package/dist/image/SmoodlyVideo.d.ts +1 -1
- package/dist/image/SmoodlyVideo.js +3 -2
- package/dist/image/index.d.ts +2 -3
- package/dist/image/index.js +0 -2
- package/dist/image/markup.d.ts +53 -0
- package/dist/image/markup.js +111 -0
- package/dist/index.d.ts +13 -23
- package/dist/index.js +7 -20
- package/dist/media-cut.d.ts +44 -0
- package/dist/media-cut.js +70 -0
- package/dist/media.d.ts +58 -4
- package/dist/media.js +126 -16
- package/dist/member-store.d.ts +2 -50
- package/dist/member-store.js +0 -56
- package/dist/next/admin-route.d.ts +1 -1
- package/dist/next/admin-route.js +5 -3
- package/dist/next/meta.js +2 -3
- package/dist/next/page-renderer.d.ts +2 -2
- package/dist/next/page-renderer.js +22 -11
- package/dist/next/queries.d.ts +2 -2
- package/dist/path-index.d.ts +0 -24
- package/dist/path-index.js +0 -98
- package/dist/paths.d.ts +1 -1
- package/dist/paths.js +1 -1
- package/dist/push.d.ts +30 -0
- package/dist/push.js +52 -0
- package/dist/read-counter.d.ts +2 -0
- package/dist/read-counter.js +6 -0
- package/dist/reads.d.ts +7 -7
- package/dist/reads.js +3 -0
- package/dist/registry-json.d.ts +11 -0
- package/dist/registry-json.js +81 -0
- package/dist/render.d.ts +0 -1
- package/dist/render.js +1 -1
- package/dist/resolve.d.ts +12 -6
- package/dist/resolve.js +11 -8
- package/dist/revalidate.d.ts +8 -1
- package/dist/revalidate.js +8 -1
- package/dist/richtext/index.d.ts +1 -1
- package/dist/section-wrapper.d.ts +1 -1
- package/dist/section-wrapper.js +0 -9
- package/dist/signed-message.d.ts +26 -0
- package/dist/signed-message.js +103 -0
- package/dist/site.d.ts +59 -18
- package/dist/site.js +21 -252
- package/dist/space-transfer.d.ts +6 -3
- package/dist/space-transfer.js +18 -6
- package/dist/store.d.ts +2 -80
- package/dist/store.js +3 -489
- package/dist/toolset.d.ts +2 -1
- package/dist/toolset.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -15
- package/dist/admin/auth-config.d.ts +0 -6
- package/dist/admin/browser/fetch-op.d.ts +0 -10
- package/dist/admin/browser/fetch-op.js +0 -44
- package/dist/admin/browser/main.d.ts +0 -1
- package/dist/admin/browser/main.js +0 -23
- package/dist/admin/ops-impl.d.ts +0 -51
- package/dist/admin/ops-impl.js +0 -1403
- package/dist/admin/shell/InviteModal.d.ts +0 -6
- package/dist/admin/shell/InviteModal.js +0 -28
- package/dist/admin/shell/LoginView.d.ts +0 -5
- package/dist/admin/shell/LoginView.js +0 -80
- package/dist/admin/shell/SchemaNotice.d.ts +0 -4
- package/dist/admin/shell/SchemaNotice.js +0 -4
- package/dist/admin/shell/SetPasswordView.d.ts +0 -7
- package/dist/admin/shell/SetPasswordView.js +0 -41
- package/dist/admin/shell/presence-realtime.d.ts +0 -3
- package/dist/admin/shell/presence-realtime.js +0 -32
- package/dist/admin/supabase-auth.d.ts +0 -7
- package/dist/admin/supabase-auth.js +0 -30
- package/dist/astro/deploy-deps.d.ts +0 -10
- package/dist/astro/deploy-deps.js +0 -8
- package/dist/astro/image.d.ts +0 -27
- package/dist/astro/image.js +0 -29
- package/dist/bind.d.ts +0 -19
- package/dist/bind.js +0 -34
- package/dist/cli/add-admin.d.ts +0 -11
- package/dist/cli/add-admin.js +0 -25
- package/dist/cli/load-collections.d.ts +0 -2
- package/dist/cli/load-collections.js +0 -27
- package/dist/cli/migrate.d.ts +0 -43
- package/dist/cli/migrate.js +0 -138
- package/dist/cli/rebuild.d.ts +0 -23
- package/dist/cli/rebuild.js +0 -22
- package/dist/cli/space-io.d.ts +0 -41
- package/dist/cli/space-io.js +0 -84
- package/dist/client.d.ts +0 -11
- package/dist/client.js +0 -54
- package/dist/current-space.d.ts +0 -4
- package/dist/current-space.js +0 -9
- package/dist/document-stamp.d.ts +0 -7
- package/dist/document-stamp.js +0 -48
- package/dist/image/loaders.d.ts +0 -15
- package/dist/image/loaders.js +0 -5
- package/dist/image/next-config.d.ts +0 -18
- package/dist/image/next-config.js +0 -32
- package/dist/image/remote-pattern.d.ts +0 -1
- package/dist/image/remote-pattern.js +0 -5
- package/dist/migrations.d.ts +0 -37
- package/dist/migrations.js +0 -73
- package/dist/project.d.ts +0 -88
- package/dist/project.js +0 -231
- package/dist/published.d.ts +0 -142
- package/dist/published.js +0 -164
- package/dist/ref-index.d.ts +0 -19
- package/dist/ref-index.js +0 -0
- package/dist/schema-read.d.ts +0 -23
- package/dist/schema-read.js +0 -50
- package/dist/schema-version.d.ts +0 -19
- package/dist/schema-version.js +0 -34
- package/dist/sql-space.d.ts +0 -44
- package/dist/sql-space.js +0 -289
- package/dist/sql.d.ts +0 -15
- package/dist/sql.js +0 -556
- package/dist/supabase-asset-store.d.ts +0 -21
- package/dist/supabase-asset-store.js +0 -170
- package/dist/supabase-entry-store.d.ts +0 -92
- package/dist/supabase-entry-store.js +0 -824
- package/dist/supabase-member-store.d.ts +0 -21
- package/dist/supabase-member-store.js +0 -96
- package/dist/supabase-path-index.d.ts +0 -30
- package/dist/supabase-path-index.js +0 -138
- package/dist/supabase-published-store.d.ts +0 -56
- package/dist/supabase-published-store.js +0 -314
- package/dist/supabase-store.d.ts +0 -103
- package/dist/supabase-store.js +0 -678
- package/migrations/20260926120000_smoodly_v1.sql +0 -535
- package/migrations/20260926130000_smoodly_v2.sql +0 -26
- package/migrations/20260926140000_smoodly_v3.sql +0 -38
- package/migrations/20260926150000_smoodly_v4.sql +0 -41
- package/migrations/20260929120000_smoodly_v5.sql +0 -56
- package/migrations/20260929135523_smoodly_v6.sql +0 -200
- package/migrations/20260930040956_smoodly_v7.sql +0 -84
- package/migrations/20261007120000_smoodly_v8.sql +0 -154
- package/migrations/20261007200000_smoodly_v9.sql +0 -30
- package/migrations/20261008120000_smoodly_v10.sql +0 -47
- package/migrations/20261009120000_smoodly_v11.sql +0 -49
- /package/dist/{admin/auth-config.js → deploy-store.js} +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
// packages/smoodly/src/admin/shell/IconRail.tsx
|
|
4
|
-
// The admin's primary navigation: the design
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// The admin's primary navigation: the design handoff's 72px IconRail (the
|
|
5
|
+
// cool-blue look's components/navigation/IconRail, in the tree until 2026-10-19;
|
|
6
|
+
// the shipped tokens are design_handoff_smoodly_neutral/), which replaced the
|
|
7
|
+
// 200px SideNav so the visual editor gets the width. Ported markup-for-markup;
|
|
8
|
+
// the handoff's click handlers are the shell's router.
|
|
8
9
|
//
|
|
9
10
|
// Items are registry-derived, never separately configured: Pages, then
|
|
10
11
|
// Collections — one rail item whose flyout lists every collection — then
|
|
@@ -12,9 +13,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
12
13
|
// the route is /globals), and Settings (the Settings area, spec
|
|
13
14
|
// 2026-09-25; the route is /settings; every member has a page there since
|
|
14
15
|
// Redirects, 2026-09-26, so the shell passes `settings` once the role is
|
|
15
|
-
// known). The user avatar is pinned to the bottom and opens the
|
|
16
|
-
// flyout
|
|
17
|
-
//
|
|
16
|
+
// known). The user avatar is pinned to the bottom and opens the account
|
|
17
|
+
// flyout: the email, the theme toggle, "Your sites at smoodly.io" and Sign
|
|
18
|
+
// out (spec 2026-10-16 sign-in and the dashboard §6).
|
|
18
19
|
//
|
|
19
20
|
// Flyouts: click toggles, Esc closes and returns focus to the trigger,
|
|
20
21
|
// ↑/↓ move between entries, a pointer down outside closes. A child being
|
|
@@ -24,6 +25,7 @@ import { Globe, Image, LibraryBig, PanelTop, RadioTower, Settings } from "lucide
|
|
|
24
25
|
import { tileCopy } from "./deploy-tile.js";
|
|
25
26
|
import { ThemeToggle } from "../ui/primitives.js";
|
|
26
27
|
import { useAdminHref } from "./base.js";
|
|
28
|
+
import { consoleLinks } from "./console-links.js";
|
|
27
29
|
import { RouteLink } from "./RouteLink.js";
|
|
28
30
|
/** Lucide at the rail's size and stroke (the tile's CSS restates both). */
|
|
29
31
|
const ICON = { size: 18, strokeWidth: 2, "aria-hidden": true };
|
|
@@ -94,5 +96,5 @@ export function IconRail({ registry, active, email, settings = true, deploy, onS
|
|
|
94
96
|
const copy = tileCopy(deploy.status, Date.now());
|
|
95
97
|
const id = menuId("deploy");
|
|
96
98
|
return (_jsxs("div", { className: "sm-rail__item sm-rail__deploy", "data-state": deploy.status.state, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-haspopup": "menu", "aria-expanded": open === "deploy", "aria-controls": id, onClick: () => toggle("deploy"), title: copy.detail, children: [_jsxs("span", { className: `sm-rail__tile${copy.lit ? " sm-rail__tile--lit" : ""}${copy.warn ? " sm-rail__tile--warn" : ""}`, children: [_jsx(RadioTower, { ...ICON }), copy.badge && _jsx("span", { className: "sm-rail__badge", "aria-label": `${copy.badge} changes waiting`, children: copy.badge })] }), _jsx("span", { className: "sm-rail__label", children: copy.label })] }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: id, "data-open": open === "deploy", children: [_jsx("li", { className: "sm-rail__menu-detail", role: "presentation", children: copy.detail }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", disabled: copy.action === null || deploy.busy, title: copy.disabledReason ?? undefined, onClick: () => { close(); deploy.onDeploy(); }, children: copy.action === "retry" ? "Deploy again" : "Deploy now" }) }), copy.disabledReason && _jsx("li", { className: "sm-rail__menu-note", role: "presentation", children: copy.disabledReason })] })] }));
|
|
97
|
-
})(), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
|
|
99
|
+
})(), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: email ?? name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("a", { role: "menuitem", className: "sm-rail__menu-item", href: consoleLinks().sites, target: "_blank", rel: "noreferrer", onClick: close, children: "Your sites at smoodly.io" }) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
|
|
98
100
|
}
|
|
@@ -8,8 +8,9 @@ import { useCallback, useEffect, useState } from "react";
|
|
|
8
8
|
import { useAssets } from "../forms/assets-context.js";
|
|
9
9
|
import { uploadFile } from "../forms/upload.js";
|
|
10
10
|
import { MediaGrid } from "../forms/MediaGrid.js";
|
|
11
|
-
import { Banner, Button, Field, Segmented, TopBar } from "../ui/primitives.js";
|
|
11
|
+
import { Banner, Button, Field, Segmented, TopBar, UploadProgress } from "../ui/primitives.js";
|
|
12
12
|
import { confirm } from "../ui/confirm.js";
|
|
13
|
+
import { adminImageSrc } from "../forms/crop-editor.js";
|
|
13
14
|
import { RouteLink } from "./RouteLink.js";
|
|
14
15
|
import { KIND_FILTERS, acceptFor, formatBytes, kindOfFilter, usageLabel } from "./media-list.js";
|
|
15
16
|
const META = ["title", "alt", "caption", "credit"];
|
|
@@ -23,7 +24,9 @@ export function MediaScreen({ initialItems = [], initialSelected = null, initial
|
|
|
23
24
|
const [meta, setMeta] = useState({
|
|
24
25
|
title: initialSelected?.title ?? "", alt: initialSelected?.alt ?? "", caption: initialSelected?.caption ?? "", credit: initialSelected?.credit ?? "",
|
|
25
26
|
});
|
|
26
|
-
|
|
27
|
+
/** The fraction sent while a file uploads; null when none is. */
|
|
28
|
+
const [progress, setProgress] = useState(null);
|
|
29
|
+
const busy = progress !== null;
|
|
27
30
|
const [error, setError] = useState(null);
|
|
28
31
|
const load = useCallback(() => {
|
|
29
32
|
assets.ops.list({ kind: kindOfFilter(filter), search: search || undefined })
|
|
@@ -58,10 +61,10 @@ export function MediaScreen({ initialItems = [], initialSelected = null, initial
|
|
|
58
61
|
const file = files?.[0];
|
|
59
62
|
if (!file)
|
|
60
63
|
return;
|
|
61
|
-
|
|
64
|
+
setProgress(0);
|
|
62
65
|
setError(null);
|
|
63
|
-
const r = await uploadFile(assets.
|
|
64
|
-
|
|
66
|
+
const r = await uploadFile(assets.upload.send, file, assets.upload.measure, setProgress);
|
|
67
|
+
setProgress(null);
|
|
65
68
|
if (!r.ok) {
|
|
66
69
|
setError(r.message);
|
|
67
70
|
return;
|
|
@@ -90,7 +93,7 @@ export function MediaScreen({ initialItems = [], initialSelected = null, initial
|
|
|
90
93
|
setSelected(null);
|
|
91
94
|
load();
|
|
92
95
|
};
|
|
93
|
-
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx("span", { className: "sm-title", children: "Media" }), _jsx("span", { className: "sm-count", children: items.length })] }), right: _jsxs("div", { className: "sm-picker-bar", children: [_jsx("input", { className: "sm-input", placeholder: "Search media", value: search, onChange: (e) => setSearch(e.target.value), style: { width: 220 } }), _jsx(Segmented, { options: KIND_FILTERS, value: filter, onChange: setFilter }), _jsxs("label", { className: "sm-btn sm-btn--primary", children: [busy ? "Uploading…" : "Upload", _jsx("input", { type: "file", accept: acceptFor(["image", "video", "file"], assets.media.accept), hidden: true, disabled: busy, onChange: (e) => { void onFiles(e.target.files); } })] })] }) }), error && _jsx(Banner, { tone: "error", onDismiss: () => setError(null), children: error }), _jsxs("div", { className: "sm-mediascreen", style: selected ? undefined : { gridTemplateColumns: "1fr" }, children: [_jsx("div", { className: "sm-mediascreen__grid", children: _jsx(MediaGrid, { items: items, selectedId: selected?.id, onSelect: (a) => setSelected(a.id === selected?.id ? null : a) }) }), selected && (_jsxs("aside", { className: "sm-mediascreen__inspector", children: [_jsx("div", { className: "sm-media__thumb", style: { aspectRatio: selected.width && selected.height ? `${selected.width} / ${selected.height}` : "4 / 3" }, children: selected.kind === "image" && _jsx("img", { src: selected
|
|
96
|
+
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx("span", { className: "sm-title", children: "Media" }), _jsx("span", { className: "sm-count", children: items.length })] }), right: _jsxs("div", { className: "sm-picker-bar", children: [_jsx("input", { className: "sm-input", placeholder: "Search media", value: search, onChange: (e) => setSearch(e.target.value), style: { width: 220 } }), _jsx(Segmented, { options: KIND_FILTERS, value: filter, onChange: setFilter }), _jsxs("label", { className: "sm-btn sm-btn--primary", children: [busy ? "Uploading…" : "Upload", _jsx("input", { type: "file", accept: acceptFor(["image", "video", "file"], assets.media.accept), hidden: true, disabled: busy, onChange: (e) => { void onFiles(e.target.files); } })] })] }) }), error && _jsx(Banner, { tone: "error", onDismiss: () => setError(null), children: error }), progress !== null && _jsx("div", { className: "sm-mediascreen__upload", children: _jsx(UploadProgress, { fraction: progress }) }), _jsxs("div", { className: "sm-mediascreen", style: selected ? undefined : { gridTemplateColumns: "1fr" }, children: [_jsx("div", { className: "sm-mediascreen__grid", children: _jsx(MediaGrid, { items: items, selectedId: selected?.id, onSelect: (a) => setSelected(a.id === selected?.id ? null : a) }) }), selected && (_jsxs("aside", { className: "sm-mediascreen__inspector", children: [_jsx("div", { className: "sm-media__thumb", style: { aspectRatio: selected.width && selected.height ? `${selected.width} / ${selected.height}` : "4 / 3" }, children: selected.kind === "image" && _jsx("img", { src: adminImageSrc(selected, 640), alt: "", style: { left: 0, top: 0, width: "100%", height: "100%" } }) }), META.map((key) => (_jsx(Field, { label: key === "alt" ? "Alt text" : key[0].toUpperCase() + key.slice(1), children: _jsx("input", { className: "sm-input", value: meta[key], onChange: (e) => setMeta((m) => ({ ...m, [key]: e.target.value })), onBlur: () => { void saveMeta(key); } }) }, key))), _jsxs("div", { className: "sm-facts", children: [_jsx("span", { children: "File" }), _jsx("span", { children: selected.filename }), _jsx("span", { children: "Type" }), _jsx("span", { children: selected.mime }), _jsx("span", { children: "Size" }), _jsx("span", { children: formatBytes(selected.size) }), selected.width && selected.height && _jsxs(_Fragment, { children: [_jsx("span", { children: "Dimensions" }), _jsxs("span", { children: [selected.width, "\u00D7", selected.height] })] }), selected.duration !== null && _jsxs(_Fragment, { children: [_jsx("span", { children: "Duration" }), _jsxs("span", { children: [Math.round(selected.duration), " s"] })] })] }), _jsxs("div", { className: "sm-usage", children: [_jsx("span", { className: "sm-label", children: usageLabel(usage.length) }), usage.map((u, i) => u.href
|
|
94
97
|
? _jsxs(RouteLink, { href: u.href, children: [u.title, " \u00B7 ", u.sourceField] }, `${u.sourceId}-${i}`)
|
|
95
98
|
: _jsxs("span", { children: [u.title, " \u00B7 ", u.sourceField] }, `${u.sourceId}-${i}`))] }), _jsx(Button, { variant: "secondary", disabled: usage.length > 0, title: usage.length > 0 ? "Remove it from those places first." : undefined, onClick: () => { void remove(); }, children: "Delete" })] }))] })] }));
|
|
96
99
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { AdminOps, MembersListResult } from "../ops.ts";
|
|
2
|
-
export declare function MembersPage({ ops,
|
|
2
|
+
export declare function MembersPage({ ops, initial }: {
|
|
3
3
|
ops: AdminOps;
|
|
4
|
-
userId: string;
|
|
5
4
|
initial?: MembersListResult | null;
|
|
6
5
|
}): import("react").JSX.Element;
|
|
@@ -1,101 +1,30 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
// packages/smoodly/src/admin/shell/MembersPage.tsx
|
|
4
|
-
// The Members page (spec 2026-09-25 §7;
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// in-place pills before) — all off when the control plane manages the
|
|
8
|
-
// space (the console URL banner stays). Own row and the last admin render
|
|
9
|
-
// their menu disabled with the guard's reason, so the rule is visible
|
|
10
|
-
// before it is hit.
|
|
4
|
+
// The Members page (spec 2026-09-25 §7; spec 2026-10-16 sign-in and the dashboard §6): a read-only list of the space's
|
|
5
|
+
// members with their roles, and "Manage members at smoodly.io" opening the project's Members tab. Every space is
|
|
6
|
+
// managed at smoodly.io, so the admin has no invite, role change or removal of its own.
|
|
11
7
|
import { useEffect, useState } from "react";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import { confirm } from "../ui/confirm.js";
|
|
8
|
+
import { Banner, StatusBadge } from "../ui/primitives.js";
|
|
9
|
+
import { consoleLinks } from "./console-links.js";
|
|
15
10
|
import { ErrorPane } from "./ErrorPane.js";
|
|
16
|
-
import { InviteModal } from "./InviteModal.js";
|
|
17
11
|
import { ListView } from "./ListView.js";
|
|
18
|
-
import {
|
|
19
|
-
export function MembersPage({ ops,
|
|
12
|
+
import { memberRows } from "./members-list.js";
|
|
13
|
+
export function MembersPage({ ops, initial = null }) {
|
|
20
14
|
const [data, setData] = useState(initial);
|
|
21
15
|
const [error, setError] = useState(null);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const load = () => ops.members.list()
|
|
25
|
-
.then((r) => (r.ok ? setData(r.data) : setError(r.message)))
|
|
26
|
-
.catch(() => setError("Couldn't reach the server."));
|
|
27
|
-
useEffect(() => { if (!initial)
|
|
28
|
-
void load(); }, [ops, initial]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
29
|
-
const invite = async (input) => {
|
|
30
|
-
// The admin's own origin and mount: /settings/… stripped, so the invite lands on the shell.
|
|
31
|
-
const redirectTo = window.location.origin + window.location.pathname.replace(/\/settings(\/.*)?$/, "");
|
|
32
|
-
const r = await ops.members.invite({ ...input, redirectTo });
|
|
33
|
-
if (!r.ok)
|
|
34
|
-
return r.message;
|
|
35
|
-
setInviting(false);
|
|
36
|
-
setNotice({
|
|
37
|
-
tone: "ok",
|
|
38
|
-
text: r.data.invited ? `Invite sent to ${input.email}.` : `${input.email} added — they can sign in with their existing account.`,
|
|
39
|
-
});
|
|
40
|
-
void load();
|
|
41
|
-
return null;
|
|
42
|
-
};
|
|
43
|
-
/** Two roles: the change is to the other one, confirmed with what it may do. */
|
|
44
|
-
const changeRole = async (row) => {
|
|
45
|
-
const role = row.value === "admin" ? "editor" : "admin";
|
|
46
|
-
const ok = await confirm({
|
|
47
|
-
title: `Change role to ${roleLabel(role).toLowerCase()}?`,
|
|
48
|
-
body: `${roleLabel(role)}: ${ROLE_DESCRIPTION[role]}`,
|
|
49
|
-
confirmLabel: "Change role",
|
|
50
|
-
subject: { name: row.email },
|
|
51
|
-
});
|
|
52
|
-
if (!ok)
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (initial)
|
|
53
18
|
return;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
setData((d) => d && { ...d, members: d.members.map((m) => (m.userId === row.key ? r.data : m)) });
|
|
60
|
-
};
|
|
61
|
-
const remove = async (row) => {
|
|
62
|
-
const ok = await confirm({
|
|
63
|
-
title: "Remove this member?",
|
|
64
|
-
body: "They keep their account and lose access to this site's admin. You can invite them again later.",
|
|
65
|
-
confirmLabel: "Remove member",
|
|
66
|
-
destructive: true,
|
|
67
|
-
subject: { name: row.email },
|
|
68
|
-
});
|
|
69
|
-
if (!ok)
|
|
70
|
-
return;
|
|
71
|
-
const r = await ops.members.remove(row.key);
|
|
72
|
-
if (!r.ok) {
|
|
73
|
-
setNotice({ tone: "error", text: r.message });
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
void load();
|
|
77
|
-
};
|
|
19
|
+
ops.members.list()
|
|
20
|
+
.then((r) => (r.ok ? setData(r.data) : setError(r.message)))
|
|
21
|
+
.catch(() => setError("Couldn't reach the server."));
|
|
22
|
+
}, [ops, initial]);
|
|
78
23
|
if (error)
|
|
79
24
|
return _jsx(ErrorPane, { message: error });
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
{ label: "Role", width: "120px" },
|
|
86
|
-
...(managed ? [] : [{ label: "", width: "48px" }]),
|
|
87
|
-
], rows: rows.map((r) => ({
|
|
88
|
-
key: r.key,
|
|
89
|
-
cells: [
|
|
90
|
-
r.fallback ? _jsx("span", { style: { color: "var(--text-muted)" }, children: r.email }) : r.email,
|
|
91
|
-
_jsx(StatusBadge, { status: r.role }),
|
|
92
|
-
...(managed ? [] : [
|
|
93
|
-
// ActionMenu has no title of its own when disabled: the span carries the guard's reason.
|
|
94
|
-
_jsx("span", { title: guardTitle(r), children: _jsx(ActionMenu, { label: "Member actions", small: true, disabled: r.own || r.lastAdmin, items: [
|
|
95
|
-
{ label: "Change role", run: () => void changeRole(r) },
|
|
96
|
-
{ label: "Remove member", danger: true, run: () => void remove(r) },
|
|
97
|
-
] }) }),
|
|
98
|
-
]),
|
|
99
|
-
],
|
|
100
|
-
})), empty: "No members yet" }), inviting && _jsx(InviteModal, { onClose: () => setInviting(false), onInvite: invite })] }));
|
|
25
|
+
const rows = data ? memberRows(data.members) : [];
|
|
26
|
+
return (_jsx(ListView, { title: "Members", notice: _jsxs(Banner, { tone: "ok", children: ["Members are managed at smoodly.io.", " ", _jsx("a", { href: consoleLinks().members, target: "_blank", rel: "noreferrer", children: "Manage members at smoodly.io" })] }), columns: [{ label: "Email", width: "minmax(200px,1fr)" }, { label: "Role", width: "120px" }], rows: rows.map((r) => ({
|
|
27
|
+
key: r.key,
|
|
28
|
+
cells: [r.fallback ? _jsx("span", { style: { color: "var(--text-muted)" }, children: r.email }) : r.email, _jsx(StatusBadge, { status: r.role })],
|
|
29
|
+
})), empty: "No members yet" }));
|
|
101
30
|
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import type { MemberRole } from "../../permissions.ts";
|
|
2
2
|
import type { AdminOps } from "../ops.ts";
|
|
3
3
|
import type { AdminRegistry } from "../serialize.ts";
|
|
4
|
-
export declare function SettingsView({ ops, registry, tail, role
|
|
4
|
+
export declare function SettingsView({ ops, registry, tail, role }: {
|
|
5
5
|
ops: AdminOps;
|
|
6
6
|
registry: AdminRegistry;
|
|
7
7
|
tail: string | undefined;
|
|
8
8
|
/** The caller's role from `members.me`; null while it loads. */
|
|
9
9
|
role: MemberRole | null;
|
|
10
|
-
userId: string;
|
|
11
10
|
}): import("react").JSX.Element;
|
|
@@ -20,7 +20,7 @@ import { RedirectsPage } from "./RedirectsPage.js";
|
|
|
20
20
|
import { RouteLink } from "./RouteLink.js";
|
|
21
21
|
import { navigate } from "./router.js";
|
|
22
22
|
import { defaultSettingsPage, settingsPage, visibleSettingsPages } from "./settings.js";
|
|
23
|
-
export function SettingsView({ ops, registry, tail, role
|
|
23
|
+
export function SettingsView({ ops, registry, tail, role }) {
|
|
24
24
|
const adminHref = useAdminHref();
|
|
25
25
|
const page = settingsPage(tail);
|
|
26
26
|
useEffect(() => {
|
|
@@ -36,7 +36,7 @@ export function SettingsView({ ops, registry, tail, role, userId }) {
|
|
|
36
36
|
return _jsx(ErrorPane, { message: "Not found." });
|
|
37
37
|
if (!visible.some((p) => p.id === page))
|
|
38
38
|
return _jsx(ErrorPane, { message: "Settings are for admins." });
|
|
39
|
-
return (_jsxs("div", { className: "sm-settings", children: [_jsx("nav", { className: "sm-settings__nav", "aria-label": "Settings", children: visible.map((p) => (_jsx(RouteLink, { className: "sm-settings__link", href: adminHref(`/settings/${p.id}`), "aria-current": p.id === page ? "page" : undefined, children: p.title }, p.id))) }), _jsx("section", { className: "sm-settings__page", children: page === "members" ? _jsx(MembersPage, { ops: ops
|
|
39
|
+
return (_jsxs("div", { className: "sm-settings", children: [_jsx("nav", { className: "sm-settings__nav", "aria-label": "Settings", children: visible.map((p) => (_jsx(RouteLink, { className: "sm-settings__link", href: adminHref(`/settings/${p.id}`), "aria-current": p.id === page ? "page" : undefined, children: p.title }, p.id))) }), _jsx("section", { className: "sm-settings__page", children: page === "members" ? _jsx(MembersPage, { ops: ops })
|
|
40
40
|
: page === "redirects" ? _jsx(RedirectsPage, { ops: ops, registry: registry })
|
|
41
41
|
: _jsx(ContentHealthPage, { ops: ops, registry: registry }) })] }));
|
|
42
42
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// After Sign out, or a sign-in the space refused (spec 2026-10-16 sign-in and the dashboard §2, §6): a page, never a
|
|
4
|
+
// redirect, since a redirect would sign the person straight back in while their smoodly.io session lives. Sign in
|
|
5
|
+
// sends them to sign in again.
|
|
6
|
+
import { Button, Logo } from "../ui/primitives.js";
|
|
7
|
+
export function SignedOutView({ host, message, onSignIn }) {
|
|
8
|
+
return (_jsx("div", { style: { height: "100vh", display: "grid", placeItems: "center", padding: 24 }, children: _jsxs("div", { style: { maxWidth: 380, display: "grid", gap: 16, justifyItems: "center", textAlign: "center" }, children: [_jsx("p", { style: { margin: 0, fontSize: 16 }, children: message ? message.replace(/^smoodly:\s*/, "") : `You're signed out of ${host}.` }), _jsx(Button, { onClick: onSignIn, children: "Sign in" }), _jsx("span", { style: { color: "var(--text-muted)" }, children: _jsx(Logo, { height: 12 }) })] }) }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Where the admin points at smoodly.io (spec 2026-10-16 sign-in and the dashboard §5, §6): the account menu's "Your
|
|
2
|
+
// sites at smoodly.io" and the Members page's "Manage members at smoodly.io", deep links into the dashboard the
|
|
3
|
+
// service names (`GET /v1/auth`'s consoleUrl). Set once by the entry from the page's data.
|
|
4
|
+
let console = { url: "https://smoodly.io", spaceId: "" };
|
|
5
|
+
export function setConsole(url, spaceId) {
|
|
6
|
+
console = { url: url.replace(/\/+$/, ""), spaceId };
|
|
7
|
+
}
|
|
8
|
+
export const consoleLinks = () => ({
|
|
9
|
+
sites: `${console.url}/projects`,
|
|
10
|
+
members: `${console.url}/spaces/${encodeURIComponent(console.spaceId)}/members`,
|
|
11
|
+
});
|
|
@@ -1,23 +1,10 @@
|
|
|
1
1
|
import type { MemberRecord } from "../../member-store.ts";
|
|
2
|
-
import { type MemberRole } from "../../permissions.ts";
|
|
3
2
|
export type MemberRow = {
|
|
4
3
|
key: string;
|
|
5
4
|
email: string;
|
|
6
5
|
fallback: boolean;
|
|
7
6
|
/** Display label. */
|
|
8
7
|
role: string;
|
|
9
|
-
/** The normalized role, what the control holds. */
|
|
10
|
-
value: MemberRole;
|
|
11
|
-
own: boolean;
|
|
12
|
-
lastAdmin: boolean;
|
|
13
8
|
};
|
|
14
9
|
export declare const roleLabel: (role: string) => string;
|
|
15
|
-
|
|
16
|
-
export declare const ROLE_DESCRIPTION: Record<MemberRole, string>;
|
|
17
|
-
export declare function memberRows(members: MemberRecord[], opts: {
|
|
18
|
-
me: string;
|
|
19
|
-
}): MemberRow[];
|
|
20
|
-
export declare const GUARD_TITLE: {
|
|
21
|
-
readonly own: "You cannot change your own membership.";
|
|
22
|
-
readonly lastAdmin: "This site needs at least one admin.";
|
|
23
|
-
};
|
|
10
|
+
export declare function memberRows(members: MemberRecord[]): MemberRow[];
|
|
@@ -1,26 +1,5 @@
|
|
|
1
1
|
import { normalizeRole } from "../../permissions.js";
|
|
2
2
|
export const roleLabel = (role) => (role ? role[0].toUpperCase() + role.slice(1) : "");
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
editor: "Edits, publishes and deletes content and media, and manages redirects.",
|
|
6
|
-
admin: "Everything an editor can, and Settings: invites, re-roles and removes members.",
|
|
7
|
-
};
|
|
8
|
-
export function memberRows(members, opts) {
|
|
9
|
-
const admins = members.filter((m) => normalizeRole(m.role) === "admin").length;
|
|
10
|
-
return members.map((m) => {
|
|
11
|
-
const value = normalizeRole(m.role);
|
|
12
|
-
return {
|
|
13
|
-
key: m.userId,
|
|
14
|
-
email: m.email ?? m.userId,
|
|
15
|
-
fallback: m.email === null,
|
|
16
|
-
role: roleLabel(value),
|
|
17
|
-
value,
|
|
18
|
-
own: m.userId === opts.me,
|
|
19
|
-
lastAdmin: value === "admin" && admins <= 1,
|
|
20
|
-
};
|
|
21
|
-
});
|
|
3
|
+
export function memberRows(members) {
|
|
4
|
+
return members.map((m) => ({ key: m.userId, email: m.email ?? m.userId, fallback: m.email === null, role: roleLabel(normalizeRole(m.role)) }));
|
|
22
5
|
}
|
|
23
|
-
export const GUARD_TITLE = {
|
|
24
|
-
own: "You cannot change your own membership.",
|
|
25
|
-
lastAdmin: "This site needs at least one admin.",
|
|
26
|
-
};
|
|
@@ -66,8 +66,6 @@ export type TreeProjection = Projection;
|
|
|
66
66
|
* them any deeper slot, the move op refuses any other parent. A record
|
|
67
67
|
* no longer in code does not move at all: deleting it is all that is open. */
|
|
68
68
|
export declare const canDragRow: (row: PagesTreeRow) => boolean;
|
|
69
|
-
/** Whether a row may only reorder among the root siblings. */
|
|
70
|
-
export declare const rootOnly: (row: PagesTreeRow) => boolean;
|
|
71
69
|
/** Levels below a node: 0 for a leaf. From the records, because the
|
|
72
70
|
* dragged node's rows are collapsed away during the drag. */
|
|
73
71
|
export declare function subtreeHeight(records: {
|
|
@@ -135,7 +135,7 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
|
|
|
135
135
|
* no longer in code does not move at all: deleting it is all that is open. */
|
|
136
136
|
export const canDragRow = (row) => !row.orphan;
|
|
137
137
|
/** Whether a row may only reorder among the root siblings. */
|
|
138
|
-
|
|
138
|
+
const rootOnly = (row) => row.fixed || row.isHome;
|
|
139
139
|
/** Levels below a node: 0 for a leaf. From the records, because the
|
|
140
140
|
* dragged node's rows are collapsed away during the drag. */
|
|
141
141
|
export function subtreeHeight(records, id) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PresenceConfig } from "../../env.ts";
|
|
2
|
+
import { type PresenceChannel } from "./presence.ts";
|
|
3
|
+
/** What the channel asks of the admin's session: the current pass, and a renewal (true when a fresh pass is in hand). */
|
|
4
|
+
export type SocketSession = {
|
|
5
|
+
pass: () => string | null;
|
|
6
|
+
renew: () => Promise<boolean>;
|
|
7
|
+
fetch?: typeof fetch;
|
|
8
|
+
};
|
|
9
|
+
/** `email` is the caller's shape: the object knows the editor from the verified ticket. */
|
|
10
|
+
export declare function connectSocket(config: PresenceConfig, _email: string, session: SocketSession, make?: (url: string) => WebSocket): PresenceChannel;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { isTarget } from "./presence.js";
|
|
3
|
+
const PING_MS = 15000;
|
|
4
|
+
const RETRY_MS = [1000, 2000, 4000, 8000, 15000, 30000];
|
|
5
|
+
/** After this many closes without a sync the service is taken to refuse (a misconfigured deployment): no more tries
|
|
6
|
+
* until the next renewal. */
|
|
7
|
+
const MAX_RETRIES = RETRY_MS.length;
|
|
8
|
+
/** The object's close codes: the pass expired or the session ended (renew, then reconnect); the session signed out and
|
|
9
|
+
* the account removed (both final). */
|
|
10
|
+
const PASS_ENDED = 4001, SIGNED_OUT = 4002, REMOVED = 4003;
|
|
11
|
+
const OPEN = 1;
|
|
12
|
+
/** `email` is the caller's shape: the object knows the editor from the verified ticket. */
|
|
13
|
+
export function connectSocket(config, _email, session, make = (url) => new WebSocket(url)) {
|
|
14
|
+
const api = config.apiUrl.replace(/\/+$/, "");
|
|
15
|
+
const space = encodeURIComponent(config.spaceId);
|
|
16
|
+
const address = (ticket) => `${api.replace(/^http/, "ws")}/v1/spaces/${space}/admin/presence?ticket=${encodeURIComponent(ticket)}`;
|
|
17
|
+
/** A ticket for the current pass; null when the service refuses it (an expired pass, an ended session). Throws when
|
|
18
|
+
* the service cannot be reached. */
|
|
19
|
+
const ticketFor = async (pass) => {
|
|
20
|
+
const res = await (session.fetch ?? fetch)(`${api}/v1/spaces/${space}/admin/presence/ticket`, { method: "POST", headers: { authorization: `Bearer ${pass}` } });
|
|
21
|
+
const body = (await res.json().catch(() => null));
|
|
22
|
+
return res.ok && body?.ok && typeof body.data?.ticket === "string" ? body.data.ticket : null;
|
|
23
|
+
};
|
|
24
|
+
let socket = null;
|
|
25
|
+
/** Bumped by every open and by unsubscribe: an answer that arrives for an older attempt is dropped. */
|
|
26
|
+
let attempt = 0;
|
|
27
|
+
let target = null;
|
|
28
|
+
let onStatus = null;
|
|
29
|
+
let state = {};
|
|
30
|
+
let retry = null;
|
|
31
|
+
let failures = 0;
|
|
32
|
+
let stopped = false;
|
|
33
|
+
const syncListeners = [];
|
|
34
|
+
const broadcastListeners = {};
|
|
35
|
+
const send = (m) => { if (socket && socket.readyState === OPEN)
|
|
36
|
+
socket.send(JSON.stringify(m)); };
|
|
37
|
+
const cancelRetry = () => { if (retry) {
|
|
38
|
+
clearTimeout(retry);
|
|
39
|
+
retry = null;
|
|
40
|
+
} };
|
|
41
|
+
/** Ends a socket without reporting it. */
|
|
42
|
+
const end = (ws) => { try {
|
|
43
|
+
ws?.close(1000);
|
|
44
|
+
}
|
|
45
|
+
catch { /* already gone */ } };
|
|
46
|
+
/** A connection that never opened (no ticket, the service unreachable): CLOSED and the backoff, as a drop. */
|
|
47
|
+
const failedBeforeOpen = (previous) => {
|
|
48
|
+
end(previous);
|
|
49
|
+
state = {};
|
|
50
|
+
onStatus?.("CLOSED");
|
|
51
|
+
scheduleRetry();
|
|
52
|
+
};
|
|
53
|
+
const scheduleRetry = () => {
|
|
54
|
+
if (stopped || failures >= MAX_RETRIES)
|
|
55
|
+
return;
|
|
56
|
+
const delay = RETRY_MS[Math.min(failures, RETRY_MS.length - 1)];
|
|
57
|
+
failures++;
|
|
58
|
+
retry = setTimeout(() => { retry = null; if (!stopped && !socket)
|
|
59
|
+
open(); }, delay);
|
|
60
|
+
};
|
|
61
|
+
/** Asks a ticket with the current pass, renewing once when it is refused, then opens the socket. `previous` (a
|
|
62
|
+
* renewal's old socket) stays until the new one is open, so the others never see the editor leave and come back. */
|
|
63
|
+
const open = (previous = null, renewed = false) => {
|
|
64
|
+
const mine = ++attempt;
|
|
65
|
+
const pass = session.pass();
|
|
66
|
+
const asked = pass ? ticketFor(pass) : Promise.resolve(null);
|
|
67
|
+
void asked.then(async (ticket) => {
|
|
68
|
+
if (stopped || mine !== attempt)
|
|
69
|
+
return;
|
|
70
|
+
if (ticket) {
|
|
71
|
+
connect(ticket, previous);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (!renewed && (await session.renew().catch(() => false)) && !stopped && mine === attempt) {
|
|
75
|
+
open(previous, true);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!stopped && mine === attempt) {
|
|
79
|
+
end(previous);
|
|
80
|
+
state = {};
|
|
81
|
+
onStatus?.("CLOSED");
|
|
82
|
+
}
|
|
83
|
+
}, () => { if (!stopped && mine === attempt)
|
|
84
|
+
failedBeforeOpen(previous); });
|
|
85
|
+
};
|
|
86
|
+
const connect = (ticket, previous) => {
|
|
87
|
+
let ws;
|
|
88
|
+
try {
|
|
89
|
+
ws = make(address(ticket));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// A constructor refusal (mixed content, a bad address) does not fix itself: presence is off, the admin is not.
|
|
93
|
+
socket = null;
|
|
94
|
+
end(previous);
|
|
95
|
+
onStatus?.("CHANNEL_ERROR");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
socket = ws;
|
|
99
|
+
let ping = null;
|
|
100
|
+
ws.addEventListener("open", () => {
|
|
101
|
+
if (socket !== ws)
|
|
102
|
+
return;
|
|
103
|
+
end(previous);
|
|
104
|
+
previous = null;
|
|
105
|
+
onStatus?.("SUBSCRIBED");
|
|
106
|
+
if (target)
|
|
107
|
+
send({ type: "track", target });
|
|
108
|
+
ping = setInterval(() => { if (socket === ws)
|
|
109
|
+
send({ type: "ping" }); }, PING_MS);
|
|
110
|
+
});
|
|
111
|
+
ws.addEventListener("message", (e) => {
|
|
112
|
+
if (socket !== ws)
|
|
113
|
+
return;
|
|
114
|
+
let m;
|
|
115
|
+
try {
|
|
116
|
+
m = JSON.parse(String(e.data));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (m.type === "sync" && Array.isArray(m.others)) {
|
|
122
|
+
// A sync proves the connection: the backoff starts over only now, not on a socket closed right after opening.
|
|
123
|
+
failures = 0;
|
|
124
|
+
const next = {};
|
|
125
|
+
for (const o of m.others) {
|
|
126
|
+
const email = o.email;
|
|
127
|
+
if (isTarget(o) && typeof email === "string")
|
|
128
|
+
(next[email] ?? (next[email] = [])).push({ kind: o.kind, id: o.id, locale: o.locale });
|
|
129
|
+
}
|
|
130
|
+
state = next;
|
|
131
|
+
for (const l of syncListeners)
|
|
132
|
+
l();
|
|
133
|
+
}
|
|
134
|
+
else if (m.type === "saved") {
|
|
135
|
+
const { type: _type, ...payload } = m;
|
|
136
|
+
for (const l of broadcastListeners.saved ?? [])
|
|
137
|
+
l(payload);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
ws.addEventListener("close", (e) => {
|
|
141
|
+
if (ping) {
|
|
142
|
+
clearInterval(ping);
|
|
143
|
+
ping = null;
|
|
144
|
+
}
|
|
145
|
+
if (socket !== ws || stopped)
|
|
146
|
+
return;
|
|
147
|
+
socket = null;
|
|
148
|
+
end(previous);
|
|
149
|
+
previous = null;
|
|
150
|
+
state = {};
|
|
151
|
+
onStatus?.("CLOSED");
|
|
152
|
+
const code = e.code;
|
|
153
|
+
if (code === REMOVED || code === SIGNED_OUT)
|
|
154
|
+
return;
|
|
155
|
+
// The pass expired or the session ended: renew, then reconnect with a ticket from the fresh pass.
|
|
156
|
+
if (code === PASS_ENDED) {
|
|
157
|
+
if (failures >= MAX_RETRIES)
|
|
158
|
+
return;
|
|
159
|
+
failures++;
|
|
160
|
+
void session.renew().catch(() => false).then((ok) => { if (ok && !stopped && !socket)
|
|
161
|
+
open(); });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
scheduleRetry();
|
|
165
|
+
});
|
|
166
|
+
};
|
|
167
|
+
return {
|
|
168
|
+
subscribe(cb) { onStatus = cb; open(); },
|
|
169
|
+
async setAuth() {
|
|
170
|
+
if (stopped || !onStatus)
|
|
171
|
+
return;
|
|
172
|
+
cancelRetry();
|
|
173
|
+
failures = 0;
|
|
174
|
+
const old = socket;
|
|
175
|
+
socket = null;
|
|
176
|
+
open(old);
|
|
177
|
+
},
|
|
178
|
+
async track(s) { target = isTarget(s) ? { kind: s.kind, id: s.id, locale: s.locale } : null; send(target ? { type: "track", target } : { type: "untrack" }); },
|
|
179
|
+
async untrack() { target = null; send({ type: "untrack" }); },
|
|
180
|
+
async send(message) {
|
|
181
|
+
const p = message.payload, savedAt = p.savedAt;
|
|
182
|
+
if (message.event !== "saved" || !isTarget(p))
|
|
183
|
+
return;
|
|
184
|
+
send({ type: "saved", target: { kind: p.kind, id: p.id, locale: p.locale }, savedAt: typeof savedAt === "string" ? savedAt : null });
|
|
185
|
+
},
|
|
186
|
+
onSync(l) { syncListeners.push(l); },
|
|
187
|
+
presenceState: () => state,
|
|
188
|
+
onBroadcast(event, l) { (broadcastListeners[event] ?? (broadcastListeners[event] = [])).push(l); },
|
|
189
|
+
async unsubscribe() { stopped = true; attempt++; cancelRetry(); const old = socket; socket = null; end(old); },
|
|
190
|
+
};
|
|
191
|
+
}
|