@eifi1/ui-kit 0.17.0 → 0.19.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/README.md +83 -4
- package/dist/chart.d.ts +13 -1
- package/dist/components/account-chips.d.ts +153 -0
- package/dist/components/account-chips.js +135 -0
- package/dist/components/account-chips.js.map +1 -0
- package/dist/components/account-settings-labels.d.ts +10 -2
- package/dist/components/account-settings-labels.js +3 -0
- package/dist/components/account-settings-labels.js.map +1 -1
- package/dist/components/amount-input.d.ts +13 -1
- package/dist/components/button-group.d.ts +13 -1
- package/dist/components/calculator.d.ts +13 -1
- package/dist/components/chip.d.ts +25 -1
- package/dist/components/chip.js +143 -6
- package/dist/components/chip.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +42 -1
- package/dist/components/confirm-dialog.js +36 -5
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.d.ts +13 -1
- package/dist/components/danger-confirm.d.ts +55 -4
- package/dist/components/danger-confirm.js +67 -30
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-cells.d.ts +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +2 -0
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/facing-pair.d.ts +13 -1
- package/dist/components/file-button.d.ts +13 -1
- package/dist/components/file-dropzone.d.ts +13 -1
- package/dist/components/floating-panel.d.ts +4 -1
- package/dist/components/floating-panel.js +15 -1
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/form-actions.d.ts +13 -1
- package/dist/components/form-actions.js +3 -0
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/legal.d.ts +84 -0
- package/dist/components/legal.js +55 -0
- package/dist/components/legal.js.map +1 -0
- package/dist/components/money-field.d.ts +13 -1
- package/dist/components/number-field.d.ts +14 -2
- package/dist/components/number-field.js +23 -22
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +13 -1
- package/dist/components/numpad-sheet.d.ts +13 -1
- package/dist/components/passkeys-setting.d.ts +27 -1
- package/dist/components/passkeys-setting.js +31 -5
- package/dist/components/passkeys-setting.js.map +1 -1
- package/dist/components/progress-bar.d.ts +15 -1
- package/dist/components/progress-bar.js +8 -1
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/reauth-dialog.d.ts +90 -0
- package/dist/components/reauth-dialog.js +158 -0
- package/dist/components/reauth-dialog.js.map +1 -0
- package/dist/components/series-chart.d.ts +13 -1
- package/dist/components/server-wake.d.ts +60 -0
- package/dist/components/server-wake.js +79 -0
- package/dist/components/server-wake.js.map +1 -0
- package/dist/components/settings-fields.d.ts +13 -1
- package/dist/components/share-card.d.ts +64 -0
- package/dist/components/share-card.js +350 -0
- package/dist/components/share-card.js.map +1 -0
- package/dist/components/status-dot.d.ts +2 -0
- package/dist/components/text-link.d.ts +13 -1
- package/dist/components/time-input.d.ts +13 -1
- package/dist/components/toast.d.ts +10 -1
- package/dist/components/toast.js +42 -2
- package/dist/components/toast.js.map +1 -1
- package/dist/components/toggle-group.js +5 -2
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/translation-review-editor.d.ts +56 -0
- package/dist/components/translation-review-editor.js +159 -0
- package/dist/components/translation-review-editor.js.map +1 -0
- package/dist/components/translation-review-labels.d.ts +92 -0
- package/dist/components/translation-review-labels.js +67 -0
- package/dist/components/translation-review-labels.js.map +1 -0
- package/dist/components/translation-review-parts.d.ts +97 -0
- package/dist/components/translation-review-parts.js +163 -0
- package/dist/components/translation-review-parts.js.map +1 -0
- package/dist/components/translation-review.d.ts +94 -0
- package/dist/components/translation-review.js +344 -0
- package/dist/components/translation-review.js.map +1 -0
- package/dist/components/ui.d.ts +13 -1
- package/dist/components/ui.js +43 -28
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/components/user-avatar.d.ts +2 -0
- package/dist/components/write-lock.d.ts +68 -0
- package/dist/components/write-lock.js +33 -0
- package/dist/components/write-lock.js.map +1 -0
- package/dist/{data-table-CpkWUJBY.d.ts → data-table-3mi2R3so.d.ts} +19 -1
- package/dist/data-table.d.ts +1 -1
- package/dist/hooks/use-file-drop.d.ts +13 -1
- package/dist/hooks/use-overlay-history.js +16 -0
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/i18n/defaults.d.ts +13 -1
- package/dist/i18n/defaults.js +15 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +81 -0
- package/dist/i18n/{locales/de.js → german.js} +110 -9
- package/dist/i18n/german.js.map +1 -0
- package/dist/i18n/kit-labels.d.ts +13 -1
- package/dist/i18n/kit-labels.js +5 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +243 -0
- package/dist/i18n/languages.js +70 -0
- package/dist/i18n/languages.js.map +1 -0
- package/dist/i18n/locales/de-CH.d.ts +26 -8
- package/dist/i18n/locales/de-CH.js +7 -3
- package/dist/i18n/locales/de-CH.js.map +1 -1
- package/dist/i18n/locales/en.d.ts +13 -1
- package/dist/i18n/locales/en.js +8 -0
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +13 -1
- package/dist/i18n/locales/es.js +127 -27
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +13 -1
- package/dist/i18n/locales/fr.js +101 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +13 -1
- package/dist/i18n/locales/hu.js +105 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +13 -1
- package/dist/i18n/locales/it.js +134 -34
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +13 -1
- package/dist/i18n/locales/zh.js +101 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +102 -0
- package/dist/i18n/review.js +158 -0
- package/dist/i18n/review.js.map +1 -0
- package/dist/i18n/swiss.d.ts +3 -3
- package/dist/i18n/swiss.js.map +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.js +97 -1
- package/dist/index.js.map +1 -1
- package/dist/{kit-labels-C4uB_zmg.d.ts → kit-labels-D1J2_jyD.d.ts} +236 -9
- package/dist/lib/floating-stack.d.ts +31 -0
- package/dist/lib/floating-stack.js +42 -0
- package/dist/lib/floating-stack.js.map +1 -0
- package/dist/lib/server-wake.d.ts +146 -0
- package/dist/lib/server-wake.js +122 -0
- package/dist/lib/server-wake.js.map +1 -0
- package/dist/lib/translation-review.d.ts +232 -0
- package/dist/lib/translation-review.js +194 -0
- package/dist/lib/translation-review.js.map +1 -0
- package/dist/rhf/fields.d.ts +13 -1
- package/dist/rhf/form.d.ts +13 -1
- package/dist/rhf.d.ts +13 -1
- package/dist/search/global-search.d.ts +3 -1
- package/dist/search/global-search.js.map +1 -1
- package/dist/shell/app-shell.d.ts +13 -1
- package/dist/shell/top-bar-brand.d.ts +13 -1
- package/dist/shell/topbar-action-menu.d.ts +2 -0
- package/dist/shell.d.ts +13 -1
- package/dist/wizard/stepper-nav.d.ts +13 -1
- package/dist/wizard.d.ts +13 -1
- package/package.json +1 -1
- package/src/components/account-chips.tsx +302 -0
- package/src/components/account-settings-labels.ts +16 -2
- package/src/components/chip.tsx +239 -6
- package/src/components/confirm-dialog.tsx +82 -7
- package/src/components/danger-confirm.tsx +118 -33
- package/src/components/data-table.tsx +20 -0
- package/src/components/floating-panel.tsx +19 -1
- package/src/components/form-actions.tsx +22 -3
- package/src/components/legal.tsx +159 -0
- package/src/components/number-field.tsx +32 -26
- package/src/components/passkeys-setting.tsx +68 -7
- package/src/components/progress-bar.tsx +21 -1
- package/src/components/reauth-dialog.tsx +273 -0
- package/src/components/server-wake.tsx +156 -0
- package/src/components/share-card.tsx +615 -0
- package/src/components/toast.tsx +72 -2
- package/src/components/toggle-group.tsx +5 -2
- package/src/components/translation-review-editor.tsx +261 -0
- package/src/components/translation-review-labels.ts +152 -0
- package/src/components/translation-review-parts.tsx +269 -0
- package/src/components/translation-review.tsx +502 -0
- package/src/components/ui.tsx +89 -32
- package/src/components/write-lock.tsx +96 -0
- package/src/hooks/use-overlay-history.ts +51 -0
- package/src/i18n/defaults.ts +14 -0
- package/src/i18n/{locales/de.ts → german.ts} +120 -13
- package/src/i18n/kit-labels.tsx +29 -2
- package/src/i18n/languages.ts +285 -0
- package/src/i18n/locales/de-CH.ts +17 -8
- package/src/i18n/locales/en.ts +8 -0
- package/src/i18n/locales/es.ts +128 -26
- package/src/i18n/locales/fr.ts +103 -0
- package/src/i18n/locales/hu.ts +107 -0
- package/src/i18n/locales/it.ts +135 -33
- package/src/i18n/locales/zh.ts +102 -0
- package/src/i18n/review.ts +193 -0
- package/src/i18n/swiss.ts +3 -3
- package/src/index.ts +86 -1
- package/src/lib/floating-stack.ts +66 -0
- package/src/lib/server-wake.ts +291 -0
- package/src/lib/translation-review.ts +465 -0
- package/src/search/global-search.tsx +3 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +0 -61
- package/dist/i18n/locales/de-CH-informal.js +0 -8
- package/dist/i18n/locales/de-CH-informal.js.map +0 -1
- package/dist/i18n/locales/de-informal.d.ts +0 -82
- package/dist/i18n/locales/de-informal.js +0 -37
- package/dist/i18n/locales/de-informal.js.map +0 -1
- package/dist/i18n/locales/de.d.ts +0 -66
- package/dist/i18n/locales/de.js.map +0 -1
- package/src/i18n/locales/de-CH-informal.ts +0 -10
- package/src/i18n/locales/de-informal.ts +0 -61
package/src/index.ts
CHANGED
|
@@ -109,7 +109,13 @@ export * from "./components/password-strength";
|
|
|
109
109
|
export * from "./components/page-contents";
|
|
110
110
|
export * from "./components/disclosure";
|
|
111
111
|
export * from "./components/dialog-frame";
|
|
112
|
-
|
|
112
|
+
// Named, not `*`: danger-confirm also holds the password field and pending hook that
|
|
113
|
+
// ReauthDialog shares with it, which are kit-internal.
|
|
114
|
+
export { DangerConfirm, DEFAULT_DANGER_CONFIRM_LABELS, typedMatches } from "./components/danger-confirm";
|
|
115
|
+
export type { DangerConfirmLabels, DangerConfirmProps, TypedMatch } from "./components/danger-confirm";
|
|
116
|
+
// 0.18: re-authentication before a sensitive action (Kurvenschmiede 4).
|
|
117
|
+
export { ReauthDialog, DEFAULT_REAUTH_DIALOG_LABELS } from "./components/reauth-dialog";
|
|
118
|
+
export type { ReauthDialogProps, ReauthDialogLabels } from "./components/reauth-dialog";
|
|
113
119
|
// `useConfirm()` — the promise-based replacement for `window.confirm`, one host per app.
|
|
114
120
|
export * from "./components/confirm-dialog";
|
|
115
121
|
// A non-modal corner panel and its round trigger (keksdose's assistant launcher).
|
|
@@ -174,6 +180,22 @@ export * from "./components/description-list";
|
|
|
174
180
|
// The Save / Cancel row forms and dialogs end in, and the editable line-item repeater
|
|
175
181
|
// (journal lines, lease components, invoice positions). 0.12.0.
|
|
176
182
|
export * from "./components/form-actions";
|
|
183
|
+
// 0.18: lock every opted-in commit under one provider, the reason in its tooltip
|
|
184
|
+
// (Kurvenschmiede 2). `useCommitReason` stays internal.
|
|
185
|
+
export { WriteLockProvider, useWriteLock, DEFAULT_WRITE_LOCK_LABELS } from "./components/write-lock";
|
|
186
|
+
export type { WriteLock, WriteLockLabels, WriteLockProviderProps } from "./components/write-lock";
|
|
187
|
+
// 0.18: the parts both apps' admin rosters repeat (Kurvenschmiede 6).
|
|
188
|
+
export { RoleChip, AccountStateChip, DateMark, dateColumn, DEFAULT_ACCOUNT_STATE_LABELS, ACCOUNT_STATE_TONES } from "./components/account-chips";
|
|
189
|
+
export type { RoleChipProps, RoleDefinition, RoleVocabulary, AccountState, AccountStateLabels, AccountStateChipProps, DateMarkProps, DateColumnOptions } from "./components/account-chips";
|
|
190
|
+
// 0.18: the cold-start notice keksdose built (#199), for every app: a framework-free
|
|
191
|
+
// watchdog over the app's requests and the corner notice that explains the wait.
|
|
192
|
+
export { createServerWake, serverWake, watchReadsAnd, attachServerWake, wrapFetch } from "./lib/server-wake";
|
|
193
|
+
export type { ServerWakeStage, ServerWakeFilter, ServerWakeOptions, ServerWakeRequest, ServerWakeWatcher, ServerWakeAxiosConfig, AxiosLikeInstance } from "./lib/server-wake";
|
|
194
|
+
export { ServerWakeNotice, useServerWakeStage, DEFAULT_SERVER_WAKE_LABELS } from "./components/server-wake";
|
|
195
|
+
export type { ServerWakeLabels, ServerWakeNoticeProps } from "./components/server-wake";
|
|
196
|
+
// 0.18: sharing — grantees, roles, pending grants, candidates (Kurvenschmiede 1).
|
|
197
|
+
export { ShareCard, ShareDialog, SharePanel, DEFAULT_SHARE_CARD_LABELS } from "./components/share-card";
|
|
198
|
+
export type { ShareCardProps, ShareDialogProps, SharePanelProps, ShareCardLabels, ShareRole, ShareGrantee, SharePendingGrant, ShareCandidate, ShareAddRequest } from "./components/share-card";
|
|
177
199
|
export * from "./components/line-items";
|
|
178
200
|
export * from "./components/progress-bar";
|
|
179
201
|
// Named: skeleton.tsx also holds SKELETON_CLASS, the look StatTile shares — internal.
|
|
@@ -319,3 +341,66 @@ export * from "./search/global-search";
|
|
|
319
341
|
// prop > the provider > the English default. See src/i18n/kit-labels.tsx.
|
|
320
342
|
export * from "./i18n/kit-labels";
|
|
321
343
|
export * from "./i18n/defaults";
|
|
344
|
+
// The kit's words as `key → text` rows for an app's translation review (keksdose).
|
|
345
|
+
export { kitLabelStrings } from "./i18n/review";
|
|
346
|
+
// The seven languages, how an app resolves one, and the kit catalogue loader for it.
|
|
347
|
+
export * from "./i18n/languages";
|
|
348
|
+
// Translation review (0.19): one contract for kastlan's and keksdose's review pages —
|
|
349
|
+
// the pure rows/status/placeholder logic and the presentational page parts.
|
|
350
|
+
export {
|
|
351
|
+
REVIEW_STATUSES,
|
|
352
|
+
fromApiReview,
|
|
353
|
+
toApiWrite,
|
|
354
|
+
flattenStrings,
|
|
355
|
+
keyNamespace,
|
|
356
|
+
placeholderTokens,
|
|
357
|
+
placeholderMismatch,
|
|
358
|
+
reviewStatus,
|
|
359
|
+
keyInArea,
|
|
360
|
+
keyInAreas,
|
|
361
|
+
translationRows,
|
|
362
|
+
summariseRows,
|
|
363
|
+
DEFAULT_TRANSLATION_REVIEW_FILTER,
|
|
364
|
+
filterTranslationRows,
|
|
365
|
+
reviewWrite,
|
|
366
|
+
mergeReviews,
|
|
367
|
+
dropReviews,
|
|
368
|
+
translationCorrections,
|
|
369
|
+
} from "./lib/translation-review";
|
|
370
|
+
export type {
|
|
371
|
+
TranslationVerdict,
|
|
372
|
+
TranslationReview,
|
|
373
|
+
TranslationReviewWrite,
|
|
374
|
+
TranslationReviewKey,
|
|
375
|
+
ReviewStatus,
|
|
376
|
+
TranslationRow,
|
|
377
|
+
ApiTranslationReview,
|
|
378
|
+
ApiTranslationReviewWrite,
|
|
379
|
+
TranslationRowsInput,
|
|
380
|
+
TranslationSummary,
|
|
381
|
+
TranslationReviewFilter,
|
|
382
|
+
TranslationCorrection,
|
|
383
|
+
} from "./lib/translation-review";
|
|
384
|
+
export {
|
|
385
|
+
TranslationReviewPanel,
|
|
386
|
+
TranslationReviewEditor,
|
|
387
|
+
ReviewStatusChip,
|
|
388
|
+
TranslationProgress,
|
|
389
|
+
TranslationLocaleTabs,
|
|
390
|
+
TranslationExportButton,
|
|
391
|
+
REVIEW_STATUS_TONES,
|
|
392
|
+
DEFAULT_TRANSLATION_REVIEW_LABELS,
|
|
393
|
+
} from "./components/translation-review";
|
|
394
|
+
export type {
|
|
395
|
+
TranslationReviewPanelProps,
|
|
396
|
+
TranslationReviewEditorProps,
|
|
397
|
+
ReviewStatusChipProps,
|
|
398
|
+
TranslationProgressProps,
|
|
399
|
+
TranslationLocaleTab,
|
|
400
|
+
TranslationLocaleTabsProps,
|
|
401
|
+
TranslationExportButtonProps,
|
|
402
|
+
TranslationReviewLabels,
|
|
403
|
+
} from "./components/translation-review";
|
|
404
|
+
// The legal pages' shell (0.19, H10): Imprint, Privacy Policy, Terms in every app.
|
|
405
|
+
export { LegalLayout, LegalSection, LegalLinks, DEFAULT_LEGAL_LABELS } from "./components/legal";
|
|
406
|
+
export type { LegalLabels, LegalLink, LegalLinksProps, LegalSectionProps, LegalLayoutProps } from "./components/legal";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What floats over the bottom of the screen right now, so a notice that comes and goes
|
|
3
|
+
* can sit above it instead of on it.
|
|
4
|
+
*
|
|
5
|
+
* kastlan 0.18: its offline pill is a `bottom-start` FloatingActionButton, and
|
|
6
|
+
* `ServerWakeNotice` sits in the same corner — right after a break both show at once
|
|
7
|
+
* (the photo outbox syncs while the first reads wake the server), one over the other.
|
|
8
|
+
* The FAB's `offset` cannot help: the notice comes and goes, and on a phone it is
|
|
9
|
+
* nearly as wide as the screen, so a FAB in EITHER corner can be under it.
|
|
10
|
+
*
|
|
11
|
+
* Measured, not declared: a FloatingActionButton registers its fixed element while it
|
|
12
|
+
* is mounted, and a notice asks {@link clearanceAbove} for the height it has to clear —
|
|
13
|
+
* whatever offset, `extended` label or `hidden` state the controls are in. A control
|
|
14
|
+
* that is `hidden` measures 0 × 0 and is skipped, so kastlan's pill, which stays
|
|
15
|
+
* mounted and hidden while there is nothing to report, costs the notice nothing.
|
|
16
|
+
*
|
|
17
|
+
* Internal: not exported from the barrel.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const occupants = new Set<HTMLElement>();
|
|
21
|
+
const listeners = new Set<() => void>();
|
|
22
|
+
let observer: ResizeObserver | null = null;
|
|
23
|
+
|
|
24
|
+
function notify() {
|
|
25
|
+
for (const listener of listeners) listener();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Track `element` until the returned function is called. Size changes — a label that
|
|
29
|
+
* grows, `hidden` toggling — notify the subscribers too. */
|
|
30
|
+
export function registerFloating(element: HTMLElement): () => void {
|
|
31
|
+
occupants.add(element);
|
|
32
|
+
if (observer === null && typeof ResizeObserver !== "undefined") observer = new ResizeObserver(notify);
|
|
33
|
+
observer?.observe(element);
|
|
34
|
+
notify();
|
|
35
|
+
return () => {
|
|
36
|
+
occupants.delete(element);
|
|
37
|
+
observer?.unobserve(element);
|
|
38
|
+
notify();
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Hear every registration, removal and size change. */
|
|
43
|
+
export function subscribeFloating(listener: () => void): () => void {
|
|
44
|
+
listeners.add(listener);
|
|
45
|
+
return () => {
|
|
46
|
+
listeners.delete(listener);
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The distance from the viewport's bottom edge to the top of the highest visible
|
|
52
|
+
* floating control that shares a column with `element` — what `element` has to clear —
|
|
53
|
+
* or `null` when nothing is under it.
|
|
54
|
+
*/
|
|
55
|
+
export function clearanceAbove(element: HTMLElement | null): number | null {
|
|
56
|
+
if (!element) return null;
|
|
57
|
+
const own = element.getBoundingClientRect();
|
|
58
|
+
let top = Infinity;
|
|
59
|
+
for (const other of occupants) {
|
|
60
|
+
const box = other.getBoundingClientRect();
|
|
61
|
+
if (box.width === 0 || box.height === 0) continue;
|
|
62
|
+
if (box.right <= own.left || box.left >= own.right) continue;
|
|
63
|
+
top = Math.min(top, box.top);
|
|
64
|
+
}
|
|
65
|
+
return top === Infinity ? null : Math.max(0, window.innerHeight - top);
|
|
66
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Is the server asleep, or is the app broken?" — the cold-start watchdog, lifted
|
|
3
|
+
* from keksdose (feedback #199) so kastlan and Kurvenschmiede get it too.
|
|
4
|
+
*
|
|
5
|
+
* All three backends run on Cloud Run with `--min-instances=0`: after an idle
|
|
6
|
+
* period the container is gone and the first request has to start it, which takes
|
|
7
|
+
* several seconds and occasionally more. Nothing in the UI said so, and none of the
|
|
8
|
+
* axios instances has a timeout, so the honest experience was a spinner that looked
|
|
9
|
+
* stuck. This counts the in-flight requests a cold start would actually delay and
|
|
10
|
+
* escalates in two steps, so a merely slow response says "still loading" and only a
|
|
11
|
+
* genuinely long wait blames the sleeping server.
|
|
12
|
+
*
|
|
13
|
+
* Framework-free on purpose (no zustand, no axios, no React): a counter, two timers
|
|
14
|
+
* and a listener set, read by `useServerWakeStage` through `useSyncExternalStore`.
|
|
15
|
+
* The adapters below take the app's axios instance / fetch as an ARGUMENT, so the
|
|
16
|
+
* kit never imports either.
|
|
17
|
+
*
|
|
18
|
+
* WHAT ARMS IT, and why the default is narrow:
|
|
19
|
+
* - reads (GET) — what every page does on open — always;
|
|
20
|
+
* - writes only when the app says so (`watchReadsAnd(/\/auth\/login\b/)`): the
|
|
21
|
+
* auth POSTs that gate the app are the only writes a user waits on before the
|
|
22
|
+
* app exists at all, so a cold start there looks exactly like a broken login.
|
|
23
|
+
* An UPLOAD must never be watched — a statement PDF or a photo takes seconds on a
|
|
24
|
+
* perfectly warm server, and "the server is waking up" during a 4 s upload would
|
|
25
|
+
* simply be false. With the default that holds by itself (an upload is a POST);
|
|
26
|
+
* an app widening `shouldWatch` keeps its upload routes out of it.
|
|
27
|
+
*
|
|
28
|
+
* Nor a DOWNLOAD, for the same reason (kastlan 0.18): axios answers only once the
|
|
29
|
+
* whole body is in, so a generated PDF or a large photo — a GET — ran past both
|
|
30
|
+
* thresholds on a warm server, and held the count for every read beside it. The
|
|
31
|
+
* default filter skips a request whose `responseType` is `"blob"`, `"arraybuffer"` or
|
|
32
|
+
* `"stream"`; a custom filter gets the request as its third argument to do the same.
|
|
33
|
+
* (`wrapFetch` ends the wait at the headers, so a download through it is harmless.)
|
|
34
|
+
*
|
|
35
|
+
* It never arms while the browser is offline, whatever `shouldWatch` says: that state
|
|
36
|
+
* has its own, truthful indicator, and a request that never left the device is not
|
|
37
|
+
* evidence of anything about the server.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** `slow` = longer than a warm server ever takes; `waking` = long enough that a cold
|
|
41
|
+
* start is the only sensible explanation. */
|
|
42
|
+
export type ServerWakeStage = "idle" | "slow" | "waking";
|
|
43
|
+
|
|
44
|
+
/** Decides which requests count. `method` arrives lower-cased (`"get"` when the
|
|
45
|
+
* request named none — axios's and fetch's default); `url` as the client had it;
|
|
46
|
+
* `request` is what the adapter saw — for axios the request config itself, so
|
|
47
|
+
* `responseType` or the app's own fields can decide. */
|
|
48
|
+
export type ServerWakeFilter = (method: string, url: string, request: ServerWakeRequest) => boolean;
|
|
49
|
+
|
|
50
|
+
export interface ServerWakeOptions {
|
|
51
|
+
/** Past this, a warm round trip is out of the question (a cold database query on a
|
|
52
|
+
* free tier can still take a second, so it is not tighter). Default 2000 ms. */
|
|
53
|
+
slowMs?: number;
|
|
54
|
+
/** Past this, the container was almost certainly scaled to zero. Default 7000 ms. */
|
|
55
|
+
wakingMs?: number;
|
|
56
|
+
/** Which requests count. Default {@link watchReadsAnd}`()` — GETs only, downloads
|
|
57
|
+
* excluded. The offline guard runs before it and cannot be overridden. */
|
|
58
|
+
shouldWatch?: ServerWakeFilter;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What a request is, for {@link ServerWakeWatcher.track} and a {@link ServerWakeFilter}. */
|
|
62
|
+
export interface ServerWakeRequest {
|
|
63
|
+
method?: string;
|
|
64
|
+
url?: string;
|
|
65
|
+
/** axios's `responseType`. `"blob"`, `"arraybuffer"` and `"stream"` mark a download,
|
|
66
|
+
* which the default filter does not count. */
|
|
67
|
+
responseType?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ServerWakeWatcher {
|
|
71
|
+
/**
|
|
72
|
+
* Count a request that is going out, and arm the escalation if it is the first.
|
|
73
|
+
* Returns whether it was counted — the caller MUST call {@link end} exactly once
|
|
74
|
+
* for every `true`, or the counter never returns to zero and the notice sticks.
|
|
75
|
+
* (The axios adapter stashes it on the request config for that reason.)
|
|
76
|
+
* `request` reaches the filter as its third argument; default `{ method, url }`.
|
|
77
|
+
*/
|
|
78
|
+
start(method?: string, url?: string, request?: ServerWakeRequest): boolean;
|
|
79
|
+
/** A counted request settled — either way: an error still proves the server
|
|
80
|
+
* answered or gave up. When the last one does, the notice goes away. Never takes
|
|
81
|
+
* the counter below zero. */
|
|
82
|
+
end(): void;
|
|
83
|
+
/** `start` + `end` around a promise (or a function returning one), for a request
|
|
84
|
+
* that does not go through an adapter. Resolves and rejects as the work does. */
|
|
85
|
+
track<T>(work: PromiseLike<T> | (() => PromiseLike<T>), request?: ServerWakeRequest): Promise<T>;
|
|
86
|
+
/** Back to a pristine watchdog — for tests, which share an instance across cases. */
|
|
87
|
+
reset(): void;
|
|
88
|
+
/** `useSyncExternalStore`'s pair. Listeners hear stage changes only. */
|
|
89
|
+
subscribe(listener: () => void): () => void;
|
|
90
|
+
getStage(): ServerWakeStage;
|
|
91
|
+
/** Counted requests currently outstanding. */
|
|
92
|
+
getInFlight(): number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Response types that make a request a download — see the module comment. */
|
|
96
|
+
const DOWNLOAD_RESPONSE_TYPES = new Set(["blob", "arraybuffer", "stream"]);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The usual filter: every GET, plus any other request whose URL matches `writes` —
|
|
100
|
+
* keksdose's `/\/auth\/(login|register|demo-session)\b/` — but never a download
|
|
101
|
+
* (`responseType` `"blob"`, `"arraybuffer"` or `"stream"`). Keep upload routes out of
|
|
102
|
+
* the pattern (see the module comment).
|
|
103
|
+
*/
|
|
104
|
+
export function watchReadsAnd(writes?: RegExp): ServerWakeFilter {
|
|
105
|
+
return (method, url, request) =>
|
|
106
|
+
!DOWNLOAD_RESPONSE_TYPES.has(request?.responseType ?? "") &&
|
|
107
|
+
(method === "get" || (writes !== undefined && writes.test(url)));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isOffline(): boolean {
|
|
111
|
+
return typeof navigator !== "undefined" && navigator.onLine === false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function createServerWake({
|
|
115
|
+
slowMs = 2000,
|
|
116
|
+
wakingMs = 7000,
|
|
117
|
+
shouldWatch = watchReadsAnd(),
|
|
118
|
+
}: ServerWakeOptions = {}): ServerWakeWatcher {
|
|
119
|
+
let inFlight = 0;
|
|
120
|
+
let stage: ServerWakeStage = "idle";
|
|
121
|
+
let slowTimer: ReturnType<typeof setTimeout> | null = null;
|
|
122
|
+
let wakingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
123
|
+
const listeners = new Set<() => void>();
|
|
124
|
+
|
|
125
|
+
const setStage = (next: ServerWakeStage) => {
|
|
126
|
+
if (next === stage) return;
|
|
127
|
+
stage = next;
|
|
128
|
+
for (const listener of listeners) listener();
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const clearTimers = () => {
|
|
132
|
+
if (slowTimer !== null) clearTimeout(slowTimer);
|
|
133
|
+
if (wakingTimer !== null) clearTimeout(wakingTimer);
|
|
134
|
+
slowTimer = null;
|
|
135
|
+
wakingTimer = null;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const start = (method?: string, url?: string, request: ServerWakeRequest = { method, url }): boolean => {
|
|
139
|
+
if (isOffline()) return false;
|
|
140
|
+
if (!shouldWatch((method ?? "get").toLowerCase(), url ?? "", request)) return false;
|
|
141
|
+
inFlight += 1;
|
|
142
|
+
// The clock runs from the FIRST outstanding request: a page firing a second read
|
|
143
|
+
// at 1.9 s is still waiting on the same sleeping server, not starting afresh.
|
|
144
|
+
if (inFlight === 1) {
|
|
145
|
+
clearTimers();
|
|
146
|
+
slowTimer = setTimeout(() => setStage("slow"), slowMs);
|
|
147
|
+
wakingTimer = setTimeout(() => setStage("waking"), wakingMs);
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const end = () => {
|
|
153
|
+
inFlight = Math.max(0, inFlight - 1);
|
|
154
|
+
if (inFlight > 0) return;
|
|
155
|
+
clearTimers();
|
|
156
|
+
setStage("idle");
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
start,
|
|
161
|
+
end,
|
|
162
|
+
track<T>(work: PromiseLike<T> | (() => PromiseLike<T>), request: ServerWakeRequest = {}) {
|
|
163
|
+
const counted = start(request.method, request.url, request);
|
|
164
|
+
let promise: Promise<T>;
|
|
165
|
+
try {
|
|
166
|
+
promise = Promise.resolve(typeof work === "function" ? work() : work);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
// A thunk that throws before it returns a promise settled too.
|
|
169
|
+
promise = Promise.reject(error);
|
|
170
|
+
}
|
|
171
|
+
return counted ? promise.finally(end) : promise;
|
|
172
|
+
},
|
|
173
|
+
reset() {
|
|
174
|
+
inFlight = 0;
|
|
175
|
+
clearTimers();
|
|
176
|
+
setStage("idle");
|
|
177
|
+
},
|
|
178
|
+
subscribe(listener) {
|
|
179
|
+
listeners.add(listener);
|
|
180
|
+
return () => {
|
|
181
|
+
listeners.delete(listener);
|
|
182
|
+
};
|
|
183
|
+
},
|
|
184
|
+
getStage: () => stage,
|
|
185
|
+
getInFlight: () => inFlight,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The shared instance: what `<ServerWakeNotice />`, `useServerWakeStage()`,
|
|
191
|
+
* {@link attachServerWake} and {@link wrapFetch} use when no watcher is passed. GETs
|
|
192
|
+
* only, default timings — an app that also watches its login POST creates its own
|
|
193
|
+
* with {@link createServerWake} and passes it to both ends.
|
|
194
|
+
*/
|
|
195
|
+
export const serverWake: ServerWakeWatcher = createServerWake();
|
|
196
|
+
|
|
197
|
+
// ── axios ─────────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/** The config fields the watchdog reads — what axios's request config has. The whole
|
|
200
|
+
* config reaches the filter, so an app's filter can read the rest of it. */
|
|
201
|
+
export interface ServerWakeAxiosConfig {
|
|
202
|
+
method?: string;
|
|
203
|
+
url?: string;
|
|
204
|
+
responseType?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The slice of an interceptor manager the adapter uses. A method (not a function
|
|
208
|
+
* property) so axios's wider `use(onFulfilled?, onRejected?, options?)` fits it. */
|
|
209
|
+
interface InterceptorManagerLike<V> {
|
|
210
|
+
use(onFulfilled: (value: V) => V | Promise<V>, onRejected: (error: unknown) => unknown): number;
|
|
211
|
+
eject(id: number): void;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** An axios instance, structurally — so the kit types the adapter without importing
|
|
215
|
+
* axios. `axios.create()`'s result fits it as is. */
|
|
216
|
+
export interface AxiosLikeInstance<C extends ServerWakeAxiosConfig, R extends { config: C }> {
|
|
217
|
+
interceptors: {
|
|
218
|
+
request: InterceptorManagerLike<C>;
|
|
219
|
+
response: InterceptorManagerLike<R>;
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Whether the watchdog counted this request — the response side owes a matching
|
|
224
|
+
* `end()` only then. On the config, as keksdose's `_wake` was: it travels with the
|
|
225
|
+
* request onto the response and onto an error. Cleared once settled, so a 401 replay
|
|
226
|
+
* of the same config re-enters the request interceptor and is counted afresh — the
|
|
227
|
+
* pairing stays balanced. */
|
|
228
|
+
const COUNTED = "_serverWake";
|
|
229
|
+
type Marked = { [COUNTED]?: boolean };
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Drive a watcher from an axios instance's interceptors: `attachServerWake(api)`.
|
|
233
|
+
* Returns a function that ejects both interceptors again.
|
|
234
|
+
*
|
|
235
|
+
* Attach it BEFORE the app's own response interceptors: axios runs those in the order
|
|
236
|
+
* they were added, so the wake one ends the wait before a 401 handler awaits a token
|
|
237
|
+
* refresh — otherwise the notice would keep counting a request that has already been
|
|
238
|
+
* answered. (Request-side order does not matter.)
|
|
239
|
+
*/
|
|
240
|
+
export function attachServerWake<C extends ServerWakeAxiosConfig, R extends { config: C }>(
|
|
241
|
+
instance: AxiosLikeInstance<C, R>,
|
|
242
|
+
watcher: ServerWakeWatcher = serverWake,
|
|
243
|
+
): () => void {
|
|
244
|
+
const settle = (config: unknown) => {
|
|
245
|
+
const marked = config as Marked | undefined;
|
|
246
|
+
if (marked?.[COUNTED]) {
|
|
247
|
+
marked[COUNTED] = false;
|
|
248
|
+
watcher.end();
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
const request = instance.interceptors.request.use(
|
|
252
|
+
(config) => {
|
|
253
|
+
(config as C & Marked)[COUNTED] = watcher.start(config.method, config.url, config);
|
|
254
|
+
return config;
|
|
255
|
+
},
|
|
256
|
+
(error) => Promise.reject(error),
|
|
257
|
+
);
|
|
258
|
+
const response = instance.interceptors.response.use(
|
|
259
|
+
(res) => {
|
|
260
|
+
settle(res.config);
|
|
261
|
+
return res;
|
|
262
|
+
},
|
|
263
|
+
(error) => {
|
|
264
|
+
settle((error as { config?: unknown } | null)?.config);
|
|
265
|
+
return Promise.reject(error);
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
return () => {
|
|
269
|
+
instance.interceptors.request.eject(request);
|
|
270
|
+
instance.interceptors.response.eject(response);
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── fetch ─────────────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* A `fetch` that drives a watcher: `const apiFetch = wrapFetch(fetch)`. The wait ends
|
|
278
|
+
* when the response's HEADERS arrive (or the call fails) — the server is awake by then,
|
|
279
|
+
* however long the body takes.
|
|
280
|
+
*/
|
|
281
|
+
export function wrapFetch(
|
|
282
|
+
fetchImpl: typeof fetch = globalThis.fetch,
|
|
283
|
+
watcher: ServerWakeWatcher = serverWake,
|
|
284
|
+
): typeof fetch {
|
|
285
|
+
return (input, init) => {
|
|
286
|
+
const request = typeof Request !== "undefined" && input instanceof Request ? input : undefined;
|
|
287
|
+
const method = init?.method ?? request?.method;
|
|
288
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : (request?.url ?? "");
|
|
289
|
+
return watcher.track(() => fetchImpl(input, init), { method, url });
|
|
290
|
+
};
|
|
291
|
+
}
|