pgkiln 0.54.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/SECURITY.md +1 -0
  2. package/db/migrations/127_builder_language.sql +16 -0
  3. package/docs/apex-feature-parity.md +4 -4
  4. package/docs/guide/03-builder.md +22 -1
  5. package/docs/guide/12-development.md +41 -0
  6. package/package.json +1 -1
  7. package/public/builder.css +18 -0
  8. package/public/worksheet.css +3 -2
  9. package/src/builder/advisor.ts +1 -1
  10. package/src/builder/ai-agents.ts +5 -5
  11. package/src/builder/ai-builder.ts +3 -3
  12. package/src/builder/api.ts +1 -1
  13. package/src/builder/autorest.ts +2 -2
  14. package/src/builder/availability.ts +1 -1
  15. package/src/builder/backups.ts +1 -1
  16. package/src/builder/changelog.ts +1 -1
  17. package/src/builder/data-modeler.ts +4 -4
  18. package/src/builder/data-security.ts +1 -1
  19. package/src/builder/dataload.ts +1 -1
  20. package/src/builder/datapump.ts +1 -1
  21. package/src/builder/ddl-tools.ts +2 -2
  22. package/src/builder/designer.ts +134 -124
  23. package/src/builder/diagnostics.ts +2 -2
  24. package/src/builder/errors.ts +10 -12
  25. package/src/builder/feedback.ts +1 -1
  26. package/src/builder/globalization.ts +1 -1
  27. package/src/builder/home.ts +59 -57
  28. package/src/builder/i18n/de.ts +675 -0
  29. package/src/builder/i18n/en.ts +678 -0
  30. package/src/builder/i18n/es.ts +675 -0
  31. package/src/builder/i18n/fr.ts +675 -0
  32. package/src/builder/i18n/nl.ts +675 -0
  33. package/src/builder/i18n/pt.ts +675 -0
  34. package/src/builder/i18n.ts +116 -0
  35. package/src/builder/language.ts +74 -0
  36. package/src/builder/launchpad.ts +13 -8
  37. package/src/builder/object-browser.ts +1 -1
  38. package/src/builder/plugins.ts +1 -1
  39. package/src/builder/querybuilder.ts +1 -1
  40. package/src/builder/quicksql.ts +1 -1
  41. package/src/builder/rest-modules.ts +2 -2
  42. package/src/builder/routes.ts +88 -66
  43. package/src/builder/sampledata.ts +1 -1
  44. package/src/builder/scheduling.ts +1 -1
  45. package/src/builder/scripts.ts +1 -1
  46. package/src/builder/search.ts +1 -1
  47. package/src/builder/shared.ts +5 -5
  48. package/src/builder/sql-charts.ts +2 -2
  49. package/src/builder/sql-monitor.ts +2 -2
  50. package/src/builder/sql-tools.ts +6 -0
  51. package/src/builder/sql-utilities.ts +3 -3
  52. package/src/builder/sql.ts +24 -22
  53. package/src/builder/static-files.ts +1 -1
  54. package/src/builder/subscriptions.ts +1 -1
  55. package/src/builder/supporting.ts +1 -1
  56. package/src/builder/table-editor.ts +1 -1
  57. package/src/builder/team.ts +7 -5
  58. package/src/builder/themeroller.ts +1 -1
  59. package/src/builder/top-sql.ts +1 -1
  60. package/src/builder/ui.ts +87 -77
  61. package/src/builder/unload.ts +1 -1
  62. package/src/builder/wizards.ts +1 -1
  63. package/src/builder/workflow-migrate.ts +1 -1
  64. package/src/builder/workingcopies.ts +2 -2
  65. package/src/builder/worksheet.ts +1 -1
  66. package/src/builder/workspace-home.ts +62 -59
  67. package/src/builder/workspaces.ts +2 -1
@@ -6,6 +6,7 @@ import { icon } from '../icons.ts';
6
6
  import { COMPONENTS } from './components.ts';
7
7
  import { back, BASE, bicon, csrf, designerUrl, developer, flash, input, select, send, shell, type Req } from './ui.ts';
8
8
  import { wantsHtml } from './errors.ts';
9
+ import { tr } from './i18n.ts';
9
10
  import type { Session } from '../session.ts';
10
11
  import { buildOptionChoices, componentForm, lookups, saveComponent } from './forms.ts';
11
12
  import { regionConfigHelp } from './region-help.ts';
@@ -51,7 +52,7 @@ export async function designerRoutes(app: FastifyInstance) {
51
52
  const no = /^\d{1,9}$/.test(req.query.page ?? '') ? Number(req.query.page) : -1;
52
53
  const p = appId === null ? null : await owner.one('select id from meta.page where app_id = $1 and page_no = $2', [appId, no]);
53
54
  if (p) return reply.redirect(designerUrl(appId!, no), 303);
54
- flash(s, `There is no page ${req.query.page ?? ''}.`, 'error');
55
+ flash(s, tr(s)('pd.no_page', { page: req.query.page ?? '' }), 'error');
55
56
  return back(reply, s, appId === null ? BASE : `${BASE}/apps/${appId}`);
56
57
  });
57
58
 
@@ -93,6 +94,12 @@ export async function designerRoutes(app: FastifyInstance) {
93
94
  ]).then((r) => r.map((x) => x.rows)),
94
95
  ]);
95
96
  const sel = req.query.c ?? '';
97
+ // (sprint 121) the toolbar, menus, tree headings, pane titles and gallery in the builder language; the property
98
+ // editor's fields and their help stay English (docs/guide/12-development.md)
99
+ const tx = tr(s);
100
+ const kindLabel = (k: string) => tx.dyn(`comp.${k}`, COMPONENTS[k].label);
101
+ const posLabel = (pos: string) => tx.dyn(`pos.${pos}`, POSITION_LABELS[pos] ?? pos);
102
+ const slotLabel = (sl: string) => tx.dyn(`slot.${sl}`, SLOT_LABELS[sl] ?? sl);
96
103
  const newKind = PAGE_KINDS.includes(req.query.new ?? '') ? req.query.new : '';
97
104
  const [kind, cid] = sel.split('-');
98
105
  const url = (q: string) => designerUrl(p.app_id, p.page_no, q);
