@backmatter/studio 0.0.6 → 0.0.7

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 (42) hide show
  1. package/dist/chunks/{EmbedPublishPage-DazYJLaD.js → EmbedPublishPage-88VsmOhi.js} +1 -1
  2. package/dist/chunks/{UiKitPage-DyVyBp0I.js → UiKitPage-X4hkFL2d.js} +3 -3
  3. package/dist/chunks/{WorkspaceRoutes-CPvkgvxe.js → WorkspaceRoutes-DXzdWqN3.js} +20287 -18407
  4. package/dist/chunks/{dashboard-6aSS8oyj.js → dashboard-DCHRI0WD.js} +465 -443
  5. package/dist/chunks/{index-DgTNL4t3.js → index-BS0-Bvbk.js} +2 -2
  6. package/dist/chunks/{status-bar-action-RNBujSgk.js → status-bar-action-wj1KZ51m.js} +5512 -5382
  7. package/dist/dashboard.js +2 -2
  8. package/dist/index.js +4 -4
  9. package/dist/site-widget.js +2 -2
  10. package/dist/studio.css +450 -62
  11. package/package.json +3 -2
  12. package/src/App.tsx +2 -0
  13. package/src/WorkspaceRoutes.tsx +2 -0
  14. package/src/api.ts +80 -0
  15. package/src/chat/ChatMessage.tsx +10 -3
  16. package/src/chat/Composer.tsx +18 -18
  17. package/src/chat/chat-helpers.ts +58 -1
  18. package/src/chrome/AppSidebar.tsx +31 -156
  19. package/src/chrome/CommandPalette.tsx +211 -74
  20. package/src/chrome/ContentExplorerSidebar.tsx +1174 -0
  21. package/src/chrome/UserMenu.tsx +18 -3
  22. package/src/chrome/WorkspaceNavContext.tsx +1 -0
  23. package/src/chrome/command-palette-icons.tsx +152 -0
  24. package/src/chrome/content-path-tree.ts +95 -0
  25. package/src/chrome/content-repo-path.ts +52 -0
  26. package/src/chrome/explorer-icons.tsx +89 -0
  27. package/src/chrome/folder-create-context.ts +147 -0
  28. package/src/pages/BlockSettingsDialog.tsx +4 -0
  29. package/src/pages/CliAuthorizePage.tsx +43 -0
  30. package/src/pages/DocEditorPage.tsx +33 -4
  31. package/src/pages/MediaFieldInput.tsx +139 -0
  32. package/src/pages/MediaGalleryPage.tsx +752 -0
  33. package/src/pages/MediaLightbox.tsx +460 -0
  34. package/src/pages/SettingsUsagePage.tsx +19 -0
  35. package/src/pages/TypeDocsPage.tsx +2 -210
  36. package/src/pages/WorkspaceLayout.tsx +6 -1
  37. package/src/pages/media-grid.ts +190 -0
  38. package/src/pages/preview-url.ts +13 -2
  39. package/src/replica/useReplica.ts +9 -0
  40. package/src/ui/ConfirmDialog.tsx +1 -1
  41. package/src/workspace/permissions.ts +9 -0
  42. package/src/workspace-path.ts +13 -0
@@ -14,6 +14,7 @@ import type { WorkspaceOutletContext } from "../workspace/types";
14
14
  import {
15
15
  defaultLocaleOf,
16
16
  isWorkspaceEditorPath,
17
+ isWorkspaceMediaPath,
17
18
  isWorkspaceSettingsPath,
18
19
  isWorkspaceTypeIndexPath,
19
20
  } from "../workspace-path";
