pgkiln 0.87.0 → 0.89.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.
@@ -7510,4 +7510,32 @@ export const it: BuilderMessages = {
7510
7510
  'pjb.steps': 'Passi',
7511
7511
  'pjb.message': 'Messaggio',
7512
7512
  'pjb.no_runs': 'Ancora nessuna esecuzione.',
7513
+ // ------------------------------------------------------------ PWA: every page offline (sprint 168)
7514
+ 'pwa.prefetch': 'Rendi disponibili offline tutte le pagine della navigazione dopo l’accesso',
7515
+ 'pwa.prefetch_help': 'Richiede Conserva le pagine visitate. Dopo l’accesso, e alla prima pagina aperta su un dispositivo, il dispositivo recupera una alla volta tutte le pagine del menu e della barra di navigazione che l’utente può aprire. Il server le mostra in sola lettura: le pagine che eseguono processi all’apertura vengono escluse e conservate solo quando l’utente le apre.',
7516
+ 'pwa.prefetch_hours': 'Recuperarle di nuovo dopo (ore)',
7517
+ 'pwa.prefetch_hours_help': 'Da 1 a 168. Le pagine vengono recuperate di nuovo alla prima pagina aperta dopo questo tempo, se c’è connessione.',
7518
+ 'pwa.prefetch_hours_range': 'Recuperarle di nuovo dopo: un numero intero di ore da 1 a 168.',
7519
+ // ------------------------------------------------------------ SQL Worksheet: one toolbar row (UX-1)
7520
+ 'ws.more': 'Altre azioni',
7521
+ 'ws.suggest': 'Suggerisci',
7522
+ 'ws.suggest_title': 'Suggerimenti al cursore (Ctrl+Spazio)',
7523
+ 'ws.check': 'Verifica',
7524
+ 'ws.check_title': 'Pianifica l’SQL senza eseguirlo, come l’Advisor',
7525
+ 'ws.key.indent': 'Rientra (Esc, poi Tab: campo successivo)',
7526
+ 'ws.search_path_plain': '{schema} per primo nel percorso di ricerca',
7527
+ // ------------------------------------------------------------ Rail labels (UX-3)
7528
+ 'rail.labels_show': 'Mostra etichette',
7529
+ 'rail.labels_hide': 'Nascondi etichette',
7530
+ // ------------------------------------------------------------ Administration page (UX-4)
7531
+ 'wu.filter': 'Filtra le utilità',
7532
+ 'wu.filter_none': 'Nessun risultato per il filtro.',
7533
+ 'wu.sec.people': 'Persone e accesso',
7534
+ 'wu.sec.connections': 'Connessioni',
7535
+ 'wu.sec.data': 'Dati e IA',
7536
+ 'wu.sec.operations': 'Operazioni',
7537
+ 'wu.sec.workspace': 'Area di lavoro {name}',
7538
+ 'wu.sec.instance': 'Istanza',
7539
+ // ------------------------------------------------------------ Workspace home: needs attention (UX-13)
7540
+ 'wh.attention': 'Richiede attenzione',
7513
7541
  };
@@ -7510,4 +7510,32 @@ export const ja: BuilderMessages = {
7510
7510
  'pjb.steps': 'ステップ',
7511
7511
  'pjb.message': 'メッセージ',
7512
7512
  'pjb.no_runs': 'まだ実行はありません。',
7513
+ // ------------------------------------------------------------ PWA: every page offline (sprint 168)
7514
+ 'pwa.prefetch': 'サインイン後、ナビゲーションのすべてのページをオフラインで使用できるようにする',
7515
+ 'pwa.prefetch_help': '「表示したページを保持」が必要です。サインイン後、およびデバイスで最初のページを開いたときに、ユーザーが開けるナビゲーション メニューとナビゲーション バーのすべてのページを 1 つずつ取得します。サーバーはそれらを読み取り専用で表示します。開くときにプロセスを実行するページは除外され、ユーザーが開いたときにのみ保持されます。',
7516
+ 'pwa.prefetch_hours': '再取得までの時間(時間)',
7517
+ 'pwa.prefetch_hours_help': '1~168。この時間が過ぎた後、接続があるときに最初にページを開くと再取得されます。',
7518
+ 'pwa.prefetch_hours_range': '再取得までの時間: 1~168 の整数の時間を指定してください。',
7519
+ // ------------------------------------------------------------ SQL Worksheet: one toolbar row (UX-1)
7520
+ 'ws.more': 'その他の操作',
7521
+ 'ws.suggest': '候補',
7522
+ 'ws.suggest_title': 'カーソル位置の候補 (Ctrl+Space)',
7523
+ 'ws.check': 'チェック',
7524
+ 'ws.check_title': 'Advisor と同様に、実行せずに SQL を計画します',
7525
+ 'ws.key.indent': 'インデント(Esc の後 Tab: 次のフィールド)',
7526
+ 'ws.search_path_plain': '検索パスの先頭は {schema}',
7527
+ // ------------------------------------------------------------ Rail labels (UX-3)
7528
+ 'rail.labels_show': 'ラベルを表示',
7529
+ 'rail.labels_hide': 'ラベルを非表示',
7530
+ // ------------------------------------------------------------ Administration page (UX-4)
7531
+ 'wu.filter': 'ユーティリティを絞り込む',
7532
+ 'wu.filter_none': 'フィルターに一致するものはありません。',
7533
+ 'wu.sec.people': 'ユーザーとサインイン',
7534
+ 'wu.sec.connections': '接続',
7535
+ 'wu.sec.data': 'データと AI',
7536
+ 'wu.sec.operations': '運用',
7537
+ 'wu.sec.workspace': 'ワークスペース {name}',
7538
+ 'wu.sec.instance': 'インスタンス',
7539
+ // ------------------------------------------------------------ Workspace home: needs attention (UX-13)
7540
+ 'wh.attention': '要対応',
7513
7541
  };
