@unoverse-platform/studio 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.
- package/bin/studio.mjs +124 -0
- package/index.html +12 -0
- package/local/catalog.ts +105 -0
- package/local/keys.ts +118 -0
- package/local/nodes.ts +77 -0
- package/local/plugin.ts +401 -0
- package/local/scaffold.d.mts +13 -0
- package/local/scaffold.mjs +85 -0
- package/package.json +43 -0
- package/public/logo.png +0 -0
- package/screens/AtomsView.tsx +206 -0
- package/screens/ComponentsView.tsx +551 -0
- package/screens/ConfigForm.tsx +217 -0
- package/screens/DesignSystemView.tsx +324 -0
- package/screens/IdentityHeader.tsx +88 -0
- package/screens/PromptBlocksView.tsx +137 -0
- package/screens/SkillsView.tsx +167 -0
- package/screens/TemplatesView.tsx +1005 -0
- package/screens/index.ts +26 -0
- package/screens/states.ts +44 -0
- package/src/App.tsx +252 -0
- package/src/ConnectUniverse.tsx +232 -0
- package/src/NewProject.tsx +89 -0
- package/src/NodeKeys.tsx +98 -0
- package/src/NodesView.tsx +443 -0
- package/src/PublishDialog.tsx +309 -0
- package/src/UniverseSession.tsx +121 -0
- package/src/host.ts +165 -0
- package/src/index.css +7 -0
- package/src/main.tsx +11 -0
- package/src/universes.ts +210 -0
- package/vendor/sdk/appEngine.tsx +219 -0
- package/vendor/sdk/lib/UnoverseComponent.tsx +120 -0
- package/vendor/sdk/lib/connection.tsx +302 -0
- package/vendor/sdk/lib/core/actions.ts +84 -0
- package/vendor/sdk/lib/core/client.ts +134 -0
- package/vendor/sdk/lib/core/conditions.ts +43 -0
- package/vendor/sdk/lib/core/connection.ts +142 -0
- package/vendor/sdk/lib/core/index.ts +32 -0
- package/vendor/sdk/lib/core/store.ts +455 -0
- package/vendor/sdk/lib/core/types.ts +194 -0
- package/vendor/sdk/lib/index.ts +59 -0
- package/vendor/sdk/lib/isolate.tsx +70 -0
- package/vendor/sdk/lib/primitives.tsx +453 -0
- package/vendor/sdk/lib/realtime/audioUtils.ts +40 -0
- package/vendor/sdk/lib/realtime/types.ts +45 -0
- package/vendor/sdk/lib/realtime/useAudioCapture.ts +237 -0
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +145 -0
- package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +164 -0
- package/vendor/sdk/lib/render.tsx +178 -0
- package/vendor/sdk/lib/streamed.tsx +65 -0
- package/vendor/sdk/lib/style.ts +227 -0
- package/vendor/sdk/lib/template.tsx +521 -0
- package/vendor/sdk/lib/theme.ts +55 -0
- package/vendor/sdk/lib/voice.tsx +311 -0
- package/vendor/sdk/main.tsx +96 -0
- package/vite.config.ts +70 -0
package/screens/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* studio: the Studio screens, owned once and imported by BOTH Studios,
|
|
3
|
+
* the Canvas-embedded catalog and the standalone local authoring app.
|
|
4
|
+
* See docs/architecture/LOCAL_STUDIO.md.
|
|
5
|
+
*
|
|
6
|
+
* These screens contain NO transport, auth or persistence of their own. Everything
|
|
7
|
+
* host-specific is imported from the bare specifier `studio-host`, which each app
|
|
8
|
+
* aliases to its own module (see host.ts in the consuming app). That is the whole
|
|
9
|
+
* seam: Canvas points it at a running universe, local Studio points it at a folder
|
|
10
|
+
* on disk.
|
|
11
|
+
*
|
|
12
|
+
* Consumed as SOURCE via a bundler alias, the same convention `@unoverse/sdk`
|
|
13
|
+
* already uses. No build step, no publish cycle, no stale dist.
|
|
14
|
+
*/
|
|
15
|
+
export { ComponentsView } from "./ComponentsView";
|
|
16
|
+
export { AtomsView } from "./AtomsView";
|
|
17
|
+
export { TemplatesView } from "./TemplatesView";
|
|
18
|
+
export { DesignSystemView } from "./DesignSystemView";
|
|
19
|
+
export { SkillsView } from "./SkillsView";
|
|
20
|
+
export { PromptBlocksView } from "./PromptBlocksView";
|
|
21
|
+
export { IdentityHeader } from "./IdentityHeader";
|
|
22
|
+
export { stateActivation } from "./states";
|
|
23
|
+
// ConfigForm renders a node/package config from its schema. Each host drives it from
|
|
24
|
+
// its own screen (Canvas from MarketplaceView, local Studio from the node testing area),
|
|
25
|
+
// so it is shared even though neither shares the screen around it.
|
|
26
|
+
export { ConfigForm, defaultsFromSchema } from "./ConfigForm";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component-state activation — shared by the Templates and Components views.
|
|
3
|
+
*
|
|
4
|
+
* A component's `states/` files are its Switch-case layers: the state NAME is the
|
|
5
|
+
* case key, and the Switch's `on` field is the discriminant that selects it
|
|
6
|
+
* (UNOVERSE_LAYERS.md §7). To show a state, write `{ [discriminant]: name }` into
|
|
7
|
+
* the component's own slice — exactly what the component's own buttons do.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Find the discriminant field whose Switch has a case named `stateName`. */
|
|
11
|
+
export function discriminantFor(root: unknown, stateName: string): string | null {
|
|
12
|
+
let found: string | null = null;
|
|
13
|
+
const walk = (n: unknown): void => {
|
|
14
|
+
if (found || !n || typeof n !== "object") return;
|
|
15
|
+
if (Array.isArray(n)) return n.forEach(walk);
|
|
16
|
+
const o = n as Record<string, unknown>;
|
|
17
|
+
if (
|
|
18
|
+
o.type === "Switch" &&
|
|
19
|
+
typeof o.on === "string" &&
|
|
20
|
+
o.cases &&
|
|
21
|
+
typeof o.cases === "object" &&
|
|
22
|
+
Object.prototype.hasOwnProperty.call(o.cases, stateName)
|
|
23
|
+
) {
|
|
24
|
+
found = o.on;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
Object.values(o).forEach(walk);
|
|
28
|
+
};
|
|
29
|
+
walk(root);
|
|
30
|
+
return found;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The COMPONENT_DATA payload that activates a component state (null if unresolvable). */
|
|
34
|
+
export function stateActivation(root: unknown, state: { name: string; when?: unknown }): Record<string, unknown> | null {
|
|
35
|
+
const disc = discriminantFor(root, state.name);
|
|
36
|
+
if (disc) return { [disc]: state.name };
|
|
37
|
+
// Fallback: a state file that carries its own `visibleWhen` selector (template-style).
|
|
38
|
+
const w = state.when;
|
|
39
|
+
if (w && typeof w === "object" && typeof (w as { field?: unknown }).field === "string") {
|
|
40
|
+
const c = w as { field: string; eq?: unknown };
|
|
41
|
+
return { [c.field]: c.eq ?? true };
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
package/src/App.tsx
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Studio — the shell.
|
|
3
|
+
*
|
|
4
|
+
* Everything visible here is a SHARED screen (../screens), the same ones the Canvas
|
|
5
|
+
* Studio renders. This file only does what a host must: pick the project, pick the
|
|
6
|
+
* theme variant, resolve the theme, and choose which screen is showing.
|
|
7
|
+
*
|
|
8
|
+
* See docs/architecture/LOCAL_STUDIO.md.
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useState } from "react";
|
|
11
|
+
import type { ResolvedTheme } from "@unoverse/sdk";
|
|
12
|
+
import {
|
|
13
|
+
ComponentsView,
|
|
14
|
+
AtomsView,
|
|
15
|
+
TemplatesView,
|
|
16
|
+
DesignSystemView,
|
|
17
|
+
SkillsView,
|
|
18
|
+
PromptBlocksView,
|
|
19
|
+
} from "@unoverse/studio";
|
|
20
|
+
import { client, usePersistentState } from "./host";
|
|
21
|
+
import { UniverseSession } from "./UniverseSession";
|
|
22
|
+
import { ConnectUniverse } from "./ConnectUniverse";
|
|
23
|
+
import { PublishDialog } from "./PublishDialog";
|
|
24
|
+
import { NewProject } from "./NewProject";
|
|
25
|
+
import { loadUniverses, type Universe } from "./universes";
|
|
26
|
+
// Local-only screens. Nodes are AUTHORED and TESTED here and nowhere else: Canvas keeps
|
|
27
|
+
// the node library (using nodes), the marketplace acquires them (a universe's job).
|
|
28
|
+
import { NodesView } from "./NodesView";
|
|
29
|
+
|
|
30
|
+
type View = "apps" | "components" | "atoms" | "tokens" | "skills" | "prompts" | "nodes";
|
|
31
|
+
|
|
32
|
+
const NAV: { key: View; label: string; group: string }[] = [
|
|
33
|
+
{ key: "apps", label: "Apps", group: "Build" },
|
|
34
|
+
{ key: "components", label: "Components", group: "Build" },
|
|
35
|
+
{ key: "atoms", label: "Atoms", group: "Build" },
|
|
36
|
+
{ key: "tokens", label: "Design System", group: "Build" },
|
|
37
|
+
{ key: "skills", label: "Skills", group: "AI" },
|
|
38
|
+
{ key: "prompts", label: "Prompt Blocks", group: "AI" },
|
|
39
|
+
{ key: "nodes", label: "Nodes", group: "Code" },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The shell, wrapped in the session for the selected universe.
|
|
44
|
+
*
|
|
45
|
+
* The provider must sit ABOVE every screen, because a screen deep in the tree
|
|
46
|
+
* (TemplatesView) already calls `useAuth()`. With no universe selected nothing is
|
|
47
|
+
* mounted and Studio behaves exactly as it did offline.
|
|
48
|
+
*/
|
|
49
|
+
export function App() {
|
|
50
|
+
// Which universe is selected, remembered across refreshes. The list itself lives in
|
|
51
|
+
// localStorage (universes.ts); this is only the pointer into it.
|
|
52
|
+
const [activeUrl, setActiveUrl] = usePersistentState<string | null>("studio:universe", null);
|
|
53
|
+
const active = loadUniverses().find((u) => u.url === activeUrl) ?? null;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<UniverseSession universe={active}>
|
|
57
|
+
<Shell
|
|
58
|
+
active={active}
|
|
59
|
+
onSelectUniverse={(u: Universe | null) => setActiveUrl(u?.url ?? null)}
|
|
60
|
+
/>
|
|
61
|
+
</UniverseSession>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function Shell({
|
|
66
|
+
active,
|
|
67
|
+
onSelectUniverse,
|
|
68
|
+
}: {
|
|
69
|
+
active: Universe | null;
|
|
70
|
+
onSelectUniverse: (u: Universe | null) => void;
|
|
71
|
+
}) {
|
|
72
|
+
const [connectOpen, setConnectOpen] = useState(false);
|
|
73
|
+
const [publishOpen, setPublishOpen] = useState(false);
|
|
74
|
+
const [newProjectOpen, setNewProjectOpen] = useState(false);
|
|
75
|
+
const [view, setView] = usePersistentState<View>("studio:view", "components");
|
|
76
|
+
const [org, setOrg] = usePersistentState<string>("studio:org", "all");
|
|
77
|
+
const [variant, setVariant] = usePersistentState<string>("studio:variant", "light");
|
|
78
|
+
|
|
79
|
+
const [projects, setProjects] = useState<string[]>([]);
|
|
80
|
+
const [theme, setTheme] = useState<ResolvedTheme | null>(null);
|
|
81
|
+
const [foundation, setFoundation] = useState<ResolvedTheme | null>(null);
|
|
82
|
+
const [error, setError] = useState<string | null>(null);
|
|
83
|
+
|
|
84
|
+
// The projects in this developer's rx/ folder. Flat, per RX_ORG_MODEL. Re-read after
|
|
85
|
+
// creating one, so a new project appears without a reload.
|
|
86
|
+
const loadProjects = () =>
|
|
87
|
+
fetch("/dev/orgs")
|
|
88
|
+
.then((r) => r.json())
|
|
89
|
+
.then((d) => setProjects(d.orgs ?? []))
|
|
90
|
+
.catch((e) => setError(String(e)));
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
loadProjects();
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
// The theme being previewed, plus the design-system foundation for the same variant
|
|
96
|
+
// so the tokens screen can show what this project overrides and what it inherits.
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const ref = org === "all" ? variant : `${org}/${variant}`;
|
|
99
|
+
client
|
|
100
|
+
.readTheme(ref)
|
|
101
|
+
.then(setTheme)
|
|
102
|
+
.catch(() => client.readTheme(variant).then(setTheme).catch((e) => setError(String(e))));
|
|
103
|
+
client.readTheme(variant).then(setFoundation).catch(() => setFoundation(null));
|
|
104
|
+
}, [org, variant]);
|
|
105
|
+
|
|
106
|
+
if (error) {
|
|
107
|
+
return (
|
|
108
|
+
<div className="flex h-screen items-center justify-center bg-[#0f141a] p-8 text-center text-sm text-red-300">
|
|
109
|
+
<div>
|
|
110
|
+
<p className="mb-2 font-semibold">Studio could not read your project.</p>
|
|
111
|
+
<p className="text-gray-400">{error}</p>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className="grid h-screen grid-rows-[auto_1fr] bg-[#0f141a] text-gray-200">
|
|
119
|
+
<header className="flex items-center gap-4 border-b border-gray-800 px-4 py-2">
|
|
120
|
+
{/* Local asset, not a CDN URL: Studio is offline by design and the mark must
|
|
121
|
+
render on a plane. Wordmark stays lowercase per the brand. */}
|
|
122
|
+
<span className="flex items-center gap-2">
|
|
123
|
+
<img src="/logo.png" alt="" className="size-6 object-contain" />
|
|
124
|
+
<span className="text-sm font-semibold tracking-tight text-gray-100">unoverse studio</span>
|
|
125
|
+
</span>
|
|
126
|
+
|
|
127
|
+
<nav className="flex items-center gap-1">
|
|
128
|
+
{NAV.map((n) => (
|
|
129
|
+
<button
|
|
130
|
+
key={n.key}
|
|
131
|
+
onClick={() => setView(n.key)}
|
|
132
|
+
className={`rounded px-2.5 py-1 text-xs font-medium transition ${
|
|
133
|
+
view === n.key ? "bg-white/10 text-gray-100" : "text-gray-400 hover:text-gray-200"
|
|
134
|
+
}`}
|
|
135
|
+
>
|
|
136
|
+
{n.label}
|
|
137
|
+
</button>
|
|
138
|
+
))}
|
|
139
|
+
</nav>
|
|
140
|
+
|
|
141
|
+
<div className="ml-auto flex items-center gap-2 text-xs">
|
|
142
|
+
{/*
|
|
143
|
+
The dropdown both SELECTS and CREATES. A separate button would be a second
|
|
144
|
+
place to look for the same idea, and the moment a developer wants a new project
|
|
145
|
+
is the moment they open this list and do not find it.
|
|
146
|
+
*/}
|
|
147
|
+
<select
|
|
148
|
+
value={org}
|
|
149
|
+
onChange={(e) => {
|
|
150
|
+
if (e.target.value === "__new") return setNewProjectOpen(true);
|
|
151
|
+
setOrg(e.target.value);
|
|
152
|
+
}}
|
|
153
|
+
className="rounded border border-gray-700 bg-[#1b2129] px-2 py-1 text-gray-200"
|
|
154
|
+
>
|
|
155
|
+
<option value="all">All projects</option>
|
|
156
|
+
{projects.map((o) => (
|
|
157
|
+
<option key={o} value={o}>
|
|
158
|
+
{o}
|
|
159
|
+
</option>
|
|
160
|
+
))}
|
|
161
|
+
<option disabled>──────────</option>
|
|
162
|
+
<option value="__new">New project…</option>
|
|
163
|
+
</select>
|
|
164
|
+
<button
|
|
165
|
+
onClick={() => setVariant(variant === "light" ? "dark" : "light")}
|
|
166
|
+
className="rounded border border-gray-700 px-2 py-1 text-gray-300 hover:text-gray-100"
|
|
167
|
+
title="Theme variant"
|
|
168
|
+
>
|
|
169
|
+
{variant}
|
|
170
|
+
</button>
|
|
171
|
+
{/* The ONLY place Studio asks for anything. Unconnected reads as an invitation,
|
|
172
|
+
not an error: working offline is a supported state, not a missing step. */}
|
|
173
|
+
<button
|
|
174
|
+
onClick={() => setConnectOpen(true)}
|
|
175
|
+
className="rounded border border-gray-700 px-2 py-1 text-gray-300 hover:text-gray-100"
|
|
176
|
+
title="Where your work publishes to"
|
|
177
|
+
>
|
|
178
|
+
{active ? active.label : "Not connected"}
|
|
179
|
+
</button>
|
|
180
|
+
{/* PUBLISHES THE SELECTED PROJECT. Disabled rather than hidden when there is
|
|
181
|
+
nowhere to send or nothing selected: a missing button reads as a missing
|
|
182
|
+
feature, a disabled one with a reason reads as a next step. */}
|
|
183
|
+
<button
|
|
184
|
+
onClick={() => setPublishOpen(true)}
|
|
185
|
+
disabled={!active || org === "all"}
|
|
186
|
+
title={
|
|
187
|
+
!active
|
|
188
|
+
? "Connect a universe first"
|
|
189
|
+
: org === "all"
|
|
190
|
+
? "Pick one project to publish"
|
|
191
|
+
: `Publish ${org} to ${active.label}`
|
|
192
|
+
}
|
|
193
|
+
className={
|
|
194
|
+
active && org !== "all"
|
|
195
|
+
? "rounded bg-emerald-500 px-3 py-1 font-medium text-emerald-950 hover:bg-emerald-400"
|
|
196
|
+
: "rounded border border-gray-700 px-2 py-1 text-gray-500 disabled:cursor-not-allowed"
|
|
197
|
+
}
|
|
198
|
+
>
|
|
199
|
+
Publish
|
|
200
|
+
</button>
|
|
201
|
+
</div>
|
|
202
|
+
</header>
|
|
203
|
+
|
|
204
|
+
{publishOpen && active && org !== "all" && (
|
|
205
|
+
<PublishDialog project={org} universe={active} onClose={() => setPublishOpen(false)} />
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
{/* Created, then SELECTED. Making a project and leaving the developer looking at the
|
|
209
|
+
old one would be a step they have to discover. */}
|
|
210
|
+
{newProjectOpen && (
|
|
211
|
+
<NewProject
|
|
212
|
+
onCreated={async (name) => {
|
|
213
|
+
await loadProjects();
|
|
214
|
+
setOrg(name);
|
|
215
|
+
setNewProjectOpen(false);
|
|
216
|
+
}}
|
|
217
|
+
onClose={() => setNewProjectOpen(false)}
|
|
218
|
+
/>
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
{connectOpen && (
|
|
222
|
+
<ConnectUniverse
|
|
223
|
+
active={active}
|
|
224
|
+
onSelect={onSelectUniverse}
|
|
225
|
+
onClose={() => setConnectOpen(false)}
|
|
226
|
+
/>
|
|
227
|
+
)}
|
|
228
|
+
|
|
229
|
+
{/* The shared screens size themselves with `flex-1`, so the host must be a flex
|
|
230
|
+
column. As a grid cell they would collapse to their content height. */}
|
|
231
|
+
<main className="flex min-h-0 flex-col overflow-hidden">
|
|
232
|
+
{!theme ? (
|
|
233
|
+
<div className="p-6 text-xs text-gray-500">Reading your theme…</div>
|
|
234
|
+
) : view === "apps" ? (
|
|
235
|
+
<TemplatesView theme={theme} themeName={variant} org={org} />
|
|
236
|
+
) : view === "components" ? (
|
|
237
|
+
<ComponentsView theme={theme} org={org} />
|
|
238
|
+
) : view === "atoms" ? (
|
|
239
|
+
<AtomsView theme={theme} />
|
|
240
|
+
) : view === "tokens" ? (
|
|
241
|
+
<DesignSystemView theme={theme} foundation={foundation} org={org} />
|
|
242
|
+
) : view === "skills" ? (
|
|
243
|
+
<SkillsView />
|
|
244
|
+
) : view === "nodes" ? (
|
|
245
|
+
<NodesView />
|
|
246
|
+
) : (
|
|
247
|
+
<PromptBlocksView />
|
|
248
|
+
)}
|
|
249
|
+
</main>
|
|
250
|
+
</div>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connect to a universe: the "when you are ready" step.
|
|
3
|
+
*
|
|
4
|
+
* Everything before this point is offline. A developer builds components, templates and
|
|
5
|
+
* nodes against their own folders with no account and no network. This screen is where
|
|
6
|
+
* they choose somewhere to publish, and it is deliberately the only place Studio asks
|
|
7
|
+
* for anything.
|
|
8
|
+
*
|
|
9
|
+
* The flow is: type an address, Studio asks that universe who authenticates it, then
|
|
10
|
+
* signs in against whatever the answer was. Studio names no provider.
|
|
11
|
+
*
|
|
12
|
+
* See docs/architecture/LOCAL_STUDIO.md.
|
|
13
|
+
*/
|
|
14
|
+
import { useState } from "react";
|
|
15
|
+
import { useAuth } from "react-oidc-context";
|
|
16
|
+
import {
|
|
17
|
+
normaliseUrl,
|
|
18
|
+
defaultLabel,
|
|
19
|
+
discover,
|
|
20
|
+
loginReady,
|
|
21
|
+
canPublish,
|
|
22
|
+
loadUniverses,
|
|
23
|
+
saveUniverse,
|
|
24
|
+
removeUniverse,
|
|
25
|
+
type Universe,
|
|
26
|
+
} from "./universes";
|
|
27
|
+
|
|
28
|
+
/** Decode a JWT payload without verifying it. Display only: the universe verifies. */
|
|
29
|
+
function claimsOf(accessToken: string | undefined): Record<string, unknown> | null {
|
|
30
|
+
if (!accessToken) return null;
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(atob(accessToken.split(".")[1] ?? ""));
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Sign out of the connected universe.
|
|
40
|
+
*
|
|
41
|
+
* `removeUser` clears the local session only: it does not sign you out of the identity
|
|
42
|
+
* provider, so signing in again may return the same account without prompting. That is the
|
|
43
|
+
* provider's decision, not ours, and a full federated logout needs a logout URL configured
|
|
44
|
+
* per universe. Local is the honest scope for a tool.
|
|
45
|
+
*/
|
|
46
|
+
function SignOut({ auth }: { auth: ReturnType<typeof useAuth> }) {
|
|
47
|
+
return (
|
|
48
|
+
<button
|
|
49
|
+
onClick={() => auth?.removeUser()}
|
|
50
|
+
className="text-xs text-gray-500 underline-offset-2 hover:text-gray-300 hover:underline"
|
|
51
|
+
>
|
|
52
|
+
Sign out
|
|
53
|
+
</button>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function ConnectUniverse({
|
|
58
|
+
active,
|
|
59
|
+
onSelect,
|
|
60
|
+
onClose,
|
|
61
|
+
}: {
|
|
62
|
+
active: Universe | null;
|
|
63
|
+
onSelect: (universe: Universe | null) => void;
|
|
64
|
+
onClose: () => void;
|
|
65
|
+
}) {
|
|
66
|
+
const [universes, setUniverses] = useState<Universe[]>(() => loadUniverses());
|
|
67
|
+
const [address, setAddress] = useState("");
|
|
68
|
+
const [busy, setBusy] = useState(false);
|
|
69
|
+
const [error, setError] = useState<string | null>(null);
|
|
70
|
+
|
|
71
|
+
// Undefined when no provider is mounted, which is the normal offline case.
|
|
72
|
+
const auth = useAuth();
|
|
73
|
+
const claims = claimsOf(auth?.user?.access_token);
|
|
74
|
+
const signedIn = Boolean(auth?.isAuthenticated);
|
|
75
|
+
|
|
76
|
+
async function connect() {
|
|
77
|
+
setError(null);
|
|
78
|
+
setBusy(true);
|
|
79
|
+
try {
|
|
80
|
+
const url = normaliseUrl(address);
|
|
81
|
+
const config = await discover(url);
|
|
82
|
+
const universe: Universe = {
|
|
83
|
+
url,
|
|
84
|
+
label: defaultLabel(url),
|
|
85
|
+
config,
|
|
86
|
+
connectedAt: new Date().toISOString(),
|
|
87
|
+
};
|
|
88
|
+
setUniverses(saveUniverse(universe));
|
|
89
|
+
setAddress("");
|
|
90
|
+
onSelect(universe);
|
|
91
|
+
} catch (e: any) {
|
|
92
|
+
// These messages are written to be actionable (universes.ts), so show them as-is.
|
|
93
|
+
setError(e?.message ?? String(e));
|
|
94
|
+
} finally {
|
|
95
|
+
setBusy(false);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function forget(url: string) {
|
|
100
|
+
setUniverses(removeUniverse(url));
|
|
101
|
+
if (active?.url === url) onSelect(null);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const readiness = active ? loginReady(active.config) : null;
|
|
105
|
+
const allowed = active && claims ? canPublish(claims, active.config) : false;
|
|
106
|
+
/** The signed-in address. Namespace-aware: Auth0 emits `email` ONLY namespaced on an
|
|
107
|
+
* access token, so a plain `claims.email` is undefined and the id leaks into the UI. */
|
|
108
|
+
const email = (() => {
|
|
109
|
+
const direct = (claims as any)?.email;
|
|
110
|
+
if (typeof direct === "string") return direct;
|
|
111
|
+
const key = Object.keys(claims ?? {}).find((k) => k.endsWith("/email") || k.endsWith("/claims/email"));
|
|
112
|
+
const value = key ? (claims as any)[key] : undefined;
|
|
113
|
+
return typeof value === "string" ? value : null;
|
|
114
|
+
})();
|
|
115
|
+
|
|
116
|
+
/** What the token says, for the message below. Trimmed, as the checks are. */
|
|
117
|
+
const permissionList: string[] = (() => {
|
|
118
|
+
const direct = (claims as any)?.permissions;
|
|
119
|
+
const nsKey = Object.keys(claims ?? {}).find((k) => k.endsWith("/permissions"));
|
|
120
|
+
const list = Array.isArray(direct) ? direct : ((claims as any)?.[nsKey ?? ""] ?? []);
|
|
121
|
+
return Array.isArray(list) ? list.map((p) => String(p).trim()) : [];
|
|
122
|
+
})();
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/60 p-8" onClick={onClose}>
|
|
126
|
+
<div
|
|
127
|
+
className="w-full max-w-lg rounded-xl border border-gray-800 bg-[#151b22] p-6 text-gray-200 shadow-2xl"
|
|
128
|
+
onClick={(e) => e.stopPropagation()}
|
|
129
|
+
>
|
|
130
|
+
<h2 className="text-base font-semibold text-gray-100">Publish to a universe</h2>
|
|
131
|
+
<p className="mt-1 text-xs text-gray-400">
|
|
132
|
+
Studio works offline. Connect a universe when you are ready to publish.
|
|
133
|
+
</p>
|
|
134
|
+
|
|
135
|
+
{/* Add one */}
|
|
136
|
+
<div className="mt-5 flex gap-2">
|
|
137
|
+
<input
|
|
138
|
+
value={address}
|
|
139
|
+
onChange={(e) => setAddress(e.target.value)}
|
|
140
|
+
onKeyDown={(e) => e.key === "Enter" && !busy && connect()}
|
|
141
|
+
placeholder="your-universe.com"
|
|
142
|
+
className="flex-1 rounded border border-gray-700 bg-[#1b2129] px-3 py-2 text-sm text-gray-200 placeholder:text-gray-600 focus:border-gray-500 focus:outline-none"
|
|
143
|
+
/>
|
|
144
|
+
<button
|
|
145
|
+
onClick={connect}
|
|
146
|
+
disabled={busy || !address.trim()}
|
|
147
|
+
className="rounded bg-gray-100 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-white disabled:opacity-30"
|
|
148
|
+
>
|
|
149
|
+
{busy ? "Checking..." : "Connect"}
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
{error && <p className="mt-2 text-xs text-red-400">{error}</p>}
|
|
153
|
+
|
|
154
|
+
{/* The ones this developer has connected to. There is no central directory of
|
|
155
|
+
universes and there cannot be: each is independent with its own provider. */}
|
|
156
|
+
{universes.length > 0 && (
|
|
157
|
+
<ul className="mt-5 space-y-2">
|
|
158
|
+
{universes.map((u) => (
|
|
159
|
+
<li
|
|
160
|
+
key={u.url}
|
|
161
|
+
className={`flex items-center justify-between rounded-md border px-3 py-2 text-sm ${
|
|
162
|
+
active?.url === u.url
|
|
163
|
+
? "border-gray-500 bg-white/5"
|
|
164
|
+
: "border-gray-800 hover:border-gray-700"
|
|
165
|
+
}`}
|
|
166
|
+
>
|
|
167
|
+
<button className="text-left" onClick={() => onSelect(u)}>
|
|
168
|
+
<span className="font-medium">{u.label}</span>
|
|
169
|
+
<span className="ml-2 text-gray-500">{u.url}</span>
|
|
170
|
+
</button>
|
|
171
|
+
<button className="text-xs text-gray-500 hover:text-red-400" onClick={() => forget(u.url)}>
|
|
172
|
+
Forget
|
|
173
|
+
</button>
|
|
174
|
+
</li>
|
|
175
|
+
))}
|
|
176
|
+
</ul>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
{/* Sign-in state for the selected universe. Every branch says what to do next. */}
|
|
180
|
+
{active && (
|
|
181
|
+
<div className="mt-5 border-t border-gray-800 pt-4 text-sm">
|
|
182
|
+
{readiness && !readiness.ok ? (
|
|
183
|
+
// A universe with auth off, or one misconfigured. Publishing is not
|
|
184
|
+
// possible, and saying so beats a redirect to a provider that is not there.
|
|
185
|
+
<p className="text-xs text-amber-400">{readiness.reason}</p>
|
|
186
|
+
) : !signedIn ? (
|
|
187
|
+
<button
|
|
188
|
+
onClick={() => auth?.signinRedirect()}
|
|
189
|
+
className="rounded bg-gray-100 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-white"
|
|
190
|
+
>
|
|
191
|
+
Sign in to {active.label}
|
|
192
|
+
</button>
|
|
193
|
+
) : allowed ? (
|
|
194
|
+
<div className="flex items-center justify-between gap-3">
|
|
195
|
+
{/* The fact, not the identifier. A raw `auth0|692be24b…` is noise: it is not
|
|
196
|
+
how anyone thinks of themselves, and it appeared only because `email` is a
|
|
197
|
+
NAMESPACED claim that a plain read misses (AUTH_TOKEN_FLOW.md names this
|
|
198
|
+
exact footgun). Read properly it is an address; absent, the sentence is
|
|
199
|
+
better without it. */}
|
|
200
|
+
<p className="text-sm font-medium text-emerald-400">
|
|
201
|
+
Ready to publish{email ? <span className="font-normal text-gray-400"> as {email}</span> : null}
|
|
202
|
+
</p>
|
|
203
|
+
<SignOut auth={auth} />
|
|
204
|
+
</div>
|
|
205
|
+
) : (
|
|
206
|
+
// Signed in but without the permission. Said HERE, not at the first push,
|
|
207
|
+
// so nobody builds for an hour and then finds out.
|
|
208
|
+
<div>
|
|
209
|
+
<p className="text-xs text-amber-400">
|
|
210
|
+
Signed in, but your account does not have{" "}
|
|
211
|
+
<code>{active.config.publishPermission}</code> on this universe. Ask whoever
|
|
212
|
+
administers it to grant it.
|
|
213
|
+
</p>
|
|
214
|
+
{/* WHAT THE TOKEN ACTUALLY CARRIES. Kept, in a reduced form, because "you do not
|
|
215
|
+
have this permission" is unhelpful when the identity provider visibly
|
|
216
|
+
says you do. Printing the claim turns a guess into a fact: it is how a
|
|
217
|
+
leading space in an Auth0 permission (" marketplace:publish") was found,
|
|
218
|
+
after the screens, the token and the label had all agreed it was correct. */}
|
|
219
|
+
<p className="mt-2 text-xs text-gray-500">
|
|
220
|
+
Your token carries: <code>{permissionList.join(", ") || "no permissions claim"}</code>
|
|
221
|
+
</p>
|
|
222
|
+
<div className="mt-2">
|
|
223
|
+
<SignOut auth={auth} />
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creating a project from inside Studio.
|
|
3
|
+
*
|
|
4
|
+
* A project is `rx/<name>/`: the folder a developer authors in, and the unit that
|
|
5
|
+
* publishes. Until this existed, Studio could create your FIRST one (bin/studio.mjs, at
|
|
6
|
+
* launch, only when the folder was empty) and never your second. The dropdown listed what
|
|
7
|
+
* was on disk and offered no way to add to it, so the answer to "start another project"
|
|
8
|
+
* was to quit Studio and mkdir.
|
|
9
|
+
*
|
|
10
|
+
* The name is validated on the server (local/scaffold.mjs) rather than here, because it is
|
|
11
|
+
* the same rule the CLI applies and a rule stated twice is a rule that will disagree with
|
|
12
|
+
* itself. This screen shows what the server said.
|
|
13
|
+
*/
|
|
14
|
+
import { useState } from "react";
|
|
15
|
+
|
|
16
|
+
export function NewProject({
|
|
17
|
+
onCreated,
|
|
18
|
+
onClose,
|
|
19
|
+
}: {
|
|
20
|
+
onCreated: (name: string) => void;
|
|
21
|
+
onClose: () => void;
|
|
22
|
+
}) {
|
|
23
|
+
const [name, setName] = useState("");
|
|
24
|
+
const [error, setError] = useState<string | null>(null);
|
|
25
|
+
const [busy, setBusy] = useState(false);
|
|
26
|
+
|
|
27
|
+
async function create() {
|
|
28
|
+
setBusy(true);
|
|
29
|
+
setError(null);
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch("/dev/projects", {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "content-type": "application/json" },
|
|
34
|
+
body: JSON.stringify({ name: name.trim() }),
|
|
35
|
+
});
|
|
36
|
+
const b = await res.json();
|
|
37
|
+
if (!res.ok || b.error) throw new Error(b.error ?? `HTTP ${res.status}`);
|
|
38
|
+
onCreated(b.name);
|
|
39
|
+
} catch (e: any) {
|
|
40
|
+
setError(e?.message ?? String(e));
|
|
41
|
+
} finally {
|
|
42
|
+
setBusy(false);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/60 p-8" onClick={onClose}>
|
|
48
|
+
<div
|
|
49
|
+
className="w-full max-w-md rounded-xl border border-gray-800 bg-[#151b22] p-6 text-gray-200 shadow-2xl"
|
|
50
|
+
onClick={(e) => e.stopPropagation()}
|
|
51
|
+
>
|
|
52
|
+
<h2 className="text-base font-semibold text-gray-100">New project</h2>
|
|
53
|
+
<p className="mt-1 text-xs text-gray-500">
|
|
54
|
+
Creates rx/<name>/ with components, styles and templates, and one component to
|
|
55
|
+
open on.
|
|
56
|
+
</p>
|
|
57
|
+
|
|
58
|
+
<input
|
|
59
|
+
autoFocus
|
|
60
|
+
value={name}
|
|
61
|
+
onChange={(e) => setName(e.target.value)}
|
|
62
|
+
onKeyDown={(e) => e.key === "Enter" && name.trim() && !busy && create()}
|
|
63
|
+
placeholder="my-project"
|
|
64
|
+
spellCheck={false}
|
|
65
|
+
className="mt-4 w-full rounded border border-gray-700 bg-[#0f141a] px-3 py-2 text-sm text-white placeholder:text-gray-600 focus:border-emerald-500 focus:outline-none"
|
|
66
|
+
/>
|
|
67
|
+
<p className="mt-2 text-xs text-gray-600">
|
|
68
|
+
Lowercase letters, numbers and dashes. This becomes the folder name and the owner
|
|
69
|
+
of everything you publish from it.
|
|
70
|
+
</p>
|
|
71
|
+
|
|
72
|
+
{error && <p className="mt-3 text-xs text-red-400">{error}</p>}
|
|
73
|
+
|
|
74
|
+
<div className="mt-6 flex items-center gap-2">
|
|
75
|
+
<button
|
|
76
|
+
onClick={create}
|
|
77
|
+
disabled={busy || !name.trim()}
|
|
78
|
+
className="rounded bg-emerald-500 px-4 py-2 text-sm font-semibold text-emerald-950 hover:bg-emerald-400 disabled:opacity-30"
|
|
79
|
+
>
|
|
80
|
+
{busy ? "Creating…" : "Create project"}
|
|
81
|
+
</button>
|
|
82
|
+
<button onClick={onClose} className="rounded px-3 py-2 text-sm text-gray-400 hover:text-gray-200">
|
|
83
|
+
Cancel
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|