@backmatter/studio 0.0.5 → 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 (48) hide show
  1. package/dist/chunks/{EmbedPublishPage-hx1xoAhI.js → EmbedPublishPage-88VsmOhi.js} +13 -13
  2. package/dist/chunks/{UiKitPage-BOunq6mr.js → UiKitPage-X4hkFL2d.js} +9 -9
  3. package/dist/chunks/{WorkspaceRoutes-CTFTWu3L.js → WorkspaceRoutes-DXzdWqN3.js} +20334 -18542
  4. package/dist/chunks/{dashboard-upmiawx5.js → dashboard-DCHRI0WD.js} +422 -400
  5. package/dist/chunks/{index-CBvevwfT.js → index-BS0-Bvbk.js} +2 -2
  6. package/dist/chunks/{status-bar-action-DiXhfcj3.js → status-bar-action-wj1KZ51m.js} +6925 -6567
  7. package/dist/dashboard.js +2 -2
  8. package/dist/index.js +18 -18
  9. package/dist/site-widget.js +788 -234
  10. package/dist/studio.css +454 -66
  11. package/package.json +3 -2
  12. package/src/App.tsx +2 -0
  13. package/src/WorkspaceRoutes.tsx +4 -0
  14. package/src/api.ts +104 -0
  15. package/src/chat/ChatMessage.tsx +10 -3
  16. package/src/chat/ChatPanel.tsx +1 -1
  17. package/src/chat/Composer.tsx +18 -18
  18. package/src/chat/chat-helpers.ts +58 -1
  19. package/src/chrome/AppSidebar.tsx +31 -156
  20. package/src/chrome/CommandPalette.tsx +211 -74
  21. package/src/chrome/ContentExplorerSidebar.tsx +1174 -0
  22. package/src/chrome/UserMenu.tsx +18 -3
  23. package/src/chrome/WorkspaceNavContext.tsx +1 -0
  24. package/src/chrome/command-palette-icons.tsx +152 -0
  25. package/src/chrome/content-path-tree.ts +95 -0
  26. package/src/chrome/content-repo-path.ts +52 -0
  27. package/src/chrome/explorer-icons.tsx +89 -0
  28. package/src/chrome/folder-create-context.ts +147 -0
  29. package/src/chrome/icons.tsx +13 -0
  30. package/src/pages/BlockSettingsDialog.tsx +4 -0
  31. package/src/pages/CliAuthorizePage.tsx +43 -0
  32. package/src/pages/DocEditorPage.tsx +33 -4
  33. package/src/pages/MediaFieldInput.tsx +139 -0
  34. package/src/pages/MediaGalleryPage.tsx +752 -0
  35. package/src/pages/MediaLightbox.tsx +460 -0
  36. package/src/pages/SettingsLayout.tsx +7 -1
  37. package/src/pages/SettingsUsagePage.tsx +187 -0
  38. package/src/pages/TypeDocsPage.tsx +2 -210
  39. package/src/pages/WorkspaceLayout.tsx +6 -1
  40. package/src/pages/media-grid.ts +190 -0
  41. package/src/pages/preview-url.ts +13 -2
  42. package/src/preview-frame.tsx +78 -0
  43. package/src/replica/useReplica.ts +9 -0
  44. package/src/site-bar.tsx +769 -0
  45. package/src/site-widget.tsx +33 -162
  46. package/src/ui/ConfirmDialog.tsx +1 -1
  47. package/src/workspace/permissions.ts +9 -0
  48. package/src/workspace-path.ts +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backmatter/studio",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "The Backmatter editing surface: document editor, AI chat, publish. Mounted on your site or hosted on the dashboard.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -52,8 +52,9 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@ai-sdk/react": "^4.0.100",
55
- "@backmatter/core": "^0.0.21",
55
+ "@backmatter/core": "^0.0.24",
56
56
  "@base-ui/react": "^1.6.0",
57
+ "@central-icons-react/round-outlined-radius-2-stroke-1.5": "1.2.15",
57
58
  "@dnd-kit/abstract": "^0.5.0",
58
59
  "@dnd-kit/react": "^0.5.0",
