nextpluggable 0.0.0-stage โ†’ 0.1.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 (71) hide show
  1. package/bin/nextpluggable.js +10 -0
  2. package/dist/api/handler.d.ts +10 -0
  3. package/dist/api/handler.js +188 -0
  4. package/dist/auth/custom.d.ts +13 -0
  5. package/dist/auth/custom.js +12 -0
  6. package/dist/auth/password.d.ts +10 -0
  7. package/dist/auth/password.js +66 -0
  8. package/dist/auth/types.d.ts +23 -0
  9. package/dist/auth/types.js +1 -0
  10. package/dist/cli.d.ts +9 -0
  11. package/dist/cli.js +119 -0
  12. package/dist/client/host-runtime.d.ts +9 -0
  13. package/dist/client/host-runtime.js +71 -0
  14. package/dist/client/island.d.ts +28 -0
  15. package/dist/client/island.js +177 -0
  16. package/dist/client/ssr-guard.d.ts +11 -0
  17. package/dist/client/ssr-guard.js +100 -0
  18. package/dist/config.d.ts +46 -0
  19. package/dist/config.js +55 -0
  20. package/dist/core/installer.d.ts +30 -0
  21. package/dist/core/installer.js +340 -0
  22. package/dist/core/routes.d.ts +20 -0
  23. package/dist/core/routes.js +95 -0
  24. package/dist/core/runtime.d.ts +32 -0
  25. package/dist/core/runtime.js +190 -0
  26. package/dist/core/state.d.ts +13 -0
  27. package/dist/core/state.js +23 -0
  28. package/dist/core/store.d.ts +17 -0
  29. package/dist/core/store.js +58 -0
  30. package/dist/core/types.d.ts +69 -0
  31. package/dist/core/types.js +1 -0
  32. package/dist/crypto.d.ts +14 -0
  33. package/dist/crypto.js +61 -0
  34. package/dist/dashboard/Dashboard.d.ts +11 -0
  35. package/dist/dashboard/Dashboard.js +324 -0
  36. package/dist/dashboard/styles.d.ts +2 -0
  37. package/dist/dashboard/styles.js +129 -0
  38. package/dist/dashboard/ui.d.ts +61 -0
  39. package/dist/dashboard/ui.js +135 -0
  40. package/dist/errors.d.ts +15 -0
  41. package/dist/errors.js +29 -0
  42. package/dist/globals.d.ts +24 -0
  43. package/dist/globals.js +24 -0
  44. package/dist/index.d.ts +40 -0
  45. package/dist/index.js +48 -0
  46. package/dist/next/dashboard-page.d.ts +5 -0
  47. package/dist/next/dashboard-page.js +23 -0
  48. package/dist/next/plugin-page.d.ts +16 -0
  49. package/dist/next/plugin-page.js +102 -0
  50. package/dist/next/slot.d.ts +7 -0
  51. package/dist/next/slot.js +42 -0
  52. package/dist/sources/github.d.ts +19 -0
  53. package/dist/sources/github.js +109 -0
  54. package/dist/sources/local.d.ts +3 -0
  55. package/dist/sources/local.js +22 -0
  56. package/dist/sources/reader.d.ts +17 -0
  57. package/dist/sources/reader.js +78 -0
  58. package/dist/storage/file.d.ts +12 -0
  59. package/dist/storage/file.js +61 -0
  60. package/dist/storage/memory.d.ts +3 -0
  61. package/dist/storage/memory.js +22 -0
  62. package/dist/storage/postgres.d.ts +15 -0
  63. package/dist/storage/postgres.js +39 -0
  64. package/dist/storage/types.d.ts +18 -0
  65. package/dist/storage/types.js +15 -0
  66. package/dist/storage/upstash.d.ts +15 -0
  67. package/dist/storage/upstash.js +61 -0
  68. package/dist/views.d.ts +112 -0
  69. package/dist/views.js +124 -0
  70. package/package.json +48 -3
  71. package/README.md +0 -3