@@ -7510,4 +7510,32 @@ export const ko: BuilderMessages = {
7510
7510
  'pjb.steps': '단계',
7511
7511
  'pjb.message': '메시지',
7512
7512
  'pjb.no_runs': '아직 실행이 없습니다.',
7513
+ // ------------------------------------------------------------ PWA: every page offline (sprint 168)
7514
+ 'pwa.prefetch': '로그인 후 탐색의 모든 페이지를 오프라인으로 사용할 수 있게 함',
7515
+ 'pwa.prefetch_help': '‘방문한 페이지 보관’이 필요합니다. 로그인 후와 기기에서 처음 페이지를 열 때, 기기는 사용자가 열 수 있는 탐색 메뉴와 탐색 모음의 모든 페이지를 하나씩 가져옵니다. 서버는 이를 읽기 전용으로 표시합니다. 열 때 프로세스를 실행하는 페이지는 제외되며 사용자가 열 때만 보관됩니다.',
7516
+ 'pwa.prefetch_hours': '다시 가져오기까지의 시간(시간)',
7517
+ 'pwa.prefetch_hours_help': '1~168. 이 시간이 지난 뒤 연결되어 있을 때 처음 페이지를 열면 다시 가져옵니다.',
7518
+ 'pwa.prefetch_hours_range': '다시 가져오기까지의 시간: 1~168 사이의 정수 시간입니다.',
7519
+ // ------------------------------------------------------------ SQL Worksheet: one toolbar row (UX-1)
7520
+ 'ws.more': '추가 작업',
7521
+ 'ws.suggest': '제안',
7522
+ 'ws.suggest_title': '커서 위치의 제안 (Ctrl+Space)',
7523
+ 'ws.check': '검사',
7524
+ 'ws.check_title': 'Advisor처럼 실행하지 않고 SQL을 계획합니다',
7525
+ 'ws.key.indent': '들여쓰기 (Esc 후 Tab: 다음 필드)',
7526
+ 'ws.search_path_plain': '검색 경로의 첫 번째는 {schema}',
7527
+ // ------------------------------------------------------------ Rail labels (UX-3)
7528
+ 'rail.labels_show': '레이블 표시',
7529
+ 'rail.labels_hide': '레이블 숨기기',
7530
+ // ------------------------------------------------------------ Administration page (UX-4)
7531
+ 'wu.filter': '유틸리티 필터',
7532
+ 'wu.filter_none': '필터와 일치하는 항목이 없습니다.',
7533
+ 'wu.sec.people': '사용자 및 로그인',
7534
+ 'wu.sec.connections': '연결',
7535
+ 'wu.sec.data': '데이터 및 AI',
7536
+ 'wu.sec.operations': '운영',
7537
+ 'wu.sec.workspace': '작업 공간 {name}',
7538
+ 'wu.sec.instance': '인스턴스',
7539
+ // ------------------------------------------------------------ Workspace home: needs attention (UX-13)
7540
+ 'wh.attention': '주의 필요',
7513
7541
  };
@@ -7510,4 +7510,32 @@ export const nl: BuilderMessages = {
7510
7510
  'pjb.steps': 'Stappen',
7511
7511
  'pjb.message': 'Melding',
7512
7512
  'pjb.no_runs': 'Nog geen uitvoeringen.',
7513
+ // ------------------------------------------------------------ PWA: every page offline (sprint 168)
7514
+ 'pwa.prefetch': 'Alle pagina\'s van de navigatie na het aanmelden offline beschikbaar maken',
7515
+ 'pwa.prefetch_help': 'Vereist Bezochte pagina\'s bewaren. Na het aanmelden, en op de eerste pagina die op een apparaat wordt geopend, haalt het apparaat één voor één alle pagina\'s van het navigatiemenu en de navigatiebalk op die de gebruiker mag openen. De server toont ze alleen-lezen: pagina\'s die bij het openen processen uitvoeren worden overgeslagen en pas bewaard als de gebruiker ze opent.',
7516
+ 'pwa.prefetch_hours': 'Opnieuw ophalen na (uren)',
7517
+ 'pwa.prefetch_hours_help': '1 tot 168. De pagina\'s worden opnieuw opgehaald bij de eerste pagina die na deze tijd wordt geopend, als er verbinding is.',
7518
+ 'pwa.prefetch_hours_range': 'Opnieuw ophalen na: een geheel aantal uren van 1 tot 168.',
7519
+ // ------------------------------------------------------------ SQL Worksheet: one toolbar row (UX-1)
7520
+ 'ws.more': 'Meer acties',
7521
+ 'ws.suggest': 'Suggesties',
7522
+ 'ws.suggest_title': 'Suggesties bij de cursor (Ctrl+Spatie)',
7523
+ 'ws.check': 'Controleren',
7524
+ 'ws.check_title': 'De SQL plannen zonder hem uit te voeren, zoals de Advisor',
7525
+ 'ws.key.indent': 'Inspringen (Esc, dan Tab: volgend veld)',
7526
+ 'ws.search_path_plain': '{schema} eerst in het zoekpad',
7527
+ // ------------------------------------------------------------ Rail labels (UX-3)
7528
+ 'rail.labels_show': 'Labels tonen',
7529
+ 'rail.labels_hide': 'Labels verbergen',
7530
+ // ------------------------------------------------------------ Administration page (UX-4)
7531
+ 'wu.filter': 'Hulpmiddelen filteren',
7532
+ 'wu.filter_none': 'Niets voldoet aan het filter.',
7533
+ 'wu.sec.people': 'Mensen en aanmelden',
7534
+ 'wu.sec.connections': 'Verbindingen',
7535
+ 'wu.sec.data': 'Gegevens en AI',
7536
+ 'wu.sec.operations': 'Beheer',
7537
+ 'wu.sec.workspace': 'Werkruimte {name}',
7538
+ 'wu.sec.instance': 'Instantie',
7539
+ // ------------------------------------------------------------ Workspace home: needs attention (UX-13)
7540
+ 'wh.attention': 'Vraagt aandacht',
7513
7541
  };