59
60
  "@fontsource/geist-mono": "^5.3.0",
package/src/App.tsx CHANGED
@@ -19,6 +19,7 @@ import { NewWorkspacePage } from "./pages/NewWorkspacePage";
19
19
  import { AcceptInvitePage } from "./pages/AcceptInvitePage";
20
20
  import { LegacyWorkspaceRedirect } from "./pages/LegacyWorkspaceRedirect";
21
21
  import { UserSettingsPage } from "./pages/UserSettingsPage";
22
+ import { CliAuthorizePage } from "./pages/CliAuthorizePage";
22
23
  import { BootstrapSplash } from "./replica/BootstrapSplash";
23
24
  import { ReplicaGate, ReplicaProvider } from "./replica/ReplicaProvider";
24
25
  import { replicaStore } from "./replica/instance";
@@ -137,6 +138,7 @@ function AppInner() {
137
138
  <Route path="/workspaces/new" element={<NewWorkspacePage />} />
138
139
  <Route path="/invite" element={<AcceptInvitePage />} />
139
140
  <Route path="/account" element={<UserSettingsPage />} />
141
+ <Route path="/cli/authorize" element={<CliAuthorizePage />} />
140
142
  <Route
141
143
  path="/workspaces/:workspaceId/*"
142
144
  element={<LegacyWorkspaceRedirect />}
@@ -6,6 +6,7 @@ import { WorkspaceLayout } from "./pages/WorkspaceLayout";
6
6
  import { WorkspaceHomeRedirect } from "./pages/WorkspaceHomeRedirect";
7
7
  import { TypeDocsPage } from "./pages/TypeDocsPage";
8
8
  import { SettingsGeneralPage } from "./pages/SettingsGeneralPage";
9
+ import { SettingsUsagePage } from "./pages/SettingsUsagePage";
9
10
  import { SettingsGithubPage } from "./pages/SettingsGithubPage";
10
11
  import { SettingsIndexRedirect, SettingsLayout } from "./pages/SettingsLayout";
11
12
  import { SettingsSchemaPage } from "./pages/SettingsSchemaPage";
@@ -13,6 +14,7 @@ import { SettingsMcpPage } from "./pages/SettingsMcpPage";
13
14
  import { SettingsUsersPage } from "./pages/SettingsUsersPage";
14
15
  import { GithubConnectPage } from "./pages/GithubConnectPage";
15
16
  import { DocEditorPage } from "./pages/DocEditorPage";
17
+ import { MediaGalleryPage } from "./pages/MediaGalleryPage";
16
18
 
17
19
  /**
18
20
  * Returns `<Route>` elements. Spread the result inside a `<Routes>`; react-router
@@ -26,6 +28,7 @@ export function workspaceRoutes() {
26
28
  <Route element={<SettingsLayout />}>
27
29
  <Route path="settings" element={<SettingsIndexRedirect />} />
28
30
  <Route path="settings/general" element={<SettingsGeneralPage />} />
31
+ <Route path="settings/usage" element={<SettingsUsagePage />} />
29
32
  <Route path="settings/github" element={<SettingsGithubPage />} />
30
33
  <Route path="settings/schema" element={<SettingsSchemaPage />} />
31
34
  <Route path="settings/mcp" element={<SettingsMcpPage />} />
@@ -33,6 +36,7 @@ export function workspaceRoutes() {
33
36
  <Route path="github" element={<GithubConnectPage />} />
34
37
  </Route>
35
38
  <Route path="content/:type/:docSlug" element={<DocEditorPage />} />
39
+ <Route path="media" element={<MediaGalleryPage />} />
36
40
  </Route>
37
41
  );
38
42
  }
package/src/api.ts CHANGED
@@ -169,10 +169,49 @@ export type WorkspaceDetail = {
169
169
  hasHostToken: boolean;
170
170
  };
171
171
 
172
+ export type WorkspaceUsage = {
173
+ period: string;
174
+ dataRequests: {
175
+ public: number;
176
+ studio: number;
177
+ replica: number;
178
+ preview: number;
179
+ ai: number;
180
+ };
181
+ work: {
182
+ commits: number;
183
+ commitFiles: number;
184
+ imports: number;
185
+ importFiles: number;
186
+ saves: number;
187
+ mediaTransforms: number;
188
+ };
189
+ ai: { inputTokens: number; outputTokens: number };
190
+ members: number;
191
+ mediaBytes: number;
192
+ };
193
+
194
+ export type CatalogMedia = {
195
+ id: string;
196
+ filename: string;
197
+ contentType: string;
198
+ byteSize: number;
199
+ width: number;
200
+ height: number;
201
+ url: string;
202
+ thumbUrl: string;
203
+ variants: Record<string, string>;
204
+ createdAt: string;
205
+ };
206
+
172
207
  export function fetchWorkspace(workspaceId: string) {
173
208
  return api<{ workspace: WorkspaceDetail }>(`/workspaces/${workspaceId}`);
174
209
  }
175
210
 
211
+ export function fetchWorkspaceUsage(workspaceId: string) {
212
+ return api<WorkspaceUsage>(`/workspaces/${workspaceId}/usage`);
213
+ }
214
+
176
215
  export function fetchReplica(workspaceId: string, cache?: ReplicaCache) {
177
216
  const query = cache ? `?${replicaQuery(cache)}` : "";
178
217
  return api<ReplicaBody>(`/workspaces/${workspaceId}/replica${query}`);
@@ -829,6 +868,71 @@ export function deleteWorkspaceMcp(workspaceId: string, id: string) {
829
868
  });
830
869
  }
831
870
 
871
+ export function fetchMedia(
872
+ workspaceId: string,
873
+ input: { query?: string; cursor?: string } = {},
874
+ ) {
875
+ const params = new URLSearchParams();
876
+ if (input.query) params.set("q", input.query);
877
+ if (input.cursor) params.set("cursor", input.cursor);
878
+ const query = params.size ? `?${params}` : "";
879
+ return api<{ total: number; media: CatalogMedia[]; nextCursor: string | null }>(
880
+ `/workspaces/${workspaceId}/media${query}`,
881
+ );
882
+ }
883
+
884
+ export function uploadMedia(
885
+ workspaceId: string,
886
+ file: File,
887
+ onProgress?: (fraction: number) => void,
888
+ ) {
889
+ return new Promise<{ media: CatalogMedia }>((resolve, reject) => {
890
+ const xhr = new XMLHttpRequest();
891
+ xhr.open("POST", apiUrl(`/workspaces/${workspaceId}/media`));
892
+ xhr.withCredentials = apiCredentials() === "include";
893
+ for (const [name, value] of Object.entries(apiHeaders())) {
894
+ xhr.setRequestHeader(name, value);
895
+ }
896
+ xhr.upload.onprogress = (event) => {
897
+ if (!onProgress || !event.lengthComputable || event.total <= 0) return;
898
+ onProgress(event.loaded / event.total);
899
+ };
900
+ xhr.onerror = () => reject(new Error("Upload failed"));
901
+ xhr.onload = () => {
902
+ const payload = (() => {
903
+ try {
904
+ return JSON.parse(xhr.responseText) as { media?: CatalogMedia; error?: string };
905
+ } catch {
906
+ return {};
907
+ }
908
+ })();
909
+ if (xhr.status === 401 || xhr.status === 426) {
910
+ reportUnauthorized(xhr.status, payload.error);
911
+ }
912
+ if (xhr.status < 200 || xhr.status >= 300 || !payload.media) {
913
+ const err = new Error(payload.error ?? `upload failed: ${xhr.status}`) as ApiError;
914
+ err.status = xhr.status;
915
+ err.code = payload.error;
916
+ reject(err);
917
+ return;
918
+ }
919
+ onProgress?.(1);
920
+ resolve({ media: payload.media });
921
+ };
922
+ const body = new FormData();
923
+ body.set("file", file);
924
+ xhr.send(body);
925
+ });
926
+ }
927
+
928
+ export function deleteMedia(workspaceId: string, mediaId: string) {
929
+ return api<{ ok: true }>(`/workspaces/${workspaceId}/media/${mediaId}`, { method: "DELETE" });
930
+ }
931
+
932
+ export function approveCliLogin(id: string) {
933
+ return api<{ ok: true }>(`/cli/login/${id}/approve`, { method: "POST" });
934
+ }
935
+
832
936
  export function testWorkspaceMcp(workspaceId: string, id: string) {
833
937
  return api<{ connector: McpConnector }>(
834
938
  `/workspaces/${workspaceId}/mcp/${id}/test`,
@@ -12,8 +12,10 @@ import {
12
12
  bindDocsPlaceholders,
13
13
  isCommitTool,
14
14
  isWriteTool,
15
+ presentedChangeStatus,
15
16
  replaceEchoedDocList,
16
17
  splitDocsPlaceholders,
18
+ stackChanges,
17
19
  toolLabel,
18
20
  trimPartialPlaceholder,
19
21
  type WriteToolOutput,
@@ -137,9 +139,13 @@ export function ChatMessage({
137
139
  }
138
140
 
139
141
  const changeIds = session.changeIdsForMessage(message);
142
+ const stack = stackChanges(session.messages, session.changes);
140
143
  const applied = changeIds
141
144
  .map((id) => session.changes[id])
142
- .filter((c): c is ChatChangeSummary => Boolean(c) && c!.status === "applied");
145
+ .filter(
146
+ (c): c is ChatChangeSummary =>
147
+ Boolean(c) && presentedChangeStatus(c, stack) === "applied",
148
+ );
143
149
  const streaming = isLast && live;
144
150
 
145
151
  async function act(id: string, fn: () => Promise<void>) {
@@ -242,6 +248,7 @@ export function ChatMessage({
242
248
  tp.state === "output-available" && !inlined.has(i) && !streaming
243
249
  ? docListFromTool(name, tp.output)
244
250
  : null;
251
+ const viewStatus = change ? presentedChangeStatus(change, stack) : null;
245
252
  return (
246
253
  <div key={tp.toolCallId} {...stylex.props(styles.toolBlock)}>
247
254
  <div {...stylex.props(styles.toolRow, failed && styles.toolFailed)}>
@@ -263,9 +270,9 @@ export function ChatMessage({
263
270
  emptyLabel={name === "searchDocs" ? "No matches" : "No documents"}
264
271
  />
265
272
  ) : null}
266
- {change ? (
273
+ {change && viewStatus ? (
267
274
  <ChangeCard
268
- change={change}
275
+ change={{ ...change, status: viewStatus }}
269
276
  workspaceId={workspaceId}
270
277
  busy={busyId === change.id}
271
278
  canAct={canAct && !streaming}
@@ -252,7 +252,7 @@ const styles = stylex.create({
252
252
  },
253
253
  panelSite: {
254
254
  right: 16,
255
- bottom: 64,
255
+ bottom: "calc(48px + env(safe-area-inset-bottom, 0px))",
256
256
  zIndex: 2147483001,
257
257
  },
258
258
  panelFill: {
@@ -257,6 +257,24 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
257
257
  </button>
258
258
  </Tooltip>
259
259
  <span {...stylex.props(styles.spacer)} />
260
+ {ui.pointer ? (
261
+ <Tooltip label={pointing ? "Stop pointing" : "Point at an element on the page"}>
262
+ <button
263
+ type="button"
264
+ aria-label="Point at an element on the page"
265
+ aria-pressed={pointing}
266
+ disabled={ui.aiAvailable !== true}
267
+ onClick={() => {
268
+ const next = !pointing;
269
+ setPointing(next);
270
+ postPointer(next);
271
+ }}
272
+ {...stylex.props(styles.iconBtn, pointing && styles.iconBtnActive)}
273
+ >
274
+ <PointerIcon />
275
+ </button>
276
+ </Tooltip>
277
+ ) : null}
260
278
  {ui.focus ? (
261
279
  <Tooltip
262
280
  label={
@@ -277,24 +295,6 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
277
295
  </button>
278
296
  </Tooltip>
279
297
  ) : null}
280
- {ui.pointer ? (
281
- <Tooltip label={pointing ? "Stop pointing" : "Point at an element on the page"}>
282
- <button
283
- type="button"
284
- aria-label="Point at an element on the page"
285
- aria-pressed={pointing}
286
- disabled={ui.aiAvailable !== true}
287
- onClick={() => {
288
- const next = !pointing;
289
- setPointing(next);
290
- postPointer(next);
291
- }}
292
- {...stylex.props(styles.iconBtn, pointing && styles.iconBtnActive)}
293
- >
294
- <PointerIcon />
295
- </button>
296
- </Tooltip>
297
- ) : null}
298
298
  <ModelPicker />
299
299
  {busy ? (
300
300
  <Tooltip label="Stop generating">
@@ -3,7 +3,7 @@
3
3
  // Pure helpers used by chat components. Kept free of StyleX/React so they can
4
4
  // be unit tested with `bun test`.
5
5
  import { getToolName, isToolUIPart, type UIMessage } from "ai";
6
- import type { ChatChangeSummary, ChatThread, DocStatus } from "../api";
6
+ import type { ChatChangeSummary, ChatThread, DocStatus, ChatChangeStatus } from "../api";
7
7
  import type { DocRef } from "../chrome/EditorFocusContext";
8
8
 
9
9
  export type WriteToolOutput =
@@ -186,6 +186,63 @@ export function changeIdsInMessage(message: UIMessage): string[] {
186
186
  return ids;
187
187
  }
188
188
 
189
+ export type ChangeStackItem = Pick<
190
+ ChatChangeSummary,
191
+ "id" | "locale" | "type" | "slug" | "status" | "createdAt"
192
+ >;
193
+
194
+ function isLaterChange(a: ChangeStackItem, b: ChangeStackItem) {
195
+ if (a.createdAt !== b.createdAt) return a.createdAt > b.createdAt;
196
+ return a.id > b.id;
197
+ }
198
+
199
+ /**
200
+ * Status to show on a change card. Edits are already in the working copy, so
201
+ * only the latest applied-or-kept edit of a document offers Keep and Undo.
202
+ * An earlier applied edit reads as kept until that later edit is undone.
203
+ */
204
+ export function presentedChangeStatus(
205
+ change: ChangeStackItem,
206
+ all: ChangeStackItem[],
207
+ ): ChatChangeStatus {
208
+ if (change.status !== "applied") return change.status;
209
+ const key = `${change.locale}/${change.type}/${change.slug}`;
210
+ const covered = all.some((other) => {
211
+ if (other.id === change.id) return false;
212
+ if (`${other.locale}/${other.type}/${other.slug}` !== key) return false;
213
+ if (other.status !== "applied" && other.status !== "kept") return false;
214
+ return isLaterChange(other, change);
215
+ });
216
+ return covered ? "kept" : "applied";
217
+ }
218
+
219
+ /** Write-tool changes in the thread, with session status winning over the tool output. */
220
+ export function stackChanges(
221
+ messages: UIMessage[],
222
+ overrides: Record<string, ChatChangeSummary>,
223
+ ): ChatChangeSummary[] {
224
+ const byId = new Map<string, ChatChangeSummary>();
225
+ for (const message of messages) {
226
+ for (const part of message.parts) {
227
+ if (!isToolUIPart(part) || part.state !== "output-available") continue;
228
+ if (!isWriteTool(getToolName(part))) continue;
229
+ const out = part.output as WriteToolOutput | undefined;
230
+ if (!out?.ok) continue;
231
+ byId.set(out.change.id, out.change);
232
+ }
233
+ }
234
+ for (const change of Object.values(overrides)) {
235
+ const existing = byId.get(change.id);
236
+ byId.set(
237
+ change.id,
238
+ existing
239
+ ? { ...existing, ...change, summary: change.summary ?? existing.summary }
240
+ : change,
241
+ );
242
+ }
243
+ return [...byId.values()];
244
+ }
245
+
189
246
  /** Find an active `@query` token ending at the caret. */
190
247
  export function mentionQueryAt(
191
248
  text: string,
@@ -4,38 +4,27 @@ import * as stylex from "@stylexjs/stylex";
4
4
  import { useReplicaState } from "../replica/useReplica";
5
5
  import { colors, space, type } from "../styles/tokens.stylex";
6
6
  import { useWorkspaceNav } from "./WorkspaceNavContext";
7
- import { NavAnchor, NavItem } from "./NavItem";
8
- import { useStudioDefinition } from "../define-studio";
7
+ import { NavItem } from "./NavItem";
9
8
  import { UserMenu } from "./UserMenu";
10
- import { IMPLICIT_SINGLETON_SLUG } from "@backmatter/core";
11
- import {
12
- cmsWorkspaceSlug,
13
- contentDocPath,
14
- contentTypePath,
15
- isWorkspaceSettingsPath,
16
- settingsPath,
17
- workspaceHomePath,
18
- } from "../workspace-path";
19
- import { DocIcon, ExternalIcon, GearIcon } from "./icons";
20
- import {
21
- groupContentTypes,
22
- modelHasLocale,
23
- type ContentTypeNav,
24
- } from "./content-type-groups";
25
-
9
+ import { cmsWorkspaceSlug, isWorkspaceMediaPath, workspaceHomePath } from "../workspace-path";
10
+ import { DocIcon } from "./icons";
11
+ import { ContentExplorerSidebar } from "./ContentExplorerSidebar";
26
12
  export function AppSidebar() {
27
13
  const location = useLocation();
28
14
  const workspaceNav = useWorkspaceNav();
29
15
  const inWorkspace = Boolean(cmsWorkspaceSlug(location.pathname));
16
+ const railOnly = inWorkspace && isWorkspaceMediaPath(location.pathname);
30
17
 
31
18
  return (
32
- <aside {...stylex.props(styles.aside)}>
19
+ <aside {...stylex.props(styles.aside, railOnly && styles.asideRail)}>
33
20
  {inWorkspace && workspaceNav ? (
34
- <WorkspaceNav workspace={workspaceNav} />
21
+ <div {...stylex.props(styles.workspaceNav)}>
22
+ <ContentExplorerSidebar workspace={workspaceNav} />
23
+ </div>
35
24
  ) : (
36
25
  <WorkspaceListNav />
37
26
  )}
38
- <SidebarFooter />
27
+ <SidebarFooter compact={railOnly} />
39
28
  </aside>
40
29
  );
41
30
  }
@@ -75,157 +64,40 @@ function WorkspaceListNav() {
75
64
  );
76
65
  }
77
66
 
78
- function typeHref(
79
- workspaceSlug: string,
80
- defaultLocale: string,
81
- model: ContentTypeNav,
82
- ) {
83
- if (model.singleton && !modelHasLocale(model.path)) {
84
- return contentDocPath(
85
- workspaceSlug,
86
- model.type,
87
- IMPLICIT_SINGLETON_SLUG,
88
- "",
89
- defaultLocale,
90
- );
91
- }
92
- return contentTypePath(workspaceSlug, model.type);
93
- }
94
-
95
- function TypeList({
96
- models,
97
- workspaceSlug,
98
- defaultLocale,
99
- }: {
100
- models: ContentTypeNav[];
101
- workspaceSlug: string;
102
- defaultLocale: string;
103
- }) {
104
- return (
105
- <ul {...stylex.props(styles.list)}>
106
- {models.map((model) => (
107
- <li key={model.type}>
108
- <NavItem to={typeHref(workspaceSlug, defaultLocale, model)}>
109
- <DocIcon />
110
- <span {...stylex.props(styles.truncate, styles.capitalize)}>
111
- {model.type}
112
- </span>
113
- </NavItem>
114
- </li>
115
- ))}
116
- </ul>
117
- );
118
- }
119
-
120
- function WorkspaceNav({
121
- workspace,
122
- }: {
123
- workspace: {
124
- workspaceSlug: string;
125
- defaultLocale: string;
126
- contentTypes: string[];
127
- contentModels: ContentTypeNav[];
128
- };
129
- }) {
130
- const location = useLocation();
131
- const grouped = groupContentTypes(workspace.contentModels);
132
- const empty = workspace.contentTypes.length === 0;
133
- const menu = useStudioDefinition().menu ?? [];
134
-
135
- return (
136
- <>
137
- <nav aria-label="Content" {...stylex.props(styles.nav)}>
138
- {empty ? (
139
- <div>
140
- <SectionLabel>Content</SectionLabel>
141
- <p {...stylex.props(styles.muted)}>
142
- No content types yet. Pull from GitHub to load models.
143
- </p>
144
- </div>
145
- ) : (
146
- <>
147
- {grouped.pages.length > 0 ? (
148
- <div>
149
- <SectionLabel>Pages</SectionLabel>
150
- <TypeList
151
- models={grouped.pages}
152
- workspaceSlug={workspace.workspaceSlug}
153
- defaultLocale={workspace.defaultLocale}
154
- />
155
- </div>
156
- ) : null}
157
- {grouped.data.length > 0 ? (
158
- <div>
159
- <SectionLabel>Data</SectionLabel>
160
- <TypeList
161
- models={grouped.data}
162
- workspaceSlug={workspace.workspaceSlug}
163
- defaultLocale={workspace.defaultLocale}
164
- />
165
- </div>
166
- ) : null}
167
- {grouped.globals.length > 0 ? (
168
- <div>
169
- <SectionLabel>Globals</SectionLabel>
170
- <TypeList
171
- models={grouped.globals}
172
- workspaceSlug={workspace.workspaceSlug}
173
- defaultLocale={workspace.defaultLocale}
174
- />
175
- </div>
176
- ) : null}
177
- </>
178
- )}
179
- </nav>
180
- <nav aria-label="Settings" {...stylex.props(styles.settings)}>
181
- <SectionLabel>Settings</SectionLabel>
182
- <ul {...stylex.props(styles.list)}>
183
- <li>
184
- <NavItem
185
- to={settingsPath(workspace.workspaceSlug, "general")}
186
- active={isWorkspaceSettingsPath(location.pathname)}
187
- >
188
- <GearIcon />
189
- Settings
190
- </NavItem>
191
- </li>
192
- {menu.map((item) => (
193
- <li key={`${item.label}:${item.href}`}>
194
- <NavAnchor href={item.href}>
195
- <ExternalIcon />
196
- {item.label}
197
- </NavAnchor>
198
- </li>
199
- ))}
200
- </ul>
201
- </nav>
202
- </>
203
- );
67
+ function SectionLabel({ children }: { children: React.ReactNode }) {
68
+ return <p {...stylex.props(styles.section)}>{children}</p>;
204
69
  }
205
70
 
206
- function SidebarFooter() {
71
+ function SidebarFooter({ compact = false }: { compact?: boolean }) {
207
72
  return (
208
- <div {...stylex.props(styles.footer)}>
209
- <UserMenu />
73
+ <div {...stylex.props(styles.footer, compact && styles.footerCompact)}>
74
+ <UserMenu compact={compact} />
210
75
  </div>
211
76
  );
212
77
  }
213
78
 
214
- function SectionLabel({ children }: { children: React.ReactNode }) {
215
- return <p {...stylex.props(styles.section)}>{children}</p>;
216
- }
217
-
218
79
  const styles = stylex.create({
80
+ workspaceNav: {
81
+ display: "flex",
82
+ minHeight: 0,
83
+ flex: 1,
84
+ flexDirection: "column",
85
+ },
219
86
  aside: {
220
87
  display: "flex",
221
88
  height: "100%",
222
- width: "220px",
89
+ width: "min(360px, 38vw)",
90
+ minWidth: "300px",
223
91
  flexShrink: 0,
224
92
  flexDirection: "column",
225
93
  borderRightWidth: 1,
226
94
  borderRightStyle: "solid",
227
95
  borderRightColor: colors.border,
228
- backgroundColor: colors.bgSidebar,
96
+ backgroundColor: colors.bgSurface,
97
+ },
98
+ asideRail: {
99
+ width: "48px",
100
+ minWidth: "48px",
229
101
  },
230
102
  brand: {
231
103
  flexShrink: 0,
@@ -302,4 +174,7 @@ const styles = stylex.create({
302
174
  borderTopColor: colors.border,
303
175
  padding: space.md,
304
176
  },
177
+ footerCompact: {
178
+ padding: space.sm,
179
+ },
305
180
  });