astro-dev-edit 0.11.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/LICENSE +21 -0
- package/README.md +125 -0
- package/package.json +52 -0
- package/src/client/admin-bar.ts +622 -0
- package/src/client/api.ts +370 -0
- package/src/client/classify-cache.ts +61 -0
- package/src/client/css-inspect.ts +345 -0
- package/src/client/editors/asset-picker.ts +155 -0
- package/src/client/editors/body-editor.ts +419 -0
- package/src/client/editors/collections-panel.ts +1532 -0
- package/src/client/editors/copy-panel.ts +73 -0
- package/src/client/editors/drawer.ts +95 -0
- package/src/client/editors/entry.ts +433 -0
- package/src/client/editors/expression.ts +77 -0
- package/src/client/editors/fields.ts +309 -0
- package/src/client/editors/image.ts +268 -0
- package/src/client/editors/markup-insert.ts +73 -0
- package/src/client/editors/markup.ts +125 -0
- package/src/client/editors/media-grid.ts +326 -0
- package/src/client/editors/media-modal.ts +588 -0
- package/src/client/editors/notice.ts +160 -0
- package/src/client/editors/peek.ts +135 -0
- package/src/client/editors/settings-panel.ts +457 -0
- package/src/client/editors/source-popup.ts +166 -0
- package/src/client/editors/text.ts +105 -0
- package/src/client/editors/unsplash-pane.ts +317 -0
- package/src/client/element-context.ts +308 -0
- package/src/client/features.ts +81 -0
- package/src/client/focus.ts +166 -0
- package/src/client/group.ts +186 -0
- package/src/client/highlight.ts +146 -0
- package/src/client/hover.ts +485 -0
- package/src/client/icons.ts +160 -0
- package/src/client/markdown.ts +319 -0
- package/src/client/overlay.ts +466 -0
- package/src/client/page-source.ts +143 -0
- package/src/client/router.ts +198 -0
- package/src/client/shadow.ts +111 -0
- package/src/client/source-map.ts +150 -0
- package/src/client/state.ts +153 -0
- package/src/client/styles.ts +3485 -0
- package/src/client/tree-model.ts +45 -0
- package/src/client/tree.ts +366 -0
- package/src/client/ui.ts +987 -0
- package/src/client/unsplash-search.ts +250 -0
- package/src/index.ts +299 -0
- package/src/patcher/astro.ts +792 -0
- package/src/patcher/content-config.ts +1035 -0
- package/src/patcher/dotenv.ts +121 -0
- package/src/patcher/expression-trace.ts +326 -0
- package/src/patcher/frontmatter.ts +249 -0
- package/src/patcher/registry.ts +11 -0
- package/src/patcher/types.ts +32 -0
- package/src/server/annotate.ts +173 -0
- package/src/server/assets.ts +167 -0
- package/src/server/collection-entries.ts +91 -0
- package/src/server/content-config.ts +210 -0
- package/src/server/editor.ts +15 -0
- package/src/server/entry-detect.ts +110 -0
- package/src/server/entry-resolve-routes.ts +218 -0
- package/src/server/entry-routes.ts +304 -0
- package/src/server/inspect-locate.ts +81 -0
- package/src/server/inspect-routes.ts +94 -0
- package/src/server/middleware.ts +480 -0
- package/src/server/options.ts +778 -0
- package/src/server/page-source-routes.ts +71 -0
- package/src/server/paths.ts +219 -0
- package/src/server/private-files.ts +116 -0
- package/src/server/route-manifest.ts +200 -0
- package/src/server/router.ts +94 -0
- package/src/server/schema-introspect.ts +233 -0
- package/src/server/schema-routes.ts +808 -0
- package/src/server/settings-routes.ts +246 -0
- package/src/server/settings.ts +382 -0
- package/src/server/text-writes.ts +105 -0
- package/src/server/unsplash-routes.ts +515 -0
- package/src/server/zod-adapt.ts +239 -0
- package/src/shared/asset-path.ts +132 -0
- package/src/shared/protocol.ts +935 -0
- package/src/shared/slug.ts +17 -0
- package/src/shared/unsplash.ts +51 -0
|
@@ -0,0 +1,1532 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CollectionEntryItem,
|
|
3
|
+
CollectionSummary,
|
|
4
|
+
CollectionsResponse,
|
|
5
|
+
FieldDescriptor,
|
|
6
|
+
FieldOverride,
|
|
7
|
+
FieldType,
|
|
8
|
+
SchemaFieldSpec,
|
|
9
|
+
SchemaForm,
|
|
10
|
+
} from '../../shared/protocol.ts';
|
|
11
|
+
import * as api from '../api.ts';
|
|
12
|
+
import { has } from '../features.ts';
|
|
13
|
+
import { clearHighlight } from '../hover.ts';
|
|
14
|
+
import { icon, type IconName } from '../icons.ts';
|
|
15
|
+
import {
|
|
16
|
+
badge,
|
|
17
|
+
buildTabs,
|
|
18
|
+
footButton,
|
|
19
|
+
inputEl,
|
|
20
|
+
setButtonEnabled,
|
|
21
|
+
styled,
|
|
22
|
+
switchControl,
|
|
23
|
+
toast,
|
|
24
|
+
} from '../ui.ts';
|
|
25
|
+
import { card, item, itemGroup } from '../group.ts';
|
|
26
|
+
import { openCopyPanel } from './copy-panel.ts';
|
|
27
|
+
import { openDrawer } from './drawer.ts';
|
|
28
|
+
import { openEntryCreatePanel, openEntryPanel } from './entry.ts';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The Collections drawer — the collection and field designer.
|
|
32
|
+
*
|
|
33
|
+
* A **peer** of the Settings drawer, opened from its own item in the admin bar's
|
|
34
|
+
* menu rather than as a tab inside Settings. Options and content structure are
|
|
35
|
+
* different jobs: an option is a switch on this tool, while a collection's shape
|
|
36
|
+
* is the project's own committed source. Reaching the designer should not mean
|
|
37
|
+
* going through a settings screen first.
|
|
38
|
+
*
|
|
39
|
+
* Shaped like Webflow's collections UI, adapted to a drawer: a list of
|
|
40
|
+
* collections, then one collection's field table, then a create form. What it
|
|
41
|
+
* does *not* borrow from Webflow is the illusion of a single store, because there
|
|
42
|
+
* are two, and which one a control writes to matters:
|
|
43
|
+
*
|
|
44
|
+
* - **Schema** (type, required, default, add, remove) → `src/content.config.ts`.
|
|
45
|
+
* Committed source. It changes what `astro build` accepts, and it is the half
|
|
46
|
+
* `schemaEditor: false` switches off.
|
|
47
|
+
* - **Editor** (widget, label, hidden) → `.astro-dev-edit.json`. Local,
|
|
48
|
+
* gitignored, and only the entry drawer reads it.
|
|
49
|
+
*
|
|
50
|
+
* Every field row carries both halves under those two words, and the legend at
|
|
51
|
+
* the top of the field table says what each one means. A save sends one request;
|
|
52
|
+
* the response reports each half separately, so a partial outcome is stated
|
|
53
|
+
* rather than smoothed over.
|
|
54
|
+
*
|
|
55
|
+
* **Refusals are surfaced, not worked around.** A schema the patcher can't prove
|
|
56
|
+
* (built by a helper, holding a spread) makes the schema half read-only for that
|
|
57
|
+
* collection and offers "Open source" instead — the same stance the rest of the
|
|
58
|
+
* overlay takes when the AST can't answer.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/** Types the designer can write into a schema. `textarea` is deliberately absent:
|
|
62
|
+
* it is a *widget*, not a schema shape, and the Widget control below owns it —
|
|
63
|
+
* which is the two-store split working as intended. `json` is absent because it
|
|
64
|
+
* can't be synthesized at all. */
|
|
65
|
+
const SCHEMA_TYPES: readonly FieldType[] = [
|
|
66
|
+
'text',
|
|
67
|
+
'date',
|
|
68
|
+
'number',
|
|
69
|
+
'boolean',
|
|
70
|
+
'select',
|
|
71
|
+
'tags',
|
|
72
|
+
'image',
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** Widget choices for the editor half. `''` means "whatever the schema implies". */
|
|
76
|
+
const WIDGET_CHOICES: ReadonlyArray<[string, string]> = [
|
|
77
|
+
['', 'From schema'],
|
|
78
|
+
['text', 'Text'],
|
|
79
|
+
['textarea', 'Textarea'],
|
|
80
|
+
['date', 'Date'],
|
|
81
|
+
['number', 'Number'],
|
|
82
|
+
['boolean', 'Checkbox'],
|
|
83
|
+
['select', 'Select'],
|
|
84
|
+
['tags', 'Tags'],
|
|
85
|
+
['image', 'Image'],
|
|
86
|
+
['json', 'Read-only JSON'],
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
/** What the `image` option says while the collection can't hold one. Named
|
|
90
|
+
* once: the select is rebuilt on render and re-labelled live by the switch. */
|
|
91
|
+
const IMAGE_UNAVAILABLE = 'Image — turn on Image fields above';
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The Type choices for a schema half, with `image` kept **visible but
|
|
95
|
+
* unpickable** on a plain `z.object` schema rather than filtered out.
|
|
96
|
+
*
|
|
97
|
+
* `image()` is only in scope in the `({ image }) => z.object({ … })` form, so
|
|
98
|
+
* the patcher refuses it otherwise — but silently dropping the option leaves
|
|
99
|
+
* the reason nowhere the eye is looking. The select's popup is drawn by the
|
|
100
|
+
* browser over whatever sits beneath it, so a note under the form is behind the
|
|
101
|
+
* list at exactly the moment the question is asked. The disabled option says
|
|
102
|
+
* *that* there is a change to make and points down; the note below says what
|
|
103
|
+
* the change is, in full, once the popup is out of the way.
|
|
104
|
+
*/
|
|
105
|
+
function schemaTypeChoices(allowImage: boolean): Choice[] {
|
|
106
|
+
return SCHEMA_TYPES.map((t) =>
|
|
107
|
+
t === 'image' && !allowImage
|
|
108
|
+
? ([t, IMAGE_UNAVAILABLE, true] as Choice)
|
|
109
|
+
: ([t, TYPE_LABEL[t]] as Choice),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const TYPE_LABEL: Record<string, string> = {
|
|
114
|
+
text: 'Text',
|
|
115
|
+
textarea: 'Textarea',
|
|
116
|
+
date: 'Date',
|
|
117
|
+
number: 'Number',
|
|
118
|
+
boolean: 'Checkbox',
|
|
119
|
+
select: 'Select',
|
|
120
|
+
tags: 'Tags',
|
|
121
|
+
image: 'Image',
|
|
122
|
+
json: 'Read-only JSON',
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Collection to reopen after a full-page reload.
|
|
127
|
+
*
|
|
128
|
+
* Writing `content.config.ts` makes Astro resync its content layer, which
|
|
129
|
+
* full-reloads the page — and takes this drawer with it, mid-save. Remembering
|
|
130
|
+
* where the user was is the same trick, in the same store, that already carries
|
|
131
|
+
* edit mode across the reload every text save causes.
|
|
132
|
+
*/
|
|
133
|
+
const RESUME_KEY = 'astroDevEditCollection';
|
|
134
|
+
|
|
135
|
+
/** How long a remembered collection stays valid. Long enough to survive the
|
|
136
|
+
* reload (Astro emits more than one while it resyncs, so the key must outlive
|
|
137
|
+
* the first boot), short enough that it can never hijack a later, unrelated
|
|
138
|
+
* visit to the designer. */
|
|
139
|
+
const RESUME_TTL_MS = 20_000;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The collection a schema write is waiting to return to, or null.
|
|
143
|
+
*
|
|
144
|
+
* Deliberately **not** consumed on read: Astro's content resync reloads the page
|
|
145
|
+
* more than once, and a key eaten by the first boot would leave the second with
|
|
146
|
+
* nothing. It expires instead, and {@link clearPendingCollection} drops it the
|
|
147
|
+
* moment the resumed drawer is closed.
|
|
148
|
+
*/
|
|
149
|
+
export function takePendingCollection(): string | null {
|
|
150
|
+
try {
|
|
151
|
+
const raw = sessionStorage.getItem(RESUME_KEY);
|
|
152
|
+
if (!raw) return null;
|
|
153
|
+
const { name, at } = JSON.parse(raw) as { name?: string; at?: number };
|
|
154
|
+
if (!name || typeof at !== 'number' || Date.now() - at > RESUME_TTL_MS) {
|
|
155
|
+
sessionStorage.removeItem(RESUME_KEY);
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
return name;
|
|
159
|
+
} catch {
|
|
160
|
+
// sessionStorage unavailable or junk in it — the drawer just won't reopen.
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Forget the remembered collection. Called when the resumed drawer closes. */
|
|
166
|
+
export function clearPendingCollection(): void {
|
|
167
|
+
remember(null);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function remember(name: string | null): void {
|
|
171
|
+
try {
|
|
172
|
+
if (name) sessionStorage.setItem(RESUME_KEY, JSON.stringify({ name, at: Date.now() }));
|
|
173
|
+
else sessionStorage.removeItem(RESUME_KEY);
|
|
174
|
+
} catch {
|
|
175
|
+
// As above: a missing store costs the convenience, nothing else.
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface CollectionsPaneOptions {
|
|
180
|
+
/**
|
|
181
|
+
* Close the surface this pane lives in, then run `open`.
|
|
182
|
+
*
|
|
183
|
+
* The entry drawers are *peers* of this one, not children: they claim the same
|
|
184
|
+
* interaction slot, so stacking one on the other would leave the outer drawer
|
|
185
|
+
* unable to close itself. Handing off instead is also the honest reading of the
|
|
186
|
+
* gesture — clicking an entry means "edit this entry now".
|
|
187
|
+
*/
|
|
188
|
+
handoff(open: () => void): void;
|
|
189
|
+
/** Open straight into this collection's detail view, when it exists. Set by the
|
|
190
|
+
* overlay after a schema write reloaded the page. */
|
|
191
|
+
initialCollection?: string;
|
|
192
|
+
/**
|
|
193
|
+
* Hand the surface the one button that completes the view being shown, or
|
|
194
|
+
* `null` for a view that completes nothing — the list is a place to look, not
|
|
195
|
+
* a decision to make.
|
|
196
|
+
*
|
|
197
|
+
* The pane cannot draw it itself and be read: the field list is longer than
|
|
198
|
+
* the drawer, so a Save at the end of it sits below the fold behind the
|
|
199
|
+
* scroll while the footer band — the one place the eye goes for the decision
|
|
200
|
+
* — holds nothing but Close. Called on every render, so the surface can
|
|
201
|
+
* assume the previous button is spent.
|
|
202
|
+
*/
|
|
203
|
+
onPrimary?(button: HTMLElement | null): void;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface CollectionsPane {
|
|
207
|
+
/** Mount point for the drawer's body. */
|
|
208
|
+
root: HTMLElement;
|
|
209
|
+
/** Read (or re-read) from the server. Safe to call again at any time. */
|
|
210
|
+
load(): void;
|
|
211
|
+
/** Whether anything is queued but unsaved — folded into the drawer's
|
|
212
|
+
* discard-confirm so a stray backdrop click can't lose a field edit. */
|
|
213
|
+
isDirty(): boolean;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface CollectionsPanelOptions {
|
|
217
|
+
/** Open straight into this collection's fields. Set by the overlay when a
|
|
218
|
+
* schema write reloaded the page out from under the drawer. */
|
|
219
|
+
collection?: string;
|
|
220
|
+
/** Run after the drawer closes, whatever the outcome. */
|
|
221
|
+
onClose?(): void;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Open the Collections drawer.
|
|
226
|
+
*
|
|
227
|
+
* The pane below carries all the state; this is only its shell. The footer band
|
|
228
|
+
* holds Close plus one slot the pane fills with whatever completes the view it
|
|
229
|
+
* is currently showing — Save changes in a collection, Create collection in the
|
|
230
|
+
* new-collection form, nothing at all in the list.
|
|
231
|
+
*
|
|
232
|
+
* It is a slot rather than a fixed drawer-wide Save because there is no such
|
|
233
|
+
* thing here: a schema write and an override write are different stores, and one
|
|
234
|
+
* button standing for both would have to lie about which it meant. What the slot
|
|
235
|
+
* fixes is where the button *is*. Drawn at the end of the pane it sat below the
|
|
236
|
+
* fold behind a field list longer than the drawer, leaving the band that every
|
|
237
|
+
* other drawer uses for the decision holding only the way out.
|
|
238
|
+
*/
|
|
239
|
+
export function openCollectionsPanel(opts: CollectionsPanelOptions = {}): void {
|
|
240
|
+
clearHighlight();
|
|
241
|
+
|
|
242
|
+
// The footer's action slot, declared before the pane that fills it.
|
|
243
|
+
const primary = styled('div', 'atx-collections-primary');
|
|
244
|
+
|
|
245
|
+
const pane = buildCollectionsPane({
|
|
246
|
+
// An entry drawer replaces this one rather than stacking on it (see
|
|
247
|
+
// `handoff`). The dirty check runs first, so a queued field edit can't be
|
|
248
|
+
// lost by clicking an entry.
|
|
249
|
+
handoff: (open) => {
|
|
250
|
+
if (!shell.close()) return;
|
|
251
|
+
open();
|
|
252
|
+
},
|
|
253
|
+
...(opts.collection ? { initialCollection: opts.collection } : {}),
|
|
254
|
+
onPrimary: (button) => {
|
|
255
|
+
primary.textContent = '';
|
|
256
|
+
if (button) primary.append(button);
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const shell = openDrawer('Collections', {
|
|
261
|
+
isDirty: () => pane.isDirty(),
|
|
262
|
+
discardMessage: 'Discard unsaved collection changes?',
|
|
263
|
+
// On the title rather than in a view, because it is true of the whole
|
|
264
|
+
// designer — the list, a collection's fields and the create form alike —
|
|
265
|
+
// and the title is the one thing that survives navigating between them.
|
|
266
|
+
badge: badge('experimental', 'muted'),
|
|
267
|
+
// Wider than the default drawer: a field row carries both stores' controls
|
|
268
|
+
// side by side, and wrapping them would hide the split the legend explains.
|
|
269
|
+
width: 'min(max(560px, 48vw), 96vw)',
|
|
270
|
+
...(opts.onClose ? { onClose: opts.onClose } : {}),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
shell.body.append(pane.root);
|
|
274
|
+
// Close first, then the slot: the pane's own action is the rightmost thing in
|
|
275
|
+
// the band, where the confirm sits in every other drawer.
|
|
276
|
+
shell.foot.append(footButton('Close', 'outline', () => shell.close()), primary);
|
|
277
|
+
pane.load();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function buildCollectionsPane(opts: CollectionsPaneOptions): CollectionsPane {
|
|
281
|
+
const root = styled('div', 'atx-collections');
|
|
282
|
+
|
|
283
|
+
let data: CollectionsResponse | null = null;
|
|
284
|
+
let selected: string | null = null;
|
|
285
|
+
let creating = false;
|
|
286
|
+
let busy = false;
|
|
287
|
+
|
|
288
|
+
/** Pending edits for the selected collection, cleared on every navigation and
|
|
289
|
+
* after a successful save. */
|
|
290
|
+
let editors: FieldEditor[] = [];
|
|
291
|
+
let queuedAdds: SchemaFieldSpec[] = [];
|
|
292
|
+
let removals = new Set<string>();
|
|
293
|
+
/** The schema form staged by the detail view's Image fields switch, or null
|
|
294
|
+
* while it still matches what the config says. Staged like every other schema
|
|
295
|
+
* edit and written by Save changes — a switch that wrote on flip would be the
|
|
296
|
+
* one control in this drawer that commits without being asked to. */
|
|
297
|
+
let formChange: SchemaForm | null = null;
|
|
298
|
+
/** The same switch on the create form, which has no config to compare to. */
|
|
299
|
+
let createForm: SchemaForm = 'object';
|
|
300
|
+
/** Field specs typed into the create form. */
|
|
301
|
+
let newFields: SchemaFieldSpec[] = [];
|
|
302
|
+
let createDirty = false;
|
|
303
|
+
/**
|
|
304
|
+
* Type selects that must re-answer "can this be an image?" when the switch
|
|
305
|
+
* moves. Re-rendering the view instead would be simpler and wrong: it rebuilds
|
|
306
|
+
* the field editors from the server's copy, throwing away every other staged
|
|
307
|
+
* edit — so the switch would silently undo work.
|
|
308
|
+
*/
|
|
309
|
+
let imageAvailability: Array<(allow: boolean) => void> = [];
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Keep one Type select in step with the switch. `keepOwn` is for a field that
|
|
313
|
+
* is *already* an image: its own type stays selectable whatever the switch
|
|
314
|
+
* says, so the control can never fail to show the value it holds.
|
|
315
|
+
*/
|
|
316
|
+
function bindImageChoice(sel: HTMLSelectElement, keepOwn = false): HTMLSelectElement {
|
|
317
|
+
const opt = [...sel.options].find((o) => o.value === 'image');
|
|
318
|
+
if (opt) {
|
|
319
|
+
imageAvailability.push((allow) => {
|
|
320
|
+
const on = allow || (keepOwn && sel.value === 'image');
|
|
321
|
+
opt.disabled = !on;
|
|
322
|
+
opt.textContent = on ? TYPE_LABEL.image : IMAGE_UNAVAILABLE;
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return sel;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const setImageAvailable = (allow: boolean): void => {
|
|
329
|
+
for (const fn of imageAvailability) fn(allow);
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/** What a collection's schema form *would* be if the pending edits were saved.
|
|
333
|
+
* Every Type control asks this rather than `c.schemaForm`, so ticking the
|
|
334
|
+
* switch makes Image pickable in the same sitting rather than after a save. */
|
|
335
|
+
const wantedForm = (c: CollectionSummary): SchemaForm =>
|
|
336
|
+
formChange ?? c.schemaForm ?? 'object';
|
|
337
|
+
/** Consumed by the first load, so a later navigation isn't hijacked. */
|
|
338
|
+
let initial = opts.initialCollection ?? null;
|
|
339
|
+
|
|
340
|
+
const resetPending = (): void => {
|
|
341
|
+
editors = [];
|
|
342
|
+
queuedAdds = [];
|
|
343
|
+
removals = new Set();
|
|
344
|
+
formChange = null;
|
|
345
|
+
createForm = 'object';
|
|
346
|
+
newFields = [];
|
|
347
|
+
createDirty = false;
|
|
348
|
+
imageAvailability = [];
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const isDirty = (): boolean =>
|
|
352
|
+
!busy &&
|
|
353
|
+
(queuedAdds.length > 0 ||
|
|
354
|
+
removals.size > 0 ||
|
|
355
|
+
formChange !== null ||
|
|
356
|
+
createDirty ||
|
|
357
|
+
newFields.length > 0 ||
|
|
358
|
+
editors.some((e) => e.dirty()));
|
|
359
|
+
|
|
360
|
+
// --- navigation ------------------------------------------------------------
|
|
361
|
+
const goList = (): void => {
|
|
362
|
+
selected = null;
|
|
363
|
+
creating = false;
|
|
364
|
+
resetPending();
|
|
365
|
+
render();
|
|
366
|
+
};
|
|
367
|
+
const goDetail = (name: string): void => {
|
|
368
|
+
selected = name;
|
|
369
|
+
creating = false;
|
|
370
|
+
resetPending();
|
|
371
|
+
render();
|
|
372
|
+
};
|
|
373
|
+
const goCreate = (): void => {
|
|
374
|
+
selected = null;
|
|
375
|
+
creating = true;
|
|
376
|
+
resetPending();
|
|
377
|
+
render();
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const load = (): void => {
|
|
381
|
+
root.textContent = '';
|
|
382
|
+
opts.onPrimary?.(null);
|
|
383
|
+
root.append(note([icon('spinner', 16), textNode('Reading collections…')], 'muted'));
|
|
384
|
+
void api.listCollections().then(
|
|
385
|
+
(next) => {
|
|
386
|
+
data = next;
|
|
387
|
+
if (initial) {
|
|
388
|
+
if (next.collections.some((c) => c.name === initial)) selected = initial;
|
|
389
|
+
initial = null;
|
|
390
|
+
}
|
|
391
|
+
render();
|
|
392
|
+
},
|
|
393
|
+
(err: unknown) => {
|
|
394
|
+
root.textContent = '';
|
|
395
|
+
root.append(
|
|
396
|
+
note(
|
|
397
|
+
[icon('alert', 16), textNode(err instanceof Error ? err.message : 'Could not read collections.')],
|
|
398
|
+
'warn',
|
|
399
|
+
),
|
|
400
|
+
);
|
|
401
|
+
},
|
|
402
|
+
);
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// --- rendering -------------------------------------------------------------
|
|
406
|
+
function render(): void {
|
|
407
|
+
root.textContent = '';
|
|
408
|
+
opts.onPrimary?.(null);
|
|
409
|
+
// The editor list belongs to the DOM this call is about to build. Without
|
|
410
|
+
// clearing it, a return to this view would leave detached editors from the
|
|
411
|
+
// previous render in the dirty check and in the next save's payload. The
|
|
412
|
+
// image-availability hooks are per-render for the same reason: they close
|
|
413
|
+
// over selects this call is about to replace.
|
|
414
|
+
editors = [];
|
|
415
|
+
imageAvailability = [];
|
|
416
|
+
if (!data) return;
|
|
417
|
+
if (creating) root.append(renderCreate(data));
|
|
418
|
+
else if (selected) {
|
|
419
|
+
const found = data.collections.find((c) => c.name === selected);
|
|
420
|
+
root.append(found ? renderDetail(data, found) : renderList(data));
|
|
421
|
+
} else root.append(renderList(data));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The one control that decides whether a collection's detail pages offer the
|
|
426
|
+
* entry drawer — what replaces hand-emitting the page-source meta tag.
|
|
427
|
+
*
|
|
428
|
+
* It sits on the **list row** and **saves on the flip**, which is deliberate
|
|
429
|
+
* on both counts: this view has no Save button (`onPrimary(null)`), and
|
|
430
|
+
* switching several collections on is the first thing anyone does here.
|
|
431
|
+
*
|
|
432
|
+
* A row is its own hit target, so the switch has to stop its own events
|
|
433
|
+
* reaching it — otherwise every flip would also navigate into the collection.
|
|
434
|
+
*/
|
|
435
|
+
function pageEditingSwitch(c: CollectionSummary): HTMLElement {
|
|
436
|
+
// A switch, not a checkbox: this is a live capability that is on or off
|
|
437
|
+
// right now, not an answer inside a form waiting for Save. It is named
|
|
438
|
+
// rather than labelled On/Off — "On" beside a collection says nothing about
|
|
439
|
+
// *what* is on, and the row has room for the two words that do.
|
|
440
|
+
const sw = switchControl(
|
|
441
|
+
'Content editor',
|
|
442
|
+
c.pageEditing,
|
|
443
|
+
(wanted) => {
|
|
444
|
+
sw.input.disabled = true;
|
|
445
|
+
void api.setCollectionPageEditing({ collection: c.name, enabled: wanted }).then(
|
|
446
|
+
() => {
|
|
447
|
+
toast(`Content editor ${wanted ? 'on' : 'off'} for ${c.name}`, 'ok');
|
|
448
|
+
// Reload rather than patch the row in place: the detected route and
|
|
449
|
+
// the "no detail route" badge are part of the same answer, and a row
|
|
450
|
+
// that kept a stale one would be worse than a brief spinner.
|
|
451
|
+
load();
|
|
452
|
+
},
|
|
453
|
+
(err: unknown) => {
|
|
454
|
+
sw.input.checked = !wanted;
|
|
455
|
+
sw.input.disabled = false;
|
|
456
|
+
toast(err instanceof Error ? err.message : 'Could not save', 'err');
|
|
457
|
+
},
|
|
458
|
+
);
|
|
459
|
+
},
|
|
460
|
+
// Every row says "Content editor"; the name has to say which one.
|
|
461
|
+
`Content editor for ${c.name}`,
|
|
462
|
+
);
|
|
463
|
+
sw.root.classList.add('atx-collections-pageedit');
|
|
464
|
+
if (c.pageEditingLocked) {
|
|
465
|
+
sw.input.disabled = true;
|
|
466
|
+
sw.root.append(icon('lock', 12));
|
|
467
|
+
sw.root.title = 'Set in astro.config.mjs, which takes precedence.';
|
|
468
|
+
}
|
|
469
|
+
// The row owns click and Enter/Space; without this every flip navigates.
|
|
470
|
+
sw.root.addEventListener('click', (e) => e.stopPropagation());
|
|
471
|
+
sw.root.addEventListener('keydown', (e) => e.stopPropagation());
|
|
472
|
+
return sw.root;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function renderList(d: CollectionsResponse): HTMLElement {
|
|
476
|
+
const wrap = styled('div', 'atx-collections-list');
|
|
477
|
+
|
|
478
|
+
// One card: what these are, where they are declared, and — in the corner —
|
|
479
|
+
// the one action that adds to them. "New" is a header action rather than a
|
|
480
|
+
// button trailing the list, so it reads as belonging to the collection set
|
|
481
|
+
// rather than to the last row.
|
|
482
|
+
const n = d.collections.length;
|
|
483
|
+
const listCard = card({
|
|
484
|
+
// The count rather than the word "Collections", which the drawer's own
|
|
485
|
+
// title already said 30px above this line.
|
|
486
|
+
title: n === 1 ? '1 collection' : `${n} collections`,
|
|
487
|
+
description: d.configPath
|
|
488
|
+
? `Declared in ${d.configPath}. Open one to edit its fields.`
|
|
489
|
+
: 'This project has no content config. Create src/content.config.ts to use the designer.',
|
|
490
|
+
...(d.configPath && d.schemaEditor
|
|
491
|
+
? { action: newCollectionButton(goCreate) }
|
|
492
|
+
: {}),
|
|
493
|
+
});
|
|
494
|
+
wrap.append(listCard.root);
|
|
495
|
+
|
|
496
|
+
if (!d.schemaEditor) {
|
|
497
|
+
listCard.body.append(
|
|
498
|
+
note(
|
|
499
|
+
[
|
|
500
|
+
icon('lock', 12),
|
|
501
|
+
textNode(
|
|
502
|
+
'Schema editing is off, so fields and collections are read-only here. ' +
|
|
503
|
+
'Settings → Editing turns it on. Widget, label and hidden still save.',
|
|
504
|
+
),
|
|
505
|
+
],
|
|
506
|
+
'muted',
|
|
507
|
+
),
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const list = itemGroup({ bleed: true });
|
|
512
|
+
for (const c of d.collections) {
|
|
513
|
+
const row = item({
|
|
514
|
+
title: c.name,
|
|
515
|
+
description: `${c.dir} · ${c.entryCount} ${c.entryCount === 1 ? 'entry' : 'entries'} · ${c.fields.length} fields`,
|
|
516
|
+
media: icon('collections', 16),
|
|
517
|
+
actions: [pageEditingSwitch(c)],
|
|
518
|
+
});
|
|
519
|
+
// The route the switch actually affects, on its own line rather than
|
|
520
|
+
// appended to the description: it answers a different question — not
|
|
521
|
+
// "what is this collection" but "where would turning this on show up" —
|
|
522
|
+
// and a dot-separated list that wraps leaves a separator dangling. Shown
|
|
523
|
+
// whether the switch is on or off, so you can see what it would do before
|
|
524
|
+
// you do it.
|
|
525
|
+
if (c.detailRoute) {
|
|
526
|
+
const route = styled('div', 'atx-collections-route');
|
|
527
|
+
route.append(icon('file', 11), textNode(c.detailRoute));
|
|
528
|
+
row.content.append(route);
|
|
529
|
+
}
|
|
530
|
+
row.root.classList.add('atx-collections-row', `atx-collections-row-${c.name}`);
|
|
531
|
+
// A row is the whole hit target, so it carries the button semantics
|
|
532
|
+
// rather than nesting a button that would only cover its label.
|
|
533
|
+
row.root.role = 'button';
|
|
534
|
+
row.root.tabIndex = 0;
|
|
535
|
+
if (!c.registered) row.title.append(badge('not registered', 'warn'));
|
|
536
|
+
if (c.schemaForm === null) row.title.append(badge('no readable schema', 'muted'));
|
|
537
|
+
if (c.fieldSource === 'source' && c.schemaForm !== null) {
|
|
538
|
+
row.title.append(badge('schema not loaded', 'warn'));
|
|
539
|
+
}
|
|
540
|
+
if (!c.dirExists) row.title.append(badge('directory missing', 'warn'));
|
|
541
|
+
// Switched on with nothing to switch on *for*: worth saying, because the
|
|
542
|
+
// user has just asked for a button that will not appear anywhere.
|
|
543
|
+
if (c.pageEditing && !c.detailRoute) row.title.append(badge('no detail route', 'warn'));
|
|
544
|
+
row.root.addEventListener('click', () => goDetail(c.name));
|
|
545
|
+
row.root.addEventListener('keydown', (e) => {
|
|
546
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
547
|
+
e.preventDefault();
|
|
548
|
+
goDetail(c.name);
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
list.append(row.root);
|
|
552
|
+
}
|
|
553
|
+
listCard.body.append(list);
|
|
554
|
+
return wrap;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* What the switch on the list row means for *this* collection.
|
|
559
|
+
*
|
|
560
|
+
* A note rather than a second switch: two controls bound to one value is a
|
|
561
|
+
* source of disagreement, not convenience. What this adds instead is the
|
|
562
|
+
* detail the row has no space for — which route was detected, and, when none
|
|
563
|
+
* was, the meta tag that reaches these entries anyway.
|
|
564
|
+
*
|
|
565
|
+
* The tool deliberately does not write that tag into the project's layout: it
|
|
566
|
+
* would have to guess the entry variable's name, how the layout is wrapped,
|
|
567
|
+
* and where the document `<head>` lives — three guesses this project takes
|
|
568
|
+
* nowhere else. Handing over the snippet is the honest substitute.
|
|
569
|
+
*/
|
|
570
|
+
function pageEditingNote(c: CollectionSummary): HTMLElement {
|
|
571
|
+
if (!c.pageEditing) {
|
|
572
|
+
return note(
|
|
573
|
+
[
|
|
574
|
+
icon('file', 12),
|
|
575
|
+
textNode(
|
|
576
|
+
'Content editor is off. Switch it on in the collections list and ' +
|
|
577
|
+
(c.detailRoute
|
|
578
|
+
? `${c.detailRoute} gets an Edit entry button.`
|
|
579
|
+
: "this collection's detail pages get an Edit entry button."),
|
|
580
|
+
),
|
|
581
|
+
],
|
|
582
|
+
'muted',
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
if (c.detailRoute) {
|
|
586
|
+
return note(
|
|
587
|
+
[
|
|
588
|
+
icon('file', 12),
|
|
589
|
+
textNode(`Content editor is on — ${c.detailRoute} offers Edit entry, with no meta tag.`),
|
|
590
|
+
],
|
|
591
|
+
'muted',
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
const snippet =
|
|
595
|
+
'{import.meta.env.DEV && (\n' +
|
|
596
|
+
' <meta name="astro-dev-edit:page-source" content={entry.filePath} />\n' +
|
|
597
|
+
')}';
|
|
598
|
+
const n = note(
|
|
599
|
+
[
|
|
600
|
+
icon('alert', 12),
|
|
601
|
+
textNode(
|
|
602
|
+
'Content editor is on, but no route naming this collection was found — it may ' +
|
|
603
|
+
"fetch its entries through a helper. Emit this in the detail page's <head>, " +
|
|
604
|
+
'with your own entry variable, and the drawer works there too:',
|
|
605
|
+
),
|
|
606
|
+
],
|
|
607
|
+
'warn',
|
|
608
|
+
);
|
|
609
|
+
const pre = styled('pre', 'atx-collections-snippet');
|
|
610
|
+
pre.textContent = snippet;
|
|
611
|
+
n.classList.add('atx-collections-note-snippet');
|
|
612
|
+
n.append(pre, cornerButton('Copy', 'copy', () => void copySnippet(c.name, snippet)));
|
|
613
|
+
return n;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async function copySnippet(collection: string, snippet: string): Promise<void> {
|
|
617
|
+
try {
|
|
618
|
+
await navigator.clipboard.writeText(snippet);
|
|
619
|
+
toast(`Copied the meta tag for ${collection}`, 'ok');
|
|
620
|
+
} catch {
|
|
621
|
+
// The same fallback the copy-context flow takes: an insecure context has
|
|
622
|
+
// no clipboard API, and a panel the user can select from still works.
|
|
623
|
+
openCopyPanel(`${collection} page-source meta`, snippet);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function renderDetail(d: CollectionsResponse, c: CollectionSummary): HTMLElement {
|
|
628
|
+
const wrap = styled('div', `atx-collections-detail atx-collections-detail-${c.name}`);
|
|
629
|
+
const fieldsPane = styled('div', 'atx-collections-fieldspane');
|
|
630
|
+
const head = styled('div', 'atx-collections-head');
|
|
631
|
+
head.append(backLink(goList));
|
|
632
|
+
const name = styled('span', 'atx-collections-title');
|
|
633
|
+
name.textContent = c.name;
|
|
634
|
+
head.append(name);
|
|
635
|
+
head.append(styled('span', 'atx-collections-spacer'));
|
|
636
|
+
if (has('openInEditor')) {
|
|
637
|
+
const openBtn = cornerButton('Open source', 'code', () => {
|
|
638
|
+
void api.openCollectionSource({ collection: c.name }).catch((err: unknown) => {
|
|
639
|
+
toast(err instanceof Error ? err.message : 'Could not open the config', 'err');
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
head.append(openBtn);
|
|
643
|
+
}
|
|
644
|
+
wrap.append(head);
|
|
645
|
+
|
|
646
|
+
const meta = styled('div', 'atx-collections-meta');
|
|
647
|
+
meta.textContent =
|
|
648
|
+
`${c.dir} · ${c.entryCount} ${c.entryCount === 1 ? 'entry' : 'entries'}` +
|
|
649
|
+
(c.schemaForm === 'function' ? ' · function schema (image() available)' : '') +
|
|
650
|
+
(c.schemaForm === 'object' ? ' · plain z.object schema' : '');
|
|
651
|
+
wrap.append(meta);
|
|
652
|
+
wrap.append(pageEditingNote(c));
|
|
653
|
+
|
|
654
|
+
const items = buildItemsPane(c);
|
|
655
|
+
const tabs = buildTabs(
|
|
656
|
+
[
|
|
657
|
+
{ id: 'fields', label: 'Fields', pane: fieldsPane },
|
|
658
|
+
{ id: 'items', label: `Items · ${c.entryCount}`, pane: items.root },
|
|
659
|
+
],
|
|
660
|
+
{
|
|
661
|
+
classPrefix: 'collections-view',
|
|
662
|
+
onActivate: (id) => {
|
|
663
|
+
if (id === 'items') items.load();
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
);
|
|
667
|
+
wrap.append(tabs.strip, tabs.host);
|
|
668
|
+
|
|
669
|
+
const writable = d.schemaEditor && c.schemaForm !== null;
|
|
670
|
+
if (c.unrecognized) {
|
|
671
|
+
fieldsPane.append(
|
|
672
|
+
note(
|
|
673
|
+
[
|
|
674
|
+
icon('alert', 12),
|
|
675
|
+
textNode(
|
|
676
|
+
`${c.unrecognized}. Fields are read-only here — edit the config directly.`,
|
|
677
|
+
),
|
|
678
|
+
],
|
|
679
|
+
'warn',
|
|
680
|
+
),
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
if (c.fieldSource === 'source' && c.schemaForm !== null) {
|
|
684
|
+
fieldsPane.append(
|
|
685
|
+
note(
|
|
686
|
+
[
|
|
687
|
+
icon('alert', 12),
|
|
688
|
+
textNode(
|
|
689
|
+
'Astro could not load this content config, so these field names come from the ' +
|
|
690
|
+
'config text and their types are unknown. Fix the config error first — the dev ' +
|
|
691
|
+
'server log names it.',
|
|
692
|
+
),
|
|
693
|
+
],
|
|
694
|
+
'warn',
|
|
695
|
+
),
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
fieldsPane.append(legend());
|
|
699
|
+
|
|
700
|
+
// Above the fields, because it decides what a field is allowed to be. The
|
|
701
|
+
// switch is a schema write like any other, so `schemaEditor: false` and an
|
|
702
|
+
// unreadable schema lock it for the same reasons they lock a type.
|
|
703
|
+
if (c.schemaForm !== null) {
|
|
704
|
+
fieldsPane.append(
|
|
705
|
+
imageSwitch(
|
|
706
|
+
wantedForm(c),
|
|
707
|
+
writable
|
|
708
|
+
? null
|
|
709
|
+
: 'Schema editing is off, so the form of this schema can’t be changed here.',
|
|
710
|
+
(form) => {
|
|
711
|
+
formChange = form === c.schemaForm ? null : form;
|
|
712
|
+
refreshSave();
|
|
713
|
+
},
|
|
714
|
+
),
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const list = styled('div', 'atx-collections-fields');
|
|
719
|
+
for (const f of c.fields) {
|
|
720
|
+
const editor = buildFieldEditor(f, c, writable);
|
|
721
|
+
editors.push(editor);
|
|
722
|
+
list.append(editor.root);
|
|
723
|
+
}
|
|
724
|
+
fieldsPane.append(list);
|
|
725
|
+
|
|
726
|
+
// Queued additions live between the existing fields and the add form, so the
|
|
727
|
+
// order on screen is the order they will land in.
|
|
728
|
+
const pending = styled('div', 'atx-collections-pending');
|
|
729
|
+
const repaintPending = (): void => {
|
|
730
|
+
pending.textContent = '';
|
|
731
|
+
for (const spec of queuedAdds) pending.append(pendingRow(spec, () => {
|
|
732
|
+
queuedAdds = queuedAdds.filter((s) => s !== spec);
|
|
733
|
+
repaintPending();
|
|
734
|
+
refreshSave();
|
|
735
|
+
}));
|
|
736
|
+
};
|
|
737
|
+
repaintPending();
|
|
738
|
+
fieldsPane.append(pending);
|
|
739
|
+
|
|
740
|
+
const error = styled('p', 'atx-collections-error');
|
|
741
|
+
|
|
742
|
+
if (writable) {
|
|
743
|
+
const form = buildFieldSpecForm(c, (spec) => {
|
|
744
|
+
if (c.fields.some((f) => f.name === spec.name) || queuedAdds.some((s) => s.name === spec.name)) {
|
|
745
|
+
showError(error, `${c.name} already has a "${spec.name}" field.`);
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
queuedAdds.push(spec);
|
|
749
|
+
repaintPending();
|
|
750
|
+
refreshSave();
|
|
751
|
+
return true;
|
|
752
|
+
});
|
|
753
|
+
fieldsPane.append(form.root);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const saveBtn = footButton('Save changes', 'default', () => void saveDetail(c, error));
|
|
757
|
+
opts.onPrimary?.(saveBtn);
|
|
758
|
+
fieldsPane.append(error);
|
|
759
|
+
|
|
760
|
+
const refreshSave = (): void => setButtonEnabled(saveBtn, isDirty());
|
|
761
|
+
for (const e of editors) e.onChange(refreshSave);
|
|
762
|
+
refreshSave();
|
|
763
|
+
return wrap;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* The Items view: one collection's entry files, newest first.
|
|
768
|
+
*
|
|
769
|
+
* This is the half of the designer that reaches entries no rendered page links
|
|
770
|
+
* to — drafts, and anything whose route doesn't exist yet. Clicking one hands
|
|
771
|
+
* off to the existing entry drawer rather than reimplementing it.
|
|
772
|
+
*/
|
|
773
|
+
function buildItemsPane(c: CollectionSummary): { root: HTMLElement; load(): void } {
|
|
774
|
+
const root = styled('div', 'atx-collections-items');
|
|
775
|
+
let loaded = false;
|
|
776
|
+
|
|
777
|
+
const paint = (list: CollectionEntryItem[], truncated: boolean): void => {
|
|
778
|
+
root.textContent = '';
|
|
779
|
+
const bar = styled('div', 'atx-collections-itembar');
|
|
780
|
+
const count = styled('span', 'atx-collections-itemcount');
|
|
781
|
+
count.textContent = `${list.length} ${list.length === 1 ? 'entry' : 'entries'} in ${c.dir}`;
|
|
782
|
+
bar.append(count);
|
|
783
|
+
|
|
784
|
+
// A new entry's form is built from schema fields; with no resolvable schema
|
|
785
|
+
// there is nothing to build it from, so the button says why instead of
|
|
786
|
+
// opening an empty drawer.
|
|
787
|
+
const canCreate = c.fieldSource === 'schema' && c.dirExists;
|
|
788
|
+
const newBtn = footButton('New item', 'outline', () => {
|
|
789
|
+
opts.handoff(() =>
|
|
790
|
+
openEntryCreatePanel({
|
|
791
|
+
collection: c.name,
|
|
792
|
+
collectionDir: c.dir,
|
|
793
|
+
file: `${c.dir}/_new.md`,
|
|
794
|
+
fields: c.fields,
|
|
795
|
+
// Started from the designer, not from a rendered page, so there is no
|
|
796
|
+
// sibling route to navigate to.
|
|
797
|
+
afterCreate: (file) => toast(`Created ${file}`, 'ok'),
|
|
798
|
+
}),
|
|
799
|
+
);
|
|
800
|
+
});
|
|
801
|
+
setButtonEnabled(newBtn, canCreate);
|
|
802
|
+
bar.append(newBtn);
|
|
803
|
+
root.append(bar);
|
|
804
|
+
if (!canCreate) {
|
|
805
|
+
root.append(
|
|
806
|
+
note(
|
|
807
|
+
[
|
|
808
|
+
textNode(
|
|
809
|
+
c.dirExists
|
|
810
|
+
? 'A new entry’s form comes from the collection’s schema, which didn’t resolve.'
|
|
811
|
+
: `${c.dir} doesn’t exist yet, so there is nowhere to write an entry.`,
|
|
812
|
+
),
|
|
813
|
+
],
|
|
814
|
+
'muted',
|
|
815
|
+
),
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (list.length === 0) {
|
|
820
|
+
root.append(blurb('No entries yet.'));
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
for (const e of list) {
|
|
825
|
+
const row = styled('button', 'atx-collections-item');
|
|
826
|
+
row.type = 'button';
|
|
827
|
+
const title = styled('div', 'atx-collections-item-title');
|
|
828
|
+
title.append(textNode(e.title ?? e.slug));
|
|
829
|
+
if (e.draft) title.append(badge('draft', 'warn'));
|
|
830
|
+
const meta = styled('div', 'atx-collections-item-meta');
|
|
831
|
+
meta.textContent = `${e.slug} · ${when(e.mtime)}`;
|
|
832
|
+
row.append(title, meta);
|
|
833
|
+
row.addEventListener('click', () => {
|
|
834
|
+
opts.handoff(() => void openEntryPanel(e.file));
|
|
835
|
+
});
|
|
836
|
+
root.append(row);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (truncated) {
|
|
840
|
+
root.append(
|
|
841
|
+
note(
|
|
842
|
+
[
|
|
843
|
+
icon('alert', 12),
|
|
844
|
+
textNode(
|
|
845
|
+
`Only the first ${list.length} entries are listed — this collection has more.`,
|
|
846
|
+
),
|
|
847
|
+
],
|
|
848
|
+
'warn',
|
|
849
|
+
),
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
const load = (): void => {
|
|
855
|
+
if (loaded) return;
|
|
856
|
+
loaded = true;
|
|
857
|
+
root.textContent = '';
|
|
858
|
+
root.append(note([icon('spinner', 16), textNode('Reading entries…')], 'muted'));
|
|
859
|
+
void api.listCollectionEntries({ collection: c.name }).then(
|
|
860
|
+
(res) => paint(res.entries, res.truncated === true),
|
|
861
|
+
(err: unknown) => {
|
|
862
|
+
root.textContent = '';
|
|
863
|
+
root.append(
|
|
864
|
+
note(
|
|
865
|
+
[
|
|
866
|
+
icon('alert', 16),
|
|
867
|
+
textNode(err instanceof Error ? err.message : 'Could not read the entries.'),
|
|
868
|
+
],
|
|
869
|
+
'warn',
|
|
870
|
+
),
|
|
871
|
+
);
|
|
872
|
+
},
|
|
873
|
+
);
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
return { root, load };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** One field's two halves. */
|
|
880
|
+
function buildFieldEditor(
|
|
881
|
+
f: FieldDescriptor,
|
|
882
|
+
c: CollectionSummary,
|
|
883
|
+
writable: boolean,
|
|
884
|
+
): FieldEditor {
|
|
885
|
+
const expr = c.expressions[f.name];
|
|
886
|
+
// A field the schema doesn't declare (inferred, or absent from the source)
|
|
887
|
+
// can't be retyped — there is nothing to patch. Its editor half still works.
|
|
888
|
+
const inSchema = expr !== undefined;
|
|
889
|
+
const schemaEditable = writable && inSchema && f.type !== 'json';
|
|
890
|
+
|
|
891
|
+
const card = styled('div', `atx-collections-field atx-collections-field-${f.name}`);
|
|
892
|
+
|
|
893
|
+
const head = styled('div', 'atx-collections-field-head');
|
|
894
|
+
const nameEl = styled('span', 'atx-collections-field-name');
|
|
895
|
+
nameEl.textContent = f.name;
|
|
896
|
+
head.append(nameEl);
|
|
897
|
+
if (!inSchema) head.append(badge('not in schema', 'muted'));
|
|
898
|
+
head.append(styled('span', 'atx-collections-spacer'));
|
|
899
|
+
|
|
900
|
+
const listeners: Array<() => void> = [];
|
|
901
|
+
const fire = (): void => listeners.forEach((fn) => fn());
|
|
902
|
+
|
|
903
|
+
let removed = false;
|
|
904
|
+
// Outline, not danger: clicking this only *queues* a removal — the card dims
|
|
905
|
+
// and the label becomes "Undo remove", and nothing is written until Save.
|
|
906
|
+
// Eight red buttons down a field list would also shout louder than the field
|
|
907
|
+
// names. What it must not be is a bare word: it is the only control in the
|
|
908
|
+
// card's header and has to look like one.
|
|
909
|
+
const removeBtn = cornerButton('Remove', null, () => {
|
|
910
|
+
removed = !removed;
|
|
911
|
+
if (removed) removals.add(f.name);
|
|
912
|
+
else removals.delete(f.name);
|
|
913
|
+
removeBtn.textContent = removed ? 'Undo remove' : 'Remove';
|
|
914
|
+
card.toggleAttribute('data-removed', removed);
|
|
915
|
+
fire();
|
|
916
|
+
});
|
|
917
|
+
if (schemaEditable) head.append(removeBtn);
|
|
918
|
+
card.append(head);
|
|
919
|
+
|
|
920
|
+
// The two stores sit side by side, not stacked: they hold the *same* field
|
|
921
|
+
// and the point of the card is that you can read one against the other.
|
|
922
|
+
// The grid collapses to one column when the drawer is too narrow to keep
|
|
923
|
+
// a control legible beside its label.
|
|
924
|
+
const stores = styled('div', 'atx-collections-stores');
|
|
925
|
+
|
|
926
|
+
// --- schema half ---------------------------------------------------------
|
|
927
|
+
const typeSel = bindImageChoice(
|
|
928
|
+
select(
|
|
929
|
+
// A field that is *already* an image stays pickable whatever the form
|
|
930
|
+
// says, so its own type can't become unselectable underneath it.
|
|
931
|
+
schemaTypeChoices(wantedForm(c) === 'function' || f.type === 'image'),
|
|
932
|
+
SCHEMA_TYPES.includes(f.type) ? f.type : '',
|
|
933
|
+
fire,
|
|
934
|
+
),
|
|
935
|
+
true,
|
|
936
|
+
);
|
|
937
|
+
if (!SCHEMA_TYPES.includes(f.type)) {
|
|
938
|
+
// e.g. a `json` field: offer the choices without claiming the current one.
|
|
939
|
+
const unknown = document.createElement('option');
|
|
940
|
+
unknown.value = '';
|
|
941
|
+
unknown.textContent = TYPE_LABEL[f.type] ?? f.type;
|
|
942
|
+
typeSel.prepend(unknown);
|
|
943
|
+
typeSel.value = '';
|
|
944
|
+
}
|
|
945
|
+
const requiredBox = checkbox('Required', f.required, fire);
|
|
946
|
+
const defaultInput = input(
|
|
947
|
+
f.defaultValue === undefined ? '' : String(f.defaultValue),
|
|
948
|
+
'no default',
|
|
949
|
+
fire,
|
|
950
|
+
);
|
|
951
|
+
const optionsInput = input((f.options ?? []).join(', '), 'Option, Option, …', fire);
|
|
952
|
+
const optionsRow = controlRow('Options', [optionsInput]);
|
|
953
|
+
const syncOptions = (): void => {
|
|
954
|
+
optionsRow.toggleAttribute('data-hidden', typeSel.value !== 'select');
|
|
955
|
+
};
|
|
956
|
+
typeSel.addEventListener('change', syncOptions);
|
|
957
|
+
|
|
958
|
+
const schemaGroup = group('Schema', [
|
|
959
|
+
controlRow('Type', [typeSel]),
|
|
960
|
+
controlRow('Required', [requiredBox.root]),
|
|
961
|
+
controlRow('Default', [defaultInput]),
|
|
962
|
+
optionsRow,
|
|
963
|
+
]);
|
|
964
|
+
syncOptions();
|
|
965
|
+
if (!schemaEditable) {
|
|
966
|
+
for (const el of schemaGroup.querySelectorAll('input, select')) {
|
|
967
|
+
(el as HTMLInputElement).disabled = true;
|
|
968
|
+
}
|
|
969
|
+
schemaGroup.toggleAttribute('data-off', true);
|
|
970
|
+
}
|
|
971
|
+
stores.append(schemaGroup);
|
|
972
|
+
|
|
973
|
+
// --- editor half ---------------------------------------------------------
|
|
974
|
+
const o = c.overrides[f.name] ?? {};
|
|
975
|
+
const overrideLocked = c.lockedFields.includes(f.name);
|
|
976
|
+
const widgetSel = select(WIDGET_CHOICES, o.widget ?? '', fire);
|
|
977
|
+
const labelInput = input(o.label ?? '', f.label, fire);
|
|
978
|
+
const hiddenBox = checkbox('Hidden', o.hidden === true, fire);
|
|
979
|
+
const editorGroup = group('Editor', [
|
|
980
|
+
controlRow('Widget', [widgetSel]),
|
|
981
|
+
controlRow('Label', [labelInput]),
|
|
982
|
+
controlRow('Hidden', [hiddenBox.root]),
|
|
983
|
+
]);
|
|
984
|
+
if (overrideLocked) {
|
|
985
|
+
// astro.config.mjs owns this field's override, and config wins at resolve
|
|
986
|
+
// time — so accepting input here would store a value that does nothing.
|
|
987
|
+
for (const el of editorGroup.querySelectorAll('input, select')) {
|
|
988
|
+
(el as HTMLInputElement).disabled = true;
|
|
989
|
+
}
|
|
990
|
+
editorGroup.append(
|
|
991
|
+
note(
|
|
992
|
+
[icon('lock', 12), textNode('Set in astro.config.mjs, which takes precedence.')],
|
|
993
|
+
'muted',
|
|
994
|
+
),
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
stores.append(editorGroup);
|
|
998
|
+
card.append(stores);
|
|
999
|
+
|
|
1000
|
+
if (inSchema) {
|
|
1001
|
+
const exprEl = styled('code', 'atx-collections-expr');
|
|
1002
|
+
exprEl.textContent = expr;
|
|
1003
|
+
card.append(exprEl);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const currentSpec = (): SchemaFieldSpec => ({
|
|
1007
|
+
name: f.name,
|
|
1008
|
+
type: (typeSel.value || f.type) as FieldType,
|
|
1009
|
+
required: requiredBox.input.checked,
|
|
1010
|
+
...(defaultInput.value.trim() ? { defaultValue: defaultInput.value.trim() } : {}),
|
|
1011
|
+
...(typeSel.value === 'select'
|
|
1012
|
+
? { options: optionsInput.value.split(',').map((s) => s.trim()).filter(Boolean) }
|
|
1013
|
+
: {}),
|
|
1014
|
+
});
|
|
1015
|
+
const initialSpec = JSON.stringify({
|
|
1016
|
+
type: SCHEMA_TYPES.includes(f.type) ? f.type : '',
|
|
1017
|
+
required: f.required,
|
|
1018
|
+
def: f.defaultValue === undefined ? '' : String(f.defaultValue),
|
|
1019
|
+
options: (f.options ?? []).join(', '),
|
|
1020
|
+
});
|
|
1021
|
+
const nowSpec = (): string =>
|
|
1022
|
+
JSON.stringify({
|
|
1023
|
+
type: typeSel.value,
|
|
1024
|
+
required: requiredBox.input.checked,
|
|
1025
|
+
def: defaultInput.value.trim(),
|
|
1026
|
+
options: optionsInput.value,
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
/** A locked field reports its effective value unchanged, so it can never be
|
|
1030
|
+
* read as dirty and can never be sent. */
|
|
1031
|
+
const currentOverride = (): FieldOverride =>
|
|
1032
|
+
overrideLocked
|
|
1033
|
+
? normalize(o)
|
|
1034
|
+
: {
|
|
1035
|
+
...(widgetSel.value ? { widget: widgetSel.value as FieldType } : {}),
|
|
1036
|
+
...(labelInput.value.trim() ? { label: labelInput.value.trim() } : {}),
|
|
1037
|
+
...(hiddenBox.input.checked ? { hidden: true } : {}),
|
|
1038
|
+
};
|
|
1039
|
+
const initialOverride = JSON.stringify(normalize(o));
|
|
1040
|
+
|
|
1041
|
+
return {
|
|
1042
|
+
name: f.name,
|
|
1043
|
+
root: card,
|
|
1044
|
+
removed: () => removed,
|
|
1045
|
+
schemaChange: () =>
|
|
1046
|
+
!removed && schemaEditable && nowSpec() !== initialSpec ? currentSpec() : null,
|
|
1047
|
+
overrideChange: () => {
|
|
1048
|
+
const next = currentOverride();
|
|
1049
|
+
if (JSON.stringify(normalize(next)) === initialOverride) return undefined;
|
|
1050
|
+
return Object.keys(next).length > 0 ? next : null;
|
|
1051
|
+
},
|
|
1052
|
+
dirty: () =>
|
|
1053
|
+
removed ||
|
|
1054
|
+
(schemaEditable && nowSpec() !== initialSpec) ||
|
|
1055
|
+
JSON.stringify(normalize(currentOverride())) !== initialOverride,
|
|
1056
|
+
onChange: (fn) => listeners.push(fn),
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// --- saving ----------------------------------------------------------------
|
|
1061
|
+
async function saveDetail(c: CollectionSummary, error: HTMLElement): Promise<void> {
|
|
1062
|
+
error.toggleAttribute('data-on', false);
|
|
1063
|
+
const update = editors.map((e) => e.schemaChange()).filter((s): s is SchemaFieldSpec => s !== null);
|
|
1064
|
+
const overrides: Record<string, FieldOverride | null> = {};
|
|
1065
|
+
for (const e of editors) {
|
|
1066
|
+
const next = e.overrideChange();
|
|
1067
|
+
if (next !== undefined) overrides[e.name] = next;
|
|
1068
|
+
}
|
|
1069
|
+
const remove = [...removals];
|
|
1070
|
+
const form = formChange;
|
|
1071
|
+
const schemaEdits = update.length + remove.length + queuedAdds.length + (form ? 1 : 0);
|
|
1072
|
+
if (schemaEdits === 0 && Object.keys(overrides).length === 0) {
|
|
1073
|
+
showError(error, 'Nothing has changed yet.');
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
busy = true;
|
|
1078
|
+
// Set before the request, because the reload the write triggers can arrive
|
|
1079
|
+
// before its response does. Corrected below if nothing was in fact written.
|
|
1080
|
+
const willReload = schemaEdits > 0;
|
|
1081
|
+
if (willReload) remember(c.name);
|
|
1082
|
+
try {
|
|
1083
|
+
const result = await api.applyCollectionSchema({
|
|
1084
|
+
collection: c.name,
|
|
1085
|
+
...(data?.etag ? { etag: data.etag } : {}),
|
|
1086
|
+
...(schemaEdits > 0
|
|
1087
|
+
? { schema: { ...(form ? { form } : {}), update, remove, add: queuedAdds } }
|
|
1088
|
+
: {}),
|
|
1089
|
+
...(Object.keys(overrides).length > 0 ? { overrides } : {}),
|
|
1090
|
+
});
|
|
1091
|
+
if (!result.schemaWritten) remember(null);
|
|
1092
|
+
const wrote = [
|
|
1093
|
+
result.schemaWritten ? 'schema' : null,
|
|
1094
|
+
result.overridesWritten ? 'editor settings' : null,
|
|
1095
|
+
].filter(Boolean);
|
|
1096
|
+
toast(wrote.length ? `Saved ${wrote.join(' and ')}` : 'Nothing to change', 'ok');
|
|
1097
|
+
if (!result.ok && result.error) showError(error, result.error);
|
|
1098
|
+
resetPending();
|
|
1099
|
+
// Re-read: the config etag has moved on, and the schema the entry drawer
|
|
1100
|
+
// sees comes from Astro's own module graph, not from this response.
|
|
1101
|
+
load();
|
|
1102
|
+
} catch (err) {
|
|
1103
|
+
remember(null);
|
|
1104
|
+
showError(
|
|
1105
|
+
error,
|
|
1106
|
+
err instanceof Error ? err.message : 'Could not save the collection changes.',
|
|
1107
|
+
);
|
|
1108
|
+
} finally {
|
|
1109
|
+
busy = false;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// --- the create form -------------------------------------------------------
|
|
1114
|
+
function renderCreate(d: CollectionsResponse): HTMLElement {
|
|
1115
|
+
const wrap = styled('div', 'atx-collections-create');
|
|
1116
|
+
const head = styled('div', 'atx-collections-head atx-collections-head-create');
|
|
1117
|
+
head.append(backLink(goList));
|
|
1118
|
+
const title = styled('span', 'atx-collections-title');
|
|
1119
|
+
title.textContent = 'New collection';
|
|
1120
|
+
head.append(title);
|
|
1121
|
+
wrap.append(head);
|
|
1122
|
+
wrap.append(
|
|
1123
|
+
blurb(
|
|
1124
|
+
`Appends a defineCollection block to ${d.configPath ?? 'the content config'}, registers ` +
|
|
1125
|
+
'the name in `export const collections`, and creates the entry directory.',
|
|
1126
|
+
),
|
|
1127
|
+
);
|
|
1128
|
+
|
|
1129
|
+
const nameInput = input('', 'notes', () => {
|
|
1130
|
+
createDirty = nameInput.value.trim().length > 0;
|
|
1131
|
+
if (!dirTouched) dirInput.value = nameInput.value.trim() ? `src/content/${nameInput.value.trim()}` : '';
|
|
1132
|
+
refresh();
|
|
1133
|
+
});
|
|
1134
|
+
let dirTouched = false;
|
|
1135
|
+
const dirInput = input('', 'src/content/notes', () => {
|
|
1136
|
+
dirTouched = true;
|
|
1137
|
+
refresh();
|
|
1138
|
+
});
|
|
1139
|
+
const patternInput = input('**/*.md', '**/*.md', refresh);
|
|
1140
|
+
wrap.append(
|
|
1141
|
+
controlRow('Name', [nameInput]),
|
|
1142
|
+
controlRow('Directory', [dirInput]),
|
|
1143
|
+
controlRow('Pattern', [patternInput]),
|
|
1144
|
+
);
|
|
1145
|
+
|
|
1146
|
+
const error = styled('p', 'atx-collections-error');
|
|
1147
|
+
|
|
1148
|
+
wrap.append(
|
|
1149
|
+
imageSwitch(createForm, null, (form) => {
|
|
1150
|
+
createForm = form;
|
|
1151
|
+
createDirty = true;
|
|
1152
|
+
refresh();
|
|
1153
|
+
}),
|
|
1154
|
+
);
|
|
1155
|
+
|
|
1156
|
+
const fieldList = styled('div', 'atx-collections-newfields');
|
|
1157
|
+
const repaintFields = (): void => {
|
|
1158
|
+
fieldList.textContent = '';
|
|
1159
|
+
fieldList.append(caption('Fields'));
|
|
1160
|
+
if (newFields.length === 0) {
|
|
1161
|
+
fieldList.append(blurb('No fields yet — a collection needs at least one.'));
|
|
1162
|
+
}
|
|
1163
|
+
for (const spec of newFields) {
|
|
1164
|
+
fieldList.append(
|
|
1165
|
+
pendingRow(spec, () => {
|
|
1166
|
+
newFields = newFields.filter((s) => s !== spec);
|
|
1167
|
+
repaintFields();
|
|
1168
|
+
refresh();
|
|
1169
|
+
}),
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
repaintFields();
|
|
1174
|
+
wrap.append(fieldList);
|
|
1175
|
+
|
|
1176
|
+
const form = buildFieldSpecForm(null, (spec) => {
|
|
1177
|
+
if (newFields.some((s) => s.name === spec.name)) {
|
|
1178
|
+
showError(error, `"${spec.name}" is already in the list.`);
|
|
1179
|
+
return false;
|
|
1180
|
+
}
|
|
1181
|
+
newFields.push(spec);
|
|
1182
|
+
repaintFields();
|
|
1183
|
+
refresh();
|
|
1184
|
+
return true;
|
|
1185
|
+
});
|
|
1186
|
+
wrap.append(form.root);
|
|
1187
|
+
|
|
1188
|
+
const createBtn = footButton('Create collection', 'default', () => void create());
|
|
1189
|
+
opts.onPrimary?.(createBtn);
|
|
1190
|
+
wrap.append(error);
|
|
1191
|
+
|
|
1192
|
+
function refresh(): void {
|
|
1193
|
+
setButtonEnabled(createBtn, nameInput.value.trim().length > 0 && newFields.length > 0);
|
|
1194
|
+
}
|
|
1195
|
+
refresh();
|
|
1196
|
+
|
|
1197
|
+
async function create(): Promise<void> {
|
|
1198
|
+
error.toggleAttribute('data-on', false);
|
|
1199
|
+
busy = true;
|
|
1200
|
+
remember(nameInput.value.trim());
|
|
1201
|
+
try {
|
|
1202
|
+
const created = await api.createCollection({
|
|
1203
|
+
name: nameInput.value.trim(),
|
|
1204
|
+
...(dirInput.value.trim() ? { dir: dirInput.value.trim() } : {}),
|
|
1205
|
+
...(patternInput.value.trim() ? { pattern: patternInput.value.trim() } : {}),
|
|
1206
|
+
schemaForm: createForm,
|
|
1207
|
+
fields: newFields,
|
|
1208
|
+
...(d.etag ? { etag: d.etag } : {}),
|
|
1209
|
+
});
|
|
1210
|
+
toast(`Created ${created.name} in ${created.dir}`, 'ok');
|
|
1211
|
+
selected = created.name;
|
|
1212
|
+
creating = false;
|
|
1213
|
+
resetPending();
|
|
1214
|
+
load();
|
|
1215
|
+
} catch (err) {
|
|
1216
|
+
remember(null);
|
|
1217
|
+
showError(error, err instanceof Error ? err.message : 'Could not create the collection.');
|
|
1218
|
+
} finally {
|
|
1219
|
+
busy = false;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
return wrap;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* The Image fields switch — the one control that decides which schema form is
|
|
1228
|
+
* written, on both the create form and an existing collection.
|
|
1229
|
+
*
|
|
1230
|
+
* It exists because the form used to be *inferred*: adding an image field to a
|
|
1231
|
+
* new collection quietly emitted the function form, and an existing plain
|
|
1232
|
+
* collection had no way to reach it at all. Inference is a poor fit here — the
|
|
1233
|
+
* form is a visible property of the user's own committed source, and which one
|
|
1234
|
+
* they get should be something they chose, not something they triggered.
|
|
1235
|
+
*
|
|
1236
|
+
* `onFlip` receives the form now wanted. Nothing is written: the detail view
|
|
1237
|
+
* stages it for Save changes, the create form holds it until Create.
|
|
1238
|
+
*/
|
|
1239
|
+
function imageSwitch(
|
|
1240
|
+
current: SchemaForm,
|
|
1241
|
+
locked: string | null,
|
|
1242
|
+
onFlip: (form: SchemaForm) => void,
|
|
1243
|
+
): HTMLElement {
|
|
1244
|
+
const c = card({
|
|
1245
|
+
title: 'Image fields',
|
|
1246
|
+
description:
|
|
1247
|
+
'Writes the schema as ({ image }) => z.object({ … }), which is the only form ' +
|
|
1248
|
+
'Astro gives its image() helper. Turn this on to add image fields.',
|
|
1249
|
+
});
|
|
1250
|
+
const box = checkbox(
|
|
1251
|
+
'Image fields',
|
|
1252
|
+
current === 'function',
|
|
1253
|
+
() => {
|
|
1254
|
+
const form: SchemaForm = box.input.checked ? 'function' : 'object';
|
|
1255
|
+
setImageAvailable(form === 'function');
|
|
1256
|
+
onFlip(form);
|
|
1257
|
+
},
|
|
1258
|
+
['On', 'Off'],
|
|
1259
|
+
);
|
|
1260
|
+
if (locked) {
|
|
1261
|
+
box.input.disabled = true;
|
|
1262
|
+
c.body.append(box.root, note([icon('lock', 12), textNode(locked)], 'muted'));
|
|
1263
|
+
} else {
|
|
1264
|
+
c.body.append(box.root);
|
|
1265
|
+
}
|
|
1266
|
+
return c.root;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* The one form used for both "add a field" and a new collection's starter
|
|
1271
|
+
* fields, so the two paths can't drift in what they accept.
|
|
1272
|
+
*/
|
|
1273
|
+
function buildFieldSpecForm(
|
|
1274
|
+
c: CollectionSummary | null,
|
|
1275
|
+
accept: (spec: SchemaFieldSpec) => boolean,
|
|
1276
|
+
): { root: HTMLElement } {
|
|
1277
|
+
const wrap = styled('div', 'atx-collections-addfield');
|
|
1278
|
+
wrap.append(caption('Add field'));
|
|
1279
|
+
|
|
1280
|
+
const nameInput = input('', 'subtitle', () => {});
|
|
1281
|
+
// Both views ask the same question — what will the schema form be when this
|
|
1282
|
+
// is saved — so neither has a rule of its own about image().
|
|
1283
|
+
const allowImage = (c === null ? createForm : wantedForm(c)) === 'function';
|
|
1284
|
+
const typeSel = bindImageChoice(
|
|
1285
|
+
select(schemaTypeChoices(allowImage), 'text', () => {
|
|
1286
|
+
optionsRow.toggleAttribute('data-hidden', typeSel.value !== 'select');
|
|
1287
|
+
}),
|
|
1288
|
+
);
|
|
1289
|
+
const requiredBox = checkbox('Required', true, () => {});
|
|
1290
|
+
const defaultInput = input('', 'no default', () => {});
|
|
1291
|
+
const optionsInput = input('', 'Option, Option, …', () => {});
|
|
1292
|
+
const optionsRow = controlRow('Options', [optionsInput]);
|
|
1293
|
+
optionsRow.toggleAttribute('data-hidden', true);
|
|
1294
|
+
|
|
1295
|
+
wrap.append(
|
|
1296
|
+
controlRow('Name', [nameInput]),
|
|
1297
|
+
controlRow('Type', [typeSel]),
|
|
1298
|
+
controlRow('Required', [requiredBox.root]),
|
|
1299
|
+
controlRow('Default', [defaultInput]),
|
|
1300
|
+
optionsRow,
|
|
1301
|
+
);
|
|
1302
|
+
const addBtn = footButton('Add', 'outline', () => {
|
|
1303
|
+
const name = nameInput.value.trim();
|
|
1304
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(name)) {
|
|
1305
|
+
toast('A field name must be a plain identifier', 'err');
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
const ok = accept({
|
|
1309
|
+
name,
|
|
1310
|
+
type: typeSel.value as FieldType,
|
|
1311
|
+
required: requiredBox.input.checked,
|
|
1312
|
+
...(defaultInput.value.trim() ? { defaultValue: defaultInput.value.trim() } : {}),
|
|
1313
|
+
...(typeSel.value === 'select'
|
|
1314
|
+
? { options: optionsInput.value.split(',').map((s) => s.trim()).filter(Boolean) }
|
|
1315
|
+
: {}),
|
|
1316
|
+
});
|
|
1317
|
+
if (!ok) return;
|
|
1318
|
+
nameInput.value = '';
|
|
1319
|
+
defaultInput.value = '';
|
|
1320
|
+
optionsInput.value = '';
|
|
1321
|
+
});
|
|
1322
|
+
wrap.append(addBtn);
|
|
1323
|
+
return { root: wrap };
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
return { root, load, isDirty };
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// --- one field's editing state ----------------------------------------------
|
|
1330
|
+
|
|
1331
|
+
interface FieldEditor {
|
|
1332
|
+
name: string;
|
|
1333
|
+
root: HTMLElement;
|
|
1334
|
+
removed(): boolean;
|
|
1335
|
+
/** The full desired spec when the schema half changed, else null. Full, not a
|
|
1336
|
+
* diff: `updateField` rewrites the whole expression. */
|
|
1337
|
+
schemaChange(): SchemaFieldSpec | null;
|
|
1338
|
+
/** The override to store, `null` to clear it, `undefined` when unchanged. */
|
|
1339
|
+
overrideChange(): FieldOverride | null | undefined;
|
|
1340
|
+
dirty(): boolean;
|
|
1341
|
+
onChange(fn: () => void): void;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// --- small DOM helpers ------------------------------------------------------
|
|
1345
|
+
|
|
1346
|
+
/** Which of the two voices a note speaks in: `warn` for something the user has
|
|
1347
|
+
* to act on, `muted` for a state that is merely worth saying. */
|
|
1348
|
+
type Tone = 'warn' | 'muted';
|
|
1349
|
+
|
|
1350
|
+
/** Two-store legend. The one piece of chrome that explains the whole drawer. */
|
|
1351
|
+
function legend(): HTMLElement {
|
|
1352
|
+
const wrap = styled('div', 'atx-collections-legend');
|
|
1353
|
+
const line = (word: string, rest: string): HTMLElement => {
|
|
1354
|
+
const p = styled('p', 'atx-collections-legend-line');
|
|
1355
|
+
const strong = styled('strong', 'atx-collections-legend-word');
|
|
1356
|
+
strong.textContent = word;
|
|
1357
|
+
p.append(strong, document.createTextNode(` ${rest}`));
|
|
1358
|
+
return p;
|
|
1359
|
+
};
|
|
1360
|
+
wrap.append(
|
|
1361
|
+
line('Schema', 'writes your content config — committed source, and it changes what builds.'),
|
|
1362
|
+
line('Editor', 'writes .astro-dev-edit.json — local, and only this drawer reads it.'),
|
|
1363
|
+
line(
|
|
1364
|
+
'',
|
|
1365
|
+
'Saving a schema change rewrites that field’s expression in canonical form, and reloads ' +
|
|
1366
|
+
'the page as Astro resyncs — this drawer reopens here afterwards. A retype existing ' +
|
|
1367
|
+
'entries don’t satisfy will fail that sync until you update them.',
|
|
1368
|
+
),
|
|
1369
|
+
);
|
|
1370
|
+
return wrap;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function group(title: string, rows: HTMLElement[]): HTMLElement {
|
|
1374
|
+
const wrap = styled('div', `atx-collections-group atx-collections-group-${title.toLowerCase()}`);
|
|
1375
|
+
wrap.append(caption(title));
|
|
1376
|
+
for (const r of rows) wrap.append(r);
|
|
1377
|
+
return wrap;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function caption(text: string): HTMLElement {
|
|
1381
|
+
const el = styled('div', 'atx-collections-caption');
|
|
1382
|
+
el.textContent = text;
|
|
1383
|
+
return el;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function controlRow(label: string, controls: HTMLElement[]): HTMLElement {
|
|
1387
|
+
const row = styled('label', 'atx-collections-control');
|
|
1388
|
+
const name = styled('span', 'atx-collections-control-label');
|
|
1389
|
+
name.textContent = label;
|
|
1390
|
+
row.append(name, ...controls);
|
|
1391
|
+
return row;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function input(value: string, placeholder: string, onChange: () => void): HTMLInputElement {
|
|
1395
|
+
const el = inputEl('input', 'atx-collections-input');
|
|
1396
|
+
el.type = 'text';
|
|
1397
|
+
el.value = value;
|
|
1398
|
+
el.placeholder = placeholder;
|
|
1399
|
+
el.spellcheck = false;
|
|
1400
|
+
el.addEventListener('input', onChange);
|
|
1401
|
+
return el;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/** A choice: value, label, and whether it is shown but unpickable. */
|
|
1405
|
+
type Choice = readonly [value: string, label: string, disabled?: boolean];
|
|
1406
|
+
|
|
1407
|
+
function select(
|
|
1408
|
+
choices: ReadonlyArray<Choice>,
|
|
1409
|
+
value: string,
|
|
1410
|
+
onChange: () => void,
|
|
1411
|
+
): HTMLSelectElement {
|
|
1412
|
+
const el = inputEl('select', 'atx-collections-select');
|
|
1413
|
+
for (const [v, label, disabled] of choices) {
|
|
1414
|
+
const opt = document.createElement('option');
|
|
1415
|
+
opt.value = v;
|
|
1416
|
+
opt.textContent = label;
|
|
1417
|
+
if (disabled) opt.disabled = true;
|
|
1418
|
+
el.append(opt);
|
|
1419
|
+
}
|
|
1420
|
+
el.value = value;
|
|
1421
|
+
el.addEventListener('change', onChange);
|
|
1422
|
+
return el;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function checkbox(
|
|
1426
|
+
label: string,
|
|
1427
|
+
checked: boolean,
|
|
1428
|
+
onChange: () => void,
|
|
1429
|
+
/** The words beside the box. A required flag reads Yes/No; a switch that turns
|
|
1430
|
+
* a capability on reads On/Off, because "Yes" answers nothing there. */
|
|
1431
|
+
words: readonly [on: string, off: string] = ['Yes', 'No'],
|
|
1432
|
+
): { root: HTMLElement; input: HTMLInputElement } {
|
|
1433
|
+
const wrap = styled('span', 'atx-collections-checkbox');
|
|
1434
|
+
const box = styled('input', 'atx-collections-check');
|
|
1435
|
+
box.type = 'checkbox';
|
|
1436
|
+
box.checked = checked;
|
|
1437
|
+
const hint = styled('span', 'atx-collections-check-hint');
|
|
1438
|
+
hint.textContent = checked ? words[0] : words[1];
|
|
1439
|
+
box.addEventListener('change', () => {
|
|
1440
|
+
hint.textContent = box.checked ? words[0] : words[1];
|
|
1441
|
+
onChange();
|
|
1442
|
+
});
|
|
1443
|
+
wrap.append(box, hint);
|
|
1444
|
+
wrap.setAttribute('aria-label', label);
|
|
1445
|
+
return { root: wrap, input: box };
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function pendingRow(spec: SchemaFieldSpec, undo: () => void): HTMLElement {
|
|
1449
|
+
const row = styled('div', 'atx-collections-new');
|
|
1450
|
+
const name = styled('span', 'atx-collections-new-name');
|
|
1451
|
+
name.textContent = spec.name;
|
|
1452
|
+
const meta = styled('span', 'atx-collections-new-meta');
|
|
1453
|
+
meta.textContent =
|
|
1454
|
+
`${TYPE_LABEL[spec.type] ?? spec.type}${spec.required ? ' · required' : ' · optional'}` +
|
|
1455
|
+
(spec.defaultValue !== undefined ? ` · default ${String(spec.defaultValue)}` : '');
|
|
1456
|
+
row.append(name, meta, cornerButton('Remove', null, undo));
|
|
1457
|
+
return row;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/**
|
|
1461
|
+
* A control that belongs to the surface it sits on rather than to a footer: a
|
|
1462
|
+
* card header's corner action, or the detail view's way back. One size down
|
|
1463
|
+
* and outlined, which is the shape `newCollectionButton` already uses — these
|
|
1464
|
+
* are the same kind of thing and reading as one family is the point.
|
|
1465
|
+
*/
|
|
1466
|
+
function cornerButton(
|
|
1467
|
+
label: string,
|
|
1468
|
+
glyph: IconName | null,
|
|
1469
|
+
onClick: () => void,
|
|
1470
|
+
): HTMLButtonElement {
|
|
1471
|
+
const btn = footButton(label, 'outline', onClick);
|
|
1472
|
+
btn.classList.add('atx-btn-sm');
|
|
1473
|
+
if (glyph) btn.prepend(icon(glyph, 16));
|
|
1474
|
+
return btn;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function backLink(onClick: () => void): HTMLButtonElement {
|
|
1478
|
+
const btn = cornerButton('Collections', null, onClick);
|
|
1479
|
+
btn.classList.add('atx-collections-back');
|
|
1480
|
+
// The chevron is the forward one, turned around — one path, two directions.
|
|
1481
|
+
btn.prepend(icon('chevronRight', 16));
|
|
1482
|
+
return btn;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/** The collection list's corner action, the same shape the entry drawer's
|
|
1486
|
+
* "New" uses, because it is the same kind of thing. */
|
|
1487
|
+
function newCollectionButton(onClick: () => void): HTMLButtonElement {
|
|
1488
|
+
return cornerButton('New', 'plus', onClick);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
function blurb(text: string): HTMLElement {
|
|
1492
|
+
const el = styled('p', 'atx-collections-blurb');
|
|
1493
|
+
el.textContent = text;
|
|
1494
|
+
return el;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function note(parts: Node[], tone: Tone): HTMLElement {
|
|
1498
|
+
const el = styled('p', 'atx-collections-note');
|
|
1499
|
+
el.dataset.tone = tone;
|
|
1500
|
+
el.append(...parts);
|
|
1501
|
+
return el;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/** Relative time, coarse. A listing needs "recent or not", not a timestamp. */
|
|
1505
|
+
function when(mtime: number): string {
|
|
1506
|
+
if (!mtime) return 'unknown date';
|
|
1507
|
+
const mins = Math.round((Date.now() - mtime) / 60000);
|
|
1508
|
+
if (mins < 1) return 'just now';
|
|
1509
|
+
if (mins < 60) return `${mins}m ago`;
|
|
1510
|
+
const hours = Math.round(mins / 60);
|
|
1511
|
+
if (hours < 24) return `${hours}h ago`;
|
|
1512
|
+
const days = Math.round(hours / 24);
|
|
1513
|
+
return days < 30 ? `${days}d ago` : new Date(mtime).toISOString().slice(0, 10);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
function textNode(text: string): Text {
|
|
1517
|
+
return document.createTextNode(text);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
function showError(el: HTMLElement, message: string): void {
|
|
1521
|
+
el.textContent = message;
|
|
1522
|
+
el.toggleAttribute('data-on', true);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/** An override with its empty keys dropped, for comparison. */
|
|
1526
|
+
function normalize(o: FieldOverride): FieldOverride {
|
|
1527
|
+
return {
|
|
1528
|
+
...(o.widget ? { widget: o.widget } : {}),
|
|
1529
|
+
...(o.label ? { label: o.label } : {}),
|
|
1530
|
+
...(o.hidden ? { hidden: true } : {}),
|
|
1531
|
+
};
|
|
1532
|
+
}
|