pgkiln 0.88.0 → 0.90.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.
@@ -104,11 +104,15 @@
104
104
  out.dataset.active = key;
105
105
  };
106
106
  show(out.dataset.active || 'result');
107
+ // (UX-1) before a run the output is only its tab strip; choosing a tab opens it
107
108
  out.querySelector('[role=tablist]').addEventListener('click', (e) => {
108
109
  const t = e.target.closest('[role=tab]');
109
- if (t) show(t.dataset.wsTab);
110
+ if (!t) return;
111
+ delete out.dataset.empty;
112
+ show(t.dataset.wsTab);
110
113
  });
111
114
  out.querySelector('[role=tablist]').addEventListener('keydown', (e) => {
115
+ delete out.dataset.empty;
112
116
  const k = tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true');
113
117
  const next = { ArrowRight: k + 1, ArrowLeft: k - 1, Home: 0, End: tabs.length - 1 }[e.key];
114
118
  if (next === undefined) return;
@@ -552,6 +556,16 @@
552
556
  (k === 'h' ? fr : k === 'g' ? bar.querySelector('[data-ws-goto]') : fq).focus();
553
557
  };
554
558
  form.querySelector('[data-ws-find]').addEventListener('click', () => openFind('f'));
559
+ // (UX-1) Suggest and Check in the toolbar: the code editor's own buttons (its bar below the editor stays hidden
560
+ // until it has a message, such as Check's result)
561
+ const ceButton = (cls) => form.querySelector(`.ws-editor .${cls}`);
562
+ for (const [attr, cls] of [['data-ws-suggest', 'ce-suggest'], ['data-ws-check', 'ce-check']]) {
563
+ const btn = form.querySelector(`[${attr}]`);
564
+ btn?.addEventListener('click', () => ceButton(cls)?.click());
565
+ const sync = () => { if (btn) btn.hidden = !ceButton(cls) || ceButton(cls).hidden; };
566
+ sync();
567
+ window.addEventListener('load', sync);
568
+ }
555
569
  form.querySelector('[data-ws-format]').addEventListener('click', format);
556
570
  form.querySelector('[data-ws-case]').addEventListener('click', convertCase);
557
571
  bar.addEventListener('keydown', (e) => {
@@ -233,6 +233,9 @@ export async function appWizardRoutes(app: FastifyInstance) {
233
233
  return createPage(s, reply, schema, null);
234
234
  });
235
235
 
236
+ // (UX-7) the addresses a developer guesses
237
+ for (const path of ['apps/new', 'apps/create']) app.get(`${BASE}/${path}`, (_req: Req, reply) => reply.redirect(`${BASE}/create`, 302));
238
+
236
239
  // the earlier address of "From existing tables"
237
240
  app.get(`${BASE}/create/tables`, async (req: Req, reply) => {
238
241
  const s = await developer(req, reply);
@@ -325,6 +328,11 @@ const rlsBadge = (r: Relation) =>
325
328
  ? html` <span class="tag aw-rls" title="${r.policies === 1 ? tx('aw.rls_on_1') : tx('aw.rls_on', { n: r.policies })}">${icon('shield')} RLS · ${r.policies}</span>`
326
329
  : html` <span class="tag tag-error aw-rls" title="${tx('aw.row_level_security_is_on')}">${icon('shield')} ${tx('aw.rls_no_policies')}</span>`;
327
330
 
331
+ /** A select that belongs to another form (the form attribute), with its label and help. */
332
+ const selectFor = (form: string, name: string, label: string, value: string, options: [string, string][], help?: string) =>
333
+ html`<div class="field"><label class="label" for="f_${name}">${label}</label>
334
+ <select id="f_${name}" name="${name}" form="${form}">${options.map(([v, l]) => html`<option value="${v}"${value === v ? raw(' selected') : ''}>${l}</option>`)}</select>${help ? html`<small class="help">${help}</small>` : ''}</div>`;
335
+
328
336
  async function createPage(s: Session, reply: FastifyReply, schema: string, b: Body | null, message: Raw | '' = '') {
329
337
  const schemas = await appSchemas();
330
338
  const known = schemas.includes(schema);
@@ -336,6 +344,19 @@ async function createPage(s: Session, reply: FastifyReply, schema: string, b: Bo
336
344
  const suggested = known ? schema.replace(/_/g, ' ').replace(/^./, (x) => x.toUpperCase()) : '';
337
345
  const kindOf = (r: Relation, i: number) => (b ? (b[`t_${i}`] === r.relname ? (b[`k_${i}`] === undefined ? defaultKind(r) : String(b[`k_${i}`])) : '') : defaultKind(r));
338
346
  const rows = (r: Relation) => (r.rows === null ? tx('aw.not_analyzed') : tx('aw.about_rows', { n: Number(r.rows).toLocaleString('en') }));
347
+ // (UX-7) how many pages each page type makes for a relation: a report and a form, or one page
348
+ const pagesOf = (r: Relation, k: string) => (!k ? 0 : (k === 'report_form' || (r.kind === 'table' && !!r.pk && ['facets', 'cards', 'calendar'].includes(k)) ? 2 : 1));
349
+ const plannedPages = 1 + relations.reduce((n, r, i) => n + pagesOf(r, kindOf(r, i)), 0)
350
+ + (relations.some((r, i) => kindOf(r, i)) && (b ? b.chart === 'true' : true) ? 1 : 0);
351
+ const authValue = v('authentication', 'app_users');
352
+ const langValue = v('language', 'en');
353
+ const summary = [
354
+ known ? tx(plannedPages === 1 ? 'aw.sum_on_1' : 'aw.sum_on', { n: plannedPages, schema }) : tx('aw.sum_new'),
355
+ authValue === 'none' ? tx('aw.none_public') : tx('aw.app_users_login_page'),
356
+ LANGUAGE_NAMES[langValue as keyof typeof LANGUAGE_NAMES] ?? langValue,
357
+ ].join(' · ');
358
+ // (UX-7) name first, then the data and its pages, features, appearance and sign-in; Create in a sticky footer.
359
+ // The schema picker belongs to a GET form of its own (form="aw-schema-get"), placed inside the create form.
339
360
  const main = html`<div class="ab-narrow create-app">
340
361
  <h1>${tx('aw.create_an_application')}</h1>
341
362
  <nav class="create-sources" aria-label="${tx('aw.other_ways_to_start')}">${SOURCES.map(
@@ -343,45 +364,46 @@ async function createPage(s: Session, reply: FastifyReply, schema: string, b: Bo
343
364
  )}</nav>
344
365
  ${message}
345
366
  ${schema && !known && !message ? html`<div class="alert alert-error" role="alert">${tx('aw.choose_one_of_the_schemas')}</div>` : ''}
346
- ${region(tx('aw.data'), html`<form method="get" action="${BASE}/create" class="create-schema">
347
- <div class="form-grid">${select('schema', tx('aw.pages_on_the_tables_of'), schema, [['', tx('aw.a_new_empty_schema_home')], ...schemas.map((x): [string, string] => [x, x])],
348
- tx('aw.schema_help'))}</div>
349
- <div class="buttons"><button class="btn">${icon('search')} ${tx('aw.show_its_tables')}</button></div></form>`)}
350
- <form method="post" action="${BASE}/apps" data-progress="Creating the application and its pages…">${csrf(s)}
367
+ <form method="get" action="${BASE}/create" class="create-schema" id="aw-schema-get"></form>
368
+ <form method="post" action="${BASE}/apps" class="aw-form" data-progress="Creating the application and its pages…">${csrf(s)}
351
369
  <input type="hidden" name="features" value="1">
352
370
  <div id="aw-schema-fields"><input type="hidden" name="schema" value="${known ? schema : ''}">${known ? html`<input type="hidden" name="tables" value="1">` : ''}</div>
353
371
  ${region(tx('aw.application'), html`<div class="form-grid">
354
372
  <div class="field"><label class="label" for="f_name">${tx('aw.name')}</label>
355
- <input id="f_name" name="name" value="${v('name', suggested)}" required data-select-once></div>
356
- ${input('description', tx('aw.description'), v('description', ''), { placeholder: tx('aw.one_line_for_the_home') })}
373
+ <input id="f_name" name="name" value="${v('name', suggested)}" required data-select-once autofocus></div>
374
+ <div class="field"><label class="label" for="f_alias">${tx('aw.alias_url')}</label>
375
+ <input id="f_alias" name="alias" value="${v('alias', known ? await aliasFor(schema) : '')}" required pattern="[a-z][a-z0-9_\-]*" maxlength="50" data-derive-from="f_name"${b && b.alias ? raw(' data-edited') : ''}>
376
+ <small class="help">${tx('aw.lowercase_e_g_inventory_a')}</small></div>
357
377
  ${iconPicker('f_icon', 'icon', tx('aw.icon'), v('icon', DEFAULT_APP_ICON))}
378
+ ${input('description', tx('aw.description'), v('description', ''), { placeholder: tx('aw.one_line_for_the_home') })}
358
379
  </div>`)}
359
- ${region(tx('aw.appearance'), html`<div class="form-grid">
360
- ${select('base', tx('aw.theme_style'), v('base', 'iris'), [['iris', tx('aw.iris_light_body_dark_header')], ['lavender', tx('aw.lavender_rounded_indigo')], ['standard', tx('aw.standard_blue_navy_header')]])}
361
- ${select('nav', tx('aw.navigation'), v('nav', 'side'), [['side', tx('aw.side_menu')], ['top', tx('aw.top_menu')]])}
362
- ${input('accent', tx('aw.accent_colour'), v('accent', BASE_STYLES.iris.accent), { type: 'color', help: tx('aw.of_the_theme_style_the') })}
363
- </div>`)}
364
- ${region(tx('aw.pages'), html`<ul class="aw-pages">
380
+ ${region(tx('aw.data_and_pages'), html`<div class="form-grid aw-schema-row">${selectFor('aw-schema-get', 'schema', tx('aw.pages_on_the_tables_of'), schema,
381
+ [['', tx('aw.a_new_empty_schema_home')], ...schemas.map((x): [string, string] => [x, x])], tx('aw.schema_help'))}
382
+ <div class="buttons"><button class="btn" form="aw-schema-get">${icon('search')} ${tx('aw.show_its_tables')}</button></div></div>
383
+ <div id="aw-pages"><ul class="aw-pages">
365
384
  <li><span class="aw-page-kind">${icon('home')} ${tx('aw.home')}</span><span class="muted">${tx('aw.a_hero_with_the_name')}</span></li>
366
385
  ${known
367
386
  ? relations.length
368
387
  ? relations.map((r, i) => html`<li><input type="hidden" name="t_${i}" value="${r.relname}">
369
388
  <label class="aw-page-kind" for="f_k_${i}">${icon(r.kind === 'view' ? 'eye' : 'table')} ${r.label}</label>
370
- <select id="f_k_${i}" name="k_${i}">${kindsFor(r).map((k) => html`<option value="${k}"${k === kindOf(r, i) ? raw(' selected') : ''}>${tx.dyn(`aw.k.${k || 'none'}`, PAGE_KINDS[k])}</option>`)}</select>
389
+ <select id="f_k_${i}" name="k_${i}">${kindsFor(r).map((k) => html`<option value="${k}" data-pages="${pagesOf(r, k)}"${k === kindOf(r, i) ? raw(' selected') : ''}>${tx.dyn(`aw.k.${k || 'none'}`, PAGE_KINDS[k])}</option>`)}</select>
371
390
  <small class="muted"><code>${r.relname}</code> · ${r.kind === 'view' ? tx('aw.view') : rows(r)}${r.kind === 'table' && !r.pk ? tx('aw.no_key') : ''}${Number(r.rows ?? 0) > LARGE_TABLE ? tx('aw.paged_grid') : ''}</small>${rlsBadge(r)}</li>`)
372
391
  : html`<li class="muted">${tx('aw.no_relations', { schema })}</li>`
373
392
  : html`<li class="muted">${tx('aw.choose_a_schema_under_data')}</li>`}
374
393
  </ul>
375
- ${known ? check('chart', tx('aw.dashboard_a_chart_per_table'), b ? b.chart === 'true' : true) : ''}`, '', 'aw-pages')}
394
+ ${known ? check('chart', tx('aw.dashboard_a_chart_per_table'), b ? b.chart === 'true' : true) : ''}
395
+ <template id="aw-summary-pages" data-schema="${known ? schema : ''}" data-one="${tx('aw.sum_on_1', { n: 1, schema: known ? schema : '' })}" data-many="${tx('aw.sum_on', { n: '{n}', schema: known ? schema : '' })}" data-new="${tx('aw.sum_new')}"></template></div>`)}
376
396
  ${region(tx('aw.features'), html`<div class="aw-features">${FEATURES.map(([k, label, help]) => { const [l, h] = featureText(k, label, help); return check(`f_${k}`, l, features[k], h); })}</div>`)}
377
- ${region(tx('aw.settings'), html`<div class="form-grid">
378
- <div class="field"><label class="label" for="f_alias">${tx('aw.alias_url')}</label>
379
- <input id="f_alias" name="alias" value="${v('alias', known ? await aliasFor(schema) : '')}" required pattern="[a-z][a-z0-9_\-]*" maxlength="50" data-derive-from="f_name"${b && b.alias ? raw(' data-edited') : ''}>
380
- <small class="help">${tx('aw.lowercase_e_g_inventory_a')}</small></div>
381
- ${select('authentication', tx('aw.authentication'), v('authentication', 'app_users'), [['app_users', tx('aw.app_users_login_page')], ['none', tx('aw.none_public')]])}
397
+ ${region(tx('aw.appearance'), html`<div class="form-grid">
398
+ ${select('base', tx('aw.theme_style'), v('base', 'iris'), [['iris', tx('aw.iris_light_body_dark_header')], ['lavender', tx('aw.lavender_rounded_indigo')], ['standard', tx('aw.standard_blue_navy_header')]])}
399
+ ${select('nav', tx('aw.navigation'), v('nav', 'side'), [['side', tx('aw.side_menu')], ['top', tx('aw.top_menu')]])}
400
+ ${input('accent', tx('aw.accent_colour'), v('accent', BASE_STYLES.iris.accent), { type: 'color', help: tx('aw.of_the_theme_style_the') })}
401
+ </div>`)}
402
+ ${region(tx('aw.sign_in'), html`<div class="form-grid">
403
+ ${select('authentication', tx('aw.authentication'), authValue, [['app_users', tx('aw.app_users_login_page')], ['none', tx('aw.none_public')]])}
382
404
  ${input('admin_user', tx('aw.first_user'), v('admin_user', ''), { placeholder: tx('aw.e_g_your_name'), help: tx('aw.gets_the_admin_role_an') })}
383
405
  ${input('admin_password', tx('aw.password'), '', { type: 'password', auto: 'new-password', help: tx('aw.for_a_new_account_at') })}
384
- ${select('language', tx('aw.language'), v('language', 'en'), Object.entries(LANGUAGE_NAMES).map(([code, name]): [string, string] => [code, `${name} (${code})`]))}
406
+ ${select('language', tx('aw.language'), langValue, Object.entries(LANGUAGE_NAMES).map(([code, name]): [string, string] => [code, `${name} (${code})`]))}
385
407
  </div>`)}
386
408
  ${!known && bp.length
387
409
  ? html`<details class="region region-standard aw-advanced"><summary class="region-header"><h2>${tx('aw.advanced')}</h2></summary><div class="region-body"><div class="form-grid">
@@ -389,7 +411,8 @@ async function createPage(s: Session, reply: FastifyReply, schema: string, b: Bo
389
411
  tx('aw.bp_help'))}
390
412
  </div></div></details>`
391
413
  : ''}
392
- <div class="buttons"><a class="btn" href="${BASE}">${tx('aw.cancel')}</a><button class="btn btn-hot">${tx('aw.create_application')}</button></div>
414
+ <div class="aw-footer"><p class="aw-summary muted" id="aw-summary" aria-live="polite">${summary}</p>
415
+ <div class="buttons"><a class="btn" href="${BASE}">${tx('aw.cancel')}</a><button class="btn btn-hot">${tx('aw.create_application')}</button></div></div>
393
416
  </form>
394
417
  </div>`;
395
418
  return send(reply, s, shell(s, tx('aw.create_application'), [[tx('aw.app_builder'), BASE], [tx('aw.create')]], main));
@@ -133,27 +133,28 @@ export async function datasetRoutes(app: FastifyInstance) {
133
133
  const main = html`<div class="title-row"><h1>${tx('dts.data_reporter')}</h1>
134
134
  <div class="buttons"><a class="btn btn-hot" href="${root}/datasets/new">${icon('plus')} ${tx('dts.new_dataset')}</a><a class="btn" href="${root}/import">${icon('upload')} ${tx('dts.import')}</a>${sets.length ? html`<a class="btn" href="${root}/export">${icon('download')} ${tx('dts.export_all')}</a>` : ''}</div></div>
135
135
  <p class="muted u-mt0">${tx('dts.intro', { workspace: s.workspace?.name ?? tx('dts.this_workspace') })}</p>
136
- ${region(tx('dts.datasets'), html`<div class="table-wrap"><table class="report report-reflow">
137
- <thead><tr><th scope="col">${tx('dts.dataset')}</th><th scope="col">${tx('dts.schema')}</th><th scope="col">${tx('dts.tables_and_views')}</th><th scope="col" class="num">${tx('dts.columns')}</th><th scope="col">${tx('dts.updated_utc')}</th><th scope="col">${tx('dts.actions')}</th></tr></thead>
136
+ ${region(tx('dts.datasets'), html`<div class="table-wrap rep-fit"><table class="report report-reflow">
137
+ <thead><tr><th scope="col">${tx('dts.dataset')}</th><th scope="col" class="col-low">${tx('dts.schema')}</th><th scope="col">${tx('dts.tables_and_views')}</th><th scope="col" class="num">${tx('dts.columns')}</th><th scope="col" class="col-low">${tx('dts.updated_utc')}</th><th scope="col" class="col-actions">${tx('dts.actions')}</th></tr></thead>
138
138
  <tbody>${sets.length ? sets.map((d) => html`<tr>
139
- <td data-label="${tx('dts.dataset')}"><a href="${root}/datasets/${d.id}">${d.label}</a> <code>${d.name}</code>${d.description ? html`<br><span class="muted small">${d.description.slice(0, 160)}</span>` : ''}</td>
140
- <td data-label="${tx('dts.schema')}"><code>${d.schema_name}</code></td>
141
- <td data-label="${tx('dts.tables_and_views')}">${d.tables.map((t) => t.table).join(', ')}</td>
139
+ <td class="col-wrap" data-label="${tx('dts.dataset')}"><a href="${root}/datasets/${d.id}">${d.label}</a> <code>${d.name}</code>${d.description ? html`<br><span class="muted small">${d.description.slice(0, 160)}</span>` : ''}</td>
140
+ <td class="col-low" data-label="${tx('dts.schema')}"><code>${d.schema_name}</code></td>
141
+ <td class="col-wrap" data-label="${tx('dts.tables_and_views')}">${d.tables.map((t) => t.table).join(', ')}</td>
142
142
  <td class="num" data-label="${tx('dts.columns')}">${d.columns.length}</td>
143
- <td data-label="${tx('dts.updated_utc')}">${when(d.updated_at)}${d.updated_by ? html` <span class="muted">${d.updated_by}</span>` : ''}</td>
144
- <td data-label=""><a class="btn" href="${root}/apps/new?dataset=${encodeURIComponent(d.name)}">${icon('plus')} ${tx('dts.create_reporting_application')}</a></td>
143
+ <td class="col-low" data-label="${tx('dts.updated_utc')}">${when(d.updated_at)}${d.updated_by ? html` <span class="muted">${d.updated_by}</span>` : ''}</td>
144
+ <td class="col-actions" data-label=""><a class="btn" href="${root}/apps/new?dataset=${encodeURIComponent(d.name)}">${icon('plus')} ${tx('dts.create_reporting_application')}</a></td>
145
145
  </tr>`) : html`<tr><td colspan="6" class="empty">${tx('dts.no_datasets_yet_create_one')}</td></tr>`}</tbody></table></div>`)}
146
- ${region(tx('dts.reporting_applications'), html`<div class="table-wrap"><table class="report report-reflow">
147
- <thead><tr><th scope="col">${tx('dts.application')}</th><th scope="col">${tx('dts.datasets')}</th><th scope="col" class="num">${tx('dts.users')}</th><th scope="col" class="num">${tx('dts.published_reports')}</th><th scope="col">${tx('dts.actions')}</th></tr></thead>
146
+ ${region(tx('dts.reporting_applications'), html`<div class="table-wrap rep-fit"><table class="report report-reflow">
147
+ <thead><tr><th scope="col">${tx('dts.application')}</th><th scope="col">${tx('dts.datasets')}</th><th scope="col" class="num">${tx('dts.users')}</th><th scope="col" class="num">${tx('dts.published_reports')}</th><th scope="col" class="col-actions">${tx('dts.actions')}</th></tr></thead>
148
148
  <tbody>${apps.length ? apps.map((a) => html`<tr>
149
149
  <td data-label="${tx('dts.application')}"><a href="${BASE}/apps/${a.id}">${a.name}</a> <code>${a.alias}</code></td>
150
150
  <td data-label="${tx('dts.datasets')}">${a.datasets.join(', ')}</td>
151
151
  <td class="num" data-label="${tx('dts.users')}">${a.users}</td>
152
152
  <td class="num" data-label="${tx('dts.published_reports')}">${a.reports}</td>
153
- <td data-label=""><a class="btn" href="/a/${a.alias}/">${icon('play')} ${tx('dts.run')}</a> <a class="btn" href="${BASE}/apps/${a.id}/shared">${icon('users')} ${tx('dts.users_and_roles')}</a></td>
153
+ <td class="col-actions" data-label=""><a class="btn" href="/a/${a.alias}/">${icon('play')} ${tx('dts.run')}</a> <a class="btn" href="${BASE}/apps/${a.id}/shared">${icon('users')} ${tx('dts.users_and_roles')}</a></td>
154
154
  </tr>`) : html`<tr><td colspan="5" class="empty">${tx('dts.no_reporting_applications_yet_choose')}</td></tr>`}</tbody></table></div>
155
155
  <p class="muted small">${tx.rich('dts.roles_help', { viewer: html`<b>viewer</b>`, editor: html`<b>editor</b>`, administrator: html`<b>administrator</b>` })}</p>`)}`;
156
- return page(s, reply, tx('dts.data_reporter'), [], main);
156
+ // (UX-8) the lists take the page's width
157
+ return send(reply, s, shell(s, tx('dts.data_reporter'), crumbs([]), html`<div class="dataset-page">${main}</div>`, 'reporter'));
157
158
  });
158
159
 
159
160
  // ---------------------------------------------------------------- a new dataset: schema, then tables and views
@@ -123,7 +123,7 @@ export async function designerRoutes(app: FastifyInstance) {
123
123
  // (sprint 90) a commented-out component: struck through in the tree, Uncomment in its menu
124
124
  const commented = (r: any) => (r.build_option === 'COMMENTED_OUT' ? raw(' data-commented') : '');
125
125
  const leaf = (k: string, r: any, ic: Raw | '', label: string, note = '') =>
126
- html`<li><a class="pd-node" href="${url(`c=${k}-${r.id}`)}"${cur(`${k}-${r.id}`)} data-kind="${k}" data-id="${r.id}"${commented(r)}>${ic}<span class="pd-label">${label}</span>${badges(r)}${note ? html`<span class="pd-note">${note}</span>` : ''}</a></li>`;
126
+ html`<li><a class="pd-node" href="${url(`c=${k}-${r.id}`)}"${cur(`${k}-${r.id}`)} data-kind="${k}" data-id="${r.id}" title="${note ? `${label} (${note})` : label}"${commented(r)}>${ic}<span class="pd-label">${label}</span>${badges(r)}${note ? html`<span class="pd-note">${note}</span>` : ''}</a></li>`;
127
127
  const folder = (label: string, children: Raw[] | Raw, open = true) =>
128
128
  html`<li${open ? '' : raw(' data-collapsed')}><span class="pd-node pd-folder"><span class="pd-label">${label}</span></span><ul>${children}</ul></li>`;
129
129
  const none = (text: string) => html`<li class="pd-empty">${text}</li>`;
@@ -136,7 +136,7 @@ export async function designerRoutes(app: FastifyInstance) {
136
136
  const bySlot = ['region', ...TOP_SLOTS, ...BOTTOM_SLOTS].map((sl) => [sl, buttons.filter((b) => (b.slot ?? 'region') === sl)] as const).filter(([, list]) => list.length);
137
137
  const kids = [...(items.length ? [folder(tx('pd.tree.items'), items.map(itemLeaf))] : []),
138
138
  ...bySlot.map(([sl, list]) => folder(sl === 'region' ? tx('pd.tree.buttons') : tx('pd.tree.buttons_at', { slot: slotLabel(sl) }), list.map(buttonLeaf)))];
139
- return html`<li><a class="pd-node" href="${url(`c=region-${r.id}`)}"${cur(`region-${r.id}`)} data-kind="region" data-id="${r.id}" data-position="${r.position ?? ''}" data-next="${next?.id ?? ''}"${commented(r)}>${bicon(regionIcon(r.type))}<span class="pd-label">${regionTitle(r)}</span>${badges(r)}<span class="pd-note">${r.type}</span></a>${kids.length ? html`<ul>${kids}</ul>` : ''}</li>`;
139
+ return html`<li><a class="pd-node" href="${url(`c=region-${r.id}`)}"${cur(`region-${r.id}`)} data-kind="region" data-id="${r.id}" data-position="${r.position ?? ''}" data-next="${next?.id ?? ''}" title="${regionTitle(r)} (${r.type})"${commented(r)}>${bicon(regionIcon(r.type))}<span class="pd-label">${regionTitle(r)}</span>${badges(r)}<span class="pd-note">${r.type}</span></a>${kids.length ? html`<ul>${kids}</ul>` : ''}</li>`;
140
140
  };
141
141
  // (sprint 77, APEX: the rendering tree by display position) the page template's positions, and others in use
142
142
  const treeSide = p.page_template === 'left_side_column' ? 'left' : p.page_template === 'right_side_column' ? 'right' : null;
@@ -419,9 +419,7 @@ export async function designerRoutes(app: FastifyInstance) {
419
419
  </div></fieldset>
420
420
  <div class="buttons"><button class="btn btn-hot">${tx('pd.save_page')}</button></div>
421
421
  </form>`, true)])}
422
- ${await usedInPanel(p.app_id, 'page', p)}
423
- <form method="post" action="${BASE}/pages/${p.id}/delete" class="danger-zone">${csrf(s)}
424
- <button class="btn btn-sm btn-danger" data-confirm="${tx('pd.delete_page_confirm', { n: p.page_no })}">${tx('pd.delete_page')}</button></form>`;
422
+ ${await usedInPanel(p.app_id, 'page', p)}`;
425
423
  }
426
424
  // (sprint 59) the help pane: the guide section of the selected component's type
427
425
  const guide = (() => {
@@ -465,7 +463,11 @@ export async function designerRoutes(app: FastifyInstance) {
465
463
  <a href="${BASE}/apps/${p.app_id}/shared">${bicon('shapes')} ${tx('pd.tab.shared')}</a>
466
464
  <a href="${BASE}/apps/${p.app_id}">${bicon('pages')} ${tx('pd.menu.all_pages')}</a>
467
465
  <a href="${BASE}/apps/${p.app_id}/export">${icon('download')} ${tx('pd.menu.export')}</a>
468
- </div></div></details>`;
466
+ </div>
467
+ <!-- (UX-6) away from Save: deleting the page takes its number typed -->
468
+ <div class="menu-section"><form method="post" action="${BASE}/pages/${p.id}/delete" class="danger-form">${csrf(s)}
469
+ <label class="field"><span class="label">${tx('pd.delete_type', { n: p.page_no })}</span><input name="confirm" inputmode="numeric" autocomplete="off" required pattern="${p.page_no}"></label>
470
+ <button class="btn btn-sm btn-danger">${icon('trash')} ${tx('pd.delete_page')}</button></form></div></div></details>`;
469
471
  const toolbar = html`
470
472
  <div class="tb-group pd-pagenav" role="group" aria-label="${tx('pd.copy.page')}">
471
473
  ${prev ? html`<a class="tb-btn" href="${designerUrl(p.app_id, prev.page_no)}" title="${tx('pd.prev_title', { n: prev.page_no, name: prev.name })}">${bicon('left')}<span class="sr-only">${tx('pd.prev')}</span></a>` : html`<span class="tb-btn" aria-disabled="true">${bicon('left')}</span>`}
@@ -537,6 +539,13 @@ export async function designerRoutes(app: FastifyInstance) {
537
539
  app.post(`${BASE}/pages/:pid/delete`, async (req: Req, reply) => {
538
540
  const s = await developer(req, reply);
539
541
  if (!s) return;
542
+ // (UX-6) only with the page number typed
543
+ const target = await owner.one<{ page_no: number }>('select page_no from meta.page where id = $1', [Number(req.params.pid) || 0]);
544
+ if (!target) return reply.code(404).send('Not found');
545
+ if (String(req.body?.confirm ?? '').trim() !== String(target.page_no)) {
546
+ flash(s, tr(s)('pd.delete_mismatch', { n: target.page_no }), 'error');
547
+ return back(reply, s, `${BASE}/pages/${req.params.pid}`);
548
+ }
540
549
  const p = await owner.one('delete from meta.page where id = $1 returning app_id, page_no', [req.params.pid]);
541
550
  if (p) for (const t of ['builder_lock', 'dev_comment']) await owner.query(`delete from meta.${t} where app_id = $1 and page_no = $2`, [p.app_id, p.page_no]);
542
551
  flash(s, tr(s)('pd.page_deleted'));
@@ -1,8 +1,12 @@
1
1
  import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
2
2
  import { html } from '../html.ts';
3
3
  import { documentShell } from '../layout.ts';
4
- import { BASE, THEME_COOKIE, validTheme } from './ui.ts';
5
- import { cookieLanguage, translatorFor } from './i18n.ts';
4
+ import { BASE, shell, THEME_COOKIE, validTheme } from './ui.ts';
5
+ import { cookieLanguage, setRequestTranslator, tr, translatorFor } from './i18n.ts';
6
+ import { getSession } from '../session.ts';
7
+ import { loadLanguage } from './language.ts';
8
+ import { loadWorkspaces } from './workspaces.ts';
9
+ import { recentApps } from './home.ts';
6
10
 
7
11
  // (sprint 86) The builder's error pages: a browser that opens an address under /builder that
8
12
  // doesn't exist (or an application or page that doesn't, or not for this developer), or that
@@ -33,8 +37,42 @@ export function builderErrorPage(req: FastifyRequest, status: number) {
33
37
  );
34
38
  }
35
39
 
36
- const sendPage = (req: FastifyRequest, reply: FastifyReply, status: number) =>
37
- reply.code(status).type('text/html; charset=utf-8').header('cache-control', 'no-store').send(builderErrorPage(req, status));
40
+ /** "/builder/apps/new?x" → "apps new": the words of an address, for the search field of the page */
41
+ export const searchFromPath = (url: string) =>
42
+ url.split(/[?#]/)[0].slice(BASE.length).split('/').map((w) => { try { return decodeURIComponent(w); } catch { return w; } })
43
+ .filter((w) => w && !/^\d+$/.test(w)).join(' ').replace(/[^\p{L}\p{N} _.-]+/gu, ' ').replace(/\s+/g, ' ').trim().slice(0, 80);
44
+
45
+ /**
46
+ * (UX-10) "Page not found" for a signed-in developer: inside the builder's frame (rail, header search, status bar),
47
+ * with a search field filled from the address and the recent applications. The session is only read: nothing is
48
+ * saved, so a waiting message stays for the next page. Null: not signed in (the standalone page then).
49
+ */
50
+ export async function framedNotFound(req: FastifyRequest, reply: FastifyReply) {
51
+ const s = await getSession(req, reply, null, BASE, { touch: false }).catch(() => null);
52
+ if (!s?.username) return null;
53
+ if (s.state.__BLANG === undefined) await loadLanguage(s);
54
+ const tx = tr(s);
55
+ setRequestTranslator(tx);
56
+ await loadWorkspaces(s);
57
+ s.path = req.url;
58
+ const title = tx('error.not_found_title');
59
+ const recent = await recentApps(s).catch(() => []);
60
+ const main = html`<div class="ab-narrow nf-page">
61
+ <h1>${title}</h1>
62
+ <p>${tx('error.not_found_text')}</p>
63
+ <p class="muted">${tx('error.label_not_found')}: <code>${req.url.split('?')[0].slice(0, 200)}</code></p>
64
+ <form method="get" action="${BASE}/palette" class="search nf-search" role="search">
65
+ <label class="sr-only" for="nf-q">${tx('bar.search_label')}</label>
66
+ <input id="nf-q" type="search" name="q" value="${searchFromPath(req.url)}" autofocus><button class="btn">${tx('common.search')}</button></form>
67
+ ${recent.length ? html`<h2>${tx('ab.recent')}</h2><ul class="ab-links">${recent.map((a) => html`<li><a href="${BASE}/apps/${a.id}">${a.name}</a></li>`)}</ul>` : ''}
68
+ <p><a class="btn btn-hot" href="${BASE}">${tx('error.home')}</a></p>
69
+ </div>`;
70
+ return shell(s, title, [[title]], main);
71
+ }
72
+
73
+ const sendPage = async (req: FastifyRequest, reply: FastifyReply, status: number) =>
74
+ reply.code(status).type('text/html; charset=utf-8').header('cache-control', 'no-store')
75
+ .send((status === 404 && (await framedNotFound(req, reply))) || builderErrorPage(req, status));
38
76
 
39
77
  /**
40
78
  * The hooks (registered on the root instance): unknown builder addresses, the builder's own
@@ -50,7 +88,7 @@ export function builderErrorPages(app: FastifyInstance) {
50
88
  if (reply.statusCode !== 404 || !isBuilder(req.url) || !wantsHtml(req)) return payload;
51
89
  if (!/^text\/plain\b/.test(String(reply.getHeader('content-type') ?? ''))) return payload;
52
90
  reply.type('text/html; charset=utf-8');
53
- return builderErrorPage(req, 404);
91
+ return (await framedNotFound(req, reply)) || builderErrorPage(req, 404);
54
92
  });
55
93
  }
56
94
 
@@ -58,6 +96,6 @@ export function builderErrorPages(app: FastifyInstance) {
58
96
  export function builderServerError(req: FastifyRequest, reply: FastifyReply, err: Error & { statusCode?: number }) {
59
97
  if (!isBuilder(req.url) || !wantsHtml(req) || (err.statusCode !== undefined && err.statusCode < 500)) return false;
60
98
  req.log.error(err);
61
- sendPage(req, reply, 500);
99
+ reply.code(500).type('text/html; charset=utf-8').header('cache-control', 'no-store').send(builderErrorPage(req, 500));
62
100
  return true;
63
101
  }
@@ -9,7 +9,7 @@ import { migrate } from '../migrate.ts';
9
9
  import { clientIp, logActivity } from '../session.ts';
10
10
  import { openBlueprint } from './blueprints.ts';
11
11
  import { isAdmin } from './locks.ts';
12
- import { back, BASE, csrf, developer, flash, send, shell, type Req } from './ui.ts';
12
+ import { back, BASE, csrf, developer, flash, pageHeader, send, shell, type Req } from './ui.ts';
13
13
  import { currentWorkspace, inWorkspaceSql, placeApp } from './workspaces.ts';
14
14
  import { workspacesChanged } from '../workspace-db.ts';
15
15
  import { scopedTx as tx, type BuilderKey } from './i18n.ts';
@@ -104,7 +104,7 @@ export async function galleryRoutes(app: FastifyInstance) {
104
104
  const tabs = html`<nav class="gal-tabs segmented" aria-label="${tx('gal.kinds')}">${TABS.map(([t, l]) =>
105
105
  html`<a href="${BASE}/gallery?tab=${t}${q ? `&q=${encodeURIComponent(q)}` : ''}"${tab === t ? raw(' aria-current="page"') : ''}>${tx(l)} <span class="gal-count">${counts[t]}</span></a>`)}</nav>`;
106
106
  const blueprintCard = (kind: 'starters' | 'utilities') => (x: Starter) => card(x.icon, x.title, 'pgkiln', x.description,
107
- html`<form method="post" action="${BASE}/gallery/${kind}/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm">${tx('gal.install')}</button></form>`);
107
+ html`<form method="post" action="${BASE}/gallery/${kind}/${x.key}">${csrf(s)}<button class="btn btn-sm">${tx('gal.install')}</button></form>`);
108
108
  const cards =
109
109
  tab === 'samples'
110
110
  ? samples.filter(matches).map((x) => {
@@ -115,13 +115,13 @@ export async function galleryRoutes(app: FastifyInstance) {
115
115
  ? html`<a class="btn btn-sm" href="${BASE}/apps/${it.id}">${tx('gal.edit')}</a>`
116
116
  : html`<span class="muted small">${tx('gal.other_workspace')}</span>`}`
117
117
  : admin
118
- ? html`<form method="post" action="${BASE}/gallery/samples/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm" data-confirm="${tx('gal.confirm_install', { title: x.title })}">${tx('gal.install')}</button></form>`
118
+ ? html`<form method="post" action="${BASE}/gallery/samples/${x.key}">${csrf(s)}<button class="btn btn-sm" data-confirm="${tx('gal.confirm_install', { title: x.title })}">${tx('gal.install')}</button></form>`
119
119
  : html`<span class="muted small">${tx('gal.admin_installs')}</span>`);
120
120
  })
121
121
  : tab === 'starters' || tab === 'utilities'
122
122
  ? (tab === 'starters' ? starters : utilities).filter(matches).map(blueprintCard(tab))
123
123
  : custom.filter(matches).map((x) => card('copy', x.name, tx('gal.this_workspace'), x.description ?? tx('gal.boilerplate_desc'),
124
- html`<a class="btn btn-hot btn-sm" href="${BASE}/create?boilerplate=${x.id}">${tx('gal.create_from')}</a><a class="btn btn-sm" href="${BASE}/apps/${x.id}">${tx('gal.edit')}</a>`));
124
+ html`<a class="btn btn-sm" href="${BASE}/create?boilerplate=${x.id}">${tx('gal.create_from')}</a><a class="btn btn-sm" href="${BASE}/apps/${x.id}">${tx('gal.edit')}</a>`));
125
125
  const empty = q && counts[tab]
126
126
  ? html`<p class="muted">${tx.rich('gal.no_match', { q: html`<strong>${q}</strong>` })} <a href="${BASE}/gallery?tab=${tab}">${tx('gal.show_all')}</a></p>`
127
127
  : tab === 'custom'
@@ -129,13 +129,13 @@ export async function galleryRoutes(app: FastifyInstance) {
129
129
  : html`<p class="muted">${tx('gal.empty')}</p>`;
130
130
  const intro = tx(({ samples: 'gal.intro.samples', starters: 'gal.intro.starters', utilities: 'gal.intro.utilities', custom: 'gal.intro.custom' } as const)[tab]);
131
131
  const main = html`<div class="gal">
132
+ ${pageHeader(tx('rail.gallery'))}
132
133
  <div class="gal-bar">
133
134
  <form class="ab-search gal-search" role="search" method="get" action="${BASE}/gallery"><label class="sr-only" for="gal-q">${tx('gal.search')}</label>${icon('search')}
134
135
  <input type="hidden" name="tab" value="${tab}">
135
136
  <input id="gal-q" name="q" type="search" placeholder="${tx('gal.search')}" autocomplete="off" value="${q}" data-filter-list=".gal [data-filter-row]"></form>
136
137
  ${tabs}
137
138
  </div>
138
- <h1 class="sr-only">${tx('rail.gallery')}</h1>
139
139
  <p class="muted">${intro}</p>
140
140
  ${cards.length ? html`<div class="gal-grid">${cards}</div>` : empty}
141
141
  </div>`;