pgkiln 0.43.0 → 0.44.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.
@@ -44,7 +44,10 @@ export interface Locale {
44
44
  timeZoneFrom: 'user' | 'browser' | 'app' | 'database';
45
45
  }
46
46
 
47
- export const THEME_COOKIE = 'pgkiln_theme';
47
+ /** (sprint 79) The light/dark choice of a user who is not signed in, per application (path /a/<alias>). */
48
+ export const THEME_COOKIE = 'pgkiln_mode';
49
+ /** The cookie's options: the application's path, a year. */
50
+ export const themeCookieOptions = (base: string) => ({ path: base, sameSite: 'lax' as const, secure: process.env.COOKIE_SECURE === 'true', maxAge: 365 * 86400 });
48
51
  const THEMES: ThemeMode[] = ['auto', 'light', 'dark'];
49
52
  export const isTheme = (v: unknown): v is ThemeMode => THEMES.includes(v as ThemeMode);
50
53
 
@@ -292,9 +292,14 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
292
292
  </form>
293
293
  </div>
294
294
  </details>`
295
- : html`<a href="${ctx.base}/login">${t('login.sign_in_link')}</a>`
295
+ : // (sprint 79, UI-3; APEX: the user menu of a public page) signed out: Appearance and Sign in in the user menu
296
+ ctx.locale.themeChoice || styleChoice(ctx.app)
297
+ ? html`<details class="menu t-user"><summary>${icon('user')}<span>${t('login.sign_in_link')}</span></summary>
298
+ <div class="menu-panel align-right">${themeSwitch(ctx, here(ctx))}${styleSwitch(ctx, here(ctx))}
299
+ <div class="menu-section"><a href="${ctx.base}/login">${icon('arrow-right')} ${t('login.sign_in_link')}</a></div></div></details>`
300
+ : html`<a href="${ctx.base}/login">${t('login.sign_in_link')}</a>`
296
301
  : ctx.locale.themeChoice || styleChoice(ctx.app)
297
- ? html`<details class="menu t-user"><summary>${icon('settings')}<span class="sr-only">${t('theme.label')}</span></summary>
302
+ ? html`<details class="menu t-user"><summary title="${t('theme.label')}">${icon('settings')}<span class="sr-only">${t('theme.label')}</span></summary>
298
303
  <div class="menu-panel align-right">${themeSwitch(ctx, here(ctx))}${styleSwitch(ctx, here(ctx))}</div></details>`
299
304
  : ''}
300
305
  </header>