@@ -28,6 +29,7 @@ export function WorkspaceLayout() {
28
29
  const isEditor = isWorkspaceEditorPath(location.pathname);
29
30
  const isTypeIndex = isWorkspaceTypeIndexPath(location.pathname);
30
31
  const isSettings = isWorkspaceSettingsPath(location.pathname);
32
+ const isMedia = isWorkspaceMediaPath(location.pathname);
31
33
  const { userId } = useAppChrome();
32
34
  const setNav = useSetWorkspaceNav();
33
35
  const replica = useReplicaState();
@@ -64,6 +66,9 @@ export function WorkspaceLayout() {
64
66
  workspaceName: workspace.name,
65
67
  role: workspace.role,
66
68
  defaultLocale: defaultLocaleOf(workspace.locales),
69
+ locales: workspace.locales?.length
70
+ ? workspace.locales
71
+ : [defaultLocaleOf(workspace.locales)],
67
72
  contentTypes: models.map((m) => m.type),
68
73
  contentModels: models.map((m) => ({
69
74
  type: m.type,
@@ -96,7 +101,7 @@ export function WorkspaceLayout() {
96
101
  }
97
102
 
98
103
  return (
99
- <PageFrame flush={isEditor || isTypeIndex || isSettings}>
104
+ <PageFrame flush={isEditor || isTypeIndex || isSettings || isMedia}>
100
105
  <Outlet context={outletContext} />
101
106
  </PageFrame>
102
107
  );
@@ -0,0 +1,190 @@
1
+ // packages/studio/src/pages/media-grid.ts
2
+ import { isMediaContentType, isMediaId, MAX_MEDIA_BYTES } from "@backmatter/core";
3
+
4
+ export type CatalogMedia = {
5
+ id: string;
6
+ filename: string;
7
+ url: string;
8
+ thumbUrl: string;
9
+ width: number | null;
10
+ height: number | null;
11
+ contentType?: string;
12
+ byteSize?: number;
13
+ createdAt?: string;
14
+ variants?: Record<string, string>;
15
+ };
16
+
17
+ const LIGHTBOX_PARAM = "image";
18
+
19
+ /** The media id in `?image=`, or null when the query is absent or not a catalog id. */
20
+ export function lightboxImageId(params: URLSearchParams): string | null {
21
+ const value = params.get(LIGHTBOX_PARAM);
22
+ return value && isMediaId(value) ? value : null;
23
+ }
24
+
25
+ /** A copy of the query with the lightbox id set or removed. Other params stay. */
26
+ export function searchWithLightboxImage(params: URLSearchParams, id: string | null): URLSearchParams {
27
+ const next = new URLSearchParams(params);
28
+ if (id && isMediaId(id)) next.set(LIGHTBOX_PARAM, id);
29
+ else next.delete(LIGHTBOX_PARAM);
30
+ return next;
31
+ }
32
+
33
+ /** The id that stays open after `removedId` leaves the list. The next image takes its place; the previous one does at the end. */
34
+ export function mediaIdAfterRemoval(ids: readonly string[], removedId: string): string | null {
35
+ const index = ids.indexOf(removedId);
36
+ if (index < 0) return null;
37
+ const rest = ids.filter((id) => id !== removedId);
38
+ return rest[index] ?? rest[index - 1] ?? null;
39
+ }
40
+
41
+ export type PendingUpload = {
42
+ key: string;
43
+ filename: string;
44
+ previewUrl: string;
45
+ status: "uploading" | "error" | "done";
46
+ progress: number;
47
+ error?: string;
48
+ media?: CatalogMedia;
49
+ };
50
+
51
+ export type MediaGridState = {
52
+ pending: PendingUpload[];
53
+ loaded: CatalogMedia[];
54
+ total: number;
55
+ nextCursor: string | null;
56
+ };
57
+
58
+ export function emptyMediaGrid(): MediaGridState {
59
+ return { pending: [], loaded: [], total: 0, nextCursor: null };
60
+ }
61
+
62
+ export function beginUploads(
63
+ state: MediaGridState,
64
+ items: Array<{ key: string; filename: string; previewUrl: string }>,
65
+ ): MediaGridState {
66
+ return {
67
+ ...state,
68
+ pending: [
69
+ ...items.map((item) => ({ ...item, status: "uploading" as const, progress: 0 })),
70
+ ...state.pending,
71
+ ],
72
+ };
73
+ }
74
+
75
+ export function setUploadProgress(state: MediaGridState, key: string, progress: number): MediaGridState {
76
+ const fraction = Math.min(1, Math.max(0, progress));
77
+ return {
78
+ ...state,
79
+ pending: state.pending.map((item) =>
80
+ item.key === key && item.status === "uploading" ? { ...item, progress: fraction } : item,
81
+ ),
82
+ };
83
+ }
84
+
85
+ /** The finished image replaces its upload cell. Later uploads do not move. */
86
+ export function finishUpload(
87
+ state: MediaGridState,
88
+ key: string,
89
+ media: CatalogMedia,
90
+ ): MediaGridState {
91
+ const slot = state.pending.find((item) => item.key === key);
92
+ if (!slot || slot.media) return state;
93
+ const alreadyListed = state.loaded.some((item) => item.id === media.id);
94
+ return {
95
+ ...state,
96
+ pending: state.pending.map((item) =>
97
+ item.key === key ? { ...item, status: "done", progress: 1, error: undefined, media } : item,
98
+ ),
99
+ loaded: state.loaded.filter((item) => item.id !== media.id),
100
+ total: alreadyListed ? state.total : state.total + 1,
101
+ };
102
+ }
103
+
104
+ export function failUpload(state: MediaGridState, key: string, error: string): MediaGridState {
105
+ return {
106
+ ...state,
107
+ pending: state.pending.map((item) =>
108
+ item.key === key ? { ...item, status: "error", error } : item,
109
+ ),
110
+ };
111
+ }
112
+
113
+ export function receivePage(
114
+ state: MediaGridState,
115
+ page: { total: number; media: CatalogMedia[]; nextCursor: string | null },
116
+ mode: "replace" | "append",
117
+ ): MediaGridState {
118
+ const held = new Set(state.pending.flatMap((item) => (item.media ? [item.media.id] : [])));
119
+ const incoming = page.media.filter(
120
+ (item) => !held.has(item.id) && !state.loaded.some((have) => have.id === item.id),
121
+ );
122
+ const loaded = mode === "replace" ? page.media.filter((item) => !held.has(item.id)) : [...state.loaded, ...incoming];
123
+ return {
124
+ ...state,
125
+ loaded,
126
+ total: page.total,
127
+ nextCursor: page.nextCursor,
128
+ };
129
+ }
130
+
131
+ /** Drop a hidden row from the grid. A just-finished upload leaves its cell too. */
132
+ export function removeMedia(state: MediaGridState, id: string): MediaGridState {
133
+ const pending = state.pending.filter((item) => item.media?.id !== id);
134
+ const removedHeld = pending.length !== state.pending.length;
135
+ const removedLoaded = state.loaded.some((item) => item.id === id);
136
+ if (!removedHeld && !removedLoaded) return state;
137
+ return {
138
+ ...state,
139
+ pending,
140
+ loaded: state.loaded.filter((item) => item.id !== id),
141
+ total: Math.max(0, state.total - 1),
142
+ };
143
+ }
144
+
145
+ /** Cells the grid paints: in-flight uploads, then known rows, then empty slots for the rest. */
146
+ export function gridLength(state: MediaGridState): number {
147
+ const held = state.pending.filter((item) => item.media).length;
148
+ const catalog = Math.max(state.total - held, state.loaded.length);
149
+ return state.pending.length + catalog;
150
+ }
151
+
152
+ export type GridArrow = "left" | "right" | "up" | "down";
153
+
154
+ /**
155
+ * The catalog image an arrow lands on. Left and right step to the next image,
156
+ * continuing onto the next row. Up and down stay in the same column. Uploads
157
+ * occupy cells in front, so the first image is not always column zero. A move
158
+ * onto an upload, an empty slot, or past the edge stays put. No selection yet
159
+ * starts at the first image.
160
+ */
161
+ export function nextMediaIndex(input: {
162
+ index: number | null;
163
+ arrow: GridArrow;
164
+ columns: number;
165
+ pendingCount: number;
166
+ mediaCount: number;
167
+ places?: readonly number[];
168
+ }): number | null {
169
+ const places =
170
+ input.places ??
171
+ Array.from({ length: input.mediaCount }, (_, offset) => input.pendingCount + offset);
172
+ const { index, arrow, columns } = input;
173
+ if (places.length < 1 || columns < 1) return null;
174
+ if (index === null || index < 0 || index >= places.length) return 0;
175
+ if (arrow === "left") return Math.max(0, index - 1);
176
+ if (arrow === "right") return Math.min(places.length - 1, index + 1);
177
+
178
+ const cell = places[index]!;
179
+ const row = Math.floor(cell / columns) + (arrow === "down" ? 1 : -1);
180
+ const column = cell % columns;
181
+ if (row < 0) return index;
182
+ const next = places.indexOf(row * columns + column);
183
+ return next < 0 ? index : next;
184
+ }
185
+
186
+ export function rejectionReason(file: { type: string; size: number; name: string }): string | null {
187
+ if (file.size > MAX_MEDIA_BYTES) return `${file.name} is larger than 20 MB`;
188
+ if (file.type && !isMediaContentType(file.type)) return `${file.name} is not a supported image`;
189
+ return null;
190
+ }
@@ -1,5 +1,12 @@
1
1
  // packages/studio/src/pages/preview-url.ts
2
- import { routeFor, revalidateUrl, type RouteModel } from "@backmatter/core";
2
+ import {
3
+ encodeDocLocale,
4
+ PREVIEW_LOCALE_QUERY,
5
+ revalidateUrl,
6
+ routeFor,
7
+ routeTemplate,
8
+ type RouteModel,
9
+ } from "@backmatter/core";
3
10
 
4
11
  export function modelForType(models: readonly RouteModel[], type: string): RouteModel {
5
12
  return models.find((m) => m.type === type) ?? { type };
@@ -18,7 +25,11 @@ export function editorPreviewPath(
18
25
  tree?: string | null,
19
26
  ): string | null {
20
27
  if (!branch?.trim() || !tree?.trim()) return null;
21
- return publicDocPath(model, locale, slug);
28
+ const path = publicDocPath(model, locale, slug);
29
+ if (!path || routeTemplate(model).includes("{locale}")) return path || null;
30
+ const params = new URLSearchParams();
31
+ params.set(PREVIEW_LOCALE_QUERY, encodeDocLocale(locale));
32
+ return `${path}?${params.toString()}`;
22
33
  }
23
34
 
24
35
  /**
@@ -11,3 +11,12 @@ export function useReplicaState(): ReplicaStateView {
11
11
  );
12
12
  return replicaStore.getState();
13
13
  }
14
+
15
+ /** Bumps when replica docs/git/checkout change — use in useMemo deps with `useReplicaState()`. */
16
+ export function useReplicaRevision(): number {
17
+ return useSyncExternalStore(
18
+ replicaStore.subscribe,
19
+ replicaStore.getRevision,
20
+ replicaStore.getRevision,
21
+ );
22
+ }
@@ -80,7 +80,7 @@ const styles = stylex.create({
80
80
  backdrop: {
81
81
  position: "fixed",
82
82
  inset: 0,
83
- zIndex: z.overlay,
83
+ zIndex: z.palette,
84
84
  backgroundColor: colors.overlay,
85
85
  transitionProperty: "opacity",
86
86
  transitionDuration: "120ms",
@@ -15,3 +15,12 @@ export function canDevelop(role: string): boolean {
15
15
  export function canPublish(role: string): boolean {
16
16
  return role === "admin" || role === "developer" || role === "editor";
17
17
  }
18
+
19
+ export function canWriteContent(role: string): boolean {
20
+ return (
21
+ role === "admin" ||
22
+ role === "developer" ||
23
+ role === "editor" ||
24
+ role === "writer"
25
+ );
26
+ }
@@ -21,6 +21,10 @@ export function contentDocPath(
21
21
  return path;
22
22
  }
23
23
 
24
+ export function mediaPath(slug: string) {
25
+ return `/${slug}/media`;
26
+ }
27
+
24
28
  export function settingsPath(slug: string, page: string) {
25
29
  return `/${slug}/settings/${page}`;
26
30
  }
@@ -83,6 +87,15 @@ export function isWorkspaceTypeIndexPath(pathname: string) {
83
87
  return /\/content\/[^/]+\/?$/.test(pathname) && !isWorkspaceEditorPath(pathname);
84
88
  }
85
89
 
90
+ export function isWorkspaceMediaPath(pathname: string) {
91
+ return /\/media\/?$/.test(pathname);
92
+ }
93
+
94
+ /** The content file tree belongs on content routes only. */
95
+ export function isWorkspaceContentPath(pathname: string) {
96
+ return /\/content(?:\/|$)/.test(pathname);
97
+ }
98
+
86
99
  export function isWorkspaceSettingsPath(pathname: string) {
87
100
  return /\/settings(?:\/|$)/.test(pathname) || /\/github\/?$/.test(pathname);
88
101
  }