@antelopejs/dms 0.10.0 → 0.11.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 (92) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/dist/config.d.ts +0 -2
  3. package/dist/config.js +0 -4
  4. package/dist/config.js.map +1 -1
  5. package/dist/implementations/dms/frontend-modules.js +17 -6
  6. package/dist/implementations/dms/frontend-modules.js.map +1 -1
  7. package/dist/implementations/dms/manifest.d.ts +6 -0
  8. package/dist/implementations/dms/manifest.js +10 -7
  9. package/dist/implementations/dms/manifest.js.map +1 -1
  10. package/dist/index.js +4 -7
  11. package/dist/index.js.map +1 -1
  12. package/dist/store/listing.js +1 -2
  13. package/dist/store/listing.js.map +1 -1
  14. package/dist/store/operations.js +1 -2
  15. package/dist/store/operations.js.map +1 -1
  16. package/dist/utils/ensure-default-tenant.d.ts +2 -1
  17. package/dist/utils/ensure-default-tenant.js +13 -1
  18. package/dist/utils/ensure-default-tenant.js.map +1 -1
  19. package/dist/utils/export-jobs.js +2 -1
  20. package/dist/utils/export-jobs.js.map +1 -1
  21. package/frontend-vue/layers/dms-core/app/build/composables/renderNow.ts +1 -2
  22. package/frontend-vue/layers/dms-core/app/composables/translation/useTranslation.ts +3 -0
  23. package/frontend-vue/layers/dms-core/app/composables/useApiError.ts +3 -5
  24. package/frontend-vue/layers/dms-core/app/composables/useFieldErrors.ts +61 -26
  25. package/frontend-vue/layers/dms-core/app/utils/formatter.ts +5 -37
  26. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -0
  27. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -0
  28. package/frontend-vue/layers/dms-core/shared/types/app-config.ts +0 -2
  29. package/frontend-vue/layers/dms-layout/app/app.config.ts +0 -1
  30. package/frontend-vue/layers/dms-layout/app/assets/css/main.css +11 -1
  31. package/frontend-vue/layers/dms-layout/app/assets/fonts/Geist-Italic-Variable.woff2 +0 -0
  32. package/frontend-vue/layers/dms-layout/app/assets/fonts/Geist-Variable.woff2 +0 -0
  33. package/frontend-vue/layers/dms-layout/app/assets/fonts/GeistMono-Italic-Variable.woff2 +0 -0
  34. package/frontend-vue/layers/dms-layout/app/assets/fonts/GeistMono-Variable.woff2 +0 -0
  35. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardPageBody.vue +2 -7
  36. package/frontend-vue/layers/dms-layout/app/build/components/layout/Footer.vue +2 -106
  37. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +0 -8
  38. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue +0 -10
  39. package/frontend-vue/layers/dms-layout/app/build/utils/github-repository.ts +2 -2
  40. package/frontend-vue/layers/dms-layout/app/composables/layout/useServedHeaderActions.ts +24 -4
  41. package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStore.ts +10 -2
  42. package/frontend-vue/layers/dms-layout/app/composables/page/usePageRecordDeletion.ts +162 -0
  43. package/frontend-vue/layers/dms-layout/app/custom-layouts/DefaultLayout.vue +2 -7
  44. package/frontend-vue/layers/dms-layout/app/custom-layouts/SettingsLayout.vue +2 -7
  45. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +7 -0
  46. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-navigation.client.ts +9 -9
  47. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-personal-pages.client.ts +9 -9
  48. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-quick-actions.client.ts +7 -7
  49. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-session.client.ts +11 -10
  50. package/frontend-vue/layers/dms-layout/app/theme/actions.ts +5 -4
  51. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +6 -4
  52. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +6 -4
  53. package/frontend-vue/layers/dms-ui/app/build/components/form/FormLoadError.vue +5 -1
  54. package/frontend-vue/layers/dms-ui/app/build/components/table/FiltersRow.vue +11 -1
  55. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/actionError.ts +6 -1
  56. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/numberSuffix.ts +2 -5
  57. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +26 -5
  58. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/relationRows.ts +22 -1
  59. package/frontend-vue/layers/dms-ui/app/build/composables/form/formRecordLoad.ts +11 -5
  60. package/frontend-vue/layers/dms-ui/app/build/composables/form/formValidation.ts +36 -4
  61. package/frontend-vue/layers/dms-ui/app/build/composables/form/repeaterTable.ts +17 -4
  62. package/frontend-vue/layers/dms-ui/app/build/composables/table/useRelationFilterLabels.ts +30 -6
  63. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +13 -3
  64. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/writeError.ts +21 -0
  65. package/frontend-vue/layers/dms-ui/app/build/utils/codeHighlight.ts +67 -29
  66. package/frontend-vue/layers/dms-ui/app/build/utils/htmlGrammar.ts +79 -0
  67. package/frontend-vue/layers/dms-ui/app/build/utils/shellParser.ts +1 -1
  68. package/frontend-vue/layers/dms-ui/app/build/utils/streamHighlight.ts +90 -0
  69. package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +23 -12
  70. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +1 -0
  71. package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +53 -22
  72. package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +27 -6
  73. package/frontend-vue/layers/dms-ui/app/components/form/components/RepeaterTable.vue +14 -7
  74. package/frontend-vue/layers/dms-ui/app/components/table-view/CascaderPath.vue +74 -25
  75. package/frontend-vue/layers/dms-ui/app/components/table-view/KanbanBoard.vue +3 -9
  76. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +16 -1
  77. package/frontend-vue/layers/dms-ui/app/composables/form/types/field.ts +12 -0
  78. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +47 -7
  79. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +19 -9
  80. package/frontend-vue/layers/dms-ui/app/utils/cascader.ts +94 -3
  81. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +4 -1
  82. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +4 -1
  83. package/frontend-vue/package.json +6 -2
  84. package/package.json +7 -7
  85. package/skills/dms-module/SKILL.md +1 -1
  86. package/skills/dms-pages/REFERENCE.md +7 -3
  87. package/dist/hooks/tenant-instances.d.ts +0 -1
  88. package/dist/hooks/tenant-instances.js +0 -13
  89. package/dist/hooks/tenant-instances.js.map +0 -1
  90. package/frontend-vue/layers/dms-core/app/composables/hydration/isHydratingComponent.ts +0 -29
  91. package/frontend-vue/layers/dms-layout/app/composables/layout/usePageRecord.ts +0 -50
  92. package/frontend-vue/layers/dms-layout/app/utils/pageRecord.ts +0 -71
