@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.
- package/README.md +119 -2
- package/dist/BodyEditor-CAGNfofv.js +3047 -0
- package/dist/a11y/FieldNotes.d.ts +23 -0
- package/dist/a11y/NotesDrawer.d.ts +23 -0
- package/dist/a11y/pdf.d.ts +3 -0
- package/dist/a11y/run.d.ts +3 -0
- package/dist/a11y/useNotes.d.ts +28 -0
- package/dist/app/App.d.ts +2 -0
- package/dist/app/Screen.d.ts +88 -0
- package/dist/app/UserMenu.d.ts +7 -0
- package/dist/app/api.d.ts +75 -0
- package/dist/app/appearance.d.ts +10 -0
- package/dist/app/browser-api.d.ts +17 -0
- package/dist/app/collection-icon.d.ts +12 -0
- package/dist/app/context.d.ts +23 -0
- package/dist/app/entries-changed.d.ts +11 -0
- package/dist/app/file-url.d.ts +13 -0
- package/dist/app/format.d.ts +4 -0
- package/dist/app/idb.d.ts +7 -0
- package/dist/app/load.d.ts +16 -0
- package/dist/app/media.d.ts +5 -0
- package/dist/app/navigation.d.ts +4 -0
- package/dist/app/pane-context.d.ts +19 -0
- package/dist/app/panes.d.ts +14 -0
- package/dist/app/router.d.ts +62 -0
- package/dist/body/BodyEditor.d.ts +42 -0
- package/dist/body/TableView.d.ts +2 -0
- package/dist/body/catalog.d.ts +47 -0
- package/dist/body/checks.d.ts +15 -0
- package/dist/body/chips.d.ts +31 -0
- package/dist/body/commands.d.ts +31 -0
- package/dist/body/document.d.ts +42 -0
- package/dist/body/insert.d.ts +69 -0
- package/dist/body/nodes.d.ts +38 -0
- package/dist/body/reveal.d.ts +13 -0
- package/dist/body/scroll.d.ts +16 -0
- package/dist/body/settings.d.ts +19 -0
- package/dist/body/shortcuts.d.ts +13 -0
- package/dist/body/table.d.ts +31 -0
- package/dist/body/ui.d.ts +30 -0
- package/dist/body/views.d.ts +9 -0
- package/dist/checks-C-0xDu-I.js +57 -0
- package/dist/cloud/CloudGate.d.ts +14 -0
- package/dist/cloud/access.d.ts +14 -0
- package/dist/cloud/changes.d.ts +26 -0
- package/dist/cloud/connection.d.ts +100 -0
- package/dist/cloud/editor.d.ts +44 -0
- package/dist/cloud/session.d.ts +62 -0
- package/dist/cloud/storage.d.ts +45 -0
- package/dist/editor/Field.d.ts +24 -0
- package/dist/editor/PagePicker.d.ts +20 -0
- package/dist/editor/arrange.d.ts +13 -0
- package/dist/editor/context.d.ts +23 -0
- package/dist/editor/copy.d.ts +9 -0
- package/dist/editor/reveal.d.ts +7 -0
- package/dist/editor/target.d.ts +21 -0
- package/dist/editor/values.d.ts +18 -0
- package/dist/geo-map-CT3OGD7O.js +110 -0
- package/dist/github/GitHubGate.d.ts +15 -0
- package/dist/github/api.d.ts +20 -0
- package/dist/github/cache.d.ts +2 -0
- package/dist/github/session.d.ts +35 -0
- package/dist/index.d.ts +3 -0
- package/dist/markdown-KUkv11EB.js +211 -0
- package/dist/media/MediaPicker.d.ts +28 -0
- package/dist/media/file-icon.d.ts +3 -0
- package/dist/media/formats.d.ts +23 -0
- package/dist/media/hygiene.d.ts +37 -0
- package/dist/media/upload.d.ts +31 -0
- package/dist/mount.d.ts +3 -0
- package/dist/pdf-m5s_uXJ3.js +24 -0
- package/dist/run-y4XRkg17.js +7 -0
- package/dist/schedule/ScheduleDialog.d.ts +28 -0
- package/dist/schedule/ScheduleSummary.d.ts +12 -0
- package/dist/schedule/ScheduledView.d.ts +8 -0
- package/dist/schedule/cloud.d.ts +21 -0
- package/dist/schedule/now.d.ts +5 -0
- package/dist/schedule/times.d.ts +37 -0
- package/dist/screens/EntryEditor.d.ts +13 -0
- package/dist/screens/EntryList.d.ts +18 -0
- package/dist/screens/Home.d.ts +6 -0
- package/dist/screens/ListPane.d.ts +6 -0
- package/dist/screens/MediaBrowser.d.ts +7 -0
- package/dist/screens/NotFound.d.ts +3 -0
- package/dist/screens/PageTree.d.ts +10 -0
- package/dist/screens/Redirects.d.ts +1 -0
- package/dist/screens/Unavailable.d.ts +8 -0
- package/dist/shortcuts-B_JMAvF9.js +4630 -0
- package/dist/studio.css +3 -0
- package/dist/studio.js +6312 -0
- package/dist/types.d.ts +76 -0
- package/dist/widgets/basic.d.ts +25 -0
- package/dist/widgets/crops.d.ts +43 -0
- package/dist/widgets/focal-point.d.ts +15 -0
- package/dist/widgets/geo-map.d.ts +10 -0
- package/dist/widgets/geo.d.ts +16 -0
- package/dist/widgets/group.d.ts +41 -0
- package/dist/widgets/icon.d.ts +9 -0
- package/dist/widgets/image.d.ts +16 -0
- package/dist/widgets/markdown-lazy.d.ts +2 -0
- package/dist/widgets/markdown.d.ts +2 -0
- package/dist/widgets/reference.d.ts +12 -0
- package/dist/widgets/registry.d.ts +32 -0
- package/dist/widgets/special.d.ts +20 -0
- package/dist/widgets/union.d.ts +36 -0
- package/package.json +63 -4
- package/src/a11y/FieldNotes.tsx +56 -0
- package/src/a11y/NotesDrawer.tsx +81 -0
- package/src/a11y/pdf.ts +45 -0
- package/src/a11y/run.ts +12 -0
- package/src/a11y/useNotes.ts +139 -0
- package/src/app/App.tsx +156 -0
- package/src/app/Screen.tsx +416 -0
- package/src/app/UserMenu.tsx +95 -0
- package/src/app/api.ts +176 -0
- package/src/app/appearance.ts +54 -0
- package/src/app/browser-api.ts +173 -0
- package/src/app/collection-icon.ts +15 -0
- package/src/app/context.tsx +32 -0
- package/src/app/entries-changed.ts +48 -0
- package/src/app/file-url.tsx +44 -0
- package/src/app/format.ts +39 -0
- package/src/app/idb.ts +82 -0
- package/src/app/load.ts +40 -0
- package/src/app/media.ts +20 -0
- package/src/app/navigation.ts +4 -0
- package/src/app/pane-context.ts +25 -0
- package/src/app/panes.tsx +513 -0
- package/src/app/router.ts +158 -0
- package/src/body/BodyEditor.tsx +1139 -0
- package/src/body/TableView.tsx +700 -0
- package/src/body/catalog.ts +198 -0
- package/src/body/checks.ts +83 -0
- package/src/body/chips.ts +150 -0
- package/src/body/commands.ts +191 -0
- package/src/body/document.ts +388 -0
- package/src/body/insert.tsx +301 -0
- package/src/body/nodes.ts +287 -0
- package/src/body/reveal.ts +93 -0
- package/src/body/scroll.ts +53 -0
- package/src/body/settings.tsx +510 -0
- package/src/body/shortcuts.ts +31 -0
- package/src/body/table.ts +192 -0
- package/src/body/ui.ts +77 -0
- package/src/body/views.tsx +268 -0
- package/src/build.ts +6 -0
- package/src/cloud/CloudGate.tsx +245 -0
- package/src/cloud/access.ts +52 -0
- package/src/cloud/changes.tsx +474 -0
- package/src/cloud/connection.ts +281 -0
- package/src/cloud/editor.tsx +250 -0
- package/src/cloud/session.ts +289 -0
- package/src/cloud/storage.ts +212 -0
- package/src/editor/Field.tsx +182 -0
- package/src/editor/PagePicker.tsx +102 -0
- package/src/editor/arrange.ts +98 -0
- package/src/editor/context.tsx +31 -0
- package/src/editor/copy.ts +30 -0
- package/src/editor/reveal.ts +38 -0
- package/src/editor/target.ts +104 -0
- package/src/editor/values.ts +71 -0
- package/src/github/GitHubGate.tsx +261 -0
- package/src/github/api.ts +81 -0
- package/src/github/cache.ts +64 -0
- package/src/github/session.ts +146 -0
- package/src/index.ts +10 -0
- package/src/media/MediaPicker.tsx +302 -0
- package/src/media/file-icon.ts +17 -0
- package/src/media/formats.ts +262 -0
- package/src/media/hygiene.ts +220 -0
- package/src/media/upload.ts +47 -0
- package/src/mount.tsx +20 -0
- package/src/schedule/ScheduleDialog.tsx +300 -0
- package/src/schedule/ScheduleSummary.tsx +59 -0
- package/src/schedule/ScheduledView.tsx +294 -0
- package/src/schedule/cloud.ts +66 -0
- package/src/schedule/now.ts +18 -0
- package/src/schedule/times.ts +133 -0
- package/src/screens/EntryEditor.tsx +1803 -0
- package/src/screens/EntryList.tsx +274 -0
- package/src/screens/Home.tsx +131 -0
- package/src/screens/ListPane.tsx +459 -0
- package/src/screens/MediaBrowser.tsx +253 -0
- package/src/screens/NotFound.tsx +19 -0
- package/src/screens/PageTree.tsx +718 -0
- package/src/screens/Redirects.tsx +522 -0
- package/src/screens/Unavailable.tsx +27 -0
- package/src/studio.css +163 -0
- package/src/types.ts +82 -0
- package/src/widgets/basic.tsx +311 -0
- package/src/widgets/crops.tsx +399 -0
- package/src/widgets/focal-point.tsx +240 -0
- package/src/widgets/geo-map.tsx +160 -0
- package/src/widgets/geo.tsx +170 -0
- package/src/widgets/group.tsx +827 -0
- package/src/widgets/icon.tsx +47 -0
- package/src/widgets/image.tsx +407 -0
- package/src/widgets/markdown-lazy.tsx +22 -0
- package/src/widgets/markdown.tsx +267 -0
- package/src/widgets/reference.tsx +196 -0
- package/src/widgets/registry.ts +104 -0
- package/src/widgets/special.tsx +286 -0
- 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
|
+
}
|