nextpluggable 0.0.0-stage → 0.1.1

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 (77) hide show
  1. package/README.md +288 -2
  2. package/bin/nextpluggable.js +10 -0
  3. package/dist/api/handler.d.ts +10 -0
  4. package/dist/api/handler.js +188 -0
  5. package/dist/auth/custom.d.ts +13 -0
  6. package/dist/auth/custom.js +12 -0
  7. package/dist/auth/password.d.ts +10 -0
  8. package/dist/auth/password.js +66 -0
  9. package/dist/auth/types.d.ts +23 -0
  10. package/dist/auth/types.js +1 -0
  11. package/dist/cli.d.ts +9 -0
  12. package/dist/cli.js +123 -0
  13. package/dist/client/host-runtime.d.ts +9 -0
  14. package/dist/client/host-runtime.js +71 -0
  15. package/dist/client/island.d.ts +28 -0
  16. package/dist/client/island.js +177 -0
  17. package/dist/client/ssr-guard.d.ts +11 -0
  18. package/dist/client/ssr-guard.js +100 -0
  19. package/dist/config.d.ts +55 -0
  20. package/dist/config.js +57 -0
  21. package/dist/core/installer.d.ts +30 -0
  22. package/dist/core/installer.js +340 -0
  23. package/dist/core/routes.d.ts +20 -0
  24. package/dist/core/routes.js +95 -0
  25. package/dist/core/runtime.d.ts +34 -0
  26. package/dist/core/runtime.js +195 -0
  27. package/dist/core/sitemap.d.ts +12 -0
  28. package/dist/core/sitemap.js +83 -0
  29. package/dist/core/state.d.ts +13 -0
  30. package/dist/core/state.js +23 -0
  31. package/dist/core/store.d.ts +17 -0
  32. package/dist/core/store.js +58 -0
  33. package/dist/core/types.d.ts +69 -0
  34. package/dist/core/types.js +1 -0
  35. package/dist/crypto.d.ts +14 -0
  36. package/dist/crypto.js +61 -0
  37. package/dist/dashboard/Dashboard.d.ts +11 -0
  38. package/dist/dashboard/Dashboard.js +324 -0
  39. package/dist/dashboard/styles.d.ts +2 -0
  40. package/dist/dashboard/styles.js +129 -0
  41. package/dist/dashboard/ui.d.ts +61 -0
  42. package/dist/dashboard/ui.js +135 -0
  43. package/dist/errors.d.ts +15 -0
  44. package/dist/errors.js +29 -0
  45. package/dist/globals.d.ts +24 -0
  46. package/dist/globals.js +24 -0
  47. package/dist/index.d.ts +47 -0
  48. package/dist/index.js +65 -0
  49. package/dist/next/dashboard-page.d.ts +5 -0
  50. package/dist/next/dashboard-page.js +23 -0
  51. package/dist/next/plugin-page.d.ts +16 -0
  52. package/dist/next/plugin-page.js +91 -0
  53. package/dist/next/seo.d.ts +32 -0
  54. package/dist/next/seo.js +86 -0
  55. package/dist/next/slot.d.ts +7 -0
  56. package/dist/next/slot.js +43 -0
  57. package/dist/next/style.d.ts +10 -0
  58. package/dist/next/style.js +13 -0
  59. package/dist/sources/github.d.ts +19 -0
  60. package/dist/sources/github.js +109 -0
  61. package/dist/sources/local.d.ts +3 -0
  62. package/dist/sources/local.js +22 -0
  63. package/dist/sources/reader.d.ts +17 -0
  64. package/dist/sources/reader.js +78 -0
  65. package/dist/storage/file.d.ts +12 -0
  66. package/dist/storage/file.js +61 -0
  67. package/dist/storage/memory.d.ts +3 -0
  68. package/dist/storage/memory.js +22 -0
  69. package/dist/storage/postgres.d.ts +15 -0
  70. package/dist/storage/postgres.js +39 -0
  71. package/dist/storage/types.d.ts +18 -0
  72. package/dist/storage/types.js +15 -0
  73. package/dist/storage/upstash.d.ts +15 -0
  74. package/dist/storage/upstash.js +61 -0
  75. package/dist/views.d.ts +112 -0
  76. package/dist/views.js +124 -0
  77. package/package.json +48 -3
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ /** Dashboard styles, shipped as a string so the package needs no CSS tooling in the host site. Everything is scoped under `.np-root`. */
2
+ export declare const dashboardCss = "\n.np-root{--np-bg:#f5f6f8;--np-surface:#fff;--np-surface-2:#f9fafb;--np-text:#111827;--np-muted:#667085;--np-border:#e4e7ec;--np-primary:#4f46e5;--np-primary-hover:#4338ca;--np-primary-soft:#eef2ff;--np-danger:#d92d20;--np-danger-soft:#fef3f2;--np-success:#067647;--np-success-soft:#ecfdf3;--np-warn:#b54708;--np-warn-soft:#fffaeb;--np-shadow:0 1px 2px rgba(16,24,40,.06),0 1px 3px rgba(16,24,40,.08);--np-radius:12px;--np-accent:var(--np-primary);\nposition:fixed;inset:0;z-index:2147483000;overflow-y:auto;background:var(--np-bg);color:var(--np-text);font:15px/1.5 ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",Roboto,\"Helvetica Neue\",Arial,sans-serif;-webkit-font-smoothing:antialiased;text-align:left}\n@media (prefers-color-scheme:dark){.np-root{--np-bg:#0c0e13;--np-surface:#161a22;--np-surface-2:#1b202a;--np-text:#eceef2;--np-muted:#98a2b3;--np-border:#2a303c;--np-primary:#818cf8;--np-primary-hover:#a5b4fc;--np-primary-soft:#1e2140;--np-danger:#f97066;--np-danger-soft:#3a1614;--np-success:#47cd89;--np-success-soft:#0d2a1c;--np-warn:#fdb022;--np-warn-soft:#33240a;--np-shadow:0 1px 2px rgba(0,0,0,.4)}}\n.np-root *,.np-root *::before,.np-root *::after{box-sizing:border-box}\n.np-root h1,.np-root h2,.np-root h3,.np-root p,.np-root ul,.np-root ol,.np-root dl,.np-root dd{margin:0;padding:0}\n.np-root ul,.np-root ol{list-style:none}\n.np-root a{color:var(--np-primary);text-decoration:none}\n.np-root a:hover{text-decoration:underline}\n.np-root button,.np-root input,.np-root select,.np-root textarea{font:inherit;color:inherit}\n.np-root :focus-visible{outline:2px solid var(--np-primary);outline-offset:2px}\n.np-top{background:var(--np-surface);border-bottom:1px solid var(--np-border);position:sticky;top:0;z-index:5}\n.np-top-in{max-width:1040px;margin:0 auto;padding:0 20px;display:flex;align-items:center;gap:20px;min-height:60px;flex-wrap:wrap}\n.np-brand{display:flex;align-items:center;gap:10px;font-weight:650;font-size:16px;margin-right:auto;color:var(--np-text)!important;text-decoration:none!important}\n.np-brand-mark{width:28px;height:28px;border-radius:8px;background:var(--np-primary);color:#fff;display:grid;place-items:center;font-size:15px}\n.np-brand small{font-weight:400;color:var(--np-muted);font-size:13px}\n.np-nav{display:flex;gap:4px;order:3;width:100%;margin:0 -8px;overflow-x:auto}\n@media (min-width:720px){.np-nav{order:0;width:auto;margin:0}}\n.np-nav a{padding:8px 12px;border-radius:8px;color:var(--np-muted);font-weight:500;white-space:nowrap;text-decoration:none!important}\n.np-nav a:hover{color:var(--np-text);background:var(--np-surface-2)}\n.np-nav a[aria-current=page]{color:var(--np-primary);background:var(--np-primary-soft)}\n.np-main{max-width:1040px;margin:0 auto;padding:28px 20px 80px}\n.np-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:20px;flex-wrap:wrap}\n.np-head h1{font-size:24px;line-height:1.25;letter-spacing:-.01em}\n.np-head p{color:var(--np-muted);margin-top:4px;max-width:60ch}\n.np-card{background:var(--np-surface);border:1px solid var(--np-border);border-radius:var(--np-radius);box-shadow:var(--np-shadow)}\n.np-pad{padding:20px}\n.np-btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--np-border);background:var(--np-surface);border-radius:8px;padding:8px 14px;font-weight:550;cursor:pointer;white-space:nowrap;text-decoration:none!important;color:var(--np-text)!important;min-height:38px}\n.np-btn:hover{background:var(--np-surface-2)}\n.np-btn:disabled{opacity:.55;cursor:not-allowed}\n.np-btn-primary{background:var(--np-primary);border-color:var(--np-primary);color:#fff!important}\n.np-btn-primary:hover{background:var(--np-primary-hover);border-color:var(--np-primary-hover)}\n@media (prefers-color-scheme:dark){.np-btn-primary{color:#0c0e13!important}}\n.np-btn-danger{color:var(--np-danger)!important}\n.np-btn-danger-solid{background:var(--np-danger);border-color:var(--np-danger);color:#fff!important}\n.np-btn-sm{padding:5px 10px;min-height:32px;font-size:14px}\n.np-btn-link{border:0;background:none;padding:0;min-height:0;color:var(--np-primary)!important;font-weight:500;cursor:pointer}\n.np-spin{width:14px;height:14px;border-radius:50%;border:2px solid currentColor;border-right-color:transparent;animation:np-spin .7s linear infinite;flex:none}\n@keyframes np-spin{to{transform:rotate(360deg)}}\n.np-list{display:grid;gap:12px}\n.np-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:14px 16px;padding:18px 20px;align-items:start}\n@media (min-width:720px){.np-row{grid-template-columns:auto minmax(0,1fr) auto;align-items:center}}\n.np-row-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap;grid-column:1/-1}\n@media (min-width:720px){.np-row-actions{grid-column:auto;justify-content:flex-end}}\n.np-icon{width:44px;height:44px;border-radius:10px;background:var(--np-primary-soft);display:grid;place-items:center;font-size:22px;flex:none;overflow:hidden}\n.np-icon img{width:100%;height:100%;object-fit:cover}\n.np-title{font-weight:650;font-size:16px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}\n.np-title a{color:inherit}\n.np-meta{color:var(--np-muted);font-size:14px}\n.np-desc{color:var(--np-muted);margin-top:2px;overflow-wrap:anywhere}\n.np-chip{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;background:var(--np-surface-2);color:var(--np-muted);border:1px solid var(--np-border);white-space:nowrap}\n.np-chip::before{content:\"\";width:6px;height:6px;border-radius:50%;background:currentColor}\n.np-chip-on{background:var(--np-success-soft);color:var(--np-success);border-color:transparent}\n.np-chip-warn{background:var(--np-warn-soft);color:var(--np-warn);border-color:transparent}\n.np-links{display:flex;gap:6px 14px;flex-wrap:wrap;margin-top:8px;font-size:14px}\n.np-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:14px}\n.np-tile{padding:18px;display:flex;flex-direction:column;gap:12px}\n.np-tile-head{display:flex;gap:12px;align-items:center}\n.np-tile-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:auto;padding-top:4px}\n.np-adds{font-size:14px;color:var(--np-muted);display:grid;gap:4px}\n.np-adds li{display:flex;gap:8px;overflow-wrap:anywhere}\n.np-adds li::before{content:\"+\";color:var(--np-primary);font-weight:700}\n.np-empty{text-align:center;padding:56px 24px}\n.np-empty-art{font-size:40px;margin-bottom:10px}\n.np-empty h2{font-size:19px;margin-bottom:6px}\n.np-empty p{color:var(--np-muted);max-width:46ch;margin:0 auto 18px}\n.np-switch{position:relative;display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:14px;color:var(--np-muted);user-select:none}\n.np-switch input{position:absolute;opacity:0;width:100%;height:100%;margin:0;cursor:pointer}\n.np-switch i{width:40px;height:24px;border-radius:999px;background:var(--np-border);position:relative;transition:background .15s;flex:none}\n.np-switch i::after{content:\"\";position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}\n.np-switch input:checked+i{background:var(--np-success)}\n.np-switch input:checked+i::after{transform:translateX(16px)}\n.np-switch input:focus-visible+i{outline:2px solid var(--np-primary);outline-offset:2px}\n.np-switch input:disabled+i{opacity:.5}\n.np-field{display:grid;gap:6px;margin-bottom:18px}\n.np-field:last-child{margin-bottom:0}\n.np-field label,.np-label{font-weight:600;font-size:14px}\n.np-help{color:var(--np-muted);font-size:13px}\n.np-error{color:var(--np-danger);font-size:13px;font-weight:500}\n.np-input{width:100%;border:1px solid var(--np-border);background:var(--np-surface);border-radius:8px;padding:9px 12px;min-height:40px}\n.np-input:focus{outline:2px solid var(--np-primary);outline-offset:-1px;border-color:transparent}\n.np-input[aria-invalid=true]{border-color:var(--np-danger)}\ntextarea.np-input{min-height:96px;resize:vertical}\n.np-affix{display:flex;align-items:stretch;border:1px solid var(--np-border);border-radius:8px;overflow:hidden;background:var(--np-surface)}\n.np-affix span{padding:9px 10px;background:var(--np-surface-2);color:var(--np-muted);border-right:1px solid var(--np-border);white-space:nowrap;max-width:50%;overflow:hidden;text-overflow:ellipsis;font-size:14px;display:flex;align-items:center}\n.np-affix input{border:0;flex:1;min-width:0;padding:9px 12px;background:transparent}\n.np-affix:focus-within{outline:2px solid var(--np-primary);outline-offset:-1px;border-color:transparent}\n.np-affix input:focus{outline:0}\n.np-color{display:flex;gap:8px;align-items:center}\n.np-color input[type=color]{width:44px;height:40px;padding:2px;border:1px solid var(--np-border);border-radius:8px;background:var(--np-surface);cursor:pointer}\n.np-section{margin-bottom:16px}\n.np-section>h2{font-size:15px;padding:14px 20px;border-bottom:1px solid var(--np-border)}\n.np-tabs{display:flex;gap:2px;border-bottom:1px solid var(--np-border);margin-bottom:20px;overflow-x:auto}\n.np-tabs button{border:0;background:none;padding:10px 14px;font-weight:550;color:var(--np-muted);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;white-space:nowrap}\n.np-tabs button[aria-selected=true]{color:var(--np-primary);border-bottom-color:var(--np-primary)}\n.np-savebar{position:sticky;bottom:16px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 16px;margin-top:16px}\n.np-note{padding:12px 14px;border-radius:10px;font-size:14px;background:var(--np-primary-soft);margin-bottom:16px}\n.np-note-warn{background:var(--np-warn-soft);color:var(--np-warn)}\n.np-note-danger{background:var(--np-danger-soft);color:var(--np-danger)}\n.np-steps{counter-reset:s;display:grid;gap:8px;font-size:14px;margin:10px 0 0}\n.np-steps li{counter-increment:s;display:flex;gap:10px}\n.np-steps li::before{content:counter(s);flex:none;width:22px;height:22px;border-radius:50%;background:var(--np-primary-soft);color:var(--np-primary);font-weight:700;font-size:12px;display:grid;place-items:center}\n.np-code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;background:var(--np-surface-2);border:1px solid var(--np-border);border-radius:6px;padding:1px 6px;overflow-wrap:anywhere}\n.np-overlay{position:fixed;inset:0;background:rgba(10,13,20,.55);display:grid;place-items:center;padding:16px;z-index:20}\n.np-modal{background:var(--np-surface);border-radius:14px;max-width:480px;width:100%;max-height:calc(100vh - 32px);overflow-y:auto;box-shadow:0 20px 50px rgba(0,0,0,.3)}\n.np-modal header{padding:20px 22px 0}\n.np-modal header h2{font-size:18px}\n.np-modal-body{padding:12px 22px 4px;color:var(--np-muted)}\n.np-modal-body strong{color:var(--np-text)}\n.np-modal footer{display:flex;justify-content:flex-end;gap:10px;padding:18px 22px 20px;flex-wrap:wrap}\n.np-check{display:flex;gap:10px;align-items:flex-start;font-size:14px;color:var(--np-text);margin-top:12px;cursor:pointer}\n.np-check input{margin-top:3px;width:16px;height:16px;accent-color:var(--np-primary)}\n.np-toasts{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);display:grid;gap:8px;z-index:30;width:min(440px,calc(100vw - 32px))}\n.np-toast{padding:12px 16px;border-radius:10px;background:#111827;color:#fff;box-shadow:0 8px 24px rgba(0,0,0,.25);font-size:14px;display:flex;gap:10px;align-items:flex-start}\n.np-toast-error{background:#b42318}\n.np-toast button{margin-left:auto;border:0;background:none;color:inherit;cursor:pointer;opacity:.8;font-size:16px;line-height:1}\n.np-center{min-height:100%;display:grid;place-items:center;padding:24px}\n.np-auth{width:100%;max-width:400px;padding:28px}\n.np-auth h1{font-size:21px;margin:14px 0 6px}\n.np-auth p{color:var(--np-muted);margin-bottom:18px}\n.np-search{width:240px;max-width:100%}\n.np-dl{display:grid;grid-template-columns:minmax(110px,auto) 1fr;gap:10px 20px;font-size:14px}\n.np-dl dt{color:var(--np-muted)}\n.np-dl dd{overflow-wrap:anywhere}\n.np-skel{height:84px;border-radius:var(--np-radius);background:linear-gradient(90deg,var(--np-surface) 25%,var(--np-surface-2) 50%,var(--np-surface) 75%);background-size:200% 100%;animation:np-sh 1.2s infinite;border:1px solid var(--np-border)}\n@keyframes np-sh{to{background-position:-200% 0}}\n@media (prefers-reduced-motion:reduce){.np-root *{animation:none!important;transition:none!important}}\n";
@@ -0,0 +1,129 @@
1
+ /** Dashboard styles, shipped as a string so the package needs no CSS tooling in the host site. Everything is scoped under `.np-root`. */
2
+ export const dashboardCss = `
3
+ .np-root{--np-bg:#f5f6f8;--np-surface:#fff;--np-surface-2:#f9fafb;--np-text:#111827;--np-muted:#667085;--np-border:#e4e7ec;--np-primary:#4f46e5;--np-primary-hover:#4338ca;--np-primary-soft:#eef2ff;--np-danger:#d92d20;--np-danger-soft:#fef3f2;--np-success:#067647;--np-success-soft:#ecfdf3;--np-warn:#b54708;--np-warn-soft:#fffaeb;--np-shadow:0 1px 2px rgba(16,24,40,.06),0 1px 3px rgba(16,24,40,.08);--np-radius:12px;--np-accent:var(--np-primary);
4
+ position:fixed;inset:0;z-index:2147483000;overflow-y:auto;background:var(--np-bg);color:var(--np-text);font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;-webkit-font-smoothing:antialiased;text-align:left}
5
+ @media (prefers-color-scheme:dark){.np-root{--np-bg:#0c0e13;--np-surface:#161a22;--np-surface-2:#1b202a;--np-text:#eceef2;--np-muted:#98a2b3;--np-border:#2a303c;--np-primary:#818cf8;--np-primary-hover:#a5b4fc;--np-primary-soft:#1e2140;--np-danger:#f97066;--np-danger-soft:#3a1614;--np-success:#47cd89;--np-success-soft:#0d2a1c;--np-warn:#fdb022;--np-warn-soft:#33240a;--np-shadow:0 1px 2px rgba(0,0,0,.4)}}
6
+ .np-root *,.np-root *::before,.np-root *::after{box-sizing:border-box}
7
+ .np-root h1,.np-root h2,.np-root h3,.np-root p,.np-root ul,.np-root ol,.np-root dl,.np-root dd{margin:0;padding:0}
8
+ .np-root ul,.np-root ol{list-style:none}
9
+ .np-root a{color:var(--np-primary);text-decoration:none}
10
+ .np-root a:hover{text-decoration:underline}
11
+ .np-root button,.np-root input,.np-root select,.np-root textarea{font:inherit;color:inherit}
12
+ .np-root :focus-visible{outline:2px solid var(--np-primary);outline-offset:2px}
13
+ .np-top{background:var(--np-surface);border-bottom:1px solid var(--np-border);position:sticky;top:0;z-index:5}
14
+ .np-top-in{max-width:1040px;margin:0 auto;padding:0 20px;display:flex;align-items:center;gap:20px;min-height:60px;flex-wrap:wrap}
15
+ .np-brand{display:flex;align-items:center;gap:10px;font-weight:650;font-size:16px;margin-right:auto;color:var(--np-text)!important;text-decoration:none!important}
16
+ .np-brand-mark{width:28px;height:28px;border-radius:8px;background:var(--np-primary);color:#fff;display:grid;place-items:center;font-size:15px}
17
+ .np-brand small{font-weight:400;color:var(--np-muted);font-size:13px}
18
+ .np-nav{display:flex;gap:4px;order:3;width:100%;margin:0 -8px;overflow-x:auto}
19
+ @media (min-width:720px){.np-nav{order:0;width:auto;margin:0}}
20
+ .np-nav a{padding:8px 12px;border-radius:8px;color:var(--np-muted);font-weight:500;white-space:nowrap;text-decoration:none!important}
21
+ .np-nav a:hover{color:var(--np-text);background:var(--np-surface-2)}
22
+ .np-nav a[aria-current=page]{color:var(--np-primary);background:var(--np-primary-soft)}
23
+ .np-main{max-width:1040px;margin:0 auto;padding:28px 20px 80px}
24
+ .np-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:20px;flex-wrap:wrap}
25
+ .np-head h1{font-size:24px;line-height:1.25;letter-spacing:-.01em}
26
+ .np-head p{color:var(--np-muted);margin-top:4px;max-width:60ch}
27
+ .np-card{background:var(--np-surface);border:1px solid var(--np-border);border-radius:var(--np-radius);box-shadow:var(--np-shadow)}
28
+ .np-pad{padding:20px}
29
+ .np-btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--np-border);background:var(--np-surface);border-radius:8px;padding:8px 14px;font-weight:550;cursor:pointer;white-space:nowrap;text-decoration:none!important;color:var(--np-text)!important;min-height:38px}
30
+ .np-btn:hover{background:var(--np-surface-2)}
31
+ .np-btn:disabled{opacity:.55;cursor:not-allowed}
32
+ .np-btn-primary{background:var(--np-primary);border-color:var(--np-primary);color:#fff!important}
33
+ .np-btn-primary:hover{background:var(--np-primary-hover);border-color:var(--np-primary-hover)}
34
+ @media (prefers-color-scheme:dark){.np-btn-primary{color:#0c0e13!important}}
35
+ .np-btn-danger{color:var(--np-danger)!important}
36
+ .np-btn-danger-solid{background:var(--np-danger);border-color:var(--np-danger);color:#fff!important}
37
+ .np-btn-sm{padding:5px 10px;min-height:32px;font-size:14px}
38
+ .np-btn-link{border:0;background:none;padding:0;min-height:0;color:var(--np-primary)!important;font-weight:500;cursor:pointer}
39
+ .np-spin{width:14px;height:14px;border-radius:50%;border:2px solid currentColor;border-right-color:transparent;animation:np-spin .7s linear infinite;flex:none}
40
+ @keyframes np-spin{to{transform:rotate(360deg)}}
41
+ .np-list{display:grid;gap:12px}
42
+ .np-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:14px 16px;padding:18px 20px;align-items:start}
43
+ @media (min-width:720px){.np-row{grid-template-columns:auto minmax(0,1fr) auto;align-items:center}}
44
+ .np-row-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap;grid-column:1/-1}
45
+ @media (min-width:720px){.np-row-actions{grid-column:auto;justify-content:flex-end}}
46
+ .np-icon{width:44px;height:44px;border-radius:10px;background:var(--np-primary-soft);display:grid;place-items:center;font-size:22px;flex:none;overflow:hidden}
47
+ .np-icon img{width:100%;height:100%;object-fit:cover}
48
+ .np-title{font-weight:650;font-size:16px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
49
+ .np-title a{color:inherit}
50
+ .np-meta{color:var(--np-muted);font-size:14px}
51
+ .np-desc{color:var(--np-muted);margin-top:2px;overflow-wrap:anywhere}
52
+ .np-chip{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;background:var(--np-surface-2);color:var(--np-muted);border:1px solid var(--np-border);white-space:nowrap}
53
+ .np-chip::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}
54
+ .np-chip-on{background:var(--np-success-soft);color:var(--np-success);border-color:transparent}
55
+ .np-chip-warn{background:var(--np-warn-soft);color:var(--np-warn);border-color:transparent}
56
+ .np-links{display:flex;gap:6px 14px;flex-wrap:wrap;margin-top:8px;font-size:14px}
57
+ .np-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:14px}
58
+ .np-tile{padding:18px;display:flex;flex-direction:column;gap:12px}
59
+ .np-tile-head{display:flex;gap:12px;align-items:center}
60
+ .np-tile-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:auto;padding-top:4px}
61
+ .np-adds{font-size:14px;color:var(--np-muted);display:grid;gap:4px}
62
+ .np-adds li{display:flex;gap:8px;overflow-wrap:anywhere}
63
+ .np-adds li::before{content:"+";color:var(--np-primary);font-weight:700}
64
+ .np-empty{text-align:center;padding:56px 24px}
65
+ .np-empty-art{font-size:40px;margin-bottom:10px}
66
+ .np-empty h2{font-size:19px;margin-bottom:6px}
67
+ .np-empty p{color:var(--np-muted);max-width:46ch;margin:0 auto 18px}
68
+ .np-switch{position:relative;display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:14px;color:var(--np-muted);user-select:none}
69
+ .np-switch input{position:absolute;opacity:0;width:100%;height:100%;margin:0;cursor:pointer}
70
+ .np-switch i{width:40px;height:24px;border-radius:999px;background:var(--np-border);position:relative;transition:background .15s;flex:none}
71
+ .np-switch i::after{content:"";position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
72
+ .np-switch input:checked+i{background:var(--np-success)}
73
+ .np-switch input:checked+i::after{transform:translateX(16px)}
74
+ .np-switch input:focus-visible+i{outline:2px solid var(--np-primary);outline-offset:2px}
75
+ .np-switch input:disabled+i{opacity:.5}
76
+ .np-field{display:grid;gap:6px;margin-bottom:18px}
77
+ .np-field:last-child{margin-bottom:0}
78
+ .np-field label,.np-label{font-weight:600;font-size:14px}
79
+ .np-help{color:var(--np-muted);font-size:13px}
80
+ .np-error{color:var(--np-danger);font-size:13px;font-weight:500}
81
+ .np-input{width:100%;border:1px solid var(--np-border);background:var(--np-surface);border-radius:8px;padding:9px 12px;min-height:40px}
82
+ .np-input:focus{outline:2px solid var(--np-primary);outline-offset:-1px;border-color:transparent}
83
+ .np-input[aria-invalid=true]{border-color:var(--np-danger)}
84
+ textarea.np-input{min-height:96px;resize:vertical}
85
+ .np-affix{display:flex;align-items:stretch;border:1px solid var(--np-border);border-radius:8px;overflow:hidden;background:var(--np-surface)}
86
+ .np-affix span{padding:9px 10px;background:var(--np-surface-2);color:var(--np-muted);border-right:1px solid var(--np-border);white-space:nowrap;max-width:50%;overflow:hidden;text-overflow:ellipsis;font-size:14px;display:flex;align-items:center}
87
+ .np-affix input{border:0;flex:1;min-width:0;padding:9px 12px;background:transparent}
88
+ .np-affix:focus-within{outline:2px solid var(--np-primary);outline-offset:-1px;border-color:transparent}
89
+ .np-affix input:focus{outline:0}
90
+ .np-color{display:flex;gap:8px;align-items:center}
91
+ .np-color input[type=color]{width:44px;height:40px;padding:2px;border:1px solid var(--np-border);border-radius:8px;background:var(--np-surface);cursor:pointer}
92
+ .np-section{margin-bottom:16px}
93
+ .np-section>h2{font-size:15px;padding:14px 20px;border-bottom:1px solid var(--np-border)}
94
+ .np-tabs{display:flex;gap:2px;border-bottom:1px solid var(--np-border);margin-bottom:20px;overflow-x:auto}
95
+ .np-tabs button{border:0;background:none;padding:10px 14px;font-weight:550;color:var(--np-muted);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;white-space:nowrap}
96
+ .np-tabs button[aria-selected=true]{color:var(--np-primary);border-bottom-color:var(--np-primary)}
97
+ .np-savebar{position:sticky;bottom:16px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 16px;margin-top:16px}
98
+ .np-note{padding:12px 14px;border-radius:10px;font-size:14px;background:var(--np-primary-soft);margin-bottom:16px}
99
+ .np-note-warn{background:var(--np-warn-soft);color:var(--np-warn)}
100
+ .np-note-danger{background:var(--np-danger-soft);color:var(--np-danger)}
101
+ .np-steps{counter-reset:s;display:grid;gap:8px;font-size:14px;margin:10px 0 0}
102
+ .np-steps li{counter-increment:s;display:flex;gap:10px}
103
+ .np-steps li::before{content:counter(s);flex:none;width:22px;height:22px;border-radius:50%;background:var(--np-primary-soft);color:var(--np-primary);font-weight:700;font-size:12px;display:grid;place-items:center}
104
+ .np-code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;background:var(--np-surface-2);border:1px solid var(--np-border);border-radius:6px;padding:1px 6px;overflow-wrap:anywhere}
105
+ .np-overlay{position:fixed;inset:0;background:rgba(10,13,20,.55);display:grid;place-items:center;padding:16px;z-index:20}
106
+ .np-modal{background:var(--np-surface);border-radius:14px;max-width:480px;width:100%;max-height:calc(100vh - 32px);overflow-y:auto;box-shadow:0 20px 50px rgba(0,0,0,.3)}
107
+ .np-modal header{padding:20px 22px 0}
108
+ .np-modal header h2{font-size:18px}
109
+ .np-modal-body{padding:12px 22px 4px;color:var(--np-muted)}
110
+ .np-modal-body strong{color:var(--np-text)}
111
+ .np-modal footer{display:flex;justify-content:flex-end;gap:10px;padding:18px 22px 20px;flex-wrap:wrap}
112
+ .np-check{display:flex;gap:10px;align-items:flex-start;font-size:14px;color:var(--np-text);margin-top:12px;cursor:pointer}
113
+ .np-check input{margin-top:3px;width:16px;height:16px;accent-color:var(--np-primary)}
114
+ .np-toasts{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);display:grid;gap:8px;z-index:30;width:min(440px,calc(100vw - 32px))}
115
+ .np-toast{padding:12px 16px;border-radius:10px;background:#111827;color:#fff;box-shadow:0 8px 24px rgba(0,0,0,.25);font-size:14px;display:flex;gap:10px;align-items:flex-start}
116
+ .np-toast-error{background:#b42318}
117
+ .np-toast button{margin-left:auto;border:0;background:none;color:inherit;cursor:pointer;opacity:.8;font-size:16px;line-height:1}
118
+ .np-center{min-height:100%;display:grid;place-items:center;padding:24px}
119
+ .np-auth{width:100%;max-width:400px;padding:28px}
120
+ .np-auth h1{font-size:21px;margin:14px 0 6px}
121
+ .np-auth p{color:var(--np-muted);margin-bottom:18px}
122
+ .np-search{width:240px;max-width:100%}
123
+ .np-dl{display:grid;grid-template-columns:minmax(110px,auto) 1fr;gap:10px 20px;font-size:14px}
124
+ .np-dl dt{color:var(--np-muted)}
125
+ .np-dl dd{overflow-wrap:anywhere}
126
+ .np-skel{height:84px;border-radius:var(--np-radius);background:linear-gradient(90deg,var(--np-surface) 25%,var(--np-surface-2) 50%,var(--np-surface) 75%);background-size:200% 100%;animation:np-sh 1.2s infinite;border:1px solid var(--np-border)}
127
+ @keyframes np-sh{to{background-position:-200% 0}}
128
+ @media (prefers-reduced-motion:reduce){.np-root *{animation:none!important;transition:none!important}}
129
+ `;
@@ -0,0 +1,61 @@
1
+ import * as React from 'react';
2
+ import type { SettingField } from '@nextpluggable/plugin/manifest';
3
+ export declare class ApiError extends Error {
4
+ status: number;
5
+ fields?: Record<string, string> | undefined;
6
+ constructor(message: string, status: number, fields?: Record<string, string> | undefined);
7
+ }
8
+ export type ApiCall = <T = unknown>(method: string, path: string, body?: unknown) => Promise<T>;
9
+ export declare function createApi(apiPath: string, onUnauthorized: () => void): ApiCall;
10
+ export declare function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement> & {
11
+ variant?: 'primary' | 'danger' | 'danger-solid' | 'link';
12
+ small?: boolean;
13
+ busy?: boolean;
14
+ }): React.JSX.Element;
15
+ export declare function Switch(props: {
16
+ checked: boolean;
17
+ onChange: (v: boolean) => void;
18
+ label: string;
19
+ disabled?: boolean;
20
+ showLabel?: boolean;
21
+ }): React.JSX.Element;
22
+ export declare function PluginIcon({ icon, name }: {
23
+ icon?: string;
24
+ name: string;
25
+ }): React.JSX.Element;
26
+ export declare function Modal(props: {
27
+ title: string;
28
+ onClose: () => void;
29
+ children: React.ReactNode;
30
+ footer: React.ReactNode;
31
+ }): React.JSX.Element;
32
+ export interface Toast {
33
+ id: number;
34
+ kind: 'ok' | 'error';
35
+ text: string;
36
+ }
37
+ export declare function Toasts({ toasts, dismiss }: {
38
+ toasts: Toast[];
39
+ dismiss: (id: number) => void;
40
+ }): React.JSX.Element;
41
+ export type FieldValue = string | number | boolean | undefined;
42
+ /** One auto-generated form control for a plugin setting. */
43
+ export declare function SettingInput(props: {
44
+ field: SettingField;
45
+ value: FieldValue;
46
+ secretSet?: boolean;
47
+ error?: string;
48
+ onChange: (value: FieldValue) => void;
49
+ }): React.JSX.Element;
50
+ /** What a plugin adds and is allowed to do, in plain language. */
51
+ export declare function describePlugin(p: {
52
+ pages: {
53
+ title: string;
54
+ path: string;
55
+ }[];
56
+ slots: string[];
57
+ adminPages: string[];
58
+ permissions: string[];
59
+ hasServer: boolean;
60
+ }): string[];
61
+ export declare function timeAgo(iso: string | undefined): string;