@@ -67,16 +67,14 @@ export function resolveApiErrorMessage(error: unknown): string {
67
67
 
68
68
  export function useApiError(error: unknown, options?: Options) {
69
69
  const toast = useToast();
70
- const dmsApp = useDmsApp();
71
- const { t } = dmsApp.$i18n;
72
- const translate = (message: string) => resolveApiMessage(t, message);
70
+ const { processApiMessage } = getAppTranslation();
73
71
 
74
72
  const title = options?.title || resolveApiErrorTitle(error);
75
73
  const description = options?.description || resolveApiErrorMessage(error);
76
74
 
77
75
  toast.add({
78
- title: translate(title),
79
- description: translate(description),
76
+ title: processApiMessage(title),
77
+ description: processApiMessage(description),
80
78
  color: "error",
81
79
  });
82
80
  }
@@ -1,5 +1,8 @@
1
1
  import { nextTick, reactive } from "vue";
2
- import { resolveApiMessage } from "./translation/useTranslation";
2
+ import {
3
+ type I18nTranslate,
4
+ resolveApiMessage,
5
+ } from "./translation/useTranslation";
3
6
  import { resolveApiErrorMessage } from "./useApiError";
4
7
 
5
8
  /**
@@ -116,24 +119,23 @@ const FIELD_ERROR_KEYS = {
116
119
  /** Issue types whose size is a count (characters, items), not a value. */
117
120
  const COUNTED_TYPES = new Set(["string", "array", "set"]);
118
121
 
119
- // The data API (`@antelopejs/interface-data-api`) words its refusals in
120
- // English and lists the fields after a colon.
121
- const DATA_API_MESSAGES: ReadonlyArray<{ pattern: RegExp; message: string }> = [
122
- {
123
- pattern: /^Missing mandatory fields:\s*(.+)$/,
124
- message: FIELD_ERROR_KEYS.required,
125
- },
126
- {
127
- pattern: /^Invalid field type\(s\):\s*(.+)$/,
128
- message: FIELD_ERROR_KEYS.invalid,
129
- },
130
- ];
122
+ // The codes the data API (`@antelopejs/interface-data-api`) refuses a body
123
+ // with, `{ code, fields }`: the message each field shows.
124
+ const DATA_API_CODES: Readonly<Record<string, string>> = {
125
+ missing_mandatory_fields: FIELD_ERROR_KEYS.required,
126
+ invalid_field_types: FIELD_ERROR_KEYS.invalid,
127
+ };
128
+
129
+ // The same, naming the field: "Loss reason is required".
130
+ const NAMED_FIELD_ERROR_KEYS: Readonly<Record<string, string>> = {
131
+ [FIELD_ERROR_KEYS.required]: "dms.field_errors.named_required",
132
+ [FIELD_ERROR_KEYS.invalid]: "dms.field_errors.named_invalid",
133
+ };
131
134
 
132
135
  // `name: String must contain…`: one validation issue prefixed with its field.
133
136
  const FIELD_PREFIXED_MESSAGE = /^([\w.]+):\s+(.+)$/s;
134
137
  // `error.invalid_2fa_code`, `page.settings.roles.error.name_taken`.
135
138
  const BARE_I18N_KEY = /^[a-z0-9_]+(\.[a-z0-9_]+)+$/i;
136
- const LIST_SEPARATOR = /\s*,\s*/;
137
139
 
138
140
  /** The HTTP status of a fetch error; undefined for a network failure. */
139
141
  export function apiErrorStatus(error: unknown): number | undefined {
@@ -265,16 +267,19 @@ function fromFieldObject(body: Record<string, unknown>): ApiFieldError[] {
265
267
  ];
266
268
  }
267
269
 
270
+ /** `{ code, fields }`, the data API's refusal of a body; undefined otherwise. */
271
+ function fromCodedFields(
272
+ body: Record<string, unknown>,
273
+ ): ApiFieldError[] | undefined {
274
+ const { code, fields } = body;
275
+ const message = typeof code === "string" ? DATA_API_CODES[code] : undefined;
276
+ if (!message || !Array.isArray(fields)) return undefined;
277
+ return fields
278
+ .filter((field): field is string => typeof field === "string")
279
+ .map((field) => ({ field, message }));
280
+ }
281
+
268
282
  function fromText(text: string): ApiFieldError[] {
269
- for (const { pattern, message } of DATA_API_MESSAGES) {
270
- const match = pattern.exec(text);
271
- if (match) {
272
- return match[1]!
273
- .split(LIST_SEPARATOR)
274
- .filter(Boolean)
275
- .map((field) => ({ field, message }));
276
- }
277
- }
278
283
  const prefixed = FIELD_PREFIXED_MESSAGE.exec(text);
279
284
  if (prefixed) {
280
285
  const [, field, message] = prefixed;
@@ -320,7 +325,7 @@ function fromBody(body: unknown): ApiFieldError[] {
320
325
  const record = body as Record<string, unknown>;
321
326
  if (isIssueList(record.issues)) return fromIssues(record.issues);
322
327
  if (isIssueList(record.errors)) return fromIssues(record.errors);
323
- return fromFieldObject(record);
328
+ return fromCodedFields(record) ?? fromFieldObject(record);
324
329
  }
325
330
 
326
331
  function isClientError(status: number | undefined): status is number {
@@ -337,8 +342,8 @@ function isClientError(status: number | undefined): status is number {
337
342
  *
338
343
  * Read, in order: a code the form maps to a field (`codes`, for any 4xx), a
339
344
  * zod issue list (what `assertValidation` answers), a `{ field, message }`
340
- * body (with optional `values` and `params`), the data API's "Missing mandatory fields: a, b" / "Invalid field
341
- * type(s): a" and a `field: message` text. A network failure, a 403, a 429
345
+ * body (with optional `values` and `params`), the data API's
346
+ * `{ code, fields }` and a `field: message` text. A network failure, a 403, a 429
342
347
  * or a 5xx never resolves to a field.
343
348
  *
344
349
  * @returns The field errors, one per field (the first issue wins), and
@@ -371,6 +376,36 @@ export function resolveFieldErrors<F extends string>(
371
376
  return { fields, hasUnmatched: hasUnmatched || fields.length === 0 };
372
377
  }
373
378
 
379
+ /**
380
+ * The data API's refusal of a body (`{ code, fields }`) as one sentence per
381
+ * field naming it, translated: "Loss reason is required." Undefined for any
382
+ * other error. `labelOf` gives a field's label, translated; the field's id
383
+ * stands in for one it does not know.
384
+ */
385
+ export function describeFieldErrors(
386
+ error: unknown,
387
+ translate: I18nTranslate,
388
+ labelOf: (field: string) => string | undefined,
389
+ ): string | undefined {
390
+ const status = apiErrorStatus(error);
391
+ if (status === undefined || !VALIDATION_STATUSES.has(status)) {
392
+ return undefined;
393
+ }
394
+ const body = bodyOf(error);
395
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
396
+ return undefined;
397
+ }
398
+ const entries = fromCodedFields(body as Record<string, unknown>);
399
+ if (!entries?.length) return undefined;
400
+ return entries
401
+ .map(({ field, message }) =>
402
+ translate(NAMED_FIELD_ERROR_KEYS[message]!, {
403
+ field: labelOf(field) ?? field,
404
+ }),
405
+ )
406
+ .join(" ");
407
+ }
408
+
374
409
  /**
375
410
  * The message of an error that belongs to no field, for its toast: the API's
376
411
  * message key, the first validation issue's (or a generic one), else the
@@ -227,50 +227,18 @@ export function formatDateTime(
227
227
  });
228
228
  }
229
229
 
230
- // The narrow no-break space French (and a few other locales) groups digits
231
- // with: the interface's font draws it a fraction of a digit wide, so "1 234"
232
- // read as "1234" beside a column's "12 345". Groups take a full no-break space.
233
- const NARROW_GROUP_SEPARATOR = " ";
234
- const GROUP_SEPARATOR = " ";
235
-
236
- /** `Intl.NumberFormat`, its digit groups always shown and visible. */
237
- export interface ReadableNumberFormat {
238
- format(value: number): string;
239
- formatToParts(value: number): Intl.NumberFormatPart[];
240
- resolvedOptions(): Intl.ResolvedNumberFormatOptions;
241
- }
242
-
243
- function readablePart(part: Intl.NumberFormatPart): Intl.NumberFormatPart {
244
- return part.type === "group" && part.value === NARROW_GROUP_SEPARATOR
245
- ? { ...part, value: GROUP_SEPARATOR }
246
- : part;
247
- }
248
-
249
230
  /**
250
231
  * `new Intl.NumberFormat(locale, options)` the way the interface writes a
251
232
  * figure: a four-digit one grouped like a longer one ("1 234" next to
252
- * "12 345", where Spanish or Polish would leave "1234" by default), with a
253
- * group separator wide enough to see. `options.useGrouping` still decides.
254
- * Throws as `Intl.NumberFormat` does on options it refuses.
233
+ * "12 345", where Spanish or Polish would leave "1234" by default).
234
+ * `options.useGrouping` still decides. Throws as `Intl.NumberFormat` does on
235
+ * options it refuses.
255
236
  */
256
237
  export function readableNumberFormat(
257
238
  locale?: string,
258
239
  options?: Intl.NumberFormatOptions,
259
- ): ReadableNumberFormat {
260
- const format = new Intl.NumberFormat(locale, {
261
- useGrouping: true,
262
- ...options,
263
- });
264
- const formatToParts = (value: number) =>
265
- format.formatToParts(value).map(readablePart);
266
- return {
267
- format: (value) =>
268
- formatToParts(value)
269
- .map((part) => part.value)
270
- .join(""),
271
- formatToParts,
272
- resolvedOptions: () => format.resolvedOptions(),
273
- };
240
+ ): Intl.NumberFormat {
241
+ return new Intl.NumberFormat(locale, { useGrouping: true, ...options });
274
242
  }
275
243
 
276
244
  /**
@@ -408,6 +408,8 @@
408
408
  "too_short": "This value is too short.",
409
409
  "too_small": "This value is too small.",
410
410
  "too_large": "This value is too large.",
411
+ "named_required": "{field} is required.",
412
+ "named_invalid": "{field} is not valid.",
411
413
  "with_values": "{message}: {values}",
412
414
  "code_incomplete": "Enter every digit of the code.",
413
415
  "password_rules": "The password doesn’t meet every rule.",
@@ -408,6 +408,8 @@
408
408
  "too_short": "Cette valeur est trop courte.",
409
409
  "too_small": "Cette valeur est trop petite.",
410
410
  "too_large": "Cette valeur est trop grande.",
411
+ "named_required": "{field} est obligatoire.",
412
+ "named_invalid": "{field} n’est pas valide.",
411
413
  "with_values": "{message} : {values}",
412
414
  "code_incomplete": "Saisissez tous les chiffres du code.",
413
415
  "password_rules": "Le mot de passe ne respecte pas toutes les règles.",
@@ -67,8 +67,6 @@ export interface DmsFooterConfig {
67
67
  * the copyright.
68
68
  */
69
69
  copyright?: string | false;
70
- /** The GitHub "Star" button of the DMS repository. */
71
- githubStar?: boolean;
72
70
  /** The language picker. */
73
71
  localeSwitcher?: boolean;
74
72
  }
@@ -215,7 +215,6 @@ export default {
215
215
  defaults: { logo: dmsLogo(), colors: dmsColors() },
216
216
  footer: {
217
217
  copyright: "Antelope",
218
- githubStar: true,
219
218
  localeSwitcher: true,
220
219
  },
221
220
  },
@@ -31,7 +31,12 @@
31
31
  }
32
32
 
33
33
  /* Self-hosted Geist (SIL OFL 1.1, files and LICENSE.txt in ../fonts).
34
- Variable files cover weights 100–900 and keep builds deterministic offline. */
34
+ Variable files cover weights 100–900 and keep builds deterministic offline.
35
+ Geist has no narrow no-break space (U+202F), which French and a few other
36
+ locales group digits with: a fallback face drew it a fraction of a digit
37
+ wide, so "1 234" read as "1234". Each file here maps U+202F to its no-break
38
+ space glyph (fontTools: cmap[0x202F] = cmap[0xA0]); keep that mapping when
39
+ updating them. */
35
40
  @font-face {
36
41
  font-family: "Geist";
37
42
  font-style: normal;
@@ -155,6 +160,11 @@
155
160
  items, ~10px menus and 16px modals. */
156
161
  --ui-radius: calc(0.5rem / 1.5);
157
162
  --dms-radius-card: 0.875rem;
163
+ /* Pill tabs keep their v2 10px track, 7px tab and 4px count at the
164
+ default radius, and follow a theme that changes it. */
165
+ --dms-radius-tab-list: calc(var(--ui-radius) * 1.875);
166
+ --dms-radius-tab: calc(var(--ui-radius) * 1.3125);
167
+ --dms-radius-tab-badge: calc(var(--ui-radius) * 0.75);
158
168
 
159
169
  /* v2 shell metrics: 48px navbar, 56px sidebar brand row. */
160
170
  --ui-header-height: 3rem;
@@ -5,7 +5,6 @@ import PageHeaderActionsOutlet from "./PageHeaderActionsOutlet.vue";
5
5
  import PageSkeleton from "./PageSkeleton.vue";
6
6
  import type { LayoutHeaderAction } from "./PageHeaderActionBar.vue";
7
7
  import { providePageHeaderActions } from "../../../composables/layout/usePageHeaderActions";
8
- import type { PageRecordSource } from "../../../composables/layout/usePageRecord";
9
8
 
10
9
  interface Props {
11
10
  hideHeader?: boolean;
@@ -15,14 +14,12 @@ interface Props {
15
14
  /** Header buttons declared by the backend page (`headerActions`). */
16
15
  headerActions?: LayoutHeaderAction[];
17
16
  /**
18
- * The page query the server resolved the buttons' availability for, set
19
- * when one declares an `availability` (`headerActionsQuery`).
17
+ * The page query the server served the buttons for, set when one declares
18
+ * an `availability` or the page reads a `record` (`headerActionsQuery`).
20
19
  */
21
20
  headerActionsQuery?: Record<string, string>;
22
21
  /** The route parameters (`:id`) they were resolved for, likewise. */
23
22
  headerActionsParams?: Record<string, string>;
24
- /** The record the page shows, which header buttons may act on (`record`). */
25
- record?: PageRecordSource;
26
23
  /** Space between the page header and the page. */
27
24
  headerClass?: string;
28
25
  }
@@ -35,7 +32,6 @@ const props = withDefaults(defineProps<Props>(), {
35
32
  headerActions: () => [],
36
33
  headerActionsQuery: undefined,
37
34
  headerActionsParams: undefined,
38
- record: undefined,
39
35
  headerClass: "pb-6",
40
36
  });
41
37
 
@@ -75,7 +71,6 @@ const PAGE_SLOT_CLASS =
75
71
  :actions="props.headerActions"
76
72
  :served-query="props.headerActionsQuery"
77
73
  :served-params="props.headerActionsParams"
78
- :record="props.record"
79
74
  />
80
75
  </template>
81
76
  </PageHeader>
@@ -1,17 +1,8 @@
1
1
  <script setup lang="ts">
2
2
  import type { SelectItem } from "@nuxt/ui";
3
- import {
4
- GITHUB_REPOSITORY,
5
- GITHUB_REPOSITORY_URL,
6
- } from "../../utils/github-repository";
7
3
 
8
4
  const EXTERNAL_LINK_PATTERN = /^https?:\/\//;
9
5
  const DEFAULT_COPYRIGHT = "Antelope";
10
- const GITHUB_REPOSITORY_API = `https://api.github.com/repos/${GITHUB_REPOSITORY}`;
11
- const GITHUB_STARS_CACHE_KEY = "dms-github-stars";
12
- // GitHub allows 60 anonymous API calls an hour per visitor: the count is
13
- // cached for the session and refreshed hourly.
14
- const GITHUB_STARS_CACHE_TTL_MS = 60 * 60 * 1000;
15
6
 
16
7
  const { locale, setLocale, t } = useI18n();
17
8
  const { loggedIn: isLoggedIn } = useDmsUserSession();
@@ -25,7 +16,6 @@ const copyright = computed(() =>
25
16
  ? null
26
17
  : processI18n(footer.value.copyright ?? DEFAULT_COPYRIGHT),
27
18
  );
28
- const showsGithubStar = computed(() => footer.value.githubStar !== false);
29
19
  const showsLocaleSwitcher = computed(
30
20
  () => footer.value.localeSwitcher !== false,
31
21
  );
@@ -39,71 +29,6 @@ function isExternal(to: string): boolean {
39
29
  return EXTERNAL_LINK_PATTERN.test(to);
40
30
  }
41
31
 
42
- interface CachedStars {
43
- count: number;
44
- fetchedAt: number;
45
- }
46
-
47
- const githubStars = ref<number | null>(null);
48
- // The count is fetched after mount: a placeholder holds its place until then,
49
- // and the segment only goes away if no count could be read.
50
- const githubStarsSettled = ref(false);
51
- const formattedGithubStars = computed(() =>
52
- githubStars.value === null
53
- ? ""
54
- : formatNumber(githubStars.value, locale.value, { notation: "compact" }),
55
- );
56
-
57
- function readCachedStars(): CachedStars | null {
58
- try {
59
- const raw = sessionStorage.getItem(GITHUB_STARS_CACHE_KEY);
60
- return raw ? (JSON.parse(raw) as CachedStars) : null;
61
- } catch {
62
- return null;
63
- }
64
- }
65
-
66
- function writeCachedStars(count: number): void {
67
- try {
68
- sessionStorage.setItem(
69
- GITHUB_STARS_CACHE_KEY,
70
- JSON.stringify({ count, fetchedAt: Date.now() } satisfies CachedStars),
71
- );
72
- } catch {
73
- /* storage unavailable: the count is fetched again next time */
74
- }
75
- }
76
-
77
- // Starring itself happens on GitHub (it needs the visitor's GitHub session);
78
- // the button shows the live count and opens the repository.
79
- async function loadGithubStars(): Promise<void> {
80
- const cached = readCachedStars();
81
- if (cached && Date.now() - cached.fetchedAt < GITHUB_STARS_CACHE_TTL_MS) {
82
- githubStars.value = cached.count;
83
- return;
84
- }
85
- try {
86
- const response = await fetch(GITHUB_REPOSITORY_API, {
87
- headers: { Accept: "application/vnd.github+json" },
88
- });
89
- if (!response.ok) return;
90
- const { stargazers_count: count } = (await response.json()) as {
91
- stargazers_count?: unknown;
92
- };
93
- if (typeof count !== "number") return;
94
- githubStars.value = count;
95
- writeCachedStars(count);
96
- } catch {
97
- /* offline or rate-limited: the button shows without a count */
98
- }
99
- }
100
-
101
- onMounted(async () => {
102
- if (!showsGithubStar.value) return;
103
- await loadGithubStars();
104
- githubStarsSettled.value = true;
105
- });
106
-
107
32
  // A signed-in user on a page of this layout (the account switcher) switches
108
33
  // their profile's language too: the session applies that one on every load,
109
34
  // and the server writes in it, so switching the screen alone would leave the
@@ -119,8 +44,8 @@ async function onLocaleChange(code: unknown) {
119
44
 
120
45
  <template>
121
46
  <!-- v2 .au-foot: one quiet 12.5px row, the locale switcher pushed right.
122
- On phones the links get a taller invisible hit area and the star
123
- button grows to 32px, so each stays easy to tap. -->
47
+ On phones the links get a taller invisible hit area, so each stays
48
+ easy to tap. -->
124
49
  <footer
125
50
  class="border-default text-dimmed flex flex-wrap items-center gap-x-[18px] gap-y-1.5 border-t px-5 py-3 text-[12.5px] max-sm:justify-center"
126
51
  >
@@ -144,35 +69,6 @@ async function onLocaleChange(code: unknown) {
144
69
  </DmsLink>
145
70
  </template>
146
71
 
147
- <!-- GitHub star button: icon + "Star" | live count, opens the repository. -->
148
- <a
149
- v-if="showsGithubStar"
150
- :href="GITHUB_REPOSITORY_URL"
151
- target="_blank"
152
- rel="noopener noreferrer"
153
- :aria-label="t('empty_layout.footer.github_star_label')"
154
- :title="t('empty_layout.footer.github_star_label')"
155
- class="border-default text-muted hover:text-highlighted hover:border-accented hover:bg-elevated inline-flex h-6 items-stretch overflow-hidden rounded-md border transition-colors max-sm:h-8"
156
- >
157
- <span class="flex items-center gap-1.5 px-2 font-medium">
158
- <UIcon name="i-ph-github-logo" class="size-3.5" :aria-hidden="true" />
159
- <UIcon name="i-ph-star" class="size-3" :aria-hidden="true" />
160
- {{ t("empty_layout.footer.github_star") }}
161
- </span>
162
- <span
163
- v-if="githubStars !== null || !githubStarsSettled"
164
- class="border-default flex items-center border-s px-2 font-mono tabular-nums"
165
- >
166
- <USkeleton
167
- :aria-label="t('dms.a11y.loading')"
168
- v-if="githubStars === null"
169
- aria-hidden="true"
170
- class="h-2.5 w-6"
171
- />
172
- <template v-else>{{ formattedGithubStars }}</template>
173
- </span>
174
- </a>
175
-
176
72
  <USelect
177
73
  v-if="showsLocaleSwitcher"
178
74
  :model-value="locale"
@@ -24,7 +24,6 @@ import {
24
24
  findServedQuickAction,
25
25
  } from "#dms-ui/app/build/utils/dispatchQuickActionTarget";
26
26
  import { DEFAULT_PAGE_HEADER_ACTIONS_ORDER } from "../../../composables/layout/usePageHeaderActions";
27
- import { type PageRecord, withPageRecord } from "../../../utils/pageRecord";
28
27
 
29
28
  /**
30
29
  * A button of the page header as the server serves it (`DefaultLayout({
@@ -44,8 +43,6 @@ export interface LayoutHeaderAction
44
43
  buttonId?: string;
45
44
  /** Place among the header's buttons, smallest first; `0` by default. */
46
45
  order?: number;
47
- /** Acts on the page's record: disabled while it loads, gone without it. */
48
- requiresRecord?: boolean;
49
46
  }
50
47
 
51
48
  interface PageHeaderActionBarProps {
@@ -55,8 +52,6 @@ interface PageHeaderActionBarProps {
55
52
  * among the buttons: after every button of an order up to this one.
56
53
  */
57
54
  slotOrder?: number;
58
- /** The record the page shows, for the buttons acting on it. */
59
- record?: PageRecord;
60
55
  }
61
56
 
62
57
  interface ResolvedHeaderAction {
@@ -71,7 +66,6 @@ interface ResolvedHeaderAction {
71
66
 
72
67
  const props = withDefaults(defineProps<PageHeaderActionBarProps>(), {
73
68
  slotOrder: DEFAULT_PAGE_HEADER_ACTIONS_ORDER,
74
- record: undefined,
75
69
  });
76
70
 
77
71
  const { processI18n } = useTranslation();
@@ -279,8 +273,6 @@ function lockForPreview(action: ResolvedHeaderAction): ResolvedHeaderAction {
279
273
 
280
274
  const resolved = computed(() =>
281
275
  props.actions
282
- .map((action) => withPageRecord(action, props.record))
283
- .filter((action): action is LayoutHeaderAction => action !== null)
284
276
  .map(resolveAction)
285
277
  .filter((action): action is ResolvedHeaderAction => action !== null)
286
278
  .map(lockForPreview)
@@ -5,10 +5,6 @@ import PageHeaderActionBar, {
5
5
  } from "./PageHeaderActionBar.vue";
6
6
  import { injectPageHeaderActionsHost } from "../../../composables/layout/usePageHeaderActions";
7
7
  import { useServedHeaderActions } from "../../../composables/layout/useServedHeaderActions";
8
- import {
9
- type PageRecordSource,
10
- usePageRecord,
11
- } from "../../../composables/layout/usePageRecord";
12
8
 
13
9
  interface PageHeaderActionsOutletProps {
14
10
  /** Header buttons declared by the backend page (`headerActions`). */
@@ -17,18 +13,13 @@ interface PageHeaderActionsOutletProps {
17
13
  servedQuery?: Record<string, string>;
18
14
  /** The route parameters the server resolved their availability for. */
19
15
  servedParams?: Record<string, string>;
20
- /** The record the page shows (`record` of the layout). */
21
- record?: PageRecordSource;
22
16
  }
23
17
 
24
18
  const props = withDefaults(defineProps<PageHeaderActionsOutletProps>(), {
25
19
  servedQuery: undefined,
26
20
  servedParams: undefined,
27
- record: undefined,
28
21
  });
29
22
 
30
- const pageRecord = usePageRecord(() => props.record);
31
-
32
23
  const actions = useServedHeaderActions({
33
24
  actions: () => props.actions,
34
25
  servedQuery: () => props.servedQuery,
@@ -82,7 +73,6 @@ const showsServerMarkup = computed(
82
73
  <PageHeaderActionBar
83
74
  :actions="actions"
84
75
  :slot-order="host?.actionsOrder.value"
85
- :record="pageRecord"
86
76
  >
87
77
  <component :is="pageActions" v-if="pageActions" />
88
78
  </PageHeaderActionBar>
@@ -1,5 +1,5 @@
1
1
  /** The DMS repository on GitHub, as `owner/name`. */
2
- export const GITHUB_REPOSITORY = "AntelopeJS/dms";
2
+ const GITHUB_REPOSITORY = "AntelopeJS/dms";
3
3
 
4
- /** Opened by the auth footer's star button and by the dashboard's star prompt. */
4
+ /** Opened by the dashboard's star prompt. */
5
5
  export const GITHUB_REPOSITORY_URL = `https://github.com/${GITHUB_REPOSITORY}`;
@@ -1,4 +1,11 @@
1
- import { computed, shallowRef, watch, type ComputedRef } from "vue";
1
+ import {
2
+ computed,
3
+ onScopeDispose,
4
+ shallowRef,
5
+ watch,
6
+ type ComputedRef,
7
+ } from "vue";
8
+ import { onPageBlocksRefresh } from "#dms-ui/app/utils/blockRefresh";
2
9
  import type { LayoutHeaderAction } from "../../build/components/layout/PageHeaderActionBar.vue";
3
10
  import type { PageLayout } from "../../types/page";
4
11
  import {
@@ -13,8 +20,8 @@ interface ServedHeaderActionsOptions {
13
20
  /** The buttons the page layout was served with. */
14
21
  actions: () => LayoutHeaderAction[];
15
22
  /**
16
- * The page query their availability was resolved for, set when one of them
17
- * declares an `availability` (`headerActionsQuery`).
23
+ * The page query they were served for, set when one of them declares an
24
+ * `availability` or the page reads a `record` (`headerActionsQuery`).
18
25
  */
19
26
  servedQuery: () => Record<string, string> | undefined;
20
27
  /** The route parameters (`:id`) their availability was resolved for. */
@@ -29,7 +36,10 @@ interface ServedHeaderActionsOptions {
29
36
  * for that query and takes its buttons, keeping the last ones meanwhile. The
30
37
  * same goes for the route parameters: the layout of `/projects/project/:id`
31
38
  * is cached once for the page, whichever record it was first served for.
32
- * Buttons without an availability never cause a request.
39
+ * A write on the page (`refreshPageBlocks()`, after a header button or a
40
+ * form) asks again too: it may have changed the record the buttons read, or
41
+ * what their availability checks. Buttons without an availability, on a page
42
+ * reading no record, never cause a request.
33
43
  */
34
44
  export function useServedHeaderActions({
35
45
  actions,
@@ -81,12 +91,22 @@ export function useServedHeaderActions({
81
91
  void askFor(fillRouteParams(layoutUrl, params), current);
82
92
  }
83
93
 
94
+ function askAgain() {
95
+ const layoutUrl = pageRoute()?.metadata?.layoutUrl;
96
+ if (!servedQuery() || !layoutUrl) return;
97
+ void askFor(
98
+ fillRouteParams(layoutUrl, pageRoute()?.params ?? {}),
99
+ pageQueryValues(route.query as Record<string, unknown>),
100
+ );
101
+ }
102
+
84
103
  if (!import.meta.env.SSR) {
85
104
  watch(
86
105
  [() => route.query, () => route.path, servedQuery, servedParams],
87
106
  follow,
88
107
  { immediate: true },
89
108
  );
109
+ onScopeDispose(onPageBlocksRefresh(askAgain));
90
110
  }
91
111
 
92
112
  return computed(() => refreshed.value ?? actions());
@@ -69,16 +69,22 @@ export const useModuleStore = () => {
69
69
  }
70
70
 
71
71
  let isStopped = false;
72
+ let stopWaiting: (() => void) | undefined;
72
73
 
73
74
  /**
74
75
  * Starts following once mounted: reads again only when the first read
75
76
  * failed, or something moves; a listing already on screen stays as is.
76
- * A first read still in flight (a client navigation) is waited for.
77
+ * A read still in flight (a client navigation, or the background read of a
78
+ * return to the page) is waited for until it settles.
77
79
  */
78
80
  function start(): void {
79
81
  isStopped = false;
82
+ stopWaiting?.();
80
83
  if (initial.pending.value) {
81
- void Promise.resolve(initial).then(() => {
84
+ stopWaiting = watch(initial.pending, (pending) => {
85
+ if (pending) return;
86
+ stopWaiting?.();
87
+ stopWaiting = undefined;
82
88
  if (!isStopped) start();
83
89
  });
84
90
  return;
@@ -139,6 +145,8 @@ export const useModuleStore = () => {
139
145
 
140
146
  function stop(): void {
141
147
  isStopped = true;
148
+ stopWaiting?.();
149
+ stopWaiting = undefined;
142
150
  if (followTimer) clearTimeout(followTimer);
143
151
  followTimer = undefined;
144
152
  }