@backmatter/studio 0.0.6 → 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.
- package/dist/chunks/{EmbedPublishPage-DazYJLaD.js → EmbedPublishPage-CtIlaeGO.js} +1 -1
- package/dist/chunks/{UiKitPage-DyVyBp0I.js → UiKitPage-DcnSQ-Hf.js} +3 -3
- package/dist/chunks/{WorkspaceRoutes-CPvkgvxe.js → WorkspaceRoutes-DVpUOaMV.js} +20841 -18934
- package/dist/chunks/{dashboard-6aSS8oyj.js → dashboard-bNp6AbKE.js} +465 -443
- package/dist/chunks/{index-DgTNL4t3.js → index-DoDE-hwB.js} +2 -2
- package/dist/chunks/{status-bar-action-RNBujSgk.js → status-bar-action-C0fsAWvD.js} +5804 -5668
- package/dist/dashboard.js +2 -2
- package/dist/index.js +4 -4
- package/dist/site-widget.js +2 -2
- package/dist/studio.css +454 -62
- package/package.json +3 -2
- package/src/App.tsx +2 -0
- package/src/WorkspaceRoutes.tsx +2 -0
- package/src/api.ts +80 -0
- package/src/chat/ChatMessage.tsx +10 -3
- package/src/chat/Composer.tsx +18 -18
- package/src/chat/chat-helpers.ts +58 -1
- package/src/chrome/AppShell.tsx +47 -10
- package/src/chrome/AppSidebar.tsx +10 -160
- package/src/chrome/CommandPalette.tsx +211 -74
- package/src/chrome/ContentExplorerSidebar.tsx +1212 -0
- package/src/chrome/UserMenu.tsx +20 -3
- package/src/chrome/WorkspaceNavContext.tsx +1 -0
- package/src/chrome/command-palette-icons.tsx +152 -0
- package/src/chrome/content-path-tree.ts +95 -0
- package/src/chrome/content-repo-path.ts +52 -0
- package/src/chrome/explorer-icons.tsx +87 -0
- package/src/chrome/folder-create-context.ts +147 -0
- package/src/pages/BlockSettingsDialog.tsx +4 -0
- package/src/pages/CliAuthorizePage.tsx +43 -0
- package/src/pages/DocEditorPage.tsx +33 -4
- package/src/pages/MediaFieldInput.tsx +139 -0
- package/src/pages/MediaGalleryPage.tsx +748 -0
- package/src/pages/MediaLightbox.tsx +467 -0
- package/src/pages/SelectRepositoriesLink.tsx +28 -4
- package/src/pages/SettingsGeneralPage.tsx +25 -28
- package/src/pages/SettingsGithubPage.tsx +413 -124
- package/src/pages/SettingsUsagePage.tsx +19 -0
- package/src/pages/TypeDocsPage.tsx +2 -210
- package/src/pages/WorkspaceLayout.tsx +6 -1
- package/src/pages/media-grid.ts +190 -0
- package/src/pages/preview-url.ts +13 -2
- package/src/replica/useReplica.ts +9 -0
- package/src/ui/ConfirmDialog.tsx +1 -1
- package/src/workspace/permissions.ts +9 -0
- package/src/workspace-path.ts +27 -1
|
@@ -1,18 +1,5 @@
|
|
|
1
1
|
// packages/studio/src/pages/TypeDocsPage.tsx
|
|
2
|
-
import { useMemo } from "react";
|
|
3
|
-
import { Link, useOutletContext, useParams } from "react-router";
|
|
4
|
-
import * as stylex from "@stylexjs/stylex";
|
|
5
2
|
import type { DocListItem } from "../api";
|
|
6
|
-
import { useReplicaState } from "../replica/useReplica";
|
|
7
|
-
import { colors, space, type } from "../styles/tokens.stylex";
|
|
8
|
-
import { PageHeader } from "../ui/Page";
|
|
9
|
-
import { Text } from "../ui/Text";
|
|
10
|
-
import type { WorkspaceOutletContext } from "../workspace/types";
|
|
11
|
-
import { modelHasLocale } from "../chrome/content-type-groups";
|
|
12
|
-
import { compareTypeDocs } from "./type-docs";
|
|
13
|
-
import { contentDocPath, defaultLocaleOf } from "../workspace-path";
|
|
14
|
-
|
|
15
|
-
const hover = "@media (hover: hover)";
|
|
16
3
|
|
|
17
4
|
type DocRow = DocListItem;
|
|
18
5
|
|
|
@@ -24,202 +11,7 @@ export function statusLabel(doc: Pick<DocRow, "status">) {
|
|
|
24
11
|
return "";
|
|
25
12
|
}
|
|
26
13
|
|
|
14
|
+
/** Type index: navigation lives in the content explorer; main area stays empty. */
|
|
27
15
|
export function TypeDocsPage() {
|
|
28
|
-
|
|
29
|
-
const { workspace, models } = useOutletContext<WorkspaceOutletContext>();
|
|
30
|
-
const docs = useReplicaState()
|
|
31
|
-
.docs(workspace.id)
|
|
32
|
-
.filter((doc) => doc.type === type);
|
|
33
|
-
|
|
34
|
-
const model = models.find((m) => m.type === type);
|
|
35
|
-
const showLocale = !model || modelHasLocale(model.path);
|
|
36
|
-
const rows = useMemo(
|
|
37
|
-
() =>
|
|
38
|
-
[...docs].sort(compareTypeDocs),
|
|
39
|
-
[docs],
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
return (
|
|
43
|
-
<section {...stylex.props(styles.page)}>
|
|
44
|
-
<div {...stylex.props(styles.toolbar)}>
|
|
45
|
-
<PageHeader
|
|
46
|
-
compact
|
|
47
|
-
title={type}
|
|
48
|
-
description={
|
|
49
|
-
model
|
|
50
|
-
? model.path
|
|
51
|
-
: "This type is not in the current schema snapshot."
|
|
52
|
-
}
|
|
53
|
-
/>
|
|
54
|
-
{rows.length === 0 ? (
|
|
55
|
-
<Text tone="muted">
|
|
56
|
-
No documents for this type yet. Add files to the repo or pull from
|
|
57
|
-
GitHub via the branch menu.
|
|
58
|
-
</Text>
|
|
59
|
-
) : null}
|
|
60
|
-
</div>
|
|
61
|
-
{rows.length > 0 ? (
|
|
62
|
-
<table {...stylex.props(styles.table)}>
|
|
63
|
-
<thead>
|
|
64
|
-
<tr>
|
|
65
|
-
<th scope="col" {...stylex.props(styles.th, styles.padStart)}>
|
|
66
|
-
Slug
|
|
67
|
-
</th>
|
|
68
|
-
{showLocale ? (
|
|
69
|
-
<th scope="col" {...stylex.props(styles.th, styles.colMeta)}>
|
|
70
|
-
Locale
|
|
71
|
-
</th>
|
|
72
|
-
) : null}
|
|
73
|
-
<th
|
|
74
|
-
scope="col"
|
|
75
|
-
{...stylex.props(styles.th, styles.colMeta, styles.padEnd)}
|
|
76
|
-
>
|
|
77
|
-
Status
|
|
78
|
-
</th>
|
|
79
|
-
</tr>
|
|
80
|
-
</thead>
|
|
81
|
-
<tbody>
|
|
82
|
-
{rows.map((doc) => {
|
|
83
|
-
const to = contentDocPath(
|
|
84
|
-
workspace.slug,
|
|
85
|
-
doc.type,
|
|
86
|
-
doc.slug,
|
|
87
|
-
doc.locale,
|
|
88
|
-
defaultLocaleOf(workspace.locales),
|
|
89
|
-
);
|
|
90
|
-
return (
|
|
91
|
-
<tr
|
|
92
|
-
key={`${doc.locale}/${doc.type}/${doc.slug}`}
|
|
93
|
-
{...stylex.props(styles.row)}
|
|
94
|
-
>
|
|
95
|
-
<td {...stylex.props(styles.td, styles.padStart)}>
|
|
96
|
-
<Link to={to} title={doc.slug} {...stylex.props(styles.slugLink)}>
|
|
97
|
-
{doc.slug}
|
|
98
|
-
</Link>
|
|
99
|
-
</td>
|
|
100
|
-
{showLocale ? (
|
|
101
|
-
<td {...stylex.props(styles.td, styles.colMeta, styles.locale)}>
|
|
102
|
-
{doc.locale}
|
|
103
|
-
</td>
|
|
104
|
-
) : null}
|
|
105
|
-
<td
|
|
106
|
-
{...stylex.props(
|
|
107
|
-
styles.td,
|
|
108
|
-
styles.colMeta,
|
|
109
|
-
styles.padEnd,
|
|
110
|
-
doc.status === "conflict" && styles.conflict,
|
|
111
|
-
)}
|
|
112
|
-
>
|
|
113
|
-
{statusLabel(doc)}
|
|
114
|
-
</td>
|
|
115
|
-
</tr>
|
|
116
|
-
);
|
|
117
|
-
})}
|
|
118
|
-
</tbody>
|
|
119
|
-
</table>
|
|
120
|
-
) : null}
|
|
121
|
-
</section>
|
|
122
|
-
);
|
|
16
|
+
return null;
|
|
123
17
|
}
|
|
124
|
-
|
|
125
|
-
const styles = stylex.create({
|
|
126
|
-
page: {
|
|
127
|
-
display: "flex",
|
|
128
|
-
minHeight: 0,
|
|
129
|
-
flex: 1,
|
|
130
|
-
flexDirection: "column",
|
|
131
|
-
overflowY: "auto",
|
|
132
|
-
},
|
|
133
|
-
toolbar: {
|
|
134
|
-
display: "flex",
|
|
135
|
-
flexDirection: "column",
|
|
136
|
-
gap: space.md,
|
|
137
|
-
paddingInline: space.xxl,
|
|
138
|
-
paddingTop: space.xxl,
|
|
139
|
-
paddingBottom: space.lg,
|
|
140
|
-
},
|
|
141
|
-
table: {
|
|
142
|
-
width: "100%",
|
|
143
|
-
borderCollapse: "collapse",
|
|
144
|
-
tableLayout: "fixed",
|
|
145
|
-
borderTopWidth: 1,
|
|
146
|
-
borderTopStyle: "solid",
|
|
147
|
-
borderTopColor: colors.border,
|
|
148
|
-
},
|
|
149
|
-
th: {
|
|
150
|
-
position: "sticky",
|
|
151
|
-
top: 0,
|
|
152
|
-
zIndex: 2,
|
|
153
|
-
paddingBlock: space.sm,
|
|
154
|
-
paddingInline: space.lg,
|
|
155
|
-
borderBottomWidth: 1,
|
|
156
|
-
borderBottomStyle: "solid",
|
|
157
|
-
borderBottomColor: colors.border,
|
|
158
|
-
backgroundColor: colors.bgApp,
|
|
159
|
-
color: colors.textMuted,
|
|
160
|
-
fontSize: type.xs,
|
|
161
|
-
fontWeight: 500,
|
|
162
|
-
letterSpacing: "0.02em",
|
|
163
|
-
textAlign: "left",
|
|
164
|
-
},
|
|
165
|
-
td: {
|
|
166
|
-
minWidth: 0,
|
|
167
|
-
height: 40,
|
|
168
|
-
paddingBlock: space.md,
|
|
169
|
-
paddingInline: space.lg,
|
|
170
|
-
borderBottomWidth: 1,
|
|
171
|
-
borderBottomStyle: "solid",
|
|
172
|
-
borderBottomColor: colors.border,
|
|
173
|
-
color: colors.textMuted,
|
|
174
|
-
fontSize: type.md,
|
|
175
|
-
verticalAlign: "middle",
|
|
176
|
-
},
|
|
177
|
-
padStart: {
|
|
178
|
-
paddingLeft: space.xxl,
|
|
179
|
-
},
|
|
180
|
-
padEnd: {
|
|
181
|
-
paddingRight: space.xxl,
|
|
182
|
-
},
|
|
183
|
-
colMeta: {
|
|
184
|
-
width: "7.5rem",
|
|
185
|
-
},
|
|
186
|
-
locale: {
|
|
187
|
-
fontSize: type.xs,
|
|
188
|
-
textTransform: "uppercase",
|
|
189
|
-
color: colors.textMuted,
|
|
190
|
-
},
|
|
191
|
-
conflict: {
|
|
192
|
-
color: colors.danger,
|
|
193
|
-
fontWeight: 500,
|
|
194
|
-
},
|
|
195
|
-
row: {
|
|
196
|
-
position: "relative",
|
|
197
|
-
[hover]: {
|
|
198
|
-
":hover": { backgroundColor: colors.bgHover },
|
|
199
|
-
},
|
|
200
|
-
":focus-within": {
|
|
201
|
-
outlineWidth: 2,
|
|
202
|
-
outlineStyle: "solid",
|
|
203
|
-
outlineColor: colors.focus,
|
|
204
|
-
outlineOffset: -2,
|
|
205
|
-
},
|
|
206
|
-
},
|
|
207
|
-
slugLink: {
|
|
208
|
-
display: "block",
|
|
209
|
-
minWidth: 0,
|
|
210
|
-
fontWeight: 500,
|
|
211
|
-
color: colors.text,
|
|
212
|
-
overflow: "hidden",
|
|
213
|
-
textOverflow: "ellipsis",
|
|
214
|
-
whiteSpace: "nowrap",
|
|
215
|
-
"::after": {
|
|
216
|
-
content: '""',
|
|
217
|
-
position: "absolute",
|
|
218
|
-
inset: 0,
|
|
219
|
-
zIndex: 1,
|
|
220
|
-
},
|
|
221
|
-
":focus-visible": {
|
|
222
|
-
outlineWidth: 0,
|
|
223
|
-
},
|
|
224
|
-
},
|
|
225
|
-
});
|
|
@@ -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
|
+
/** Uploads plus catalog rows, including rows that have not been fetched yet. */
|
|
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
|
+
}
|
package/src/pages/preview-url.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
// packages/studio/src/pages/preview-url.ts
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
+
}
|
package/src/ui/ConfirmDialog.tsx
CHANGED
|
@@ -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
|
+
}
|
package/src/workspace-path.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -29,7 +33,7 @@ export function githubConnectPath(slug: string) {
|
|
|
29
33
|
return `/${slug}/github`;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
const APP_ROOTS = new Set(["workspaces", "account", "invite", "__ui"]);
|
|
36
|
+
const APP_ROOTS = new Set(["workspaces", "account", "invite", "cli", "__ui"]);
|
|
33
37
|
|
|
34
38
|
export function cmsWorkspaceSlug(pathname: string): string | null {
|
|
35
39
|
if (pathname === "/") return null;
|
|
@@ -83,6 +87,28 @@ 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
|
+
|
|
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
|
+
|
|
86
112
|
export function isWorkspaceSettingsPath(pathname: string) {
|
|
87
113
|
return /\/settings(?:\/|$)/.test(pathname) || /\/github\/?$/.test(pathname);
|
|
88
114
|
}
|