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,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
|
+
}
|