@@ -7510,4 +7510,32 @@ export const pt: BuilderMessages = {
7510
7510
  'pjb.steps': 'Etapas',
7511
7511
  'pjb.message': 'Mensagem',
7512
7512
  'pjb.no_runs': 'Nenhuma execução ainda.',
7513
+ // ------------------------------------------------------------ PWA: every page offline (sprint 168)
7514
+ 'pwa.prefetch': 'Disponibilizar todas as páginas da navegação offline após o login',
7515
+ 'pwa.prefetch_help': 'Requer Manter as páginas visitadas. Após o login, e na primeira página aberta em um dispositivo, o dispositivo busca uma a uma todas as páginas do menu e da barra de navegação que o usuário pode abrir. O servidor as exibe somente leitura: páginas que executam processos ao abrir são deixadas de fora e mantidas só quando o usuário as abre.',
7516
+ 'pwa.prefetch_hours': 'Buscá-las novamente após (horas)',
7517
+ 'pwa.prefetch_hours_help': 'De 1 a 168. As páginas são buscadas novamente na primeira página aberta após esse tempo, quando houver conexão.',
7518
+ 'pwa.prefetch_hours_range': 'Buscá-las novamente após: um número inteiro de horas de 1 a 168.',
7519
+ // ------------------------------------------------------------ SQL Worksheet: one toolbar row (UX-1)
7520
+ 'ws.more': 'Mais ações',
7521
+ 'ws.suggest': 'Sugerir',
7522
+ 'ws.suggest_title': 'Sugestões no cursor (Ctrl+Espaço)',
7523
+ 'ws.check': 'Verificar',
7524
+ 'ws.check_title': 'Planejar o SQL sem executá-lo, como o Advisor',
7525
+ 'ws.key.indent': 'Recuar (Esc e depois Tab: próximo campo)',
7526
+ 'ws.search_path_plain': '{schema} primeiro no caminho de pesquisa',
7527
+ // ------------------------------------------------------------ Rail labels (UX-3)
7528
+ 'rail.labels_show': 'Mostrar rótulos',
7529
+ 'rail.labels_hide': 'Ocultar rótulos',
7530
+ // ------------------------------------------------------------ Administration page (UX-4)
7531
+ 'wu.filter': 'Filtrar os utilitários',
7532
+ 'wu.filter_none': 'Nada corresponde ao filtro.',
7533
+ 'wu.sec.people': 'Pessoas e login',
7534
+ 'wu.sec.connections': 'Conexões',
7535
+ 'wu.sec.data': 'Dados e IA',
7536
+ 'wu.sec.operations': 'Operações',
7537
+ 'wu.sec.workspace': 'Espaço de trabalho {name}',
7538
+ 'wu.sec.instance': 'Instância',
7539
+ // ------------------------------------------------------------ Workspace home: needs attention (UX-13)
7540
+ 'wh.attention': 'Requer atenção',
7513
7541
  };
@@ -7510,4 +7510,32 @@ export const zhCN: BuilderMessages = {
7510
7510
  'pjb.steps': '步骤',
7511
7511
  'pjb.message': '消息',
7512
7512
  'pjb.no_runs': '尚无运行记录。',
7513
+ // ------------------------------------------------------------ PWA: every page offline (sprint 168)
7514
+ 'pwa.prefetch': '登录后使导航中的所有页面可离线使用',
7515
+ 'pwa.prefetch_help': '需要“将访问过的页面保留在设备上”。登录后,以及在设备上打开第一个页面时,设备会逐个获取用户可以打开的导航菜单和导航栏中的所有页面。服务器以只读方式显示它们:打开时运行进程的页面会被跳过,只有在用户打开时才会保留。',
7516
+ 'pwa.prefetch_hours': '多少小时后重新获取',
7517
+ 'pwa.prefetch_hours_help': '1 到 168。超过此时间后,在有网络连接时打开第一个页面时会重新获取这些页面。',
7518
+ 'pwa.prefetch_hours_range': '重新获取时间:1 到 168 之间的整数小时。',
7519
+ // ------------------------------------------------------------ SQL Worksheet: one toolbar row (UX-1)
7520
+ 'ws.more': '更多操作',
7521
+ 'ws.suggest': '建议',
7522
+ 'ws.suggest_title': '光标处的建议 (Ctrl+Space)',
7523
+ 'ws.check': '检查',
7524
+ 'ws.check_title': '像 Advisor 一样规划 SQL 而不运行',
7525
+ 'ws.key.indent': '缩进(先按 Esc 再按 Tab:下一个字段)',
7526
+ 'ws.search_path_plain': '{schema} 位于搜索路径首位',
7527
+ // ------------------------------------------------------------ Rail labels (UX-3)
7528
+ 'rail.labels_show': '显示标签',
7529
+ 'rail.labels_hide': '隐藏标签',
7530
+ // ------------------------------------------------------------ Administration page (UX-4)
7531
+ 'wu.filter': '筛选实用工具',
7532
+ 'wu.filter_none': '没有符合筛选条件的项目。',
7533
+ 'wu.sec.people': '人员与登录',
7534
+ 'wu.sec.connections': '连接',
7535
+ 'wu.sec.data': '数据与 AI',
7536
+ 'wu.sec.operations': '运维',
7537
+ 'wu.sec.workspace': '工作区 {name}',
7538
+ 'wu.sec.instance': '实例',
7539
+ // ------------------------------------------------------------ Workspace home: needs attention (UX-13)
7540
+ 'wh.attention': '需要注意',
7513
7541
  };
