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
@@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
2
2
  import { html } from '../html.ts';
3
3
  import { documentShell } from '../layout.ts';
4
4
  import { BASE, THEME_COOKIE, validTheme } from './ui.ts';
5
+ import { cookieLanguage, translatorFor } from './i18n.ts';
5
6
 
6
7
  // (sprint 86) The builder's error pages: a browser that opens an address under /builder that
7
8
  // doesn't exist (or an application or page that doesn't, or not for this developer), or that
@@ -14,24 +15,21 @@ export const wantsHtml = (req: FastifyRequest) =>
14
15
 
15
16
  const isBuilder = (url: string) => url === BASE || url.startsWith(`${BASE}/`) || url.startsWith(`${BASE}?`);
16
17
 
17
- const TEXTS: Record<number, { title: string; text: string }> = {
18
- 404: { title: 'Page not found', text: 'There is nothing at this address in the App Builder. The application or page may have been deleted, or belongs to a workspace you are not a member of.' },
19
- 500: { title: 'Something went wrong', text: 'The App Builder could not show this page because of an error on the server. It has been logged; try again, or go back.' },
20
- };
21
-
22
- /** The error page for a status (404, or 500 for any server error). */
18
+ /** The error page for a status (404, or 500 for any server error), in the builder language of this device (i18n.ts). */
23
19
  export function builderErrorPage(req: FastifyRequest, status: number) {
24
- const { title, text } = TEXTS[status >= 500 ? 500 : 404];
20
+ const tx = translatorFor(cookieLanguage(req));
21
+ const server = status >= 500;
22
+ const title = tx(server ? 'error.server_title' : 'error.not_found_title');
25
23
  const theme = validTheme((req.cookies as Record<string, string> | undefined)?.[THEME_COOKIE]) ?? 'auto';
26
24
  return documentShell(
27
- `${title} · App Builder`,
28
- html`<main class="t-error" id="main"><div class="card"><h1>${title}</h1><p>${text}</p>
29
- <p class="muted">${status >= 500 ? 'Error' : 'Not found'}: <code>${req.url.split('?')[0].slice(0, 200)}</code></p>
30
- <p><a class="btn btn-hot" href="${BASE}">App Builder home</a></p></div></main>`,
25
+ `${title} · ${tx('rail.app_builder')}`,
26
+ html`<main class="t-error" id="main"><div class="card"><h1>${title}</h1><p>${tx(server ? 'error.server_text' : 'error.not_found_text')}</p>
27
+ <p class="muted">${tx(server ? 'error.label_error' : 'error.label_not_found')}: <code>${req.url.split('?')[0].slice(0, 200)}</code></p>
28
+ <p><a class="btn btn-hot" href="${BASE}">${tx('error.home')}</a></p></div></main>`,
31
29
  'login-body',
32
30
  {},
33
31
  '',
34
- { theme },
32
+ { theme, lang: tx.lang },
35
33
  );
36
34
  }
37
35
 
@@ -57,7 +57,7 @@ export async function feedbackRoutes(app: FastifyInstance) {
57
57
  </div>
58
58
  <div class="buttons"><button class="btn btn-sm btn-hot">Save</button></div></form>
59
59
  </li>`)}</ul>` : html`<p class="muted">No feedback${status || kind ? ' with these filters' : ' yet'}.</p>`}`)}`;
60
- const main = html`${appHeader(a, 'activity')}<div class="ide-body">${body}</div>`;
60
+ const main = html`${appHeader(a, 'activity', s)}<div class="ide-body">${body}</div>`;
61
61
  return send(reply, s, shell(s, `${a.name} feedback`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Activity', `${BASE}/apps/${a.id}/activity`], ['Feedback']], main));
62
62
  });
63
63
 
