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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/package.json +52 -0
  4. package/src/client/admin-bar.ts +622 -0
  5. package/src/client/api.ts +370 -0
  6. package/src/client/classify-cache.ts +61 -0
  7. package/src/client/css-inspect.ts +345 -0
  8. package/src/client/editors/asset-picker.ts +155 -0
  9. package/src/client/editors/body-editor.ts +419 -0
  10. package/src/client/editors/collections-panel.ts +1532 -0
  11. package/src/client/editors/copy-panel.ts +73 -0
  12. package/src/client/editors/drawer.ts +95 -0
  13. package/src/client/editors/entry.ts +433 -0
  14. package/src/client/editors/expression.ts +77 -0
  15. package/src/client/editors/fields.ts +309 -0
  16. package/src/client/editors/image.ts +268 -0
  17. package/src/client/editors/markup-insert.ts +73 -0
  18. package/src/client/editors/markup.ts +125 -0
  19. package/src/client/editors/media-grid.ts +326 -0
  20. package/src/client/editors/media-modal.ts +588 -0
  21. package/src/client/editors/notice.ts +160 -0
  22. package/src/client/editors/peek.ts +135 -0
  23. package/src/client/editors/settings-panel.ts +457 -0
  24. package/src/client/editors/source-popup.ts +166 -0
  25. package/src/client/editors/text.ts +105 -0
  26. package/src/client/editors/unsplash-pane.ts +317 -0
  27. package/src/client/element-context.ts +308 -0
  28. package/src/client/features.ts +81 -0
  29. package/src/client/focus.ts +166 -0
  30. package/src/client/group.ts +186 -0
  31. package/src/client/highlight.ts +146 -0
  32. package/src/client/hover.ts +485 -0
  33. package/src/client/icons.ts +160 -0
  34. package/src/client/markdown.ts +319 -0
  35. package/src/client/overlay.ts +466 -0
  36. package/src/client/page-source.ts +143 -0
  37. package/src/client/router.ts +198 -0
  38. package/src/client/shadow.ts +111 -0
  39. package/src/client/source-map.ts +150 -0
  40. package/src/client/state.ts +153 -0
  41. package/src/client/styles.ts +3485 -0
  42. package/src/client/tree-model.ts +45 -0
  43. package/src/client/tree.ts +366 -0
  44. package/src/client/ui.ts +987 -0
  45. package/src/client/unsplash-search.ts +250 -0
  46. package/src/index.ts +299 -0
  47. package/src/patcher/astro.ts +792 -0
  48. package/src/patcher/content-config.ts +1035 -0
  49. package/src/patcher/dotenv.ts +121 -0
  50. package/src/patcher/expression-trace.ts +326 -0
  51. package/src/patcher/frontmatter.ts +249 -0
  52. package/src/patcher/registry.ts +11 -0
  53. package/src/patcher/types.ts +32 -0
  54. package/src/server/annotate.ts +173 -0
  55. package/src/server/assets.ts +167 -0
  56. package/src/server/collection-entries.ts +91 -0
  57. package/src/server/content-config.ts +210 -0
  58. package/src/server/editor.ts +15 -0
  59. package/src/server/entry-detect.ts +110 -0
  60. package/src/server/entry-resolve-routes.ts +218 -0
  61. package/src/server/entry-routes.ts +304 -0
  62. package/src/server/inspect-locate.ts +81 -0
  63. package/src/server/inspect-routes.ts +94 -0
  64. package/src/server/middleware.ts +480 -0
  65. package/src/server/options.ts +778 -0
  66. package/src/server/page-source-routes.ts +71 -0
  67. package/src/server/paths.ts +219 -0
  68. package/src/server/private-files.ts +116 -0
  69. package/src/server/route-manifest.ts +200 -0
  70. package/src/server/router.ts +94 -0
  71. package/src/server/schema-introspect.ts +233 -0
  72. package/src/server/schema-routes.ts +808 -0
  73. package/src/server/settings-routes.ts +246 -0
  74. package/src/server/settings.ts +382 -0
  75. package/src/server/text-writes.ts +105 -0
  76. package/src/server/unsplash-routes.ts +515 -0
  77. package/src/server/zod-adapt.ts +239 -0
  78. package/src/shared/asset-path.ts +132 -0
  79. package/src/shared/protocol.ts +935 -0
  80. package/src/shared/slug.ts +17 -0
  81. package/src/shared/unsplash.ts +51 -0
