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