@vespercms/admin 0.0.0-stage → 0.1.0-next.0

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.
Files changed (203) hide show
  1. package/README.md +119 -2
  2. package/dist/BodyEditor-CAGNfofv.js +3047 -0
  3. package/dist/a11y/FieldNotes.d.ts +23 -0
  4. package/dist/a11y/NotesDrawer.d.ts +23 -0
  5. package/dist/a11y/pdf.d.ts +3 -0
  6. package/dist/a11y/run.d.ts +3 -0
  7. package/dist/a11y/useNotes.d.ts +28 -0
  8. package/dist/app/App.d.ts +2 -0
  9. package/dist/app/Screen.d.ts +88 -0
  10. package/dist/app/UserMenu.d.ts +7 -0
  11. package/dist/app/api.d.ts +75 -0
  12. package/dist/app/appearance.d.ts +10 -0
  13. package/dist/app/browser-api.d.ts +17 -0
  14. package/dist/app/collection-icon.d.ts +12 -0
  15. package/dist/app/context.d.ts +23 -0
  16. package/dist/app/entries-changed.d.ts +11 -0
  17. package/dist/app/file-url.d.ts +13 -0
  18. package/dist/app/format.d.ts +4 -0
  19. package/dist/app/idb.d.ts +7 -0
  20. package/dist/app/load.d.ts +16 -0
  21. package/dist/app/media.d.ts +5 -0
  22. package/dist/app/navigation.d.ts +4 -0
  23. package/dist/app/pane-context.d.ts +19 -0
  24. package/dist/app/panes.d.ts +14 -0
  25. package/dist/app/router.d.ts +62 -0
  26. package/dist/body/BodyEditor.d.ts +42 -0
  27. package/dist/body/TableView.d.ts +2 -0
  28. package/dist/body/catalog.d.ts +47 -0
  29. package/dist/body/checks.d.ts +15 -0
  30. package/dist/body/chips.d.ts +31 -0
  31. package/dist/body/commands.d.ts +31 -0
  32. package/dist/body/document.d.ts +42 -0
  33. package/dist/body/insert.d.ts +69 -0
  34. package/dist/body/nodes.d.ts +38 -0
  35. package/dist/body/reveal.d.ts +13 -0
  36. package/dist/body/scroll.d.ts +16 -0
  37. package/dist/body/settings.d.ts +19 -0
  38. package/dist/body/shortcuts.d.ts +13 -0
  39. package/dist/body/table.d.ts +31 -0
  40. package/dist/body/ui.d.ts +30 -0
  41. package/dist/body/views.d.ts +9 -0
  42. package/dist/checks-C-0xDu-I.js +57 -0
  43. package/dist/cloud/CloudGate.d.ts +14 -0
  44. package/dist/cloud/access.d.ts +14 -0
  45. package/dist/cloud/changes.d.ts +26 -0
  46. package/dist/cloud/connection.d.ts +100 -0
  47. package/dist/cloud/editor.d.ts +44 -0
  48. package/dist/cloud/session.d.ts +62 -0
  49. package/dist/cloud/storage.d.ts +45 -0
  50. package/dist/editor/Field.d.ts +24 -0
  51. package/dist/editor/PagePicker.d.ts +20 -0
  52. package/dist/editor/arrange.d.ts +13 -0
  53. package/dist/editor/context.d.ts +23 -0
  54. package/dist/editor/copy.d.ts +9 -0
  55. package/dist/editor/reveal.d.ts +7 -0
  56. package/dist/editor/target.d.ts +21 -0
  57. package/dist/editor/values.d.ts +18 -0
  58. package/dist/geo-map-CT3OGD7O.js +110 -0
  59. package/dist/github/GitHubGate.d.ts +15 -0
  60. package/dist/github/api.d.ts +20 -0
  61. package/dist/github/cache.d.ts +2 -0
  62. package/dist/github/session.d.ts +35 -0
  63. package/dist/index.d.ts +3 -0
  64. package/dist/markdown-KUkv11EB.js +211 -0
  65. package/dist/media/MediaPicker.d.ts +28 -0
  66. package/dist/media/file-icon.d.ts +3 -0
  67. package/dist/media/formats.d.ts +23 -0
  68. package/dist/media/hygiene.d.ts +37 -0
  69. package/dist/media/upload.d.ts +31 -0
  70. package/dist/mount.d.ts +3 -0
  71. package/dist/pdf-m5s_uXJ3.js +24 -0
  72. package/dist/run-y4XRkg17.js +7 -0
  73. package/dist/schedule/ScheduleDialog.d.ts +28 -0
  74. package/dist/schedule/ScheduleSummary.d.ts +12 -0
  75. package/dist/schedule/ScheduledView.d.ts +8 -0
  76. package/dist/schedule/cloud.d.ts +21 -0
  77. package/dist/schedule/now.d.ts +5 -0
  78. package/dist/schedule/times.d.ts +37 -0
  79. package/dist/screens/EntryEditor.d.ts +13 -0
  80. package/dist/screens/EntryList.d.ts +18 -0
  81. package/dist/screens/Home.d.ts +6 -0
  82. package/dist/screens/ListPane.d.ts +6 -0
  83. package/dist/screens/MediaBrowser.d.ts +7 -0
  84. package/dist/screens/NotFound.d.ts +3 -0
  85. package/dist/screens/PageTree.d.ts +10 -0
  86. package/dist/screens/Redirects.d.ts +1 -0
  87. package/dist/screens/Unavailable.d.ts +8 -0
  88. package/dist/shortcuts-B_JMAvF9.js +4630 -0
  89. package/dist/studio.css +3 -0
  90. package/dist/studio.js +6312 -0
  91. package/dist/types.d.ts +76 -0
  92. package/dist/widgets/basic.d.ts +25 -0
  93. package/dist/widgets/crops.d.ts +43 -0
  94. package/dist/widgets/focal-point.d.ts +15 -0
  95. package/dist/widgets/geo-map.d.ts +10 -0
  96. package/dist/widgets/geo.d.ts +16 -0
  97. package/dist/widgets/group.d.ts +41 -0
  98. package/dist/widgets/icon.d.ts +9 -0
  99. package/dist/widgets/image.d.ts +16 -0
  100. package/dist/widgets/markdown-lazy.d.ts +2 -0
  101. package/dist/widgets/markdown.d.ts +2 -0
  102. package/dist/widgets/reference.d.ts +12 -0
  103. package/dist/widgets/registry.d.ts +32 -0
  104. package/dist/widgets/special.d.ts +20 -0
  105. package/dist/widgets/union.d.ts +36 -0
  106. package/package.json +63 -4
  107. package/src/a11y/FieldNotes.tsx +56 -0
  108. package/src/a11y/NotesDrawer.tsx +81 -0
  109. package/src/a11y/pdf.ts +45 -0
  110. package/src/a11y/run.ts +12 -0
  111. package/src/a11y/useNotes.ts +139 -0
  112. package/src/app/App.tsx +156 -0
  113. package/src/app/Screen.tsx +416 -0
  114. package/src/app/UserMenu.tsx +95 -0
  115. package/src/app/api.ts +176 -0
  116. package/src/app/appearance.ts +54 -0
  117. package/src/app/browser-api.ts +173 -0
  118. package/src/app/collection-icon.ts +15 -0
  119. package/src/app/context.tsx +32 -0
  120. package/src/app/entries-changed.ts +48 -0
  121. package/src/app/file-url.tsx +44 -0
  122. package/src/app/format.ts +39 -0
  123. package/src/app/idb.ts +82 -0
  124. package/src/app/load.ts +40 -0
  125. package/src/app/media.ts +20 -0
  126. package/src/app/navigation.ts +4 -0
  127. package/src/app/pane-context.ts +25 -0
  128. package/src/app/panes.tsx +513 -0
  129. package/src/app/router.ts +158 -0
  130. package/src/body/BodyEditor.tsx +1139 -0
  131. package/src/body/TableView.tsx +700 -0
  132. package/src/body/catalog.ts +198 -0
  133. package/src/body/checks.ts +83 -0
  134. package/src/body/chips.ts +150 -0
  135. package/src/body/commands.ts +191 -0
  136. package/src/body/document.ts +388 -0
  137. package/src/body/insert.tsx +301 -0
  138. package/src/body/nodes.ts +287 -0
  139. package/src/body/reveal.ts +93 -0
  140. package/src/body/scroll.ts +53 -0
  141. package/src/body/settings.tsx +510 -0
  142. package/src/body/shortcuts.ts +31 -0
  143. package/src/body/table.ts +192 -0
  144. package/src/body/ui.ts +77 -0
  145. package/src/body/views.tsx +268 -0
  146. package/src/build.ts +6 -0
  147. package/src/cloud/CloudGate.tsx +245 -0
  148. package/src/cloud/access.ts +52 -0
  149. package/src/cloud/changes.tsx +474 -0
  150. package/src/cloud/connection.ts +281 -0
  151. package/src/cloud/editor.tsx +250 -0
  152. package/src/cloud/session.ts +289 -0
  153. package/src/cloud/storage.ts +212 -0
  154. package/src/editor/Field.tsx +182 -0
  155. package/src/editor/PagePicker.tsx +102 -0
  156. package/src/editor/arrange.ts +98 -0
  157. package/src/editor/context.tsx +31 -0
  158. package/src/editor/copy.ts +30 -0
  159. package/src/editor/reveal.ts +38 -0
  160. package/src/editor/target.ts +104 -0
  161. package/src/editor/values.ts +71 -0
  162. package/src/github/GitHubGate.tsx +261 -0
  163. package/src/github/api.ts +81 -0
  164. package/src/github/cache.ts +64 -0
  165. package/src/github/session.ts +146 -0
  166. package/src/index.ts +10 -0
  167. package/src/media/MediaPicker.tsx +302 -0
  168. package/src/media/file-icon.ts +17 -0
  169. package/src/media/formats.ts +262 -0
  170. package/src/media/hygiene.ts +220 -0
  171. package/src/media/upload.ts +47 -0
  172. package/src/mount.tsx +20 -0
  173. package/src/schedule/ScheduleDialog.tsx +300 -0
  174. package/src/schedule/ScheduleSummary.tsx +59 -0
  175. package/src/schedule/ScheduledView.tsx +294 -0
  176. package/src/schedule/cloud.ts +66 -0
  177. package/src/schedule/now.ts +18 -0
  178. package/src/schedule/times.ts +133 -0
  179. package/src/screens/EntryEditor.tsx +1803 -0
  180. package/src/screens/EntryList.tsx +274 -0
  181. package/src/screens/Home.tsx +131 -0
  182. package/src/screens/ListPane.tsx +459 -0
  183. package/src/screens/MediaBrowser.tsx +253 -0
  184. package/src/screens/NotFound.tsx +19 -0
  185. package/src/screens/PageTree.tsx +718 -0
  186. package/src/screens/Redirects.tsx +522 -0
  187. package/src/screens/Unavailable.tsx +27 -0
  188. package/src/studio.css +163 -0
  189. package/src/types.ts +82 -0
  190. package/src/widgets/basic.tsx +311 -0
  191. package/src/widgets/crops.tsx +399 -0
  192. package/src/widgets/focal-point.tsx +240 -0
  193. package/src/widgets/geo-map.tsx +160 -0
  194. package/src/widgets/geo.tsx +170 -0
  195. package/src/widgets/group.tsx +827 -0
  196. package/src/widgets/icon.tsx +47 -0
  197. package/src/widgets/image.tsx +407 -0
  198. package/src/widgets/markdown-lazy.tsx +22 -0
  199. package/src/widgets/markdown.tsx +267 -0
  200. package/src/widgets/reference.tsx +196 -0
  201. package/src/widgets/registry.ts +104 -0
  202. package/src/widgets/special.tsx +286 -0
  203. package/src/widgets/union.tsx +233 -0