@@ -100,7 +107,7 @@ export async function designerRoutes(app: FastifyInstance) {
100
107
  const post = `${BASE}/pages/${p.id}`;
101
108
  const isSel = (key: string) => sel === key || (key === 'page' && !sel && !newKind);
102
109
  const cur = (key: string) => (isSel(key) ? raw(' aria-current="true"') : '');
103
- const badges = (r: any) => html`${r.authz ? html`<span class="pd-tag" title="Authorization: ${r.authz}">${icon('shield')}<span class="sr-only">Authorization ${r.authz}</span></span>` : ''}${r.condition || r.readonly_condition || r.condition_type ? html`<span class="pd-tag" title="Has a condition">${icon('filter')}<span class="sr-only">Has a condition</span></span>` : ''}${r.build_option ? html`<span class="pd-tag" title="Build option: ${r.build_option}">${icon('settings')}<span class="sr-only">Build option ${r.build_option}</span></span>` : ''}`;
110
+ const badges = (r: any) => html`${r.authz ? html`<span class="pd-tag" title="${tx('pd.badge.authz', { name: r.authz })}">${icon('shield')}<span class="sr-only">${tx('pd.badge.authz', { name: r.authz })}</span></span>` : ''}${r.condition || r.readonly_condition || r.condition_type ? html`<span class="pd-tag" title="${tx('pd.badge.condition')}">${icon('filter')}<span class="sr-only">${tx('pd.badge.condition')}</span></span>` : ''}${r.build_option ? html`<span class="pd-tag" title="${tx('pd.badge.build_option', { name: r.build_option })}">${icon('settings')}<span class="sr-only">${tx('pd.badge.build_option', { name: r.build_option })}</span></span>` : ''}`;
104
111
  const regionTitle = (r: any) => r.title ?? `(${r.type})`;
105
112
 
106
113
  // ------------------------------------------------------------ left: component tree
@@ -119,8 +126,8 @@ export async function designerRoutes(app: FastifyInstance) {
119
126
  const buttons = rows.button.filter((b) => b.region_id === r.id);
120
127
  // (sprint 77, APEX: region slots) buttons by their position in the region
121
128
  const bySlot = ['region', ...TOP_SLOTS, ...BOTTOM_SLOTS].map((sl) => [sl, buttons.filter((b) => (b.slot ?? 'region') === sl)] as const).filter(([, list]) => list.length);
122
- const kids = [...(items.length ? [folder('Items', items.map(itemLeaf))] : []),
123
- ...bySlot.map(([sl, list]) => folder(sl === 'region' ? 'Buttons' : `Buttons: ${SLOT_LABELS[sl]}`, list.map(buttonLeaf)))];
129
+ const kids = [...(items.length ? [folder(tx('pd.tree.items'), items.map(itemLeaf))] : []),
130
+ ...bySlot.map(([sl, list]) => folder(sl === 'region' ? tx('pd.tree.buttons') : tx('pd.tree.buttons_at', { slot: slotLabel(sl) }), list.map(buttonLeaf)))];
124
131
  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>`;
125
132
  };
126
133
  // (sprint 77, APEX: the rendering tree by display position) the page template's positions, and others in use
@@ -128,7 +135,7 @@ export async function designerRoutes(app: FastifyInstance) {
128
135
  const regionFolders = ['banner', 'breadcrumb', 'left', 'body', 'right', 'footer'].map((pos) => {
129
136
  const list = rows.region.filter((r) => (r.position ?? 'body') === pos);
130
137
  if (!list.length && pos !== 'body' && pos !== treeSide) return '';
131
- return folder(POSITION_LABELS[pos], list.length ? list.map((r, i) => regionLeaf(r, list[i + 1])) : none(pos === 'body' ? 'No regions yet' : 'No regions'), list.length > 0);
138
+ return folder(posLabel(pos), list.length ? list.map((r, i) => regionLeaf(r, list[i + 1])) : none(tx(pos === 'body' ? 'pd.tree.no_regions_yet' : 'pd.tree.no_regions')), list.length > 0);
132
139
  });
133
140
  const pageItems = rows.item.filter((i) => i.region_id === null);
134
141
  const pageButtons = rows.button.filter((b) => b.region_id === null);
@@ -137,65 +144,65 @@ export async function designerRoutes(app: FastifyInstance) {
137
144
  const headerComps = rows.computation.filter((x) => x.point === 'before_header');
138
145
  const submitComps = rows.computation.filter((x) => x.point !== 'before_header');
139
146
  const headerBranches = rows.branch.filter((x) => x.point === 'before_header');
140
- const branchNote = (b: any) => `${b.when_button ? `${b.when_button}: ` : ''}→ ${b.target_type === 'url' ? b.target_url : `page ${b.target_page ?? p.page_no}`}`;
147
+ const branchNote = (b: any) => `${b.when_button ? `${b.when_button}: ` : ''}→ ${b.target_type === 'url' ? b.target_url : tx('pd.page_n', { n: b.target_page ?? p.page_no })}`;
141
148
  const compLeaf = (x: any) => leaf('computation', x, icon('activity'), x.item_name, x.type);
142
149
  const branchLeaf = (x: any) => leaf('branch', x, bicon('right'), x.name, branchNote(x));
143
150
  const preRendering = [...headerBranches.map(branchLeaf), ...headerComps.map(compLeaf), ...loadProcs.map((x) => leaf('process', x, icon('code'), x.name))];
144
151
  const createBar = (links: [string, string][]) =>
145
152
  html`<div class="pd-treebar">${links.map(([k, label]) => html`<a class="tb-btn tb-text" href="${url(`new=${k}`)}">${icon('plus')}<span>${label}</span></a>`)}</div>`;
146
153
 
147
- const rendering = html`${createBar([['region', 'Region'], ['item', 'Item'], ['button', 'Button']])}
148
- <ul class="pd-tree" aria-label="Rendering" data-tree-menu data-page-id="${p.id}">
149
- <li><a class="pd-node" href="${url('c=page')}"${cur('page')}>${icon('file')}<span class="pd-label">Page ${p.page_no}: ${p.name}</span>${badges(p)}</a>
154
+ const rendering = html`${createBar([['region', kindLabel('region')], ['item', kindLabel('item')], ['button', kindLabel('button')]])}
155
+ <ul class="pd-tree" aria-label="${tx('pd.tab.rendering')}" data-tree-menu data-page-id="${p.id}">
156
+ <li><a class="pd-node" href="${url('c=page')}"${cur('page')}>${icon('file')}<span class="pd-label">${tx('pd.page_title', { n: p.page_no, name: p.name })}</span>${badges(p)}</a>
150
157
  <ul>
151
- ${folder('Pre-Rendering (before header)', preRendering.length ? preRendering : none('No branches, computations or processes before rendering'), preRendering.length > 0)}
158
+ ${folder(tx('pd.tree.pre_rendering'), preRendering.length ? preRendering : none(tx('pd.tree.no_pre_rendering')), preRendering.length > 0)}
152
159
  ${regionFolders}
153
- ${pageItems.length ? folder('Page items', pageItems.map(itemLeaf)) : ''}
154
- ${pageButtons.length ? folder('Page buttons', pageButtons.map(buttonLeaf)) : ''}
160
+ ${pageItems.length ? folder(tx('pd.tree.page_items'), pageItems.map(itemLeaf)) : ''}
161
+ ${pageButtons.length ? folder(tx('pd.tree.page_buttons'), pageButtons.map(buttonLeaf)) : ''}
155
162
  </ul></li>
156
163
  </ul>
157
164
  <dialog class="pd-dialog" id="pd-copy-dialog" aria-labelledby="pd-copy-title">
158
165
  <form method="post" action="${post}/layout/copy">${csrf(s)}<input type="hidden" name="kind" value=""><input type="hidden" name="id" value="">
159
- <h2 id="pd-copy-title">Copy to other page</h2>
166
+ <h2 id="pd-copy-title">${tx('pd.copy.title')}</h2>
160
167
  <p class="muted" data-copy-what></p>
161
- <div class="field"><label class="label" for="pd-copy-page">Page</label>
168
+ <div class="field"><label class="label" for="pd-copy-page">${tx('pd.copy.page')}</label>
162
169
  <select id="pd-copy-page" name="page">${pages.map((x) => html`<option value="${x.id}"${x.id === p.id ? raw(' selected') : ''}>${x.page_no}. ${x.name}</option>`)}</select>
163
- <small class="help">Items named P${p.page_no}_… get the other page's number; a region's items and buttons come along. References in SQL and conditions are not changed.</small></div>
164
- <div class="pd-dialog-buttons"><button type="button" class="btn" data-dialog-close>Cancel</button><button class="btn btn-primary">Copy</button></div>
170
+ <small class="help">${tx('pd.copy.help', { prefix: `P${p.page_no}_…` })}</small></div>
171
+ <div class="pd-dialog-buttons"><button type="button" class="btn" data-dialog-close>${tx('common.cancel')}</button><button class="btn btn-primary">${tx('pd.copy.copy')}</button></div>
165
172
  </form>
166
173
  </dialog>`;
167
- const events: [string, string][] = [['change', 'Change'], ['click', 'Click'], ['load', 'Page load'], ['menu_entry', 'Menu entry chosen']];
174
+ const events: [string, string][] = [['change', tx('pd.event.change')], ['click', tx('pd.event.click')], ['load', tx('pd.event.load')], ['menu_entry', tx('pd.event.menu_entry')]];
168
175
  const daLeaf = (d: (typeof rows.dynamic_action)[number]) => leaf('dynamic_action', d, icon('bolt'), d.name, `${d.trigger_element ?? ''} → ${d.action}`);
169
176
  // (sprint 115) the other events (focus, custom, before_submit, …) in one folder, when there are any
170
177
  const others = rows.dynamic_action.filter((d) => !events.some(([ev]) => ev === d.event));
171
- const dynamicActions = html`${createBar([['dynamic_action', 'Dynamic action']])}
172
- <ul class="pd-tree" aria-label="Dynamic actions">
178
+ const dynamicActions = html`${createBar([['dynamic_action', kindLabel('dynamic_action')]])}
179
+ <ul class="pd-tree" aria-label="${tx('pd.tab.da')}">
173
180
  ${events.map(([ev, label]) => {
174
181
  const list = rows.dynamic_action.filter((d) => d.event === ev);
175
- return folder(`Events: ${label}`, list.length ? list.map(daLeaf) : none('None'), list.length > 0);
182
+ return folder(tx('pd.tree.events', { event: label }), list.length ? list.map(daLeaf) : none(tx('common.none')), list.length > 0);
176
183
  })}
177
- ${others.length ? folder('Events: Other', others.map((d) => leaf('dynamic_action', d, icon('bolt'), d.name, `${d.event}: ${d.trigger_element ?? ''} → ${d.action}`)), true) : ''}
184
+ ${others.length ? folder(tx('pd.tree.events', { event: tx('pd.event.other') }), others.map((d) => leaf('dynamic_action', d, icon('bolt'), d.name, `${d.event}: ${d.trigger_element ?? ''} → ${d.action}`)), true) : ''}
178
185
  </ul>`;
179
186
  // after processing: the branches in sequence, then (when none applies) the pressed button's target page
180
187
  const afterBranches = rows.branch.filter((x) => x.point !== 'before_header');
181
188
  const buttonTargets = rows.button.filter((b) => b.target_page && b.action === 'submit');
182
- const afterProcessing = [...afterBranches.map(branchLeaf), ...buttonTargets.map((b) => leaf('button', b, bicon('right'), `${b.name} → page ${b.target_page}`, 'button target'))];
183
- const processing = html`${createBar([['computation', 'Computation'], ['validation', 'Validation'], ['process', 'Process'], ['branch', 'Branch']])}
184
- <ul class="pd-tree" aria-label="Processing">
185
- ${folder('After submit (computations)', submitComps.length ? submitComps.map(compLeaf) : none('No computations'), submitComps.length > 0)}
186
- ${folder('Validating', rows.validation.length ? rows.validation.map((v) => leaf('validation', v, icon('check'), v.name, v.when_button ?? '')) : none('No validations'))}
187
- ${folder('Processing', submitProcs.length ? submitProcs.map((x) => leaf('process', x, icon('code'), x.name, x.when_button ?? '')) : none('No processes'))}
188
- ${folder('After processing (branches)', afterProcessing.length ? afterProcessing : none('No branches'), afterProcessing.length > 0)}
189
+ const afterProcessing = [...afterBranches.map(branchLeaf), ...buttonTargets.map((b) => leaf('button', b, bicon('right'), `${b.name} → ${tx('pd.page_n', { n: b.target_page })}`, tx('pd.tree.button_target')))];
190
+ const processing = html`${createBar([['computation', kindLabel('computation')], ['validation', kindLabel('validation')], ['process', kindLabel('process')], ['branch', kindLabel('branch')]])}
191
+ <ul class="pd-tree" aria-label="${tx('pd.tab.processing')}">
192
+ ${folder(tx('pd.tree.after_submit'), submitComps.length ? submitComps.map(compLeaf) : none(tx('pd.tree.no_computations')), submitComps.length > 0)}
193
+ ${folder(tx('pd.tree.validating'), rows.validation.length ? rows.validation.map((v) => leaf('validation', v, icon('check'), v.name, v.when_button ?? '')) : none(tx('pd.tree.no_validations')))}
194
+ ${folder(tx('pd.tab.processing'), submitProcs.length ? submitProcs.map((x) => leaf('process', x, icon('code'), x.name, x.when_button ?? '')) : none(tx('pd.tree.no_processes')))}
195
+ ${folder(tx('pd.tree.after_processing'), afterProcessing.length ? afterProcessing : none(tx('pd.tree.no_branches')), afterProcessing.length > 0)}
189
196
  </ul>`;
190
197
  const sharedLink = (k: string, id: number, ic: string, label: string) =>
191
198
  html`<li><a class="pd-node" href="${BASE}/apps/${p.app_id}/shared?c=${k}-${id}">${icon(ic)}<span class="pd-label">${label}</span></a></li>`;
192
199
  const [lovs, schemes, navs, appItems] = shared;
193
- const sharedTree = html`<div class="pd-treebar"><a class="tb-btn tb-text" href="${BASE}/apps/${p.app_id}/shared">${bicon('shapes')}<span>All shared components</span></a></div>
194
- <ul class="pd-tree" aria-label="Shared components">
195
- ${folder('Lists of values', lovs.length ? lovs.map((l) => sharedLink('lov', l.id, 'list', l.name)) : none('None'), lovs.length > 0)}
196
- ${folder('Authorization schemes', schemes.length ? schemes.map((a) => sharedLink('authz_scheme', a.id, 'shield', a.name)) : none('None'), false)}
197
- ${folder('Navigation menu', navs.length ? navs.map((n) => sharedLink('nav_entry', n.id, 'menu', n.label)) : none('None'), false)}
198
- ${folder('Application items', appItems.length ? appItems.map((n) => sharedLink('app_item', n.id, 'edit', n.name)) : none('None'), false)}
200
+ const sharedTree = html`<div class="pd-treebar"><a class="tb-btn tb-text" href="${BASE}/apps/${p.app_id}/shared">${bicon('shapes')}<span>${tx('pd.shared.all')}</span></a></div>
201
+ <ul class="pd-tree" aria-label="${tx('pd.tab.shared')}">
202
+ ${folder(tx('pd.shared.lovs'), lovs.length ? lovs.map((l) => sharedLink('lov', l.id, 'list', l.name)) : none(tx('common.none')), lovs.length > 0)}
203
+ ${folder(tx('pd.shared.authz'), schemes.length ? schemes.map((a) => sharedLink('authz_scheme', a.id, 'shield', a.name)) : none(tx('common.none')), false)}
204
+ ${folder(tx('pd.shared.nav'), navs.length ? navs.map((n) => sharedLink('nav_entry', n.id, 'menu', n.label)) : none(tx('common.none')), false)}
205
+ ${folder(tx('pd.shared.app_items'), appItems.length ? appItems.map((n) => sharedLink('app_item', n.id, 'edit', n.name)) : none(tx('common.none')), false)}
199
206
  </ul>`;
200
207
  const selProcess = kind === 'process' ? rows.process.find((x) => String(x.id) === cid) : null;
201
208
  const selComp = kind === 'computation' ? rows.computation.find((x) => String(x.id) === cid) : null;
@@ -203,18 +210,18 @@ export async function designerRoutes(app: FastifyInstance) {
203
210
  const leftTab = kind === 'dynamic_action' || newKind === 'dynamic_action' ? 'da'
204
211
  : kind === 'validation' || ['validation', 'process', 'computation', 'branch'].includes(newKind ?? '') || (selProcess && selProcess.point !== 'load')
205
212
  || (selComp && selComp.point !== 'before_header') || (selBranch && selBranch.point !== 'before_header') ? 'proc' : 'rend';
206
- const left = html`<div class="pd-tabs pd-tabs-icons" data-tabs="pd-left" aria-label="Page components">
207
- ${tab('pd-l-rendering', 'Rendering', rendering, leftTab === 'rend', 'rendering')}
208
- ${tab('pd-l-da', 'Dynamic actions', dynamicActions, leftTab === 'da', 'bolt')}
209
- ${tab('pd-l-processing', 'Processing', processing, leftTab === 'proc', 'processing')}
210
- ${tab('pd-l-shared', 'Shared components', sharedTree, false, 'shapes')}
213
+ const left = html`<div class="pd-tabs pd-tabs-icons" data-tabs="pd-left" aria-label="${tx('pd.components')}">
214
+ ${tab('pd-l-rendering', tx('pd.tab.rendering'), rendering, leftTab === 'rend', 'rendering')}
215
+ ${tab('pd-l-da', tx('pd.tab.da'), dynamicActions, leftTab === 'da', 'bolt')}
216
+ ${tab('pd-l-processing', tx('pd.tab.processing'), processing, leftTab === 'proc', 'processing')}
217
+ ${tab('pd-l-shared', tx('pd.tab.shared'), sharedTree, false, 'shapes')}
211
218
  </div>`;
212
219
 
213
220
  // ------------------------------------------------------------ center: layout + gallery
214
221
  const chip = (k: 'item' | 'button', r: any) =>
215
222
  html`<a class="pd-chip pd-chip-${k}${isSel(`${k}-${r.id}`) ? ' is-selected' : ''}${k === 'button' && r.hot ? ' is-hot' : ''}" href="${url(`c=${k}-${r.id}`)}" draggable="true" data-kind="${k}" data-id="${r.id}"${cur(`${k}-${r.id}`)} aria-describedby="pd-kbd">${k === 'item' ? bicon(itemIcon(r.type)) : ''}<span>${r.label || r.name}</span>${k === 'item' ? html`<small>${r.name}</small>` : ''}</a>`;
216
223
  const slot = (k: 'item' | 'button', regionId: number | null, list: any[], buttonSlot = '') =>
217
- html`<div class="pd-slot pd-slot-${k}${buttonSlot && buttonSlot !== 'region' && !list.length ? ' pd-slot-spare' : ''}" data-drop="${k}" data-region="${regionId ?? ''}"${buttonSlot ? raw(` data-slot="${buttonSlot}"`) : ''}${buttonSlot && buttonSlot !== 'region' ? raw(` title="Button position: ${SLOT_LABELS[buttonSlot]}"`) : ''}>${list.length ? list.map((r) => chip(k, r)) : html`<span class="pd-slot-hint">${k === 'item' ? 'Items' : buttonSlot && buttonSlot !== 'region' ? SLOT_LABELS[buttonSlot] : 'Buttons'}</span>`}</div>`;
224
+ html`<div class="pd-slot pd-slot-${k}${buttonSlot && buttonSlot !== 'region' && !list.length ? ' pd-slot-spare' : ''}" data-drop="${k}" data-region="${regionId ?? ''}"${buttonSlot ? raw(` data-slot="${buttonSlot}"`) : ''}${buttonSlot && buttonSlot !== 'region' ? html` title="${tx('pd.slot_title', { slot: slotLabel(buttonSlot) })}"` : ''}>${list.length ? list.map((r) => chip(k, r)) : html`<span class="pd-slot-hint">${k === 'item' ? tx('pd.tree.items') : buttonSlot && buttonSlot !== 'region' ? slotLabel(buttonSlot) : tx('pd.tree.buttons')}</span>`}</div>`;
218
225
  // (sprint 77, APEX: region slots) a region's buttons by position: the header's, the region's own, the bottom bar's
219
226
  const buttonSlots = (r: any) => {
220
227
  const mine = rows.button.filter((b) => b.region_id === r.id);
@@ -224,17 +231,17 @@ export async function designerRoutes(app: FastifyInstance) {
224
231
  };
225
232
  const block = (r: any) => html`<div class="pd-region pd-span-${r.columns}${isSel(`region-${r.id}`) ? ' is-selected' : ''}" data-kind="region" data-id="${r.id}" data-span="${r.columns}">
226
233
  <div class="pd-region-head" draggable="true" data-kind="region" data-id="${r.id}">
227
- <span class="pd-grip" title="Drag to move">${bicon('grip')}</span>
234
+ <span class="pd-grip" title="${tx('pd.drag_move')}">${bicon('grip')}</span>
228
235
  <a class="pd-region-link" href="${url(`c=region-${r.id}`)}"${cur(`region-${r.id}`)} aria-describedby="pd-kbd">${bicon(regionIcon(r.type))}<span>${regionTitle(r)}</span></a>
229
236
  <span class="pd-region-meta">${r.columns}/12</span>
230
237
  </div>
231
238
  <div class="pd-region-body">
232
- <div class="pd-region-type">${REGION_LABELS[r.type]?.[0] ?? r.type}${r.template !== 'standard' ? ` · ${r.template}` : ''}</div>
239
+ <div class="pd-region-type">${tx.dyn(`gallery.region.${r.type}`, REGION_LABELS[r.type]?.[0] ?? r.type)}${r.template !== 'standard' ? ` · ${r.template}` : ''}</div>
233
240
  ${r.source || r.table_name ? html`<code class="pd-region-src">${firstLine(r.table_name ?? r.source)}</code>` : ''}
234
241
  ${slot('item', r.id, rows.item.filter((i) => i.region_id === r.id))}
235
242
  ${buttonSlots(r)}
236
243
  </div>
237
- <span class="pd-resize" title="Drag to change the column span" aria-hidden="true"></span>
244
+ <span class="pd-resize" title="${tx('pd.drag_span')}" aria-hidden="true"></span>
238
245
  </div>`;
239
246
  // (sprint 77, APEX: the page template's display positions) a drop zone per position of the page template, and
240
247
  // for any other position that has regions (they show in the body when the page runs)
@@ -243,11 +250,11 @@ export async function designerRoutes(app: FastifyInstance) {
243
250
  const zone = (pos: string) => {
244
251
  const list = byPos(pos);
245
252
  const off = (pos === 'left' || pos === 'right') && pos !== side;
246
- return html`<section class="pd-zone pd-zone-${pos}${list.length ? '' : ' is-empty'}" aria-label="${POSITION_LABELS[pos]}">
247
- <div class="pd-zone-label">${POSITION_LABELS[pos]}${off ? html` <small>(not in this page template: shown in the body)</small>` : ''}</div>
253
+ return html`<section class="pd-zone pd-zone-${pos}${list.length ? '' : ' is-empty'}" aria-label="${posLabel(pos)}">
254
+ <div class="pd-zone-label">${posLabel(pos)}${off ? html` <small>${tx('pd.zone_off')}</small>` : ''}</div>
248
255
  <div class="pd-grid" data-drop="region" data-position="${pos}">
249
256
  ${list.map(block)}
250
- <div class="pd-grid-end" data-end><a href="${url(`new=region${pos === 'body' ? '' : `&position=${pos}`}`)}">${icon('plus')} Region${pos === 'body' ? '' : html`<span class="sr-only"> in ${POSITION_LABELS[pos]}</span>`}</a></div>
257
+ <div class="pd-grid-end" data-end><a href="${url(`new=region${pos === 'body' ? '' : `&position=${pos}`}`)}">${icon('plus')} ${kindLabel('region')}${pos === 'body' ? '' : html`<span class="sr-only"> ${tx('pd.in_position', { position: posLabel(pos) })}</span>`}</a></div>
251
258
  </div></section>`;
252
259
  };
253
260
  const extra = (pos: string) => pos !== side && byPos(pos).length > 0;
@@ -256,28 +263,31 @@ export async function designerRoutes(app: FastifyInstance) {
256
263
  ${side === 'left' || extra('left') ? zone('left') : ''}${zone('body')}${side === 'right' || extra('right') ? zone('right') : ''}
257
264
  </div>${zone('footer')}`;
258
265
  const gallery = (k: 'region' | 'item' | 'button', types: string[], labels: Record<string, [string, string]>) =>
259
- html`<ul class="pd-gallery-list" aria-label="${COMPONENTS[k].plural}">${types.map((t) => html`<li><a class="pd-gal" href="${url(`new=${k}&type=${t}`)}" draggable="true" data-new="${k}" data-type="${t}" title="${labels[t]?.[0] ?? t}: drag onto the layout, or open to create">${bicon(labels[t]?.[1] ?? k)}<span>${labels[t]?.[0] ?? t}</span></a></li>`)}</ul>`;
266
+ html`<ul class="pd-gallery-list" aria-label="${tx.dyn(`comp.${k}.plural`, COMPONENTS[k].plural)}">${types.map((t) => {
267
+ const name = tx.dyn(`gallery.${k}.${t}`, labels[t]?.[0] ?? t);
268
+ return html`<li><a class="pd-gal" href="${url(`new=${k}&type=${t}`)}" draggable="true" data-new="${k}" data-type="${t}" title="${tx('pd.gallery_title', { name })}">${bicon(labels[t]?.[1] ?? k)}<span>${name}</span></a></li>`;
269
+ })}</ul>`;
260
270
  const layout = html`
261
- <div class="pd-canvas-bar" role="toolbar" aria-label="Layout">
262
- <button type="button" class="tb-btn" data-zoom="-1" title="Zoom out" hidden>${bicon('zoom-out')}<span class="sr-only">Zoom out</span></button>
263
- <button type="button" class="tb-btn" data-zoom="1" title="Zoom in" hidden>${bicon('zoom-in')}<span class="sr-only">Zoom in</span></button>
264
- <button type="button" class="tb-btn" data-maximize title="Maximize the layout" aria-pressed="false" hidden>${bicon('expand')}<span class="sr-only">Maximize the layout</span></button>
265
- <span class="pd-canvas-hint" id="pd-kbd">Drag to move. Keyboard: Alt+↑/↓ moves the focused component, Alt+Shift+←/→ changes a region's width.</span>
271
+ <div class="pd-canvas-bar" role="toolbar" aria-label="${tx('pd.layout')}">
272
+ <button type="button" class="tb-btn" data-zoom="-1" title="${tx('pd.zoom_out')}" hidden>${bicon('zoom-out')}<span class="sr-only">${tx('pd.zoom_out')}</span></button>
273
+ <button type="button" class="tb-btn" data-zoom="1" title="${tx('pd.zoom_in')}" hidden>${bicon('zoom-in')}<span class="sr-only">${tx('pd.zoom_in')}</span></button>
274
+ <button type="button" class="tb-btn" data-maximize title="${tx('pd.maximize')}" aria-pressed="false" hidden>${bicon('expand')}<span class="sr-only">${tx('pd.maximize')}</span></button>
275
+ <span class="pd-canvas-hint" id="pd-kbd">${tx('pd.kbd_hint')}</span>
266
276
  </div>
267
277
  <div class="pd-canvas" id="pd-layout" data-page-id="${p.id}">
268
- <div class="pd-page-label">${icon('file')} Page ${p.page_no}: ${p.name}${p.mode === 'modal' ? ' (modal dialog)' : ''}</div>
278
+ <div class="pd-page-label">${icon('file')} ${tx('pd.page_title', { n: p.page_no, name: p.name })}${p.mode === 'modal' ? ` ${tx('pd.modal')}` : ''}</div>
269
279
  ${zones}
270
280
  <div class="pd-pagelevel">
271
- <div class="pd-pagelevel-title">Page level (no region)</div>
281
+ <div class="pd-pagelevel-title">${tx('pd.page_level')}</div>
272
282
  ${slot('item', null, pageItems)}
273
283
  ${slot('button', null, pageButtons)}
274
284
  </div>
275
285
  </div>
276
286
  <div class="pd-gallery">
277
- <div class="pd-tabs" data-tabs="pd-gallery" data-tabs-remember aria-label="Gallery">
278
- ${tab('pd-g-regions', 'Regions', gallery('region', REGION_TYPES(), REGION_LABELS), true)}
279
- ${tab('pd-g-items', 'Items', gallery('item', ITEM_TYPES(), ITEM_LABELS))}
280
- ${tab('pd-g-buttons', 'Buttons', gallery('button', BUTTON_ACTIONS(), BUTTON_LABELS))}
287
+ <div class="pd-tabs" data-tabs="pd-gallery" data-tabs-remember aria-label="${tx('pd.gallery')}">
288
+ ${tab('pd-g-regions', tx('pd.gallery.regions'), gallery('region', REGION_TYPES(), REGION_LABELS), true)}
289
+ ${tab('pd-g-items', tx('pd.tree.items'), gallery('item', ITEM_TYPES(), ITEM_LABELS))}
290
+ ${tab('pd-g-buttons', tx('pd.tree.buttons'), gallery('button', BUTTON_ACTIONS(), BUTTON_LABELS))}
281
291
  </div>
282
292
  </div>`;
283
293
  const help = html`<div class="pd-help">
@@ -297,8 +307,8 @@ export async function designerRoutes(app: FastifyInstance) {
297
307
  <p>A PL/pgSQL <code>raise exception 'Message' using column = 'sal'</code> shows the message on the item whose source column is <code>sal</code>.</p>
298
308
  </div>`;
299
309
  const pageSearch = html`<form method="get" action="${BASE}/apps/${p.app_id}/search" class="pd-search" role="search">
300
- <label class="label" for="pd-q">Search the application's pages and components</label>
301
- <div class="u-row"><input id="pd-q" name="q" type="search" placeholder="e.g. P${p.page_no}_ or a table name"><button class="btn btn-sm">${icon('search')} Search</button></div>
310
+ <label class="label" for="pd-q">${tx('pd.search_label')}</label>
311
+ <div class="u-row"><input id="pd-q" name="q" type="search" placeholder="${tx('pd.search_placeholder', { prefix: `P${p.page_no}_` })}"><button class="btn btn-sm">${icon('search')} ${tx('common.search')}</button></div>
302
312
  </form>`;
303
313
  // a lock of another developer (on this page or the whole application) makes the page read-only
304
314
  const locks = (await appLocks(p.app_id)).filter((l) => l.page_no === 0 || l.page_no === p.page_no);
@@ -308,12 +318,12 @@ export async function designerRoutes(app: FastifyInstance) {
308
318
  const blocking = runOnly ?? locks.find((l) => l.locked_by !== s.username);
309
319
  const mine = locks.find((l) => l.locked_by === s.username);
310
320
  const commentCount = (await owner.one('select count(*)::int as n from meta.dev_comment where app_id = $1 and page_no = $2', [p.app_id, p.page_no])).n;
311
- const center = html`${blocking ? html`<div class="alert alert-error pd-lock" role="status">${icon('key')} ${lockText(blocking)} Your changes will be refused.</div>` : ''}${await pageIssuesNote(p.app_id, p.page_no)}
312
- <div class="pd-tabs pd-tabs-center" data-tabs="pd-center" aria-label="Page">
313
- ${tab('pd-c-layout', 'Layout', layout, true)}
314
- ${tab('pd-c-search', 'Page search', pageSearch)}
315
- ${tab('pd-c-notes', `Lock and comments${commentCount ? ` (${commentCount})` : ''}${mine || blocking ? ' · locked' : ''}`, await lockPanel(s, p.app_id, p.page_no))}
316
- ${tab('pd-c-help', 'Help', help)}
321
+ const center = html`${blocking ? html`<div class="alert alert-error pd-lock" role="status">${icon('key')} ${lockText(blocking)} ${tx('pd.lock_refused')}</div>` : ''}${await pageIssuesNote(p.app_id, p.page_no)}
322
+ <div class="pd-tabs pd-tabs-center" data-tabs="pd-center" aria-label="${tx('pd.copy.page')}">
323
+ ${tab('pd-c-layout', tx('pd.layout'), layout, true)}
324
+ ${tab('pd-c-search', tx('pd.page_search'), pageSearch)}
325
+ ${tab('pd-c-notes', `${tx('pd.lock_comments')}${commentCount ? ` (${commentCount})` : ''}${mine || blocking ? ` · ${tx('pd.locked')}` : ''}`, await lockPanel(s, p.app_id, p.page_no))}
326
+ ${tab('pd-c-help', tx('rail.help'), help)}
317
327
  </div>`;
318
328
 
319
329
  // ------------------------------------------------------------ right: property editor
@@ -322,7 +332,7 @@ export async function designerRoutes(app: FastifyInstance) {
322
332
  let arrange: Raw | '' = '';
323
333
  const formId = 'pd-form';
324
334
  let hasForm = true;
325
- const peTabs = (panels: Raw[]) => html`<div class="pd-tabs" data-tabs="pd-right" data-tabs-remember aria-label="Properties">${panels}</div>`;
335
+ const peTabs = (panels: Raw[]) => html`<div class="pd-tabs" data-tabs="pd-right" data-tabs-remember aria-label="${tx('pd.properties')}">${panels}</div>`;
326
336
  const moveForm = (k: string, id: number, fields: Raw, button: Raw, title: string) =>
327
337
  html`<form method="post" action="${post}/layout/move" class="pe-arrange-form">${csrf(s)}<input type="hidden" name="kind" value="${k}"><input type="hidden" name="id" value="${id}">${fields}<button class="tb-btn" title="${title}">${button}</button></form>`;
328
338
  if (newKind) {
@@ -332,43 +342,43 @@ export async function designerRoutes(app: FastifyInstance) {
332
342
  const presetType = spec.fields.find((f) => f.name === typeField)?.options?.includes(req.query.type ?? '') ? { [typeField]: req.query.type } : {};
333
343
  const presetPlace = newKind === 'region' && POSITIONS().includes(req.query.position ?? '') ? { position: req.query.position } : {};
334
344
  const defaults = { seq: lastSeq + 10, ...spec.defaults, ...presetType, ...presetPlace, region_id: /^\d+$/.test(req.query.region ?? '') ? Number(req.query.region) : null };
335
- heading = html`${icon(spec.icon)}<span>New ${spec.label.toLowerCase()}</span>`;
336
- props = peTabs([tab('pd-r-props', spec.label, componentForm(spec, newKind, defaults, lk, `${BASE}/pages/${p.id}/c/${newKind}`, s, `Create ${spec.label.toLowerCase()}`, { id: formId }), true)]);
345
+ heading = html`${icon(spec.icon)}<span>${tx.dyn(`pd.new.${newKind}`, `New ${spec.label.toLowerCase()}`)}</span>`;
346
+ props = peTabs([tab('pd-r-props', kindLabel(newKind), componentForm(spec, newKind, defaults, lk, `${BASE}/pages/${p.id}/c/${newKind}`, s, tx.dyn(`pd.create.${newKind}`, `Create ${spec.label.toLowerCase()}`), { id: formId }), true)]);
337
347
  } else if (kind && kind !== 'page' && PAGE_KINDS.includes(kind)) {
338
348
  const spec = COMPONENTS[kind];
339
349
  const row = rows[kind].find((r) => String(r.id) === cid);
340
350
  if (row) {
341
- heading = html`${kind === 'region' ? bicon(regionIcon(row.type)) : kind === 'item' ? bicon(itemIcon(row.type)) : kind === 'button' ? bicon('button') : icon(spec.icon)}<span>${spec.label}: ${spec.summary(row)}</span>`;
351
+ heading = html`${kind === 'region' ? bicon(regionIcon(row.type)) : kind === 'item' ? bicon(itemIcon(row.type)) : kind === 'button' ? bicon('button') : icon(spec.icon)}<span>${kindLabel(kind)}: ${spec.summary(row)}</span>`;
342
352
  const settings = kind === 'region' ? await regionSettingsForm(p.id, p.app_id, row, s) : '';
343
353
  props = html`${peTabs([
344
- tab('pd-r-props', spec.label, componentForm(spec, kind, row, lk, `${BASE}/pages/${p.id}/c/${kind}/${row.id}`, s, 'Save', { id: formId }), true),
345
- ...(settings ? [tab('pd-r-attrs', 'Attributes', settings)] : []),
346
- ...(kind === 'process' && row.type === 'chain' && row.config?.background ? [tab('pd-r-jobs', 'Jobs', await processJobsPanel(row.id))] : []),
354
+ tab('pd-r-props', kindLabel(kind), componentForm(spec, kind, row, lk, `${BASE}/pages/${p.id}/c/${kind}/${row.id}`, s, tx('common.save'), { id: formId }), true),
355
+ ...(settings ? [tab('pd-r-attrs', tx('pd.attributes'), settings)] : []),
356
+ ...(kind === 'process' && row.type === 'chain' && row.config?.background ? [tab('pd-r-jobs', tx('pd.jobs'), await processJobsPanel(row.id))] : []),
347
357
  ])}
348
358
  ${await usedInPanel(p.app_id, kind, row)}
349
359
  <form method="post" action="${BASE}/pages/${p.id}/c/${kind}/${row.id}/delete" class="danger-zone">${csrf(s)}
350
- <button class="btn btn-sm btn-danger" data-confirm="Delete this ${spec.label.toLowerCase()}?">Delete ${spec.label.toLowerCase()}</button></form>`;
360
+ <button class="btn btn-sm btn-danger" data-confirm="${tx.dyn(`pd.delete_confirm.${kind}`, `Delete this ${spec.label.toLowerCase()}?`)}">${tx.dyn(`pd.delete.${kind}`, `Delete ${spec.label.toLowerCase()}`)}</button></form>`;
351
361
  if (kind === 'region' || kind === 'item' || kind === 'button') {
352
- const regionOptions = html`<option value="">- page level -</option>${rows.region.map((r) => html`<option value="${r.id}"${row.region_id === r.id ? raw(' selected') : ''}>${regionTitle(r)}</option>`)}`;
353
- arrange = html`<div class="pe-arrange" role="group" aria-label="Arrange">
354
- <span class="pe-arrange-label">Arrange</span>
355
- ${moveForm(kind, row.id, html`<input type="hidden" name="dir" value="up">`, html`${bicon('arrow-up')}<span class="sr-only">Move up</span>`, 'Move up (earlier in the sequence)')}
356
- ${moveForm(kind, row.id, html`<input type="hidden" name="dir" value="down">`, html`${bicon('arrow-down')}<span class="sr-only">Move down</span>`, 'Move down (later in the sequence)')}
362
+ const regionOptions = html`<option value="">${tx('pd.arrange.page_level')}</option>${rows.region.map((r) => html`<option value="${r.id}"${row.region_id === r.id ? raw(' selected') : ''}>${regionTitle(r)}</option>`)}`;
363
+ arrange = html`<div class="pe-arrange" role="group" aria-label="${tx('pd.arrange')}">
364
+ <span class="pe-arrange-label">${tx('pd.arrange')}</span>
365
+ ${moveForm(kind, row.id, html`<input type="hidden" name="dir" value="up">`, html`${bicon('arrow-up')}<span class="sr-only">${tx('pd.arrange.up')}</span>`, tx('pd.arrange.up_title'))}
366
+ ${moveForm(kind, row.id, html`<input type="hidden" name="dir" value="down">`, html`${bicon('arrow-down')}<span class="sr-only">${tx('pd.arrange.down')}</span>`, tx('pd.arrange.down_title'))}
357
367
  ${kind === 'region'
358
- ? html`<form method="post" action="${post}/layout/span" class="pe-arrange-form">${csrf(s)}<input type="hidden" name="id" value="${row.id}"><input type="hidden" name="delta" value="-1"><button class="tb-btn" title="Narrower (one column less)"${row.columns <= 1 ? raw(' disabled') : ''}>${bicon('narrow')}<span class="sr-only">Narrower</span></button></form>
359
- <span class="pe-span" title="Column span">${row.columns}/12</span>
360
- <form method="post" action="${post}/layout/span" class="pe-arrange-form">${csrf(s)}<input type="hidden" name="id" value="${row.id}"><input type="hidden" name="delta" value="1"><button class="tb-btn" title="Wider (one column more)"${row.columns >= 12 ? raw(' disabled') : ''}>${bicon('wide')}<span class="sr-only">Wider</span></button></form>`
361
- : moveForm(kind, row.id, html`<label class="sr-only" for="pe-move-region">Move to region</label><select id="pe-move-region" name="region">${regionOptions}</select>`, html`<span>Move</span>`, 'Move to the end of this region')}
368
+ ? html`<form method="post" action="${post}/layout/span" class="pe-arrange-form">${csrf(s)}<input type="hidden" name="id" value="${row.id}"><input type="hidden" name="delta" value="-1"><button class="tb-btn" title="${tx('pd.arrange.narrower_title')}"${row.columns <= 1 ? raw(' disabled') : ''}>${bicon('narrow')}<span class="sr-only">${tx('pd.arrange.narrower')}</span></button></form>
369
+ <span class="pe-span" title="${tx('pd.arrange.span')}">${row.columns}/12</span>
370
+ <form method="post" action="${post}/layout/span" class="pe-arrange-form">${csrf(s)}<input type="hidden" name="id" value="${row.id}"><input type="hidden" name="delta" value="1"><button class="tb-btn" title="${tx('pd.arrange.wider_title')}"${row.columns >= 12 ? raw(' disabled') : ''}>${bicon('wide')}<span class="sr-only">${tx('pd.arrange.wider')}</span></button></form>`
371
+ : moveForm(kind, row.id, html`<label class="sr-only" for="pe-move-region">${tx('pd.arrange.to_region')}</label><select id="pe-move-region" name="region">${regionOptions}</select>`, html`<span>${tx('pd.arrange.move')}</span>`, tx('pd.arrange.move_title'))}
362
372
  </div>`;
363
373
  }
364
374
  } else {
365
- heading = html`<span>Not found</span>`;
366
- props = html`<p class="muted">Component not found.</p>`;
375
+ heading = html`<span>${tx('error.label_not_found')}</span>`;
376
+ props = html`<p class="muted">${tx('pd.not_found')}</p>`;
367
377
  hasForm = false;
368
378
  }
369
379
  } else {
370
- heading = html`${icon('file')}<span>Page ${p.page_no}: ${p.name}</span>`;
371
- props = html`${peTabs([tab('pd-r-props', 'Page', html`
380
+ heading = html`${icon('file')}<span>${tx('pd.page_title', { n: p.page_no, name: p.name })}</span>`;
381
+ props = html`${peTabs([tab('pd-r-props', tx('pd.copy.page'), html`
372
382
  <form method="post" action="${BASE}/pages/${p.id}" class="component-form" id="${formId}">${csrf(s)}
373
383
  <fieldset class="prop-group"><legend>Identification</legend><div class="form-grid">
374
384
  ${input('page_no', 'Page number', p.page_no, { type: 'number', required: true })}
@@ -394,11 +404,11 @@ export async function designerRoutes(app: FastifyInstance) {
394
404
  ${select('build_option', 'Build option', p.build_option ?? '', buildOptionChoices(lk, p.build_option),
395
405
  'While the option is excluded (or "Not": included) the page does not exist in the running application.')}
396
406
  </div></fieldset>
397
- <div class="buttons"><button class="btn btn-hot">Save page</button></div>
407
+ <div class="buttons"><button class="btn btn-hot">${tx('pd.save_page')}</button></div>
398
408
  </form>`, true)])}
399
409
  ${await usedInPanel(p.app_id, 'page', p)}
400
410
  <form method="post" action="${BASE}/pages/${p.id}/delete" class="danger-zone">${csrf(s)}
401
- <button class="btn btn-sm btn-danger" data-confirm="Delete page ${p.page_no} and all its components?">Delete page</button></form>`;
411
+ <button class="btn btn-sm btn-danger" data-confirm="${tx('pd.delete_page_confirm', { n: p.page_no })}">${tx('pd.delete_page')}</button></form>`;
402
412
  }
403
413
  // (sprint 59) the help pane: the guide section of the selected component's type
404
414
  const guide = (() => {
@@ -410,13 +420,13 @@ export async function designerRoutes(app: FastifyInstance) {
410
420
  })();
411
421
  const right = html`
412
422
  <div class="pe-head"><div class="pe-title">${heading}</div>
413
- <div class="pe-filter-row" hidden><label class="sr-only" for="pe-filter">Filter properties</label>${icon('filter')}<input id="pe-filter" class="pe-filter" type="search" placeholder="Filter" autocomplete="off"></div>
423
+ <div class="pe-filter-row" hidden><label class="sr-only" for="pe-filter">${tx('pd.filter_label')}</label>${icon('filter')}<input id="pe-filter" class="pe-filter" type="search" placeholder="${tx('pd.filter')}" autocomplete="off"></div>
414
424
  </div>
415
425
  ${arrange}
416
426
  <div class="pe">${props}</div>
417
427
  <details class="pe-help" open${guide ? raw(` data-guide="${guide}"`) : ''}>
418
- <summary>${icon('help')} Help</summary>
419
- <div class="pe-help-body" aria-live="polite" data-pe-help><p class="muted">Select a property to see its help here.</p></div>
428
+ <summary>${icon('help')} ${tx('rail.help')}</summary>
429
+ <div class="pe-help-body" aria-live="polite" data-pe-help><p class="muted">${tx('pd.help_hint')}</p></div>
420
430
  </details>`;
421
431
 
422
432
  // ------------------------------------------------------------ toolbar
@@ -424,54 +434,54 @@ export async function designerRoutes(app: FastifyInstance) {
424
434
  const prev = pages[idx - 1];
425
435
  const next = pages[idx + 1];
426
436
  const h = undoState(s, p.id);
427
- const tbForm = (op: string, ic: string, label: string, title: string | null) =>
428
- html`<form method="post" action="${post}/layout/${op}" class="tb-form">${csrf(s)}<button class="tb-btn" title="${title ? `${label}: ${title}` : `Nothing to ${label.toLowerCase()}`}"${title ? '' : raw(' disabled')}>${bicon(ic)}<span class="sr-only">${label}</span></button></form>`;
437
+ const tbForm = (op: string, ic: string, label: string, nothing: string, title: string | null) =>
438
+ html`<form method="post" action="${post}/layout/${op}" class="tb-form">${csrf(s)}<button class="tb-btn" title="${title ? `${label}: ${title}` : nothing}"${title ? '' : raw(' disabled')}>${bicon(ic)}<span class="sr-only">${label}</span></button></form>`;
429
439
  const createMenu = html`<details class="menu tb-menu">
430
- <summary class="tb-btn" title="Create">${icon('plus')}${bicon('down', 'icon tb-caret')}<span class="sr-only">Create</span></summary>
440
+ <summary class="tb-btn" title="${tx('pd.menu.create')}">${icon('plus')}${bicon('down', 'icon tb-caret')}<span class="sr-only">${tx('pd.menu.create')}</span></summary>
431
441
  <div class="menu-panel align-right"><div class="menu-section menu-links">
432
- ${[['region', 'Region'], ['item', 'Page item'], ['button', 'Button'], ['dynamic_action', 'Dynamic action'], ['computation', 'Computation'], ['validation', 'Validation'], ['process', 'Process'], ['branch', 'Branch']].map(([k, label]) =>
433
- html`<a href="${url(`new=${k}`)}">${icon(COMPONENTS[k].icon)} ${label}</a>`)}
442
+ ${['region', 'item', 'button', 'dynamic_action', 'computation', 'validation', 'process', 'branch'].map((k) =>
443
+ html`<a href="${url(`new=${k}`)}">${icon(COMPONENTS[k].icon)} ${k === 'item' ? tx('pd.menu.page_item') : kindLabel(k)}</a>`)}
434
444
  </div><div class="menu-section menu-links">
435
- <a href="${BASE}/apps/${p.app_id}#create-page">${icon('file')} Page…</a>
445
+ <a href="${BASE}/apps/${p.app_id}#create-page">${icon('file')} ${tx('pd.menu.page')}</a>
436
446
  </div></div></details>`;
437
447
  const utilMenu = html`<details class="menu tb-menu">
438
- <summary class="tb-btn" title="Utilities">${bicon('wrench')}${bicon('down', 'icon tb-caret')}<span class="sr-only">Utilities</span></summary>
448
+ <summary class="tb-btn" title="${tx('pd.menu.utilities')}">${bicon('wrench')}${bicon('down', 'icon tb-caret')}<span class="sr-only">${tx('pd.menu.utilities')}</span></summary>
439
449
  <div class="menu-panel align-right"><div class="menu-section menu-links">
440
- <a href="${BASE}/apps/${p.app_id}/advisor">${icon('check')} Advisor</a>
441
- <a href="${BASE}/apps/${p.app_id}/search?q=P${p.page_no}_">${icon('search')} Search this page's items</a>
442
- <a href="${BASE}/apps/${p.app_id}/shared">${bicon('shapes')} Shared components</a>
443
- <a href="${BASE}/apps/${p.app_id}">${bicon('pages')} All pages</a>
444
- <a href="${BASE}/apps/${p.app_id}/export">${icon('download')} Export application</a>
450
+ <a href="${BASE}/apps/${p.app_id}/advisor">${icon('check')} ${tx('app.tab.advisor')}</a>
451
+ <a href="${BASE}/apps/${p.app_id}/search?q=P${p.page_no}_">${icon('search')} ${tx('pd.menu.search_items')}</a>
452
+ <a href="${BASE}/apps/${p.app_id}/shared">${bicon('shapes')} ${tx('pd.tab.shared')}</a>
453
+ <a href="${BASE}/apps/${p.app_id}">${bicon('pages')} ${tx('pd.menu.all_pages')}</a>
454
+ <a href="${BASE}/apps/${p.app_id}/export">${icon('download')} ${tx('pd.menu.export')}</a>
445
455
  </div></div></details>`;
446
456
  const toolbar = html`
447
- <div class="tb-group pd-pagenav" role="group" aria-label="Page">
448
- ${prev ? html`<a class="tb-btn" href="${designerUrl(p.app_id, prev.page_no)}" title="Previous page: ${prev.page_no}. ${prev.name}">${bicon('left')}<span class="sr-only">Previous page</span></a>` : html`<span class="tb-btn" aria-disabled="true">${bicon('left')}</span>`}
457
+ <div class="tb-group pd-pagenav" role="group" aria-label="${tx('pd.copy.page')}">
458
+ ${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>`}
449
459
  <form method="get" action="${BASE}/apps/${p.app_id}/goto" class="tb-form pd-goto-no">
450
- <label class="sr-only" for="pd-goto-no">Page number (Enter goes there)</label>
451
- <input id="pd-goto-no" name="page" type="number" min="0" max="999999999" step="1" value="${p.page_no}" inputmode="numeric" title="Page number: type or step to a number, then Enter">
460
+ <label class="sr-only" for="pd-goto-no">${tx('pd.goto_no')}</label>
461
+ <input id="pd-goto-no" name="page" type="number" min="0" max="999999999" step="1" value="${p.page_no}" inputmode="numeric" title="${tx('pd.goto_no_title')}">
452
462
  </form>
453
463
  <form method="get" action="${BASE}/apps/${p.app_id}/goto" class="tb-form pd-goto">
454
- <label class="sr-only" for="pd-goto">Go to page</label>
464
+ <label class="sr-only" for="pd-goto">${tx('pd.goto')}</label>
455
465
  <select id="pd-goto" name="page" data-autosubmit>${pages.map((x) => html`<option value="${x.page_no}"${x.id === p.id ? raw(' selected') : ''}>${x.page_no} · ${x.name}</option>`)}</select>
456
- <button class="tb-btn tb-text pd-goto-go">Go</button>
466
+ <button class="tb-btn tb-text pd-goto-go">${tx('common.go')}</button>
457
467
  </form>
458
- ${next ? html`<a class="tb-btn" href="${designerUrl(p.app_id, next.page_no)}" title="Next page: ${next.page_no}. ${next.name}">${bicon('right')}<span class="sr-only">Next page</span></a>` : html`<span class="tb-btn" aria-disabled="true">${bicon('right')}</span>`}
468
+ ${next ? html`<a class="tb-btn" href="${designerUrl(p.app_id, next.page_no)}" title="${tx('pd.next_title', { n: next.page_no, name: next.name })}">${bicon('right')}<span class="sr-only">${tx('pd.next')}</span></a>` : html`<span class="tb-btn" aria-disabled="true">${bicon('right')}</span>`}
459
469
  </div>
460
- <div class="tb-group tb-undo" role="group" aria-label="History">${tbForm('undo', 'undo', 'Undo', h.undo)}${tbForm('redo', 'redo', 'Redo', h.redo)}</div>
470
+ <div class="tb-group tb-undo" role="group" aria-label="${tx('pd.history')}">${tbForm('undo', 'undo', tx('pd.undo'), tx('pd.nothing_undo'), h.undo)}${tbForm('redo', 'redo', tx('pd.redo'), tx('pd.nothing_redo'), h.redo)}</div>
461
471
  <div class="tb-group tb-menus">${createMenu}${utilMenu}</div>
462
472
  <div class="tb-group tb-main">
463
- ${hasForm ? html`<button class="btn btn-sm tb-save" form="${formId}" title="Save the properties">${bicon('save')}<span>Save</span></button>` : ''}
464
- <a class="btn btn-sm btn-run" href="/a/${p.alias}/${p.page_no}" target="_blank" rel="noopener" title="Run page ${p.page_no}">${icon('play')}<span>Run</span></a>
473
+ ${hasForm ? html`<button class="btn btn-sm tb-save" form="${formId}" title="${tx('pd.save_title')}">${bicon('save')}<span>${tx('common.save')}</span></button>` : ''}
474
+ <a class="btn btn-sm btn-run" href="/a/${p.alias}/${p.page_no}" target="_blank" rel="noopener" title="${tx('pd.run_title', { n: p.page_no })}">${icon('play')}<span>${tx('common.run')}</span></a>
465
475
  </div>`;
466
476
 
467
477
  const pane = newKind || sel ? 'pd-p-props' : 'pd-p-layout';
468
- const main = html`<h1 class="sr-only">Page designer: page ${p.page_no}, ${p.name}</h1>
469
- <div class="pd" data-tabs="pd-panes" data-tabs-media="(max-width: 1023px)" aria-label="Page designer">
470
- <section class="pd-pane pd-left tab-panel" id="pd-p-tree" data-tab><h2 class="tab-title pd-pane-title"><span>Tree</span></h2>${left}</section>
471
- <section class="pd-pane pd-center tab-panel" id="pd-p-layout" data-tab${pane === 'pd-p-layout' ? raw(' data-tab-active') : ''}><h2 class="tab-title pd-pane-title"><span>Layout</span></h2>${center}</section>
472
- <section class="pd-pane pd-right tab-panel" id="pd-p-props" data-tab${pane === 'pd-p-props' ? raw(' data-tab-active') : ''}><h2 class="tab-title pd-pane-title"><span>Properties</span></h2>${right}</section>
478
+ const main = html`<h1 class="sr-only">${tx('pd.h1', { n: p.page_no, name: p.name })}</h1>
479
+ <div class="pd" data-tabs="pd-panes" data-tabs-media="(max-width: 1023px)" aria-label="${tx('pd.designer')}">
480
+ <section class="pd-pane pd-left tab-panel" id="pd-p-tree" data-tab><h2 class="tab-title pd-pane-title"><span>${tx('pd.pane.tree')}</span></h2>${left}</section>
481
+ <section class="pd-pane pd-center tab-panel" id="pd-p-layout" data-tab${pane === 'pd-p-layout' ? raw(' data-tab-active') : ''}><h2 class="tab-title pd-pane-title"><span>${tx('pd.layout')}</span></h2>${center}</section>
482
+ <section class="pd-pane pd-right tab-panel" id="pd-p-props" data-tab${pane === 'pd-p-props' ? raw(' data-tab-active') : ''}><h2 class="tab-title pd-pane-title"><span>${tx('pd.properties')}</span></h2>${right}</section>
473
483
  </div>`;
474
- return send(reply, s, shell(s, `Page ${p.page_no}`, [['App Builder', BASE], [p.app_name, `${BASE}/apps/${p.app_id}`], [`Page ${p.page_no}: ${p.name}`]], main, 'apps', { toolbar, full: true }));
484
+ return send(reply, s, shell(s, tx('pd.page_n_title', { n: p.page_no }), [['App Builder', BASE], [p.app_name, `${BASE}/apps/${p.app_id}`], [tx('pd.page_title', { n: p.page_no, name: p.name })]], main, 'apps', { toolbar, full: true }));
475
485
  }
476
486
 
477
487
  // (sprint 59) Quick edit from the developer toolbar: an item or button of the page by its name
@@ -503,7 +513,7 @@ export async function designerRoutes(app: FastifyInstance) {
503
513
  if (before && before.page_no !== Number(b.page_no))
504
514
  for (const t of ['builder_lock', 'dev_comment'])
505
515
  await owner.query(`update meta.${t} set page_no = $3 where app_id = $1 and page_no = $2`, [before.app_id, before.page_no, Number(b.page_no)]);
506
- flash(s, 'Page saved.');
516
+ flash(s, tr(s)('pd.page_saved'));
507
517
  } catch (e) {
508
518
  flash(s, (e as Error).message, 'error');
509
519
  }
@@ -515,7 +525,7 @@ export async function designerRoutes(app: FastifyInstance) {
515
525
  if (!s) return;
516
526
  const p = await owner.one('delete from meta.page where id = $1 returning app_id, page_no', [req.params.pid]);
517
527
  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]);
518
- flash(s, 'Page deleted.');
528
+ flash(s, tr(s)('pd.page_deleted'));
519
529
  return back(reply, s, p ? `${BASE}/apps/${p.app_id}` : BASE);
520
530
  });
521
531
 
@@ -526,7 +536,7 @@ export async function designerRoutes(app: FastifyInstance) {
526
536
  if (!PAGE_KINDS.includes(kind)) return reply.code(404).send('Unknown component type');
527
537
  try {
528
538
  const id = await saveComponent(kind, 'page_id', pid, cid, req.body ?? {});
529
- flash(s, `${COMPONENTS[kind].label} saved.`);
539
+ flash(s, tr(s).dyn(`pd.saved.${kind}`, `${COMPONENTS[kind].label} saved.`));
530
540
  return back(reply, s, `${BASE}/pages/${pid}?c=${kind}-${id}`);
531
541
  } catch (e) {
532
542
  flash(s, (e as Error).message, 'error');
@@ -540,7 +550,7 @@ export async function designerRoutes(app: FastifyInstance) {
540
550
  const { pid, kind, cid } = req.params;
541
551
  if (!PAGE_KINDS.includes(kind)) return reply.code(404).send('Unknown component type');
542
552
  await owner.query(`delete from ${COMPONENTS[kind].table} where id = $1 and page_id = $2`, [cid, pid]);
543
- flash(s, `${COMPONENTS[kind].label} deleted.`);
553
+ flash(s, tr(s).dyn(`pd.deleted.${kind}`, `${COMPONENTS[kind].label} deleted.`));
544
554
  return back(reply, s, `${BASE}/pages/${pid}`);
545
555
  });
546
556
  }
@@ -64,7 +64,7 @@ export async function diagnosticsRoutes(app: FastifyInstance) {
64
64
  for (const [k, v] of Object.entries(extra)) q.set(k, v);
65
65
  return q.toString();
66
66
  };
67
- const main = html`${appHeader(a, 'activity')}
67
+ const main = html`${appHeader(a, 'activity', s)}
68
68
  <div class="columns">
69
69
  ${region('Debug level', html`
70
70
  <p class="muted u-mt0">${a.debug_level
@@ -131,7 +131,7 @@ export async function diagnosticsRoutes(app: FastifyInstance) {
131
131
  )
132
132
  ).rows;
133
133
  const slowest = Math.max(0, ...entries.map((e) => e.dur ?? 0));
134
- const main = html`${appHeader(a, 'activity')}
134
+ const main = html`${appHeader(a, 'activity', s)}
135
135
  ${region(`Page view ${v.id}`, html`
136
136
  <div class="stat-grid">
137
137
  <div class="stat"><b>${v.ms ?? '?'} ms</b><span>total time</span></div>