@@ -142,7 +142,7 @@ export async function globalizationRoutes(app: FastifyInstance) {
142
142
  const done = texts.filter((t) => map.has(t.text)).length;
143
143
  const all = [...new Set([a.language, ...langs])];
144
144
 
145
- const main = html`${appHeader(a, 'shared')}
145
+ const main = html`${appHeader(a, 'shared', s)}
146
146
  <p class="muted u-mt0"><a href="${BASE}/apps/${a.id}/shared">Shared Components</a> / Globalization</p>
147
147
  <div class="columns">
148
148
  ${region('Languages', html`<ul class="checklist">
@@ -7,6 +7,7 @@ import { crossAppLinks } from './cross-app.ts';
7
7
  import type { Session } from '../session.ts';
8
8
  import { isAdmin } from './locks.ts';
9
9
  import { currentWorkspace, inWorkspaceSql, workspaceAdmin } from './workspaces.ts';
10
+ import { tr, translatorFor, type BuilderKey, type BuilderTranslate } from './i18n.ts';
10
11
 
11
12
  // The workspace pages of the builder, laid out like APEX's App Builder: the
12
13
  // home page (tiles, a searchable list of applications as a report or as
@@ -30,56 +31,56 @@ export function rememberApp(s: Session, appId: number) {
30
31
  s.state.__RECENT = [appId, ...recentIds(s).filter((id) => id !== appId)].slice(0, RECENT_MAX).join(',');
31
32
  }
32
33
 
33
- /** "3 minutes ago", "2 days ago", "1.7 years ago", as APEX shows it. */
34
- export function ago(at: Date | string | null | undefined, now = Date.now()) {
34
+ /** "3 minutes ago", "2 days ago", "1.7 years ago", as APEX shows it (in the builder language of `tx`: ago.* in i18n/en.ts). */
35
+ export function ago(at: Date | string | null | undefined, now = Date.now(), tx: BuilderTranslate = translatorFor('en')) {
35
36
  if (!at) return '';
36
37
  const sec = Math.max(0, (now - new Date(at).getTime()) / 1000);
37
- const units: [number, string][] = [[365 * 86400, 'year'], [30 * 86400, 'month'], [7 * 86400, 'week'], [86400, 'day'], [3600, 'hour'], [60, 'minute']];
38
+ const units: [number, 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute'][] = [[365 * 86400, 'year'], [30 * 86400, 'month'], [7 * 86400, 'week'], [86400, 'day'], [3600, 'hour'], [60, 'minute']];
38
39
  for (const [size, unit] of units) {
39
40
  if (sec >= size) {
40
41
  const n = sec / size;
41
42
  const v = unit === 'year' && n < 10 ? Math.round(n * 10) / 10 : Math.round(n);
42
- return `${v} ${unit}${v === 1 ? '' : 's'} ago`;
43
+ return tx(v === 1 ? `ago.${unit}` : `ago.${unit}s`, { n: tx.lang === 'en' ? v : String(v).replace('.', ',') });
43
44
  }
44
45
  }
45
- return 'just now';
46
+ return tx('ago.now');
46
47
  }
47
48
 
48
49
  /** (116) the App Builder list's working copies count and lock state */
49
- const copiesText = (n: number) => `${n} working cop${n === 1 ? 'y' : 'ies'}`;
50
- const lockedText = (by: string) => `Locked by ${by}`;
50
+ const copiesText = (tx: BuilderTranslate, n: number) => tx(n === 1 ? 'ab.copies_one' : 'ab.copies', { n });
51
+ const lockedText = (tx: BuilderTranslate, by: string) => tx('ab.locked_by', { user: by });
51
52
 
52
53
  const appIcon = (a: { id: number; name: string }, cls = '') =>
53
54
  html`<span class="app-icon app-color-${a.id % APP_COLORS}${cls ? ` ${cls}` : ''}" aria-hidden="true">${a.name.slice(0, 1).toUpperCase()}</span>`;
54
55
 
55
56
  /** The four tiles at the top of the App Builder. */
56
- const tiles = () => html`<nav class="ab-tiles" aria-label="App Builder">
57
- ${[
58
- ['create', 'plus', 'Create', 'Create a new application in this workspace.'],
59
- ['import', 'upload', 'Import', 'Import an exported application into this workspace.'],
60
- ['dashboard', 'activity', 'Dashboard', 'View workspace usage, activity and sign-in problems.'],
61
- ['utilities', 'settings', 'Workspace Utilities', 'Users, identity providers, developers and the SQL Workshop.'],
62
- ].map(([path, ic, title, text]) => html`<a class="ab-tile" href="${BASE}/${path}"><span class="ab-tile-icon">${icon(ic)}</span><strong>${title}</strong><span>${text}</span></a>`)}
57
+ const tiles = (tx: BuilderTranslate) => html`<nav class="ab-tiles" aria-label="${tx('rail.app_builder')}">
58
+ ${([
59
+ ['create', 'plus', tx('ab.tile.create'), tx('ab.tile.create_text')],
60
+ ['import', 'upload', tx('ab.tile.import'), tx('ab.tile.import_text')],
61
+ ['dashboard', 'activity', tx('ab.tile.dashboard'), tx('ab.tile.dashboard_text')],
62
+ ['utilities', 'settings', tx('ab.tile.utilities'), tx('ab.tile.utilities_text')],
63
+ ] as const).map(([path, ic, title, text]) => html`<a class="ab-tile" href="${BASE}/${path}"><span class="ab-tile-icon">${icon(ic)}</span><strong>${title}</strong><span>${text}</span></a>`)}
63
64
  </nav>`;
64
65
 
65
66
  /** The side column: About, Recent, Tasks. */
66
- function aside(recent: { id: number; name: string }[], about: string, tasks: [string, string][]) {
67
- return html`<aside class="ab-side" aria-label="About, recent and tasks">
68
- <section><h2>About</h2><p>${about}</p>
69
- <p><a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md" target="_blank" rel="noopener">Learn more …</a></p></section>
70
- ${recent.length ? html`<section><h2>Recent</h2><ul class="ab-links">${recent.map((a) => html`<li><a href="${BASE}/apps/${a.id}">${a.name} - ${a.id}</a></li>`)}</ul></section>` : ''}
71
- <section><h2>Tasks</h2><ul class="ab-links ab-tasks">${tasks.map(([label, href]) => html`<li><a href="${href}">${label}${icon('chevron')}</a></li>`)}</ul></section>
67
+ function aside(tx: BuilderTranslate, recent: { id: number; name: string }[], about: string, tasks: [string, string][]) {
68
+ return html`<aside class="ab-side" aria-label="${tx('ab.aside')}">
69
+ <section><h2>${tx('common.about')}</h2><p>${about}</p>
70
+ <p><a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md" target="_blank" rel="noopener">${tx('common.learn_more')}</a></p></section>
71
+ ${recent.length ? html`<section><h2>${tx('ab.recent')}</h2><ul class="ab-links">${recent.map((a) => html`<li><a href="${BASE}/apps/${a.id}">${a.name} - ${a.id}</a></li>`)}</ul></section>` : ''}
72
+ <section><h2>${tx('common.tasks')}</h2><ul class="ab-links ab-tasks">${tasks.map(([label, href]) => html`<li><a href="${href}">${label}${icon('chevron')}</a></li>`)}</ul></section>
72
73
  </aside>`;
73
74
  }
74
75
 
75
- const TASKS: [string, string][] = [
76
- ['Create application', `${BASE}/create`],
77
- ['Create application from a file', `${BASE}/create/file`],
78
- ['Import application', `${BASE}/import`],
79
- ['Manage users', `${BASE}/users`],
80
- ['Identity providers', `${BASE}/users/providers`],
81
- ['SQL Workshop', `${BASE}/sql`],
82
- ['Developers', `${BASE}/developers`],
76
+ const TASKS: [BuilderKey, string][] = [
77
+ ['ab.task.create', `${BASE}/create`],
78
+ ['ab.task.create_file', `${BASE}/create/file`],
79
+ ['ab.task.import', `${BASE}/import`],
80
+ ['ab.task.users', `${BASE}/users`],
81
+ ['ab.task.providers', `${BASE}/users/providers`],
82
+ ['rail.sql_workshop', `${BASE}/sql`],
83
+ ['admin.developers', `${BASE}/developers`],
83
84
  ];
84
85
 
85
86
  async function recentApps(s: Session) {
@@ -100,6 +101,7 @@ export async function homeRoutes(app: FastifyInstance) {
100
101
  const s = await developer(req, reply);
101
102
  if (!s) return;
102
103
  if (s.workspace?.builder_home === 'home' && req.url === BASE) return reply.redirect(`${BASE}/home`);
104
+ const tx = tr(s);
103
105
  const q = String(req.query.q ?? '').trim().slice(0, 100);
104
106
  // the view and sort are remembered for the session
105
107
  if (req.query.view === 'grid' || req.query.view === 'report') s.state.__BVIEW = req.query.view;
@@ -141,64 +143,64 @@ export async function homeRoutes(app: FastifyInstance) {
141
143
  html`<th scope="col"${cls ? raw(` class="${cls}"`) : ''}${sort === key ? raw(' aria-sort="' + (key === 'pages' || key === 'updated' ? 'descending' : 'ascending') + '"') : ''}><a href="${link({ sort: key })}">${label}${sort === key ? html`<span class="ab-sorted" aria-hidden="true">${key === 'pages' || key === 'updated' ? '↓' : '↑'}</span>` : ''}</a></th>`;
142
144
  const rows = apps.rows;
143
145
  const list = rows.length === 0
144
- ? html`<p class="ab-empty">${total.n ? html`No application ${group ? html`in the group ${group.name}${q ? html` matches “${q}”` : ''}` : html`matches “${q}”`}. <a href="${BASE}/apps?q=&amp;group=">Show all</a>` : html`No applications yet. <a href="${BASE}/create">Create one</a> or <a href="${BASE}/import">import one</a>.`}</p>`
146
+ ? html`<p class="ab-empty">${total.n ? html`${group ? (q ? tx('ab.none_group_q', { group: group.name, q }) : tx('ab.none_group', { group: group.name })) : tx('ab.none_q', { q })} <a href="${BASE}/apps?q=&amp;group=">${tx('ab.show_all')}</a>` : tx.rich('ab.none_yet', { create: html`<a href="${BASE}/create">${tx('ab.create_one')}</a>`, import: html`<a href="${BASE}/import">${tx('ab.import_one')}</a>` })}</p>`
145
147
  : view === 'grid'
146
148
  ? html`<div class="ab-cards">${rows.map((a) => html`<article class="ab-card" data-filter-row>
147
149
  <a class="ab-card-main" href="${BASE}/apps/${a.id}">${appIcon(a)}<span><strong>${a.name}</strong><small>${a.id} · /a/${a.alias}</small></span></a>
148
- <div class="ab-card-meta">${a.group_name ? html`<span class="tag">${a.group_name}</span>` : ''}<span>${a.pages} pages</span><span>${a.views} views today</span><span>${ago(a.updated_at)}${a.updated_by ? html` by <span class="ab-by">${a.updated_by}</span>` : ''}</span></div>
149
- ${a.copies || a.locked_by ? html`<div class="ab-card-badges">${a.copies ? html`<a class="tag" href="${BASE}/apps/${a.id}/working-copies">${icon('layers')} ${copiesText(a.copies)}</a>` : ''}${a.locked_by ? html`<span class="tag ab-locked" title="${lockedText(a.locked_by)}">${icon('lock')} <span class="ab-by">${lockedText(a.locked_by)}</span></span>` : ''}</div>` : ''}
150
+ <div class="ab-card-meta">${a.group_name ? html`<span class="tag">${a.group_name}</span>` : ''}<span>${tx(a.pages === 1 ? 'ab.pages_one' : 'ab.pages', { n: a.pages })}</span><span>${tx('ab.views_today', { n: a.views })}</span><span>${ago(a.updated_at, Date.now(), tx)}${a.updated_by ? html` ${tx('ab.by')} <span class="ab-by">${a.updated_by}</span>` : ''}</span></div>
151
+ ${a.copies || a.locked_by ? html`<div class="ab-card-badges">${a.copies ? html`<a class="tag" href="${BASE}/apps/${a.id}/working-copies">${icon('layers')} ${copiesText(tx, a.copies)}</a>` : ''}${a.locked_by ? html`<span class="tag ab-locked" title="${lockedText(tx, a.locked_by)}">${icon('lock')} <span class="ab-by">${lockedText(tx, a.locked_by)}</span></span>` : ''}</div>` : ''}
150
152
  <div class="ab-card-actions">
151
- <a class="tb-btn" href="${BASE}/apps/${a.id}" title="Edit ${a.name}">${icon('edit')}<span class="sr-only">Edit ${a.name}</span></a>
152
- <a class="tb-btn" href="/a/${a.alias}" target="_blank" rel="noopener" title="Run ${a.name}">${icon('play')}<span class="sr-only">Run ${a.name}</span></a>
153
+ <a class="tb-btn" href="${BASE}/apps/${a.id}" title="${tx('ab.edit', { name: a.name })}">${icon('edit')}<span class="sr-only">${tx('ab.edit', { name: a.name })}</span></a>
154
+ <a class="tb-btn" href="/a/${a.alias}" target="_blank" rel="noopener" title="${tx('ab.run', { name: a.name })}">${icon('play')}<span class="sr-only">${tx('ab.run', { name: a.name })}</span></a>
153
155
  </div></article>`)}</div>`
154
156
  : html`<div class="table-wrap ab-table"><table class="report ab-apps">
155
- <caption class="sr-only">Applications</caption>
156
- <thead><tr>${th('id', 'Application', 'num')}${th('name', 'Name')}${th('alias', 'Alias')}${th('pages', 'Pages', 'num')}<th scope="col" class="num">Views (24h)</th><th scope="col">Sign-in</th>${th('updated', 'Updated')}<th scope="col">Updated by</th><th scope="col" class="num">Working copies</th><th scope="col" class="ab-actions-col">Actions</th></tr></thead>
157
+ <caption class="sr-only">${tx('ab.col.apps')}</caption>
158
+ <thead><tr>${th('id', tx('ab.col.app'), 'num')}${th('name', tx('ab.col.name'))}${th('alias', tx('ab.col.alias'))}${th('pages', tx('ab.col.pages'), 'num')}<th scope="col" class="num">${tx('ab.col.views')}</th><th scope="col">${tx('ab.col.signin')}</th>${th('updated', tx('ab.col.updated'))}<th scope="col">${tx('ab.col.updated_by')}</th><th scope="col" class="num">${tx('ab.col.copies')}</th><th scope="col" class="ab-actions-col">${tx('ab.col.actions')}</th></tr></thead>
157
159
  <tbody>${rows.map((a) => html`<tr data-filter-row>
158
160
  <td class="num">${a.id}</td>
159
- <td class="ab-name"><a href="${BASE}/apps/${a.id}">${appIcon(a)}<span>${a.name}</span></a>${a.locked_by ? html`<span class="ab-lock" role="img" aria-label="${lockedText(a.locked_by)}" title="${lockedText(a.locked_by)}">${icon('lock')}</span>` : ''}</td>
161
+ <td class="ab-name"><a href="${BASE}/apps/${a.id}">${appIcon(a)}<span>${a.name}</span></a>${a.locked_by ? html`<span class="ab-lock" role="img" aria-label="${lockedText(tx, a.locked_by)}" title="${lockedText(tx, a.locked_by)}">${icon('lock')}</span>` : ''}</td>
160
162
  <td>${a.alias}${a.group_name ? html` <span class="tag">${a.group_name}</span>` : ''}</td>
161
163
  <td class="num">${a.pages}</td>
162
164
  <td class="num">${a.views}</td>
163
- <td>${a.authentication === 'none' ? 'Public' : a.authentication === 'header' ? 'HTTP header' : a.authentication === 'database' ? 'Database accounts' : a.authentication === 'custom' ? 'Custom' : 'App users'}</td>
164
- <td title="${a.updated_at ? new Date(a.updated_at).toISOString().slice(0, 16).replace('T', ' ') : ''}">${ago(a.updated_at)}</td>
165
+ <td>${tx(a.authentication === 'none' ? 'ab.auth.none' : a.authentication === 'header' ? 'ab.auth.header' : a.authentication === 'database' ? 'ab.auth.database' : a.authentication === 'custom' ? 'ab.auth.custom' : 'ab.auth.app_users')}</td>
166
+ <td title="${a.updated_at ? new Date(a.updated_at).toISOString().slice(0, 16).replace('T', ' ') : ''}">${ago(a.updated_at, Date.now(), tx)}</td>
165
167
  <td class="ab-by">${a.updated_by ?? ''}</td>
166
- <td class="num">${a.copies ? html`<a href="${BASE}/apps/${a.id}/working-copies" aria-label="${copiesText(a.copies)} of ${a.name}">${a.copies}</a>` : '0'}</td>
168
+ <td class="num">${a.copies ? html`<a href="${BASE}/apps/${a.id}/working-copies" aria-label="${tx('ab.copies_of', { copies: copiesText(tx, a.copies), name: a.name })}">${a.copies}</a>` : '0'}</td>
167
169
  <td class="ab-actions">
168
- <a class="tb-btn" href="${BASE}/apps/${a.id}" title="Edit ${a.name}">${icon('edit')}<span class="sr-only">Edit ${a.name}</span></a>
169
- <a class="tb-btn" href="/a/${a.alias}" target="_blank" rel="noopener" title="Run ${a.name}">${icon('play')}<span class="sr-only">Run ${a.name}</span></a>
170
+ <a class="tb-btn" href="${BASE}/apps/${a.id}" title="${tx('ab.edit', { name: a.name })}">${icon('edit')}<span class="sr-only">${tx('ab.edit', { name: a.name })}</span></a>
171
+ <a class="tb-btn" href="/a/${a.alias}" target="_blank" rel="noopener" title="${tx('ab.run', { name: a.name })}">${icon('play')}<span class="sr-only">${tx('ab.run', { name: a.name })}</span></a>
170
172
  </td></tr>`)}</tbody></table></div>`;
171
173
  const viewButton = (v: View, ic: string, label: string) =>
172
174
  html`<a class="tb-btn" href="${link({ view: v })}" title="${label}"${view === v ? raw(' aria-current="true"') : ''}>${icon(ic)}<span class="sr-only">${label}</span></a>`;
173
175
  const main = html`<div class="ab-home">
174
176
  <div class="ab-main">
175
- <h1 class="sr-only">App Builder</h1>
177
+ <h1 class="sr-only">${tx('rail.app_builder')}</h1>
176
178
  ${s.workspace?.message ? html`<div class="alert alert-info ab-message" role="status">${icon('bell')}<div><strong>${s.workspace.name}</strong><p>${s.workspace.message}</p></div></div>` : ''}
177
- ${tiles()}
179
+ ${tiles(tx)}
178
180
  <div class="ab-toolbar">
179
181
  <form class="ab-search" method="get" action="${BASE}/apps" role="search">
180
- <label class="sr-only" for="ab-q">Search applications</label>${icon('search')}
181
- <input id="ab-q" name="q" type="search" value="${q}" placeholder="Search" autocomplete="off" data-filter-list=".ab-home [data-filter-row]">
182
+ <label class="sr-only" for="ab-q">${tx('ab.search_label')}</label>${icon('search')}
183
+ <input id="ab-q" name="q" type="search" value="${q}" placeholder="${tx('common.search')}" autocomplete="off" data-filter-list=".ab-home [data-filter-row]">
182
184
  </form>
183
- <div class="ab-views" role="group" aria-label="View">${viewButton('grid', 'grid', 'Cards')}${viewButton('report', 'list', 'Report')}</div>
184
- ${groups.length ? html`<form method="get" action="${BASE}/apps" class="ab-group"><label class="sr-only" for="ab-group">Application group</label>
185
- <select id="ab-group" name="group" data-autosubmit><option value="">All groups</option>${groups.map((g) => html`<option value="${g.id}"${group?.id === g.id ? raw(' selected') : ''}>${g.name}</option>`)}</select>
186
- <noscript><button class="btn btn-sm">Show</button></noscript></form>` : ''}
187
- <details class="menu ab-menu"><summary class="btn">Actions ${icon('chevron')}</summary>
185
+ <div class="ab-views" role="group" aria-label="${tx('ab.view')}">${viewButton('grid', 'grid', tx('ab.view_cards'))}${viewButton('report', 'list', tx('ab.view_report'))}</div>
186
+ ${groups.length ? html`<form method="get" action="${BASE}/apps" class="ab-group"><label class="sr-only" for="ab-group">${tx('ab.group')}</label>
187
+ <select id="ab-group" name="group" data-autosubmit><option value="">${tx('ab.all_groups')}</option>${groups.map((g) => html`<option value="${g.id}"${group?.id === g.id ? raw(' selected') : ''}>${g.name}</option>`)}</select>
188
+ <noscript><button class="btn btn-sm">${tx('common.show')}</button></noscript></form>` : ''}
189
+ <details class="menu ab-menu"><summary class="btn">${tx('ab.actions')} ${icon('chevron')}</summary>
188
190
  <div class="menu-panel" role="menu">
189
- <div class="menu-section"><span class="small muted">Sort by</span>
190
- ${(Object.keys(SORTS) as Sort[]).map((k) => html`<a role="menuitem" href="${link({ sort: k })}"${sort === k ? raw(' aria-current="true"') : ''}>${{ name: 'Name', id: 'Application ID', alias: 'Alias', pages: 'Most pages', updated: 'Recently updated' }[k]}</a>`)}</div>
191
+ <div class="menu-section"><span class="small muted">${tx('ab.sort_by')}</span>
192
+ ${(Object.keys(SORTS) as Sort[]).map((k) => html`<a role="menuitem" href="${link({ sort: k })}"${sort === k ? raw(' aria-current="true"') : ''}>${tx(`ab.sort.${k}`)}</a>`)}</div>
191
193
  <div class="menu-section menu-links">
192
- <a role="menuitem" href="${BASE}/import">${icon('upload')} Import application</a>
193
- <a role="menuitem" href="${BASE}/dashboard">${icon('activity')} Workspace dashboard</a>
194
+ <a role="menuitem" href="${BASE}/import">${icon('upload')} ${tx('ab.task.import')}</a>
195
+ <a role="menuitem" href="${BASE}/dashboard">${icon('activity')} ${tx('ab.ws_dashboard')}</a>
194
196
  </div>
195
197
  </div></details>
196
- <span class="ab-count muted" aria-live="polite">${rows.length === total.n ? `${total.n} application${total.n === 1 ? '' : 's'}` : `${rows.length} of ${total.n}`}</span>
197
- <a class="btn btn-hot ab-create" href="${BASE}/create">Create</a>
198
+ <span class="ab-count muted" aria-live="polite">${rows.length === total.n ? tx(total.n === 1 ? 'ab.count_one' : 'ab.count', { n: total.n }) : tx('ab.count_of', { n: rows.length, total: total.n })}</span>
199
+ <a class="btn btn-hot ab-create" href="${BASE}/create">${tx('ab.tile.create')}</a>
198
200
  </div>
199
201
  ${list}
200
202
  </div>
201
- ${aside(recent, 'The App Builder creates, edits and runs the applications of this workspace: pages and regions on your PostgreSQL tables, with sign-in, roles and row level security.', TASKS)}
203
+ ${aside(tx, recent, tx('ab.about'), TASKS.map(([key, href]): [string, string] => [tx(key), href]))}
202
204
  </div>`;
203
205
  return send(reply, s, shell(s, 'App Builder', [['App Builder']], main));
204
206
  };