@@ -0,0 +1,274 @@
1
+ import { decodeValue, humanize, type CollectionManifest, type EntrySummary } from '@vespercms/core';
2
+ import { ButtonLink, Link, Select, Table, TextField, type TableColumn } from '@vespercms/ui';
3
+ import { useEffect, useMemo, useState } from 'react';
4
+ import type { SortDescriptor } from 'react-aria-components';
5
+ import { useStudio } from '../app/context.tsx';
6
+ import { entryShownTo, mayCreateIn } from '../cloud/access.ts';
7
+ import { formatRelative } from '../app/format.ts';
8
+ import { useLoad } from '../app/load.ts';
9
+ import { hrefFor, navigate } from '../app/router.ts';
10
+ import { collectionIcon } from '../app/collection-icon.ts';
11
+ import { Screen } from '../app/Screen.tsx';
12
+ import { hasUpcoming, rowData, scheduleWords, siteZone } from '../schedule/times.ts';
13
+
14
+ type Filter = 'all' | 'drafts' | 'attention' | 'scheduled';
15
+
16
+ /**
17
+ * A collection's entries (spec §10.3): one row per entry with its title,
18
+ * the collection's list fields, when it was edited, and its status in
19
+ * words. One search box and one status filter; sort by column header.
20
+ */
21
+ export function EntryList({ collection }: { collection: CollectionManifest }) {
22
+ const { api, cloud, manifest } = useStudio();
23
+ const { zone } = siteZone(manifest);
24
+ const [load] = useLoad(() => api.listEntries(collection.name), collection.name);
25
+ const [query, setQuery] = useState('');
26
+ const [filter, setFilter] = useState<Filter>('all');
27
+ const [sort, setSort] = useState<SortDescriptor>(() => defaultSort(collection));
28
+ // In the cloud, only the entries the member can edit (spec §7.5); viewers see all.
29
+ const entries = useMemo(
30
+ () => (load.state === 'ready' ? load.value.filter((e) => entryShownTo(cloud, e.path)) : []),
31
+ [load, cloud],
32
+ );
33
+
34
+ // A singleton has no list: open its one entry, or start it.
35
+ const singleton = collection.presentation.singleton === true;
36
+ useEffect(() => {
37
+ if (!singleton || load.state !== 'ready') return;
38
+ const [only] = load.value;
39
+ if (only)
40
+ navigate({ screen: 'edit', collection: collection.name, id: only.id }, { replace: true });
41
+ else if (collection.editable)
42
+ navigate({ screen: 'new', collection: collection.name }, { replace: true });
43
+ }, [singleton, load, collection]);
44
+
45
+ const listKeys = (collection.presentation.listFields ?? []).filter((key) => !TITLE_KEYS.has(key));
46
+ const columns: TableColumn[] = [
47
+ { id: 'title', label: 'Title', isRowHeader: true, allowsSorting: true },
48
+ ...listKeys.map((key) => ({
49
+ id: `field:${key}`,
50
+ label: labelFor(collection, key),
51
+ allowsSorting: true,
52
+ })),
53
+ { id: 'updatedAt', label: 'Edited', allowsSorting: true },
54
+ { id: 'status', label: 'Status' },
55
+ ];
56
+
57
+ const needsAttention = entries.some((e) => e.status !== 'editable');
58
+ const hasDrafts = entries.some((e) => e.draft);
59
+ const hasScheduled = entries.some((e) => hasUpcoming(e.schedule));
60
+ const rows = useMemo(() => {
61
+ const needle = query.trim().toLowerCase();
62
+ const shown = entries.filter(
63
+ (entry) =>
64
+ (needle === '' ||
65
+ entry.title.toLowerCase().includes(needle) ||
66
+ entry.id.toLowerCase().includes(needle)) &&
67
+ (filter === 'all' ||
68
+ (filter === 'drafts' && entry.draft === true) ||
69
+ (filter === 'attention' && entry.status !== 'editable') ||
70
+ (filter === 'scheduled' && hasUpcoming(entry.schedule))),
71
+ );
72
+ return [...shown].sort(compareBy(sort));
73
+ }, [entries, query, filter, sort]);
74
+ const words = (entry: EntrySummary) =>
75
+ scheduleWords(
76
+ collection.presentation.schedule,
77
+ rowData(collection.presentation.schedule, entry.schedule),
78
+ zone,
79
+ );
80
+
81
+ return (
82
+ <Screen
83
+ title={collection.label}
84
+ icon={collectionIcon(collection)}
85
+ afterglow={load.state === 'ready' && entries.length === 0}
86
+ >
87
+ {!collection.editable && (
88
+ <p className="mb-6 max-w-2xl text-content-muted">
89
+ {collection.label} can be read here but not edited.
90
+ </p>
91
+ )}
92
+ {load.state === 'failed' && (
93
+ <p role="alert" className="mb-6 text-danger">
94
+ Couldn’t load entries: {load.message}
95
+ </p>
96
+ )}
97
+ <div className="mb-6 flex flex-wrap items-end gap-4">
98
+ <TextField
99
+ label="Search"
100
+ type="search"
101
+ icon="search"
102
+ value={query}
103
+ onChange={setQuery}
104
+ className="w-full max-w-xs"
105
+ />
106
+ {(hasDrafts || needsAttention || hasScheduled) && (
107
+ <Select
108
+ label="Show"
109
+ value={filter}
110
+ onChange={(value) => {
111
+ setFilter((value ?? 'all') as Filter);
112
+ }}
113
+ options={[
114
+ { value: 'all', label: 'All entries' },
115
+ ...(hasDrafts ? [{ value: 'drafts', label: 'Drafts' }] : []),
116
+ ...(needsAttention ? [{ value: 'attention', label: 'Needs a developer' }] : []),
117
+ ...(hasScheduled ? [{ value: 'scheduled', label: 'Scheduled' }] : []),
118
+ ]}
119
+ className="w-48"
120
+ />
121
+ )}
122
+ {filter === 'scheduled' && (
123
+ <Link href={hrefFor({ screen: 'scheduled' })} className="mb-2">
124
+ Everything scheduled on the site
125
+ </Link>
126
+ )}
127
+ {collection.editable && !singleton && mayCreateIn(cloud, collection) && (
128
+ // The screen's one primary action.
129
+ <ButtonLink
130
+ variant="primary"
131
+ icon="add"
132
+ href={hrefFor({ screen: 'new', collection: collection.name })}
133
+ className="ml-auto"
134
+ >
135
+ New entry
136
+ </ButtonLink>
137
+ )}
138
+ </div>
139
+ {load.state === 'loading' ? (
140
+ <p className="text-small text-content-muted">Loading entries…</p>
141
+ ) : (
142
+ <Table
143
+ label={collection.label}
144
+ columns={columns}
145
+ rows={rows}
146
+ sort={sort}
147
+ onSortChange={setSort}
148
+ rowHref={(entry) =>
149
+ hrefFor({ screen: 'edit', collection: collection.name, id: entry.id })
150
+ }
151
+ emptyState={entries.length === 0 ? 'No entries yet.' : 'No entries match.'}
152
+ renderCell={(entry, column) => (
153
+ <Cell entry={entry} column={column} schedule={words(entry)} />
154
+ )}
155
+ />
156
+ )}
157
+ <p className="sr-only" role="status">
158
+ {load.state === 'ready'
159
+ ? `${String(rows.length)} of ${String(entries.length)} entries shown.`
160
+ : ''}
161
+ </p>
162
+ </Screen>
163
+ );
164
+ }
165
+
166
+ export const TITLE_KEYS = new Set(['title', 'name', 'heading', 'label']);
167
+
168
+ function Cell({
169
+ entry,
170
+ column,
171
+ schedule,
172
+ }: {
173
+ entry: EntrySummary;
174
+ column: string;
175
+ schedule: string | null;
176
+ }) {
177
+ if (column === 'title') {
178
+ return (
179
+ <span className="font-semibold text-content">
180
+ {entry.title}
181
+ {entry.title !== entry.id && (
182
+ <span className="block font-mono text-caption font-normal text-content-muted">
183
+ {entry.id}
184
+ </span>
185
+ )}
186
+ </span>
187
+ );
188
+ }
189
+ if (column === 'updatedAt') {
190
+ return (
191
+ <span className="text-content-muted">
192
+ {entry.updatedAt ? formatRelative(entry.updatedAt) : ''}
193
+ </span>
194
+ );
195
+ }
196
+ if (column === 'status') {
197
+ return <span className="text-content-muted">{statusText(entry, schedule)}</span>;
198
+ }
199
+ return <span>{formatListValue(entry.fields[column.slice('field:'.length)])}</span>;
200
+ }
201
+
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
+ /** A list field's value as short text. Dates at midnight UTC are calendar dates. */
219
+ export function formatListValue(encoded: unknown): string {
220
+ const value = decodeValue(encoded);
221
+ if (value instanceof Date) {
222
+ const dateOnly = value.getUTCHours() === 0 && value.getUTCMinutes() === 0;
223
+ return value.toLocaleDateString(undefined, {
224
+ dateStyle: 'medium',
225
+ ...(dateOnly ? { timeZone: 'UTC' } : {}),
226
+ } as Intl.DateTimeFormatOptions);
227
+ }
228
+ if (Array.isArray(value)) return value.map((v) => formatListValue(v)).join(', ');
229
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
230
+ if (value === null || value === undefined) return '';
231
+ if (typeof value === 'object') return '…';
232
+ return String(value);
233
+ }
234
+
235
+ export function labelFor(collection: CollectionManifest, key: string): string {
236
+ return (
237
+ collection.fields?.find((f) => f.path.length === 1 && f.path[0] === key)?.label ?? humanize(key)
238
+ );
239
+ }
240
+
241
+ export function defaultSort(collection: CollectionManifest): SortDescriptor {
242
+ // A collection in a set order is listed in that order.
243
+ const { orderBy } = collection.presentation;
244
+ if (orderBy) return { column: `field:${orderBy}`, direction: 'ascending' };
245
+ const dateKey = (collection.presentation.listFields ?? []).find((key) =>
246
+ collection.fields?.some((f) => f.path[0] === key && f.path.length === 1 && f.kind === 'date'),
247
+ );
248
+ return dateKey
249
+ ? { column: `field:${dateKey}`, direction: 'descending' }
250
+ : { column: 'title', direction: 'ascending' };
251
+ }
252
+
253
+ function sortValue(entry: EntrySummary, column: string): string | number {
254
+ if (column === 'title') return entry.title.toLowerCase();
255
+ if (column === 'updatedAt') return entry.updatedAt ?? '';
256
+ const value = decodeValue(entry.fields[column.slice('field:'.length)]);
257
+ if (value instanceof Date) return value.getTime();
258
+ if (typeof value === 'number') return value;
259
+ return formatListValue(entry.fields[column.slice('field:'.length)]).toLowerCase();
260
+ }
261
+
262
+ export function compareBy(sort: SortDescriptor) {
263
+ const column = String(sort.column);
264
+ const direction = sort.direction === 'descending' ? -1 : 1;
265
+ return (a: EntrySummary, b: EntrySummary) => {
266
+ const x = sortValue(a, column);
267
+ const y = sortValue(b, column);
268
+ if (x === y) return a.title.localeCompare(b.title);
269
+ // Entries without the value come last either way, as an ordered collection's do.
270
+ if (x === '') return 1;
271
+ if (y === '') return -1;
272
+ return (x < y ? -1 : 1) * direction;
273
+ };
274
+ }
@@ -0,0 +1,131 @@
1
+ import type { CollectionManifest, CollectionSummary } from '@vespercms/core';
2
+ import { Icon, InlineStatus, Link } from '@vespercms/ui';
3
+ import { useStudio } from '../app/context.tsx';
4
+ import { formatCount, formatRelative } from '../app/format.ts';
5
+ import { useLoad } from '../app/load.ts';
6
+ import { hrefFor } from '../app/router.ts';
7
+ import { collectionIcon } from '../app/collection-icon.ts';
8
+ import { Screen } from '../app/Screen.tsx';
9
+ import { shownTo } from '../cloud/access.ts';
10
+ import { DraftsWaiting } from '../cloud/changes.tsx';
11
+ import { ScheduledLine } from '../schedule/ScheduledView.tsx';
12
+
13
+ /**
14
+ * The home screen (spec §10.3): a plain list of collections and singletons,
15
+ * with entry counts and when each was last edited. Nothing else. One screen
16
+ * at a time only: among panes, the structure pane lists them (app/panes.tsx).
17
+ */
18
+ export function Home() {
19
+ const { manifest, api, cloud } = useStudio();
20
+ const [summary] = useLoad(() => api.summary());
21
+ const byName = new Map<string, CollectionSummary>(
22
+ summary.state === 'ready' ? summary.value.map((s) => [s.collection, s]) : [],
23
+ );
24
+
25
+ // In the cloud, only what the member can edit (spec §7.5).
26
+ const shown = manifest.collections.filter((c) => shownTo(cloud, c.name));
27
+ const collections = shown.filter((c) => !c.presentation.singleton);
28
+ const singletons = shown.filter((c) => c.presentation.singleton);
29
+
30
+ return (
31
+ <Screen title="Content" afterglow>
32
+ {cloud && (
33
+ <div className="mb-8 max-w-2xl">
34
+ <DraftsWaiting />
35
+ </div>
36
+ )}
37
+ <ScheduledLine className="mb-8 max-w-2xl" />
38
+ {summary.state === 'failed' && (
39
+ <p className="mb-6 text-danger" role="alert">
40
+ Couldn’t load entry counts: {summary.message}
41
+ </p>
42
+ )}
43
+ {manifest.collections.length === 0 ? (
44
+ <p className="max-w-2xl text-content-muted">
45
+ This site has no content collections yet. Add one in{' '}
46
+ <code className="font-mono text-small">src/content.config.ts</code> and it appears here.
47
+ </p>
48
+ ) : (
49
+ <div className="grid max-w-2xl gap-12">
50
+ {collections.length > 0 && (
51
+ <CollectionList
52
+ heading="Collections"
53
+ collections={collections}
54
+ summaries={byName}
55
+ loading={summary.state === 'loading'}
56
+ />
57
+ )}
58
+ {singletons.length > 0 && (
59
+ <CollectionList
60
+ heading="Settings"
61
+ collections={singletons}
62
+ summaries={byName}
63
+ loading={summary.state === 'loading'}
64
+ />
65
+ )}
66
+ </div>
67
+ )}
68
+ </Screen>
69
+ );
70
+ }
71
+
72
+ function CollectionList({
73
+ heading,
74
+ collections,
75
+ summaries,
76
+ loading,
77
+ }: {
78
+ heading: string;
79
+ collections: CollectionManifest[];
80
+ summaries: Map<string, CollectionSummary>;
81
+ loading: boolean;
82
+ }) {
83
+ return (
84
+ <section aria-labelledby={`home-${heading}`}>
85
+ <h2 id={`home-${heading}`} className="mb-3 text-heading-2 text-content">
86
+ {heading}
87
+ </h2>
88
+ <ul className="divide-y divide-border border-y border-border">
89
+ {collections.map((collection) => (
90
+ <li
91
+ key={collection.name}
92
+ className="flex min-h-12 flex-wrap items-center justify-between gap-x-6 gap-y-1 py-3"
93
+ >
94
+ <span className="flex min-w-0 items-center gap-3">
95
+ <Icon {...collectionIcon(collection)} className="text-content-muted" />
96
+ <Link variant="title" href={hrefFor({ screen: 'list', collection: collection.name })}>
97
+ {collection.label}
98
+ </Link>
99
+ </span>
100
+ <CollectionFacts
101
+ collection={collection}
102
+ summary={summaries.get(collection.name)}
103
+ loading={loading}
104
+ />
105
+ </li>
106
+ ))}
107
+ </ul>
108
+ </section>
109
+ );
110
+ }
111
+
112
+ function CollectionFacts({
113
+ collection,
114
+ summary,
115
+ loading,
116
+ }: {
117
+ collection: CollectionManifest;
118
+ summary: CollectionSummary | undefined;
119
+ loading: boolean;
120
+ }) {
121
+ const facts: string[] = [];
122
+ if (!collection.editable) facts.push('Read only');
123
+ if (summary) {
124
+ if (!collection.presentation.singleton)
125
+ facts.push(formatCount(summary.count, 'entry', 'entries'));
126
+ if (summary.updatedAt) facts.push(`edited ${formatRelative(summary.updatedAt)}`);
127
+ } else if (loading) {
128
+ facts.push('Loading…');
129
+ }
130
+ return <InlineStatus live={false}>{facts.join(' · ')}</InlineStatus>;
131
+ }