pgkiln 0.53.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.
- package/SECURITY.md +2 -0
- package/db/migrations/127_builder_language.sql +16 -0
- package/docs/apex-feature-parity.md +7 -7
- package/docs/guide/03-builder.md +62 -8
- package/docs/guide/12-development.md +50 -3
- package/examples/gallery/README.md +37 -6
- package/examples/gallery/gallery.json +23 -1
- package/examples/gallery/samples/sample-calendar.sql +172 -0
- package/examples/gallery/samples/sample-cards.sql +157 -0
- package/examples/gallery/samples/sample-charts.sql +265 -0
- package/examples/gallery/samples/sample-dynamic-actions.sql +177 -0
- package/examples/gallery/samples/sample-grids.sql +151 -0
- package/examples/gallery/samples/sample-reporting.sql +151 -0
- package/examples/gallery/samples/ux-patterns.sql +295 -0
- package/examples/gallery/utilities/announcements.json +24 -0
- package/examples/gallery/utilities/lookup-lists.json +28 -0
- package/examples/gallery/utilities/release-notes.json +41 -0
- package/package.json +1 -1
- package/public/app.css +2 -0
- package/public/builder.css +39 -1
- package/public/worksheet.css +3 -2
- package/src/builder/advisor.ts +1 -1
- package/src/builder/ai-agents.ts +5 -5
- package/src/builder/ai-builder.ts +3 -3
- package/src/builder/api.ts +1 -1
- package/src/builder/autorest.ts +2 -2
- package/src/builder/availability.ts +1 -1
- package/src/builder/backups.ts +1 -1
- package/src/builder/changelog.ts +1 -1
- package/src/builder/data-modeler.ts +4 -4
- package/src/builder/data-security.ts +1 -1
- package/src/builder/dataload.ts +1 -1
- package/src/builder/datapump.ts +1 -1
- package/src/builder/ddl-tools.ts +2 -2
- package/src/builder/designer.ts +134 -124
- package/src/builder/diagnostics.ts +2 -2
- package/src/builder/errors.ts +10 -12
- package/src/builder/feedback.ts +1 -1
- package/src/builder/gallery.ts +94 -47
- package/src/builder/globalization.ts +1 -1
- package/src/builder/home.ts +59 -57
- package/src/builder/i18n/de.ts +675 -0
- package/src/builder/i18n/en.ts +678 -0
- package/src/builder/i18n/es.ts +675 -0
- package/src/builder/i18n/fr.ts +675 -0
- package/src/builder/i18n/nl.ts +675 -0
- package/src/builder/i18n/pt.ts +675 -0
- package/src/builder/i18n.ts +116 -0
- package/src/builder/language.ts +74 -0
- package/src/builder/launchpad.ts +13 -8
- package/src/builder/object-browser.ts +1 -1
- package/src/builder/plugins.ts +1 -1
- package/src/builder/querybuilder.ts +1 -1
- package/src/builder/quicksql.ts +1 -1
- package/src/builder/rest-modules.ts +2 -2
- package/src/builder/routes.ts +88 -66
- package/src/builder/sampledata.ts +1 -1
- package/src/builder/scheduling.ts +1 -1
- package/src/builder/scripts.ts +1 -1
- package/src/builder/search.ts +1 -1
- package/src/builder/shared.ts +5 -5
- package/src/builder/sql-charts.ts +2 -2
- package/src/builder/sql-monitor.ts +2 -2
- package/src/builder/sql-tools.ts +6 -0
- package/src/builder/sql-utilities.ts +3 -3
- package/src/builder/sql.ts +24 -22
- package/src/builder/static-files.ts +1 -1
- package/src/builder/subscriptions.ts +1 -1
- package/src/builder/supporting.ts +1 -1
- package/src/builder/table-editor.ts +1 -1
- package/src/builder/team.ts +7 -5
- package/src/builder/themeroller.ts +1 -1
- package/src/builder/top-sql.ts +1 -1
- package/src/builder/ui.ts +87 -77
- package/src/builder/unload.ts +1 -1
- package/src/builder/wizards.ts +1 -1
- package/src/builder/workflow-migrate.ts +1 -1
- package/src/builder/workingcopies.ts +2 -2
- package/src/builder/worksheet.ts +1 -1
- package/src/builder/workspace-home.ts +62 -59
- package/src/builder/workspaces.ts +2 -1
- package/src/runtime/charts.ts +4 -1
- package/src/runtime/regions.ts +8 -5
package/src/builder/errors.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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} ·
|
|
28
|
-
html`<main class="t-error" id="main"><div class="card"><h1>${title}</h1><p>${
|
|
29
|
-
<p class="muted">${
|
|
30
|
-
<p><a class="btn btn-hot" href="${BASE}"
|
|
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
|
|
package/src/builder/feedback.ts
CHANGED
|
@@ -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
|
|
package/src/builder/gallery.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import type { FastifyInstance } from 'fastify';
|
|
4
|
-
import { owner, ownerUrl } from '../db.ts';
|
|
4
|
+
import { owner, ownerUrl, type Client } from '../db.ts';
|
|
5
5
|
import { root } from '../env.ts';
|
|
6
6
|
import { html, raw } from '../html.ts';
|
|
7
7
|
import { icon } from '../icons.ts';
|
|
@@ -13,16 +13,21 @@ import { back, BASE, csrf, developer, flash, send, shell, type Req } from './ui.
|
|
|
13
13
|
import { currentWorkspace, inWorkspaceSql, placeApp } from './workspaces.ts';
|
|
14
14
|
import { workspacesChanged } from '../workspace-db.ts';
|
|
15
15
|
|
|
16
|
-
// The App Gallery (sprint 61; APEX: App Gallery with Sample, Starter and
|
|
16
|
+
// The App Gallery (sprint 61, sprint 120; APEX: App Gallery with Sample, Starter, Utility and
|
|
17
|
+
// Custom Apps).
|
|
17
18
|
//
|
|
18
|
-
// Sample apps applications shipped with pgkiln
|
|
19
|
-
//
|
|
19
|
+
// Sample apps applications shipped with pgkiln: an administrator installs one with its
|
|
20
|
+
// schema, data and demo account, either an example directory (`example`, as
|
|
21
|
+
// `pgkiln migrate --example`) or one SQL file (`file`: examples/*.sql or
|
|
22
|
+
// examples/gallery/samples/*.sql, run in one transaction as the owner);
|
|
20
23
|
// Starter apps blueprints shipped with pgkiln (examples/gallery/starters/): Install opens
|
|
21
24
|
// the blueprint's review, and the developer creates the application from it
|
|
22
25
|
// in the current workspace (a new role and schema, like every new application);
|
|
26
|
+
// Utility apps (sprint 120) blueprints too (examples/gallery/utilities/), of applications a
|
|
27
|
+
// workspace uses rather than learns from, installed the same way;
|
|
23
28
|
// Custom apps the workspace's boilerplate applications: Create application starts from one.
|
|
24
29
|
//
|
|
25
|
-
// The list is examples/gallery/gallery.json.
|
|
30
|
+
// The list is examples/gallery/gallery.json; requests only ever name a key of it.
|
|
26
31
|
|
|
27
32
|
interface Sample {
|
|
28
33
|
key: string;
|
|
@@ -44,64 +49,94 @@ interface Starter {
|
|
|
44
49
|
const GALLERY = join(root, 'examples', 'gallery');
|
|
45
50
|
const KEY = /^[a-z][a-z0-9-]{0,40}$/;
|
|
46
51
|
|
|
47
|
-
|
|
52
|
+
const list = <T>(v: unknown): T[] => (Array.isArray(v) ? v : []);
|
|
53
|
+
|
|
54
|
+
function catalog(): { samples: Sample[]; starters: Starter[]; utilities: Starter[] } {
|
|
48
55
|
try {
|
|
49
56
|
const c = JSON.parse(readFileSync(join(GALLERY, 'gallery.json'), 'utf8'));
|
|
50
|
-
return { samples:
|
|
57
|
+
return { samples: list(c.samples), starters: list(c.starters), utilities: list(c.utilities) };
|
|
51
58
|
} catch {
|
|
52
|
-
return { samples: [], starters: [] };
|
|
59
|
+
return { samples: [], starters: [], utilities: [] };
|
|
53
60
|
}
|
|
54
61
|
}
|
|
55
62
|
|
|
63
|
+
/** The blueprint files a kind of entry may name: starters/x.json or utilities/x.json. */
|
|
64
|
+
const BLUEPRINT_FILE = { starters: /^starters\/[a-z0-9-]+\.json$/, utilities: /^utilities\/[a-z0-9-]+\.json$/ } as const;
|
|
65
|
+
/** The SQL files a sample may name: examples/x.sql or examples/gallery/samples/x.sql. */
|
|
66
|
+
const SAMPLE_FILE = /^(samples\/)?[a-z0-9-]+\.sql$/;
|
|
67
|
+
|
|
56
68
|
/** A file of the gallery: only names from the catalogue, never a path from the request. */
|
|
57
69
|
const galleryFile = (rel: string) => readFileSync(join(rel.includes('/') ? GALLERY : join(root, 'examples'), rel), 'utf8');
|
|
58
70
|
|
|
59
|
-
type Tab = 'samples' | 'starters' | 'custom';
|
|
71
|
+
type Tab = 'samples' | 'starters' | 'utilities' | 'custom';
|
|
72
|
+
const TABS: readonly [Tab, string][] = [['samples', 'Sample Apps'], ['starters', 'Starter Apps'], ['utilities', 'Utility Apps'], ['custom', 'Custom Apps']];
|
|
60
73
|
|
|
61
74
|
export async function galleryRoutes(app: FastifyInstance) {
|
|
62
75
|
app.get(`${BASE}/gallery`, async (req: Req, reply) => {
|
|
63
76
|
const s = await developer(req, reply);
|
|
64
77
|
if (!s) return;
|
|
65
|
-
const tab: Tab = ([
|
|
78
|
+
const tab: Tab = TABS.map(([t]) => t).find((t) => t === req.query?.tab) ?? 'samples';
|
|
79
|
+
const q = typeof req.query?.q === 'string' ? req.query.q.trim().slice(0, 100) : '';
|
|
66
80
|
const admin = await isAdmin(s.username);
|
|
67
|
-
const
|
|
81
|
+
const ws = currentWorkspace(s);
|
|
82
|
+
const { samples, starters, utilities } = catalog();
|
|
83
|
+
// an installed sample: Edit only in its own workspace
|
|
68
84
|
const installed = new Map(
|
|
69
|
-
(await owner.query<{ alias: string; id: number }>(
|
|
85
|
+
(await owner.query<{ alias: string; id: number; here: boolean }>(`select a.alias, a.id, ${inWorkspaceSql('a', 2)} as here from meta.app a where a.alias = any($1)`,
|
|
86
|
+
[samples.map((x) => x.alias), ws])).rows.map((r) => [r.alias, r]),
|
|
70
87
|
);
|
|
71
88
|
const custom = (await owner.query<{ id: number; name: string; description: string | null }>(
|
|
72
89
|
`select a.id, a.name, null::text as description from meta.app a where a.app_type = 'boilerplate' and ${inWorkspaceSql('a', 1)} order by lower(a.name), a.id`,
|
|
73
|
-
[
|
|
90
|
+
[ws],
|
|
74
91
|
)).rows;
|
|
92
|
+
// (sprint 120) the search also works without JavaScript: ?q= leaves out what doesn't match
|
|
93
|
+
// (like the script's filter: every word of the search in the title or description)
|
|
94
|
+
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
95
|
+
const matches = (x: { title?: string; name?: string; description?: string | null }) => {
|
|
96
|
+
const text = `${x.title ?? x.name ?? ''} ${x.description ?? ''}`.toLowerCase();
|
|
97
|
+
return words.every((w) => text.includes(w));
|
|
98
|
+
};
|
|
99
|
+
const counts: Record<Tab, number> = { samples: samples.length, starters: starters.length, utilities: utilities.length, custom: custom.length };
|
|
75
100
|
const card = (ic: string, title: string, by: string, text: string, action: unknown) =>
|
|
76
101
|
html`<article class="gal-card" data-filter-row><div class="gal-head"><span class="gal-icon">${icon(ic)}</span><div><h2>${title}</h2><span class="muted small">${by}</span></div></div>
|
|
77
102
|
<p>${text}</p><div class="gal-foot">${action}</div></article>`;
|
|
78
|
-
const tabs = html`<nav class="gal-tabs segmented" aria-label="Kinds of applications">${
|
|
79
|
-
html`<a href="${BASE}/gallery?tab=${t}"${tab === t ? raw(' aria-current="page"') : ''}>${l}</a>`)}</nav>`;
|
|
103
|
+
const tabs = html`<nav class="gal-tabs segmented" aria-label="Kinds of applications">${TABS.map(([t, l]) =>
|
|
104
|
+
html`<a href="${BASE}/gallery?tab=${t}${q ? `&q=${encodeURIComponent(q)}` : ''}"${tab === t ? raw(' aria-current="page"') : ''}>${l} <span class="gal-count">${counts[t]}</span></a>`)}</nav>`;
|
|
105
|
+
const blueprintCard = (kind: 'starters' | 'utilities') => (x: Starter) => card(x.icon, x.title, 'pgkiln', x.description,
|
|
106
|
+
html`<form method="post" action="${BASE}/gallery/${kind}/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm">Install</button></form>`);
|
|
80
107
|
const cards =
|
|
81
108
|
tab === 'samples'
|
|
82
|
-
? samples.map((x) =>
|
|
83
|
-
installed.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
? html`<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
109
|
+
? samples.filter(matches).map((x) => {
|
|
110
|
+
const it = installed.get(x.alias);
|
|
111
|
+
return card(x.icon, x.title, 'pgkiln', x.description,
|
|
112
|
+
it
|
|
113
|
+
? html`<span class="tag tag-ok">Installed</span><a class="btn btn-sm" href="/a/${x.alias}" target="_blank" rel="noopener">Run</a>${it.here
|
|
114
|
+
? html`<a class="btn btn-sm" href="${BASE}/apps/${it.id}">Edit</a>`
|
|
115
|
+
: html`<span class="muted small">in another workspace</span>`}`
|
|
116
|
+
: admin
|
|
117
|
+
? html`<form method="post" action="${BASE}/gallery/samples/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm" data-confirm="Install ${x.title}? It adds its own schema, data and demo accounts with known passwords: not for a production instance.">Install</button></form>`
|
|
118
|
+
: html`<span class="muted small">An administrator installs it.</span>`);
|
|
119
|
+
})
|
|
120
|
+
: tab === 'starters' || tab === 'utilities'
|
|
121
|
+
? (tab === 'starters' ? starters : utilities).filter(matches).map(blueprintCard(tab))
|
|
122
|
+
: custom.filter(matches).map((x) => card('copy', x.name, 'This workspace', x.description ?? 'A boilerplate application: a new application starts with its pages, shared components and settings.',
|
|
92
123
|
html`<a class="btn btn-hot btn-sm" href="${BASE}/create?boilerplate=${x.id}">Create from it</a><a class="btn btn-sm" href="${BASE}/apps/${x.id}">Edit</a>`));
|
|
93
|
-
const empty =
|
|
94
|
-
? html`<p class="muted">No
|
|
95
|
-
:
|
|
124
|
+
const empty = q && counts[tab]
|
|
125
|
+
? html`<p class="muted">No app matches <strong>${q}</strong>. <a href="${BASE}/gallery?tab=${tab}">Show all</a></p>`
|
|
126
|
+
: tab === 'custom'
|
|
127
|
+
? html`<p class="muted">No custom apps yet: make an application of type <em>Boilerplate</em> (Application settings → Application type) and it appears here.</p>`
|
|
128
|
+
: html`<p class="muted">The gallery is empty.</p>`;
|
|
96
129
|
const intro = {
|
|
97
|
-
samples: 'Applications that show what pgkiln can do, with
|
|
130
|
+
samples: 'Applications that show what pgkiln can do, each with its own schema, data and demo account: the showcase, the tutorial, one app per feature area and the UX Pattern Catalog. An administrator installs them on a development or demo instance.',
|
|
98
131
|
starters: 'Small, ready-to-use applications. Install shows what will be created (tables, pages, sample data) before anything happens; the application gets its own role and schema in this workspace.',
|
|
132
|
+
utilities: 'Applications a workspace uses rather than learns from: shared lookup lists, release notes, announcements. Like starter apps, Install shows what will be created first, and the application gets its own role and schema in this workspace.',
|
|
99
133
|
custom: 'The boilerplate applications of this workspace: start a new application from one.',
|
|
100
134
|
}[tab];
|
|
101
135
|
const main = html`<div class="gal">
|
|
102
136
|
<div class="gal-bar">
|
|
103
|
-
<
|
|
104
|
-
<input
|
|
137
|
+
<form class="ab-search gal-search" role="search" method="get" action="${BASE}/gallery"><label class="sr-only" for="gal-q">Search apps</label>${icon('search')}
|
|
138
|
+
<input type="hidden" name="tab" value="${tab}">
|
|
139
|
+
<input id="gal-q" name="q" type="search" placeholder="Search apps" autocomplete="off" value="${q}" data-filter-list=".gal [data-filter-row]"></form>
|
|
105
140
|
${tabs}
|
|
106
141
|
</div>
|
|
107
142
|
<h1 class="sr-only">App Gallery</h1>
|
|
@@ -120,19 +155,30 @@ export async function galleryRoutes(app: FastifyInstance) {
|
|
|
120
155
|
if (!x) return reply.code(404).send('Not found');
|
|
121
156
|
try {
|
|
122
157
|
if (await owner.one('select 1 as ok from meta.app where alias = $1', [x.alias])) throw new Error(`an application with the alias ${x.alias} exists already.`);
|
|
123
|
-
if (x.example && KEY.test(x.example)) await migrate({ root, databaseUrl: ownerUrl, example: x.example, log: () => {} });
|
|
124
|
-
else if (x.file && /^[a-z0-9-]+\.sql$/.test(x.file)) await owner.query(galleryFile(x.file));
|
|
125
|
-
else throw new Error('the gallery entry names nothing to install.');
|
|
126
|
-
const made = await owner.one<{ id: number; db_role: string | null }>('select id, db_role from meta.app where alias = $1', [x.alias]);
|
|
127
|
-
if (!made) throw new Error(`the scripts ran, but there is no application ${x.alias}.`);
|
|
128
|
-
// into the current workspace (an isolated one gets the application's role too)
|
|
129
158
|
const ws = currentWorkspace(s);
|
|
130
|
-
|
|
131
|
-
|
|
159
|
+
// into the current workspace (an isolated one gets the application's role too)
|
|
160
|
+
const place = async (c: Client) => {
|
|
161
|
+
const made = (await c.query('select id, db_role from meta.app where alias = $1', [x.alias])).rows[0] as { id: number; db_role: string | null } | undefined;
|
|
162
|
+
if (!made) throw new Error(`the scripts ran, but there is no application ${x.alias}.`);
|
|
163
|
+
if (ws > 1) {
|
|
132
164
|
if (made.db_role && (await c.query('select 1 from meta.workspace where id = $1 and isolated', [ws])).rowCount)
|
|
133
165
|
await c.query('insert into meta.workspace_role (workspace_id, role_name) values ($1, $2) on conflict do nothing', [ws, made.db_role]);
|
|
134
166
|
await placeApp(c, made.id, ws);
|
|
167
|
+
}
|
|
168
|
+
return made;
|
|
169
|
+
};
|
|
170
|
+
let made: { id: number; db_role: string | null };
|
|
171
|
+
if (x.example && KEY.test(x.example)) {
|
|
172
|
+
await migrate({ root, databaseUrl: ownerUrl, example: x.example, log: () => {} });
|
|
173
|
+
made = await owner.tx(place);
|
|
174
|
+
} else if (x.file && SAMPLE_FILE.test(x.file)) {
|
|
175
|
+
// (sprint 120) the file and the placement in one transaction: a failing script leaves nothing behind
|
|
176
|
+
const sql = galleryFile(x.file);
|
|
177
|
+
made = await owner.tx(async (c) => {
|
|
178
|
+
await c.query(sql);
|
|
179
|
+
return place(c);
|
|
135
180
|
});
|
|
181
|
+
} else throw new Error('the gallery entry names nothing to install.');
|
|
136
182
|
await workspacesChanged();
|
|
137
183
|
await logActivity({ appId: made.id, username: s.username, event: 'gallery', ip: clientIp(req), detail: `installed sample app ${x.key}` });
|
|
138
184
|
flash(s, `${x.title} installed (/a/${x.alias}).`);
|
|
@@ -143,12 +189,13 @@ export async function galleryRoutes(app: FastifyInstance) {
|
|
|
143
189
|
}
|
|
144
190
|
});
|
|
145
191
|
|
|
146
|
-
// a starter app: its blueprint's review (nothing is created before the developer confirms there)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
192
|
+
// a starter or utility app: its blueprint's review (nothing is created before the developer confirms there)
|
|
193
|
+
for (const kind of ['starters', 'utilities'] as const)
|
|
194
|
+
app.post(`${BASE}/gallery/${kind}/:key`, async (req: Req, reply) => {
|
|
195
|
+
const s = await developer(req, reply);
|
|
196
|
+
if (!s) return;
|
|
197
|
+
const x = KEY.test(req.params.key) ? catalog()[kind].find((c) => c.key === req.params.key) : undefined;
|
|
198
|
+
if (!x || typeof x.file !== 'string' || !BLUEPRINT_FILE[kind].test(x.file)) return reply.code(404).send('Not found');
|
|
199
|
+
return openBlueprint(s, reply, x.title, JSON.stringify(JSON.parse(galleryFile(x.file)), null, 2));
|
|
200
|
+
});
|
|
154
201
|
}
|
|
@@ -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">
|
package/src/builder/home.ts
CHANGED
|
@@ -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,
|
|
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
|
|
43
|
+
return tx(v === 1 ? `ago.${unit}` : `ago.${unit}s`, { n: tx.lang === 'en' ? v : String(v).replace('.', ',') });
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
|
-
return '
|
|
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) =>
|
|
50
|
-
const lockedText = (by: string) =>
|
|
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="
|
|
57
|
-
${[
|
|
58
|
-
['create', 'plus', '
|
|
59
|
-
['import', 'upload', '
|
|
60
|
-
['dashboard', 'activity', '
|
|
61
|
-
['utilities', 'settings', '
|
|
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="
|
|
68
|
-
<section><h2
|
|
69
|
-
<p><a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md" target="_blank" rel="noopener"
|
|
70
|
-
${recent.length ? html`<section><h2
|
|
71
|
-
<section><h2
|
|
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: [
|
|
76
|
-
['
|
|
77
|
-
['
|
|
78
|
-
['
|
|
79
|
-
['
|
|
80
|
-
['
|
|
81
|
-
['
|
|
82
|
-
['
|
|
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
|
|
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=&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
|
|
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="
|
|
152
|
-
<a class="tb-btn" href="/a/${a.alias}" target="_blank" rel="noopener" title="
|
|
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"
|
|
156
|
-
<thead><tr>${th('id', '
|
|
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' ? '
|
|
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)
|
|
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="
|
|
169
|
-
<a class="tb-btn" href="/a/${a.alias}" target="_blank" rel="noopener" title="
|
|
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"
|
|
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"
|
|
181
|
-
<input id="ab-q" name="q" type="search" value="${q}" placeholder="
|
|
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="
|
|
184
|
-
${groups.length ? html`<form method="get" action="${BASE}/apps" class="ab-group"><label class="sr-only" for="ab-group"
|
|
185
|
-
<select id="ab-group" name="group" data-autosubmit><option value=""
|
|
186
|
-
<noscript><button class="btn btn-sm"
|
|
187
|
-
<details class="menu ab-menu"><summary class="btn"
|
|
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"
|
|
190
|
-
${(Object.keys(SORTS) as Sort[]).map((k) => html`<a role="menuitem" href="${link({ sort: k })}"${sort === k ? raw(' aria-current="true"') : ''}>${{
|
|
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')}
|
|
193
|
-
<a role="menuitem" href="${BASE}/dashboard">${icon('activity')}
|
|
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 ?
|
|
197
|
-
<a class="btn btn-hot ab-create" href="${BASE}/create"
|
|
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, '
|
|
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
|
};
|