@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.
@@ -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.0",
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.0",
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
- const details = repo.ok
93
- ? ((await repo.json()) as { permissions?: { push?: boolean }; private?: boolean })
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')}
@@ -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, scheduleWords, siteZone, timesOf, withTimes } from '../schedule/times.ts';
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
- // Never empty, so the way back (or the title) above it always sits in the same place.
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">{statusLine}</span>
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 { hasUpcoming, rowData, scheduleWords, siteZone } from '../schedule/times.ts';
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 words = (entry: EntrySummary) =>
75
- scheduleWords(
76
- collection.presentation.schedule,
77
- rowData(collection.presentation.schedule, entry.schedule),
78
- zone,
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} schedule={words(entry)} />
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
- schedule,
178
+ state,
172
179
  }: {
173
180
  entry: EntrySummary;
174
181
  column: string;
175
- schedule: string | null;
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
- return <span className="text-content-muted">{statusText(entry, schedule)}</span>;
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);
@@ -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 { hasUpcoming, rowData, scheduleWords, siteZone } from '../schedule/times.ts';
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
- compareBy,
32
- defaultSort,
33
- formatListValue,
34
- labelFor,
35
- statusText,
36
- TITLE_KEYS,
37
- } from './EntryList.tsx';
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
- id: entry.id,
198
- title: entry.title,
199
- entryId: entry.id,
200
- meta: [
201
- ...listKeys.map((key) => formatListValue(entry.fields[key])),
202
- statusText(
203
- entry,
204
- scheduleWords(
205
- collection.presentation.schedule,
206
- rowData(collection.presentation.schedule, entry.schedule),
207
- zone,
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
- .filter(Boolean)
213
- .join(' · '),
214
- current: entry.id === currentId,
215
- children: [],
216
- }));
217
- }, [visible, query, filter, sort, collection, currentId, zone]);
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.meta}
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>
@@ -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
- <span className="min-w-0 flex-1 px-1">
342
- <span className="block truncate font-semibold text-content">{row.title}</span>
343
- <span className="block truncate font-mono text-caption text-content-muted">
344
- {row.url ?? `${row.path}/`}
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
- </span>
347
- <span className="shrink-0 text-small text-content-muted">{statusOf(row)}</span>
348
- <Menu
349
- label={`More actions for ${row.title}`}
350
- items={menuItems(byKey.get(row.id) ?? row, rows, byPath, {
351
- editable,
352
- maxDepth,
353
- covered,
354
- })}
355
- onAction={(action) => {
356
- onMenu(row, action);
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
- if (row.status === 'read-only') return 'Read only';
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 {