@@ -567,17 +567,20 @@ export function pagerNav(ctx: PageContext, r: Region, info: PageInfo, linkAttr:
567
567
  </nav>`;
568
568
  }
569
569
 
570
- /** (sprint 76) Rows per page as a select in the pager (a GET form; app.js sends it on change, else its button). */
570
+ /** (sprint 76) Rows per page as a select in the pager (a detached GET form; app.js sends it on change, else its button). */
571
571
  function sizeChooser(ctx: PageContext, r: Region, info: PageInfo): Raw | '' {
572
572
  if (r.type !== 'report' || r.config.interactive === false) return '';
573
573
  const st = reportState(ctx, r);
574
574
  const keep = [...ctx.params.entries()].filter(([k]) => k !== key(r, 'n') && k !== key(r, 'p') && k !== key(r, 'k') && !['clear', 'cs'].includes(k));
575
575
  const t = ctx.locale.t;
576
576
  if (info.total !== null && info.total <= PAGE_SIZES[0] && st.size === PAGE_SIZES[0]) return '';
577
- return html`<form method="get" action="${ctx.base}/${ctx.page.page_no}" class="pager-size">${keep.map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}${ctx.dialog ? html`<input type="hidden" name="dialog" value="1">` : ''}
577
+ // the region sits inside the page's form: the GET form is detached, the select and its button join it by its id
578
+ const form = `rn${r.id}`;
579
+ ctx.detached.push(html`<form id="${form}" method="get" action="${ctx.base}/${ctx.page.page_no}">${keep.map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}${ctx.dialog ? html`<input type="hidden" name="dialog" value="1">` : ''}</form>`);
580
+ return html`<span class="pager-size">
578
581
  <label for="r${r.id}_size">${t('report.rows_per_page')}</label>
579
- <select id="r${r.id}_size" name="${key(r, 'n')}" data-go>${[...new Set([...PAGE_SIZES, st.size])].sort((a, b) => a - b).map((n) => html`<option value="${n}"${n === st.size ? raw(' selected') : ''}>${n}</option>`)}</select>
580
- <button class="btn btn-sm no-js-only">${t('report.go')}</button></form>`;
582
+ <select id="r${r.id}_size" name="${key(r, 'n')}" form="${form}" data-go>${[...new Set([...PAGE_SIZES, st.size])].sort((a, b) => a - b).map((n) => html`<option value="${n}"${n === st.size ? raw(' selected') : ''}>${n}</option>`)}</select>
583
+ <button class="btn btn-sm no-js-only" form="${form}">${t('report.go')}</button></span>`;
581
584
  }
582
585
 
583
586
  /** (sprint 76) The most values a column heading's menu lists. */
@@ -788,6 +791,8 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
788
791
  const st = reportState(ctx, r);
789
792
  const searchable = r.config.searchable !== false;
790
793
  const interactive = r.config.interactive !== false && searchable;
794
+ // (sprint 78; APEX: a faceted search's results have no search bar) one search field: the facets'
795
+ const searchBox = !ctx.page.regions.some((x) => x.type === 'facets' && x.config.search === true && Number(x.config.report) === r.id && ctx.vis?.regions.has(x.id) !== false);
791
796
 
792
797
  let res: pg.QueryResult<any[]>;
793
798
  let pageNo = st.page;
@@ -1124,14 +1129,14 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
1124
1129
  ? html`<div class="report-toolbar">
1125
1130
  ${filterItems}
1126
1131
  ${searchable
1127
- ? html`<div class="search" role="search">
1128
- <div class="search-field">${interactive
1132
+ ? html`<div class="search"${searchBox ? raw(' role="search"') : ''}>
1133
+ ${searchBox ? html`<div class="search-field">${interactive
1129
1134
  ? html`<label class="sr-only" for="r${r.id}_qc">${t('report.search_column')}</label>
1130
- <select id="r${r.id}_qc" class="search-scope" name="${key(r, 'qc')}" form="${searchForm}">
1131
- <option value="">${t('report.all_columns')}</option>${cols.map(({ f }) => html`<option value="${f.name}"${f.name === st.searchCol ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>`
1135
+ <span class="search-scope-wrap" title="${t('report.search_column')}">${icon('search')}<select id="r${r.id}_qc" class="search-scope" name="${key(r, 'qc')}" form="${searchForm}">
1136
+ <option value="">${t('report.all_columns')}</option>${cols.map(({ f }) => html`<option value="${f.name}"${f.name === st.searchCol ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select></span>`
1132
1137
  : ''}
1133
1138
  <input type="search" name="${key(r, 'q')}" value="${st.search}" placeholder="${t('report.search_all')}" form="${searchForm}" aria-label="${t('report.search')} ${r.title ?? ''}"></div>
1134
- <button class="btn" form="${searchForm}">${t('report.go')}</button>
1139
+ <button class="btn no-js-only" form="${searchForm}">${t('report.go')}</button>` : ''}
1135
1140
  ${actionsMenu}
1136
1141
  </div>`
1137
1142
  : ''}
@@ -44,7 +44,7 @@ import { PassThrough } from 'node:stream';
44
44
  import { reportPdf } from './pdf.ts';
45
45
  import { renderDocument } from './documents.ts';
46
46
  import { pwaHead } from './pwa.ts';
47
- import { resolveLocale, THEME_COOKIE, translateApp, translatePage, validTimeZone, type Locale } from './locale.ts';
47
+ import { resolveLocale, THEME_COOKIE, themeCookieOptions, translateApp, translatePage, validTimeZone, type Locale } from './locale.ts';
48
48
  import { chrome, dialogClosePage, languagePicker, renderPage } from './render.ts';
49
49
  import { LOGIN_ERROR, LOGIN_USER, safeNext, signInForm } from './signin.ts';
50
50
  import { passwordResetRoutes } from './password-reset.ts';
@@ -122,13 +122,14 @@ export async function signIn(req: FastifyRequest, reply: FastifyReply, a: App, o
122
122
  // the browser's time zone (sent before signing in) stays with the new session
123
123
  if (typeof oldSession.state.__TZ === 'string') s.state.__TZ = oldSession.state.__TZ;
124
124
  // the account's preferences: light/dark, language and time zone
125
- const pref = await runtime.one<{ theme_pref: string; language: string | null; time_zone: string | null }>(
125
+ const pref = await runtime.one<{ theme_pref: string | null; language: string | null; time_zone: string | null }>(
126
126
  'select theme_pref, language, time_zone from meta.account where lower(username) = lower($1)', [username]);
127
127
  if (pref) {
128
- s.state.__THEME = pref.theme_pref;
128
+ // (sprint 79) no choice (null): the application's own mode
129
+ if (pref.theme_pref) s.state.__THEME = pref.theme_pref;
129
130
  if (pref.language) s.state.__LANG = pref.language;
130
131
  if (pref.time_zone) s.state.__TZ_PREF = pref.time_zone;
131
- if (a.theme?.user_choice !== false) reply.setCookie(THEME_COOKIE, pref.theme_pref, { path: '/', sameSite: 'lax', secure: process.env.COOKIE_SECURE === 'true', maxAge: 365 * 86400 });
132
+ if (pref.theme_pref && a.theme?.user_choice !== false) reply.setCookie(THEME_COOKIE, pref.theme_pref, themeCookieOptions(base));
132
133
  }
133
134
  // the account's style variant for this app (honoured only while the app offers it: styles.ts)
134
135
  const style = await runtime.one<{ style: string }>(