@vespercms/admin 0.1.0-next.0 → 0.1.0-next.1
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/README.md +1 -1
- package/dist/{BodyEditor-CAGNfofv.js → BodyEditor-_mkh0BSQ.js} +735 -735
- package/dist/{markdown-KUkv11EB.js → markdown-4fVeej3c.js} +14 -14
- package/dist/schedule/times.d.ts +1 -3
- package/dist/screens/EntryList.d.ts +0 -2
- package/dist/screens/status.d.ts +36 -0
- package/dist/{shortcuts-B_JMAvF9.js → shortcuts-CarKeDqp.js} +534 -498
- package/dist/studio.css +1 -1
- package/dist/studio.js +1873 -1736
- package/dist/widgets/blank-date.d.ts +8 -0
- package/package.json +2 -2
- package/src/github/GitHubGate.tsx +47 -10
- package/src/schedule/ScheduleDialog.tsx +3 -0
- package/src/schedule/times.ts +0 -11
- package/src/screens/EntryEditor.tsx +23 -9
- package/src/screens/EntryList.tsx +24 -26
- package/src/screens/ListPane.tsx +48 -30
- package/src/screens/PageTree.tsx +45 -25
- package/src/screens/status.tsx +104 -0
- package/src/studio.css +5 -0
- package/src/widgets/basic.tsx +5 -1
- package/src/widgets/blank-date.ts +32 -0
- package/src/widgets/special.tsx +5 -1
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type FocusEvent, type KeyboardEvent } from 'react';
|
|
2
|
+
/** Props for a `date` or `datetime-local` input whose value is `value`. */
|
|
3
|
+
export declare function useBlankWhenEmpty(value: string): {
|
|
4
|
+
'data-blank': string | undefined;
|
|
5
|
+
onPointerDown: () => void;
|
|
6
|
+
onKeyDown: (event: KeyboardEvent) => void;
|
|
7
|
+
onBlur: (event: FocusEvent<HTMLInputElement>) => void;
|
|
8
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vespercms/admin",
|
|
3
|
-
"version": "0.1.0-next.
|
|
3
|
+
"version": "0.1.0-next.1",
|
|
4
4
|
"description": "The Vesper studio: the editor UI served at /admin, one bundle for every storage mode.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://vespercms.com",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"@tiptap/pm": "^3.32.0",
|
|
46
46
|
"@tiptap/react": "^3.32.0",
|
|
47
47
|
"@tiptap/starter-kit": "^3.32.0",
|
|
48
|
-
"@vespercms/core": "0.1.0-next.
|
|
48
|
+
"@vespercms/core": "0.1.0-next.1",
|
|
49
49
|
"dompurify": "^3.4.17",
|
|
50
50
|
"maplibre-gl": "^6.14.0",
|
|
51
51
|
"pdfjs-dist": "^6.4.299",
|
|
@@ -29,6 +29,8 @@ type State =
|
|
|
29
29
|
| { kind: 'checking' }
|
|
30
30
|
| { kind: 'signed-out'; message?: string }
|
|
31
31
|
| { kind: 'no-access'; login: string }
|
|
32
|
+
/** GitHub answered 404: most often the site's GitHub App isn't installed on the repository. */
|
|
33
|
+
| { kind: 'not-found'; login: string }
|
|
32
34
|
| { kind: 'error'; message: string }
|
|
33
35
|
| {
|
|
34
36
|
kind: 'ready';
|
|
@@ -89,9 +91,25 @@ export function GitHubGate({
|
|
|
89
91
|
? ((await user.json()) as { login?: string; name?: string | null; avatar_url?: string })
|
|
90
92
|
: {};
|
|
91
93
|
const login = profile.login ?? '';
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
// A GitHub App's sign-in sees only repositories the app is installed on,
|
|
95
|
+
// so a 404 isn't the editor's access: blaming it sent an owner to ask
|
|
96
|
+
// themselves for write access (DECISIONS.md, "GitHub mode tells a missing
|
|
97
|
+
// install apart from missing access").
|
|
98
|
+
if (repo.status === 404) {
|
|
99
|
+
set({ kind: 'not-found', login });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!repo.ok) {
|
|
103
|
+
set({
|
|
104
|
+
kind: 'error',
|
|
105
|
+
message: `GitHub couldn’t open ${settings.owner}/${settings.repo} just now. Try again in a few minutes.`,
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const details = (await repo.json()) as {
|
|
110
|
+
permissions?: { push?: boolean };
|
|
111
|
+
private?: boolean;
|
|
112
|
+
};
|
|
95
113
|
const permissions = details.permissions;
|
|
96
114
|
if (!permissions?.push) {
|
|
97
115
|
set({ kind: 'no-access', login });
|
|
@@ -121,6 +139,10 @@ export function GitHubGate({
|
|
|
121
139
|
const signIn = useCallback(() => {
|
|
122
140
|
void startSignIn(settings, window.location.hash);
|
|
123
141
|
}, [settings]);
|
|
142
|
+
const tryAgain = useCallback(() => {
|
|
143
|
+
setState({ kind: 'checking' });
|
|
144
|
+
setAttempt((n) => n + 1);
|
|
145
|
+
}, []);
|
|
124
146
|
const leave = useCallback(() => {
|
|
125
147
|
signOut();
|
|
126
148
|
setExpired(false);
|
|
@@ -210,17 +232,30 @@ export function GitHubGate({
|
|
|
210
232
|
</div>
|
|
211
233
|
</>
|
|
212
234
|
)}
|
|
235
|
+
{state.kind === 'not-found' && (
|
|
236
|
+
<>
|
|
237
|
+
<p>
|
|
238
|
+
{state.login ? `You’re signed in to GitHub as @${state.login}. ` : ''}The site’s
|
|
239
|
+
GitHub App may not be installed on {repo}, or your account may not be able to see it.
|
|
240
|
+
</p>
|
|
241
|
+
<p>
|
|
242
|
+
The site’s owner installs the app from its settings on GitHub, under Install App, then
|
|
243
|
+
tries again here. If it’s installed already, ask the owner to give your account write
|
|
244
|
+
access to {repo}.
|
|
245
|
+
</p>
|
|
246
|
+
<div className="flex flex-wrap gap-3">
|
|
247
|
+
<Button variant="primary" onPress={tryAgain}>
|
|
248
|
+
Try again
|
|
249
|
+
</Button>
|
|
250
|
+
<Button onPress={leave}>Sign out of GitHub</Button>
|
|
251
|
+
</div>
|
|
252
|
+
</>
|
|
253
|
+
)}
|
|
213
254
|
{state.kind === 'error' && (
|
|
214
255
|
<>
|
|
215
256
|
<p role="alert">{state.message}</p>
|
|
216
257
|
<div>
|
|
217
|
-
<Button
|
|
218
|
-
variant="primary"
|
|
219
|
-
onPress={() => {
|
|
220
|
-
setState({ kind: 'checking' });
|
|
221
|
-
setAttempt((n) => n + 1);
|
|
222
|
-
}}
|
|
223
|
-
>
|
|
258
|
+
<Button variant="primary" onPress={tryAgain}>
|
|
224
259
|
Try again
|
|
225
260
|
</Button>
|
|
226
261
|
</div>
|
|
@@ -237,6 +272,8 @@ function titleOf(state: State): string {
|
|
|
237
272
|
return 'Opening the studio';
|
|
238
273
|
case 'no-access':
|
|
239
274
|
return 'Your GitHub account can’t change this site';
|
|
275
|
+
case 'not-found':
|
|
276
|
+
return 'GitHub can’t find this site’s repository';
|
|
240
277
|
case 'error':
|
|
241
278
|
return 'GitHub couldn’t be reached';
|
|
242
279
|
default:
|
|
@@ -13,6 +13,7 @@ import { useStudio } from '../app/context.tsx';
|
|
|
13
13
|
import { useDrafts } from '../cloud/editor.tsx';
|
|
14
14
|
import { ChoosePageButton } from '../editor/PagePicker.tsx';
|
|
15
15
|
import { inputClass } from '../widgets/basic.tsx';
|
|
16
|
+
import { useBlankWhenEmpty } from '../widgets/blank-date.ts';
|
|
16
17
|
import { cloudKeepsTime } from './cloud.ts';
|
|
17
18
|
import { useNow } from './now.ts';
|
|
18
19
|
import { everyText, redirectField, siteZone, type ScheduleTimes } from './times.ts';
|
|
@@ -234,6 +235,7 @@ function TimeField({
|
|
|
234
235
|
action?: ReactNode;
|
|
235
236
|
}) {
|
|
236
237
|
const note = value ? dstNote(value, zone, now) : null;
|
|
238
|
+
const blank = useBlankWhenEmpty(value);
|
|
237
239
|
const described = [describedBy, note ? `${id}-note` : '', problem ? `${id}-problem` : '']
|
|
238
240
|
.filter(Boolean)
|
|
239
241
|
.join(' ');
|
|
@@ -249,6 +251,7 @@ function TimeField({
|
|
|
249
251
|
onChange={(event) => {
|
|
250
252
|
onChange(event.target.value);
|
|
251
253
|
}}
|
|
254
|
+
{...blank}
|
|
252
255
|
aria-describedby={described}
|
|
253
256
|
aria-invalid={problem ? true : undefined}
|
|
254
257
|
className={cx(inputClass, 'max-w-64')}
|
package/src/schedule/times.ts
CHANGED
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
offsetOf,
|
|
4
4
|
publishWindow,
|
|
5
5
|
resolveTimeZone,
|
|
6
|
-
scheduleStatus,
|
|
7
6
|
type CollectionManifest,
|
|
8
7
|
type DataObject,
|
|
9
8
|
type DataValue,
|
|
@@ -91,16 +90,6 @@ export function withTimes(
|
|
|
91
90
|
return next;
|
|
92
91
|
}
|
|
93
92
|
|
|
94
|
-
/** The entry's schedule as status words, or null when there's nothing to say. */
|
|
95
|
-
export function scheduleWords(
|
|
96
|
-
schedule: SchedulePresentation | undefined,
|
|
97
|
-
data: DataValue,
|
|
98
|
-
zone: string,
|
|
99
|
-
now: Date = new Date(),
|
|
100
|
-
): string | null {
|
|
101
|
-
return scheduleStatus(schedule, data, now, zone);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
93
|
/** An entry row's schedule (ISO times) as values under the schedule's field names. */
|
|
105
94
|
export function rowData(
|
|
106
95
|
schedule: SchedulePresentation | undefined,
|
|
@@ -77,7 +77,8 @@ import { useNow } from '../schedule/now.ts';
|
|
|
77
77
|
import { ScheduleDialog } from '../schedule/ScheduleDialog.tsx';
|
|
78
78
|
import { ScheduleSummary } from '../schedule/ScheduleSummary.tsx';
|
|
79
79
|
import { holdUntil } from '../schedule/cloud.ts';
|
|
80
|
-
import { scheduleKeys,
|
|
80
|
+
import { scheduleKeys, siteZone, timesOf, withTimes } from '../schedule/times.ts';
|
|
81
|
+
import { StateWords, entryState, isNewInDraft } from './status.tsx';
|
|
81
82
|
import { useEntryNotes } from '../a11y/useNotes.ts';
|
|
82
83
|
import {
|
|
83
84
|
ConflictCompare,
|
|
@@ -909,7 +910,6 @@ function EntryForm({
|
|
|
909
910
|
const later = (date: Date | undefined) => date !== undefined && date.getTime() > now.getTime();
|
|
910
911
|
/** Live already, so Schedule… sets only when it comes down (call 8). */
|
|
911
912
|
const live = !isNew && !later(savedTimes.publishAt);
|
|
912
|
-
const scheduleText = schedule && !isNew ? scheduleWords(schedule, saved.values, zone, now) : null;
|
|
913
913
|
const nowLabel = publishes ? 'Publish now' : 'Make it live now';
|
|
914
914
|
/** Sets the values and saves them, or publishes them where saves are drafts. */
|
|
915
915
|
const saveWith = async (next: DataObject) => {
|
|
@@ -1008,12 +1008,22 @@ function EntryForm({
|
|
|
1008
1008
|
});
|
|
1009
1009
|
};
|
|
1010
1010
|
|
|
1011
|
-
//
|
|
1011
|
+
// What the entry is, its word coloured, then how its changes stand: never
|
|
1012
|
+
// empty, so the way back (or the title) above it always sits in the same place.
|
|
1013
|
+
const state =
|
|
1014
|
+
linkCopied > 0 || (isNew && !draft)
|
|
1015
|
+
? null
|
|
1016
|
+
: entryState(
|
|
1017
|
+
collection,
|
|
1018
|
+
{
|
|
1019
|
+
draft,
|
|
1020
|
+
times: isNew ? null : saved.values,
|
|
1021
|
+
newDraft: isNewInDraft(entryDraft ? [entryDraft] : [], filePath),
|
|
1022
|
+
},
|
|
1023
|
+
{ zone, cloud: Boolean(cloud), now },
|
|
1024
|
+
);
|
|
1012
1025
|
const statusLine =
|
|
1013
|
-
linkCopied > 0
|
|
1014
|
-
? 'Link copied'
|
|
1015
|
-
: [draft ? 'Draft' : '', scheduleText ?? '', statusText].filter(Boolean).join(' · ') ||
|
|
1016
|
-
(editable ? 'No changes' : 'Read only');
|
|
1026
|
+
linkCopied > 0 ? 'Link copied' : statusText || (editable ? 'No changes' : 'Read only');
|
|
1017
1027
|
// Content, the collection's own tabs, then Details; no tab list when Content is all there is.
|
|
1018
1028
|
const panels = sourceOnly
|
|
1019
1029
|
? []
|
|
@@ -1066,8 +1076,12 @@ function EntryForm({
|
|
|
1066
1076
|
{pane && Boolean(arranged.title) && !sourceOnly && (
|
|
1067
1077
|
<span className="truncate font-semibold text-content">{titleText}</span>
|
|
1068
1078
|
)}
|
|
1069
|
-
<InlineStatus dot className="h-5 min-w-0">
|
|
1070
|
-
<span className="min-w-0 truncate">
|
|
1079
|
+
<InlineStatus dot busy={saving || publishing} className="h-5 min-w-0">
|
|
1080
|
+
<span className="min-w-0 truncate">
|
|
1081
|
+
<StateWords state={state} />
|
|
1082
|
+
{state ? ' · ' : ''}
|
|
1083
|
+
<span>{statusLine}</span>
|
|
1084
|
+
</span>
|
|
1071
1085
|
</InlineStatus>
|
|
1072
1086
|
</div>
|
|
1073
1087
|
{notes.length > 0 && !notesInMenu && (
|
|
@@ -9,7 +9,9 @@ import { useLoad } from '../app/load.ts';
|
|
|
9
9
|
import { hrefFor, navigate } from '../app/router.ts';
|
|
10
10
|
import { collectionIcon } from '../app/collection-icon.ts';
|
|
11
11
|
import { Screen } from '../app/Screen.tsx';
|
|
12
|
-
import {
|
|
12
|
+
import { useDrafts } from '../cloud/editor.tsx';
|
|
13
|
+
import { hasUpcoming, rowData, siteZone } from '../schedule/times.ts';
|
|
14
|
+
import { StateWords, entryState, isNewInDraft, kindText, type EntryState } from './status.tsx';
|
|
13
15
|
|
|
14
16
|
type Filter = 'all' | 'drafts' | 'attention' | 'scheduled';
|
|
15
17
|
|
|
@@ -21,6 +23,7 @@ type Filter = 'all' | 'drafts' | 'attention' | 'scheduled';
|
|
|
21
23
|
export function EntryList({ collection }: { collection: CollectionManifest }) {
|
|
22
24
|
const { api, cloud, manifest } = useStudio();
|
|
23
25
|
const { zone } = siteZone(manifest);
|
|
26
|
+
const { drafts } = useDrafts();
|
|
24
27
|
const [load] = useLoad(() => api.listEntries(collection.name), collection.name);
|
|
25
28
|
const [query, setQuery] = useState('');
|
|
26
29
|
const [filter, setFilter] = useState<Filter>('all');
|
|
@@ -71,11 +74,15 @@ export function EntryList({ collection }: { collection: CollectionManifest }) {
|
|
|
71
74
|
);
|
|
72
75
|
return [...shown].sort(compareBy(sort));
|
|
73
76
|
}, [entries, query, filter, sort]);
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
collection
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
const stateOf = (entry: EntrySummary) =>
|
|
78
|
+
entryState(
|
|
79
|
+
collection,
|
|
80
|
+
{
|
|
81
|
+
draft: entry.draft === true,
|
|
82
|
+
times: rowData(collection.presentation.schedule, entry.schedule),
|
|
83
|
+
newDraft: isNewInDraft(drafts, entry.path),
|
|
84
|
+
},
|
|
85
|
+
{ zone, cloud: Boolean(cloud) },
|
|
79
86
|
);
|
|
80
87
|
|
|
81
88
|
return (
|
|
@@ -150,7 +157,7 @@ export function EntryList({ collection }: { collection: CollectionManifest }) {
|
|
|
150
157
|
}
|
|
151
158
|
emptyState={entries.length === 0 ? 'No entries yet.' : 'No entries match.'}
|
|
152
159
|
renderCell={(entry, column) => (
|
|
153
|
-
<Cell entry={entry} column={column}
|
|
160
|
+
<Cell entry={entry} column={column} state={stateOf(entry)} />
|
|
154
161
|
)}
|
|
155
162
|
/>
|
|
156
163
|
)}
|
|
@@ -168,11 +175,11 @@ export const TITLE_KEYS = new Set(['title', 'name', 'heading', 'label']);
|
|
|
168
175
|
function Cell({
|
|
169
176
|
entry,
|
|
170
177
|
column,
|
|
171
|
-
|
|
178
|
+
state,
|
|
172
179
|
}: {
|
|
173
180
|
entry: EntrySummary;
|
|
174
181
|
column: string;
|
|
175
|
-
|
|
182
|
+
state: EntryState | null;
|
|
176
183
|
}) {
|
|
177
184
|
if (column === 'title') {
|
|
178
185
|
return (
|
|
@@ -194,27 +201,18 @@ function Cell({
|
|
|
194
201
|
);
|
|
195
202
|
}
|
|
196
203
|
if (column === 'status') {
|
|
197
|
-
|
|
204
|
+
const kind = kindText(entry.status);
|
|
205
|
+
return (
|
|
206
|
+
<span className="text-content-muted">
|
|
207
|
+
<StateWords state={state} />
|
|
208
|
+
{state && kind ? ' · ' : ''}
|
|
209
|
+
{kind}
|
|
210
|
+
</span>
|
|
211
|
+
);
|
|
198
212
|
}
|
|
199
213
|
return <span>{formatListValue(entry.fields[column.slice('field:'.length)])}</span>;
|
|
200
214
|
}
|
|
201
215
|
|
|
202
|
-
/** An entry's status in words: what it is, then its schedule ("Draft · Scheduled · Sat, Oct 31, 8:00 AM"). */
|
|
203
|
-
export function statusText(
|
|
204
|
-
entry: Pick<EntrySummary, 'status' | 'draft'>,
|
|
205
|
-
schedule: string | null = null,
|
|
206
|
-
): string {
|
|
207
|
-
const kind =
|
|
208
|
-
entry.status === 'read-only'
|
|
209
|
-
? 'Read only'
|
|
210
|
-
: entry.status === 'source-only'
|
|
211
|
-
? 'Edit as YAML'
|
|
212
|
-
: entry.draft
|
|
213
|
-
? 'Draft'
|
|
214
|
-
: '';
|
|
215
|
-
return [kind, schedule ?? ''].filter(Boolean).join(' · ');
|
|
216
|
-
}
|
|
217
|
-
|
|
218
216
|
/** A list field's value as short text. Dates at midnight UTC are calendar dates. */
|
|
219
217
|
export function formatListValue(encoded: unknown): string {
|
|
220
218
|
const value = decodeValue(encoded);
|
package/src/screens/ListPane.tsx
CHANGED
|
@@ -26,15 +26,17 @@ import { hasUnsavedChanges, hrefFor, navigate } from '../app/router.ts';
|
|
|
26
26
|
import { collectionIcon } from '../app/collection-icon.ts';
|
|
27
27
|
import { PaneFrame, useLeadHeading } from '../app/Screen.tsx';
|
|
28
28
|
import { entryShownTo, mayCreateIn } from '../cloud/access.ts';
|
|
29
|
-
import {
|
|
29
|
+
import { useDrafts } from '../cloud/editor.tsx';
|
|
30
|
+
import { hasUpcoming, rowData, siteZone } from '../schedule/times.ts';
|
|
31
|
+
import { compareBy, defaultSort, formatListValue, labelFor, TITLE_KEYS } from './EntryList.tsx';
|
|
30
32
|
import {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
} from './
|
|
33
|
+
StateWords,
|
|
34
|
+
entryState,
|
|
35
|
+
isNewInDraft,
|
|
36
|
+
kindText,
|
|
37
|
+
stateText,
|
|
38
|
+
type EntryState,
|
|
39
|
+
} from './status.tsx';
|
|
38
40
|
|
|
39
41
|
/*
|
|
40
42
|
* A flat collection's list as a pane (DECISIONS.md, "The studio is panes,
|
|
@@ -90,7 +92,12 @@ function cacheOf(api: StudioApi): Map<string, EntrySummary[]> {
|
|
|
90
92
|
type Row = TreeNode & {
|
|
91
93
|
title: string;
|
|
92
94
|
entryId: string;
|
|
95
|
+
/** The line under the title, in words: list fields, state, when it was edited. */
|
|
93
96
|
meta: string;
|
|
97
|
+
/** The same line in parts, so the state's word can take its colour. */
|
|
98
|
+
fields: string[];
|
|
99
|
+
state: EntryState | null;
|
|
100
|
+
after: string[];
|
|
94
101
|
/** The entry open beside the list. */
|
|
95
102
|
current: boolean;
|
|
96
103
|
children: Row[];
|
|
@@ -130,6 +137,7 @@ export function ListPane({
|
|
|
130
137
|
}) {
|
|
131
138
|
const { api, cloud, manifest } = useStudio();
|
|
132
139
|
const { zone } = siteZone(manifest);
|
|
140
|
+
const { drafts } = useDrafts();
|
|
133
141
|
const memory = memoryOf(api, collection.name);
|
|
134
142
|
const heading = useRef<HTMLHeadingElement>(null);
|
|
135
143
|
useLeadHeading(collection.label, heading);
|
|
@@ -193,28 +201,34 @@ export function ListPane({
|
|
|
193
201
|
(filter === 'scheduled' && hasUpcoming(entry.schedule))),
|
|
194
202
|
)
|
|
195
203
|
.sort(compareBy(descriptorOf(collection, sort)))
|
|
196
|
-
.map((entry) =>
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
entry,
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
),
|
|
204
|
+
.map((entry) => {
|
|
205
|
+
const fields = listKeys.map((key) => formatListValue(entry.fields[key])).filter(Boolean);
|
|
206
|
+
const state = entryState(
|
|
207
|
+
collection,
|
|
208
|
+
{
|
|
209
|
+
draft: entry.draft === true,
|
|
210
|
+
times: rowData(collection.presentation.schedule, entry.schedule),
|
|
211
|
+
newDraft: isNewInDraft(drafts, entry.path),
|
|
212
|
+
},
|
|
213
|
+
{ zone, cloud: Boolean(cloud) },
|
|
214
|
+
);
|
|
215
|
+
const after = [
|
|
216
|
+
kindText(entry.status),
|
|
210
217
|
entry.updatedAt ? `edited ${formatRelative(entry.updatedAt)}` : '',
|
|
211
|
-
]
|
|
212
|
-
|
|
213
|
-
.
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
+
].filter(Boolean);
|
|
219
|
+
return {
|
|
220
|
+
id: entry.id,
|
|
221
|
+
title: entry.title,
|
|
222
|
+
entryId: entry.id,
|
|
223
|
+
meta: [...fields, stateText(state), ...after].filter(Boolean).join(' · '),
|
|
224
|
+
fields,
|
|
225
|
+
state,
|
|
226
|
+
after,
|
|
227
|
+
current: entry.id === currentId,
|
|
228
|
+
children: [],
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
}, [visible, query, filter, sort, collection, currentId, zone, drafts, cloud]);
|
|
218
232
|
|
|
219
233
|
// In order, whole and editable: then rows move. Searching, filtering or another sort
|
|
220
234
|
// would leave neighbours out of sight, and a member who can't see every entry can't
|
|
@@ -421,7 +435,11 @@ export function ListPane({
|
|
|
421
435
|
</span>
|
|
422
436
|
{row.meta && (
|
|
423
437
|
<span className="block truncate text-caption text-content-muted">
|
|
424
|
-
{row.
|
|
438
|
+
{row.fields.join(' · ')}
|
|
439
|
+
{row.fields.length > 0 && row.state ? ' · ' : ''}
|
|
440
|
+
<StateWords state={row.state} />
|
|
441
|
+
{(row.fields.length > 0 || row.state) && row.after.length > 0 ? ' · ' : ''}
|
|
442
|
+
{row.after.join(' · ')}
|
|
425
443
|
</span>
|
|
426
444
|
)}
|
|
427
445
|
</span>
|
package/src/screens/PageTree.tsx
CHANGED
|
@@ -17,11 +17,14 @@ import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from 'r
|
|
|
17
17
|
import { useStudio } from '../app/context.tsx';
|
|
18
18
|
import { onEntriesChanged } from '../app/entries-changed.ts';
|
|
19
19
|
import { mayCreateIn, treeCovered } from '../cloud/access.ts';
|
|
20
|
+
import { useDrafts } from '../cloud/editor.tsx';
|
|
21
|
+
import { siteZone } from '../schedule/times.ts';
|
|
20
22
|
import { useLoad } from '../app/load.ts';
|
|
21
23
|
import { hrefFor, navigate } from '../app/router.ts';
|
|
22
24
|
import { collectionIcon } from '../app/collection-icon.ts';
|
|
23
25
|
import { usePane } from '../app/pane-context.ts';
|
|
24
26
|
import { Screen } from '../app/Screen.tsx';
|
|
27
|
+
import { StateWords, entryState, isNewInDraft, kindText } from './status.tsx';
|
|
25
28
|
|
|
26
29
|
/*
|
|
27
30
|
* The outline of a tree collection (spec §6.15.2), in place of the flat
|
|
@@ -64,9 +67,19 @@ export function PageTree({
|
|
|
64
67
|
/** The page open beside the outline, among panes. */
|
|
65
68
|
currentId?: string | null;
|
|
66
69
|
}) {
|
|
67
|
-
const { api, cloud } = useStudio();
|
|
70
|
+
const { api, cloud, manifest } = useStudio();
|
|
71
|
+
const { drafts } = useDrafts();
|
|
68
72
|
const pane = usePane();
|
|
69
73
|
const [load, reload] = useLoad(() => api.pageTree(collection.name), collection.name);
|
|
74
|
+
// A row knows no schedule times, so a scheduled collection's rows never claim Live.
|
|
75
|
+
const stateOf = (row: Row) =>
|
|
76
|
+
row.entryId === undefined
|
|
77
|
+
? null
|
|
78
|
+
: entryState(
|
|
79
|
+
collection,
|
|
80
|
+
{ draft: row.draft === true, times: null, newDraft: isNewInDraft(drafts, row.file) },
|
|
81
|
+
{ zone: siteZone(manifest).zone, cloud: Boolean(cloud) },
|
|
82
|
+
);
|
|
70
83
|
// A save beside the outline can change a title or a status.
|
|
71
84
|
useEffect(() => onEntriesChanged(collection.name, reload), [collection.name, reload]);
|
|
72
85
|
const [expanded, setExpanded] = useState<Set<Key>>(() => new Set());
|
|
@@ -336,28 +349,36 @@ export function PageTree({
|
|
|
336
349
|
{...(editable && covered('') ? { dragAndDropHooks } : {})}
|
|
337
350
|
emptyState={rows.length === 0 ? 'No pages yet.' : 'No pages match.'}
|
|
338
351
|
currentKey={currentKey}
|
|
339
|
-
renderRow={(row) =>
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
352
|
+
renderRow={(row) => {
|
|
353
|
+
const state = stateOf(row);
|
|
354
|
+
const words = statusOf(row);
|
|
355
|
+
return (
|
|
356
|
+
<>
|
|
357
|
+
<span className="min-w-0 flex-1 px-1">
|
|
358
|
+
<span className="block truncate font-semibold text-content">{row.title}</span>
|
|
359
|
+
<span className="block truncate font-mono text-caption text-content-muted">
|
|
360
|
+
{row.url ?? `${row.path}/`}
|
|
361
|
+
</span>
|
|
362
|
+
</span>
|
|
363
|
+
<span className="shrink-0 text-small text-content-muted">
|
|
364
|
+
<StateWords state={state} />
|
|
365
|
+
{state && words ? ' · ' : ''}
|
|
366
|
+
{words}
|
|
345
367
|
</span>
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
)}
|
|
368
|
+
<Menu
|
|
369
|
+
label={`More actions for ${row.title}`}
|
|
370
|
+
items={menuItems(byKey.get(row.id) ?? row, rows, byPath, {
|
|
371
|
+
editable,
|
|
372
|
+
maxDepth,
|
|
373
|
+
covered,
|
|
374
|
+
})}
|
|
375
|
+
onAction={(action) => {
|
|
376
|
+
onMenu(row, action);
|
|
377
|
+
}}
|
|
378
|
+
/>
|
|
379
|
+
</>
|
|
380
|
+
);
|
|
381
|
+
}}
|
|
361
382
|
/>
|
|
362
383
|
</div>
|
|
363
384
|
)}
|
|
@@ -641,11 +662,10 @@ function menuItems(
|
|
|
641
662
|
return items;
|
|
642
663
|
}
|
|
643
664
|
|
|
665
|
+
/** What the row is besides its state: a folder with no page, or what can be done with it. */
|
|
644
666
|
function statusOf(row: Row): string {
|
|
645
667
|
if (row.entryId === undefined) return 'No section page';
|
|
646
|
-
|
|
647
|
-
if (row.status === 'source-only') return 'Edit as YAML';
|
|
648
|
-
return row.draft ? 'Draft' : '';
|
|
668
|
+
return kindText(row.status);
|
|
649
669
|
}
|
|
650
670
|
|
|
651
671
|
function toRows(nodes: PageTreeNode[]): Row[] {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {
|
|
2
|
+
scheduleState,
|
|
3
|
+
type CollectionManifest,
|
|
4
|
+
type DataValue,
|
|
5
|
+
type Draft,
|
|
6
|
+
type EntryStatus,
|
|
7
|
+
} from '@vespercms/core';
|
|
8
|
+
import { StatusPill, type StatusTone } from '@vespercms/ui';
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* An entry's state as one coloured word and the words after it (DECISIONS.md,
|
|
12
|
+
* "Status words have colour"): Draft, Scheduled, Live or Unpublished. The
|
|
13
|
+
* words are the ones the studio already said; only the first takes a colour,
|
|
14
|
+
* so what a screen reader hears, and what type-ahead matches, is unchanged.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type EntryState = {
|
|
18
|
+
tone: StatusTone;
|
|
19
|
+
/** "Draft", "Scheduled", "Live" or "Unpublished". */
|
|
20
|
+
word: string;
|
|
21
|
+
/** What follows the word, its joiner included: " · Sat, Oct 31, 8:00 AM". */
|
|
22
|
+
rest: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What an entry is now. `times` is the entry's data with its schedule's
|
|
27
|
+
* fields, or null where they aren't known (a page tree's rows), which then
|
|
28
|
+
* never claims Live. Live is said only where an entry could be something
|
|
29
|
+
* else: a collection with a schedule or a `draft` field, or any in the cloud,
|
|
30
|
+
* where a new entry starts as a draft. Elsewhere every entry is live, and
|
|
31
|
+
* saying so on every row would only be noise.
|
|
32
|
+
*/
|
|
33
|
+
export function entryState(
|
|
34
|
+
collection: CollectionManifest,
|
|
35
|
+
entry: { draft: boolean; times: DataValue | null; newDraft?: boolean },
|
|
36
|
+
options: { zone: string; cloud: boolean; now?: Date },
|
|
37
|
+
): EntryState | null {
|
|
38
|
+
const schedule =
|
|
39
|
+
entry.times === null
|
|
40
|
+
? null
|
|
41
|
+
: scheduleState(
|
|
42
|
+
collection.presentation.schedule,
|
|
43
|
+
entry.times,
|
|
44
|
+
options.now ?? new Date(),
|
|
45
|
+
options.zone,
|
|
46
|
+
);
|
|
47
|
+
if (entry.draft) {
|
|
48
|
+
return {
|
|
49
|
+
tone: 'neutral',
|
|
50
|
+
word: 'Draft',
|
|
51
|
+
rest: schedule ? ` · ${schedule.word}${schedule.rest}` : '',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (schedule) {
|
|
55
|
+
return {
|
|
56
|
+
tone:
|
|
57
|
+
schedule.phase === 'scheduled'
|
|
58
|
+
? 'scheduled'
|
|
59
|
+
: schedule.phase === 'live'
|
|
60
|
+
? 'live'
|
|
61
|
+
: 'neutral',
|
|
62
|
+
word: schedule.word,
|
|
63
|
+
rest: schedule.rest,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (entry.newDraft) return { tone: 'neutral', word: 'Draft', rest: '' };
|
|
67
|
+
const couldBeOther =
|
|
68
|
+
options.cloud ||
|
|
69
|
+
collection.presentation.schedule !== undefined ||
|
|
70
|
+
(collection.fields ?? []).some((field) => field.path.length === 1 && field.path[0] === 'draft');
|
|
71
|
+
if (entry.times === null && collection.presentation.schedule !== undefined) return null;
|
|
72
|
+
return couldBeOther ? { tone: 'live', word: 'Live', rest: '' } : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Whether a file is in a draft that would create it: in the cloud, an entry never published. */
|
|
76
|
+
export function isNewInDraft(drafts: readonly Draft[], path: string | undefined): boolean {
|
|
77
|
+
return (
|
|
78
|
+
path !== undefined &&
|
|
79
|
+
drafts.some((draft) =>
|
|
80
|
+
draft.files.some((file) => file.path === path && file.op === 'put' && file.base === null),
|
|
81
|
+
)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** "Read only" or "Edit as YAML": what can be done with the entry, after its state. */
|
|
86
|
+
export function kindText(status: EntryStatus | undefined): string {
|
|
87
|
+
return status === 'read-only' ? 'Read only' : status === 'source-only' ? 'Edit as YAML' : '';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The state as plain words, for names and type-ahead. */
|
|
91
|
+
export function stateText(state: EntryState | null): string {
|
|
92
|
+
return state ? `${state.word}${state.rest}` : '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The state's word on its colour, then the words after it as text. */
|
|
96
|
+
export function StateWords({ state }: { state: EntryState | null }) {
|
|
97
|
+
if (!state) return null;
|
|
98
|
+
return (
|
|
99
|
+
<>
|
|
100
|
+
<StatusPill tone={state.tone}>{state.word}</StatusPill>
|
|
101
|
+
{state.rest}
|
|
102
|
+
</>
|
|
103
|
+
);
|
|
104
|
+
}
|
package/src/studio.css
CHANGED
|
@@ -67,6 +67,11 @@
|
|
|
67
67
|
outline: 3px solid var(--color-focus);
|
|
68
68
|
outline-offset: 3px;
|
|
69
69
|
}
|
|
70
|
+
|
|
71
|
+
/* An empty date field shows no date until it's used: Safari's is today's (widgets/blank-date.ts). */
|
|
72
|
+
input[data-blank]::-webkit-datetime-edit {
|
|
73
|
+
opacity: 0;
|
|
74
|
+
}
|
|
70
75
|
}
|
|
71
76
|
|
|
72
77
|
@layer components {
|