@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.
- package/dist/chunks/{EmbedPublishPage-DazYJLaD.js → EmbedPublishPage-88VsmOhi.js} +1 -1
- package/dist/chunks/{UiKitPage-DyVyBp0I.js → UiKitPage-X4hkFL2d.js} +3 -3
- package/dist/chunks/{WorkspaceRoutes-CPvkgvxe.js → WorkspaceRoutes-DXzdWqN3.js} +20287 -18407
- package/dist/chunks/{dashboard-6aSS8oyj.js → dashboard-DCHRI0WD.js} +465 -443
- package/dist/chunks/{index-DgTNL4t3.js → index-BS0-Bvbk.js} +2 -2
- package/dist/chunks/{status-bar-action-RNBujSgk.js → status-bar-action-wj1KZ51m.js} +5512 -5382
- package/dist/dashboard.js +2 -2
- package/dist/index.js +4 -4
- package/dist/site-widget.js +2 -2
- package/dist/studio.css +450 -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/AppSidebar.tsx +31 -156
- package/src/chrome/CommandPalette.tsx +211 -74
- package/src/chrome/ContentExplorerSidebar.tsx +1174 -0
- package/src/chrome/UserMenu.tsx +18 -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 +89 -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 +752 -0
- package/src/pages/MediaLightbox.tsx +460 -0
- 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 +13 -0
package/src/chrome/UserMenu.tsx
CHANGED
|
@@ -16,7 +16,7 @@ export function FloatingUserMenu() {
|
|
|
16
16
|
);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export function UserMenu({ quiet = false }: { quiet?: boolean }) {
|
|
19
|
+
export function UserMenu({ quiet = false, compact = false }: { quiet?: boolean; compact?: boolean }) {
|
|
20
20
|
const { email, onLogout } = useAppChrome();
|
|
21
21
|
const location = useLocation();
|
|
22
22
|
const onAccount = location.pathname.replace(/\/$/, "") === "/account";
|
|
@@ -28,15 +28,20 @@ export function UserMenu({ quiet = false }: { quiet?: boolean }) {
|
|
|
28
28
|
className={cx(
|
|
29
29
|
styles.trigger,
|
|
30
30
|
quiet && styles.triggerQuiet,
|
|
31
|
+
compact && styles.triggerCompact,
|
|
31
32
|
onAccount && styles.triggerActive,
|
|
32
33
|
)}
|
|
33
34
|
>
|
|
34
|
-
|
|
35
|
+
{compact ? (
|
|
36
|
+
<span {...stylex.props(styles.initial)}>{email.trim().charAt(0).toUpperCase() || "?"}</span>
|
|
37
|
+
) : (
|
|
38
|
+
<span {...stylex.props(styles.email)}>{email}</span>
|
|
39
|
+
)}
|
|
35
40
|
</Menu.Trigger>
|
|
36
41
|
<Menu.Portal>
|
|
37
42
|
<Menu.Positioner
|
|
38
43
|
side="top"
|
|
39
|
-
align="start"
|
|
44
|
+
align={compact ? "center" : "start"}
|
|
40
45
|
sideOffset={6}
|
|
41
46
|
className={cx(popup.positioner)}
|
|
42
47
|
>
|
|
@@ -104,6 +109,16 @@ const styles = stylex.create({
|
|
|
104
109
|
},
|
|
105
110
|
},
|
|
106
111
|
},
|
|
112
|
+
triggerCompact: {
|
|
113
|
+
justifyContent: "center",
|
|
114
|
+
paddingInline: 0,
|
|
115
|
+
paddingBlock: 0,
|
|
116
|
+
minHeight: "32px",
|
|
117
|
+
},
|
|
118
|
+
initial: {
|
|
119
|
+
fontSize: type.sm,
|
|
120
|
+
fontWeight: 600,
|
|
121
|
+
},
|
|
107
122
|
triggerQuiet: {
|
|
108
123
|
width: "auto",
|
|
109
124
|
maxWidth: "220px",
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// packages/studio/src/chrome/command-palette-icons.tsx
|
|
2
|
+
import {
|
|
3
|
+
ApiIcon,
|
|
4
|
+
DocIcon,
|
|
5
|
+
GearIcon,
|
|
6
|
+
GithubIcon,
|
|
7
|
+
GitBranchIcon,
|
|
8
|
+
OrgIcon,
|
|
9
|
+
PlugIcon,
|
|
10
|
+
SchemaIcon,
|
|
11
|
+
} from "./icons";
|
|
12
|
+
import {
|
|
13
|
+
FileIcon,
|
|
14
|
+
HistoryIcon,
|
|
15
|
+
PlusIcon,
|
|
16
|
+
SparkIcon,
|
|
17
|
+
UndoIcon,
|
|
18
|
+
} from "../chat/icons";
|
|
19
|
+
|
|
20
|
+
export type PaletteIconId =
|
|
21
|
+
| "save"
|
|
22
|
+
| "commit"
|
|
23
|
+
| "pull"
|
|
24
|
+
| "chat"
|
|
25
|
+
| "chat-new"
|
|
26
|
+
| "chat-history"
|
|
27
|
+
| "revert"
|
|
28
|
+
| "workspaces"
|
|
29
|
+
| "workspace"
|
|
30
|
+
| "workspace-new"
|
|
31
|
+
| "account"
|
|
32
|
+
| "theme"
|
|
33
|
+
| "logout"
|
|
34
|
+
| "ui-kit"
|
|
35
|
+
| "settings-general"
|
|
36
|
+
| "settings-github"
|
|
37
|
+
| "settings-schema"
|
|
38
|
+
| "connectors"
|
|
39
|
+
| "content-type"
|
|
40
|
+
| "document";
|
|
41
|
+
|
|
42
|
+
function SaveIcon({ size = 15 }: { size?: number }) {
|
|
43
|
+
return (
|
|
44
|
+
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden>
|
|
45
|
+
<path
|
|
46
|
+
d="M4.5 2.5h5.2L12 4.8V13a.5.5 0 0 1-.5.5h-7a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5Z"
|
|
47
|
+
stroke="currentColor"
|
|
48
|
+
strokeWidth="1.25"
|
|
49
|
+
strokeLinejoin="round"
|
|
50
|
+
/>
|
|
51
|
+
<path d="M6 13.5h4M8 7v3.5" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
|
52
|
+
</svg>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function LogOutIcon({ size = 15 }: { size?: number }) {
|
|
57
|
+
return (
|
|
58
|
+
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden>
|
|
59
|
+
<path
|
|
60
|
+
d="M6 3.5H4.5a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1H6"
|
|
61
|
+
stroke="currentColor"
|
|
62
|
+
strokeWidth="1.25"
|
|
63
|
+
strokeLinecap="round"
|
|
64
|
+
/>
|
|
65
|
+
<path
|
|
66
|
+
d="M9 8h5.5M12.25 5.75 14.5 8l-2.25 2.25"
|
|
67
|
+
stroke="currentColor"
|
|
68
|
+
strokeWidth="1.25"
|
|
69
|
+
strokeLinecap="round"
|
|
70
|
+
strokeLinejoin="round"
|
|
71
|
+
/>
|
|
72
|
+
</svg>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ThemeIcon({ size = 15 }: { size?: number }) {
|
|
77
|
+
return (
|
|
78
|
+
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden>
|
|
79
|
+
<circle cx="8" cy="8" r="3.25" stroke="currentColor" strokeWidth="1.25" />
|
|
80
|
+
<path
|
|
81
|
+
d="M8 1.8v1.5M8 12.7v1.5M1.8 8h1.5M12.7 8h1.5M3.6 3.6l1.05 1.05M11.35 11.35l1.05 1.05M3.6 12.4l1.05-1.05M11.35 4.65l1.05-1.05"
|
|
82
|
+
stroke="currentColor"
|
|
83
|
+
strokeWidth="1.25"
|
|
84
|
+
strokeLinecap="round"
|
|
85
|
+
/>
|
|
86
|
+
</svg>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function PullIcon({ size = 15 }: { size?: number }) {
|
|
91
|
+
return (
|
|
92
|
+
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden>
|
|
93
|
+
<path
|
|
94
|
+
d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9"
|
|
95
|
+
stroke="currentColor"
|
|
96
|
+
strokeWidth="1.25"
|
|
97
|
+
strokeLinecap="round"
|
|
98
|
+
/>
|
|
99
|
+
<path
|
|
100
|
+
d="M13.5 12.5v-2.8h-2.8M8 8v5.5M5.25 10.75 8 13.5l2.75-2.75"
|
|
101
|
+
stroke="currentColor"
|
|
102
|
+
strokeWidth="1.25"
|
|
103
|
+
strokeLinecap="round"
|
|
104
|
+
strokeLinejoin="round"
|
|
105
|
+
/>
|
|
106
|
+
</svg>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function PaletteIcon({ id, size = 15 }: { id: PaletteIconId; size?: number }) {
|
|
111
|
+
switch (id) {
|
|
112
|
+
case "save":
|
|
113
|
+
return <SaveIcon size={size} />;
|
|
114
|
+
case "commit":
|
|
115
|
+
return <GitBranchIcon />;
|
|
116
|
+
case "pull":
|
|
117
|
+
return <PullIcon size={size} />;
|
|
118
|
+
case "chat":
|
|
119
|
+
return <SparkIcon size={size} />;
|
|
120
|
+
case "chat-new":
|
|
121
|
+
return <PlusIcon size={size} />;
|
|
122
|
+
case "chat-history":
|
|
123
|
+
return <HistoryIcon size={size} />;
|
|
124
|
+
case "revert":
|
|
125
|
+
return <UndoIcon size={size} />;
|
|
126
|
+
case "workspaces":
|
|
127
|
+
case "workspace":
|
|
128
|
+
return <OrgIcon />;
|
|
129
|
+
case "workspace-new":
|
|
130
|
+
return <PlusIcon size={size} />;
|
|
131
|
+
case "account":
|
|
132
|
+
return <GearIcon />;
|
|
133
|
+
case "theme":
|
|
134
|
+
return <ThemeIcon size={size} />;
|
|
135
|
+
case "logout":
|
|
136
|
+
return <LogOutIcon size={size} />;
|
|
137
|
+
case "ui-kit":
|
|
138
|
+
return <ApiIcon />;
|
|
139
|
+
case "settings-general":
|
|
140
|
+
return <GearIcon />;
|
|
141
|
+
case "settings-github":
|
|
142
|
+
return <GithubIcon size={size} />;
|
|
143
|
+
case "settings-schema":
|
|
144
|
+
return <SchemaIcon />;
|
|
145
|
+
case "connectors":
|
|
146
|
+
return <PlugIcon />;
|
|
147
|
+
case "content-type":
|
|
148
|
+
return <DocIcon />;
|
|
149
|
+
case "document":
|
|
150
|
+
return <FileIcon size={size} />;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// packages/studio/src/chrome/content-path-tree.ts
|
|
2
|
+
|
|
3
|
+
const CONTENT_ROOT = "content/";
|
|
4
|
+
|
|
5
|
+
/** Drop the shared `content/` prefix for sidebar trees (panel is already labeled Content). */
|
|
6
|
+
export function treePathFromRepo(repoPath: string): string {
|
|
7
|
+
return repoPath.startsWith(CONTENT_ROOT)
|
|
8
|
+
? repoPath.slice(CONTENT_ROOT.length)
|
|
9
|
+
: repoPath;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type PathTreeFile = {
|
|
13
|
+
kind: "file";
|
|
14
|
+
name: string;
|
|
15
|
+
repoPath: string;
|
|
16
|
+
href: string;
|
|
17
|
+
active: boolean;
|
|
18
|
+
dirty: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type PathTreeFolder = {
|
|
22
|
+
kind: "folder";
|
|
23
|
+
name: string;
|
|
24
|
+
pathKey: string;
|
|
25
|
+
children: PathTreeNode[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type PathTreeNode = PathTreeFile | PathTreeFolder;
|
|
29
|
+
|
|
30
|
+
type Level = {
|
|
31
|
+
folders: Map<string, Level>;
|
|
32
|
+
files: PathTreeFile[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function compareName(a: string, b: string) {
|
|
36
|
+
return a.localeCompare(b, undefined, { sensitivity: "base" });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function levelToNodes(level: Level, pathKey: string): PathTreeNode[] {
|
|
40
|
+
const nodes: PathTreeNode[] = [];
|
|
41
|
+
for (const name of [...level.folders.keys()].sort(compareName)) {
|
|
42
|
+
const child = level.folders.get(name)!;
|
|
43
|
+
const nextKey = pathKey ? `${pathKey}/${name}` : name;
|
|
44
|
+
nodes.push({
|
|
45
|
+
kind: "folder",
|
|
46
|
+
name,
|
|
47
|
+
pathKey: nextKey,
|
|
48
|
+
children: levelToNodes(child, nextKey),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const files = [...level.files].sort((a, b) => compareName(a.name, b.name));
|
|
52
|
+
nodes.push(...files);
|
|
53
|
+
return nodes;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Merge repo-relative file paths into a nested folder tree (VS Code–style). */
|
|
57
|
+
export function buildPathTree(files: PathTreeFile[]): PathTreeNode[] {
|
|
58
|
+
const root: Level = { folders: new Map(), files: [] };
|
|
59
|
+
for (const file of files) {
|
|
60
|
+
const parts = file.repoPath.split("/").filter(Boolean);
|
|
61
|
+
if (parts.length === 0) {
|
|
62
|
+
root.files.push(file);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (parts.length === 1) {
|
|
66
|
+
root.files.push({ ...file, name: parts[0]! });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const dirs = parts.slice(0, -1);
|
|
70
|
+
const leafName = parts[parts.length - 1]!;
|
|
71
|
+
let level = root;
|
|
72
|
+
let pathKey = "";
|
|
73
|
+
for (const dir of dirs) {
|
|
74
|
+
pathKey = pathKey ? `${pathKey}/${dir}` : dir;
|
|
75
|
+
let next = level.folders.get(dir);
|
|
76
|
+
if (!next) {
|
|
77
|
+
next = { folders: new Map(), files: [] };
|
|
78
|
+
level.folders.set(dir, next);
|
|
79
|
+
}
|
|
80
|
+
level = next;
|
|
81
|
+
}
|
|
82
|
+
level.files.push({ ...file, name: leafName });
|
|
83
|
+
}
|
|
84
|
+
return levelToNodes(root, "");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function folderKeysOnPathTo(repoPath: string): string[] {
|
|
88
|
+
const parts = repoPath.split("/").filter(Boolean);
|
|
89
|
+
if (parts.length <= 1) return [];
|
|
90
|
+
const keys: string[] = [];
|
|
91
|
+
for (let i = 1; i < parts.length; i++) {
|
|
92
|
+
keys.push(parts.slice(0, i).join("/"));
|
|
93
|
+
}
|
|
94
|
+
return keys;
|
|
95
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// packages/studio/src/chrome/content-repo-path.ts
|
|
2
|
+
import { IMPLICIT_SINGLETON_SLUG, repoPathFromModel } from "@backmatter/core";
|
|
3
|
+
import type { DocListItem } from "../api";
|
|
4
|
+
import { modelHasLocale, type ContentTypeNav } from "./content-type-groups";
|
|
5
|
+
|
|
6
|
+
export function docRepoPath(
|
|
7
|
+
model: ContentTypeNav,
|
|
8
|
+
doc: Pick<DocListItem, "locale" | "slug">,
|
|
9
|
+
defaultLocale: string,
|
|
10
|
+
): string {
|
|
11
|
+
const locale = modelHasLocale(model.path)
|
|
12
|
+
? doc.locale || defaultLocale
|
|
13
|
+
: undefined;
|
|
14
|
+
return repoPathFromModel(model, locale, doc.slug);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function singletonRepoPath(
|
|
18
|
+
model: ContentTypeNav,
|
|
19
|
+
defaultLocale: string,
|
|
20
|
+
): string {
|
|
21
|
+
return docRepoPath(
|
|
22
|
+
model,
|
|
23
|
+
{ slug: IMPLICIT_SINGLETON_SLUG, locale: defaultLocale },
|
|
24
|
+
defaultLocale,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function repoPathBasename(repoPath: string): string {
|
|
29
|
+
const parts = repoPath.split("/");
|
|
30
|
+
return parts[parts.length - 1] ?? repoPath;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function fileExtFromRepoPath(repoPath: string): string {
|
|
34
|
+
const base = repoPathBasename(repoPath);
|
|
35
|
+
const dot = base.lastIndexOf(".");
|
|
36
|
+
return dot >= 0 ? base.slice(dot + 1).toLowerCase() : "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Label for a row in the content tree (repo-relative filename). */
|
|
40
|
+
export function docTreeLabel(
|
|
41
|
+
model: ContentTypeNav,
|
|
42
|
+
doc: Pick<DocListItem, "locale" | "slug">,
|
|
43
|
+
defaultLocale: string,
|
|
44
|
+
): string {
|
|
45
|
+
const repoPath = docRepoPath(model, doc, defaultLocale);
|
|
46
|
+
const name = repoPathBasename(repoPath);
|
|
47
|
+
const locale = doc.locale || defaultLocale;
|
|
48
|
+
if (modelHasLocale(model.path) && locale !== defaultLocale) {
|
|
49
|
+
return `${locale}/${name}`;
|
|
50
|
+
}
|
|
51
|
+
return name;
|
|
52
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// packages/studio/src/chrome/explorer-icons.tsx
|
|
2
|
+
// Central Icons — round outlined, 2px corner radius, 1.5px stroke
|
|
3
|
+
// Package: @central-icons-react/round-outlined-radius-2-stroke-1.5
|
|
4
|
+
// Install requires CENTRAL_LICENSE_KEY (see .env.example).
|
|
5
|
+
import {
|
|
6
|
+
IconChevronDownSmall,
|
|
7
|
+
IconChevronRightSmall,
|
|
8
|
+
IconBrackets2,
|
|
9
|
+
IconFileText,
|
|
10
|
+
IconFiles,
|
|
11
|
+
IconFolder1,
|
|
12
|
+
IconFolderOpen,
|
|
13
|
+
IconGit,
|
|
14
|
+
IconHistory,
|
|
15
|
+
IconImages1,
|
|
16
|
+
IconListBullets,
|
|
17
|
+
IconMagnifyingGlass,
|
|
18
|
+
IconPageAdd,
|
|
19
|
+
IconPlusMedium,
|
|
20
|
+
IconSettingsGear2,
|
|
21
|
+
} from "@central-icons-react/round-outlined-radius-2-stroke-1.5";
|
|
22
|
+
import type { CentralIconBaseProps } from "@central-icons-react/round-outlined-radius-2-stroke-1.5";
|
|
23
|
+
import type { ComponentType } from "react";
|
|
24
|
+
|
|
25
|
+
const TREE = 16;
|
|
26
|
+
const TREE_ACTION = 16;
|
|
27
|
+
const RAIL = 18;
|
|
28
|
+
const HEADER = 14;
|
|
29
|
+
|
|
30
|
+
function Central({
|
|
31
|
+
icon: Icon,
|
|
32
|
+
size,
|
|
33
|
+
}: {
|
|
34
|
+
icon: ComponentType<CentralIconBaseProps>;
|
|
35
|
+
size: number;
|
|
36
|
+
}) {
|
|
37
|
+
return <Icon size={size} ariaHidden />;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function ExplorerTwistie({ open }: { open: boolean }) {
|
|
41
|
+
const Icon = open ? IconChevronDownSmall : IconChevronRightSmall;
|
|
42
|
+
return <Central icon={Icon} size={TREE} />;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ExplorerFolderIcon({ open }: { open: boolean }) {
|
|
46
|
+
const Icon = open ? IconFolderOpen : IconFolder1;
|
|
47
|
+
return <Central icon={Icon} size={TREE} />;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function ExplorerFileIcon({ ext }: { ext?: string }) {
|
|
51
|
+
if (ext === "json") return <Central icon={IconBrackets2} size={TREE} />;
|
|
52
|
+
return <Central icon={IconFileText} size={TREE} />;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function ExplorerRailFilesIcon() {
|
|
56
|
+
return <Central icon={IconFiles} size={RAIL} />;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function ExplorerRailSearchIcon() {
|
|
60
|
+
return <Central icon={IconMagnifyingGlass} size={RAIL} />;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function ExplorerRailSourceControlIcon() {
|
|
64
|
+
return <Central icon={IconGit} size={RAIL} />;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ExplorerRailHistoryIcon() {
|
|
68
|
+
return <Central icon={IconHistory} size={RAIL} />;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function ExplorerRailMediaIcon() {
|
|
72
|
+
return <Central icon={IconImages1} size={RAIL} />;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function ExplorerRailSettingsIcon() {
|
|
76
|
+
return <Central icon={IconSettingsGear2} size={RAIL} />;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function ExplorerHeaderNewFileIcon() {
|
|
80
|
+
return <Central icon={IconPageAdd} size={HEADER} />;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function ExplorerTreeNewFileIcon() {
|
|
84
|
+
return <Central icon={IconPlusMedium} size={TREE_ACTION} />;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function ExplorerHeaderListIcon() {
|
|
88
|
+
return <Central icon={IconListBullets} size={HEADER} />;
|
|
89
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// packages/studio/src/chrome/folder-create-context.ts
|
|
2
|
+
import { contentExtFromPath } from "@backmatter/core";
|
|
3
|
+
import type { ContentTypeNav } from "./content-type-groups";
|
|
4
|
+
import { modelHasLocale } from "./content-type-groups";
|
|
5
|
+
import { treePathFromRepo } from "./content-path-tree";
|
|
6
|
+
import type { PathTreeFolder, PathTreeNode } from "./content-path-tree";
|
|
7
|
+
import { docRepoPath, repoPathBasename } from "./content-repo-path";
|
|
8
|
+
|
|
9
|
+
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
10
|
+
|
|
11
|
+
export type FolderCreateContext = {
|
|
12
|
+
type: string;
|
|
13
|
+
locale: string;
|
|
14
|
+
ext: string;
|
|
15
|
+
/** JSON landing pages need `blocks: []` in frontmatter on create. */
|
|
16
|
+
pageFormat: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function isValidContentSlug(slug: string): boolean {
|
|
20
|
+
return SLUG_RE.test(slug);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Strip optional extension; basename only. */
|
|
24
|
+
export function slugFromNewFileName(name: string, expectedExt: string): string {
|
|
25
|
+
const trimmed = name.trim();
|
|
26
|
+
if (!trimmed) return "";
|
|
27
|
+
const base = repoPathBasename(trimmed);
|
|
28
|
+
const dot = base.lastIndexOf(".");
|
|
29
|
+
if (dot >= 0) {
|
|
30
|
+
const ext = base.slice(dot + 1).toLowerCase();
|
|
31
|
+
if (ext === expectedExt.toLowerCase()) {
|
|
32
|
+
return base.slice(0, dot);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return base;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function folderPathKeyForModel(
|
|
39
|
+
model: ContentTypeNav,
|
|
40
|
+
locale: string | undefined,
|
|
41
|
+
defaultLocale: string,
|
|
42
|
+
): string {
|
|
43
|
+
const loc = modelHasLocale(model.path)
|
|
44
|
+
? locale || defaultLocale
|
|
45
|
+
: undefined;
|
|
46
|
+
const repo = docRepoPath(
|
|
47
|
+
model,
|
|
48
|
+
{ slug: "new", locale: loc ?? "" },
|
|
49
|
+
defaultLocale,
|
|
50
|
+
);
|
|
51
|
+
const tree = treePathFromRepo(repo);
|
|
52
|
+
const parts = tree.split("/").filter(Boolean);
|
|
53
|
+
parts.pop();
|
|
54
|
+
return parts.join("/");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function resolveFolderCreateContext(
|
|
58
|
+
pathKey: string,
|
|
59
|
+
models: ContentTypeNav[],
|
|
60
|
+
locales: string[],
|
|
61
|
+
defaultLocale: string,
|
|
62
|
+
): FolderCreateContext | null {
|
|
63
|
+
const matches: FolderCreateContext[] = [];
|
|
64
|
+
for (const model of models) {
|
|
65
|
+
if (model.singleton) continue;
|
|
66
|
+
const ext = contentExtFromPath(model.path);
|
|
67
|
+
const pageFormat = ext === "json";
|
|
68
|
+
if (modelHasLocale(model.path)) {
|
|
69
|
+
for (const locale of locales) {
|
|
70
|
+
const key = folderPathKeyForModel(model, locale, defaultLocale);
|
|
71
|
+
if (key === pathKey) {
|
|
72
|
+
matches.push({ type: model.type, locale, ext, pageFormat });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
const key = folderPathKeyForModel(model, undefined, defaultLocale);
|
|
77
|
+
if (key === pathKey) {
|
|
78
|
+
matches.push({ type: model.type, locale: "", ext, pageFormat });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (matches.length !== 1) return null;
|
|
83
|
+
return matches[0]!;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function compareName(a: string, b: string) {
|
|
87
|
+
return a.localeCompare(b, undefined, { sensitivity: "base" });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function insertFolderPath(nodes: PathTreeNode[], segments: string[], pathKey: string): PathTreeNode[] {
|
|
91
|
+
if (segments.length === 0) return nodes;
|
|
92
|
+
const [head, ...rest] = segments;
|
|
93
|
+
const name = head!;
|
|
94
|
+
const nextKey = pathKey ? `${pathKey}/${name}` : name;
|
|
95
|
+
const idx = nodes.findIndex(
|
|
96
|
+
(n) => n.kind === "folder" && n.name === name,
|
|
97
|
+
);
|
|
98
|
+
if (idx >= 0) {
|
|
99
|
+
const folder = nodes[idx] as PathTreeFolder;
|
|
100
|
+
const children = insertFolderPath(folder.children, rest, nextKey);
|
|
101
|
+
if (children === folder.children) return nodes;
|
|
102
|
+
const next = [...nodes];
|
|
103
|
+
next[idx] = { ...folder, children };
|
|
104
|
+
return next;
|
|
105
|
+
}
|
|
106
|
+
const child: PathTreeFolder = {
|
|
107
|
+
kind: "folder",
|
|
108
|
+
name,
|
|
109
|
+
pathKey: nextKey,
|
|
110
|
+
children: rest.length
|
|
111
|
+
? insertFolderPath([], rest, nextKey)
|
|
112
|
+
: [],
|
|
113
|
+
};
|
|
114
|
+
const next = [...nodes, child];
|
|
115
|
+
next.sort((a, b) => {
|
|
116
|
+
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
|
|
117
|
+
return compareName(a.name, b.name);
|
|
118
|
+
});
|
|
119
|
+
return next;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Ensure each model's content directory exists as a folder (even with no files yet). */
|
|
123
|
+
export function withModelFolderPlaceholders(
|
|
124
|
+
tree: PathTreeNode[],
|
|
125
|
+
models: ContentTypeNav[],
|
|
126
|
+
locales: string[],
|
|
127
|
+
defaultLocale: string,
|
|
128
|
+
): PathTreeNode[] {
|
|
129
|
+
const keys = new Set<string>();
|
|
130
|
+
for (const model of models) {
|
|
131
|
+
if (model.singleton) continue;
|
|
132
|
+
if (modelHasLocale(model.path)) {
|
|
133
|
+
for (const locale of locales) {
|
|
134
|
+
keys.add(folderPathKeyForModel(model, locale, defaultLocale));
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
keys.add(folderPathKeyForModel(model, undefined, defaultLocale));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
let out = tree;
|
|
141
|
+
for (const key of [...keys].sort(compareName)) {
|
|
142
|
+
if (!key) continue;
|
|
143
|
+
const segments = key.split("/").filter(Boolean);
|
|
144
|
+
out = insertFolderPath(out, segments, "");
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
@@ -9,6 +9,7 @@ import { Button } from "../ui/Button";
|
|
|
9
9
|
import { Field } from "../ui/Field";
|
|
10
10
|
import { Input, Textarea } from "../ui/Input";
|
|
11
11
|
import { cx, isTransitioning } from "../ui/popup";
|
|
12
|
+
import { MediaFieldInput } from "./MediaFieldInput";
|
|
12
13
|
|
|
13
14
|
function fieldLabel(name: string, field: SchemaField) {
|
|
14
15
|
if ("label" in field && typeof field.label === "string" && field.label) return field.label;
|
|
@@ -155,6 +156,9 @@ function FieldControl({
|
|
|
155
156
|
<CustomInput name={name} field={field} value={value} readOnly={readOnly} onChange={onChange} />
|
|
156
157
|
);
|
|
157
158
|
}
|
|
159
|
+
if (field.kind === "media") {
|
|
160
|
+
return <MediaFieldInput name={name} value={value} readOnly={readOnly} onChange={onChange} />;
|
|
161
|
+
}
|
|
158
162
|
if (field.kind === "boolean") {
|
|
159
163
|
return (
|
|
160
164
|
<input
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// packages/studio/src/pages/CliAuthorizePage.tsx
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { useSearchParams } from "react-router";
|
|
4
|
+
import { approveCliLogin } from "../api";
|
|
5
|
+
import { Button } from "../ui/Button";
|
|
6
|
+
import { Stack } from "../ui/Page";
|
|
7
|
+
import { Text } from "../ui/Text";
|
|
8
|
+
|
|
9
|
+
export function CliAuthorizePage() {
|
|
10
|
+
const [params] = useSearchParams();
|
|
11
|
+
const id = params.get("id") ?? "";
|
|
12
|
+
const [status, setStatus] = useState<"idle" | "done" | "error">("idle");
|
|
13
|
+
const [error, setError] = useState<string | null>(null);
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<Stack gap="lg">
|
|
17
|
+
<Text as="h1" size="xl" weight="medium">
|
|
18
|
+
Allow the CLI
|
|
19
|
+
</Text>
|
|
20
|
+
<Text tone="muted">
|
|
21
|
+
This connects the Backmatter CLI on your computer to your account.
|
|
22
|
+
</Text>
|
|
23
|
+
{status === "done" ? <Text>You can return to the terminal.</Text> : null}
|
|
24
|
+
{error ? <Text tone="danger">{error}</Text> : null}
|
|
25
|
+
{status === "done" ? null : (
|
|
26
|
+
<Button
|
|
27
|
+
disabled={!id}
|
|
28
|
+
onClick={() => {
|
|
29
|
+
setError(null);
|
|
30
|
+
void approveCliLogin(id)
|
|
31
|
+
.then(() => setStatus("done"))
|
|
32
|
+
.catch((err: unknown) => {
|
|
33
|
+
setStatus("error");
|
|
34
|
+
setError(err instanceof Error ? err.message : "Could not approve");
|
|
35
|
+
});
|
|
36
|
+
}}
|
|
37
|
+
>
|
|
38
|
+
Allow
|
|
39
|
+
</Button>
|
|
40
|
+
)}
|
|
41
|
+
</Stack>
|
|
42
|
+
);
|
|
43
|
+
}
|