@@ -0,0 +1,588 @@
1
+ import type { AssetInfo, MediaPick } from '../../shared/protocol.ts';
2
+ import * as api from '../api.ts';
3
+ import { hasUnsplash } from '../features.ts';
4
+ import { trapFocus } from '../focus.ts';
5
+ import { clearHighlight } from '../hover.ts';
6
+ import { icon } from '../icons.ts';
7
+ import * as state from '../state.ts';
8
+ import {
9
+ basename,
10
+ buildBackdrop,
11
+ buildPanel,
12
+ buildTabs,
13
+ footButton,
14
+ inputEl,
15
+ setButtonEnabled,
16
+ setFreshSrc,
17
+ styled,
18
+ toast,
19
+ } from '../ui.ts';
20
+ import { buildMediaGrid, type GridTile, type MediaGridHandle } from './media-grid.ts';
21
+ import { createUnsplashPane } from './unsplash-pane.ts';
22
+ import { mount } from '../shadow.ts';
23
+
24
+ /**
25
+ * The media modal — one picker, four callers, two sources.
26
+ *
27
+ * It **resolves a promise** rather than firing a callback mid-flight, so the
28
+ * caller decides when anything is written: a click stages a tile, the footer
29
+ * commits it, and Cancel/Escape resolve `null` with nothing staged. That is what
30
+ * makes "select a tile and press Escape" leave the working tree untouched.
31
+ *
32
+ * Three things here are easy to get wrong and are load-bearing:
33
+ *
34
+ * 1. **Stacking and the interaction token.** This can open *over* the CMS
35
+ * drawer, which is `Z_MODAL+6` and holds the single interaction token. The
36
+ * modal takes `+8` (backdrop `+7`) and claims its **own** token, handing back
37
+ * on close — the re-claim idiom from `source-popup.ts`. Getting it wrong
38
+ * closes the drawer out from under the modal and discards unsaved fields.
39
+ * 2. **No global busy lock during an import.** Claiming `state.begin({kind:
40
+ * 'busy'})` would evict the modal's own panel token and break Escape and the
41
+ * backdrop for the length of a multi-second download. Busy is per-tile.
42
+ * 3. **Cancel means nothing was written.** Uploads and imports do write files —
43
+ * that is unavoidable, they are how the asset arrives — but the *source
44
+ * edit* only happens on commit.
45
+ */
46
+
47
+ export interface MediaModalOptions {
48
+ /** Title-bar text. */
49
+ title?: string;
50
+ /** `'relative'` picks importable `src/` assets for an `image()` field;
51
+ * omitted picks web-servable ones. The two sets are disjoint. */
52
+ assetRef?: 'relative';
53
+ /** The web path the field already holds, marked with a `Current` chip. */
54
+ currentWebPath?: string;
55
+ /** Root-relative directory the project list opens scoped to. */
56
+ scopeDir?: string;
57
+ /** Root-relative directory uploads and imports are written into. */
58
+ targetDir?: string;
59
+ }
60
+
61
+ /**
62
+ * A source of images inside the modal. The shell owns chrome, selection and the
63
+ * footer; a pane owns its own toolbar, how it fills the grid, what the details
64
+ * rail says, and what committing a staged key means.
65
+ */
66
+ export interface MediaPane {
67
+ /**
68
+ * The pane's **toolbar only** — its own controls (filter/sort, or search and
69
+ * orientation). Deliberately not the grid: the grid is one shared instance so
70
+ * selection has a single home, and a DOM node can only live in one parent, so
71
+ * the shell places it below whichever toolbar is showing.
72
+ */
73
+ el: HTMLElement;
74
+ /** Footer commit-button label — "Use image" vs "Import & use". */
75
+ commitLabel: string;
76
+ /** Called the first time the tab is shown. */
77
+ activate(): void;
78
+ /** Fill `into` with details for the staged key (or its empty state). */
79
+ renderRail(into: HTMLElement, key: string | null): void;
80
+ /** Footer count line. */
81
+ status(): string;
82
+ /** Turn a staged key into a pick. Resolves `null` when it failed — the pane
83
+ * has already told the user why. */
84
+ commit(key: string): Promise<MediaPick | null>;
85
+ dispose(): void;
86
+ }
87
+
88
+ export interface MediaPaneDeps {
89
+ grid: MediaGridHandle;
90
+ /** Where an upload/import should land. */
91
+ targetDir?: string;
92
+ assetRef?: 'relative';
93
+ /** Ask the shell to repaint the footer and rail — after results arrive, a
94
+ * page is appended, or an error changes what the pane can say. */
95
+ refresh(): void;
96
+ }
97
+
98
+ type SortKey = 'newest' | 'name';
99
+
100
+ export function openMediaModal(opts: MediaModalOptions = {}): Promise<MediaPick | null> {
101
+ clearHighlight();
102
+
103
+ const openedAt = Date.now();
104
+
105
+ return new Promise((resolve) => {
106
+ let settled = false;
107
+ /** Every exit goes through here exactly once. */
108
+ const finish = (pick: MediaPick | null): void => {
109
+ if (settled) return;
110
+ settled = true;
111
+ projectPane.dispose();
112
+ unsplashPane?.dispose();
113
+ // Hand the slot back to the drawer/panel underneath, not just release it
114
+ // — see state.ts::releaseTo.
115
+ state.releaseTo(token, heldBefore);
116
+ releaseFocus();
117
+ panel.remove();
118
+ backdrop.remove();
119
+ window.removeEventListener('keydown', onKey, true);
120
+ resolve(pick);
121
+ };
122
+
123
+ // --- shell ---------------------------------------------------------------
124
+ const panel = buildPanel(opts.title ?? 'Choose an image', undefined, {
125
+ width: 'min(1080px, 94vw)',
126
+ height: 'min(720px, 86vh)',
127
+ layer: 8,
128
+ });
129
+ const body = panel.querySelector('[data-body]') as HTMLElement;
130
+ const foot = panel.querySelector('[data-foot]') as HTMLElement;
131
+ // The body is a column that never scrolls; the grid inside it does.
132
+ body.classList.add('atx-media-body');
133
+
134
+ // The backdrop must sit above the drawer this may have opened over, but
135
+ // below the modal itself.
136
+ const backdrop = buildBackdrop(() => finish(null), 7);
137
+ // Captured before claiming, so the drawer underneath gets the slot back.
138
+ const heldBefore = state.get();
139
+ const token = state.begin({ kind: 'panel', close: () => finish(null) });
140
+
141
+ // Escape closes the modal ONLY. Captured here rather than left to the
142
+ // global handler so it can never reach the drawer underneath.
143
+ const onKey = (e: KeyboardEvent): void => {
144
+ if (e.key !== 'Escape') return;
145
+ e.preventDefault();
146
+ e.stopPropagation();
147
+ finish(null);
148
+ };
149
+ window.addEventListener('keydown', onKey, true);
150
+
151
+ // --- tabs + upload -------------------------------------------------------
152
+ const uploadBtn = styled('button', 'atx-btn atx-btn-outline atx-media-upload');
153
+ uploadBtn.type = 'button';
154
+ uploadBtn.append(icon('upload', 16), document.createTextNode('Upload file…'));
155
+
156
+ const fileInput = styled('input', 'atx-media-file');
157
+ fileInput.type = 'file';
158
+ fileInput.accept = 'image/*';
159
+ uploadBtn.addEventListener('click', () => fileInput.click());
160
+ fileInput.addEventListener('change', () => {
161
+ const file = fileInput.files?.[0];
162
+ if (file) void uploadFile(file);
163
+ });
164
+
165
+ // --- panes ---------------------------------------------------------------
166
+ const paneHost = styled('div', 'atx-media-panes');
167
+ const rail = styled('div', 'atx-media-rail');
168
+ const content = styled('div', 'atx-media-content');
169
+ content.append(paneHost, rail);
170
+
171
+ const dropStrip = styled('div', 'atx-media-drop');
172
+
173
+ // `tabsRow` and `toolbarHost` come from buildTabs, below.
174
+
175
+ // --- footer --------------------------------------------------------------
176
+ const status = styled('span', 'atx-media-status atx-media-foot-status');
177
+ const cancelBtn = footButton('Cancel', 'outline', () => finish(null));
178
+ const useBtn = footButton('Use image', 'default', () => void commitSelection());
179
+ foot.append(status, cancelBtn, useBtn);
180
+
181
+ // --- pane construction ---------------------------------------------------
182
+ const grid = buildMediaGrid({
183
+ onSelect: () => refresh(),
184
+ onCommit: () => void commitSelection(),
185
+ emptyText: 'No images found.',
186
+ });
187
+
188
+ const paneDeps: MediaPaneDeps = {
189
+ grid,
190
+ ...(opts.targetDir ? { targetDir: opts.targetDir } : {}),
191
+ ...(opts.assetRef ? { assetRef: opts.assetRef } : {}),
192
+ refresh: () => refresh(),
193
+ };
194
+
195
+ const projectPane = createProjectPane(paneDeps, opts);
196
+ // A project that never opted in gets a single-source modal — not a tab that
197
+ // errors when clicked, and not a hidden one.
198
+ const unsplashPane = hasUnsplash() ? createUnsplashPane(paneDeps) : null;
199
+ const panes: Array<{ id: string; label: string; pane: MediaPane }> = [
200
+ { id: 'project', label: 'Project', pane: projectPane },
201
+ ...(unsplashPane ? [{ id: 'unsplash', label: 'Unsplash', pane: unsplashPane }] : []),
202
+ ];
203
+
204
+ const byId = new Map(panes.map((p) => [p.id, p.pane]));
205
+ const tabs = buildTabs(
206
+ panes.map(({ id, label, pane }) => ({ id, label, pane: pane.el })),
207
+ {
208
+ classPrefix: 'media',
209
+ // A source's first activation is what runs its initial search, so it is
210
+ // deferred until the user actually asks for that tab.
211
+ onActivate: (id: string) => byId.get(id)?.activate(),
212
+ onChange: (id: string) => {
213
+ active = panes.find((p) => p.id === id) ?? active;
214
+ // A selection in one source means nothing in another.
215
+ grid.select(null);
216
+ refresh();
217
+ },
218
+ },
219
+ );
220
+ // The strip's own host is the toolbar slot: only the source's toolbar swaps,
221
+ // while the grid below it stays put.
222
+ tabs.host.classList.add('atx-media-tabhost');
223
+ tabs.strip.append(uploadBtn);
224
+ let active = panes[0];
225
+ tabs.host.classList.add('atx-media-toolbars');
226
+
227
+ body.append(tabs.strip, content, dropStrip, fileInput);
228
+ paneHost.append(tabs.host, grid.el);
229
+
230
+ /** Repaint everything that depends on pane state or selection. */
231
+ function refresh(): void {
232
+ const key = grid.selected();
233
+ useBtn.textContent = active.pane.commitLabel;
234
+ setButtonEnabled(useBtn, key !== null);
235
+ status.textContent = active.pane.status();
236
+ rail.textContent = '';
237
+ active.pane.renderRail(rail, key);
238
+ // The project tab's count is the only place the tab label can carry one.
239
+ tabs.setLabel('project', projectCount === null ? 'Project' : `Project · ${projectCount}`);
240
+ dropStrip.textContent = opts.targetDir
241
+ ? `Drop an image anywhere to upload it to ${opts.targetDir}`
242
+ : 'Drop an image anywhere to upload it';
243
+ }
244
+
245
+ async function commitSelection(): Promise<void> {
246
+ const key = grid.selected();
247
+ if (key === null) return;
248
+ const pick = await active.pane.commit(key);
249
+ if (pick) finish(pick);
250
+ }
251
+
252
+ // --- drag & drop over the whole modal ------------------------------------
253
+ // A dedicated dashed box would eat grid height; the modal itself is the
254
+ // target, with an accent tint while a file is over it.
255
+ const dropOverlay = styled('div', 'atx-media-dropzone');
256
+ panel.append(dropOverlay);
257
+ let dragDepth = 0;
258
+ panel.addEventListener('dragenter', (e) => {
259
+ e.preventDefault();
260
+ // Counted, because dragging across child elements fires enter/leave pairs.
261
+ if (++dragDepth === 1) dropOverlay.toggleAttribute('data-on', true);
262
+ });
263
+ panel.addEventListener('dragover', (e) => e.preventDefault());
264
+ panel.addEventListener('dragleave', () => {
265
+ if (--dragDepth <= 0) {
266
+ dragDepth = 0;
267
+ dropOverlay.toggleAttribute('data-on', false);
268
+ }
269
+ });
270
+ panel.addEventListener('drop', (e) => {
271
+ e.preventDefault();
272
+ dragDepth = 0;
273
+ dropOverlay.toggleAttribute('data-on', false);
274
+ const file = e.dataTransfer?.files?.[0];
275
+ if (file) void uploadFile(file);
276
+ });
277
+
278
+ /** Upload is shared by the button and the drop target, and always lands in
279
+ * the project source — an uploaded file is a project asset by definition. */
280
+ async function uploadFile(file: File): Promise<void> {
281
+ if (!file.type.startsWith('image/')) {
282
+ toast('That is not an image file', 'err');
283
+ return;
284
+ }
285
+ setButtonEnabled(uploadBtn, false);
286
+ uploadBtn.textContent = 'Uploading…';
287
+ try {
288
+ const dataUrl = await new Promise<string>((res, rej) => {
289
+ const fr = new FileReader();
290
+ fr.onload = () => res(String(fr.result));
291
+ fr.onerror = () => rej(fr.error);
292
+ fr.readAsDataURL(file);
293
+ });
294
+ const { webPath } = await api.upload({
295
+ dataUrl,
296
+ filename: file.name,
297
+ ...(opts.assetRef ? { assetRef: opts.assetRef } : {}),
298
+ ...(opts.targetDir ? { targetDir: opts.targetDir } : {}),
299
+ });
300
+ toast(`Uploaded ${basename(webPath)}`, 'ok');
301
+ // Show it immediately, staged and first under Newest — but still only
302
+ // *staged*: the user still has to commit.
303
+ tabs.show('project');
304
+ await projectPane.reload();
305
+ grid.select(webPath);
306
+ } catch (err) {
307
+ toast(`Upload failed — ${err instanceof Error ? err.message : 'unknown error'}`, 'err');
308
+ } finally {
309
+ setButtonEnabled(uploadBtn, true);
310
+ uploadBtn.textContent = '';
311
+ uploadBtn.append(icon('upload', 16), document.createTextNode('Upload file…'));
312
+ }
313
+ }
314
+
315
+ // --- project pane --------------------------------------------------------
316
+ let projectCount: number | null = null;
317
+
318
+ /** Defined here (rather than in its own module) because the project source
319
+ * *is* the modal's default: its state is the shell's own. */
320
+ function createProjectPane(
321
+ deps: MediaPaneDeps,
322
+ modal: MediaModalOptions,
323
+ ): MediaPane & { reload(): Promise<void> } {
324
+ const relative = modal.assetRef === 'relative';
325
+ let assets: AssetInfo[] = [];
326
+ /** Named in the refusal a non-servable tile carries, so the message says
327
+ * the project's own directory rather than a hardcoded `public/`. */
328
+ let publicDir = 'public';
329
+ let sort: SortKey = 'newest';
330
+ let showAll = false;
331
+ let failure: string | null = null;
332
+
333
+ const el = styled('div', 'atx-media-toolbar atx-media-pane-project');
334
+ const filterInput = inputEl('input', 'atx-asset-filter');
335
+ filterInput.type = 'search';
336
+ filterInput.placeholder = 'Filter…';
337
+ filterInput.addEventListener('input', () => paint());
338
+
339
+ const scopeToggle = styled('button', 'atx-btn atx-btn-outline atx-asset-scope');
340
+ scopeToggle.type = 'button';
341
+ scopeToggle.addEventListener('click', () => {
342
+ showAll = !showAll;
343
+ paint();
344
+ });
345
+
346
+ const sortSelect = inputEl('select', 'atx-media-sort');
347
+ for (const [value, label] of [['newest', 'Newest'], ['name', 'Name']] as const) {
348
+ const option = document.createElement('option');
349
+ option.value = value;
350
+ option.textContent = label;
351
+ sortSelect.append(option);
352
+ }
353
+ sortSelect.value = sort;
354
+ sortSelect.addEventListener('change', () => {
355
+ sort = sortSelect.value as SortKey;
356
+ paint();
357
+ });
358
+
359
+ el.append(filterInput, scopeToggle, sortSelect);
360
+
361
+ /**
362
+ * Why this asset cannot be used in this mode, or null when it can.
363
+ *
364
+ * The two modes ask different questions and neither is the other's
365
+ * complement. An `image()` field needs a file Astro can *import*, which
366
+ * means under `src/`. Everything else — a plain `<img src>`, a markdown
367
+ * destination — needs a URL the **built** site actually has, which only
368
+ * the public dir gives; `/src/assets/hero.svg` is a truthful dev URL and
369
+ * a 404 in production, which is the whole of issue #9. Servability is the
370
+ * server's answer, carried per file, not something inferred here from a
371
+ * path prefix. */
372
+ const refusal = (a: AssetInfo): { short: string; full: string } | null => {
373
+ if (relative) {
374
+ if (a.path.startsWith('/src/')) return null;
375
+ return {
376
+ short: 'Not importable',
377
+ full: `${a.path} — an image() field imports its asset, so the file has to live under src/.`,
378
+ };
379
+ }
380
+ if (a.servable) return null;
381
+ return {
382
+ short: 'Dev only',
383
+ full: `${a.path} — served in dev only. A build copies just ${publicDir}/, so this path would 404 in the built site.`,
384
+ };
385
+ };
386
+
387
+ /** Everything listed that suits this mode. */
388
+ const suitable = (): AssetInfo[] => assets.filter((a) => refusal(a) === null);
389
+
390
+ const visible = (): AssetInfo[] => {
391
+ let list = assets;
392
+ const scope = modal.scopeDir;
393
+ if (scope && !showAll) {
394
+ const scoped = list.filter((a) => a.path.startsWith('/' + scope + '/'));
395
+ // An empty scope would read as "no assets" — widen rather than mislead.
396
+ if (scoped.length) list = scoped;
397
+ }
398
+ const needle = filterInput.value.trim().toLowerCase();
399
+ if (needle) list = list.filter((a) => a.path.toLowerCase().includes(needle));
400
+ // Usable first, then the chosen sort. The unusable ones stay on screen
401
+ // to be explained, but a "Newest" listing that opens on six tiles you
402
+ // cannot click is worse than not showing them at all.
403
+ return [...list].sort((a, b) => {
404
+ const usable = Number(refusal(b) === null) - Number(refusal(a) === null);
405
+ if (usable) return usable;
406
+ return sort === 'newest' ? b.mtime - a.mtime : a.path.localeCompare(b.path);
407
+ });
408
+ };
409
+
410
+ const paint = (): void => {
411
+ if (failure) {
412
+ deps.grid.showMessage(failure, () => void load());
413
+ deps.refresh();
414
+ return;
415
+ }
416
+ const scope = modal.scopeDir;
417
+ if (scope) {
418
+ scopeToggle.toggleAttribute('data-on', true);
419
+ scopeToggle.textContent = showAll ? 'This folder' : 'Show all';
420
+ scopeToggle.title = showAll ? `Show only ${scope}` : `Showing ${scope} — click to list every asset`;
421
+ }
422
+ const list = visible();
423
+ projectCount = suitable().length;
424
+ deps.grid.setTiles(
425
+ list.map(
426
+ (asset): GridTile => ({
427
+ key: asset.path,
428
+ thumbUrl: asset.path,
429
+ // A file written since the modal opened is one this session just
430
+ // created, so it may still be inside Vite's brief 404 window.
431
+ ...(asset.mtime > openedAt ? { fresh: true } : {}),
432
+ label: asset.path,
433
+ current: asset.path === modal.currentWebPath,
434
+ caption: { kind: 'name', text: basename(asset.path), title: asset.path },
435
+ ...(refusal(asset)
436
+ ? {
437
+ disabledReason: refusal(asset)!.short,
438
+ disabledTitle: refusal(asset)!.full,
439
+ }
440
+ : {}),
441
+ }),
442
+ ),
443
+ );
444
+ deps.refresh();
445
+ };
446
+
447
+ const load = async (): Promise<void> => {
448
+ failure = null;
449
+ deps.grid.showSkeletons();
450
+ try {
451
+ const listing = await api.getAssets();
452
+ assets = listing.files;
453
+ publicDir = listing.publicDir;
454
+ } catch (err) {
455
+ failure = `Could not load the image list: ${err instanceof Error ? err.message : 'unknown error'}`;
456
+ }
457
+ paint();
458
+ };
459
+
460
+ return {
461
+ el,
462
+ commitLabel: 'Use image',
463
+ activate: () => void load(),
464
+ reload: load,
465
+ status() {
466
+ if (failure) return '';
467
+ const list = visible();
468
+ const usable = list.filter((a) => refusal(a) === null).length;
469
+ const total = suitable().length;
470
+ // The count is of what can be picked; anything listed but refused is
471
+ // named separately rather than folded into a number that would then
472
+ // overstate the choice.
473
+ const blocked = list.length - usable;
474
+ const tail = blocked ? ` · ${blocked} not usable here` : '';
475
+ if (!total) {
476
+ return relative
477
+ ? `No importable images under src/${tail}`
478
+ : `No images in the asset directories${tail}`;
479
+ }
480
+ const head =
481
+ usable === total ? `${total} image${total === 1 ? '' : 's'}` : `${usable} of ${total}`;
482
+ return head + tail;
483
+ },
484
+ renderRail(into, key) {
485
+ const asset = key === null ? null : assets.find((a) => a.path === key) ?? null;
486
+ if (!asset) {
487
+ into.append(railEmpty('Select an image to see its details.'));
488
+ return;
489
+ }
490
+ into.append(
491
+ railPreview(asset.path, undefined, asset.mtime > openedAt),
492
+ railTitle(basename(asset.path)),
493
+ railLine('Path', asset.path),
494
+ railLine('Size', formatBytes(asset.size)),
495
+ railLine('Modified', formatWhen(asset.mtime)),
496
+ );
497
+ },
498
+ async commit(key) {
499
+ return { webPath: key, origin: 'existing' };
500
+ },
501
+ dispose() {},
502
+ };
503
+ }
504
+
505
+ // Kick off the default pane and paint the chrome.
506
+ projectPane.activate();
507
+ refresh();
508
+ mount(backdrop, panel);
509
+ // Stacks on the trap of whatever this opened over — the CMS drawer keeps
510
+ // its own the moment this one is released.
511
+ const releaseFocus = trapFocus(panel);
512
+
513
+ /** The panel's own token is re-claimed after any `busy` interaction the
514
+ * panes take, so the modal keeps owning the page's clicks. */
515
+ void token;
516
+ });
517
+ }
518
+
519
+ // --- rail primitives, shared with the Unsplash pane --------------------------
520
+
521
+ export function railEmpty(text: string): HTMLElement {
522
+ const el = styled('p', 'atx-media-rail-empty');
523
+ el.textContent = text;
524
+ return el;
525
+ }
526
+
527
+ export function railPreview(src: string, color?: string, fresh = false): HTMLElement {
528
+ const box = styled('div', 'atx-media-rail-preview');
529
+ // The photo's own average colour, where the source knows it; otherwise the
530
+ // checkerboard the class paints.
531
+ if (color) box.style.background = color;
532
+ const img = styled('img', 'atx-media-rail-img');
533
+ img.alt = '';
534
+ img.decoding = 'async';
535
+ img.addEventListener('error', () => img.toggleAttribute('data-hidden', true));
536
+ // A retry that finally succeeds must undo that — see ui.ts::setFreshSrc.
537
+ img.addEventListener('load', () => img.toggleAttribute('data-hidden', false));
538
+ if (fresh) setFreshSrc(img, src);
539
+ else img.src = src;
540
+ box.append(img);
541
+ return box;
542
+ }
543
+
544
+ export function railTitle(text: string): HTMLElement {
545
+ const el = styled('h4', 'atx-media-rail-title');
546
+ el.textContent = text;
547
+ el.title = text;
548
+ return el;
549
+ }
550
+
551
+ export function railLine(label: string, value: string): HTMLElement {
552
+ const row = styled('div', 'atx-media-rail-line');
553
+ const key = styled('span', 'atx-media-rail-key');
554
+ key.textContent = label;
555
+ const val = styled('span', 'atx-media-rail-value');
556
+ val.textContent = value;
557
+ row.append(key, val);
558
+ return row;
559
+ }
560
+
561
+ export function railLink(label: string, text: string, href: string): HTMLElement {
562
+ const row = styled('div', 'atx-media-rail-line');
563
+ const key = styled('span', 'atx-media-rail-key');
564
+ key.textContent = label;
565
+ const a = styled('a', 'atx-media-rail-value atx-media-rail-link atx-unsplash-credit');
566
+ a.href = href;
567
+ a.target = '_blank';
568
+ a.rel = 'noreferrer';
569
+ a.textContent = text;
570
+ row.append(key, a);
571
+ return row;
572
+ }
573
+
574
+ function formatBytes(bytes: number): string {
575
+ if (bytes < 1024) return `${bytes} B`;
576
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
577
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
578
+ }
579
+
580
+ /** Relative for anything recent, absolute once it stops being useful. */
581
+ function formatWhen(mtime: number): string {
582
+ const seconds = Math.max(0, (Date.now() - mtime) / 1000);
583
+ if (seconds < 60) return 'just now';
584
+ if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`;
585
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)} h ago`;
586
+ if (seconds < 7 * 86400) return `${Math.floor(seconds / 86400)} d ago`;
587
+ return new Date(mtime).toLocaleDateString();
588
+ }