@@ -7510,4 +7510,32 @@ export const zhTW: BuilderMessages = {
7510
7510
  'pjb.steps': '步驟',
7511
7511
  'pjb.message': '訊息',
7512
7512
  'pjb.no_runs': '尚無執行記錄。',
7513
+ // ------------------------------------------------------------ PWA: every page offline (sprint 168)
7514
+ 'pwa.prefetch': '登入後使導覽中的所有頁面可離線使用',
7515
+ 'pwa.prefetch_help': '需要「將造訪過的頁面保留在裝置上」。登入後,以及在裝置上開啟第一個頁面時,裝置會逐一取得使用者可以開啟的導覽選單和導覽列中的所有頁面。伺服器以唯讀方式顯示它們:開啟時執行處理程序的頁面會被略過,只有在使用者開啟時才會保留。',
7516
+ 'pwa.prefetch_hours': '多少小時後重新取得',
7517
+ 'pwa.prefetch_hours_help': '1 到 168。超過此時間後,在有網路連線時開啟第一個頁面時會重新取得這些頁面。',
7518
+ 'pwa.prefetch_hours_range': '重新取得時間:1 到 168 之間的整數小時。',
7519
+ // ------------------------------------------------------------ SQL Worksheet: one toolbar row (UX-1)
7520
+ 'ws.more': '更多動作',
7521
+ 'ws.suggest': '建議',
7522
+ 'ws.suggest_title': '游標處的建議 (Ctrl+Space)',
7523
+ 'ws.check': '檢查',
7524
+ 'ws.check_title': '像 Advisor 一樣規劃 SQL 而不執行',
7525
+ 'ws.key.indent': '縮排(先按 Esc 再按 Tab:下一個欄位)',
7526
+ 'ws.search_path_plain': '{schema} 位於搜尋路徑首位',
7527
+ // ------------------------------------------------------------ Rail labels (UX-3)
7528
+ 'rail.labels_show': '顯示標籤',
7529
+ 'rail.labels_hide': '隱藏標籤',
7530
+ // ------------------------------------------------------------ Administration page (UX-4)
7531
+ 'wu.filter': '篩選公用程式',
7532
+ 'wu.filter_none': '沒有符合篩選條件的項目。',
7533
+ 'wu.sec.people': '人員與登入',
7534
+ 'wu.sec.connections': '連線',
7535
+ 'wu.sec.data': '資料與 AI',
7536
+ 'wu.sec.operations': '維運',
7537
+ 'wu.sec.workspace': '工作區 {name}',
7538
+ 'wu.sec.instance': '執行個體',
7539
+ // ------------------------------------------------------------ Workspace home: needs attention (UX-13)
7540
+ 'wh.attention': '需要注意',
7513
7541
  };
@@ -32,7 +32,7 @@ async function pushStats(appId: number) {
32
32
  ))!;
33
33
  }
34
34
 
