@gogitcms/design-system 0.16.0-next.0 → 0.16.0-next.10
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/package.json +1 -1
- package/src/__tests__/ApplyChangesModal.test.tsx +48 -0
- package/src/__tests__/BranchDeletedModal.test.tsx +42 -0
- package/src/__tests__/ContentBrowser.fieldfocus.test.tsx +109 -0
- package/src/__tests__/ContentBrowser.forms.test.tsx +157 -0
- package/src/__tests__/ContentBrowser.history.test.tsx +385 -0
- package/src/__tests__/ContentBrowser.historycollab.test.tsx +433 -0
- package/src/__tests__/ContentBrowser.slotmedia.test.tsx +150 -0
- package/src/__tests__/FormsBrowser.test.tsx +7 -2
- package/src/__tests__/MediaField.test.tsx +18 -0
- package/src/__tests__/ProtectedBranchModal.test.tsx +68 -0
- package/src/components/ApplyChangesModal.tsx +70 -21
- package/src/components/BranchDeletedModal.tsx +111 -0
- package/src/components/ChangeDetail.tsx +94 -11
- package/src/components/CollabField.tsx +71 -0
- package/src/components/ContentBrowser.tsx +440 -71
- package/src/components/DocumentHistory.tsx +408 -0
- package/src/components/FormsBrowser.tsx +3 -5
- package/src/components/MediaField.tsx +62 -0
- package/src/components/MediaPreview.tsx +14 -1
- package/src/components/MediaPreview.web.tsx +18 -3
- package/src/components/Notifications.tsx +146 -28
- package/src/components/ProtectedBranchModal.tsx +136 -0
- package/src/components/primitives.tsx +46 -1
- package/src/history.ts +82 -0
- package/src/icons.ts +1 -0
- package/src/index.ts +13 -3
- package/src/media.ts +15 -0
|
@@ -4,10 +4,11 @@ import { useTheme, useThemeMode, ThemeScope } from "../ThemeProvider";
|
|
|
4
4
|
import { lightTheme, darkTheme } from "../theme";
|
|
5
5
|
import { Text } from "./Text";
|
|
6
6
|
import { Icon } from "./Icon";
|
|
7
|
+
import type { IconName } from "../icons";
|
|
7
8
|
import { Spinner } from "./Spinner";
|
|
8
9
|
|
|
9
10
|
// One notification, shaped for display. Transport-agnostic: the frontend/mobile
|
|
10
|
-
// apps map GraphQL rows and the desktop
|
|
11
|
+
// apps map GraphQL rows and the desktop and dashboard map REST rows into this.
|
|
11
12
|
export type NotificationItem = {
|
|
12
13
|
id: string;
|
|
13
14
|
title: string;
|
|
@@ -16,8 +17,39 @@ export type NotificationItem = {
|
|
|
16
17
|
read: boolean;
|
|
17
18
|
createdAt: string; // ISO 8601
|
|
18
19
|
author?: string; // display name/email of who caused it, when known
|
|
20
|
+
// What produced it — picks the icon. Unknown kinds fall back to the bell.
|
|
21
|
+
kind?: NotificationKind | string;
|
|
22
|
+
// Where it happened, for a list that spans several repositories/projects.
|
|
23
|
+
// Omit whichever the list is already scoped to.
|
|
24
|
+
repository?: string;
|
|
25
|
+
project?: string;
|
|
19
26
|
};
|
|
20
27
|
|
|
28
|
+
export type NotificationKind = "import" | "export" | "merge" | "change_request" | "member" | "branch" | "project";
|
|
29
|
+
|
|
30
|
+
// The icon for a notification kind. Exported so hosts that draw their own rows
|
|
31
|
+
// (a scope picker's section headers, say) agree with the list.
|
|
32
|
+
export function notificationKindIcon(kind?: string): IconName {
|
|
33
|
+
switch (kind) {
|
|
34
|
+
case "import":
|
|
35
|
+
return "download";
|
|
36
|
+
case "export":
|
|
37
|
+
return "upload";
|
|
38
|
+
case "merge":
|
|
39
|
+
return "gitMerge";
|
|
40
|
+
case "change_request":
|
|
41
|
+
return "gitPullRequest";
|
|
42
|
+
case "member":
|
|
43
|
+
return "user";
|
|
44
|
+
case "branch":
|
|
45
|
+
return "gitBranch";
|
|
46
|
+
case "project":
|
|
47
|
+
return "folder";
|
|
48
|
+
default:
|
|
49
|
+
return "bell";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
21
53
|
// relativeTime renders a compact age ("just now", "5m", "3h", "2d").
|
|
22
54
|
function relativeTime(iso: string): string {
|
|
23
55
|
const then = Date.parse(iso);
|
|
@@ -32,7 +64,65 @@ function relativeTime(iso: string): string {
|
|
|
32
64
|
return `${days}d`;
|
|
33
65
|
}
|
|
34
66
|
|
|
35
|
-
//
|
|
67
|
+
// InlineCode renders a sentence whose backticked spans (`main`, `#12`'s
|
|
68
|
+
// branch) are set in the mono face, so a title like "Created `zyx` branch"
|
|
69
|
+
// reads as prose around a name rather than as punctuation. An unmatched
|
|
70
|
+
// backtick is left as typed.
|
|
71
|
+
export function InlineCode({
|
|
72
|
+
text,
|
|
73
|
+
variant = "sm",
|
|
74
|
+
weight,
|
|
75
|
+
color,
|
|
76
|
+
numberOfLines,
|
|
77
|
+
testID,
|
|
78
|
+
}: {
|
|
79
|
+
text: string;
|
|
80
|
+
variant?: "sm" | "body" | "monoSm" | "xs";
|
|
81
|
+
weight?: "regular" | "medium" | "semibold";
|
|
82
|
+
color?: string;
|
|
83
|
+
numberOfLines?: number;
|
|
84
|
+
testID?: string;
|
|
85
|
+
}) {
|
|
86
|
+
const t = useTheme();
|
|
87
|
+
const parts = text.split("`");
|
|
88
|
+
// An even number of parts means an odd number of backticks: not a pair, so
|
|
89
|
+
// render the text untouched.
|
|
90
|
+
if (parts.length % 2 === 0) {
|
|
91
|
+
return (
|
|
92
|
+
<Text variant={variant} weight={weight} color={color} numberOfLines={numberOfLines} testID={testID}>
|
|
93
|
+
{text}
|
|
94
|
+
</Text>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return (
|
|
98
|
+
<Text variant={variant} weight={weight} color={color} numberOfLines={numberOfLines} testID={testID}>
|
|
99
|
+
{parts.map((part, i) =>
|
|
100
|
+
i % 2 === 1 ? (
|
|
101
|
+
<Text
|
|
102
|
+
key={i}
|
|
103
|
+
variant={variant}
|
|
104
|
+
weight={weight}
|
|
105
|
+
color={color}
|
|
106
|
+
mono
|
|
107
|
+
style={{
|
|
108
|
+
backgroundColor: t.color.surfaceSunken,
|
|
109
|
+
borderRadius: t.radius.sm,
|
|
110
|
+
paddingHorizontal: 3,
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
{part}
|
|
114
|
+
</Text>
|
|
115
|
+
) : (
|
|
116
|
+
part
|
|
117
|
+
),
|
|
118
|
+
)}
|
|
119
|
+
</Text>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A single notification row: a kind icon, the title (unread rows in bold, error
|
|
124
|
+
// rows tinted), the body, and a context line — where it happened and who did
|
|
125
|
+
// it — with the age on the right.
|
|
36
126
|
function NotificationRow({
|
|
37
127
|
item,
|
|
38
128
|
divided,
|
|
@@ -44,12 +134,16 @@ function NotificationRow({
|
|
|
44
134
|
}) {
|
|
45
135
|
const t = useTheme();
|
|
46
136
|
const titleColor = item.level === "error" ? t.color.diffDelFg : t.color.textPrimary;
|
|
137
|
+
const context = [item.repository, item.project].filter(Boolean).join(" · ");
|
|
138
|
+
const meta = [context, item.author ? `by ${item.author}` : ""].filter(Boolean).join(" · ");
|
|
47
139
|
return (
|
|
48
140
|
<Pressable
|
|
49
141
|
onPress={onPress}
|
|
142
|
+
disabled={!onPress}
|
|
143
|
+
testID={`notification-${item.id}`}
|
|
50
144
|
style={({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => ({
|
|
51
145
|
flexDirection: "row",
|
|
52
|
-
gap: t.space(
|
|
146
|
+
gap: t.space(3),
|
|
53
147
|
paddingHorizontal: t.space(4),
|
|
54
148
|
paddingVertical: t.space(3),
|
|
55
149
|
borderBottomWidth: divided ? 1 : 0,
|
|
@@ -57,33 +151,57 @@ function NotificationRow({
|
|
|
57
151
|
backgroundColor: hovered || pressed ? t.color.surfaceHover : "transparent",
|
|
58
152
|
})}
|
|
59
153
|
>
|
|
60
|
-
<View
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
color={
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
154
|
+
<View style={{ width: 28, height: 28, alignItems: "center", justifyContent: "center", position: "relative" }}>
|
|
155
|
+
<View
|
|
156
|
+
style={{
|
|
157
|
+
width: 28,
|
|
158
|
+
height: 28,
|
|
159
|
+
borderRadius: t.radius.pill,
|
|
160
|
+
alignItems: "center",
|
|
161
|
+
justifyContent: "center",
|
|
162
|
+
backgroundColor: item.level === "error" ? t.color.diffDelBg : t.color.surfaceSunken,
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
<Icon
|
|
166
|
+
name={notificationKindIcon(item.kind)}
|
|
167
|
+
size={14}
|
|
168
|
+
color={item.level === "error" ? t.color.diffDelFg : t.color.textSecondary}
|
|
169
|
+
/>
|
|
170
|
+
</View>
|
|
171
|
+
{/* Unread dot, riding on the icon's corner. */}
|
|
172
|
+
{item.read ? null : (
|
|
173
|
+
<View
|
|
174
|
+
testID="notification-unread"
|
|
175
|
+
style={{
|
|
176
|
+
position: "absolute",
|
|
177
|
+
top: -1,
|
|
178
|
+
right: -1,
|
|
179
|
+
width: 8,
|
|
180
|
+
height: 8,
|
|
181
|
+
borderRadius: t.radius.pill,
|
|
182
|
+
backgroundColor: t.color.diffDelFg,
|
|
183
|
+
borderWidth: 1,
|
|
184
|
+
borderColor: t.color.surfaceRaised,
|
|
185
|
+
}}
|
|
186
|
+
/>
|
|
187
|
+
)}
|
|
188
|
+
</View>
|
|
189
|
+
<View style={{ flex: 1, gap: 2, minWidth: 0 }}>
|
|
190
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: t.space(2) }}>
|
|
191
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
192
|
+
<InlineCode
|
|
193
|
+
text={item.title}
|
|
194
|
+
variant="sm"
|
|
195
|
+
weight={item.read ? "medium" : "semibold"}
|
|
196
|
+
color={titleColor}
|
|
197
|
+
numberOfLines={2}
|
|
198
|
+
/>
|
|
199
|
+
</View>
|
|
80
200
|
<Text variant="monoSm" color="tertiary">{relativeTime(item.createdAt)}</Text>
|
|
81
201
|
</View>
|
|
82
|
-
{item.body ?
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
{item.author ? (
|
|
86
|
-
<Text variant="monoSm" color="tertiary" numberOfLines={1}>{`by ${item.author}`}</Text>
|
|
202
|
+
{item.body ? <InlineCode text={item.body} variant="monoSm" color={t.color.textSecondary} numberOfLines={2} /> : null}
|
|
203
|
+
{meta ? (
|
|
204
|
+
<Text variant="monoSm" color="tertiary" numberOfLines={1}>{meta}</Text>
|
|
87
205
|
) : null}
|
|
88
206
|
</View>
|
|
89
207
|
</Pressable>
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { View, Pressable } from "react-native";
|
|
3
|
+
import { useTheme } from "../ThemeProvider";
|
|
4
|
+
import { Text } from "./Text";
|
|
5
|
+
import { Icon } from "./Icon";
|
|
6
|
+
import { Input } from "./Input";
|
|
7
|
+
import { Button } from "./Button";
|
|
8
|
+
|
|
9
|
+
export type ProtectedBranchModalProps = {
|
|
10
|
+
/** The protected branch the save was refused on. */
|
|
11
|
+
branch: string;
|
|
12
|
+
/** The document that could not be saved, for naming what is at stake. */
|
|
13
|
+
documentLabel?: string;
|
|
14
|
+
/** Prefills the field — the host derives it from the document. */
|
|
15
|
+
suggestedName?: string;
|
|
16
|
+
/** True while the branch is being created and the save applied. */
|
|
17
|
+
busy?: boolean;
|
|
18
|
+
/** The server's message, when the attempt failed. */
|
|
19
|
+
error?: string | null;
|
|
20
|
+
/** Create `name` from `branch` and save the pending edit onto it. */
|
|
21
|
+
onCreate: (name: string) => void;
|
|
22
|
+
/** Dismiss. The edit stays as an unsaved draft on the protected branch. */
|
|
23
|
+
onCancel: () => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Shown when a save is refused because the branch is protected.
|
|
28
|
+
*
|
|
29
|
+
* It is a prompt rather than an error banner because the refusal has exactly one
|
|
30
|
+
* remedy and the editor knows what it is: the work is intact, it just needs
|
|
31
|
+
* somewhere it can land. Presenting that as "here is a message, now go find the
|
|
32
|
+
* branch menu" would make the author reconstruct a plan the product already has.
|
|
33
|
+
*
|
|
34
|
+
* The draft is deliberately NOT discarded on cancel — a protected branch is
|
|
35
|
+
* often noticed mid-thought, and the author may want to keep writing and pick a
|
|
36
|
+
* branch name later. It stays in storage exactly as any other unsaved work does.
|
|
37
|
+
*/
|
|
38
|
+
export function ProtectedBranchModal({
|
|
39
|
+
branch, documentLabel, suggestedName, busy, error, onCreate, onCancel,
|
|
40
|
+
}: ProtectedBranchModalProps) {
|
|
41
|
+
const t = useTheme();
|
|
42
|
+
const [name, setName] = useState(suggestedName ?? "");
|
|
43
|
+
// The suggestion is derived from the document, which the host may resolve a
|
|
44
|
+
// render after the modal opens. Adopted only while the field is untouched.
|
|
45
|
+
const [touched, setTouched] = useState(false);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!touched && suggestedName) setName(suggestedName);
|
|
48
|
+
}, [suggestedName, touched]);
|
|
49
|
+
|
|
50
|
+
const trimmed = name.trim();
|
|
51
|
+
const submit = () => {
|
|
52
|
+
if (!trimmed || busy) return;
|
|
53
|
+
onCreate(trimmed);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<View
|
|
58
|
+
testID="protected-branch-modal"
|
|
59
|
+
style={{
|
|
60
|
+
position: "absolute", top: 0, left: 0, right: 0, bottom: 0, zIndex: 50,
|
|
61
|
+
alignItems: "center", justifyContent: "center", padding: t.space(4),
|
|
62
|
+
backgroundColor: "rgba(0,0,0,0.45)",
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<View
|
|
66
|
+
style={{
|
|
67
|
+
width: 460, maxWidth: "100%", gap: t.space(4), padding: t.space(5),
|
|
68
|
+
borderRadius: t.radius.lg, borderWidth: 1, borderColor: t.color.borderDefault,
|
|
69
|
+
backgroundColor: t.color.surfaceRaised,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
|
|
73
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
74
|
+
<Icon name="lock" size={16} color={t.color.textSecondary} />
|
|
75
|
+
<Text variant="h3" weight="semibold">This branch is protected</Text>
|
|
76
|
+
</View>
|
|
77
|
+
{busy ? null : (
|
|
78
|
+
<Pressable
|
|
79
|
+
testID="protected-branch-close"
|
|
80
|
+
accessibilityRole="button"
|
|
81
|
+
accessibilityLabel="Cancel"
|
|
82
|
+
onPress={onCancel}
|
|
83
|
+
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
|
84
|
+
>
|
|
85
|
+
<Icon name="x" size={18} color={t.color.textSecondary} />
|
|
86
|
+
</Pressable>
|
|
87
|
+
)}
|
|
88
|
+
</View>
|
|
89
|
+
|
|
90
|
+
<Text variant="body" color="secondary">
|
|
91
|
+
{documentLabel
|
|
92
|
+
? `${branch} doesn’t accept changes directly, so “${documentLabel}” can’t be saved to it. Name a branch to save it to instead — your edit lands there.`
|
|
93
|
+
: `${branch} doesn’t accept changes directly. Name a branch to save your edit to instead.`}
|
|
94
|
+
</Text>
|
|
95
|
+
|
|
96
|
+
<Input
|
|
97
|
+
label="New branch"
|
|
98
|
+
value={name}
|
|
99
|
+
onChangeText={(v) => { setTouched(true); setName(v); }}
|
|
100
|
+
onSubmitEditing={submit}
|
|
101
|
+
placeholder="feat/my-change"
|
|
102
|
+
autoCapitalize="none"
|
|
103
|
+
autoFocus
|
|
104
|
+
mono
|
|
105
|
+
editable={!busy}
|
|
106
|
+
error={!!error}
|
|
107
|
+
testID="protected-branch-name"
|
|
108
|
+
/>
|
|
109
|
+
<Text variant="monoSm" color="tertiary">{`Branched from ${branch}`}</Text>
|
|
110
|
+
|
|
111
|
+
{error ? (
|
|
112
|
+
<Text testID="protected-branch-error" variant="monoSm" color={t.color.diffDelFg}>{error}</Text>
|
|
113
|
+
) : null}
|
|
114
|
+
|
|
115
|
+
<View style={{ flexDirection: "row", justifyContent: "flex-end", gap: t.space(2) }}>
|
|
116
|
+
<Button
|
|
117
|
+
title="Cancel"
|
|
118
|
+
variant="ghost"
|
|
119
|
+
size="md"
|
|
120
|
+
disabled={busy}
|
|
121
|
+
onPress={onCancel}
|
|
122
|
+
testID="protected-branch-cancel"
|
|
123
|
+
/>
|
|
124
|
+
<Button
|
|
125
|
+
title={busy ? "Creating…" : "Create branch and save"}
|
|
126
|
+
variant="primary"
|
|
127
|
+
size="md"
|
|
128
|
+
disabled={busy || trimmed === ""}
|
|
129
|
+
onPress={submit}
|
|
130
|
+
testID="protected-branch-submit"
|
|
131
|
+
/>
|
|
132
|
+
</View>
|
|
133
|
+
</View>
|
|
134
|
+
</View>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { View, StyleProp, ViewStyle } from "react-native";
|
|
2
|
+
import { View, Pressable, StyleProp, ViewStyle } from "react-native";
|
|
3
3
|
import { useTheme } from "../ThemeProvider";
|
|
4
4
|
import { Text } from "./Text";
|
|
5
|
+
import { Icon } from "./Icon";
|
|
5
6
|
|
|
6
7
|
/** Bordered surface card — defined by a hairline border, never a shadow. */
|
|
7
8
|
export function Card({
|
|
@@ -141,3 +142,47 @@ export function SectionLabel({ children, style }: { children: React.ReactNode; s
|
|
|
141
142
|
</View>
|
|
142
143
|
);
|
|
143
144
|
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A square check box: filled and ticked when on, an empty hairline box when
|
|
148
|
+
* off.
|
|
149
|
+
*
|
|
150
|
+
* Lives here rather than beside its first caller because two surfaces select
|
|
151
|
+
* things now — entry rows in the browser, and the fields of a past version in
|
|
152
|
+
* the history diff — and they have to look like the same control. The press
|
|
153
|
+
* stops propagating: every current caller puts this inside a larger Pressable
|
|
154
|
+
* (a list row, a field block), and a tick that also opened the row would be
|
|
155
|
+
* unusable.
|
|
156
|
+
*/
|
|
157
|
+
export function CheckBox({
|
|
158
|
+
value,
|
|
159
|
+
onToggle,
|
|
160
|
+
testID = "check-box",
|
|
161
|
+
}: {
|
|
162
|
+
value: boolean;
|
|
163
|
+
onToggle: () => void;
|
|
164
|
+
testID?: string;
|
|
165
|
+
}) {
|
|
166
|
+
const t = useTheme();
|
|
167
|
+
return (
|
|
168
|
+
<Pressable
|
|
169
|
+
onPress={(e?: { stopPropagation?: () => void }) => {
|
|
170
|
+
e?.stopPropagation?.();
|
|
171
|
+
onToggle();
|
|
172
|
+
}}
|
|
173
|
+
testID={testID}
|
|
174
|
+
style={{
|
|
175
|
+
width: 18,
|
|
176
|
+
height: 18,
|
|
177
|
+
borderRadius: t.radius.sm,
|
|
178
|
+
borderWidth: 1,
|
|
179
|
+
borderColor: value ? t.color.borderStrong : t.color.borderDefault,
|
|
180
|
+
backgroundColor: value ? t.color.surfaceInverted : t.color.surfaceRaised,
|
|
181
|
+
alignItems: "center",
|
|
182
|
+
justifyContent: "center",
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
{value ? <Icon name="check" size={12} color={t.color.textInverted} /> : null}
|
|
186
|
+
</Pressable>
|
|
187
|
+
);
|
|
188
|
+
}
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { DocumentChange } from "./components/ChangeDetail";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One commit in a document's history — a row in the version list.
|
|
5
|
+
*
|
|
6
|
+
* This is the provider's history for the document's CURRENT path. GitHub's
|
|
7
|
+
* commits API has no equivalent of `git log --follow`, so a renamed document's
|
|
8
|
+
* history stops at the rename; the list says so at its end rather than letting
|
|
9
|
+
* the absence read as "this is where the document began".
|
|
10
|
+
*/
|
|
11
|
+
export type DocumentVersion = {
|
|
12
|
+
sha: string;
|
|
13
|
+
/** First 7 characters — an identity without the noise. */
|
|
14
|
+
shortSha: string;
|
|
15
|
+
/** The full commit message; the list shows its first line. */
|
|
16
|
+
message: string;
|
|
17
|
+
authorName: string;
|
|
18
|
+
authorEmail?: string | null;
|
|
19
|
+
/** ISO 8601, in UTC. */
|
|
20
|
+
authoredAt: string;
|
|
21
|
+
/** The commit's page on the provider. */
|
|
22
|
+
url: string;
|
|
23
|
+
/** How many files the commit touched — this document is one of them. */
|
|
24
|
+
filesChanged: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* HistoryApi is the data seam between the design system and its host, the same
|
|
29
|
+
* split MediaApi and FormsApi use: the DS owns the version browser's UI and its
|
|
30
|
+
* (ephemeral) open/selected state, the host owns fetching. Omitting it from
|
|
31
|
+
* ContentBrowser hides the history affordance entirely, which is what a
|
|
32
|
+
* deployment with no provider — local mode, the desktop app — should look like.
|
|
33
|
+
*/
|
|
34
|
+
export interface HistoryApi {
|
|
35
|
+
/**
|
|
36
|
+
* One page of a document's versions, newest first. The DS pages as the list
|
|
37
|
+
* scrolls; a short page (fewer than `limit`) ends it.
|
|
38
|
+
*/
|
|
39
|
+
list: (params: { documentId: string; limit: number; offset: number }) => Promise<DocumentVersion[]>;
|
|
40
|
+
/**
|
|
41
|
+
* The document as it was at one commit, diffed against what it is now:
|
|
42
|
+
* `before` on each field is the value at that version, `after` its current
|
|
43
|
+
* value. Unchanged fields are included, which is what makes the result a
|
|
44
|
+
* readable preview of the whole document rather than a list of differences.
|
|
45
|
+
*
|
|
46
|
+
* Null when the version can't be read (a commit that has gone away under a
|
|
47
|
+
* force push, say) — the pane says so instead of showing an empty document.
|
|
48
|
+
*/
|
|
49
|
+
get: (params: { documentId: string; sha: string }) => Promise<DocumentChange | null>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* firstLine is the summary a version row shows. A commit message's first line is
|
|
54
|
+
* its subject by convention, and the body below it is prose that would push
|
|
55
|
+
* every other row off the screen.
|
|
56
|
+
*/
|
|
57
|
+
export function firstLine(message: string): string {
|
|
58
|
+
const line = message.split("\n", 1)[0].trim();
|
|
59
|
+
return line || "(no commit message)";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* relativeTime renders a commit's age the way a history reads — "3d ago" —
|
|
64
|
+
* falling back to the date once "ago" stops being informative.
|
|
65
|
+
*
|
|
66
|
+
* `now` is injectable so a test can assert a fixed string rather than race the
|
|
67
|
+
* clock.
|
|
68
|
+
*/
|
|
69
|
+
export function relativeTime(iso: string, now: Date = new Date()): string {
|
|
70
|
+
const then = new Date(iso);
|
|
71
|
+
const seconds = Math.round((now.getTime() - then.getTime()) / 1000);
|
|
72
|
+
if (!Number.isFinite(seconds)) return "";
|
|
73
|
+
if (seconds < 60) return "just now";
|
|
74
|
+
const minutes = Math.round(seconds / 60);
|
|
75
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
76
|
+
const hours = Math.round(minutes / 60);
|
|
77
|
+
if (hours < 24) return `${hours}h ago`;
|
|
78
|
+
const days = Math.round(hours / 24);
|
|
79
|
+
if (days < 30) return `${days}d ago`;
|
|
80
|
+
// Past a month, "5 weeks ago" is harder to place than the date itself.
|
|
81
|
+
return then.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
|
82
|
+
}
|
package/src/icons.ts
CHANGED
|
@@ -28,6 +28,7 @@ export const icons = {
|
|
|
28
28
|
chevronDown: ["m6 9 6 6 6-6"],
|
|
29
29
|
chevronUp: ["m18 15-6-6-6 6"],
|
|
30
30
|
folder: ["M3 6a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"],
|
|
31
|
+
user: ["M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2", "M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z"],
|
|
31
32
|
building: ["M3 21h18", "M6 21V7l7-4v18", "M18 21V11l-5-3"],
|
|
32
33
|
menu: ["M3 6h18", "M3 12h18", "M3 18h18"],
|
|
33
34
|
plus: ["M5 12h14", "M12 5v14"],
|
package/src/index.ts
CHANGED
|
@@ -55,6 +55,10 @@ export type {
|
|
|
55
55
|
} from "./components/ApplyChangesModal";
|
|
56
56
|
|
|
57
57
|
// Change-request summary surface (developer escalation)
|
|
58
|
+
export { ProtectedBranchModal } from "./components/ProtectedBranchModal";
|
|
59
|
+
export type { ProtectedBranchModalProps } from "./components/ProtectedBranchModal";
|
|
60
|
+
export { BranchDeletedModal } from "./components/BranchDeletedModal";
|
|
61
|
+
export type { BranchDeletedModalProps } from "./components/BranchDeletedModal";
|
|
58
62
|
export { ChangeRequestSummary } from "./components/ChangeRequestSummary";
|
|
59
63
|
export type {
|
|
60
64
|
ChangeRequestSummaryProps,
|
|
@@ -63,7 +67,11 @@ export type {
|
|
|
63
67
|
} from "./components/ChangeRequestSummary";
|
|
64
68
|
|
|
65
69
|
// Changes surface: one document's diff between a branch and its base
|
|
66
|
-
export { ChangeDetail } from "./components/ChangeDetail";
|
|
70
|
+
export { ChangeDetail, BODY_FIELD } from "./components/ChangeDetail";
|
|
71
|
+
export { DocumentHistory } from "./components/DocumentHistory";
|
|
72
|
+
export type { DocumentHistoryProps } from "./components/DocumentHistory";
|
|
73
|
+
export { firstLine, relativeTime } from "./history";
|
|
74
|
+
export type { DocumentVersion, HistoryApi } from "./history";
|
|
67
75
|
export type {
|
|
68
76
|
ChangeDetailProps,
|
|
69
77
|
DocumentChange,
|
|
@@ -109,9 +117,10 @@ export { Spinner } from "./components/Spinner";
|
|
|
109
117
|
export type { SpinnerProps } from "./components/Spinner";
|
|
110
118
|
|
|
111
119
|
// Notifications
|
|
112
|
-
export { NotificationBell, NotificationList } from "./components/Notifications";
|
|
120
|
+
export { NotificationBell, NotificationList, InlineCode, notificationKindIcon } from "./components/Notifications";
|
|
113
121
|
export type {
|
|
114
122
|
NotificationItem,
|
|
123
|
+
NotificationKind,
|
|
115
124
|
NotificationBellProps,
|
|
116
125
|
NotificationListProps,
|
|
117
126
|
} from "./components/Notifications";
|
|
@@ -127,7 +136,7 @@ export {
|
|
|
127
136
|
} from "./components/layout";
|
|
128
137
|
|
|
129
138
|
// Media: the picker control, its previews, and the host data seam
|
|
130
|
-
export { MediaField, MediaPicker, MediaProvider, DocumentPathProvider, useMediaApi, pathForStoreAs } from "./components/MediaField";
|
|
139
|
+
export { MediaField, MediaPicker, MediaProvider, DocumentPathProvider, useMediaApi, useFieldMedia, pathForStoreAs } from "./components/MediaField";
|
|
131
140
|
export type { MediaFieldProps, MediaPickerProps } from "./components/MediaField";
|
|
132
141
|
export { MediaPreview, MediaChip } from "./components/MediaPreview";
|
|
133
142
|
export type { MediaPreviewProps } from "./components/MediaPreview";
|
|
@@ -155,6 +164,7 @@ export type {
|
|
|
155
164
|
MediaResolution,
|
|
156
165
|
MediaSetInfo,
|
|
157
166
|
MediaApi,
|
|
167
|
+
FieldMediaApi,
|
|
158
168
|
MediaCondition,
|
|
159
169
|
MediaFilterField,
|
|
160
170
|
MediaFilterOp,
|
package/src/media.ts
CHANGED
|
@@ -166,6 +166,21 @@ export interface MediaApi {
|
|
|
166
166
|
}) => Promise<MediaAsset>;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
// FieldMediaApi is the slice of media access a host-supplied field control
|
|
170
|
+
// (the markdown body editor, a plugin field) gets through the renderField slot.
|
|
171
|
+
// It is scoped to one field: the picker opens on that field's set and writes
|
|
172
|
+
// paths in its store_as form, and resolution is relative to the open document.
|
|
173
|
+
// Absent from the slot args when the host has no media wiring.
|
|
174
|
+
export interface FieldMediaApi {
|
|
175
|
+
// Opens the picker. Resolves with the chosen asset plus the string to write
|
|
176
|
+
// for it (in the field's store_as form), or null when the picker is dismissed.
|
|
177
|
+
pick: (opts?: { kinds?: MediaKind[] }) => Promise<{ asset: MediaAsset; path: string } | null>;
|
|
178
|
+
// Resolves reference strings as they appear in the document (an image's src,
|
|
179
|
+
// a frontmatter path) to assets, so a control can show the real file behind a
|
|
180
|
+
// path the CMS origin does not serve.
|
|
181
|
+
resolve: (paths: string[]) => Promise<MediaResolution[]>;
|
|
182
|
+
}
|
|
183
|
+
|
|
169
184
|
// MediaKind is the render class a preview switches on. It is derived server-side
|
|
170
185
|
// on import and delivered on every Media object, so clients never re-derive it
|
|
171
186
|
// from a filename (and never disagree with the server about it).
|