@eifi1/ui-kit 0.41.0 → 0.42.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 +19 -6
- package/dist/admin/admin-area-labels.d.ts +55 -0
- package/dist/admin/admin-area-labels.js +33 -0
- package/dist/admin/admin-area-labels.js.map +1 -0
- package/dist/admin/admin-forbidden.d.ts +35 -0
- package/dist/admin/admin-forbidden.js +36 -0
- package/dist/admin/admin-forbidden.js.map +1 -0
- package/dist/admin/admin-overview.d.ts +93 -0
- package/dist/admin/admin-overview.js +69 -0
- package/dist/admin/admin-overview.js.map +1 -0
- package/dist/admin/admin-sections.d.ts +63 -0
- package/dist/admin/admin-sections.js +64 -0
- package/dist/admin/admin-sections.js.map +1 -0
- package/dist/admin/admin-tool-links.d.ts +48 -0
- package/dist/admin/admin-tool-links.js +31 -0
- package/dist/admin/admin-tool-links.js.map +1 -0
- package/dist/billing/billing-banner.d.ts +25 -2
- package/dist/billing/billing-banner.js +5 -2
- package/dist/billing/billing-banner.js.map +1 -1
- package/dist/billing/billing-labels.d.ts +32 -0
- package/dist/billing/billing-labels.js +9 -0
- package/dist/billing/billing-labels.js.map +1 -1
- package/dist/billing/billing-overview.d.ts +33 -0
- package/dist/billing/billing-overview.js +6 -0
- package/dist/billing/billing-overview.js.map +1 -1
- package/dist/billing/billing-standing.d.ts +8 -0
- package/dist/billing/billing-standing.js.map +1 -1
- package/dist/billing/cancel-subscription.d.ts +46 -0
- package/dist/billing/cancel-subscription.js +57 -0
- package/dist/billing/cancel-subscription.js.map +1 -0
- package/dist/billing/plan-card.d.ts +45 -1
- package/dist/billing/plan-card.js +53 -2
- package/dist/billing/plan-card.js.map +1 -1
- package/dist/billing/plan-switch.d.ts +10 -1
- package/dist/billing/plan-switch.js +22 -13
- package/dist/billing/plan-switch.js.map +1 -1
- package/dist/billing/subscription-actions.d.ts +37 -4
- package/dist/billing/subscription-actions.js +10 -3
- package/dist/billing/subscription-actions.js.map +1 -1
- package/dist/components/account-chips.d.ts +42 -1
- package/dist/components/account-chips.js +33 -0
- package/dist/components/account-chips.js.map +1 -1
- package/dist/components/empty-state.d.ts +2 -1
- package/dist/components/empty-state.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/i18n/defaults.js +5 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +79 -0
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +9 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +93 -1
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +76 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +75 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +76 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +76 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +75 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +20 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +18 -2
- package/dist/index.js +23 -1
- package/dist/index.js.map +1 -1
- package/dist/settings/settings-layout.d.ts +7 -1
- package/dist/settings/settings-layout.js +35 -27
- package/dist/settings/settings-layout.js.map +1 -1
- package/dist/shell/account-menu-language.d.ts +46 -0
- package/dist/shell/account-menu-language.js +64 -0
- package/dist/shell/account-menu-language.js.map +1 -0
- package/dist/shell/admin-menu.d.ts +46 -0
- package/dist/shell/admin-menu.js +60 -0
- package/dist/shell/admin-menu.js.map +1 -0
- package/dist/shell/app-shell.d.ts +10 -0
- package/dist/shell/app-shell.js +22 -4
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/dev-switchers.d.ts +53 -0
- package/dist/shell/dev-switchers.js +102 -0
- package/dist/shell/dev-switchers.js.map +1 -0
- package/dist/shell/sync-status-indicator.d.ts +86 -0
- package/dist/shell/sync-status-indicator.js +170 -0
- package/dist/shell/sync-status-indicator.js.map +1 -0
- package/dist/shell/top-bar-actions.d.ts +49 -0
- package/dist/shell/top-bar-actions.js +31 -0
- package/dist/shell/top-bar-actions.js.map +1 -0
- package/dist/shell/top-bar-parts-labels.d.ts +84 -0
- package/dist/shell/top-bar-parts-labels.js +48 -0
- package/dist/shell/top-bar-parts-labels.js.map +1 -0
- package/dist/shell/topbar-action-menu.d.ts +13 -1
- package/dist/shell/topbar-action-menu.js +5 -2
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.d.ts +16 -3
- package/dist/shell/topbar-controls.js +5 -1
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/shell/tour-menu.d.ts +58 -0
- package/dist/shell/tour-menu.js +73 -0
- package/dist/shell/tour-menu.js.map +1 -0
- package/dist/shell.d.ts +14 -0
- package/dist/shell.js +19 -0
- package/dist/shell.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/admin-area-labels.ts +85 -0
- package/src/admin/admin-forbidden.tsx +75 -0
- package/src/admin/admin-overview.tsx +166 -0
- package/src/admin/admin-sections.tsx +137 -0
- package/src/admin/admin-tool-links.tsx +84 -0
- package/src/billing/billing-banner.tsx +32 -4
- package/src/billing/billing-labels.ts +53 -0
- package/src/billing/billing-overview.ts +37 -0
- package/src/billing/billing-standing.ts +8 -0
- package/src/billing/cancel-subscription.tsx +102 -0
- package/src/billing/plan-card.tsx +97 -1
- package/src/billing/plan-switch.tsx +16 -0
- package/src/billing/subscription-actions.tsx +64 -15
- package/src/components/account-chips.tsx +81 -1
- package/src/components/empty-state.tsx +2 -1
- package/src/i18n/defaults.ts +4 -0
- package/src/i18n/german.ts +92 -0
- package/src/i18n/kit-labels.tsx +9 -0
- package/src/i18n/locales/en.ts +19 -0
- package/src/i18n/locales/es.ts +86 -0
- package/src/i18n/locales/fr.ts +86 -0
- package/src/i18n/locales/hu.ts +84 -0
- package/src/i18n/locales/it.ts +87 -0
- package/src/i18n/locales/zh.ts +79 -0
- package/src/i18n/review.ts +19 -0
- package/src/index.ts +30 -2
- package/src/settings/settings-layout.tsx +58 -34
- package/src/shell/account-menu-language.tsx +116 -0
- package/src/shell/admin-menu.tsx +117 -0
- package/src/shell/app-shell.tsx +44 -3
- package/src/shell/dev-switchers.tsx +171 -0
- package/src/shell/sync-status-indicator.tsx +301 -0
- package/src/shell/top-bar-actions.tsx +79 -0
- package/src/shell/top-bar-parts-labels.ts +133 -0
- package/src/shell/topbar-action-menu.tsx +15 -2
- package/src/shell/topbar-controls.tsx +20 -3
- package/src/shell/tour-menu.tsx +144 -0
- package/src/shell.ts +24 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { FlaskConical, Undo2 } from "lucide-react";
|
|
4
|
+
import { DEFAULT_TOP_BAR_LABELS, useKitLabels, type LabelOverride } from "../i18n/kit-labels";
|
|
5
|
+
import { TopBarActionMenu, type TopBarMenuEntry } from "./topbar-action-menu";
|
|
6
|
+
import { useTopBarPartsLabels, type DevSwitchersLabels } from "./top-bar-parts-labels";
|
|
7
|
+
|
|
8
|
+
/** Where the role the session started with is kept, unless the app names its own key. */
|
|
9
|
+
export const DEV_ORIGINAL_ROLE_KEY = "ui-kit:dev-original-role";
|
|
10
|
+
|
|
11
|
+
function readKey(key: string): string | null {
|
|
12
|
+
try {
|
|
13
|
+
return sessionStorage.getItem(key);
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function writeKey(key: string, value: string | null): void {
|
|
20
|
+
try {
|
|
21
|
+
if (value === null) sessionStorage.removeItem(key);
|
|
22
|
+
else sessionStorage.setItem(key, value);
|
|
23
|
+
} catch {
|
|
24
|
+
/* storage blocked: "Back to" lasts as long as this page */
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DevSwitchersProps<R extends string, P extends string> {
|
|
29
|
+
/** Draw anything at all: the app passes `import.meta.env.DEV`, so a production build
|
|
30
|
+
* tree-shakes the switchers with the constant. */
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
roles?: readonly R[];
|
|
33
|
+
/** The role the server enforces now. Named `currentRole`, not `role`, because `role` on
|
|
34
|
+
* a JSX element is the ARIA role (as `RoleChip`'s `value`). */
|
|
35
|
+
currentRole?: R;
|
|
36
|
+
/** Apply a role — the app calls server-kit's `POST /dev/switch {role}` and re-reads
|
|
37
|
+
* `/auth/me` (§2.7, §6.2). */
|
|
38
|
+
onRole?: (role: R) => void;
|
|
39
|
+
plans?: readonly P[];
|
|
40
|
+
/** The plan the server enforces now. Left out (not known yet — kastlan's overview is a
|
|
41
|
+
* 404 while billing is off), the plan menu still renders, with no row checked; a
|
|
42
|
+
* `GET /dev/switch` answers it. */
|
|
43
|
+
currentPlan?: P;
|
|
44
|
+
/** Apply a plan — `POST /dev/switch {plan}`, then the overview (§6.3). */
|
|
45
|
+
onPlan?: (plan: P) => void;
|
|
46
|
+
/**
|
|
47
|
+
* The role to offer "Back to {role}" for. Left out, the kit keeps it itself: the role
|
|
48
|
+
* before the first switch of this browser session, in `sessionStorage` under
|
|
49
|
+
* `storageKey` — the server answers with the NEW role after a switch, so only the
|
|
50
|
+
* browser still knows the old one.
|
|
51
|
+
*/
|
|
52
|
+
originalRole?: R;
|
|
53
|
+
/** The `sessionStorage` key for the kept role. Default {@link DEV_ORIGINAL_ROLE_KEY};
|
|
54
|
+
* an app with several accounts on one browser may scope it per user. */
|
|
55
|
+
storageKey?: string;
|
|
56
|
+
/** Billing is off locally: the plan menu says plans change nothing, and stays usable
|
|
57
|
+
* (§6.3). */
|
|
58
|
+
billingOff?: boolean;
|
|
59
|
+
/** A role's or a plan's words in the menu. Default: the value itself. */
|
|
60
|
+
roleLabel?: (role: R) => ReactNode;
|
|
61
|
+
planLabel?: (plan: P) => ReactNode;
|
|
62
|
+
/** Prop > `<UiKitProvider labels={{ topBarParts: { dev } }}>` > English. */
|
|
63
|
+
labels?: LabelOverride<DevSwitchersLabels>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const flask = <FlaskConical className="size-5 text-warning" />;
|
|
67
|
+
|
|
68
|
+
/** A label's text for a sentence, or the raw value when the label is not text. */
|
|
69
|
+
const textOf = (label: ReactNode, value: string) =>
|
|
70
|
+
typeof label === "string" || typeof label === "number" ? String(label) : value;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The top bar's local-dev switchers (docs/top-bar-harmonization.md §2.7, §6.2, §6.3): the
|
|
74
|
+
* role, then the plan, each a menu behind `RoleSwitcher`'s amber flask. Nothing renders
|
|
75
|
+
* unless `enabled`; either menu renders only with its list and handler.
|
|
76
|
+
*
|
|
77
|
+
* The role menu ends in "Back to {role}" once the role differs from the one the session
|
|
78
|
+
* started with: the dev route needs only a signed-in user, so a switch to a lesser role
|
|
79
|
+
* can always switch back. With `billingOff` the plan menu notes that plans change nothing
|
|
80
|
+
* while billing is off.
|
|
81
|
+
*/
|
|
82
|
+
export function DevSwitchers<R extends string, P extends string>({
|
|
83
|
+
enabled,
|
|
84
|
+
roles,
|
|
85
|
+
currentRole: role,
|
|
86
|
+
onRole,
|
|
87
|
+
plans,
|
|
88
|
+
currentPlan: plan,
|
|
89
|
+
onPlan,
|
|
90
|
+
originalRole,
|
|
91
|
+
storageKey = DEV_ORIGINAL_ROLE_KEY,
|
|
92
|
+
billingOff = false,
|
|
93
|
+
roleLabel = (r) => r,
|
|
94
|
+
planLabel = (p) => p,
|
|
95
|
+
labels: labelsProp,
|
|
96
|
+
}: DevSwitchersProps<R, P>) {
|
|
97
|
+
const bar = useKitLabels("topBar", DEFAULT_TOP_BAR_LABELS);
|
|
98
|
+
const words = useTopBarPartsLabels(labelsProp ? { dev: labelsProp } : undefined).dev;
|
|
99
|
+
const [kept, setKept] = useState<string | null>(() => (enabled ? readKey(storageKey) : null));
|
|
100
|
+
if (!enabled) return null;
|
|
101
|
+
|
|
102
|
+
const original = (originalRole ?? kept ?? undefined) as R | undefined;
|
|
103
|
+
|
|
104
|
+
const pickRole = (next: R) => {
|
|
105
|
+
if (originalRole === undefined) {
|
|
106
|
+
if (next === original) {
|
|
107
|
+
// Back where the session began: nothing left to go back to.
|
|
108
|
+
writeKey(storageKey, null);
|
|
109
|
+
setKept(null);
|
|
110
|
+
} else if (kept === null && role !== undefined && next !== role) {
|
|
111
|
+
writeKey(storageKey, role);
|
|
112
|
+
setKept(role);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
onRole?.(next);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const roleMenu =
|
|
119
|
+
roles && roles.length > 0 && role !== undefined && onRole ? (
|
|
120
|
+
<TopBarActionMenu
|
|
121
|
+
icon={flask}
|
|
122
|
+
aria-label={`${bar.switchRole} · ${bar.role(role)}`}
|
|
123
|
+
entries={[
|
|
124
|
+
{ kind: "heading", key: "head-role", label: words.roleHeading },
|
|
125
|
+
...roles.map(
|
|
126
|
+
(r): TopBarMenuEntry => ({ key: `role-${r}`, label: roleLabel(r), checked: r === role, onSelect: () => pickRole(r) }),
|
|
127
|
+
),
|
|
128
|
+
...(original !== undefined && original !== role
|
|
129
|
+
? ([
|
|
130
|
+
{ kind: "divider", key: "sep-back" },
|
|
131
|
+
{
|
|
132
|
+
key: "back",
|
|
133
|
+
icon: <Undo2 className="size-4" />,
|
|
134
|
+
label: words.backTo(textOf(roleLabel(original), original)),
|
|
135
|
+
onSelect: () => pickRole(original),
|
|
136
|
+
},
|
|
137
|
+
] satisfies TopBarMenuEntry[])
|
|
138
|
+
: []),
|
|
139
|
+
]}
|
|
140
|
+
/>
|
|
141
|
+
) : null;
|
|
142
|
+
|
|
143
|
+
const planMenu =
|
|
144
|
+
plans && plans.length > 0 && onPlan ? (
|
|
145
|
+
<TopBarActionMenu
|
|
146
|
+
icon={flask}
|
|
147
|
+
aria-label={plan !== undefined ? `${words.switchPlan} · ${words.plan(plan)}` : words.switchPlan}
|
|
148
|
+
entries={[
|
|
149
|
+
{ kind: "heading", key: "head-plan", label: words.planHeading },
|
|
150
|
+
...plans.map(
|
|
151
|
+
(p): TopBarMenuEntry => ({ key: `plan-${p}`, label: planLabel(p), checked: p === plan, onSelect: () => onPlan(p) }),
|
|
152
|
+
),
|
|
153
|
+
]}
|
|
154
|
+
>
|
|
155
|
+
{billingOff
|
|
156
|
+
? () => (
|
|
157
|
+
<li data-dev-billing-off="" className="mt-1 max-w-64 border-t border-subtle px-3 py-2 text-xs text-muted">
|
|
158
|
+
{words.billingOff}
|
|
159
|
+
</li>
|
|
160
|
+
)
|
|
161
|
+
: undefined}
|
|
162
|
+
</TopBarActionMenu>
|
|
163
|
+
) : null;
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<>
|
|
167
|
+
{roleMenu}
|
|
168
|
+
{planMenu}
|
|
169
|
+
</>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { ComponentType } from "react";
|
|
3
|
+
import { Check, Cloud, CloudAlert, CloudOff, RefreshCw, UploadCloud } from "lucide-react";
|
|
4
|
+
import type { LucideProps } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/cn";
|
|
6
|
+
import { IconButton } from "../components/icon-button";
|
|
7
|
+
import { Tooltip } from "../components/tooltip";
|
|
8
|
+
import { useBillingLabels } from "../billing/billing-labels";
|
|
9
|
+
import { useKitLocale, type LabelOverride } from "../i18n/kit-labels";
|
|
10
|
+
import { useTopBarPartsLabels, type SyncStatusLabels } from "./top-bar-parts-labels";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What the indicator shows, fed by the app's sync engine (docs/top-bar-harmonization.md
|
|
14
|
+
* §2.6). Small on purpose: keksdose's sync-status store, kastlan's handover controller and
|
|
15
|
+
* Kurvenschmiede's online state + `useIsMutating` all fill it.
|
|
16
|
+
*/
|
|
17
|
+
export interface SyncStatus {
|
|
18
|
+
/** What the engine is doing. `offline` is the engine's own verdict; `online: false` is
|
|
19
|
+
* the browser's — either shows the offline state. */
|
|
20
|
+
phase: "idle" | "syncing" | "offline" | "error";
|
|
21
|
+
/** The browser's `navigator.onLine` (or the app's better guess). */
|
|
22
|
+
online: boolean;
|
|
23
|
+
/** Writes kept on this device and not yet sent. */
|
|
24
|
+
pending: number;
|
|
25
|
+
/** Writes this device cannot send at all (keksdose: no key for the budget). Ranked first. */
|
|
26
|
+
blocked?: number;
|
|
27
|
+
/** Writes the server refused as read-only and keeps until a plan (billing §12.5). */
|
|
28
|
+
waitingForPlan?: number;
|
|
29
|
+
/** When the last good round ended — epoch ms, an ISO string or a `Date`. */
|
|
30
|
+
lastSyncedAt?: number | string | Date | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The seven states, in their ranking: the first that holds wins. */
|
|
34
|
+
export type SyncState = "blocked" | "waitingForPlan" | "offline" | "error" | "syncing" | "pending" | "synced";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Which of the seven states a {@link SyncStatus} is in — keksdose's ranking
|
|
38
|
+
* (sync-status-indicator.tsx): a write that cannot be sent at all ranks above everything,
|
|
39
|
+
* because waiting cannot help it; then one waiting for a plan; then offline; then a refused
|
|
40
|
+
* round; then a round under way; then writes waiting; else in sync. `syncing` also holds
|
|
41
|
+
* while a tap's own round runs (`refreshing`), before the engine publishes its phase.
|
|
42
|
+
*/
|
|
43
|
+
export function syncState(status: SyncStatus, refreshing = false): SyncState {
|
|
44
|
+
if ((status.blocked ?? 0) > 0) return "blocked";
|
|
45
|
+
if ((status.waitingForPlan ?? 0) > 0) return "waitingForPlan";
|
|
46
|
+
if (!status.online || status.phase === "offline") return "offline";
|
|
47
|
+
if (status.phase === "error") return "error";
|
|
48
|
+
if (status.phase === "syncing" || refreshing) return "syncing";
|
|
49
|
+
if (status.pending > 0) return "pending";
|
|
50
|
+
return "synced";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type Tone = "danger" | "warning" | "info" | "success";
|
|
54
|
+
|
|
55
|
+
const LOOK: Record<SyncState, { Icon: ComponentType<LucideProps>; tone: Tone }> = {
|
|
56
|
+
// The one the user must act on: the kit's danger, where keksdose had rose.
|
|
57
|
+
blocked: { Icon: CloudAlert, tone: "danger" },
|
|
58
|
+
// Safe, and a payment sends them: amber, the kit's warning.
|
|
59
|
+
waitingForPlan: { Icon: CloudAlert, tone: "warning" },
|
|
60
|
+
offline: { Icon: CloudOff, tone: "warning" },
|
|
61
|
+
// Usually resolves by itself (a deploy, a read-only view): amber, not danger.
|
|
62
|
+
error: { Icon: CloudAlert, tone: "warning" },
|
|
63
|
+
syncing: { Icon: RefreshCw, tone: "info" },
|
|
64
|
+
pending: { Icon: UploadCloud, tone: "warning" },
|
|
65
|
+
synced: { Icon: Cloud, tone: "success" },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const TEXT_TONE: Record<Tone, string> = {
|
|
69
|
+
danger: "text-danger",
|
|
70
|
+
warning: "text-warning",
|
|
71
|
+
info: "text-info",
|
|
72
|
+
success: "text-success",
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The scope words of an indicator that covers only part of the app (§6.10). */
|
|
76
|
+
export interface SyncStatusScope {
|
|
77
|
+
/** Put before every state in the tooltip: "Handovers on this device" →
|
|
78
|
+
* "Handovers on this device: 3 changes waiting to sync". */
|
|
79
|
+
name: string;
|
|
80
|
+
/** The whole offline sentence instead of the kit's, when offline means something
|
|
81
|
+
* narrower: "Offline: handovers keep working on this device; everything else needs a
|
|
82
|
+
* connection". */
|
|
83
|
+
offline?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SyncStatusIndicatorProps {
|
|
87
|
+
status: SyncStatus;
|
|
88
|
+
/**
|
|
89
|
+
* Run a round now (push, then pull, then re-read what is on screen). With it the
|
|
90
|
+
* indicator is a "sync now" button, disabled offline and mid-round but keeping its
|
|
91
|
+
* colour; a returned promise keeps the spin until it settles. Without it, a status only
|
|
92
|
+
* (§6.9).
|
|
93
|
+
*/
|
|
94
|
+
onSync?: () => void | Promise<unknown>;
|
|
95
|
+
/** Render nothing while in sync — kastlan and Kurvenschmiede show it only while offline,
|
|
96
|
+
* syncing or with changes waiting, held or refused (§2.6). */
|
|
97
|
+
hideWhenIdle?: boolean;
|
|
98
|
+
/** The app's words for its part of the app (§6.10). */
|
|
99
|
+
scope?: SyncStatusScope;
|
|
100
|
+
/**
|
|
101
|
+
* Where the app keeps a change before the server has it. `"device"` (the default): an
|
|
102
|
+
* offline-first app (keksdose, kastlan's handovers) — offline means "kept here, sent
|
|
103
|
+
* later". `"none"`: an online-only app that keeps nothing on the device (Kurvenschmiede,
|
|
104
|
+
* build review B1) — offline reads "changes can't be saved until you reconnect", a
|
|
105
|
+
* round reads "Saving…", and `pending`, `blocked` and `waitingForPlan` are ignored,
|
|
106
|
+
* since there is nowhere for a change to wait. "Up to date" when settled.
|
|
107
|
+
*/
|
|
108
|
+
storage?: "device" | "none";
|
|
109
|
+
/** Short words beside the cloud ("Offline", "Syncing…", "3 waiting") while not in sync. */
|
|
110
|
+
showLabel?: boolean;
|
|
111
|
+
/** Prop > `<UiKitProvider labels={{ topBarParts: { sync } }}>` > English. */
|
|
112
|
+
labels?: LabelOverride<SyncStatusLabels>;
|
|
113
|
+
/** The clock's language for "Synced at 14:05". Default: the provider's locale. */
|
|
114
|
+
locale?: string;
|
|
115
|
+
className?: string;
|
|
116
|
+
/** `data-*` attributes go on the trigger — a tour's anchor (`data-tour="sync-status"`). */
|
|
117
|
+
[key: `data-${string}`]: string | number | boolean | undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The `data-*` attributes of a rest object, for a trigger. */
|
|
121
|
+
function dataAttributes(rest: Record<string, unknown>): Record<string, unknown> {
|
|
122
|
+
return Object.fromEntries(Object.entries(rest).filter(([k]) => k.startsWith("data-")));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The top bar's sync status (docs/top-bar-harmonization.md §2.6), lifted from keksdose's
|
|
127
|
+
* `SyncStatusIndicator`: the seven states and their ranking ({@link syncState}), one cloud
|
|
128
|
+
* per state, a ✓ badge when in sync and a spin while syncing. With `onSync` it is also the
|
|
129
|
+
* "sync now" button, and its name says both what it is and what a tap does.
|
|
130
|
+
*
|
|
131
|
+
* Colours are the kit's roles — danger for a write that cannot be sent, warning for
|
|
132
|
+
* anything waiting, info while syncing, success when in sync — where keksdose had raw
|
|
133
|
+
* rose/amber/sky/emerald pairs.
|
|
134
|
+
*/
|
|
135
|
+
export function SyncStatusIndicator({
|
|
136
|
+
status,
|
|
137
|
+
onSync,
|
|
138
|
+
hideWhenIdle = false,
|
|
139
|
+
scope,
|
|
140
|
+
showLabel = false,
|
|
141
|
+
labels: labelsProp,
|
|
142
|
+
locale: localeProp,
|
|
143
|
+
className,
|
|
144
|
+
storage = "device",
|
|
145
|
+
...rest
|
|
146
|
+
}: SyncStatusIndicatorProps) {
|
|
147
|
+
const words = useTopBarPartsLabels(labelsProp ? { sync: labelsProp } : undefined).sync;
|
|
148
|
+
const billing = useBillingLabels();
|
|
149
|
+
const locale = useKitLocale(localeProp);
|
|
150
|
+
// The tap's own round, local rather than in the app's store: the store's phase belongs
|
|
151
|
+
// to the sync engine, and a second writer would race the round itself.
|
|
152
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
153
|
+
const mounted = useRef(true);
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
mounted.current = true;
|
|
156
|
+
return () => {
|
|
157
|
+
mounted.current = false;
|
|
158
|
+
};
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
const onlineOnly = storage === "none";
|
|
162
|
+
// Online-only: nothing waits on the device, so only offline, error, saving and settled.
|
|
163
|
+
const state = syncState(
|
|
164
|
+
onlineOnly ? { ...status, pending: 0, blocked: 0, waitingForPlan: 0 } : status,
|
|
165
|
+
refreshing,
|
|
166
|
+
);
|
|
167
|
+
const data = dataAttributes(rest);
|
|
168
|
+
if (hideWhenIdle && state === "synced") return null;
|
|
169
|
+
|
|
170
|
+
const { Icon, tone } = LOOK[state];
|
|
171
|
+
const spin = state === "syncing";
|
|
172
|
+
let text: string;
|
|
173
|
+
switch (state) {
|
|
174
|
+
case "blocked":
|
|
175
|
+
text = words.blocked(status.blocked ?? 0);
|
|
176
|
+
break;
|
|
177
|
+
case "waitingForPlan":
|
|
178
|
+
text = billing.waitingChanges(status.waitingForPlan ?? 0);
|
|
179
|
+
break;
|
|
180
|
+
case "offline":
|
|
181
|
+
text = onlineOnly ? words.offlineUnsaved : words.offline;
|
|
182
|
+
break;
|
|
183
|
+
case "error":
|
|
184
|
+
text = words.refused;
|
|
185
|
+
break;
|
|
186
|
+
case "syncing":
|
|
187
|
+
text = onlineOnly ? words.saving : words.syncing;
|
|
188
|
+
break;
|
|
189
|
+
case "pending":
|
|
190
|
+
text = words.pending(status.pending);
|
|
191
|
+
break;
|
|
192
|
+
default:
|
|
193
|
+
text = !onlineOnly && status.lastSyncedAt
|
|
194
|
+
? words.syncedAt(
|
|
195
|
+
// A clock time in the UI's language, never the browser's (keksdose live #246).
|
|
196
|
+
new Date(status.lastSyncedAt).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }),
|
|
197
|
+
)
|
|
198
|
+
: words.idle;
|
|
199
|
+
}
|
|
200
|
+
if (scope) {
|
|
201
|
+
text = state === "offline" && scope.offline ? scope.offline : words.scoped(scope.name, text);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const short =
|
|
205
|
+
state === "offline"
|
|
206
|
+
? words.offlineShort
|
|
207
|
+
: state === "syncing"
|
|
208
|
+
? onlineOnly
|
|
209
|
+
? words.saving
|
|
210
|
+
: words.syncingShort
|
|
211
|
+
: state === "error"
|
|
212
|
+
? words.refusedShort
|
|
213
|
+
: state === "pending" || state === "blocked" || state === "waitingForPlan"
|
|
214
|
+
? words.waitingShort(
|
|
215
|
+
state === "pending"
|
|
216
|
+
? status.pending
|
|
217
|
+
: state === "blocked"
|
|
218
|
+
? (status.blocked ?? 0)
|
|
219
|
+
: (status.waitingForPlan ?? 0),
|
|
220
|
+
)
|
|
221
|
+
: null;
|
|
222
|
+
|
|
223
|
+
const offline = state === "offline";
|
|
224
|
+
const canSync = !!onSync && status.online && status.phase !== "offline" && !spin;
|
|
225
|
+
const name = onSync
|
|
226
|
+
? canSync
|
|
227
|
+
? words.withHint(text, words.syncHint)
|
|
228
|
+
: offline
|
|
229
|
+
? words.withHint(text, words.offlineHint)
|
|
230
|
+
: text
|
|
231
|
+
: text;
|
|
232
|
+
const badge = state === "synced" ? <Check className="stroke-[3]" /> : undefined;
|
|
233
|
+
const glyph = <Icon aria-hidden className={spin ? "animate-spin" : undefined} />;
|
|
234
|
+
|
|
235
|
+
const sync = async () => {
|
|
236
|
+
if (!canSync || !onSync) return;
|
|
237
|
+
setRefreshing(true);
|
|
238
|
+
try {
|
|
239
|
+
await onSync();
|
|
240
|
+
} finally {
|
|
241
|
+
if (mounted.current) setRefreshing(false);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const control = onSync ? (
|
|
246
|
+
<IconButton
|
|
247
|
+
{...data}
|
|
248
|
+
data-sync-state={state}
|
|
249
|
+
size="2xs"
|
|
250
|
+
glyphSize={20}
|
|
251
|
+
shape="round"
|
|
252
|
+
// A status that is also an action: disabled (offline, mid-round) is exactly when
|
|
253
|
+
// the state matters most, so it keeps its colour.
|
|
254
|
+
disabledStyle="keep"
|
|
255
|
+
tone={tone}
|
|
256
|
+
quiet={false}
|
|
257
|
+
label={name}
|
|
258
|
+
labelVisible={false}
|
|
259
|
+
// Below: the bar is at the top edge, where a bubble above has no room.
|
|
260
|
+
tooltipSide="bottom"
|
|
261
|
+
onClick={() => void sync()}
|
|
262
|
+
disabled={!canSync}
|
|
263
|
+
// It announces itself as a status: a reader hears the state change.
|
|
264
|
+
aria-live="polite"
|
|
265
|
+
badge={badge}
|
|
266
|
+
>
|
|
267
|
+
{glyph}
|
|
268
|
+
</IconButton>
|
|
269
|
+
) : (
|
|
270
|
+
<Tooltip label={name} side="bottom">
|
|
271
|
+
<span
|
|
272
|
+
{...data}
|
|
273
|
+
role="status"
|
|
274
|
+
data-sync-state={state}
|
|
275
|
+
className={cn("relative inline-flex size-9 shrink-0 items-center justify-center [&>svg]:size-5", TEXT_TONE[tone])}
|
|
276
|
+
>
|
|
277
|
+
{glyph}
|
|
278
|
+
<span className="sr-only">{name}</span>
|
|
279
|
+
{badge && (
|
|
280
|
+
<span
|
|
281
|
+
aria-hidden
|
|
282
|
+
className="pointer-events-none absolute bottom-1.5 end-1.5 inline-flex rounded-full bg-surface [&_svg]:size-3"
|
|
283
|
+
>
|
|
284
|
+
{badge}
|
|
285
|
+
</span>
|
|
286
|
+
)}
|
|
287
|
+
</span>
|
|
288
|
+
</Tooltip>
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
if (!showLabel || !short) return className ? <span className={cn("inline-flex", className)}>{control}</span> : control;
|
|
292
|
+
return (
|
|
293
|
+
<span className={cn("inline-flex items-center gap-1", className)}>
|
|
294
|
+
{control}
|
|
295
|
+
{/* The eye's short words; the name above already says it in full. */}
|
|
296
|
+
<span aria-hidden className={cn("whitespace-nowrap text-xs font-medium", TEXT_TONE[tone])}>
|
|
297
|
+
{short}
|
|
298
|
+
</span>
|
|
299
|
+
</span>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Fragment } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The top bar's items by name (docs/top-bar-harmonization.md §2.1, §6.6). Each is the
|
|
6
|
+
* app's own node — a kit part or its own — and any may be left out.
|
|
7
|
+
*/
|
|
8
|
+
export interface TopBarActionsProps {
|
|
9
|
+
/** kastlan's `CompanySwitcher`: the context, first. */
|
|
10
|
+
companySwitcher?: ReactNode;
|
|
11
|
+
/** `GlobalSearch` (⌘K). */
|
|
12
|
+
search?: ReactNode;
|
|
13
|
+
/** `ThemeToggle`. */
|
|
14
|
+
theme?: ReactNode;
|
|
15
|
+
/** `FeedbackMenu`. */
|
|
16
|
+
feedback?: ReactNode;
|
|
17
|
+
/** `TourMenu`. */
|
|
18
|
+
tours?: ReactNode;
|
|
19
|
+
/** kastlan's assistant, beside the tours while it is on. */
|
|
20
|
+
assistant?: ReactNode;
|
|
21
|
+
/** `SyncStatusIndicator`. */
|
|
22
|
+
sync?: ReactNode;
|
|
23
|
+
/** `DevSwitchers` (role, then plan). */
|
|
24
|
+
dev?: ReactNode;
|
|
25
|
+
/** kastlan's platform button. */
|
|
26
|
+
platform?: ReactNode;
|
|
27
|
+
/** The account menu. When it is set the signed-out slots are not drawn. */
|
|
28
|
+
account?: ReactNode;
|
|
29
|
+
/** Signed out (§6.6): the bar's `LanguageMenu`, since there is no account menu to hold
|
|
30
|
+
* the language. Drawn only without `account`. */
|
|
31
|
+
language?: ReactNode;
|
|
32
|
+
/** Signed out (§6.6): the "Sign in" button, where the account menu would be. Drawn only
|
|
33
|
+
* without `account`. */
|
|
34
|
+
signIn?: ReactNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** §2.1's order, then the signed-out pair in the account menu's place. */
|
|
38
|
+
export const TOP_BAR_ACTION_ORDER = [
|
|
39
|
+
"companySwitcher",
|
|
40
|
+
"search",
|
|
41
|
+
"theme",
|
|
42
|
+
"feedback",
|
|
43
|
+
"tours",
|
|
44
|
+
"assistant",
|
|
45
|
+
"sync",
|
|
46
|
+
"dev",
|
|
47
|
+
"platform",
|
|
48
|
+
"account",
|
|
49
|
+
"language",
|
|
50
|
+
"signIn",
|
|
51
|
+
] as const satisfies readonly (keyof TopBarActionsProps)[];
|
|
52
|
+
|
|
53
|
+
const present = (node: ReactNode) => node !== undefined && node !== null && node !== false && node !== "";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The top bar's actions in the one order every app shares (§2.1): company switcher ·
|
|
57
|
+
* Search · Theme · Feedback · Tours (· Assistant) · Sync · dev role · dev plan · platform ·
|
|
58
|
+
* account menu — and, signed out, Language · Sign in where the account menu goes (§6.6).
|
|
59
|
+
* The app fills the slots it has; an empty slot is skipped, so it leaves no gap.
|
|
60
|
+
*
|
|
61
|
+
* It draws a fragment, no box of its own: hand it to `TopBar`'s `actions`, whose row
|
|
62
|
+
* spaces the items.
|
|
63
|
+
*
|
|
64
|
+
* ```tsx
|
|
65
|
+
* <TopBar brand={…} actions={<TopBarActions search={…} theme={…} tours={…} account={…} />} />
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export function TopBarActions(props: TopBarActionsProps) {
|
|
69
|
+
const signedOut = !present(props.account);
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
{TOP_BAR_ACTION_ORDER.map((slot) => {
|
|
73
|
+
if ((slot === "language" || slot === "signIn") && !signedOut) return null;
|
|
74
|
+
const node = props[slot];
|
|
75
|
+
return present(node) ? <Fragment key={slot}>{node}</Fragment> : null;
|
|
76
|
+
})}
|
|
77
|
+
</>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `topBarParts` namespace (0.42, docs/top-bar-harmonization.md §3.1): the words of the
|
|
5
|
+
* top bar's shared parts — `TourMenu`, `SyncStatusIndicator` and `DevSwitchers`. A module
|
|
6
|
+
* of words with no component code, like every `*-labels.ts` (0.34, KS U8). The language
|
|
7
|
+
* sub-list (`AccountMenuLanguage`) names itself with `topBar.language`, and the dev role
|
|
8
|
+
* switcher's name and tooltip are `topBar.switchRole` / `topBar.role`, as `RoleSwitcher`'s.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** `TourMenu`'s words (§2.4). */
|
|
12
|
+
export interface TourMenuLabels {
|
|
13
|
+
/** The compass's name, and the menu's. */
|
|
14
|
+
menu: string;
|
|
15
|
+
/** The NEW dot on the compass, for a reader: it joins the trigger's name. */
|
|
16
|
+
newDot: string;
|
|
17
|
+
/** The heading over the tours that start on the open page. */
|
|
18
|
+
thisPage: string;
|
|
19
|
+
/** The heading over the untaken new tours. */
|
|
20
|
+
newTours: string;
|
|
21
|
+
/** The last row, the way to the app's tours page (only with `href`). */
|
|
22
|
+
allTours: string;
|
|
23
|
+
/** The chip on a new tour's row. */
|
|
24
|
+
badgeNew: string;
|
|
25
|
+
/** What the ✓ on a taken tour's row says to a reader. */
|
|
26
|
+
completed: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `SyncStatusIndicator`'s words (§2.6). The "waiting for a plan" state is
|
|
30
|
+
* `billing.waitingChanges`, the kit's words for it since billing §12.5. */
|
|
31
|
+
export interface SyncStatusLabels {
|
|
32
|
+
/** Writes this device cannot send (keksdose: no key for the budget). Ranked first. */
|
|
33
|
+
blocked: (count: number) => string;
|
|
34
|
+
offline: string;
|
|
35
|
+
/** The server refused the last round. */
|
|
36
|
+
refused: string;
|
|
37
|
+
syncing: string;
|
|
38
|
+
pending: (count: number) => string;
|
|
39
|
+
/** Settled, with the time of the last good round (already formatted). */
|
|
40
|
+
syncedAt: (time: string) => string;
|
|
41
|
+
/** Settled, before the first round. */
|
|
42
|
+
idle: string;
|
|
43
|
+
/** Joined to the state when a tap syncs: what the tap does. */
|
|
44
|
+
syncHint: string;
|
|
45
|
+
/** Joined to the state while offline, when a tap can do nothing. */
|
|
46
|
+
offlineHint: string;
|
|
47
|
+
/** The state and what a tap does, as one tooltip: "Offline — nothing to sync…". */
|
|
48
|
+
withHint: (state: string, hint: string) => string;
|
|
49
|
+
/** The state with the app's `scope` words before it: "Handovers on this device: …". */
|
|
50
|
+
scoped: (scope: string, state: string) => string;
|
|
51
|
+
/** The short words beside the cloud (`showLabel`): offline. */
|
|
52
|
+
offlineShort: string;
|
|
53
|
+
/** The short words while syncing. */
|
|
54
|
+
syncingShort: string;
|
|
55
|
+
/** The short words while changes wait (pending, held for a plan, or blocked). */
|
|
56
|
+
waitingShort: (count: number) => string;
|
|
57
|
+
/** The short words after a refused round. */
|
|
58
|
+
refusedShort: string;
|
|
59
|
+
/** Offline in an online-only app (`storage="none"`): nothing is kept on the device. */
|
|
60
|
+
offlineUnsaved: string;
|
|
61
|
+
/** A round under way in an online-only app — it saves, it doesn't sync. Also its short
|
|
62
|
+
* words beside the cloud. */
|
|
63
|
+
saving: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `DevSwitchers`' words (§2.7, §6.2, §6.3). */
|
|
67
|
+
export interface DevSwitchersLabels {
|
|
68
|
+
/** The heading of the role menu. */
|
|
69
|
+
roleHeading: string;
|
|
70
|
+
/** The plan switcher's name. */
|
|
71
|
+
switchPlan: string;
|
|
72
|
+
/** The plan switcher's tooltip, given the active plan. */
|
|
73
|
+
plan: (value: string) => string;
|
|
74
|
+
/** The heading of the plan menu. */
|
|
75
|
+
planHeading: string;
|
|
76
|
+
/** The row back to the role the session started with. */
|
|
77
|
+
backTo: (role: string) => string;
|
|
78
|
+
/** The plan menu's note while billing is off locally. */
|
|
79
|
+
billingOff: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface TopBarPartsLabels {
|
|
83
|
+
tours: TourMenuLabels;
|
|
84
|
+
sync: SyncStatusLabels;
|
|
85
|
+
dev: DevSwitchersLabels;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const DEFAULT_TOP_BAR_PARTS_LABELS: TopBarPartsLabels = {
|
|
89
|
+
tours: {
|
|
90
|
+
menu: "Guided tours",
|
|
91
|
+
newDot: "New tours",
|
|
92
|
+
thisPage: "On this page",
|
|
93
|
+
newTours: "New tours",
|
|
94
|
+
allTours: "All tours…",
|
|
95
|
+
badgeNew: "New",
|
|
96
|
+
completed: "Completed",
|
|
97
|
+
},
|
|
98
|
+
sync: {
|
|
99
|
+
blocked: (count) =>
|
|
100
|
+
count === 1
|
|
101
|
+
? "1 change cannot be sent from this device"
|
|
102
|
+
: `${count} changes cannot be sent from this device`,
|
|
103
|
+
offline: "Offline — changes are saved on this device and will sync when you reconnect",
|
|
104
|
+
refused: "The server refused the last sync",
|
|
105
|
+
syncing: "Syncing…",
|
|
106
|
+
pending: (count) => (count === 1 ? "1 change waiting to sync" : `${count} changes waiting to sync`),
|
|
107
|
+
syncedAt: (time) => `Synced at ${time}`,
|
|
108
|
+
idle: "Up to date",
|
|
109
|
+
syncHint: "Tap to sync now",
|
|
110
|
+
offlineHint: "nothing to sync while offline",
|
|
111
|
+
withHint: (state, hint) => `${state} — ${hint}`,
|
|
112
|
+
scoped: (scope, state) => `${scope}: ${state}`,
|
|
113
|
+
offlineShort: "Offline",
|
|
114
|
+
syncingShort: "Syncing…",
|
|
115
|
+
waitingShort: (count) => `${count} waiting`,
|
|
116
|
+
refusedShort: "Not synced",
|
|
117
|
+
offlineUnsaved: "Offline — changes can't be saved until you reconnect",
|
|
118
|
+
saving: "Saving…",
|
|
119
|
+
},
|
|
120
|
+
dev: {
|
|
121
|
+
roleHeading: "Dev · role",
|
|
122
|
+
switchPlan: "Switch plan",
|
|
123
|
+
plan: (value) => `Plan: ${value}`,
|
|
124
|
+
planHeading: "Dev · plan",
|
|
125
|
+
backTo: (role) => `Back to ${role}`,
|
|
126
|
+
billingOff: "Billing is off locally: plans change nothing",
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** The `topBarParts` namespace, resolved — English, then the provider, then `labels`. */
|
|
131
|
+
export function useTopBarPartsLabels(labels?: LabelOverride<TopBarPartsLabels>): TopBarPartsLabels {
|
|
132
|
+
return useKitLabels("topBarParts", DEFAULT_TOP_BAR_PARTS_LABELS, labels);
|
|
133
|
+
}
|