pgkiln 0.47.0 → 0.48.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.
@@ -0,0 +1,56 @@
1
+ /* pgkiln builder: the SQL Workshop's Data Modeler (sprint 98, src/builder/data-modeler.ts, public/data-modeler.js).
2
+ The diagram is an SVG with presentation attributes (light colours: an exported file); here the builder's theme
3
+ overrides them. The canvas scrolls inside the page, so a wide diagram never widens the page. */
4
+ .dm-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; margin-bottom: 8px; }
5
+ .dm-name { margin: 0; font-size: 1.25rem; overflow-wrap: anywhere; }
6
+ .dm-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 8px; }
7
+ .dm-toolbar form { display: contents; }
8
+ .dm-toolbar .btn[disabled] { opacity: 0.5; }
9
+ .dm-status { min-width: 0; }
10
+ .dm-stage { position: relative; }
11
+ .dm-canvas { overflow: auto; max-width: 100%; height: 65vh; min-height: 18rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); }
12
+ .dm-canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
13
+ .dm-svg { display: block; }
14
+ .dm-svg .dm-bg { fill: var(--bg); }
15
+ .dm-svg .dm-frame { fill: var(--surface); stroke: var(--border); }
16
+ .dm-svg .dm-view .dm-frame { fill: var(--surface-2); }
17
+ .dm-svg .dm-head { fill: var(--accent-soft); stroke: var(--border); }
18
+ .dm-svg .dm-title { fill: var(--text); }
19
+ .dm-svg .dm-col { fill: var(--text); }
20
+ .dm-svg .dm-type, .dm-svg .dm-kind, .dm-svg .dm-more { fill: var(--muted); }
21
+ .dm-svg .dm-key { fill: var(--gold, #b45309); }
22
+ .dm-svg .dm-line, .dm-svg .dm-end { stroke: var(--muted); }
23
+ .dm-svg .dm-opt { fill: var(--bg); stroke: var(--muted); }
24
+ .dm-svg .dm-fk:hover .dm-line, .dm-svg .dm-fk:hover .dm-end { stroke: var(--accent); stroke-width: 2.5; }
25
+ .dm-svg .dm-link:hover .dm-title, .dm-svg .dm-link:focus .dm-title { fill: var(--accent); text-decoration: underline; }
26
+ .dm-live .dm-box { cursor: grab; touch-action: none; }
27
+ .dm-live .dm-box.dm-dragging { cursor: grabbing; }
28
+ .dm-svg .dm-box:focus { outline: none; }
29
+ .dm-svg .dm-box:focus-visible .dm-frame { stroke: var(--accent); stroke-width: 2.5; }
30
+ .dm-svg .dm-dragging .dm-frame { stroke: var(--accent); }
31
+ .dm-svg text { user-select: none; }
32
+ .dm-minimap { position: absolute; right: 12px; bottom: 12px; width: 200px; height: 140px; border: 1px solid var(--border); border-radius: var(--radius);
33
+ background: var(--surface); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); cursor: pointer; touch-action: none; }
34
+ .dm-minimap-bg { fill: var(--surface); }
35
+ .dm-minimap-view { fill: var(--accent); fill-opacity: 0.15; stroke: var(--accent); stroke-width: 4; vector-effect: non-scaling-stroke; }
36
+ .dm-legend { margin: 6px 0 12px; }
37
+ .dm-search-row { display: flex; gap: 6px; flex-wrap: wrap; }
38
+ .dm-search-row input { flex: 1 1 12rem; min-width: 0; }
39
+ .dm-settings { margin-bottom: 16px; }
40
+ .dm-settings > summary { cursor: pointer; margin-bottom: 8px; }
41
+ .dm-settings form { margin-bottom: 8px; }
42
+ .dm-dict-actions { margin-bottom: 12px; }
43
+ .dm-dict-toc { columns: 3 14rem; }
44
+ .dm-dict-table { margin-top: 1.5rem; }
45
+ .dm-dict-table h2 { border-bottom: 1px solid var(--border); padding-bottom: 4px; overflow-wrap: anywhere; }
46
+ @media (max-width: 640px) {
47
+ .dm-btn-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
48
+ .dm-canvas { height: 55vh; }
49
+ .dm-minimap { width: 130px; height: 90px; }
50
+ }
51
+ @media print {
52
+ .sw-tabs, .dm-toolbar, .dm-legend, #dm-objects, #dm-add, .dm-settings, .dm-minimap, .dm-dict-actions, .ide-alerts { display: none !important; }
53
+ .dm-canvas { height: auto; overflow: visible; border: 0; }
54
+ .dm-svg { max-width: 100%; height: auto; }
55
+ .dm-dict-table { break-inside: avoid-page; }
56
+ }
@@ -0,0 +1,367 @@
1
+ // pgkiln builder: the SQL Workshop's Data Modeler (sprint 98, src/builder/data-modeler.ts, src/datamodel.ts).
2
+ //
3
+ // Progressive enhancement of the server-rendered SVG diagram (the boxes at their saved positions):
4
+ // - drag a box (mouse or touch), or focus it and use the arrow keys (Shift: bigger steps); the foreign key
5
+ // lines follow (fkPath: the same geometry as src/datamodel.ts)
6
+ // - Save (Ctrl+S) posts the positions; Undo (Ctrl+Z) and Redo (Ctrl+Y, Ctrl+Shift+Z); unsaved changes are
7
+ // shown and leaving asks first
8
+ // - Zoom in / out, Fit, Auto layout (the server's automatic positions, data-ax/ay; undoable), a mini-map
9
+ // - Export SVG and PNG of the diagram as it is on the screen, Print
10
+ // Strict CSP: no inline styles; geometry goes into SVG attributes, sizes through the CSSOM.
11
+ (() => {
12
+ 'use strict';
13
+ // the data dictionary's Print button
14
+ for (const b of document.querySelectorAll('.dm-dict-actions [data-dm=print]')) {
15
+ b.hidden = false;
16
+ b.addEventListener('click', () => window.print());
17
+ }
18
+ const stage = document.querySelector('[data-dm-stage]');
19
+ const svg = document.getElementById('dm-svg');
20
+ if (!stage || !svg) return;
21
+ const canvas = stage.querySelector('.dm-canvas');
22
+ const toolbar = document.querySelector('.dm-toolbar');
23
+ const saveForm = toolbar.querySelector('.dm-save-form');
24
+ const status = toolbar.querySelector('.dm-status');
25
+ const btn = (name) => toolbar.querySelector(`[data-dm="${name}"]`);
26
+ for (const b of toolbar.querySelectorAll('[data-dm][hidden]')) b.hidden = false;
27
+ const NS = 'http://www.w3.org/2000/svg';
28
+
29
+ const boxes = new Map([...svg.querySelectorAll('.dm-box')].map((g) => [g.dataset.name, {
30
+ g, x: Number(g.dataset.x), y: Number(g.dataset.y), w: Number(g.dataset.w), h: Number(g.dataset.h),
31
+ ax: g.dataset.ax === undefined ? null : Number(g.dataset.ax), ay: g.dataset.ay === undefined ? null : Number(g.dataset.ay),
32
+ }]));
33
+ const fks = [...svg.querySelectorAll('.dm-fk')];
34
+ let W = Number(svg.getAttribute('width'));
35
+ let H = Number(svg.getAttribute('height'));
36
+ let zoom = 1;
37
+
38
+ // ------------------------------------------------------------ geometry (as fkPath in src/datamodel.ts)
39
+ function fkPath(a, ay, b, by) {
40
+ const y1 = a.y + ay;
41
+ const y2 = b.y + by;
42
+ let x1, x2, d1, d2, path;
43
+ if (a.x + a.w + 40 <= b.x) {
44
+ [x1, x2, d1, d2] = [a.x + a.w, b.x, 1, -1];
45
+ const mx = Math.round((x1 + x2) / 2);
46
+ path = `M ${x1} ${y1} H ${mx} V ${y2} H ${x2}`;
47
+ } else if (b.x + b.w + 40 <= a.x) {
48
+ [x1, x2, d1, d2] = [a.x, b.x + b.w, -1, 1];
49
+ const mx = Math.round((x1 + x2) / 2);
50
+ path = `M ${x1} ${y1} H ${mx} V ${y2} H ${x2}`;
51
+ } else {
52
+ [x1, x2, d1, d2] = [a.x + a.w, b.x + b.w, 1, 1];
53
+ const lx = Math.max(x1, x2) + (a === b ? 18 : 30);
54
+ const yy = a === b && y1 === y2 ? y2 + 10 : y2;
55
+ path = `M ${x1} ${y1} H ${lx} V ${yy} H ${x2}`;
56
+ }
57
+ const foot = `M ${x1 + d1 * 14} ${y1} L ${x1} ${y1 - 7} M ${x1 + d1 * 14} ${y1} L ${x1} ${y1 + 7} M ${x1 + d1 * 14} ${y1 - 7} V ${y1 + 7}`;
58
+ const bar = `M ${x2 + d2 * 10} ${y2 - 7} V ${y2 + 7}`;
59
+ return { path, ends: `${foot} ${bar}`, circle: { cx: x2 + d2 * 20, cy: y2 } };
60
+ }
61
+
62
+ function draw() {
63
+ for (const b of boxes.values()) b.g.setAttribute('transform', `translate(${b.x} ${b.y})`);
64
+ for (const l of fks) {
65
+ const a = boxes.get(l.dataset.from);
66
+ const b = boxes.get(l.dataset.to);
67
+ if (!a || !b) continue;
68
+ const p = fkPath(a, Number(l.dataset.fy), b, Number(l.dataset.ty));
69
+ l.querySelector('.dm-line').setAttribute('d', p.path);
70
+ l.querySelector('.dm-end').setAttribute('d', p.ends);
71
+ const c = l.querySelector('.dm-opt');
72
+ if (c) {
73
+ c.setAttribute('cx', String(p.circle.cx));
74
+ c.setAttribute('cy', String(p.circle.cy));
75
+ }
76
+ }
77
+ const all = [...boxes.values()];
78
+ W = Math.max(400, ...all.map((b) => b.x + b.w + 60));
79
+ H = Math.max(240, ...all.map((b) => b.y + b.h + 40));
80
+ svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
81
+ sizeSvg();
82
+ }
83
+ function sizeSvg() {
84
+ svg.setAttribute('width', String(Math.round(W * zoom)));
85
+ svg.setAttribute('height', String(Math.round(H * zoom)));
86
+ updateMinimap();
87
+ }
88
+
89
+ // ------------------------------------------------------------ undo, redo, unsaved changes
90
+ const snapshot = () => [...boxes].map(([name, b]) => [name, b.x, b.y]);
91
+ const restore = (snap) => {
92
+ for (const [name, x, y] of snap) {
93
+ const b = boxes.get(name);
94
+ if (b) [b.x, b.y] = [x, y];
95
+ }
96
+ draw();
97
+ };
98
+ const undo = [];
99
+ const redo = [];
100
+ let saved = JSON.stringify(snapshot());
101
+ const dirty = () => JSON.stringify(snapshot()) !== saved;
102
+ function changed() {
103
+ btn('undo').disabled = !undo.length;
104
+ btn('redo').disabled = !redo.length;
105
+ const d = dirty();
106
+ btn('save').classList.toggle('btn-hot', d);
107
+ status.textContent = d ? 'Unsaved changes' : '';
108
+ }
109
+ function remember(before) {
110
+ undo.push(before);
111
+ if (undo.length > 200) undo.shift();
112
+ redo.length = 0;
113
+ changed();
114
+ }
115
+ btn('undo').addEventListener('click', () => {
116
+ if (!undo.length) return;
117
+ redo.push(snapshot());
118
+ restore(undo.pop());
119
+ changed();
120
+ });
121
+ btn('redo').addEventListener('click', () => {
122
+ if (!redo.length) return;
123
+ undo.push(snapshot());
124
+ restore(redo.pop());
125
+ changed();
126
+ });
127
+ window.addEventListener('beforeunload', (e) => {
128
+ if (!dirty()) return;
129
+ e.preventDefault();
130
+ e.returnValue = '';
131
+ });
132
+
133
+ async function save() {
134
+ const body = new URLSearchParams({
135
+ __csrf: saveForm.querySelector('input[name=__csrf]').value,
136
+ format: 'json',
137
+ positions: JSON.stringify([...boxes].map(([name, b]) => ({ name, x: Math.round(b.x), y: Math.round(b.y) }))),
138
+ });
139
+ status.textContent = 'Saving…';
140
+ try {
141
+ const res = await fetch(saveForm.action, { method: 'POST', body, credentials: 'same-origin', headers: { accept: 'application/json' } });
142
+ if (!res.ok) throw new Error(String(res.status));
143
+ saved = JSON.stringify(snapshot());
144
+ changed();
145
+ status.textContent = 'Saved';
146
+ } catch (e) {
147
+ status.textContent = `Not saved (${e.message}): try again`;
148
+ }
149
+ }
150
+ btn('save').addEventListener('click', save);
151
+ saveForm.addEventListener('submit', (e) => {
152
+ e.preventDefault();
153
+ save();
154
+ });
155
+
156
+ // ------------------------------------------------------------ moving boxes
157
+ const moveBox = (b, x, y) => {
158
+ b.x = Math.max(0, Math.min(20000, Math.round(x)));
159
+ b.y = Math.max(0, Math.min(20000, Math.round(y)));
160
+ draw();
161
+ };
162
+ let dragged = false;
163
+ for (const b of boxes.values()) {
164
+ b.g.addEventListener('pointerdown', (e) => {
165
+ if (e.button !== 0) return;
166
+ const start = { x: e.clientX, y: e.clientY, bx: b.x, by: b.y };
167
+ const before = snapshot();
168
+ let moved = false;
169
+ const move = (ev) => {
170
+ const dx = (ev.clientX - start.x) / zoom;
171
+ const dy = (ev.clientY - start.y) / zoom;
172
+ if (!moved && Math.abs(dx) + Math.abs(dy) < 4) return;
173
+ if (!moved) {
174
+ moved = true;
175
+ // captured only once it moves: a plain click still reaches the box's name (its link)
176
+ b.g.setPointerCapture(ev.pointerId);
177
+ b.g.classList.add('dm-dragging');
178
+ // on top of the others
179
+ b.g.parentNode.append(b.g);
180
+ }
181
+ moveBox(b, start.bx + dx, start.by + dy);
182
+ };
183
+ const up = () => {
184
+ b.g.removeEventListener('pointermove', move);
185
+ b.g.removeEventListener('pointerup', up);
186
+ b.g.removeEventListener('pointercancel', up);
187
+ b.g.classList.remove('dm-dragging');
188
+ if (moved) {
189
+ dragged = true;
190
+ setTimeout(() => (dragged = false), 0);
191
+ remember(before);
192
+ }
193
+ };
194
+ b.g.addEventListener('pointermove', move);
195
+ b.g.addEventListener('pointerup', up);
196
+ b.g.addEventListener('pointercancel', up);
197
+ });
198
+ b.g.addEventListener('dragstart', (e) => e.preventDefault());
199
+ // a drag that ends over the box's name doesn't open the table
200
+ b.g.addEventListener('click', (e) => {
201
+ if (dragged) e.preventDefault();
202
+ }, true);
203
+ b.g.addEventListener('keydown', (e) => {
204
+ if (e.target !== b.g) return;
205
+ const step = e.shiftKey ? 50 : 10;
206
+ const d = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[e.key];
207
+ if (!d) return;
208
+ e.preventDefault();
209
+ const before = snapshot();
210
+ moveBox(b, b.x + d[0], b.y + d[1]);
211
+ remember(before);
212
+ });
213
+ }
214
+
215
+ // ------------------------------------------------------------ auto layout
216
+ const auto = btn('auto');
217
+ auto.closest('form').addEventListener('submit', (e) => {
218
+ e.preventDefault();
219
+ const before = snapshot();
220
+ for (const b of boxes.values()) if (b.ax !== null) [b.x, b.y] = [b.ax, b.ay];
221
+ draw();
222
+ remember(before);
223
+ });
224
+
225
+ // ------------------------------------------------------------ zoom
226
+ const setZoom = (z) => {
227
+ zoom = Math.max(0.2, Math.min(3, Math.round(z * 100) / 100));
228
+ sizeSvg();
229
+ status.textContent = `${Math.round(zoom * 100)}%${dirty() ? ' · unsaved changes' : ''}`;
230
+ };
231
+ btn('zoom-in').addEventListener('click', () => setZoom(zoom * 1.25));
232
+ btn('zoom-out').addEventListener('click', () => setZoom(zoom / 1.25));
233
+ btn('fit').addEventListener('click', () => setZoom(Math.min(2, (canvas.clientWidth - 4) / W, (canvas.clientHeight - 4) / H)));
234
+ canvas.addEventListener('wheel', (e) => {
235
+ if (!e.ctrlKey) return;
236
+ e.preventDefault();
237
+ setZoom(zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1));
238
+ }, { passive: false });
239
+
240
+ // ------------------------------------------------------------ mini-map
241
+ const content = svg.querySelector('.dm-content');
242
+ content.id = 'dm-content';
243
+ let mini = null;
244
+ let view = null;
245
+ function updateMinimap() {
246
+ if (!mini) return;
247
+ mini.setAttribute('viewBox', `0 0 ${W} ${H}`);
248
+ view.setAttribute('x', String(canvas.scrollLeft / zoom));
249
+ view.setAttribute('y', String(canvas.scrollTop / zoom));
250
+ view.setAttribute('width', String(Math.min(W, canvas.clientWidth / zoom)));
251
+ view.setAttribute('height', String(Math.min(H, canvas.clientHeight / zoom)));
252
+ }
253
+ const mm = btn('minimap');
254
+ mm.setAttribute('aria-pressed', 'false');
255
+ mm.addEventListener('click', () => {
256
+ if (mini) {
257
+ mini.remove();
258
+ mini = null;
259
+ mm.setAttribute('aria-pressed', 'false');
260
+ return;
261
+ }
262
+ mini = document.createElementNS(NS, 'svg');
263
+ mini.setAttribute('class', 'dm-minimap');
264
+ mini.setAttribute('aria-hidden', 'true');
265
+ mini.setAttribute('preserveAspectRatio', 'xMinYMin meet');
266
+ const bg = document.createElementNS(NS, 'rect');
267
+ bg.setAttribute('class', 'dm-minimap-bg');
268
+ bg.setAttribute('width', '100%');
269
+ bg.setAttribute('height', '100%');
270
+ const use = document.createElementNS(NS, 'use');
271
+ use.setAttribute('href', '#dm-content');
272
+ view = document.createElementNS(NS, 'rect');
273
+ view.setAttribute('class', 'dm-minimap-view');
274
+ mini.append(bg, use, view);
275
+ stage.append(mini);
276
+ mm.setAttribute('aria-pressed', 'true');
277
+ // click or drag in the mini-map: that part of the diagram in the middle
278
+ const go = (e) => {
279
+ const r = mini.getBoundingClientRect();
280
+ const scale = Math.min(r.width / W, r.height / H);
281
+ const x = (e.clientX - r.left) / scale;
282
+ const y = (e.clientY - r.top) / scale;
283
+ canvas.scrollLeft = x * zoom - canvas.clientWidth / 2;
284
+ canvas.scrollTop = y * zoom - canvas.clientHeight / 2;
285
+ };
286
+ mini.addEventListener('pointerdown', (e) => {
287
+ mini.setPointerCapture(e.pointerId);
288
+ go(e);
289
+ const move = (ev) => go(ev);
290
+ mini.addEventListener('pointermove', move);
291
+ mini.addEventListener('pointerup', () => mini.removeEventListener('pointermove', move), { once: true });
292
+ });
293
+ updateMinimap();
294
+ });
295
+ canvas.addEventListener('scroll', updateMinimap);
296
+ window.addEventListener('resize', updateMinimap);
297
+
298
+ // ------------------------------------------------------------ export and print
299
+ const fileName = (stage.dataset.name || 'diagram').replace(/[^A-Za-z0-9_.-]+/g, '_').slice(0, 80);
300
+ /** The diagram as a standalone SVG document (its presentation attributes carry the colours). */
301
+ function svgText() {
302
+ const copy = svg.cloneNode(true);
303
+ copy.removeAttribute('id');
304
+ copy.setAttribute('width', String(W));
305
+ copy.setAttribute('height', String(H));
306
+ copy.querySelector('.dm-content').removeAttribute('id');
307
+ for (const a of [...copy.querySelectorAll('a')]) a.replaceWith(...a.childNodes);
308
+ for (const g of copy.querySelectorAll('[tabindex]')) g.removeAttribute('tabindex');
309
+ return `<?xml version="1.0" encoding="UTF-8"?>\n${new XMLSerializer().serializeToString(copy)}\n`;
310
+ }
311
+ const download = (blob, name) => {
312
+ const a = document.createElement('a');
313
+ a.href = URL.createObjectURL(blob);
314
+ a.download = name;
315
+ document.body.append(a);
316
+ a.click();
317
+ a.remove();
318
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
319
+ };
320
+ btn('svg').addEventListener('click', (e) => {
321
+ e.preventDefault();
322
+ download(new Blob([svgText()], { type: 'image/svg+xml' }), `${fileName}.svg`);
323
+ });
324
+ btn('png').addEventListener('click', () => {
325
+ const img = new Image();
326
+ img.onload = () => {
327
+ const scale = 2;
328
+ const c = document.createElement('canvas');
329
+ c.width = Math.round(W * scale);
330
+ c.height = Math.round(H * scale);
331
+ const ctx = c.getContext('2d');
332
+ ctx.scale(scale, scale);
333
+ ctx.drawImage(img, 0, 0, W, H);
334
+ c.toBlob((blob) => blob && download(blob, `${fileName}.png`), 'image/png');
335
+ };
336
+ img.onerror = () => (status.textContent = 'The PNG could not be made: export SVG instead');
337
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgText())}`;
338
+ });
339
+ btn('print').addEventListener('click', () => {
340
+ const z = zoom;
341
+ setZoom(1);
342
+ window.print();
343
+ setZoom(z);
344
+ });
345
+
346
+ document.addEventListener('keydown', (e) => {
347
+ const key = e.key.toLowerCase();
348
+ const mod = e.ctrlKey || e.metaKey;
349
+ if (!mod) return;
350
+ if (key === 's') {
351
+ e.preventDefault();
352
+ save();
353
+ } else if (/^(input|textarea|select)$/i.test(document.activeElement?.tagName || '')) {
354
+ return;
355
+ } else if (key === 'z' && !e.shiftKey) {
356
+ e.preventDefault();
357
+ btn('undo').click();
358
+ } else if (key === 'y' || (key === 'z' && e.shiftKey)) {
359
+ e.preventDefault();
360
+ btn('redo').click();
361
+ }
362
+ });
363
+
364
+ canvas.classList.add('dm-live');
365
+ changed();
366
+ status.textContent = '';
367
+ })();
@@ -0,0 +1,133 @@
1
+ // pgkiln builder: the search (Ctrl+K) and the SQL Workshop Launchpad (sprint 96, src/builder/launchpad.ts).
2
+ //
3
+ // - Ctrl+K (⌘K) on any builder page, or focusing the header's search field, opens a search dialog: tools,
4
+ // applications, pages and database objects by name (GET /builder/palette?format=json), arrow keys and Enter
5
+ // to open one. Without JavaScript the header's field posts to the results page.
6
+ // - SQL Workshop pages are remembered as "Recently visited" in this browser and listed on the home page.
7
+ // - The home page's tool groups (Development, Administration, Monitoring) become tabs.
8
+ // Results are built as text nodes, never parsed as HTML.
9
+ (() => {
10
+ 'use strict';
11
+ const store = {
12
+ get(k) { try { return JSON.parse(localStorage.getItem(`pgkiln.${k}`) || 'null'); } catch { return null; } },
13
+ set(k, v) { try { localStorage.setItem(`pgkiln.${k}`, JSON.stringify(v)); } catch { /* private mode */ } },
14
+ };
15
+
16
+ // ------------------------------------------------------------ recently visited SQL Workshop pages
17
+ const path = location.pathname;
18
+ if (/^\/builder\/sql\/./.test(path) && !/\/(panel|navigator|action)$/.test(path)) {
19
+ const title = document.title.replace(/ · Builder$/, '');
20
+ const href = path + location.search;
21
+ const list = (store.get('sw.recent') || []).filter((r) => r && r.href !== href);
22
+ list.unshift({ href, title });
23
+ store.set('sw.recent', list.slice(0, 8));
24
+ }
25
+ const recent = document.querySelector('[data-lp-recent]');
26
+ if (recent) {
27
+ const list = store.get('sw.recent') || [];
28
+ for (const r of list) {
29
+ if (typeof r?.href !== 'string' || !r.href.startsWith('/builder/')) continue;
30
+ const li = document.createElement('li');
31
+ const a = document.createElement('a');
32
+ a.href = r.href;
33
+ a.textContent = String(r.title || r.href);
34
+ li.append(a);
35
+ recent.append(li);
36
+ }
37
+ recent.hidden = !recent.children.length;
38
+ }
39
+
40
+ // ------------------------------------------------------------ the Launchpad's tabs
41
+ const tabs = document.querySelector('.lp-tabs');
42
+ if (tabs) {
43
+ tabs.hidden = false;
44
+ const show = (g) => {
45
+ for (const b of tabs.querySelectorAll('[data-lp-tab]')) b.setAttribute('aria-selected', String(b.dataset.lpTab === g));
46
+ for (const sct of document.querySelectorAll('[data-lp-group]')) sct.hidden = sct.dataset.lpGroup !== g;
47
+ store.set('lp.tab', g);
48
+ };
49
+ show(store.get('lp.tab') || 'development');
50
+ tabs.addEventListener('click', (e) => { const b = e.target.closest('[data-lp-tab]'); if (b) show(b.dataset.lpTab); });
51
+ }
52
+
53
+ // ------------------------------------------------------------ the search dialog
54
+ let dlg = null, input, listbox, status, items = [], active = -1, timer = 0, seq = 0;
55
+ const build = () => {
56
+ dlg = document.createElement('dialog');
57
+ dlg.className = 'palette';
58
+ dlg.setAttribute('aria-label', 'Search the builder');
59
+ input = Object.assign(document.createElement('input'), { type: 'search', placeholder: 'Tables, views, functions, applications, pages, tools…', autocomplete: 'off', id: 'palette-q' });
60
+ input.setAttribute('role', 'combobox');
61
+ input.setAttribute('aria-expanded', 'true');
62
+ input.setAttribute('aria-controls', 'palette-list');
63
+ input.setAttribute('aria-label', 'Search');
64
+ listbox = Object.assign(document.createElement('ul'), { id: 'palette-list', className: 'palette-list' });
65
+ listbox.setAttribute('role', 'listbox');
66
+ status = Object.assign(document.createElement('p'), { className: 'palette-status muted small' });
67
+ status.setAttribute('aria-live', 'polite');
68
+ dlg.append(input, listbox, status);
69
+ document.body.append(dlg);
70
+ input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(query, 150); });
71
+ input.addEventListener('keydown', (e) => {
72
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
73
+ e.preventDefault();
74
+ if (items.length) select((active + (e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length);
75
+ } else if (e.key === 'Enter') {
76
+ e.preventDefault();
77
+ const r = items[active] || items[0];
78
+ if (r) location.href = r.href;
79
+ else if (input.value.trim()) location.href = `/builder/palette?q=${encodeURIComponent(input.value.trim())}`;
80
+ }
81
+ });
82
+ listbox.addEventListener('click', (e) => { const li = e.target.closest('[role=option]'); if (li) location.href = items[Number(li.dataset.i)].href; });
83
+ dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); });
84
+ };
85
+ const select = (i) => {
86
+ active = i;
87
+ for (const li of listbox.children) li.setAttribute('aria-selected', String(Number(li.dataset.i) === i));
88
+ const li = listbox.children[i];
89
+ if (li) { input.setAttribute('aria-activedescendant', li.id); li.scrollIntoView({ block: 'nearest' }); }
90
+ };
91
+ const query = async () => {
92
+ const q = input.value.trim();
93
+ const mine = ++seq;
94
+ if (!q) { listbox.replaceChildren(); items = []; status.textContent = 'Type to search.'; return; }
95
+ try {
96
+ const res = await fetch(`/builder/palette?format=json&q=${encodeURIComponent(q)}`, { credentials: 'same-origin' });
97
+ if (!res.ok) throw new Error(String(res.status));
98
+ const data = await res.json();
99
+ if (mine !== seq) return; // a later query is on its way
100
+ items = data.results || [];
101
+ listbox.replaceChildren(...items.map((r, i) => {
102
+ const li = document.createElement('li');
103
+ li.setAttribute('role', 'option');
104
+ li.id = `palette-${i}`;
105
+ li.dataset.i = String(i);
106
+ const label = Object.assign(document.createElement('strong'), { textContent: r.label });
107
+ const detail = Object.assign(document.createElement('small'), { className: 'muted', textContent: ` ${r.kind}${r.detail ? ` · ${r.detail}` : ''}` });
108
+ li.append(label, detail);
109
+ return li;
110
+ }));
111
+ status.textContent = items.length ? `${items.length} found.` : 'Nothing found.';
112
+ select(items.length ? 0 : -1);
113
+ } catch {
114
+ status.textContent = 'The search failed.';
115
+ }
116
+ };
117
+ const open = (text = '') => {
118
+ if (!dlg) build();
119
+ if (!dlg.open) dlg.showModal();
120
+ input.value = text;
121
+ input.focus();
122
+ query();
123
+ };
124
+ document.addEventListener('keydown', (e) => {
125
+ if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') {
126
+ e.preventDefault();
127
+ open();
128
+ }
129
+ });
130
+ const field = document.querySelector('form[data-palette] input[name=q]');
131
+ field?.addEventListener('focus', () => { const v = field.value; field.blur(); open(v); });
132
+ window.pgkilnPalette = { open };
133
+ })();
@@ -242,3 +242,52 @@ details.menu.obj-menu > summary::before, details.menu.obj-menu > summary::after
242
242
  .te-generated { font-size: 12px; }
243
243
  .te-ddl { max-height: 60vh; overflow: auto; }
244
244
  .te-buttons { position: sticky; bottom: 0; background: var(--bg); padding: 8px 0; border-top: 1px solid var(--border); }
245
+
246
+ /* (sprint 96) the header's search, the search dialog, the SQL Workshop Launchpad, pins */
247
+ .ide-search { margin-left: auto; min-width: 0; }
248
+ .ide-search input { width: 13rem; max-width: 100%; font-size: 12.5px; padding: 3px 8px; }
249
+ @media (max-width: 700px) { .ide-search input { width: 7rem; } }
250
+ .palette { width: min(40rem, calc(100vw - 32px)); margin-top: 12vh; padding: 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); }
251
+ .palette::backdrop { background: rgb(0 0 0 / 35%); }
252
+ .palette input { width: 100%; border: 0; border-bottom: 1px solid var(--border); border-radius: 0; padding: 12px 14px; font-size: 15px; }
253
+ .palette-list { list-style: none; margin: 0; padding: 4px 0; max-height: 50vh; overflow: auto; }
254
+ .palette-list li { padding: 6px 14px; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
255
+ .palette-list li[aria-selected=true] { background: var(--sel-bg); color: var(--sel-text); }
256
+ .palette-list li[aria-selected=true] .muted { color: inherit; opacity: 0.85; }
257
+ .palette-status { margin: 0; padding: 6px 14px; border-top: 1px solid var(--border); }
258
+ .pal-results { list-style: none; padding: 0; }
259
+ .pal-results a { display: flex; gap: 8px; align-items: center; padding: 6px 0; text-decoration: none; }
260
+ .lp-pins { margin-bottom: 16px; }
261
+ .lp-pins h2, .lp-group h2 { font-size: 14px; margin: 0 0 8px; }
262
+ .lp-chips { list-style: none; margin: 0 0 8px; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
263
+ .lp-chips li { display: flex; align-items: center; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); padding-left: 10px; }
264
+ .lp-chips a { display: inline-flex; align-items: center; gap: 6px; text-decoration: none; color: var(--text); padding: 4px 0; font-size: 13px; }
265
+ .lp-recent li { padding-right: 10px; border-style: dashed; }
266
+ .lp-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 10px; }
267
+ .lp-tabs [role=tab] { border: 0; background: none; color: var(--muted); padding: 7px 12px; font: inherit; cursor: pointer; }
268
+ .lp-tabs [role=tab][aria-selected=true] { color: var(--text); box-shadow: inset 0 -2px 0 var(--hot); }
269
+ .js .lp-group > h2 { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
270
+ .lp-list { list-style: none; margin: 0 0 16px; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 8px; }
271
+ .lp-tool { display: flex; align-items: flex-start; gap: 4px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); padding: 8px; }
272
+ .lp-tool > a { flex: 1; display: flex; gap: 8px; text-decoration: none; color: var(--text); min-width: 0; }
273
+ .lp-tool > a span { display: flex; flex-direction: column; min-width: 0; }
274
+ .lp-tool small { font-size: 12px; }
275
+ .pin-form { display: inline; margin: 0; }
276
+ .pin-btn { border: 0; background: none; color: var(--muted); cursor: pointer; padding: 2px 6px; border-radius: 3px; }
277
+ .pin-btn:hover { color: var(--text); background: var(--surface-3); }
278
+ .pin-btn.is-pinned { color: var(--hot); }
279
+ .pin-btn .icon { width: 14px; height: 14px; }
280
+ .ob-pin { float: right; }
281
+
282
+ /* (sprint 97) charts: the worksheet's Chart tab, the Charts page and its dashboard */
283
+ .ch-controls { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: flex-end; margin-bottom: 8px; font-size: 13px; }
284
+ .ch-controls > label { display: flex; flex-direction: column; gap: 2px; }
285
+ .ch-controls fieldset { display: flex; flex-wrap: wrap; gap: 4px 10px; border: 1px solid var(--border); border-radius: var(--radius); padding: 4px 8px; margin: 0; }
286
+ .ch-controls legend { font-size: 12px; color: var(--muted); padding: 0 4px; }
287
+ .ch-actions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 8px; }
288
+ .ch-actions input { flex: 0 1 16rem; width: auto; min-width: 0; }
289
+ .ch-view { min-width: 0; max-width: 60rem; }
290
+ .ch-dashboard { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 420px), 1fr)); gap: 12px; margin-bottom: 16px; }
291
+ .ch-card { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); padding: 10px 12px; min-width: 0; }
292
+ .ch-card h2 { font-size: 14px; margin: 0 0 6px; }
293
+ .ch-edit { max-width: 48rem; margin-top: 16px; }
@@ -64,6 +64,7 @@
64
64
  const k = e.key.toLowerCase();
65
65
  if (k === 's' && !e.shiftKey) { save(); return true; }
66
66
  if (e.key === 'F7') { format(); return true; }
67
+ if (e.key === 'F11' && button('chart')) { run('chart'); return true; }
67
68
  if (!inEditor && !e.target.closest?.('.ws-findbar')) return false;
68
69
  if (k === 'b' && inEditor) { convertCase(); return true; }
69
70
  if (k === 'f' || k === 'h' || k === 'g') { openFind(k); return true; }
@@ -339,6 +340,9 @@
339
340
  form.querySelector('.ws-nav-switch').addEventListener('click', (e) => { const b = e.target.closest('[data-ws-navtab]'); if (b) navTab(b.dataset.wsNavtab); });
340
341
  navTab(location.hash === '#ws-files' ? 'files' : 'objects');
341
342
 
343
+ // ------------------------------------------------------------ (sprint 97) a chart's settings draw it again
344
+ form.querySelector('.ch-controls')?.addEventListener('change', () => run('chart'));
345
+
342
346
  // ------------------------------------------------------------ (sprint 93) bind and substitution values
343
347
  const binds = form.querySelector('[data-ws-binds]');
344
348
  if (binds) {
package/src/app.ts CHANGED
@@ -37,6 +37,7 @@ import { reportSettingsRoutes } from './builder/report-settings.ts';
37
37
  import { layoutRoutes } from './builder/layouts.ts';
38
38
  import { dataLoadRoutes } from './builder/dataload.ts';
39
39
  import { builderRoutes } from './builder/routes.ts';
40
+ import { dbUserRoutes } from './builder/db-users.ts';
40
41
  import { usersRoutes } from './builder/users.ts';
41
42
  import { codeEditorRoutes } from './builder/code-editor.ts';
42
43
  import { sampleDataRoutes } from './builder/sampledata.ts';
@@ -59,6 +60,7 @@ import { staticFileRoutes } from './runtime/static-files.ts';
59
60
  import { staticFileBuilderRoutes } from './builder/static-files.ts';
60
61
  import { pluginRoutes } from './builder/plugins.ts';
61
62
  import { restRoutes } from './runtime/rest.ts';
63
+ import { autoRestRoutes } from './runtime/autorest.ts';
62
64
  import { oauthRoutes } from './oauth.ts';
63
65
  import { MAX_UPLOAD_MB } from './runtime/files.ts';
64
66
  import { runtimeRoutes } from './runtime/routes.ts';
@@ -183,6 +185,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
183
185
  await app.register(pushRoutes);
184
186
  await app.register(staticFileRoutes);
185
187
  await app.register(restRoutes);
188
+ await app.register(autoRestRoutes); // (sprint 100) AutoREST: /a/:alias/autorest/
186
189
  await app.register(builderRoutes);
187
190
  await app.register(galleryRoutes);
188
191
  await app.register(appGroupRoutes);
@@ -199,6 +202,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
199
202
  await app.register(pluginRoutes);
200
203
  await app.register(sqlRoutes);
201
204
  await app.register(usersRoutes);
205
+ await app.register(dbUserRoutes);
202
206
  await app.register(dataLoadRoutes);
203
207
  await app.register(layoutRoutes);
204
208
  await app.register(automationRoutes);