@backmatter/studio 0.0.3 → 0.0.5

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.
@@ -1,6 +1,6 @@
1
1
  // packages/studio/src/pages/EmbedPublishPage.tsx
2
2
  // The CMS commit flow, alone, so the on-site bar can frame it.
3
- import { useEffect, useMemo, useState, type FormEvent } from "react";
3
+ import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
4
4
  import { installParentChatHotkey } from "../chrome/hotkeys";
5
5
  import * as stylex from "@stylexjs/stylex";
6
6
  import {
@@ -13,14 +13,13 @@ import {
13
13
  publishCommittedFiles,
14
14
  resolveDoc,
15
15
  type ApiError,
16
+ type ChangeStatus,
16
17
  type GitChange,
17
18
  type GitState,
18
19
  } from "../api";
19
20
  import { statusBarAction } from "../chrome/status-bar-action";
20
- import { colors, space, type } from "../styles/tokens.stylex";
21
- import { Button } from "../ui/Button";
22
- import { Input } from "../ui/Input";
23
- import { Text } from "../ui/Text";
21
+ import { colors, radius, space, type } from "../styles/tokens.stylex";
22
+ import { popup } from "../ui/popup";
24
23
  import { canPublish } from "../workspace/permissions";
25
24
 
26
25
  const POLL_MS = 8000;
@@ -50,7 +49,24 @@ function changeKey(doc: GitChange) {
50
49
  return `${doc.locale}/${doc.type}/${doc.slug}`;
51
50
  }
52
51
 
53
- export function EmbedPublishPage({ workspaceId }: { workspaceId: string }) {
52
+ const STATUS_LABEL: Record<ChangeStatus, string> = {
53
+ modified: "modified",
54
+ added: "new",
55
+ conflict: "conflict",
56
+ deleted: "deleted",
57
+ };
58
+
59
+ const hover = "@media (hover: hover)";
60
+
61
+ export function EmbedPublishPage({
62
+ workspaceId,
63
+ onStatus,
64
+ onPublished,
65
+ }: {
66
+ workspaceId: string;
67
+ onStatus?: (status: { changeCount: number; conflictCount: number }) => void;
68
+ onPublished?: () => void;
69
+ }) {
54
70
  useEffect(() => installParentChatHotkey(), []);
55
71
  const [git, setGit] = useState<GitState | null>(null);
56
72
  const [role, setRole] = useState("viewer");
@@ -73,6 +89,9 @@ export function EmbedPublishPage({ workspaceId }: { workspaceId: string }) {
73
89
  };
74
90
  }, [workspaceId]);
75
91
 
92
+ const onStatusRef = useRef(onStatus);
93
+ onStatusRef.current = onStatus;
94
+
76
95
  useEffect(() => {
77
96
  let stop = false;
78
97
  const load = () => {
@@ -80,14 +99,13 @@ export function EmbedPublishPage({ workspaceId }: { workspaceId: string }) {
80
99
  .then((next) => {
81
100
  if (stop) return;
82
101
  setGit(next);
102
+ const changeCount = next.changes.length;
103
+ const conflictCount = countConflicts(next);
104
+ onStatusRef.current?.({ changeCount, conflictCount });
83
105
  const origin = parentOrigin();
84
- if (!origin) return;
106
+ if (!origin || window.parent === window) return;
85
107
  window.parent.postMessage(
86
- {
87
- type: "backmatter:git",
88
- changeCount: next.changes.length,
89
- conflictCount: countConflicts(next),
90
- },
108
+ { type: "backmatter:git", changeCount, conflictCount },
91
109
  origin,
92
110
  );
93
111
  })
@@ -143,8 +161,11 @@ export function EmbedPublishPage({ workspaceId }: { workspaceId: string }) {
143
161
  const result = await commitWorkspace(workspaceId, message);
144
162
  setMessage("");
145
163
  publishCommittedFiles(workspaceId, result.files);
164
+ onPublished?.();
146
165
  const origin = parentOrigin();
147
- if (origin) window.parent.postMessage({ type: "backmatter:published" }, origin);
166
+ if (origin && window.parent !== window) {
167
+ window.parent.postMessage({ type: "backmatter:published" }, origin);
168
+ }
148
169
  setGit(await fetchGit(workspaceId));
149
170
  } catch (err) {
150
171
  setError(formatCommitError(err));
@@ -153,107 +174,281 @@ export function EmbedPublishPage({ workspaceId }: { workspaceId: string }) {
153
174
  }
154
175
  }
155
176
 
156
- const label = committing
157
- ? "Committing…"
158
- : conflictCount > 0
159
- ? `Resolve ${conflictCount} ${conflictCount === 1 ? "conflict" : "conflicts"}`
160
- : action === "blocked"
161
- ? "Writers cannot publish"
162
- : changes.length === 0
163
- ? "Nothing to publish"
164
- : `Deploy ${changes.length} ${changes.length === 1 ? "change" : "changes"}`;
177
+ const branch = git?.currentBranch ?? "main";
165
178
 
166
- return (
167
- <form {...stylex.props(styles.panel)} onSubmit={(event) => void onCommit(event)}>
168
- <Text as="p" size="sm" weight="medium" tone="muted">
169
- {git?.currentBranch ?? "Publish"}
170
- </Text>
171
- {changes.length === 0 ? (
172
- <Text as="p" size="sm" tone="muted">
173
- Nothing to publish
174
- </Text>
175
- ) : (
176
- <ul {...stylex.props(styles.list)}>
177
- {changes.map((change) => (
178
- <li key={changeKey(change)} {...stylex.props(styles.row)}>
179
- <span>
180
- {change.status} {change.type}/{change.slug}
181
- </span>
182
- {change.status === "conflict" ? (
183
- <span {...stylex.props(styles.resolve)}>
184
- <Button
185
- size="sm"
186
- variant="secondary"
179
+ if (conflicts.length > 0) {
180
+ return (
181
+ <div {...stylex.props(styles.form)}>
182
+ <p {...stylex.props(popup.groupLabel)}>Resolve conflicts</p>
183
+ <p {...stylex.props(styles.hint)}>
184
+ Pick a side for each file. Commit stays blocked until every conflict is resolved.
185
+ </p>
186
+ {error ? <p {...stylex.props(styles.error)}>{error}</p> : null}
187
+ <ul {...stylex.props(styles.files, styles.conflictList)}>
188
+ {conflicts.map((doc) => {
189
+ const key = changeKey(doc);
190
+ return (
191
+ <li key={key} {...stylex.props(styles.conflictRow)}>
192
+ <span {...stylex.props(styles.fileName)}>
193
+ {doc.type}/{doc.slug}
194
+ </span>
195
+ <div {...stylex.props(styles.conflictActions)}>
196
+ <button
197
+ type="button"
187
198
  disabled={resolvingKey !== null}
188
- onClick={() => void onResolve(change, "mine")}
199
+ onClick={() => void onResolve(doc, "mine")}
200
+ {...stylex.props(styles.conflictBtn)}
189
201
  >
190
- Keep mine
191
- </Button>
192
- <Button
193
- size="sm"
194
- variant="secondary"
202
+ {resolvingKey === `${key}:mine` ? "Keeping…" : "Keep mine"}
203
+ </button>
204
+ <button
205
+ type="button"
195
206
  disabled={resolvingKey !== null}
196
- onClick={() => void onResolve(change, "theirs")}
207
+ onClick={() => void onResolve(doc, "theirs")}
208
+ {...stylex.props(styles.conflictBtn, styles.conflictBtnDanger)}
197
209
  >
198
- Take theirs
199
- </Button>
210
+ {resolvingKey === `${key}:theirs` ? "Taking…" : "Take theirs"}
211
+ </button>
212
+ </div>
213
+ </li>
214
+ );
215
+ })}
216
+ </ul>
217
+ </div>
218
+ );
219
+ }
220
+
221
+ return (
222
+ <form {...stylex.props(styles.form)} onSubmit={(event) => void onCommit(event)}>
223
+ <p {...stylex.props(popup.groupLabel)}>
224
+ {changes.length === 0
225
+ ? "Nothing to publish"
226
+ : `${changes.length} ${changes.length === 1 ? "file" : "files"} → ${branch}`}
227
+ </p>
228
+ {changes.length > 0 ? (
229
+ <ul {...stylex.props(styles.files)}>
230
+ {changes.map((doc) => (
231
+ <li key={changeKey(doc)}>
232
+ <div {...stylex.props(styles.file)}>
233
+ <span {...stylex.props(styles.fileName)}>
234
+ {doc.type}/{doc.slug}
200
235
  </span>
201
- ) : null}
236
+ <span {...stylex.props(popup.itemMeta)}>{STATUS_LABEL[doc.status]}</span>
237
+ </div>
202
238
  </li>
203
239
  ))}
204
240
  </ul>
205
- )}
206
- {conflicts.length > 0 ? null : (
207
- <Input
208
- value={message}
209
- placeholder={placeholder}
210
- aria-label="Commit message"
211
- onChange={(event) => setMessage(event.target.value)}
212
- />
213
- )}
214
- {error ? (
215
- <Text as="p" size="sm" tone="danger">
216
- {error}
217
- </Text>
218
241
  ) : null}
219
- <Button
220
- type="submit"
221
- size="sm"
222
- disabled={committing || action !== "commit" || changes.length === 0}
223
- >
224
- {label}
225
- </Button>
242
+ {changes.length > 0 ? <div {...stylex.props(popup.separator)} /> : null}
243
+ {changes.length > 0 ? (
244
+ <div {...stylex.props(styles.footer)}>
245
+ <input
246
+ value={message}
247
+ onChange={(event) => setMessage(event.target.value)}
248
+ placeholder={placeholder}
249
+ aria-label="Commit message"
250
+ autoComplete="off"
251
+ spellCheck={false}
252
+ data-no-ring
253
+ data-1p-ignore
254
+ data-lpignore
255
+ disabled={committing || action !== "commit"}
256
+ {...stylex.props(styles.field)}
257
+ />
258
+ {error ? <p {...stylex.props(styles.error, styles.errorInFooter)}>{error}</p> : null}
259
+ {action === "blocked" ? (
260
+ <p {...stylex.props(styles.hint)}>Writers cannot publish</p>
261
+ ) : (
262
+ <button
263
+ type="submit"
264
+ disabled={committing || action !== "commit"}
265
+ {...stylex.props(styles.submit)}
266
+ >
267
+ {committing ? "Committing…" : "Commit & push"}
268
+ </button>
269
+ )}
270
+ </div>
271
+ ) : error ? (
272
+ <p {...stylex.props(styles.error)}>{error}</p>
273
+ ) : null}
226
274
  </form>
227
275
  );
228
276
  }
229
277
 
230
278
  const styles = stylex.create({
231
- panel: {
279
+ form: {
232
280
  display: "flex",
233
281
  flexDirection: "column",
234
- gap: space.lg,
235
- minHeight: "100%",
236
- boxSizing: "border-box",
237
- padding: space.xl,
282
+ paddingTop: space.xs,
238
283
  backgroundColor: colors.bgSurface,
239
284
  color: colors.text,
240
- fontSize: type.sm,
241
285
  },
242
- list: {
286
+ files: {
243
287
  margin: 0,
244
288
  padding: 0,
245
289
  listStyle: "none",
290
+ maxHeight: "160px",
291
+ overflowY: "auto",
292
+ paddingBottom: space.xs,
293
+ },
294
+ file: {
295
+ display: "flex",
296
+ alignItems: "center",
297
+ gap: space.sm,
298
+ minHeight: 30,
299
+ margin: 0,
300
+ marginInline: space.xs,
301
+ paddingInline: space.md,
302
+ paddingBlock: space.sm,
303
+ fontFamily: type.mono,
304
+ fontSize: type.xs,
305
+ color: colors.textMuted,
306
+ },
307
+ fileName: {
308
+ minWidth: 0,
309
+ flex: 1,
310
+ overflow: "hidden",
311
+ textOverflow: "ellipsis",
312
+ whiteSpace: "nowrap",
313
+ },
314
+ footer: {
246
315
  display: "flex",
247
316
  flexDirection: "column",
317
+ alignItems: "stretch",
248
318
  gap: space.md,
319
+ paddingInline: space.lg,
320
+ paddingTop: space.md,
321
+ paddingBottom: space.lg,
322
+ },
323
+ field: {
324
+ width: "100%",
325
+ boxSizing: "border-box",
326
+ minHeight: "28px",
327
+ paddingInline: space.md,
328
+ paddingBlock: space.sm,
329
+ borderWidth: 1,
330
+ borderStyle: "solid",
331
+ borderColor: colors.border,
332
+ borderRadius: radius.md,
333
+ backgroundColor: colors.bgInput,
334
+ color: colors.text,
335
+ fontSize: type.sm,
336
+ outline: "none",
337
+ ":focus": {
338
+ outline: "none",
339
+ borderColor: `color-mix(in srgb, ${colors.textMuted} 45%, ${colors.border})`,
340
+ },
341
+ ":focus-visible": {
342
+ outline: "none",
343
+ },
344
+ ":disabled": {
345
+ opacity: 0.55,
346
+ },
347
+ "::placeholder": {
348
+ color: colors.textMuted,
349
+ },
249
350
  },
250
- row: {
351
+ submit: {
352
+ display: "flex",
353
+ width: "100%",
354
+ alignItems: "center",
355
+ justifyContent: "center",
356
+ height: 28,
357
+ paddingInline: space.xl,
358
+ borderWidth: 1,
359
+ borderStyle: "solid",
360
+ borderColor: colors.accent,
361
+ borderRadius: radius.md,
362
+ backgroundColor: colors.accent,
363
+ color: colors.textInverse,
364
+ fontSize: type.xs,
365
+ fontWeight: 500,
366
+ whiteSpace: "nowrap",
367
+ cursor: "pointer",
368
+ [hover]: {
369
+ ":hover": {
370
+ backgroundColor: colors.accentHover,
371
+ borderColor: colors.accentHover,
372
+ },
373
+ },
374
+ ":disabled": {
375
+ opacity: 0.55,
376
+ cursor: "not-allowed",
377
+ },
378
+ ":active": {
379
+ transform: "scale(0.97)",
380
+ },
381
+ ":focus-visible": {
382
+ outlineWidth: 2,
383
+ outlineStyle: "solid",
384
+ outlineColor: colors.focus,
385
+ outlineOffset: 1,
386
+ },
387
+ "@media (prefers-reduced-motion: reduce)": {
388
+ ":active": {
389
+ transform: "none",
390
+ },
391
+ },
392
+ },
393
+ hint: {
394
+ margin: 0,
395
+ paddingInline: space.lg,
396
+ paddingBottom: space.sm,
397
+ fontSize: type.xs,
398
+ lineHeight: 1.4,
399
+ color: colors.textMuted,
400
+ },
401
+ error: {
402
+ margin: 0,
403
+ paddingInline: space.lg,
404
+ paddingBottom: space.sm,
405
+ fontSize: type.xs,
406
+ color: colors.danger,
407
+ },
408
+ errorInFooter: {
409
+ paddingInline: 0,
410
+ paddingBottom: 0,
411
+ },
412
+ conflictList: {
413
+ maxHeight: "280px",
414
+ },
415
+ conflictRow: {
251
416
  display: "flex",
252
417
  flexDirection: "column",
253
- gap: space.sm,
418
+ gap: space.xs,
419
+ paddingInline: space.md,
420
+ paddingBlock: space.sm,
421
+ fontFamily: type.mono,
422
+ fontSize: type.xs,
254
423
  },
255
- resolve: {
424
+ conflictActions: {
256
425
  display: "flex",
257
- gap: space.sm,
426
+ gap: space.xs,
427
+ },
428
+ conflictBtn: {
429
+ height: 24,
430
+ paddingInline: space.md,
431
+ borderWidth: 1,
432
+ borderStyle: "solid",
433
+ borderColor: colors.border,
434
+ borderRadius: radius.md,
435
+ backgroundColor: colors.bgSurface,
436
+ color: colors.text,
437
+ fontSize: type.xs,
438
+ cursor: "pointer",
439
+ ":disabled": {
440
+ opacity: 0.55,
441
+ cursor: "not-allowed",
442
+ },
443
+ ":focus-visible": {
444
+ outlineWidth: 2,
445
+ outlineStyle: "solid",
446
+ outlineColor: colors.focus,
447
+ outlineOffset: 1,
448
+ },
449
+ },
450
+ conflictBtnDanger: {
451
+ borderColor: colors.danger,
452
+ color: colors.danger,
258
453
  },
259
454
  });
@@ -8,6 +8,7 @@ import {
8
8
  rotatePreviewToken,
9
9
  } from "../api";
10
10
  import { hostEnvSnippet } from "./host-env";
11
+ import { isStudioDev, publicApiUrl } from "../studio-config";
11
12
  import { revalidateWebhookUrl } from "./preview-url";
12
13
  import { Button } from "../ui/Button";
13
14
  import { Field } from "../ui/Field";
@@ -48,6 +49,7 @@ export function SettingsGeneralPage() {
48
49
  previewToken: previewTokenOnce,
49
50
  hostToken: hostTokenOnce,
50
51
  siteUrl: workspace.siteUrls?.[0],
52
+ apiUrl: isStudioDev() ? publicApiUrl() : undefined,
51
53
  });
52
54
 
53
55
  async function onSaveSiteUrl(e: React.FormEvent) {
@@ -7,12 +7,17 @@ export function hostEnvSnippet(input: {
7
7
  previewToken?: string | null;
8
8
  hostToken?: string | null;
9
9
  siteUrl?: string | null;
10
+ /** Local API origin. Passed only while the dashboard is running in dev. */
11
+ apiUrl?: string | null;
10
12
  }): string {
11
13
  const line = (name: string, value: string | null | undefined) =>
12
14
  `${name}=${value?.trim() ?? ""}`;
15
+ const apiUrl = input.apiUrl?.trim();
13
16
  return [
14
17
  `# backmatter.config.ts → workspaceId: "${input.workspaceId}"`,
15
- "# API defaults to https://api.backmatter.app in production",
18
+ apiUrl
19
+ ? line("BACKMATTER_API_URL", apiUrl)
20
+ : "# API defaults to https://api.backmatter.app in production",
16
21
  line("BACKMATTER_WORKSPACE_ID", input.workspaceId),
17
22
  line("BACKMATTER_PREVIEW_TOKEN", input.previewToken),
18
23
  line("BACKMATTER_HOST_TOKEN", input.hostToken),