@backmatter/studio 0.0.7 → 0.0.8

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.
@@ -9,16 +9,15 @@ import {
9
9
  unbindGithub,
10
10
  } from "../api";
11
11
  import { Button, ButtonLink } from "../ui/Button";
12
- import { Field } from "../ui/Field";
13
12
  import { Input } from "../ui/Input";
14
13
  import { ConfirmDialog } from "../ui/ConfirmDialog";
15
- import { PageHeader, Panel, Stack } from "../ui/Page";
14
+ import { PageHeader } from "../ui/Page";
16
15
  import { Text } from "../ui/Text";
17
16
  import * as stylex from "@stylexjs/stylex";
18
17
  import { colors, space, type } from "../styles/tokens.stylex";
19
18
  import type { WorkspaceOutletContext } from "../workspace/types";
20
19
  import { githubConnectPath } from "../workspace-path";
21
- import { SelectRepositoriesLink } from "./SelectRepositoriesLink";
20
+ import { GithubRepositoryAccess } from "./SelectRepositoriesLink";
22
21
 
23
22
  export function SettingsGithubPage() {
24
23
  const { workspace, reload } = useOutletContext<WorkspaceOutletContext>();
@@ -34,6 +33,7 @@ export function SettingsGithubPage() {
34
33
  const [directory, setDirectory] = useState(workspace.githubDirectory ?? "");
35
34
  const [directoryError, setDirectoryError] = useState<string | null>(null);
36
35
  const [savingDirectory, setSavingDirectory] = useState(false);
36
+ const directoryDirty = directory !== (workspace.githubDirectory ?? "");
37
37
 
38
38
  useEffect(() => {
39
39
  setDirectory(workspace.githubDirectory ?? "");
@@ -119,121 +119,36 @@ export function SettingsGithubPage() {
119
119
  <Text tone="danger">{error}</Text>
120
120
  </div>
121
121
  ) : null}
122
- <Panel>
123
- {connected ? (
124
- <Stack>
125
- <dl {...stylex.props(styles.dl)}>
126
- <div>
127
- <dt {...stylex.props(styles.dt)}>Repository</dt>
128
- <dd {...stylex.props(styles.dd)}>{workspace.githubRepoFullName}</dd>
129
- </div>
130
- <div>
131
- <dt {...stylex.props(styles.dt)}>Branch</dt>
132
- <dd {...stylex.props(styles.dd)}>
133
- {workspace.githubDefaultBranch}
134
- </dd>
135
- </div>
136
- {!canEdit ? (
137
- <div>
138
- <dt {...stylex.props(styles.dt)}>Directory</dt>
139
- <dd {...stylex.props(styles.dd)}>
140
- {workspace.githubDirectory ?? "Repository root"}
141
- </dd>
142
- </div>
143
- ) : null}
144
- </dl>
145
- {canEdit ? (
146
- <form onSubmit={(e) => void onSaveDirectory(e)}>
147
- <Stack>
148
- <Field
149
- label="Directory"
150
- hint="Folder inside the repository that contains this site. Leave empty for the repository root."
151
- >
152
- <Input
153
- value={directory}
154
- onChange={(e) => setDirectory(e.target.value)}
155
- />
156
- </Field>
157
- {directoryError ? (
158
- <Text tone="danger">{directoryError}</Text>
159
- ) : null}
160
- <div>
161
- <Button type="submit" disabled={savingDirectory}>
162
- {savingDirectory ? "Saving…" : "Save"}
163
- </Button>
164
- </div>
165
- </Stack>
166
- </form>
167
- ) : null}
168
- {canEdit ? (
169
- <div {...stylex.props(styles.actions)}>
170
- <ButtonLink
171
- variant="secondary"
172
- to={githubConnectPath(workspace.slug)}
173
- >
174
- Change repository
175
- </ButtonLink>
176
- <SelectRepositoriesLink workspaceId={workspace.id} />
177
- <Button
178
- variant="danger"
179
- disabled={disconnecting}
180
- onClick={() => setConfirmDisconnect(true)}
181
- >
182
- Disconnect
183
- </Button>
184
- </div>
185
- ) : null}
186
- </Stack>
187
- ) : (
188
- <Stack>
189
- <Text tone="muted" size="sm">
190
- {workspace.githubInstallationId
191
- ? "The list only includes repositories this GitHub App can access. Select repositories on GitHub to grant more. Saving there does not return to Backmatter."
192
- : "Connect finds the backmatter CMS installation on your GitHub account and opens the repository list here. Saving on GitHub does not return to Backmatter."}
193
- </Text>
194
- {canEdit ? (
195
- <div {...stylex.props(styles.actions)}>
196
- {workspace.githubInstallationId ? (
197
- <ButtonLink to={githubConnectPath(workspace.slug)}>
198
- Choose repository
199
- </ButtonLink>
200
- ) : choices ? (
201
- choices.map((choice) => (
202
- <Button
203
- key={choice.id}
204
- disabled={connecting}
205
- onClick={() => void onConnect(choice.id)}
206
- >
207
- {choice.account ?? choice.id}
208
- </Button>
209
- ))
210
- ) : (
211
- <Button disabled={connecting} onClick={() => void onConnect()}>
212
- {connecting ? "Connecting…" : "Connect GitHub"}
213
- </Button>
214
- )}
215
- {workspace.githubInstallationId ? (
216
- <SelectRepositoriesLink workspaceId={workspace.id} />
217
- ) : null}
218
- {installUrl ? (
219
- <ButtonLink href={installUrl} newTab={false} variant="secondary">
220
- Install on GitHub
221
- </ButtonLink>
222
- ) : null}
223
- {workspace.githubInstallationId ? (
224
- <Button
225
- variant="danger"
226
- disabled={disconnecting}
227
- onClick={() => setConfirmDisconnect(true)}
228
- >
229
- Disconnect
230
- </Button>
231
- ) : null}
232
- </div>
233
- ) : null}
234
- </Stack>
235
- )}
236
- </Panel>
122
+ {connected ? (
123
+ <ConnectedGithub
124
+ repo={workspace.githubRepoFullName ?? ""}
125
+ branch={workspace.githubDefaultBranch ?? ""}
126
+ directory={directory}
127
+ directoryDirty={directoryDirty}
128
+ directoryError={directoryError}
129
+ savingDirectory={savingDirectory}
130
+ canEdit={canEdit}
131
+ changeTo={githubConnectPath(workspace.slug)}
132
+ workspaceId={workspace.id}
133
+ disconnecting={disconnecting}
134
+ onDirectoryChange={setDirectory}
135
+ onSaveDirectory={(e) => void onSaveDirectory(e)}
136
+ onDisconnect={() => setConfirmDisconnect(true)}
137
+ />
138
+ ) : (
139
+ <DisconnectedGithub
140
+ canEdit={canEdit}
141
+ connecting={connecting}
142
+ choices={choices}
143
+ installUrl={installUrl}
144
+ hasInstallation={Boolean(workspace.githubInstallationId)}
145
+ chooseTo={githubConnectPath(workspace.slug)}
146
+ workspaceId={workspace.id}
147
+ disconnecting={disconnecting}
148
+ onConnect={(id) => void onConnect(id)}
149
+ onDisconnect={() => setConfirmDisconnect(true)}
150
+ />
151
+ )}
237
152
  <ConfirmDialog
238
153
  open={confirmDisconnect}
239
154
  onOpenChange={(open) => {
@@ -254,28 +169,402 @@ export function SettingsGithubPage() {
254
169
  );
255
170
  }
256
171
 
172
+ function ConnectedGithub({
173
+ repo,
174
+ branch,
175
+ directory,
176
+ directoryDirty,
177
+ directoryError,
178
+ savingDirectory,
179
+ canEdit,
180
+ changeTo,
181
+ workspaceId,
182
+ disconnecting,
183
+ onDirectoryChange,
184
+ onSaveDirectory,
185
+ onDisconnect,
186
+ }: {
187
+ repo: string;
188
+ branch: string;
189
+ directory: string;
190
+ directoryDirty: boolean;
191
+ directoryError: string | null;
192
+ savingDirectory: boolean;
193
+ canEdit: boolean;
194
+ changeTo: string;
195
+ workspaceId: string;
196
+ disconnecting: boolean;
197
+ onDirectoryChange: (value: string) => void;
198
+ onSaveDirectory: (e: React.FormEvent) => void;
199
+ onDisconnect: () => void;
200
+ }) {
201
+ return (
202
+ <div {...stylex.props(styles.groups)}>
203
+ <SettingsGroup title="Connection" titleId="github-connection">
204
+ <SettingsRow
205
+ label="Repository"
206
+ control={
207
+ <>
208
+ <span {...stylex.props(styles.value)}>{repo}</span>
209
+ {canEdit ? (
210
+ <ButtonLink variant="secondary" to={changeTo}>
211
+ Change
212
+ </ButtonLink>
213
+ ) : null}
214
+ </>
215
+ }
216
+ />
217
+ <SettingsRow
218
+ label="Branch"
219
+ control={<span {...stylex.props(styles.value)}>{branch}</span>}
220
+ />
221
+ <SettingsRow
222
+ label="Directory"
223
+ htmlFor={canEdit ? "github-directory" : undefined}
224
+ description={
225
+ <>
226
+ Folder inside the repository. Empty means the repository root.
227
+ {directoryError ? <Text tone="danger">{directoryError}</Text> : null}
228
+ </>
229
+ }
230
+ control={
231
+ canEdit ? (
232
+ <form onSubmit={onSaveDirectory} {...stylex.props(styles.directoryForm)}>
233
+ <div {...stylex.props(styles.directoryField)}>
234
+ <Input
235
+ id="github-directory"
236
+ value={directory}
237
+ spellCheck={false}
238
+ autoCapitalize="off"
239
+ autoCorrect="off"
240
+ aria-invalid={directoryError ? true : undefined}
241
+ onChange={(e) => onDirectoryChange(e.target.value)}
242
+ />
243
+ </div>
244
+ {directoryDirty ? (
245
+ <Button type="submit" disabled={savingDirectory}>
246
+ {savingDirectory ? "Saving…" : "Save"}
247
+ </Button>
248
+ ) : null}
249
+ </form>
250
+ ) : (
251
+ <span {...stylex.props(styles.value)}>
252
+ {directory || "Repository root"}
253
+ </span>
254
+ )
255
+ }
256
+ />
257
+ </SettingsGroup>
258
+ {canEdit ? (
259
+ <SettingsGroup title="Access" titleId="github-access">
260
+ <SettingsRow
261
+ label="Repository access"
262
+ description="Repositories the app can see on GitHub. Saving there does not return here."
263
+ control={
264
+ <GithubRepositoryAccess workspaceId={workspaceId}>
265
+ {(url) => (
266
+ <ButtonLink href={url} variant="secondary">
267
+ Manage on GitHub
268
+ </ButtonLink>
269
+ )}
270
+ </GithubRepositoryAccess>
271
+ }
272
+ />
273
+ </SettingsGroup>
274
+ ) : null}
275
+ {canEdit ? (
276
+ <SettingsGroup separated>
277
+ <SettingsRow
278
+ label="Disconnect"
279
+ description="Stops commits and imports. Content already here stays."
280
+ control={
281
+ <Button variant="danger" disabled={disconnecting} onClick={onDisconnect}>
282
+ Disconnect
283
+ </Button>
284
+ }
285
+ />
286
+ </SettingsGroup>
287
+ ) : null}
288
+ </div>
289
+ );
290
+ }
291
+
292
+ function DisconnectedGithub({
293
+ canEdit,
294
+ connecting,
295
+ choices,
296
+ installUrl,
297
+ hasInstallation,
298
+ chooseTo,
299
+ workspaceId,
300
+ disconnecting,
301
+ onConnect,
302
+ onDisconnect,
303
+ }: {
304
+ canEdit: boolean;
305
+ connecting: boolean;
306
+ choices: GithubInstallationChoice[] | null;
307
+ installUrl: string | null;
308
+ hasInstallation: boolean;
309
+ chooseTo: string;
310
+ workspaceId: string;
311
+ disconnecting: boolean;
312
+ onConnect: (installationId?: string) => void;
313
+ onDisconnect: () => void;
314
+ }) {
315
+ if (choices) {
316
+ return (
317
+ <SettingsGroup
318
+ title="GitHub accounts"
319
+ titleId="github-accounts"
320
+ description="Connect finds the backmatter CMS installation on your GitHub account and opens the repository list here. Saving on GitHub does not return to Backmatter."
321
+ >
322
+ {choices.map((choice) => (
323
+ <SettingsRow
324
+ key={choice.id}
325
+ label={choice.account ?? choice.id}
326
+ control={
327
+ canEdit ? (
328
+ <Button
329
+ variant="secondary"
330
+ disabled={connecting}
331
+ onClick={() => onConnect(choice.id)}
332
+ >
333
+ Connect
334
+ </Button>
335
+ ) : null
336
+ }
337
+ />
338
+ ))}
339
+ </SettingsGroup>
340
+ );
341
+ }
342
+
343
+ if (hasInstallation) {
344
+ return (
345
+ <div {...stylex.props(styles.groups)}>
346
+ <SettingsGroup title="Connection" titleId="github-connection">
347
+ <SettingsRow
348
+ label="Repository"
349
+ description="The list only includes repositories this GitHub App can access."
350
+ control={
351
+ canEdit ? (
352
+ <ButtonLink to={chooseTo}>Choose repository</ButtonLink>
353
+ ) : null
354
+ }
355
+ />
356
+ </SettingsGroup>
357
+ {canEdit ? (
358
+ <SettingsGroup title="Access" titleId="github-access">
359
+ <SettingsRow
360
+ label="Repository access"
361
+ description="Grant more repositories on GitHub. Saving there does not return here."
362
+ control={
363
+ <GithubRepositoryAccess workspaceId={workspaceId}>
364
+ {(url) => (
365
+ <ButtonLink href={url} variant="secondary">
366
+ Manage on GitHub
367
+ </ButtonLink>
368
+ )}
369
+ </GithubRepositoryAccess>
370
+ }
371
+ />
372
+ </SettingsGroup>
373
+ ) : null}
374
+ {canEdit ? (
375
+ <SettingsGroup separated>
376
+ <SettingsRow
377
+ label="Disconnect"
378
+ description="Removes the GitHub App from this workspace. You can connect again later."
379
+ control={
380
+ <Button
381
+ variant="danger"
382
+ disabled={disconnecting}
383
+ onClick={onDisconnect}
384
+ >
385
+ Disconnect
386
+ </Button>
387
+ }
388
+ />
389
+ </SettingsGroup>
390
+ ) : null}
391
+ </div>
392
+ );
393
+ }
394
+
395
+ return (
396
+ <SettingsGroup>
397
+ <SettingsRow
398
+ label="Not connected"
399
+ description="Connect finds the backmatter CMS installation on your GitHub account and opens the repository list here. Saving on GitHub does not return to Backmatter."
400
+ control={
401
+ canEdit ? (
402
+ installUrl ? (
403
+ <ButtonLink href={installUrl} newTab={false}>
404
+ Install on GitHub
405
+ </ButtonLink>
406
+ ) : (
407
+ <Button disabled={connecting} onClick={() => onConnect()}>
408
+ {connecting ? "Connecting…" : "Connect GitHub"}
409
+ </Button>
410
+ )
411
+ ) : null
412
+ }
413
+ />
414
+ </SettingsGroup>
415
+ );
416
+ }
417
+
418
+ function SettingsGroup({
419
+ title,
420
+ titleId,
421
+ description,
422
+ separated,
423
+ children,
424
+ }: {
425
+ title?: string;
426
+ titleId?: string;
427
+ description?: string;
428
+ separated?: boolean;
429
+ children: React.ReactNode;
430
+ }) {
431
+ return (
432
+ <section
433
+ aria-labelledby={title ? titleId : undefined}
434
+ {...stylex.props(styles.group, separated && styles.groupSeparated)}
435
+ >
436
+ {title && titleId ? (
437
+ <h2 id={titleId} {...stylex.props(styles.groupTitle)}>
438
+ {title}
439
+ </h2>
440
+ ) : null}
441
+ {description ? (
442
+ <p {...stylex.props(styles.groupDescription)}>{description}</p>
443
+ ) : null}
444
+ <div {...stylex.props(styles.rows)}>{children}</div>
445
+ </section>
446
+ );
447
+ }
448
+
449
+ function SettingsRow({
450
+ label,
451
+ description,
452
+ htmlFor,
453
+ control,
454
+ }: {
455
+ label: string;
456
+ description?: React.ReactNode;
457
+ htmlFor?: string;
458
+ control?: React.ReactNode;
459
+ }) {
460
+ return (
461
+ <div {...stylex.props(styles.row)}>
462
+ <div {...stylex.props(styles.rowText)}>
463
+ {htmlFor ? (
464
+ <label htmlFor={htmlFor} {...stylex.props(styles.rowLabel)}>
465
+ {label}
466
+ </label>
467
+ ) : (
468
+ <div {...stylex.props(styles.rowLabel)}>{label}</div>
469
+ )}
470
+ {description ? (
471
+ <div {...stylex.props(styles.rowDescription)}>{description}</div>
472
+ ) : null}
473
+ </div>
474
+ {control ? <div {...stylex.props(styles.rowControl)}>{control}</div> : null}
475
+ </div>
476
+ );
477
+ }
478
+
257
479
  const styles = stylex.create({
258
- dl: {
480
+ groups: {
481
+ display: "flex",
482
+ flexDirection: "column",
483
+ gap: space.xxl,
484
+ },
485
+ group: {
486
+ display: "flex",
487
+ flexDirection: "column",
488
+ gap: space.xs,
489
+ },
490
+ groupSeparated: {
491
+ paddingTop: space.lg,
492
+ borderTopWidth: 1,
493
+ borderTopStyle: "solid",
494
+ borderTopColor: colors.border,
495
+ },
496
+ groupTitle: {
259
497
  margin: 0,
498
+ fontSize: type.sm,
499
+ fontWeight: 500,
500
+ color: colors.textMuted,
501
+ },
502
+ groupDescription: {
503
+ margin: 0,
504
+ color: colors.textMuted,
505
+ fontSize: type.sm,
506
+ lineHeight: 1.45,
507
+ },
508
+ rows: {
260
509
  display: "flex",
261
510
  flexDirection: "column",
511
+ },
512
+ row: {
513
+ display: "flex",
514
+ alignItems: "center",
515
+ justifyContent: "space-between",
516
+ flexWrap: "wrap",
262
517
  gap: space.lg,
518
+ minHeight: "44px",
519
+ paddingBlock: space.lg,
520
+ borderBottomWidth: 1,
521
+ borderBottomStyle: "solid",
522
+ borderBottomColor: colors.border,
523
+ ":last-child": {
524
+ borderBottomWidth: 0,
525
+ },
526
+ },
527
+ rowText: {
528
+ flex: "1 1 16rem",
529
+ minWidth: 0,
530
+ display: "flex",
531
+ flexDirection: "column",
532
+ gap: space.xs,
533
+ },
534
+ rowLabel: {
263
535
  fontSize: type.md,
536
+ fontWeight: 500,
537
+ color: colors.text,
264
538
  },
265
- dt: {
539
+ rowDescription: {
540
+ display: "flex",
541
+ flexDirection: "column",
542
+ gap: space.xs,
266
543
  color: colors.textMuted,
267
544
  fontSize: type.sm,
545
+ lineHeight: 1.45,
268
546
  },
269
- dd: {
270
- margin: 0,
547
+ rowControl: {
548
+ display: "flex",
549
+ alignItems: "center",
550
+ justifyContent: "flex-end",
551
+ flexShrink: 0,
552
+ gap: space.sm,
553
+ marginLeft: "auto",
554
+ },
555
+ value: {
556
+ fontSize: type.md,
271
557
  fontWeight: 500,
558
+ color: colors.text,
272
559
  },
273
- actions: {
560
+ directoryForm: {
274
561
  display: "flex",
275
- flexWrap: "wrap",
276
562
  alignItems: "center",
277
563
  gap: space.sm,
278
564
  },
565
+ directoryField: {
566
+ width: "240px",
567
+ },
279
568
  error: {
280
569
  marginBottom: space.lg,
281
570
  },
@@ -142,7 +142,7 @@ export function removeMedia(state: MediaGridState, id: string): MediaGridState {
142
142
  };
143
143
  }
144
144
 
145
- /** Cells the grid paints: in-flight uploads, then known rows, then empty slots for the rest. */
145
+ /** Uploads plus catalog rows, including rows that have not been fetched yet. */
146
146
  export function gridLength(state: MediaGridState): number {
147
147
  const held = state.pending.filter((item) => item.media).length;
148
148
  const catalog = Math.max(state.total - held, state.loaded.length);
@@ -33,7 +33,7 @@ export function githubConnectPath(slug: string) {
33
33
  return `/${slug}/github`;
34
34
  }
35
35
 
36
- const APP_ROOTS = new Set(["workspaces", "account", "invite", "__ui"]);
36
+ const APP_ROOTS = new Set(["workspaces", "account", "invite", "cli", "__ui"]);
37
37
 
38
38
  export function cmsWorkspaceSlug(pathname: string): string | null {
39
39
  if (pathname === "/") return null;
@@ -96,6 +96,19 @@ export function isWorkspaceContentPath(pathname: string) {
96
96
  return /\/content(?:\/|$)/.test(pathname);
97
97
  }
98
98
 
99
+ /**
100
+ * Where the content rail icon goes.
101
+ * Already on the file tree: stay put (null).
102
+ * On a document: the type's file tree.
103
+ * Anywhere else (settings, media, connect): the workspace home, which opens the docs.
104
+ */
105
+ export function contentRailHref(pathname: string, slug: string): string | null {
106
+ if (!isWorkspaceContentPath(pathname)) return workspaceHomePath(slug);
107
+ const doc = pathname.match(/\/content\/([^/]+)\/([^/?]+)/);
108
+ if (!doc) return null;
109
+ return contentTypePath(slug, decodeURIComponent(doc[1]!));
110
+ }
111
+
99
112
  export function isWorkspaceSettingsPath(pathname: string) {
100
113
  return /\/settings(?:\/|$)/.test(pathname) || /\/github\/?$/.test(pathname);
101
114
  }