35
- export async function pwaSection(a: { id: number; alias: string; pwa: boolean; pwa_short_name: string | null; pwa_offline_pages: boolean; pwa_offline_submit: boolean; pwa_push: boolean; has_icon: boolean }, s: Session) {
35
+ export async function pwaSection(a: { id: number; alias: string; pwa: boolean; pwa_short_name: string | null; pwa_offline_pages: boolean; pwa_prefetch_hours: number | null; pwa_offline_submit: boolean; pwa_push: boolean; has_icon: boolean }, s: Session) {
36
36
  const check = (name: string, label: string, on: boolean, help: string) =>
37
37
  html`<div class="field"><label class="check"><input type="checkbox" name="${name}" value="true"${on ? raw(' checked') : ''}> ${label}</label><small class="help">${help}</small></div>`;
38
38
  const stats = a.pwa && a.pwa_push ? await pushStats(a.id) : null;
@@ -42,6 +42,10 @@ export async function pwaSection(a: { id: number; alias: string; pwa: boolean; p
42
42
  ${input('pwa_short_name', tx('pwa.short_name'), a.pwa_short_name ?? '', { placeholder: tx('pwa.short_name_ph') })}
43
43
  </div>
44
44
  ${check('pwa_offline_pages', tx('pwa.offline_pages'), a.pwa_offline_pages, tx('pwa.offline_pages_help'))}
45
+ ${check('pwa_prefetch', tx('pwa.prefetch'), a.pwa_prefetch_hours !== null, tx('pwa.prefetch_help'))}
46
+ <div class="form-grid">
47
+ ${input('pwa_prefetch_hours', tx('pwa.prefetch_hours'), a.pwa_prefetch_hours ?? 12, { type: 'number', help: tx('pwa.prefetch_hours_help') })}
48
+ </div>
45
49
  ${check('pwa_offline_submit', tx('pwa.offline_submit'), a.pwa_offline_submit, tx('pwa.offline_submit_help'))}
46
50
  ${check('pwa_push', tx('pwa.push'), a.pwa_push, tx('pwa.push_help'))}
47
51
  <div class="field" data-wide><label class="label" for="f_pwa_icon">${tx('pwa.icon')}</label>
@@ -98,10 +102,17 @@ export async function pwaBuilderRoutes(app: FastifyInstance) {
98
102
  flash(s, tx('pwa.push_needs_key'), 'error');
99
103
  return back(reply, s, target);
100
104
  }
105
+ // (168) every navigation page offline: only with visited pages kept, every 1 to 168 hours
106
+ const hours = Number(b.pwa_prefetch_hours ?? 12);
107
+ if (b.pwa_prefetch === 'true' && (!Number.isInteger(hours) || hours < 1 || hours > 168)) {
108
+ flash(s, tx('pwa.prefetch_hours_range'), 'error');
109
+ return back(reply, s, target);
110
+ }
111
+ const prefetch = b.pwa_prefetch === 'true' && b.pwa_offline_pages === 'true' ? hours : null;
101
112
  await owner.query(
102
113
  `update meta.app set pwa = $2, pwa_short_name = $3, pwa_offline_pages = $4, pwa_offline_submit = $5,
103
- pwa_icon = case when $7 then $6 else pwa_icon end, pwa_push = $8, updated_at = now() where id = $1`,
104
- [id, b.pwa === 'true', (b.pwa_short_name ?? '').trim().slice(0, 30) || null, b.pwa_offline_pages === 'true', b.pwa_offline_submit === 'true', icon ?? null, icon !== undefined, push],
114
+ pwa_icon = case when $7 then $6 else pwa_icon end, pwa_push = $8, pwa_prefetch_hours = $9, updated_at = now() where id = $1`,
115
+ [id, b.pwa === 'true', (b.pwa_short_name ?? '').trim().slice(0, 30) || null, b.pwa_offline_pages === 'true', b.pwa_offline_submit === 'true', icon ?? null, icon !== undefined, push, prefetch],
105
116
  );
106
117
  if (push) await appPublicKey(Number(id));
107
118
  flash(s, push && b.pwa !== 'true' ? tx('pwa.saved_not_installable') : tx('pwa.saved'));
@@ -540,6 +540,24 @@ export async function builderRoutes(app: FastifyInstance) {
540
540
  return reply.redirect(`${BASE}/login`, 303);
541
541
  });
542
542
 
543
+ // (UX-3) the icon rail with labels next to the icons, or icons only (kept in the developer's session)
544
+ app.post(`${BASE}/rail`, async (req: Req, reply) => {
545
+ const s = await developer(req, reply);
546
+ if (!s) return;
547
+ if (req.body?.labels === 'labels') s.state.__RAIL = 'labels';
548
+ else delete s.state.__RAIL;
549
+ const ref = String(req.headers.referer ?? '');
550
+ let to = `${BASE}/home`;
551
+ try {
552
+ const u = new URL(ref);
553
+ if (u.host === req.headers.host && u.pathname.startsWith(BASE)) to = u.pathname + u.search;
554
+ } catch {
555
+ /* no referer: the home page */
556
+ }
557
+ await saveState(s);
558
+ return reply.redirect(to, 303);
559
+ });
560
+
543
561
  // The builder's light/dark theme (the developer's menu in the icon rail).
544
562
  app.post(`${BASE}/theme`, async (req: Req, reply) => {
545
563
  const s = await developer(req, reply);
@@ -654,7 +672,7 @@ export async function builderRoutes(app: FastifyInstance) {
654
672
  const tx = tr(s);
655
673
 
656
674
  const main = html`
657
- ${appHeader(a, 'pages', s)}
675
+ ${appHeader(a, 'pages', s, [`${BASE}/apps/${a.id}/create-page`, tx('app.create_page'), 'plus'])}
658
676
  ${appLock && appLock.locked_by !== s.username ? html`<div class="alert alert-error" role="status">${icon('key')} ${lockText(appLock)} ${tx('app.lock_refused')}</div>` : ''}
659
677
  <div class="app-home">
660
678
  <div class="app-home-main">
@@ -664,7 +682,6 @@ export async function builderRoutes(app: FastifyInstance) {
664
682
  <button class="btn">${icon('search')}<span class="sr-only">${tx('common.search')}</span></button></form>
665
683
  <div class="seg view-toggle" role="group" aria-label="${tx('ab.view')}">
666
684
  <a href="${keep({})}"${icons ? '' : raw(' aria-current="true"')}>${icon('list')} ${tx('ab.view_report')}</a><a href="${keep({ view: 'icons' })}"${icons ? raw(' aria-current="true"') : ''}>${icon('grid')} ${tx('app.view_icons')}</a></div>
667
- <a class="btn btn-hot" href="${BASE}/apps/${a.id}/create-page">${icon('plus')} ${tx('app.create_page')}</a>
668
685
  </div>
669
686
  ${region(tx('app.tab.pages'), html`${grouped
670
687
  ? html`<form method="get" class="search u-mwnone u-mb1"><div class="field"><label class="label" for="f_group">${tx('app.page_group')}</label>
@@ -690,7 +707,7 @@ export async function builderRoutes(app: FastifyInstance) {
690
707
  ${select('kind', tx('app.page_type'), 'report_form', WIZARD_KINDS.map(([k, label]): [string, string] => [k, kindLabel(k, label)]))}
691
708
  ${select('table', tx('app.table_or_view'), '', tables.map((t): [string, string] => [t.t, t.access ? t.t : tx('app.no_access', { table: t.t, role: a.db_role })]))}
692
709
  </div>
693
- <div class="buttons"><button class="btn btn-hot">${tx('common.next')}</button></div>
710
+ <div class="buttons"><button class="btn">${tx('common.next')}</button></div>
694
711
  </form>
695
712
  <p><a href="${BASE}/apps/${a.id}/create-page">${tx('app.all_page_types')} ▸</a></p>
696
713
  <p><a href="${BASE}/apps/${a.id}/ai-pages">${tx('app.ai_pages')} ▸</a> <span class="muted">${tx('app.ai_pages_note')}</span></p>`)}
@@ -702,25 +719,19 @@ export async function builderRoutes(app: FastifyInstance) {
702
719
  ${select('mode', tx('app.col.mode'), 'normal', ['normal', 'modal'])}
703
720
  ${input('parent_page', tx('app.parent'), a.home_page, { type: 'number' })}
704
721
  </div>
705
- <div class="buttons"><button class="btn btn-hot">${tx('app.create_page')}</button></div>
722
+ <div class="buttons"><button class="btn">${tx('app.create_page')}</button></div>
706
723
  </form>`)}
707
724
  </div>
708
725
  </div>
709
726
  <aside class="app-tasks region region-standard" aria-labelledby="app-tasks-h"><header class="region-header"><h2 id="app-tasks-h">${tx('common.tasks')}</h2></header>
710
727
  <ul class="sc-links">
711
- <li><a href="/a/${a.alias}" target="_blank" rel="noopener">${icon('play')} ${tx('app.task.run')}</a></li>
712
- <li><a href="${BASE}/apps/${a.id}/create-page">${icon('plus')} ${tx('app.create_page')}</a></li>
713
728
  <li><a href="${BASE}/apps/${a.id}/create-page?tab=feature">${icon('puzzle')} ${tx('app.task.add_feature')}</a></li>
714
729
  <li><a href="${BASE}/apps/${a.id}/create-page?tab=copy">${icon('copy')} ${tx('app.task.copy_page')}</a></li>
715
730
  <li><a href="${BASE}/apps/${a.id}/ai-pages">${icon('lightbulb')} ${tx('app.ai_pages')}</a></li>
716
- <li><a href="${BASE}/apps/${a.id}/shared">${icon('layers')} ${tx('app.tab.shared')}</a></li>
717
731
  <li><a href="${BASE}/apps/${a.id}/shared?type=page_group">${icon('folder')} ${tx('app.task.page_groups')}</a></li>
718
- <li><a href="${BASE}/apps/${a.id}/export">${icon('download')} ${tx('app.head.export')}</a></li>
719
- <li><a href="${BASE}/apps/${a.id}/backups">${icon('history')} ${tx('app.head.backups')}</a></li>
720
732
  <li><a href="${BASE}/apps/${a.id}/supporting-objects">${icon('database')} ${tx('app.task.supporting')}</a></li>
721
733
  <li><a href="#app-lock">${icon('lock')} ${tx('app.task.lock')}</a></li>
722
734
  ${issues.task}
723
- <li><a href="${BASE}/apps/${a.id}/settings?tab=definition">${icon('trash')} ${tx('app.task.delete')}</a></li>
724
735
  </ul></aside>
725
736
  </div>`;
726
737
  return send(reply, s, shell(s, a.name, [[tx('rte.app_builder'), BASE], [a.name]], main));
@@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyReply } from 'fastify';
2
2
  import pg from 'pg';
3
3
  import { html, raw, type Raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
- import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
5
+ import { back, BASE, csrf, developer, flash, pageHeader, region, send, shell, workshopTabs, type Req } from './ui.ts';
6
6
  import { owner } from '../db.ts';
7
7
  import { clientIp, logActivity, type Session } from '../session.ts';
8
8
  import { clearCompletions } from './code-editor.ts';
@@ -70,7 +70,7 @@ async function workshopHome(req: Req, reply: FastifyReply) {
70
70
  html`<section class="sw-recent"><h2>${title}</h2>${rows.length ? html`<ul>${rows}</ul>` : html`<p class="muted">${empty}</p>`}</section>`;
71
71
  const main = html`<div class="ab-home">
72
72
  <div class="ab-main">
73
- <h1 class="sr-only">${tx('rail.sql_workshop')}</h1>
73
+ ${pageHeader(tx('rail.sql_workshop'))}
74
74
  ${workshopTabs('home', s)}
75
75
  ${await launchpad(s, await isAdmin(s.username))}
76
76
  <div class="sw-recents">
package/src/builder/ui.ts CHANGED
@@ -50,7 +50,24 @@ export async function send(reply: FastifyReply, s: Session, body: string) {
50
50
  return reply.type('text/html').send(body);
51
51
  }
52
52
 
53
- export type Section = 'home' | 'apps' | 'users' | 'sql' | 'developers' | 'gallery' | 'reporter' | 'team';
53
+ export type Section = 'home' | 'apps' | 'users' | 'sql' | 'developers' | 'gallery' | 'reporter' | 'team' | 'admin';
54
+
55
+ /**
56
+ * (UX-3) The rail's current section, from the request's path: one rule for every builder page, so a page doesn't have
57
+ * to name it (and can't name the wrong one). Administration: the workspace utilities and what they lead to.
58
+ */
59
+ const ADMIN_PATHS = /^\/builder\/(utilities|users|developers|workspaces?|workspace-(?:import|export|files|credentials)|instance|installation|mail|mail-queue|network|dashboard|telemetry|ui-defaults|icons|vector-providers|ai|sso|backups|db-users|remote-servers|remote-databases|rest-catalogs|app-groups|cross-app)(?:[/?#]|$)/;
60
+ export function sectionOf(path: string | undefined, fallback: Section): Section {
61
+ if (!path) return fallback;
62
+ if (/^\/builder\/home(?:[/?#]|$)/.test(path)) return 'home';
63
+ if (/^\/builder\/sql(?:[/?#]|$)/.test(path)) return 'sql';
64
+ if (/^\/builder\/(gallery)(?:[/?#]|$)/.test(path)) return 'gallery';
65
+ if (/^\/builder\/reporter(?:[/?#]|$)/.test(path)) return 'reporter';
66
+ if (/^\/builder\/team(?:[/?#]|$)/.test(path)) return 'team';
67
+ if (ADMIN_PATHS.test(path)) return 'admin';
68
+ if (/^\/builder\/(apps|pages|create|import|blueprints|working-copies)(?:[/?#]|$)/.test(path)) return 'apps';
69
+ return fallback;
70
+ }
54
71
 
55
72
  // ------------------------------------------------------------------ builder head
56
73
  // Builder-only stylesheets and scripts, loaded on every builder page after
@@ -100,14 +117,15 @@ const initials = (name: string) => name.replace(/[^\p{L}\p{N}]+/gu, ' ').trim().
100
117
  * Administration, Help, the developer's menu), an environment banner (091), a compact toolbar with
101
118
  * back button and breadcrumb, then the page itself.
102
119
  */
103
- export function shell(s: Session, title: string, crumbs: [string, string?][], main: Raw, section: Section = 'apps', opts: ShellOpts = {}) {
120
+ export function shell(s: Session, title: string, crumbs: [string, string?][], main: Raw, pageSection: Section = 'apps', opts: ShellOpts = {}) {
121
+ // (UX-3) the rail's current entry follows the request's path; a page's own choice only where the path says nothing
122
+ const section = sectionOf(s.path, pageSection);
123
+ const railLabels = s.state.__RAIL === 'labels';
104
124
  const ok = takeFlash(s, '__FLASH');
105
125
  const err = takeFlash(s, '__ERROR');
106
126
  const theme = builderTheme(s);
107
127
  // (sprint 121) the developer's builder language (i18n.ts); known English breadcrumb labels are translated here
108
128
  const tx = tr(s);
109
- // the application these pages belong to (for the Search entry), from the breadcrumb
110
- const appId = crumbs.map(([, href]) => /^\/builder\/apps\/(\d+)$/.exec(href ?? '')?.[1]).find(Boolean);
111
129
  const rail = (key: Section | 'search' | 'help', href: string, ic: string, label: string, extra: Raw | '' = '') =>
112
130
  html`<li><a href="${href}" class="rail-link" title="${label}"${section === key ? raw(' aria-current="page"') : ''}${extra}>${bicon(ic)}<span class="rail-label">${label}</span></a></li>`;
113
131
  // (sprint 74) the default-password warning: on the Workspace home (sprint 109), the App Builder home and the Developers page only, until
@@ -131,40 +149,16 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
131
149
  ${rail('gallery', `${BASE}/gallery`, 'package', tx('rail.gallery'))}
132
150
  ${rail('reporter', `${BASE}/reporter`, 'pie-chart', tx('rail.reporter'))}
133
151
  ${rail('team', `${BASE}/team`, 'kanban', tx('rail.team'))}
134
- ${appId ? rail('search', `${BASE}/apps/${appId}/search`, 'search', tx('rail.search_app')) : ''}
152
+ ${rail('admin', `${BASE}/utilities`, 'settings', tx('rail.admin'))}
135
153
  </ul>
136
154
  <ul class="rail-list rail-bottom">
137
- <li><details class="menu rail-user rail-admin">
138
- <summary class="rail-link${section === 'users' || section === 'developers' ? ' rail-open' : ''}" title="${tx('rail.admin')}"${section === 'users' || section === 'developers' ? raw(' aria-current="page"') : ''}>${icon('settings')}<span class="rail-label">${tx('rail.admin')}</span></summary>
139
- <div class="menu-panel">
140
- <div class="menu-section menu-links">
141
- <strong>${tx('rail.admin')}</strong>
142
- <a href="${BASE}/utilities">${icon('grid')} ${tx('admin.utilities')}</a>
143
- </div>
144
- ${s.workspace?.admin ? html`<div class="menu-section menu-links">
145
- <span class="small muted">${tx('admin.manage_service', { name: s.workspace.name })}</span>
146
- <a href="${BASE}/workspaces/${s.workspace.id}#service">${icon('bell')} ${tx('admin.ws_message')}</a>
147
- <a href="${BASE}/workspaces/${s.workspace.id}#preferences">${icon('sliders-horizontal')} ${tx('admin.ws_prefs')}</a>
148
- <a href="${BASE}/workspaces/${s.workspace.id}#links">${icon('external-link')} ${tx('admin.ext_links')}</a>
149
- <a href="${BASE}/workspaces/${s.workspace.id}/utilization">${icon('chart')} ${tx('admin.utilization')}</a>
150
- <a href="${BASE}/mail-queue">${icon('inbox')} ${tx('admin.mail_queue')}</a>
151
- <a href="${BASE}/network">${icon('globe')} ${tx('admin.network')}</a>
152
- <a href="${BASE}/workspaces/${s.workspace.id}">${icon('layers')} ${tx('admin.ws_devs')}</a>
153
- </div>` : ''}
154
- <div class="menu-section menu-links">
155
- <a href="${BASE}/users">${icon('user')} ${tx('admin.users')}</a>
156
- <a href="${BASE}/developers">${icon('users')} ${tx('admin.developers')}</a>
157
- <a href="${BASE}/dashboard">${icon('activity')} ${tx('admin.monitor')}</a>
158
- ${s.instanceAdmin ? html`<a href="${BASE}/db-users">${icon('key')} ${tx('admin.db_users')}</a>` : ''}
159
- <a href="${BASE}/developers">${icon('key')} ${tx('admin.change_my_password')}</a>
160
- </div>
161
- </div></details></li>
162
155
  ${s.workspace?.links?.length ? html`<li><details class="menu rail-user rail-admin rail-extensions">
163
156
  <summary class="rail-link" title="${tx('rail.extensions')}">${icon('puzzle')}<span class="rail-label">${tx('rail.extensions')}</span></summary>
164
157
  <div class="menu-panel"><div class="menu-section menu-links"><strong>${tx('rail.extensions')}</strong>
165
158
  ${s.workspace.links.map((l) => html`<a href="${l.url}" target="_blank" rel="noopener noreferrer"${l.description ? html` title="${l.description}"` : ''}>${icon('external-link')} ${l.label}</a>`)}
166
159
  </div></div></details></li>` : ''}
167
160
  ${rail('help', 'https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md', 'help', tx('rail.help'), raw(' target="_blank" rel="noopener"'))}
161
+ <li><form method="post" action="${BASE}/rail">${csrf(s)}<button class="rail-link rail-toggle" name="labels" value="${railLabels ? '' : 'labels'}" title="${tx(railLabels ? 'rail.labels_hide' : 'rail.labels_show')}" aria-pressed="${railLabels ? 'true' : 'false'}">${bicon(railLabels ? 'left' : 'right')}<span class="rail-label">${tx(railLabels ? 'rail.labels_hide' : 'rail.labels_show')}</span></button></form></li>
168
162
  <li><details class="menu rail-user">
169
163
  <summary class="rail-avatar${weak ? ' has-dot' : ''}" title="${weak ? tx('user.weak_title', { user: s.username }) : s.username}"><span aria-hidden="true">${initials(s.username ?? '')}</span><span class="sr-only">${tx(weak ? 'user.account_weak' : 'user.account', { user: s.username })}</span></summary>
170
164
  <div class="menu-panel">
@@ -213,7 +207,7 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
213
207
  </footer>
214
208
  </div>
215
209
  <div id="builder-texts" hidden data-texts="${JSON.stringify(clientTexts(tx.lang))}"></div>`,
216
- `builder-body ide${opts.full ? ' ide-full' : ''}`,
210
+ `builder-body ide${opts.full ? ' ide-full' : ''}${railLabels ? ' rail-labels' : ''}`,
217
211
  {},
218
212
  builderHead(),
219
213
  { theme, lang: tx.lang },
@@ -225,6 +219,23 @@ export const BANNER_COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue'
225
219
 
226
220
  export const csrf = (s: Session) => html`<input type="hidden" name="__csrf" value="${s.csrf_token}">`;
227
221
 
222
+ /**
223
+ * (UX-5) A builder page's header: its one heading, its one primary action (accent), a few secondary actions and a ⋯ menu
224
+ * for the rest. Every builder page should have exactly one H1 and one primary button.
225
+ */
226
+ export function pageHeader(title: string | Raw, o: { sub?: Raw | string; primary?: [href: string, label: string, ic?: string]; secondary?: [href: string, label: string, ic?: string][]; more?: [href: string, label: string, ic?: string][]; moreLabel?: string } = {}) {
227
+ const link = ([href, label, ic]: [string, string, string?], cls: string) => html`<a class="${cls}" href="${href}">${ic ? icon(ic) : ''}<span>${label}</span></a>`;
228
+ return html`<div class="page-head">
229
+ <div class="page-head-title"><h1>${title}</h1>${o.sub ? html`<p class="page-head-sub muted">${o.sub}</p>` : ''}</div>
230
+ <div class="page-head-actions">
231
+ ${(o.secondary ?? []).map((a) => link(a, 'btn'))}
232
+ ${o.more?.length ? html`<details class="menu page-head-more"><summary class="btn btn-icon" title="${o.moreLabel ?? ''}"><span aria-hidden="true">⋯</span><span class="sr-only">${o.moreLabel ?? ''}</span></summary>
233
+ <div class="menu-panel align-right"><div class="menu-section menu-links">${o.more.map(([href, label, ic]) => html`<a href="${href}">${ic ? icon(ic) : ''} ${label}</a>`)}</div></div></details>` : ''}
234
+ ${o.primary ? link(o.primary, 'btn btn-hot') : ''}
235
+ </div>
236
+ </div>`;
237
+ }
238
+
228
239
  export function input(name: string, label: string, value: unknown, opts: { type?: string; help?: string; required?: boolean; placeholder?: string; auto?: string } = {}) {
229
240
  return html`<div class="field"><label class="label" for="f_${name}">${label}</label>
230
241
  <input id="f_${name}" name="${name}" type="${opts.type ?? 'text'}" value="${value ?? ''}"${opts.required ? raw(' required') : ''} placeholder="${opts.placeholder}"${opts.auto ? raw(` autocomplete="${opts.auto}"`) : ''}>
@@ -243,7 +254,7 @@ export const region = (title: string, body: Raw | Raw[], extra: Raw | '' = '', i
243
254
  html`<section class="region region-standard"${id ? raw(` id="${id.replace(/[^a-z0-9-]/g, '')}"`) : ''}><header class="region-header"><h2>${title}</h2>${extra}</header><div class="region-body">${body}</div></section>`;
244
255
 
245
256
  /** Title and tabs of an application's builder pages (Pages, Shared Components, …), in the session's builder language. */
246
- export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'activity' | 'api' | 'search' | 'advisor' | 'utilities', s?: Pick<Session, 'state'>) => {
257
+ export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'activity' | 'api' | 'search' | 'advisor' | 'utilities', s?: Pick<Session, 'state'>, primary?: [href: string, label: string, ic: string]) => {
247
258
  const tx = tr(s);
248
259
  const tab = (key: typeof active, href: string, ic: string, label: string) =>
249
260
  html`<a class="ide-tab" href="${href}"${active === key ? raw(' aria-current="page"') : ''}>${bicon(ic)}<span>${label}</span></a>`;
@@ -256,7 +267,9 @@ export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'act
256
267
  <a class="btn btn-sm" href="${BASE}/apps/${a.id}/backups">${icon('history')} ${tx('app.head.backups')}</a>
257
268
  <a class="btn btn-sm" href="${BASE}/apps/${a.id}/availability">${icon('power')} ${tx('app.head.availability')}</a>
258
269
  <a class="btn btn-sm" href="${BASE}/apps/${a.id}/export">${icon('download')} ${tx('app.head.export')}</a>
259
- <a class="btn btn-sm btn-run" href="/a/${a.alias}" target="_blank" rel="noopener">${icon('play')} ${tx('common.run')}</a>
270
+ <!-- (UX-5) one primary action: the page's own (Create page on the Pages tab), else Run -->
271
+ <a class="btn btn-sm${primary ? '' : ' btn-run'}" href="/a/${a.alias}" target="_blank" rel="noopener">${icon('play')} ${tx('common.run')}</a>
272
+ ${primary ? html`<a class="btn btn-sm btn-hot" href="${primary[0]}">${icon(primary[2])} ${primary[1]}</a>` : ''}
260
273
  </div></div>
261
274
  <nav class="ide-tabs" aria-label="${tx('app.head.tabs')}">
262
275
  ${tab('pages', `${BASE}/apps/${a.id}`, 'pages', tx('app.tab.pages'))}
@@ -323,6 +336,7 @@ export async function developer(req: Req, reply: FastifyReply) {
323
336
  // (sprint 121) a session from before the developer's language was kept in it: the stored choice (language.ts)
324
337
  if (s.state.__BLANG === undefined) await loadLanguage(s);
325
338
  setRequestTranslator(tr(s)); // (sprint 164) scopedTx in this request's handler and helpers
339
+ s.path = req.url; // (UX-3) the rail's section
326
340
  // (sprint 126, SEC-2) the two-factor policy: a banner during the grace period, then only the Security page until they enrol
327
341
  delete s.state.__MFA_DUE;
328
342
  const admin = await isAdmin(s.username);