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,457 @@
|
|
|
1
|
+
import type { FieldDescriptor, OptionDescriptor, SettingsResponse } from '../../shared/protocol.ts';
|
|
2
|
+
import * as api from '../api.ts';
|
|
3
|
+
import { setFeatures } from '../features.ts';
|
|
4
|
+
import { coerceImportWidth } from '../../shared/unsplash.ts';
|
|
5
|
+
import { clearHighlight } from '../hover.ts';
|
|
6
|
+
import { icon } from '../icons.ts';
|
|
7
|
+
import {
|
|
8
|
+
inputEl,
|
|
9
|
+
buildTabs,
|
|
10
|
+
footButton,
|
|
11
|
+
setButtonEnabled,
|
|
12
|
+
styled,
|
|
13
|
+
toast,
|
|
14
|
+
} from '../ui.ts';
|
|
15
|
+
import { card, fieldGroup } from '../group.ts';
|
|
16
|
+
import { openDrawer } from './drawer.ts';
|
|
17
|
+
import { applyFieldErrors, buildControl, collectChanges, type FieldControl } from './fields.ts';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The Settings drawer — every integration option, editable in place.
|
|
21
|
+
*
|
|
22
|
+
* **The panel does not know what the options are.** `GET /settings` returns a
|
|
23
|
+
* list of {@link OptionDescriptor}s — label, help, control type, current value,
|
|
24
|
+
* provenance — and this module renders whatever arrives, grouped into tabs by
|
|
25
|
+
* the `group` each one declares. Adding an option is one entry in the server's
|
|
26
|
+
* `OPTION_SPECS` table and no client change at all. Each descriptor becomes a
|
|
27
|
+
* synthesized {@link FieldDescriptor} so the controls come from the entry
|
|
28
|
+
* editor's own `buildControl` registry rather than a parallel one.
|
|
29
|
+
*
|
|
30
|
+
* **`locked` is rendered, not hidden.** An option `astro.config.mjs` sets cannot
|
|
31
|
+
* be changed from here, because config wins at resolve time. The control renders
|
|
32
|
+
* disabled and says where the value came from. Hiding those rows would be worse:
|
|
33
|
+
* the user would wonder why the option they can see in their config isn't listed.
|
|
34
|
+
*
|
|
35
|
+
* **The access key has two axes, not one.** `locked` is a single bit; a key can
|
|
36
|
+
* be *writable but not clearable* — one in `.env` is overridden by the
|
|
37
|
+
* `.env.local` a save writes, yet removing a line from `.env.local` cannot unset
|
|
38
|
+
* it. So the server sends `writable` and `clearable` separately and this panel
|
|
39
|
+
* renders them, rather than re-deriving either from `source`: which env file won
|
|
40
|
+
* decides the answer, and a second copy of that precedence here would drift.
|
|
41
|
+
*
|
|
42
|
+
* **The access key stays one-way.** It is never pre-filled — a read returns only
|
|
43
|
+
* a masked hint — the input is `type="password"` with an explicit reveal, and the
|
|
44
|
+
* value goes straight to the localhost-gated `/settings` endpoint. That POST is
|
|
45
|
+
* the one moment the key crosses the wire in plaintext, unavoidable for a
|
|
46
|
+
* paste-it-here UI, and its destination is the same `.env.local` a developer
|
|
47
|
+
* would otherwise have opened in their editor.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
const UNSPLASH_APPS_URL = 'https://unsplash.com/oauth/applications';
|
|
51
|
+
|
|
52
|
+
/** Where a resolved key came from, in words. `env-file` prefers the actual
|
|
53
|
+
* filename the server reported, since which file it is decides what the user
|
|
54
|
+
* has to do about it. */
|
|
55
|
+
const SOURCE_LABEL: Record<string, string> = {
|
|
56
|
+
config: 'astro.config.mjs',
|
|
57
|
+
'env-shell': 'an exported shell variable',
|
|
58
|
+
'env-file': 'a .env file',
|
|
59
|
+
file: '.astro-dev-edit.json (older location)',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const sourceLabel = (u: SettingsResponse['unsplash']): string =>
|
|
63
|
+
(u.source === 'env-file' && u.sourceFile) || SOURCE_LABEL[u.source ?? ''] || 'stored settings';
|
|
64
|
+
|
|
65
|
+
/** Tabs, in render order. A group with no options is dropped, so a server that
|
|
66
|
+
* predates a group simply shows fewer tabs. */
|
|
67
|
+
const GROUPS: ReadonlyArray<{ id: string; label: string; blurb: string }> = [
|
|
68
|
+
{
|
|
69
|
+
id: 'general',
|
|
70
|
+
label: 'General',
|
|
71
|
+
blurb: 'What the editor is allowed to touch, and how it finds your source.',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'editing',
|
|
75
|
+
label: 'Editing',
|
|
76
|
+
blurb: 'Which editing surfaces the overlay offers.',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'media',
|
|
80
|
+
label: 'Media',
|
|
81
|
+
blurb: 'Where images are read from, and where new ones are written.',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'unsplash',
|
|
85
|
+
label: 'Unsplash',
|
|
86
|
+
blurb: 'An optional photo source in the media picker.',
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
export interface SettingsPanelOptions {
|
|
91
|
+
/** Open on a particular tab. Used by the media modal's "no key configured"
|
|
92
|
+
* card, which wants the Unsplash tab specifically. */
|
|
93
|
+
tab?: string;
|
|
94
|
+
/** Stacking layer, when opened above something already raised — that same
|
|
95
|
+
* card opens this from the media modal's own layer. */
|
|
96
|
+
layer?: number;
|
|
97
|
+
/** Run after the drawer closes, whatever the outcome. */
|
|
98
|
+
onClose?(): void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function openSettingsPanel(opts: SettingsPanelOptions = {}): void {
|
|
102
|
+
clearHighlight();
|
|
103
|
+
|
|
104
|
+
let controls: FieldControl[] = [];
|
|
105
|
+
let current: SettingsResponse | null = null;
|
|
106
|
+
let saving = false;
|
|
107
|
+
/** Set once the key input has been typed into — the only dirty state the key
|
|
108
|
+
* field can report, since it never pre-fills. */
|
|
109
|
+
const keyDirty = (): boolean => keyInput.value.trim().length > 0;
|
|
110
|
+
|
|
111
|
+
const isDirty = (): boolean =>
|
|
112
|
+
!saving && (keyDirty() || Object.keys(collectChanges(controls)).length > 0);
|
|
113
|
+
|
|
114
|
+
const shell = openDrawer('Settings', {
|
|
115
|
+
isDirty,
|
|
116
|
+
discardMessage: 'Discard unsaved settings changes?',
|
|
117
|
+
width: 'min(max(520px, 44vw), 94vw)',
|
|
118
|
+
...(opts.layer !== undefined ? { layer: opts.layer, restoreState: true } : {}),
|
|
119
|
+
...(opts.onClose ? { onClose: opts.onClose } : {}),
|
|
120
|
+
});
|
|
121
|
+
const { body, foot } = shell;
|
|
122
|
+
|
|
123
|
+
// --- the option panes ------------------------------------------------------
|
|
124
|
+
const panes = new Map<string, HTMLElement>();
|
|
125
|
+
for (const g of GROUPS) {
|
|
126
|
+
const pane = styled('div', `atx-settings-pane atx-settings-pane-${g.id}`);
|
|
127
|
+
panes.set(g.id, pane);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const status = styled('p', 'atx-settings-status');
|
|
131
|
+
|
|
132
|
+
const error = styled('p', 'atx-settings-error');
|
|
133
|
+
|
|
134
|
+
// --- the Unsplash key section, which is not an option ----------------------
|
|
135
|
+
const keyCard = card({ title: 'Access key' });
|
|
136
|
+
const keySection = keyCard.root;
|
|
137
|
+
keySection.classList.add('atx-settings-key-section');
|
|
138
|
+
|
|
139
|
+
const keyBlurb = styled('p', 'atx-settings-blurb');
|
|
140
|
+
const appsLink = styled('a', 'atx-settings-link');
|
|
141
|
+
appsLink.href = UNSPLASH_APPS_URL;
|
|
142
|
+
appsLink.target = '_blank';
|
|
143
|
+
appsLink.rel = 'noreferrer';
|
|
144
|
+
appsLink.textContent = 'Create an application';
|
|
145
|
+
keyBlurb.append(
|
|
146
|
+
appsLink,
|
|
147
|
+
document.createTextNode(' to get an access key — the demo tier allows 50 searches an hour.'),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const keyStatus = styled('p', 'atx-settings-key-status');
|
|
151
|
+
|
|
152
|
+
const keyRow = styled('div', 'atx-settings-row');
|
|
153
|
+
// No masked input exists anywhere else in the overlay, so this is a plain
|
|
154
|
+
// control wearing the shared baseline rather than a reusable widget.
|
|
155
|
+
const keyInput = inputEl('input', 'atx-settings-key');
|
|
156
|
+
keyInput.type = 'password';
|
|
157
|
+
keyInput.autocomplete = 'off';
|
|
158
|
+
keyInput.spellcheck = false;
|
|
159
|
+
keyInput.placeholder = 'Paste your Unsplash access key';
|
|
160
|
+
|
|
161
|
+
const reveal = footButton('Show', 'outline', () => {
|
|
162
|
+
const hidden = keyInput.type === 'password';
|
|
163
|
+
keyInput.type = hidden ? 'text' : 'password';
|
|
164
|
+
reveal.textContent = hidden ? 'Hide' : 'Show';
|
|
165
|
+
});
|
|
166
|
+
keyRow.append(keyInput, reveal);
|
|
167
|
+
|
|
168
|
+
const keyHint = styled('p', 'atx-settings-hint');
|
|
169
|
+
|
|
170
|
+
const clearKeyBtn = footButton('Clear key', 'destructive', () => void save({ clearKey: true }));
|
|
171
|
+
const keyActions = styled('div', 'atx-settings-key-actions');
|
|
172
|
+
keyActions.append(clearKeyBtn);
|
|
173
|
+
|
|
174
|
+
keyCard.body.append(keyBlurb, keyStatus, keyRow, keyHint);
|
|
175
|
+
keyCard.foot().append(keyActions);
|
|
176
|
+
|
|
177
|
+
const warning = styled('p', 'atx-settings-warning');
|
|
178
|
+
|
|
179
|
+
const tabs = buildTabs(
|
|
180
|
+
GROUPS.map((g) => ({ id: g.id, label: g.label, pane: panes.get(g.id)! })),
|
|
181
|
+
{ classPrefix: 'settings' },
|
|
182
|
+
);
|
|
183
|
+
body.append(status, tabs.strip, tabs.host, warning, error);
|
|
184
|
+
|
|
185
|
+
// --- rendering the server's answer -----------------------------------------
|
|
186
|
+
const paint = (data: SettingsResponse): void => {
|
|
187
|
+
current = data;
|
|
188
|
+
controls = [];
|
|
189
|
+
status.textContent = '';
|
|
190
|
+
|
|
191
|
+
const options = data.options ?? [];
|
|
192
|
+
for (const g of GROUPS) {
|
|
193
|
+
const pane = panes.get(g.id)!;
|
|
194
|
+
pane.textContent = '';
|
|
195
|
+
const mine = options.filter((o) => o.group === g.id);
|
|
196
|
+
|
|
197
|
+
// One card per tab: the group's name and its one-line blurb are the
|
|
198
|
+
// card's header, so the pane says what it governs instead of opening
|
|
199
|
+
// with an unattributed sentence above a run of controls.
|
|
200
|
+
const group = fieldGroup();
|
|
201
|
+
for (const o of mine) {
|
|
202
|
+
const control = buildControl(toFieldDescriptor(o), o.value);
|
|
203
|
+
if (o.locked) control.root.append(lockNote(o));
|
|
204
|
+
controls.push(control);
|
|
205
|
+
group.append(control.root);
|
|
206
|
+
}
|
|
207
|
+
if (mine.length > 0) {
|
|
208
|
+
const optionCard = card({ title: g.label, description: g.blurb });
|
|
209
|
+
optionCard.body.append(group);
|
|
210
|
+
pane.append(optionCard.root);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The access key belongs to the Unsplash tab but is not an option: it is
|
|
214
|
+
// a secret with its own endpoint semantics and its own precedence — so
|
|
215
|
+
// it is a card of its own rather than a heading inside the options one.
|
|
216
|
+
if (g.id === 'unsplash') pane.append(keySection);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
paintKey(data);
|
|
220
|
+
paintWarning(data);
|
|
221
|
+
setButtonEnabled(saveBtn, true);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const paintKey = (data: SettingsResponse): void => {
|
|
225
|
+
const u = data.unsplash;
|
|
226
|
+
keyStatus.textContent = '';
|
|
227
|
+
|
|
228
|
+
if (!u.enabled) {
|
|
229
|
+
// The old dead end lived here: this used to read "add `unsplash: {}` to
|
|
230
|
+
// astro.config.mjs, then restart the dev server". The toggle above is now
|
|
231
|
+
// the answer, so point at it instead.
|
|
232
|
+
keyStatus.append(
|
|
233
|
+
icon('dot', 16),
|
|
234
|
+
text('Turn the photo source on above to add a key', 'muted'),
|
|
235
|
+
);
|
|
236
|
+
keySection.toggleAttribute('data-off', true);
|
|
237
|
+
keyInput.disabled = true;
|
|
238
|
+
setButtonEnabled(reveal, false);
|
|
239
|
+
keyHint.textContent = '';
|
|
240
|
+
clearKeyBtn.toggleAttribute('data-hidden', true);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
keySection.toggleAttribute('data-off', false);
|
|
245
|
+
|
|
246
|
+
if (u.configured) {
|
|
247
|
+
keyStatus.append(icon('check', 16), text(`Configured via ${sourceLabel(u)}`, 'ok'));
|
|
248
|
+
if (u.hint) keyStatus.append(text(u.hint, 'muted', true));
|
|
249
|
+
} else {
|
|
250
|
+
keyStatus.append(icon('dot', 16), text('Not configured', 'muted'));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// `writable` is the server's answer, not ours — see the header.
|
|
254
|
+
const overridden = !u.writable;
|
|
255
|
+
keyInput.disabled = overridden;
|
|
256
|
+
setButtonEnabled(reveal, !overridden);
|
|
257
|
+
keyInput.placeholder = overridden
|
|
258
|
+
? 'Overridden — remove the other key first'
|
|
259
|
+
: 'Paste your Unsplash access key';
|
|
260
|
+
|
|
261
|
+
if (overridden) {
|
|
262
|
+
keyHint.textContent =
|
|
263
|
+
`A key from ${sourceLabel(u)} takes precedence over the .env.local this panel ` +
|
|
264
|
+
'writes. Remove it to manage the key from here.';
|
|
265
|
+
} else if (u.staleStoredKey) {
|
|
266
|
+
// The one case worth nagging about: a secret in a file that is not where
|
|
267
|
+
// secrets go any more. Saving anything moves it.
|
|
268
|
+
keyHint.textContent =
|
|
269
|
+
'An older copy of your key is still stored in .astro-dev-edit.json. Save a key here ' +
|
|
270
|
+
'to move it into .env.local and remove that copy.';
|
|
271
|
+
} else if (!u.clearable && u.configured) {
|
|
272
|
+
keyHint.textContent =
|
|
273
|
+
`A key is also set in ${u.sourceFile ?? '.env'}. Saving here writes .env.local, which ` +
|
|
274
|
+
'takes precedence — remove that one when you are ready.';
|
|
275
|
+
} else {
|
|
276
|
+
keyHint.textContent =
|
|
277
|
+
'Saved to .env.local at your project root as UNSPLASH_ACCESS_KEY, readable only by ' +
|
|
278
|
+
'you (0600). It is never sent back to the browser.';
|
|
279
|
+
}
|
|
280
|
+
clearKeyBtn.toggleAttribute('data-hidden', !(u.configured && u.clearable));
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The uncommitted-secret warning, painted from `paint` rather than from
|
|
285
|
+
* `paintKey`: it is a fact about files, not about the key. Scoping it to the
|
|
286
|
+
* key left it unreachable in exactly the case it is for — the photo source
|
|
287
|
+
* off, so `paintKey` returns early, while a file already sits untracked on
|
|
288
|
+
* disk holding whatever General or Media last saved. It lives under the tab
|
|
289
|
+
* host for the same reason, so it is on screen whichever tab is open.
|
|
290
|
+
*
|
|
291
|
+
* The server sends the list, because whether `.env.local` is even present is
|
|
292
|
+
* a question only it can answer.
|
|
293
|
+
*/
|
|
294
|
+
const paintWarning = (data: SettingsResponse): void => {
|
|
295
|
+
const files = data.gitignoreWarning ?? [];
|
|
296
|
+
if (files.length > 0) {
|
|
297
|
+
const subject = files.length === 1 ? `${files[0]} is not listed` : `${files.join(' and ')} are not listed`;
|
|
298
|
+
warning.textContent =
|
|
299
|
+
`${subject} in this project’s .gitignore. They hold your settings and your Unsplash ` +
|
|
300
|
+
'access key — add them to your ignore rules before either can be committed. ' +
|
|
301
|
+
'(This integration cannot edit your ignore rules for you.)';
|
|
302
|
+
warning.toggleAttribute('data-on', true);
|
|
303
|
+
} else {
|
|
304
|
+
warning.toggleAttribute('data-on', false);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const text = (value: string, tone: 'muted' | 'ok' | 'warn', mono = false): HTMLElement => {
|
|
309
|
+
const el = styled('span', 'atx-settings-text');
|
|
310
|
+
el.dataset.tone = tone;
|
|
311
|
+
if (mono) el.dataset.mono = '';
|
|
312
|
+
el.textContent = value;
|
|
313
|
+
return el;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/** Why a control is disabled, under the control. */
|
|
317
|
+
const lockNote = (o: OptionDescriptor): HTMLElement => {
|
|
318
|
+
// Muted, not amber: an option the project set in its own config is a
|
|
319
|
+
// normal state, and painting a third of the panel in warning colour would
|
|
320
|
+
// spend the one colour that should mean "something is wrong" — which here
|
|
321
|
+
// is the uncommitted-secret warning at the bottom.
|
|
322
|
+
const note = styled('p', 'atx-settings-lock');
|
|
323
|
+
note.append(
|
|
324
|
+
icon('lock', 12),
|
|
325
|
+
document.createTextNode(
|
|
326
|
+
o.restartRequired
|
|
327
|
+
? 'Read before the dev server starts, so it lives in astro.config.mjs and changing it needs a restart.'
|
|
328
|
+
: 'Set in astro.config.mjs, which takes precedence. Remove it there to change it from here.',
|
|
329
|
+
),
|
|
330
|
+
);
|
|
331
|
+
return note;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/** Freeze the drawer during a write. Un-freezing repaints from the current
|
|
335
|
+
* response rather than blanket-enabling, so a control disabled *by state* —
|
|
336
|
+
* a config-overridden key field — stays that way. */
|
|
337
|
+
const setBusy = (busy: boolean): void => {
|
|
338
|
+
saving = busy;
|
|
339
|
+
keyInput.readOnly = busy;
|
|
340
|
+
for (const btn of shell.body.querySelectorAll('button')) setButtonEnabled(btn, !busy);
|
|
341
|
+
for (const btn of foot.querySelectorAll('button')) setButtonEnabled(btn, !busy);
|
|
342
|
+
if (!busy) {
|
|
343
|
+
// Rebuilt by the paint that follows a successful save; on a failure this
|
|
344
|
+
// restores the pre-save enablement.
|
|
345
|
+
if (current) paintKey(current);
|
|
346
|
+
setButtonEnabled(saveBtn, current !== null);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const showError = (message: string): void => {
|
|
351
|
+
error.textContent = message;
|
|
352
|
+
error.toggleAttribute('data-on', true);
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const save = async (o: { clearKey?: boolean } = {}): Promise<void> => {
|
|
356
|
+
error.toggleAttribute('data-on', false);
|
|
357
|
+
applyFieldErrors(controls, {});
|
|
358
|
+
|
|
359
|
+
const options = collectChanges(controls);
|
|
360
|
+
const key = o.clearKey ? '' : keyInput.value;
|
|
361
|
+
const sendKey = o.clearKey || key.trim().length > 0;
|
|
362
|
+
if (Object.keys(options).length === 0 && !sendKey) {
|
|
363
|
+
showError('Nothing has changed yet.');
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
setBusy(true);
|
|
368
|
+
try {
|
|
369
|
+
const next = await api.saveSettings({
|
|
370
|
+
...(Object.keys(options).length > 0 ? { options } : {}),
|
|
371
|
+
...(sendKey ? { unsplash: { accessKey: key } } : {}),
|
|
372
|
+
});
|
|
373
|
+
// Feature flags the overlay reads are option-derived, so a save repaints
|
|
374
|
+
// the page's affordances without a reload.
|
|
375
|
+
setFeatures({
|
|
376
|
+
ok: true,
|
|
377
|
+
name: 'astro-dev-edit',
|
|
378
|
+
milestone: 1,
|
|
379
|
+
root: '',
|
|
380
|
+
cssInspector: valueOf(next, 'cssInspector') === true,
|
|
381
|
+
openInEditor: valueOf(next, 'openInEditor') === true,
|
|
382
|
+
entryEditor: valueOf(next, 'entryEditor') === true,
|
|
383
|
+
unsplash: next.unsplash.configured,
|
|
384
|
+
// A `select`'s value is a string on the wire; setFeatures parses it, so
|
|
385
|
+
// the picker's size select moves with a saved default. `?? undefined`
|
|
386
|
+
// because a null here would read as "the server has no such option".
|
|
387
|
+
unsplashImportWidth:
|
|
388
|
+
coerceImportWidth(valueOf(next, 'unsplashImportWidth')) ?? undefined,
|
|
389
|
+
});
|
|
390
|
+
keyInput.value = '';
|
|
391
|
+
keyInput.type = 'password';
|
|
392
|
+
reveal.textContent = 'Show';
|
|
393
|
+
paint(next);
|
|
394
|
+
toast(
|
|
395
|
+
o.clearKey ? 'Unsplash access key cleared' : 'Settings saved',
|
|
396
|
+
'ok',
|
|
397
|
+
);
|
|
398
|
+
} catch (err) {
|
|
399
|
+
if (err instanceof api.SettingsRefusal) {
|
|
400
|
+
applyFieldErrors(controls, err.fieldErrors);
|
|
401
|
+
showError(err.message);
|
|
402
|
+
} else {
|
|
403
|
+
showError(err instanceof Error ? err.message : 'Could not save.');
|
|
404
|
+
}
|
|
405
|
+
} finally {
|
|
406
|
+
setBusy(false);
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// --- footer ----------------------------------------------------------------
|
|
411
|
+
const closeBtn = footButton('Close', 'outline', () => shell.close());
|
|
412
|
+
const saveBtn = footButton('Save', 'default', () => void save());
|
|
413
|
+
foot.append(closeBtn, saveBtn);
|
|
414
|
+
|
|
415
|
+
// --- boot ------------------------------------------------------------------
|
|
416
|
+
status.append(icon('spinner', 16), text('Reading settings…', 'muted'));
|
|
417
|
+
setButtonEnabled(saveBtn, false);
|
|
418
|
+
clearKeyBtn.toggleAttribute('data-hidden', true);
|
|
419
|
+
|
|
420
|
+
void api.getSettings().then(
|
|
421
|
+
(data) => {
|
|
422
|
+
paint(data);
|
|
423
|
+
if (opts.tab) tabs.show(opts.tab);
|
|
424
|
+
},
|
|
425
|
+
(err: unknown) => {
|
|
426
|
+
status.textContent = '';
|
|
427
|
+
status.append(icon('alert', 16), text('Could not read settings', 'warn'));
|
|
428
|
+
showError(err instanceof Error ? err.message : 'The dev server did not answer.');
|
|
429
|
+
},
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** An option, described as a field so `buildControl` can render it.
|
|
434
|
+
*
|
|
435
|
+
* `present: true` and `required: false` throughout: an option always has an
|
|
436
|
+
* effective value (there is no "absent" state to hint a default for), and none
|
|
437
|
+
* of them can be cleared to nothing — `coerceOptionPatch` refuses an empty
|
|
438
|
+
* string or an empty list, so `collectChanges`' clear-to-null path is dead
|
|
439
|
+
* weight here rather than a hazard. */
|
|
440
|
+
function toFieldDescriptor(o: OptionDescriptor): FieldDescriptor {
|
|
441
|
+
return {
|
|
442
|
+
name: o.key,
|
|
443
|
+
label: o.label,
|
|
444
|
+
type: o.type,
|
|
445
|
+
required: false,
|
|
446
|
+
present: true,
|
|
447
|
+
source: 'inferred',
|
|
448
|
+
help: o.help,
|
|
449
|
+
...(o.choices ? { options: o.choices } : {}),
|
|
450
|
+
...(o.locked ? { readOnly: true } : {}),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** An option's value out of a response, by key. */
|
|
455
|
+
function valueOf(data: SettingsResponse, key: string): unknown {
|
|
456
|
+
return data.options?.find((o) => o.key === key)?.value;
|
|
457
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { trapFocus } from '../focus.ts';
|
|
2
|
+
import { icon } from '../icons.ts';
|
|
3
|
+
import type { Interaction } from '../state.ts';
|
|
4
|
+
import * as state from '../state.ts';
|
|
5
|
+
import { mount } from '../shadow.ts';
|
|
6
|
+
import {
|
|
7
|
+
inputEl,
|
|
8
|
+
buildBackdrop,
|
|
9
|
+
buildPanel,
|
|
10
|
+
pillButton,
|
|
11
|
+
styled,
|
|
12
|
+
wirePanelButtons,
|
|
13
|
+
} from '../ui.ts';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The panel shape shared by every editor that edits *source* rather than
|
|
17
|
+
* rendered text — the markup popup and the traced-expression popup. Inline
|
|
18
|
+
* contenteditable can't serve either: one would escape the tags, the other
|
|
19
|
+
* would rewrite the template instead of the string behind it.
|
|
20
|
+
*
|
|
21
|
+
* What this owns, so its callers don't each re-implement it:
|
|
22
|
+
*
|
|
23
|
+
* - panel + backdrop + the interaction token, released exactly once;
|
|
24
|
+
* - the dirty flag the admin bar's exit button reads;
|
|
25
|
+
* - Cmd/Ctrl+Enter to save (plain Enter stays a newline — a panel has a Save
|
|
26
|
+
* button, unlike an inline edit);
|
|
27
|
+
* - **staying open when a save is refused.** A refusal here is an ordinary
|
|
28
|
+
* outcome — a disallowed tag, an ambiguous match — that the user fixes and
|
|
29
|
+
* retries, so the reason is shown inside the panel with the text intact
|
|
30
|
+
* rather than closing and losing the work.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export interface SourcePopupOptions {
|
|
34
|
+
/** Panel title bar, e.g. `Markup · index.astro:44:13`. */
|
|
35
|
+
title: string;
|
|
36
|
+
/** Label above the box. */
|
|
37
|
+
label: string;
|
|
38
|
+
/** Initial (and baseline) text. */
|
|
39
|
+
value: string;
|
|
40
|
+
/** Minimum height of the editing box. */
|
|
41
|
+
minHeight?: string;
|
|
42
|
+
/** Monospace box (source) rather than proportional (prose). */
|
|
43
|
+
mono?: boolean;
|
|
44
|
+
/** Optional controls under the box, built with the live textarea. */
|
|
45
|
+
tools?: (input: HTMLTextAreaElement, markDirty: () => void) => HTMLElement;
|
|
46
|
+
/**
|
|
47
|
+
* Write the edit. Resolve `null` on success — the panel then closes — or the
|
|
48
|
+
* message to show inside the still-open panel.
|
|
49
|
+
*/
|
|
50
|
+
save: (value: string) => Promise<string | null>;
|
|
51
|
+
/**
|
|
52
|
+
* Jump to the source in the user's editor. Adds an **open** button to the
|
|
53
|
+
* title bar: what these popups edit *is* source, so the file it came from
|
|
54
|
+
* should be one click away — the same escape hatch the hover pill and the
|
|
55
|
+
* refusal notice offer. Editing continues; the panel stays open.
|
|
56
|
+
*/
|
|
57
|
+
openSource?: () => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function openSourcePopup(opts: SourcePopupOptions): void {
|
|
61
|
+
let jump: HTMLElement | undefined;
|
|
62
|
+
if (opts.openSource) {
|
|
63
|
+
const btn = pillButton(
|
|
64
|
+
'atx-panel-open',
|
|
65
|
+
'open',
|
|
66
|
+
'Open this location in your editor',
|
|
67
|
+
{ marginLeft: '0', flex: '0 0 auto' },
|
|
68
|
+
icon('external', 12),
|
|
69
|
+
);
|
|
70
|
+
btn.addEventListener('click', () => opts.openSource?.());
|
|
71
|
+
jump = btn;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const panel = buildPanel(opts.title, jump);
|
|
75
|
+
const body = panel.querySelector('[data-body]') as HTMLElement;
|
|
76
|
+
|
|
77
|
+
const label = styled('label', 'atx-popup-label');
|
|
78
|
+
label.textContent = opts.label;
|
|
79
|
+
|
|
80
|
+
// Only the caller's height override stays inline; the face is a flag, since
|
|
81
|
+
// both faces are fixed and only the choice between them is the caller's.
|
|
82
|
+
const input = inputEl(
|
|
83
|
+
'textarea',
|
|
84
|
+
'atx-popup-input',
|
|
85
|
+
opts.minHeight ? { minHeight: opts.minHeight } : undefined,
|
|
86
|
+
);
|
|
87
|
+
if (opts.mono) input.dataset.mono = '';
|
|
88
|
+
input.value = opts.value;
|
|
89
|
+
|
|
90
|
+
const error = styled('p', 'atx-popup-error');
|
|
91
|
+
|
|
92
|
+
const markDirty = (): void => {
|
|
93
|
+
state.setSavePhase(input.value.trim() === opts.value.trim() ? 'clean' : 'dirty');
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
body.append(label, input);
|
|
97
|
+
if (opts.tools) body.append(opts.tools(input, markDirty));
|
|
98
|
+
body.append(error);
|
|
99
|
+
|
|
100
|
+
// `commit` is what leaving edit mode runs: an action labelled "Save & exit"
|
|
101
|
+
// must write the draft in the box, not drop it. A refusal keeps the panel
|
|
102
|
+
// open (see `run`), and the error phase keeps edit mode open with it.
|
|
103
|
+
const claim = (): Interaction =>
|
|
104
|
+
state.begin({ kind: 'panel', close: () => close(false), commit: () => close(true) });
|
|
105
|
+
|
|
106
|
+
let token = claim();
|
|
107
|
+
let saving = false;
|
|
108
|
+
|
|
109
|
+
const teardown = (): void => {
|
|
110
|
+
state.releaseIf(token);
|
|
111
|
+
releaseFocus();
|
|
112
|
+
panel.remove();
|
|
113
|
+
backdrop.remove();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const setBusy = (busy: boolean): void => {
|
|
117
|
+
for (const btn of panel.querySelectorAll('button')) btn.disabled = busy;
|
|
118
|
+
input.readOnly = busy;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const run = async (): Promise<void> => {
|
|
122
|
+
saving = true;
|
|
123
|
+
setBusy(true);
|
|
124
|
+
error.toggleAttribute('data-on', false);
|
|
125
|
+
|
|
126
|
+
const failure = await opts.save(input.value);
|
|
127
|
+
|
|
128
|
+
saving = false;
|
|
129
|
+
if (!failure) {
|
|
130
|
+
teardown();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// The in-flight `busy` interaction released the slot; re-claim it so the
|
|
134
|
+
// still-open panel keeps owning the page's clicks.
|
|
135
|
+
token = claim();
|
|
136
|
+
setBusy(false);
|
|
137
|
+
error.textContent = failure;
|
|
138
|
+
error.toggleAttribute('data-on', true);
|
|
139
|
+
input.focus();
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const close = (commit: boolean): void => {
|
|
143
|
+
if (saving) return; // the write is in flight; let it settle
|
|
144
|
+
if (!commit || input.value.trim() === opts.value.trim()) {
|
|
145
|
+
teardown();
|
|
146
|
+
state.setSavePhase('clean');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
void run();
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const backdrop = buildBackdrop(() => close(false));
|
|
153
|
+
wirePanelButtons(panel, () => close(false), () => close(true));
|
|
154
|
+
|
|
155
|
+
input.addEventListener('keydown', (e) => {
|
|
156
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
157
|
+
e.preventDefault();
|
|
158
|
+
close(true);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
input.addEventListener('input', markDirty);
|
|
162
|
+
|
|
163
|
+
mount(backdrop, panel);
|
|
164
|
+
const releaseFocus = trapFocus(panel, { initial: input });
|
|
165
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
166
|
+
}
|