@volter/world-platform 2.0.40 → 3.0.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.
- package/client/platform.css +2 -0
- package/client/platform.tsx +27 -10
- package/dist/client/platform.bundle.js +47 -47
- package/dist/client/platform.css +2 -0
- package/dist/client/platform.tsx +27 -10
- package/dist/src/db/pack-migrations.js +1 -1
- package/dist/src/doors.js +1 -1
- package/dist/src/mail.js +1 -1
- package/dist/src/pages.js +1 -1
- package/dist/src/platform.js +10 -2
- package/package.json +6 -8
- package/src/db/open.ts +1 -1
- package/src/db/pack-migrations.ts +1 -1
- package/src/doors.ts +1 -1
- package/src/mail.ts +1 -1
- package/src/pages.ts +1 -1
- package/src/platform.ts +10 -2
package/client/platform.css
CHANGED
|
@@ -20,6 +20,8 @@ a.button.primary { background: var(--accent); border-color: var(--accent); color
|
|
|
20
20
|
/* the sign-in card: the brand, one way in */
|
|
21
21
|
body.portal #root { width: min(440px, 100%); }
|
|
22
22
|
body.portal .card { width: 100%; }
|
|
23
|
+
/* the platform's own server pages (pages.ts pageShell: the CLI's approval, a refusal) are the same narrow card */
|
|
24
|
+
body.portal > main.card { width: min(480px, 100%); }
|
|
23
25
|
body.portal .card .brand h1 { font-size: 15px; margin: 0; }
|
|
24
26
|
body.portal .card .brand h1 a { color: var(--ink); text-decoration: none; }
|
|
25
27
|
main.boot { max-width: 520px; display: grid; gap: 14px; padding-top: 18vh; }
|
package/client/platform.tsx
CHANGED
|
@@ -79,7 +79,7 @@ type OrgWorld = { name: string; host: string; base: string; provisionedAt: strin
|
|
|
79
79
|
/** An invitation to your address still pending, where the provider keeps them (GET /-/orgs). */
|
|
80
80
|
type Invitation = { id: string; org: { id: string; slug: string | null; name: string }; role: string; expiresAt: string | null };
|
|
81
81
|
type OrgRow = { id: string; slug: string | null; name: string; role: string; held?: string | null; flags?: Record<string, boolean>; worlds?: OrgWorld[]; security?: { membersCanInvite: boolean } };
|
|
82
|
-
type WorldRow = { org: string; world: string; name: string; base: string; origin?: string; twins: Array<{ vendor: string }>; /** kept to named members */ restricted?: boolean };
|
|
82
|
+
type WorldRow = { org: string; world: string; name: string; base: string; origin?: string; twins: Array<{ vendor: string }>; /** kept to named members */ restricted?: boolean; /** its landed changesets, when the World answered */ landed?: number };
|
|
83
83
|
const slugOf = (o: OrgRow): string => o.slug ?? o.id;
|
|
84
84
|
const isAdmin = (o: OrgRow): boolean => o.role === 'admin' || o.role === 'org:admin';
|
|
85
85
|
const NAME = /^[a-z0-9][a-z0-9-]{0,38}$/;
|
|
@@ -226,7 +226,8 @@ function Palette({ open, onClose, orgs, shown, worlds, pages, support }: { open:
|
|
|
226
226
|
const items: Item[] = useMemo(() => {
|
|
227
227
|
const all: Item[] = [];
|
|
228
228
|
if (shown) for (const [k, label] of pages) all.push({ label, hint: shown.name, run: () => go({ kind: 'org', org: slugOf(shown), page: k }) });
|
|
229
|
-
|
|
229
|
+
// a World opens at an origin of its own, where its host serves its dashboard; one without has nothing to open
|
|
230
|
+
for (const w of worlds) if (w.origin) all.push({ label: `Open ${w.name}`, hint: 'World', run: () => { window.location.assign(`/-/worlds/${w.name}/open`); } });
|
|
230
231
|
for (const o of orgs) if (o.id !== shown?.id) all.push({ label: `Switch to ${o.name}`, hint: 'org', run: () => go({ kind: 'org', org: slugOf(o), page: 'worlds' }) });
|
|
231
232
|
all.push({ label: 'Account and tokens', hint: 'you', run: () => go({ kind: 'you', page: 'account' }) });
|
|
232
233
|
if (support) all.push({ label: 'Help', hint: 'you', run: () => go({ kind: 'you', page: 'help' }) });
|
|
@@ -303,7 +304,7 @@ function WorldsPage({ org, rows, load, orgs, allRows, version, readOnly, onChang
|
|
|
303
304
|
return (
|
|
304
305
|
<>
|
|
305
306
|
<Head title="Worlds" count={load.state === 'ok' ? rows.length : undefined}>{rows.length > 0 ? <input type="search" className="toolbar-search" aria-label="Find a World" placeholder="Find a World…" value={q} onChange={(e) => setQ(e.target.value)} /> : null}{make}</Head>
|
|
306
|
-
<GettingStarted orgs={orgs} worlds={allRows - rows.filter((r) => r.name.split('/')[1] === 'sample').length} firstWorld={rows.find((r) => r.name.split('/')[1] !== 'sample')?.name} version={version} />
|
|
307
|
+
<GettingStarted orgs={orgs} worlds={allRows - rows.filter((r) => r.name.split('/')[1] === 'sample').length} firstWorld={rows.find((r) => r.name.split('/')[1] !== 'sample')?.name} pushed={rows.some((r) => r.name.split('/')[1] !== 'sample' && (r.landed ?? 0) > 0)} version={version} refresh={onChanged} />
|
|
307
308
|
{load.state !== 'ok' ? <Pending load={load} what="the org's Worlds" retry={onChanged} /> : rows.length === 0 ? (
|
|
308
309
|
<Empty title="No Worlds yet" action={<div className="actions-row">{make}{!readOnly ? <button type="button" onClick={() => void trySample()} disabled={sampling}>{sampling ? 'Making the sample…' : 'Try a sample World'}</button> : null}</div>}>A World stands in for the services your app uses: your app talks to it as it would to Stripe or GitHub, nothing real happens, and you see everything it did on each vendor's own screens.{!readOnly ? <span className="mode sample-note">The sample is a small shop's Stripe and Slack, seeded as its app would leave them. It counts as one of the org's Worlds; delete it when you are done.</span> : null}</Empty>
|
|
309
310
|
) : (
|
|
@@ -312,11 +313,11 @@ function WorldsPage({ org, rows, load, orgs, allRows, version, readOnly, onChang
|
|
|
312
313
|
<thead><tr><th>World</th><th>Stand-ins for</th><th>Host</th><th>Created</th><th className="num"><span className="sr">Actions</span></th></tr></thead>
|
|
313
314
|
<tbody>{shown.map((w) => { const rec = record.get(w.name); const short = w.name.split('/')[1] ?? w.name; return (<React.Fragment key={w.name}>
|
|
314
315
|
<tr>
|
|
315
|
-
<td><span className="vendor-cell"><Initial of={short} small /><span className="who"><span className="name-line"
|
|
316
|
+
<td><span className="vendor-cell"><Initial of={short} small /><span className="who"><span className="name-line">{w.origin ? <a href={`/-/worlds/${w.name}/open`}>{short}</a> : short}{w.restricted ? <span className="badge restricted" title="Only some members of the org reach it">Restricted</span> : null}</span>{w.origin ? <span className="mode mono">{new URL(w.origin).host}</span> : null}</span></span></td>
|
|
316
317
|
<td><span className="marks" title={w.twins.map((t) => vendorName(t.vendor)).join(', ')}>{w.twins.slice(0, 5).map((t) => <Mark key={t.vendor} vendor={t.vendor} />)}{w.twins.length > 5 ? <span className="mode">+{w.twins.length - 5}</span> : null}<span className="mode">{w.twins.length === 1 ? vendorName(w.twins[0]!.vendor) : `${w.twins.length} vendors`}</span></span></td>
|
|
317
318
|
<td className="mode">{rec?.host ?? '—'}</td>
|
|
318
319
|
<td className="mode">{rec ? <span title={when(rec.provisionedAt)}>{ago(rec.provisionedAt)}</span> : '—'}</td>
|
|
319
|
-
<td className="num row-actions"
|
|
320
|
+
<td className="num row-actions">{w.origin ? <a className="button primary" href={`/-/worlds/${w.name}/open`} aria-label={`Open ${w.name}`}>Open</a> : <span className="mode" title="Its host serves no dashboard: the command and your app reach it at its address">No dashboard</span>}
|
|
320
321
|
<Menu label={`More for ${w.name}`} items={[
|
|
321
322
|
{ label: 'Copy the command to use it', onSelect: () => { void navigator.clipboard?.writeText(`volter remote add origin ${w.name}`).then(() => toast('Copied: volter remote add origin …')); } },
|
|
322
323
|
!readOnly ? { label: 'Make a key…', onSelect: () => setKeyFor(w.name) } : null,
|
|
@@ -1055,23 +1056,39 @@ function HelpPage({ orgs, onChanged }: { orgs: string[]; onChanged?: () => void
|
|
|
1055
1056
|
}
|
|
1056
1057
|
|
|
1057
1058
|
/** The first run: what a person does next, with the real commands, as a checklist with its progress; dismissed for good. */
|
|
1058
|
-
function GettingStarted({ orgs, worlds, firstWorld, version }: { orgs: string[]; worlds: number; firstWorld?: string | undefined; version: number }): React.JSX.Element | null {
|
|
1059
|
+
function GettingStarted({ orgs, worlds, firstWorld, pushed, version, refresh }: { orgs: string[]; worlds: number; firstWorld?: string | undefined; pushed: boolean; version: number; refresh: () => void }): React.JSX.Element | null {
|
|
1059
1060
|
const KEY = 'volter.console.onboarding';
|
|
1060
1061
|
const [dismissed, setDismissed] = useState<boolean>(() => { try { return localStorage.getItem(KEY) === 'done'; } catch { return false; } });
|
|
1061
1062
|
const [tokensMade, setTokensMade] = useState<boolean | null>(null);
|
|
1062
1063
|
const mounted = useRef(true); useEffect(() => () => { mounted.current = false; }, []);
|
|
1063
1064
|
useEffect(() => { void door('/-/tokens').then(async (r) => { if (mounted.current) setTokensMade(r.ok ? ((await r.json()) as { tokens: unknown[] }).tokens.length > 0 : false); }).catch(() => undefined); }, [version]);
|
|
1064
|
-
if (dismissed) return null;
|
|
1065
1065
|
const org = orgs[0];
|
|
1066
1066
|
// the way in is the app's folder (docs/contributing/architecture.md): the command makes the World from the vendors
|
|
1067
1067
|
// the app uses; Create World above is the way for a person without a terminal
|
|
1068
1068
|
const steps: Array<[boolean, React.ReactNode]> = [
|
|
1069
1069
|
[orgs.length > 0, 'Create or join an org'],
|
|
1070
|
-
[tokensMade === true,
|
|
1071
|
-
[worlds > 0,
|
|
1072
|
-
[
|
|
1070
|
+
[tokensMade === true, <React.Fragment key="command">Install the command and sign it in: <code>npm install -g @volter/world</code>, then <code>volter login {ORIGIN}</code></React.Fragment>],
|
|
1071
|
+
[worlds > 0, <React.Fragment key="world">In your app's folder, make its World here: <code>volter world init</code>, then <code>volter remote add origin {firstWorld ?? `${org ?? '<org>'}/<world>`}{firstWorld ? '' : ' --create'}</code></React.Fragment>],
|
|
1072
|
+
[pushed, <React.Fragment key="push">Send it what your app did: <code>volter world changeset -m "…"</code>, then <code>volter world push</code></React.Fragment>],
|
|
1073
1073
|
];
|
|
1074
1074
|
const done = steps.filter(([d]) => d).length;
|
|
1075
|
+
// unfinished, the list re-reads what it ticks from while its tab is in view: its last steps happen in a terminal, and
|
|
1076
|
+
// a World made or a push landed there ticks here without a reload
|
|
1077
|
+
const again = useRef(refresh); again.current = refresh;
|
|
1078
|
+
useEffect(() => {
|
|
1079
|
+
if (dismissed || done === steps.length) return;
|
|
1080
|
+
const every = setInterval(() => { if (document.visibilityState === 'visible') again.current(); }, 5000);
|
|
1081
|
+
return () => clearInterval(every);
|
|
1082
|
+
}, [dismissed, done, steps.length]);
|
|
1083
|
+
if (dismissed) return null;
|
|
1084
|
+
const dismiss = (): void => { try { localStorage.setItem(KEY, 'done'); } catch { /* no storage */ } setDismissed(true); };
|
|
1085
|
+
// all four done: what comes next, in a line, and the card goes when you say
|
|
1086
|
+
if (done === steps.length) return (
|
|
1087
|
+
<section aria-labelledby="getting-started" className="onboarding">
|
|
1088
|
+
<div className="onboarding-head"><h3 id="getting-started">You are set up</h3><span className="mode">{done} of {steps.length}</span><div className="progress" aria-hidden="true"><span style={{ width: '100%' }} /></div><button type="button" onClick={dismiss}>Done</button></div>
|
|
1089
|
+
<p className="mode onboarding-alt">Your app's changes land in {firstWorld ?? 'its World'}. Next: {org ? <Go to={{ kind: 'org', org, page: 'members' }}>invite your team</Go> : 'invite your team'}, who open it as themselves, and give each pull request a preview World with the GitHub Action.</p>
|
|
1090
|
+
</section>
|
|
1091
|
+
);
|
|
1075
1092
|
return (
|
|
1076
1093
|
<section aria-labelledby="getting-started" className="onboarding">
|
|
1077
1094
|
<div className="onboarding-head"><h3 id="getting-started">Get started</h3><span className="mode">{done} of {steps.length}</span><div className="progress" aria-hidden="true"><span style={{ width: `${(done / steps.length) * 100}%` }} /></div><button type="button" className="icon-button" aria-label="Dismiss getting started" onClick={() => { try { localStorage.setItem(KEY, 'done'); } catch { /* no storage */ } setDismissed(true); }}>✕</button></div>
|