@@ -0,0 +1,13 @@
1
+ import type { ResolvedConfig } from '../config.js';
2
+ import type { Installs } from './types.js';
3
+ /**
4
+ * Installed plugins, read through a short-lived per-process cache so that a
5
+ * page render costs at most one storage read every `cacheTtlMs`.
6
+ *
7
+ * The cache lives on `globalThis` and is keyed by the instance's API path, not
8
+ * by any object identity: Next.js loads the site's setup module once per module
9
+ * graph (pages, route handlers), and a change made through the dashboard's
10
+ * route handler must be visible to the very next page render.
11
+ */
12
+ export declare function getInstalledPlugins(cfg: ResolvedConfig): Promise<Installs>;
13
+ export declare function invalidateInstalledPlugins(cfg: ResolvedConfig): void;
@@ -0,0 +1,23 @@
1
+ import { globals } from '../globals.js';
2
+ import { getInstalls } from './store.js';
3
+ /**
4
+ * Installed plugins, read through a short-lived per-process cache so that a
5
+ * page render costs at most one storage read every `cacheTtlMs`.
6
+ *
7
+ * The cache lives on `globalThis` and is keyed by the instance's API path, not
8
+ * by any object identity: Next.js loads the site's setup module once per module
9
+ * graph (pages, route handlers), and a change made through the dashboard's
10
+ * route handler must be visible to the very next page render.
11
+ */
12
+ export async function getInstalledPlugins(cfg) {
13
+ const cache = globals().state;
14
+ const hit = cache.get(cfg.apiPath);
15
+ if (hit && Date.now() - hit.at < cfg.cacheTtlMs)
16
+ return hit.value;
17
+ const value = await getInstalls(cfg.storage);
18
+ cache.set(cfg.apiPath, { at: Date.now(), value });
19
+ return value;
20
+ }
21
+ export function invalidateInstalledPlugins(cfg) {
22
+ globals().state.delete(cfg.apiPath);
23
+ }
@@ -0,0 +1,17 @@
1
+ import { type StorageAdapter } from '../storage/types.js';
2
+ import type { Catalog, FileKind, Installs, SourceRecord, StoredFile } from './types.js';
3
+ export declare const getSources: (s: StorageAdapter) => Promise<SourceRecord[]>;
4
+ export declare const saveSources: (s: StorageAdapter, sources: SourceRecord[]) => Promise<void>;
5
+ export declare const getInstalls: (s: StorageAdapter) => Promise<Installs>;
6
+ export declare const saveInstalls: (s: StorageAdapter, installs: Installs) => Promise<void>;
7
+ export declare const getCatalog: (s: StorageAdapter, sourceId: string) => Promise<Catalog | null>;
8
+ export declare const saveCatalog: (s: StorageAdapter, catalog: Catalog) => Promise<void>;
9
+ export declare const deleteCatalog: (s: StorageAdapter, sourceId: string) => Promise<void>;
10
+ export declare const fileKey: (pluginId: string, kind: FileKind, sha256: string) => string;
11
+ export declare function putFile(s: StorageAdapter, pluginId: string, kind: FileKind, sha256: string, content: string): Promise<void>;
12
+ /** Plugin files are content-addressed and never change, so they are cached in memory for the life of the process. */
13
+ export declare function getFile(s: StorageAdapter, pluginId: string, kind: FileKind, sha256: string): Promise<string | null>;
14
+ /** Delete stored files of a plugin except the ones listed. */
15
+ export declare function pruneFiles(s: StorageAdapter, pluginId: string, keep: Partial<Record<FileKind, StoredFile>>[]): Promise<void>;
16
+ export declare const dataKey: (pluginId: string, key: string) => string;
17
+ export declare function deletePluginData(s: StorageAdapter, pluginId: string): Promise<void>;
@@ -0,0 +1,58 @@
1
+ import { globals } from '../globals.js';
2
+ import { encodeSegment } from '../storage/types.js';
3
+ const FILE_NAMES = { client: 'client.js', server: 'server.js', style: 'style.css' };
4
+ async function readJson(storage, key, fallback) {
5
+ const raw = await storage.get(key);
6
+ if (raw === null)
7
+ return fallback;
8
+ try {
9
+ return JSON.parse(raw);
10
+ }
11
+ catch {
12
+ throw new Error(`[nextpluggable] Stored record "${key}" is corrupted.`);
13
+ }
14
+ }
15
+ export const getSources = (s) => readJson(s, 'sources.json', []);
16
+ export const saveSources = (s, sources) => s.set('sources.json', JSON.stringify(sources));
17
+ export const getInstalls = (s) => readJson(s, 'installs.json', {});
18
+ export const saveInstalls = (s, installs) => s.set('installs.json', JSON.stringify(installs));
19
+ export const getCatalog = (s, sourceId) => readJson(s, `catalogs/${sourceId}.json`, null);
20
+ export const saveCatalog = (s, catalog) => s.set(`catalogs/${catalog.sourceId}.json`, JSON.stringify(catalog));
21
+ export const deleteCatalog = (s, sourceId) => s.delete(`catalogs/${sourceId}.json`);
22
+ export const fileKey = (pluginId, kind, sha256) => `files/${pluginId}/${sha256}.${FILE_NAMES[kind]}`;
23
+ export async function putFile(s, pluginId, kind, sha256, content) {
24
+ const key = fileKey(pluginId, kind, sha256);
25
+ await s.set(key, content);
26
+ globals().files.set(key, content);
27
+ }
28
+ /** Plugin files are content-addressed and never change, so they are cached in memory for the life of the process. */
29
+ export async function getFile(s, pluginId, kind, sha256) {
30
+ const key = fileKey(pluginId, kind, sha256);
31
+ const cache = globals().files;
32
+ const hit = cache.get(key);
33
+ if (hit !== undefined)
34
+ return hit;
35
+ const content = await s.get(key);
36
+ if (content !== null)
37
+ cache.set(key, content);
38
+ return content;
39
+ }
40
+ /** Delete stored files of a plugin except the ones listed. */
41
+ export async function pruneFiles(s, pluginId, keep) {
42
+ const keepKeys = new Set();
43
+ for (const set of keep)
44
+ for (const [kind, f] of Object.entries(set))
45
+ if (f)
46
+ keepKeys.add(fileKey(pluginId, kind, f.sha256));
47
+ for (const key of await s.list(`files/${pluginId}/`)) {
48
+ if (keepKeys.has(key))
49
+ continue;
50
+ await s.delete(key);
51
+ globals().files.delete(key);
52
+ }
53
+ }
54
+ export const dataKey = (pluginId, key) => `data/${pluginId}/${encodeSegment(key)}`;
55
+ export async function deletePluginData(s, pluginId) {
56
+ for (const key of await s.list(`data/${pluginId}/`))
57
+ await s.delete(key);
58
+ }
@@ -0,0 +1,69 @@
1
+ import type { PluginBuild, PluginManifest } from '@nextpluggable/plugin/manifest';
2
+ export interface SourceRecord {
3
+ id: string;
4
+ type: 'github' | 'local';
5
+ /** Human name, e.g. `acme/site-plugins`. */
6
+ label: string;
7
+ addedAt: string;
8
+ github?: {
9
+ owner: string;
10
+ repo: string;
11
+ /** Branch or tag to follow. Default: the repository's default branch. */
12
+ ref?: string;
13
+ /** Sub-directory that holds the plugins. */
14
+ dir?: string;
15
+ /** Access token, encrypted with the site secret. */
16
+ token?: string;
17
+ };
18
+ local?: {
19
+ dir: string;
20
+ };
21
+ }
22
+ export interface CatalogEntry {
23
+ /** Plugin directory inside the source. */
24
+ path: string;
25
+ build: PluginBuild;
26
+ /** Fingerprint of dist/build.json โ€” changes whenever the plugin is rebuilt. */
27
+ buildHash: string;
28
+ }
29
+ export interface Catalog {
30
+ sourceId: string;
31
+ /** Commit the listing was read at. */
32
+ revision: string;
33
+ fetchedAt: string;
34
+ name?: string;
35
+ description?: string;
36
+ plugins: CatalogEntry[];
37
+ problems: {
38
+ path: string;
39
+ message: string;
40
+ }[];
41
+ }
42
+ export type FileKind = 'client' | 'server' | 'style';
43
+ export interface StoredFile {
44
+ sha256: string;
45
+ bytes: number;
46
+ }
47
+ export interface InstallRecord {
48
+ id: string;
49
+ manifest: PluginManifest;
50
+ icon?: string;
51
+ buildHash: string;
52
+ source: {
53
+ id: string;
54
+ label: string;
55
+ path: string;
56
+ revision: string;
57
+ };
58
+ files: Partial<Record<FileKind, StoredFile>>;
59
+ /** Files of the previously installed version, kept so pages cached before an update keep working. */
60
+ previousFiles?: Partial<Record<FileKind, StoredFile>>;
61
+ enabled: boolean;
62
+ /** Setting values; secrets are encrypted. */
63
+ config: Record<string, string | number | boolean>;
64
+ /** Page addresses chosen by the site owner, keyed by page id. */
65
+ paths: Record<string, string>;
66
+ installedAt: string;
67
+ updatedAt: string;
68
+ }
69
+ export type Installs = Record<string, InstallRecord>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ export declare const sha256Hex: (data: string | Uint8Array) => string;
2
+ export declare function hmac(secret: string, data: string): string;
3
+ /** Constant-time string comparison (compares digests so length is not leaked). */
4
+ export declare function safeEqual(a: string, b: string): boolean;
5
+ export declare const isEncrypted: (value: unknown) => value is string;
6
+ /** AES-256-GCM with a key derived from the site secret. Used for plugin secrets and source tokens at rest. */
7
+ export declare function encrypt(secret: string, plaintext: string): string;
8
+ /** Returns null when the value cannot be decrypted (wrong secret or tampering). */
9
+ export declare function decrypt(secret: string, value: string): string | null;
10
+ /** A signed, expiring token: `<payload>.<signature>`. */
11
+ export declare function signToken(secret: string, payload: Record<string, unknown>): string;
12
+ export declare function verifyToken<T extends {
13
+ exp: number;
14
+ }>(secret: string, token: string | undefined | null): T | null;
package/dist/crypto.js ADDED
@@ -0,0 +1,61 @@
1
+ import { createCipheriv, createDecipheriv, createHash, createHmac, hkdfSync, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ export const sha256Hex = (data) => createHash('sha256').update(data).digest('hex');
3
+ const b64u = (buf) => Buffer.from(buf).toString('base64url');
4
+ export function hmac(secret, data) {
5
+ return createHmac('sha256', secret).update(data).digest('base64url');
6
+ }
7
+ /** Constant-time string comparison (compares digests so length is not leaked). */
8
+ export function safeEqual(a, b) {
9
+ return timingSafeEqual(createHash('sha256').update(a).digest(), createHash('sha256').update(b).digest());
10
+ }
11
+ function key(secret, purpose) {
12
+ return Buffer.from(hkdfSync('sha256', secret, 'nextpluggable', purpose, 32));
13
+ }
14
+ const ENC_PREFIX = 'enc:v1:';
15
+ export const isEncrypted = (value) => typeof value === 'string' && value.startsWith(ENC_PREFIX);
16
+ /** AES-256-GCM with a key derived from the site secret. Used for plugin secrets and source tokens at rest. */
17
+ export function encrypt(secret, plaintext) {
18
+ const iv = randomBytes(12);
19
+ const cipher = createCipheriv('aes-256-gcm', key(secret, 'settings'), iv);
20
+ const body = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final(), cipher.getAuthTag()]);
21
+ return `${ENC_PREFIX}${b64u(iv)}:${b64u(body)}`;
22
+ }
23
+ /** Returns null when the value cannot be decrypted (wrong secret or tampering). */
24
+ export function decrypt(secret, value) {
25
+ if (!isEncrypted(value))
26
+ return null;
27
+ const [ivPart, bodyPart] = value.slice(ENC_PREFIX.length).split(':');
28
+ if (!ivPart || !bodyPart)
29
+ return null;
30
+ try {
31
+ const body = Buffer.from(bodyPart, 'base64url');
32
+ const decipher = createDecipheriv('aes-256-gcm', key(secret, 'settings'), Buffer.from(ivPart, 'base64url'));
33
+ decipher.setAuthTag(body.subarray(body.length - 16));
34
+ return Buffer.concat([decipher.update(body.subarray(0, body.length - 16)), decipher.final()]).toString('utf8');
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ /** A signed, expiring token: `<payload>.<signature>`. */
41
+ export function signToken(secret, payload) {
42
+ const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
43
+ return `${body}.${hmac(secret, body)}`;
44
+ }
45
+ export function verifyToken(secret, token) {
46
+ if (!token)
47
+ return null;
48
+ const dot = token.lastIndexOf('.');
49
+ if (dot < 1)
50
+ return null;
51
+ const body = token.slice(0, dot);
52
+ if (!safeEqual(hmac(secret, body), token.slice(dot + 1)))
53
+ return null;
54
+ try {
55
+ const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
56
+ return typeof payload.exp === 'number' && payload.exp > Date.now() ? payload : null;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
@@ -0,0 +1,11 @@
1
+ import * as React from 'react';
2
+ import type { SessionView } from '../views.js';
3
+ export interface DashboardProps {
4
+ apiPath: string;
5
+ dashboardPath: string;
6
+ siteName: string;
7
+ session: SessionView;
8
+ /** Set when the site's configuration is incomplete (shown instead of the dashboard). */
9
+ setupError?: string;
10
+ }
11
+ export declare function Dashboard(props: DashboardProps): React.JSX.Element;
@@ -0,0 +1,324 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { usePathname } from 'next/navigation';
4
+ import * as React from 'react';
5
+ import { PluginIsland } from '../client/island.js';
6
+ import { dashboardCss } from './styles.js';
7
+ import { ApiError, Button, createApi, describePlugin, Modal, PluginIcon, SettingInput, Switch, timeAgo, Toasts, } from './ui.js';
8
+ /**
9
+ * Move between dashboard screens without a server round trip. Next.js keeps
10
+ * `usePathname()` in sync with `history.pushState`, and, unlike a full
11
+ * navigation (which remounts a catch-all page whenever its segments change),
12
+ * this keeps the dashboard mounted, so loaded data and notices survive.
13
+ */
14
+ function go(href) {
15
+ window.history.pushState(null, '', href);
16
+ document.querySelector('.np-root')?.scrollTo(0, 0);
17
+ }
18
+ function Link({ href, onClick, ...rest }) {
19
+ return (_jsx("a", { href: href, ...rest, onClick: (e) => {
20
+ onClick?.(e);
21
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
22
+ return;
23
+ e.preventDefault();
24
+ go(href);
25
+ } }));
26
+ }
27
+ const DashboardContext = React.createContext(null);
28
+ const useDash = () => React.useContext(DashboardContext);
29
+ export function Dashboard(props) {
30
+ const [session, setSession] = React.useState(props.session);
31
+ const [toasts, setToasts] = React.useState([]);
32
+ const nextId = React.useRef(1);
33
+ const dismiss = React.useCallback((id) => setToasts((t) => t.filter((x) => x.id !== id)), []);
34
+ const notify = React.useCallback((text, kind = 'ok') => {
35
+ const id = nextId.current++;
36
+ setToasts((t) => [...t.slice(-2), { id, kind, text }]);
37
+ setTimeout(() => dismiss(id), kind === 'error' ? 9000 : 4500);
38
+ }, [dismiss]);
39
+ const api = React.useMemo(() => createApi(props.apiPath, () => setSession((s) => ({ ...s, user: null }))), [props.apiPath]);
40
+ // The dashboard covers the site it is embedded in; keep the page behind it from scrolling.
41
+ React.useEffect(() => {
42
+ const previous = document.body.style.overflow;
43
+ document.body.style.overflow = 'hidden';
44
+ return () => {
45
+ document.body.style.overflow = previous;
46
+ };
47
+ }, []);
48
+ let content;
49
+ if (props.setupError || !session.configured)
50
+ content = _jsx(Setup, { message: props.setupError });
51
+ else if (!session.user)
52
+ content = session.mode === 'password' ? _jsx(SignIn, { api: api, siteName: props.siteName, onDone: (user) => setSession((s) => ({ ...s, user })) }) : _jsx(NotAllowed, { loginUrl: session.loginUrl });
53
+ else
54
+ content = (_jsx(App, { api: api, base: props.dashboardPath, siteName: props.siteName, notify: notify, onSignOut: session.mode === 'password'
55
+ ? async () => {
56
+ await api('POST', '/logout').catch(() => { });
57
+ setSession((s) => ({ ...s, user: null }));
58
+ }
59
+ : undefined }));
60
+ return (_jsxs("div", { className: "np-root", children: [_jsx("style", { href: "nextpluggable-dashboard", precedence: "nextpluggable", children: dashboardCss }), content, _jsx(Toasts, { toasts: toasts, dismiss: dismiss })] }));
61
+ }
62
+ // ------------------------------------------------------------------ gates
63
+ function Setup({ message }) {
64
+ return (_jsx("div", { className: "np-center", children: _jsxs("div", { className: "np-card np-auth", style: { maxWidth: 520 }, children: [_jsx("span", { className: "np-brand-mark", "aria-hidden": "true", children: "\u25C6" }), _jsx("h1", { children: "One more step to finish setup" }), _jsx("p", { children: "The plugin dashboard is installed but not switched on yet. Whoever looks after this website\u2019s hosting needs to do this once:" }), message ? _jsx("div", { className: "np-note np-note-warn", children: message }) : null, _jsxs("ol", { className: "np-steps", children: [_jsx("li", { children: _jsxs("span", { children: ["Add ", _jsx("code", { className: "np-code", children: "NEXTPLUGGABLE_PASSWORD" }), " to the site\u2019s environment variables: the password you will use to sign in here."] }) }), _jsx("li", { children: _jsxs("span", { children: ["Add ", _jsx("code", { className: "np-code", children: "NEXTPLUGGABLE_SECRET" }), ": a long random value used to keep sign-ins and saved keys safe."] }) }), _jsx("li", { children: _jsx("span", { children: "Restart or redeploy the site, then reload this page." }) })] })] }) }));
65
+ }
66
+ function NotAllowed({ loginUrl }) {
67
+ return (_jsx("div", { className: "np-center", children: _jsxs("div", { className: "np-card np-auth", children: [_jsx("span", { className: "np-brand-mark", "aria-hidden": "true", children: "\u25C6" }), _jsx("h1", { children: "Sign in to manage plugins" }), _jsx("p", { children: "You need to be signed in to this website with an account that is allowed to manage plugins." }), loginUrl ? (_jsx("a", { className: "np-btn np-btn-primary", href: loginUrl, children: "Go to sign in" })) : null] }) }));
68
+ }
69
+ function SignIn(props) {
70
+ const [password, setPassword] = React.useState('');
71
+ const [error, setError] = React.useState(null);
72
+ const [busy, setBusy] = React.useState(false);
73
+ return (_jsx("div", { className: "np-center", children: _jsxs("form", { className: "np-card np-auth", onSubmit: async (e) => {
74
+ e.preventDefault();
75
+ setBusy(true);
76
+ setError(null);
77
+ try {
78
+ const res = await props.api('POST', '/login', { password });
79
+ props.onDone(res.user);
80
+ }
81
+ catch (err) {
82
+ setError(err.message);
83
+ setBusy(false);
84
+ }
85
+ }, children: [_jsx("span", { className: "np-brand-mark", "aria-hidden": "true", children: "\u25C6" }), _jsxs("h1", { children: ["Plugins for ", props.siteName] }), _jsx("p", { children: "Enter the dashboard password to continue." }), _jsxs("div", { className: "np-field", children: [_jsx("label", { htmlFor: "np-password", children: "Password" }), _jsx("input", { id: "np-password", className: "np-input", type: "password", autoComplete: "current-password", autoFocus: true, required: true, value: password, "aria-invalid": error ? true : undefined, "aria-describedby": error ? 'np-password-error' : undefined, onChange: (e) => setPassword(e.target.value) }), error ? (_jsx("span", { className: "np-error", id: "np-password-error", role: "alert", children: error })) : null] }), _jsx(Button, { type: "submit", variant: "primary", busy: busy, style: { width: '100%' }, children: "Sign in" })] }) }));
86
+ }
87
+ // ------------------------------------------------------------------ shell
88
+ function App(props) {
89
+ const { api, base, notify } = props;
90
+ const [state, setState] = React.useState(null);
91
+ const [loadError, setLoadError] = React.useState(null);
92
+ const pathname = usePathname() ?? base;
93
+ const load = React.useCallback(() => {
94
+ setLoadError(null);
95
+ api('GET', '/admin/state').then(setState, (e) => setLoadError(e.message));
96
+ }, [api]);
97
+ React.useEffect(load, [load]);
98
+ const act = React.useCallback(async (work, done) => {
99
+ try {
100
+ const next = await work();
101
+ setState(next);
102
+ if (done)
103
+ notify(done);
104
+ return next;
105
+ }
106
+ catch (e) {
107
+ notify(e.message, 'error');
108
+ return null;
109
+ }
110
+ }, [notify]);
111
+ const at = pathname.indexOf(base);
112
+ const [section, id] = (at === -1 ? '' : pathname.slice(at + base.length)).split('/').filter(Boolean);
113
+ const current = section === 'add' ? 'add' : section === 'sources' ? 'sources' : 'installed';
114
+ const tab = (href, key, label) => (_jsx(Link, { href: href, "aria-current": current === key ? 'page' : undefined, children: label }));
115
+ let view;
116
+ if (loadError)
117
+ view = (_jsxs("div", { className: "np-card np-empty", children: [_jsx("h2", { children: "The dashboard could not load" }), _jsx("p", { children: loadError }), _jsx(Button, { variant: "primary", onClick: load, children: "Try again" })] }));
118
+ else if (!state)
119
+ view = (_jsxs("div", { className: "np-list", "aria-busy": "true", "aria-label": "Loading", children: [_jsx("div", { className: "np-skel" }), _jsx("div", { className: "np-skel" })] }));
120
+ else if (section === 'plugin' && id)
121
+ view = _jsx(PluginDetail, { id: decodeURIComponent(id) }, id);
122
+ else if (section === 'add')
123
+ view = _jsx(AddPlugins, {});
124
+ else if (section === 'sources')
125
+ view = _jsx(Sources, {});
126
+ else
127
+ view = _jsx(Installed, {});
128
+ return (_jsxs(_Fragment, { children: [_jsx("header", { className: "np-top", children: _jsxs("div", { className: "np-top-in", children: [_jsxs(Link, { href: base, className: "np-brand", children: [_jsx("span", { className: "np-brand-mark", "aria-hidden": "true", children: "\u25C6" }), "Plugins ", _jsx("small", { children: props.siteName })] }), _jsxs("nav", { className: "np-nav", "aria-label": "Dashboard", children: [tab(base, 'installed', 'Installed'), tab(`${base}/add`, 'add', 'Add plugins'), tab(`${base}/sources`, 'sources', 'Sources')] }), _jsx("a", { className: "np-btn np-btn-sm", href: "/", target: "_blank", rel: "noreferrer", children: "View site \u2197" }), props.onSignOut ? (_jsx(Button, { small: true, onClick: props.onSignOut, children: "Sign out" })) : null] }) }), _jsx("main", { className: "np-main", children: state ? _jsx(DashboardContext.Provider, { value: { api, base, state, setState, notify, act }, children: view }) : view })] }));
129
+ }
130
+ function StatusChip({ plugin }) {
131
+ if (plugin.needsSetup)
132
+ return _jsx("span", { className: "np-chip np-chip-warn", children: "Needs setup" });
133
+ return plugin.enabled ? _jsx("span", { className: "np-chip np-chip-on", children: "Active" }) : _jsx("span", { className: "np-chip", children: "Paused" });
134
+ }
135
+ function useRefresh() {
136
+ const { api, act, notify } = useDash();
137
+ const [busy, setBusy] = React.useState(false);
138
+ const refresh = React.useCallback(async (announce) => {
139
+ setBusy(true);
140
+ const res = await act(() => api('POST', '/admin/refresh'));
141
+ setBusy(false);
142
+ if (!res)
143
+ return;
144
+ for (const f of res.failures)
145
+ notify(`${f.label}: ${f.message}`, 'error');
146
+ if (announce && !res.failures.length)
147
+ notify('Everything is up to date with your sources.');
148
+ }, [api, act, notify]);
149
+ return { busy, refresh };
150
+ }
151
+ // ------------------------------------------------------------------ installed
152
+ function Installed() {
153
+ const { state, base } = useDash();
154
+ const { busy, refresh } = useRefresh();
155
+ const updates = state.plugins.filter((p) => p.update).length;
156
+ if (!state.plugins.length)
157
+ return (_jsxs("div", { className: "np-card np-empty", children: [_jsx("div", { className: "np-empty-art", "aria-hidden": "true", children: "\uD83E\uDDE9" }), _jsx("h2", { children: "No plugins installed yet" }), state.sources.length ? (_jsxs(_Fragment, { children: [_jsx("p", { children: "Plugins add new pages and features to your site. Pick one to get started." }), _jsx(Link, { className: "np-btn np-btn-primary", href: `${base}/add`, children: "Browse plugins" })] })) : (_jsxs(_Fragment, { children: [_jsx("p", { children: "Plugins add new pages and features to your site. First, connect a place to get plugins from." }), _jsx(Link, { className: "np-btn np-btn-primary", href: `${base}/sources`, children: "Connect a plugin source" })] }))] }));
158
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "np-head", children: [_jsxs("div", { children: [_jsx("h1", { children: "Installed plugins" }), _jsxs("p", { children: [state.plugins.length, " installed", updates ? ` ยท ${updates} update${updates === 1 ? '' : 's'} available` : ''] })] }), _jsxs("div", { style: { display: 'flex', gap: 10 }, children: [_jsx(Button, { busy: busy, onClick: () => refresh(true), children: "Check for updates" }), _jsx(Link, { className: "np-btn np-btn-primary", href: `${base}/add`, children: "Add plugins" })] })] }), _jsx("ul", { className: "np-list", children: state.plugins.map((p) => (_jsx(InstalledRow, { plugin: p }, p.id))) })] }));
159
+ }
160
+ function InstalledRow({ plugin }) {
161
+ const { api, act, base } = useDash();
162
+ const [busy, setBusy] = React.useState(null);
163
+ const run = async (name, work, done) => {
164
+ setBusy(name);
165
+ await act(work, done);
166
+ setBusy(null);
167
+ };
168
+ const settingsHref = `${base}/plugin/${encodeURIComponent(plugin.id)}`;
169
+ return (_jsxs("li", { className: "np-card np-row", children: [_jsx(PluginIcon, { icon: plugin.icon, name: plugin.name }), _jsxs("div", { children: [_jsxs("div", { className: "np-title", children: [_jsx(Link, { href: settingsHref, children: plugin.name }), _jsx(StatusChip, { plugin: plugin }), _jsxs("span", { className: "np-meta", children: ["v", plugin.version] })] }), plugin.description ? _jsx("p", { className: "np-desc", children: plugin.description }) : null, plugin.enabled && plugin.pages.some((pg) => pg.linkable) ? (_jsx("div", { className: "np-links", children: plugin.pages
170
+ .filter((pg) => pg.linkable)
171
+ .map((pg) => (_jsxs("a", { href: pg.path, target: "_blank", rel: "noreferrer", children: [pg.path, " \u2197"] }, pg.id))) })) : null] }), _jsxs("div", { className: "np-row-actions", children: [plugin.update ? (_jsxs(Button, { small: true, variant: "primary", busy: busy === 'update', onClick: () => run('update', () => api('POST', `/admin/plugins/${plugin.id}/update`), `${plugin.name} was updated to version ${plugin.update.version}.`), children: ["Update to ", plugin.update.version] })) : null, _jsx(Link, { className: "np-btn np-btn-sm", href: settingsHref, children: plugin.needsSetup ? 'Finish setup' : 'Settings' }), _jsx(Switch, { checked: plugin.enabled, disabled: busy !== null || plugin.needsSetup, label: `${plugin.name} is ${plugin.enabled ? 'on' : 'off'}`, onChange: (enabled) => run('toggle', () => api('PATCH', `/admin/plugins/${plugin.id}`, { enabled }), enabled ? `${plugin.name} is now live on your site.` : `${plugin.name} is paused. Its pages are hidden.`) })] })] }));
172
+ }
173
+ // ------------------------------------------------------------------ add plugins
174
+ function AddPlugins() {
175
+ const { state, base, api, act, notify } = useDash();
176
+ const { busy, refresh } = useRefresh();
177
+ const [query, setQuery] = React.useState('');
178
+ const [confirm, setConfirm] = React.useState(null);
179
+ const [installing, setInstalling] = React.useState(false);
180
+ // Pick up newly published plugins without asking the owner to think about it.
181
+ const stale = state.sources.some((s) => !s.checkedAt || Date.now() - new Date(s.checkedAt).getTime() > 5 * 60_000);
182
+ const refreshed = React.useRef(false);
183
+ React.useEffect(() => {
184
+ if (stale && !refreshed.current) {
185
+ refreshed.current = true;
186
+ void refresh(false);
187
+ }
188
+ }, [stale, refresh]);
189
+ if (!state.sources.length)
190
+ return (_jsxs("div", { className: "np-card np-empty", children: [_jsx("div", { className: "np-empty-art", "aria-hidden": "true", children: "\uD83D\uDD0C" }), _jsx("h2", { children: "Connect a plugin source first" }), _jsx("p", { children: "Plugins come from GitHub repositories. Connect one and everything it offers will show up here, ready to install." }), _jsx(Link, { className: "np-btn np-btn-primary", href: `${base}/sources`, children: "Connect a plugin source" })] }));
191
+ const q = query.trim().toLowerCase();
192
+ const items = state.catalog.filter((p) => !q || `${p.name} ${p.description ?? ''} ${p.category ?? ''}`.toLowerCase().includes(q));
193
+ const install = async (p) => {
194
+ setInstalling(true);
195
+ const res = await act(() => api('POST', '/admin/install', { sourceId: p.sourceId, path: p.path }));
196
+ setInstalling(false);
197
+ if (!res)
198
+ return;
199
+ setConfirm(null);
200
+ const installed = res.plugins.find((x) => x.id === p.id);
201
+ notify(installed?.needsSetup ? `${p.name} is installed. Fill in its settings to switch it on.` : `${p.name} is installed and live.`);
202
+ go(`${base}/plugin/${encodeURIComponent(p.id)}`);
203
+ };
204
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "np-head", children: [_jsxs("div", { children: [_jsx("h1", { children: "Add plugins" }), _jsx("p", { children: "Everything offered by your connected sources. Installing takes a few seconds and needs no rebuild." })] }), _jsxs("div", { style: { display: 'flex', gap: 10, flexWrap: 'wrap' }, children: [_jsx("input", { className: "np-input np-search", type: "search", placeholder: "Search plugins", "aria-label": "Search plugins", value: query, onChange: (e) => setQuery(e.target.value) }), _jsx(Button, { busy: busy, onClick: () => refresh(true), children: "Refresh" })] })] }), items.length ? (_jsx("ul", { className: "np-grid", children: items.map((p) => (_jsxs("li", { className: "np-card np-tile", children: [_jsxs("div", { className: "np-tile-head", children: [_jsx(PluginIcon, { icon: p.icon, name: p.name }), _jsxs("div", { children: [_jsx("div", { className: "np-title", children: p.name }), _jsxs("div", { className: "np-meta", children: ["v", p.version, p.category ? ` ยท ${p.category}` : ''] })] })] }), p.description ? _jsx("p", { className: "np-desc", children: p.description }) : null, _jsxs("div", { className: "np-tile-foot", children: [_jsxs("span", { className: "np-meta", children: ["from ", p.sourceLabel] }), p.installed ? (_jsx(Link, { className: "np-btn np-btn-sm", href: `${base}/plugin/${encodeURIComponent(p.id)}`, children: "Installed \u2713" })) : (_jsx(Button, { small: true, variant: "primary", onClick: () => setConfirm(p), children: "Install" }))] })] }, `${p.sourceId}:${p.path}`))) })) : (_jsxs("div", { className: "np-card np-empty", children: [_jsx("h2", { children: q ? 'No plugins match your search' : 'Your sources have no plugins yet' }), _jsx("p", { children: q ? 'Try a different word.' : 'When plugins are published to a connected source they will appear here.' })] })), confirm ? (_jsxs(Modal, { title: `Install ${confirm.name}?`, onClose: () => !installing && setConfirm(null), footer: _jsxs(_Fragment, { children: [_jsx(Button, { onClick: () => setConfirm(null), disabled: installing, children: "Cancel" }), _jsx(Button, { variant: "primary", busy: installing, onClick: () => install(confirm), children: "Install" })] }), children: [_jsxs("p", { children: ["From ", _jsx("strong", { children: confirm.sourceLabel }), ". This plugin:"] }), _jsx("ul", { className: "np-adds", style: { marginTop: 10 }, children: describePlugin(confirm).map((line) => (_jsx("li", { children: line }, line))) }), _jsx("p", { style: { marginTop: 12, fontSize: 13 }, children: "Only install plugins from sources you trust: a plugin can do anything your website can." })] })) : null] }));
205
+ }
206
+ // ------------------------------------------------------------------ sources
207
+ function Sources() {
208
+ const { state, api, setState, notify } = useDash();
209
+ const [url, setUrl] = React.useState('');
210
+ const [isPrivate, setIsPrivate] = React.useState(false);
211
+ const [token, setToken] = React.useState('');
212
+ const [busy, setBusy] = React.useState(false);
213
+ const [error, setError] = React.useState(null);
214
+ const [removing, setRemoving] = React.useState(null);
215
+ const connect = async (e) => {
216
+ e.preventDefault();
217
+ setBusy(true);
218
+ setError(null);
219
+ try {
220
+ const res = await api('POST', '/admin/sources', { url, token: isPrivate ? token : undefined });
221
+ const added = res.sources[res.sources.length - 1];
222
+ setState(res);
223
+ notify(added ? `Connected ${added.label}: ${added.pluginCount} plugin${added.pluginCount === 1 ? '' : 's'} found.` : 'Connected.');
224
+ setUrl('');
225
+ setToken('');
226
+ setIsPrivate(false);
227
+ }
228
+ catch (err) {
229
+ setError(err.message);
230
+ }
231
+ setBusy(false);
232
+ };
233
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "np-head", children: _jsxs("div", { children: [_jsx("h1", { children: "Plugin sources" }), _jsx("p", { children: "A source is a GitHub repository that holds plugins. Connect one and its plugins appear under \u201CAdd plugins\u201D." })] }) }), _jsxs("form", { className: "np-card np-pad np-section", onSubmit: connect, children: [_jsxs("div", { className: "np-field", children: [_jsx("label", { htmlFor: "np-source-url", children: "Repository address" }), _jsx("input", { id: "np-source-url", className: "np-input", required: true, placeholder: "https://github.com/your-company/plugins", value: url, "aria-invalid": error ? true : undefined, "aria-describedby": error ? 'np-source-error' : 'np-source-help', onChange: (e) => setUrl(e.target.value) }), _jsxs("span", { className: "np-help", id: "np-source-help", children: ["Copy it from your browser\u2019s address bar while looking at the repository on GitHub.", state.site.allowLocalSources ? ' While developing you can also enter a folder path on this computer.' : ''] }), error ? (_jsx("span", { className: "np-error", id: "np-source-error", role: "alert", children: error })) : null] }), _jsxs("label", { className: "np-check", style: { marginTop: 0, marginBottom: 14 }, children: [_jsx("input", { type: "checkbox", checked: isPrivate, onChange: (e) => setIsPrivate(e.target.checked) }), _jsx("span", { children: "This repository is private" })] }), isPrivate ? (_jsxs("div", { className: "np-field", children: [_jsx("label", { htmlFor: "np-source-token", children: "Access token" }), _jsx("input", { id: "np-source-token", className: "np-input", type: "password", autoComplete: "off", required: true, value: token, placeholder: "github_pat_\u2026", onChange: (e) => setToken(e.target.value) }), _jsx("span", { className: "np-help", children: "A private repository needs a key that lets this site read it. To create one:" }), _jsxs("ol", { className: "np-steps", children: [_jsx("li", { children: _jsxs("span", { children: ["Open", ' ', _jsx("a", { href: "https://github.com/settings/personal-access-tokens/new", target: "_blank", rel: "noreferrer", children: "GitHub \u2192 New fine-grained token \u2197" }), "."] }) }), _jsx("li", { children: _jsx("span", { children: "Under \u201CRepository access\u201D choose \u201COnly select repositories\u201D and pick this repository." }) }), _jsx("li", { children: _jsx("span", { children: "Under \u201CPermissions \u2192 Repository permissions\u201D set \u201CContents\u201D to \u201CRead-only\u201D." }) }), _jsx("li", { children: _jsx("span", { children: "Create the token, copy it, and paste it above. It is stored encrypted and never shown again." }) })] })] })) : null, _jsx(Button, { type: "submit", variant: "primary", busy: busy, children: "Connect source" })] }), state.sources.length ? (_jsx("ul", { className: "np-list", children: state.sources.map((s) => (_jsxs("li", { className: "np-card np-row", children: [_jsx("span", { className: "np-icon", "aria-hidden": "true", children: s.type === 'github' ? '๐Ÿ™' : '๐Ÿ“' }), _jsxs("div", { children: [_jsxs("div", { className: "np-title", children: [s.url ? (_jsx("a", { href: s.url, target: "_blank", rel: "noreferrer", children: s.label })) : (s.label), s.hasToken ? _jsx("span", { className: "np-chip", children: "Private" }) : null] }), _jsxs("p", { className: "np-desc", children: [s.pluginCount, " plugin", s.pluginCount === 1 ? '' : 's', " \u00B7 checked ", timeAgo(s.checkedAt)] }), s.problems.map((p) => (_jsxs("p", { className: "np-error", style: { marginTop: 6 }, children: [p.path || 'This source', ": ", p.message] }, p.path)))] }), _jsx("div", { className: "np-row-actions", children: _jsx(Button, { small: true, variant: "danger", busy: removing === s.id, onClick: async () => {
234
+ setRemoving(s.id);
235
+ try {
236
+ setState(await api('DELETE', `/admin/sources/${s.id}`));
237
+ notify(`${s.label} was disconnected. Plugins already installed from it keep working.`);
238
+ }
239
+ catch (err) {
240
+ notify(err.message, 'error');
241
+ }
242
+ setRemoving(null);
243
+ }, children: "Disconnect" }) })] }, s.id))) })) : null] }));
244
+ }
245
+ // ------------------------------------------------------------------ plugin detail
246
+ function PluginDetail({ id }) {
247
+ const { state, base, api, act } = useDash();
248
+ const plugin = state.plugins.find((p) => p.id === id);
249
+ const [tab, setTab] = React.useState('settings');
250
+ const [busy, setBusy] = React.useState(null);
251
+ const [removeOpen, setRemoveOpen] = React.useState(false);
252
+ const [deleteData, setDeleteData] = React.useState(false);
253
+ if (!plugin)
254
+ return (_jsxs("div", { className: "np-card np-empty", children: [_jsx("h2", { children: "That plugin is not installed" }), _jsx("p", { children: "It may have been removed." }), _jsx(Link, { className: "np-btn np-btn-primary", href: base, children: "Back to installed plugins" })] }));
255
+ const run = async (name, work, done) => {
256
+ setBusy(name);
257
+ const res = await act(work, done);
258
+ setBusy(null);
259
+ return res;
260
+ };
261
+ const adminTab = plugin.adminPages.find((a) => `admin:${a.id}` === tab);
262
+ return (_jsxs(_Fragment, { children: [_jsx("p", { style: { marginBottom: 14 }, children: _jsx(Link, { href: base, children: "\u2190 Installed plugins" }) }), _jsxs("div", { className: "np-head", children: [_jsxs("div", { style: { display: 'flex', gap: 14, alignItems: 'center' }, children: [_jsx(PluginIcon, { icon: plugin.icon, name: plugin.name }), _jsxs("div", { children: [_jsxs("h1", { className: "np-title", style: { fontSize: 24 }, children: [plugin.name, " ", _jsx(StatusChip, { plugin: plugin })] }), _jsxs("p", { children: ["Version ", plugin.version, plugin.author ? ` ยท by ${plugin.author}` : ''] })] })] }), _jsxs("div", { style: { display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }, children: [plugin.update ? (_jsxs(Button, { variant: "primary", busy: busy === 'update', onClick: () => run('update', () => api('POST', `/admin/plugins/${plugin.id}/update`), `${plugin.name} was updated to version ${plugin.update.version}.`), children: ["Update to ", plugin.update.version] })) : null, _jsx(Switch, { checked: plugin.enabled, disabled: busy !== null || plugin.needsSetup, showLabel: true, label: plugin.enabled ? 'On' : 'Off', onChange: (enabled) => run('toggle', () => api('PATCH', `/admin/plugins/${plugin.id}`, { enabled }), enabled ? `${plugin.name} is now live on your site.` : `${plugin.name} is paused. Its pages are hidden.`) })] })] }), plugin.needsSetup ? _jsx("div", { className: "np-note np-note-warn", children: "Fill in the required settings (marked *) and save. The plugin switches on once they are complete." }) : null, _jsx("div", { className: "np-tabs", role: "tablist", children: [{ key: 'settings', title: 'Settings' }, ...plugin.adminPages.map((a) => ({ key: `admin:${a.id}`, title: a.title })), { key: 'about', title: 'About' }].map((t) => (_jsx("button", { type: "button", role: "tab", "aria-selected": tab === t.key, onClick: () => setTab(t.key), children: t.title }, t.key))) }), tab === 'settings' ? _jsx(SettingsForm, { plugin: plugin }, plugin.updatedAt) : null, adminTab ? (plugin.enabled && plugin.runtime ? (_jsxs("div", { className: "np-card np-pad", children: [plugin.runtime.styleHref ? _jsx("link", { rel: "stylesheet", href: plugin.runtime.styleHref, precedence: "nextpluggable" }) : null, _jsx(PluginIsland, { moduleKey: plugin.runtime.moduleKey, src: plugin.runtime.scriptSrc, component: adminTab.component, info: { id: plugin.id, version: plugin.version, apiBase: plugin.runtime.apiBase, paths: plugin.runtime.paths }, config: plugin.runtime.publicConfig, loadingFallback: _jsx("div", { className: "np-skel" }), errorFallback: _jsx("div", { className: "np-note np-note-danger", children: "This screen could not be loaded. Try reloading the page." }) }, `${plugin.runtime.moduleKey}:${adminTab.id}`)] })) : (_jsx("div", { className: "np-note", children: "Switch the plugin on to use this screen." }))) : null, tab === 'about' ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "np-card np-pad np-section", children: [plugin.description ? _jsx("p", { style: { marginBottom: 16 }, children: plugin.description }) : null, _jsxs("dl", { className: "np-dl", children: [_jsx("dt", { children: "What it does" }), _jsx("dd", { children: _jsx("ul", { className: "np-adds", children: describePlugin({ ...plugin, adminPages: plugin.adminPages.map((a) => a.title) }).map((line) => (_jsx("li", { children: line }, line))) }) }), _jsx("dt", { children: "Comes from" }), _jsxs("dd", { children: [plugin.source.label, plugin.source.connected ? '' : ' (no longer connected, so updates are unavailable)'] }), _jsx("dt", { children: "Installed" }), _jsx("dd", { children: new Date(plugin.installedAt).toLocaleDateString(undefined, { dateStyle: 'long' }) })] })] }), _jsxs("div", { className: "np-card np-pad", style: { display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'center', flexWrap: 'wrap' }, children: [_jsxs("div", { children: [_jsx("div", { className: "np-label", children: "Remove this plugin" }), _jsx("p", { className: "np-help", children: "Its pages and features disappear from your site straight away." })] }), _jsx(Button, { variant: "danger", onClick: () => setRemoveOpen(true), children: "Remove plugin" })] })] })) : null, removeOpen ? (_jsxs(Modal, { title: `Remove ${plugin.name}?`, onClose: () => busy === null && setRemoveOpen(false), footer: _jsxs(_Fragment, { children: [_jsx(Button, { onClick: () => setRemoveOpen(false), disabled: busy !== null, children: "Keep plugin" }), _jsx(Button, { variant: "danger-solid", busy: busy === 'remove', onClick: async () => {
263
+ const res = await run('remove', () => api('DELETE', `/admin/plugins/${plugin.id}`, { deleteData }), `${plugin.name} was removed.`);
264
+ if (res)
265
+ go(base);
266
+ }, children: "Remove" })] }), children: [_jsxs("p", { children: [plugin.pages.length ? `Its page${plugin.pages.length === 1 ? '' : 's'} (${plugin.pages.map((p) => p.path).join(', ')}) will stop working immediately. ` : '', "You can install it again later."] }), _jsxs("label", { className: "np-check", children: [_jsx("input", { type: "checkbox", checked: deleteData, onChange: (e) => setDeleteData(e.target.checked) }), _jsxs("span", { children: ["Also delete everything this plugin has saved. ", _jsx("strong", { children: "This cannot be undone." })] })] })] })) : null] }));
267
+ }
268
+ function SettingsForm({ plugin }) {
269
+ const { api, state, setState, notify } = useDash();
270
+ const initial = React.useMemo(() => {
271
+ const values = {};
272
+ for (const f of plugin.settings)
273
+ if (f.type !== 'secret')
274
+ values[f.key] = plugin.values[f.key] ?? (f.type === 'toggle' ? false : undefined);
275
+ const paths = {};
276
+ for (const p of plugin.pages)
277
+ paths[p.id] = p.path;
278
+ return { values, paths };
279
+ }, [plugin]);
280
+ const [values, setValues] = React.useState(initial.values);
281
+ const [secrets, setSecrets] = React.useState({});
282
+ const [paths, setPaths] = React.useState(initial.paths);
283
+ const [errors, setErrors] = React.useState({});
284
+ const [saving, setSaving] = React.useState(false);
285
+ const dirty = JSON.stringify([values, paths]) !== JSON.stringify([initial.values, initial.paths]) || Object.values(secrets).some((v) => v !== undefined);
286
+ const groups = React.useMemo(() => {
287
+ const map = new Map();
288
+ for (const f of plugin.settings)
289
+ map.set(f.group ?? 'Settings', [...(map.get(f.group ?? 'Settings') ?? []), f]);
290
+ return [...map.entries()];
291
+ }, [plugin]);
292
+ const editablePages = plugin.pages.filter((p) => p.configurable);
293
+ const save = async (e) => {
294
+ e.preventDefault();
295
+ setSaving(true);
296
+ setErrors({});
297
+ const config = {};
298
+ for (const f of plugin.settings) {
299
+ if (f.type === 'secret') {
300
+ if (secrets[f.key] !== undefined)
301
+ config[f.key] = secrets[f.key];
302
+ }
303
+ else
304
+ config[f.key] = values[f.key] ?? null;
305
+ }
306
+ try {
307
+ setState(await api('PATCH', `/admin/plugins/${plugin.id}`, { config, paths, ...(plugin.needsSetup ? { enabled: true } : {}) }));
308
+ notify('Saved. Your site is already using the new settings.');
309
+ }
310
+ catch (err) {
311
+ if (err instanceof ApiError && err.fields)
312
+ setErrors(err.fields);
313
+ else
314
+ notify(err.message, 'error');
315
+ }
316
+ setSaving(false);
317
+ };
318
+ if (!plugin.settings.length && !editablePages.length)
319
+ return (_jsxs("div", { className: "np-card np-empty", children: [_jsx("h2", { children: "Nothing to set up" }), _jsx("p", { children: "This plugin works out of the box and has no settings." })] }));
320
+ return (_jsxs("form", { onSubmit: save, noValidate: true, children: [plugin.pages.length ? (_jsxs("section", { className: "np-card np-section", children: [_jsx("h2", { children: "Pages" }), _jsx("div", { className: "np-pad", children: plugin.pages.map((p) => {
321
+ const err = errors[`path:${p.id}`];
322
+ return (_jsxs("div", { className: "np-field", children: [_jsxs("label", { htmlFor: `np-path-${p.id}`, children: ["Address of \u201C", p.title, "\u201D"] }), p.configurable ? (_jsxs("span", { className: "np-affix", children: [_jsx("span", { children: state.site.url.replace(/^https?:\/\//, '') }), _jsx("input", { id: `np-path-${p.id}`, value: paths[p.id] ?? '', "aria-invalid": err ? true : undefined, onChange: (e) => setPaths({ ...paths, [p.id]: e.target.value }) })] })) : (_jsx("span", { id: `np-path-${p.id}`, className: "np-code", children: p.path })), _jsxs("span", { className: "np-help", children: [p.description ? `${p.description} ` : '', plugin.enabled && p.linkable ? (_jsx("a", { href: p.path, target: "_blank", rel: "noreferrer", children: "Open page \u2197" })) : null] }), err ? _jsx("span", { className: "np-error", children: err }) : null] }, p.id));
323
+ }) })] })) : null, groups.map(([group, fields]) => (_jsxs("section", { className: "np-card np-section", children: [_jsx("h2", { children: group }), _jsx("div", { className: "np-pad", children: fields.map((f) => (_jsx(SettingInput, { field: f, value: f.type === 'secret' ? secrets[f.key] : values[f.key], secretSet: plugin.secretsSet[f.key], error: errors[f.key], onChange: (v) => (f.type === 'secret' ? setSecrets({ ...secrets, [f.key]: v }) : setValues({ ...values, [f.key]: v })) }, f.key))) })] }, group))), _jsxs("div", { className: "np-card np-savebar", children: [_jsx("span", { className: "np-meta", children: Object.keys(errors).length ? 'Some fields need your attention.' : dirty ? 'You have unsaved changes.' : 'All changes saved.' }), _jsx(Button, { type: "submit", variant: "primary", busy: saving, disabled: !dirty && !plugin.needsSetup, children: plugin.needsSetup ? 'Save and switch on' : 'Save changes' })] })] }));
324
+ }