@scalio-cloud/ui 0.14.0 → 0.16.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/CHANGELOG.md +17 -0
- package/README.md +7 -1
- package/auth.css +67 -0
- package/package.json +1 -1
- package/react/icons.js +17 -1
- package/react/index.js +3 -1
- package/react/labels.js +39 -2
- package/react/nav.js +1 -0
- package/react/profile.js +327 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.0
|
|
4
|
+
Same profile page in every app, reference Scalio Nexus (Markus 2026-10-31, scalio-docs ADR 0042 Nachtrag).
|
|
5
|
+
- New `react/profile.js`: `ProfileIdentity` (letters, name, email · role), `ThemeChoice` (Hell, Dunkel, System as
|
|
6
|
+
option cards), `LanguageChoice` (flags, `fixed` for an instance-wide language), `TimezoneChoice` (searchable
|
|
7
|
+
list, "Automatisch" = browser), `PasswordChange` (current, new, confirm; mismatch check, error from the API).
|
|
8
|
+
- Helpers: `setUserTimezone`, `currentTz`, `browserTz`, `listTimezones`, `tzOffset` (apps format dates with
|
|
9
|
+
`currentTz()`), `applyTheme(mode)` / `resolveTheme` / `normalizeTheme` for light, dark and system.
|
|
10
|
+
- Icons `Globe`, `Clock`; labels `profile_` (DE/EN). Default security hint now names authenticator app and passkeys.
|
|
11
|
+
- **Visible change:** none until an app uses the new parts. No token changes.
|
|
12
|
+
|
|
13
|
+
## 0.15.0
|
|
14
|
+
New app Scalio Beacon (service desk, scalio-docs ADR 0043).
|
|
15
|
+
- Product switcher: entry "Beacon" (icon `RadioTower`) between Citadel and Pulse, available like the others.
|
|
16
|
+
- `manualUrl("beacon")`: docs.scaliobeacon.com / devdocs.scaliobeacon.com.
|
|
17
|
+
- **Visible change:** every app shows "Beacon" in the switcher after its upgrade (greyed out until the instance
|
|
18
|
+
has a link in `SCALIO_APP_LINKS`). No token changes.
|
|
19
|
+
|
|
3
20
|
## 0.14.0
|
|
4
21
|
UI alignment round 1, follow-up (scalio-docs ADR 0042, Markus 2026-10-30).
|
|
5
22
|
- `accountGroups({ lang, base, show })` and `accountArea(part)`: Konten und Rollen in the structure of the settings
|
package/README.md
CHANGED
|
@@ -188,7 +188,13 @@ import { AuthLayout, AuthHeader, MicrosoftButton, AuthDivider, AuthMessage, Prof
|
|
|
188
188
|
<ProfileSection id="connections" lang={lang}>...</ProfileSection> // Verknüpfungen
|
|
189
189
|
```
|
|
190
190
|
|
|
191
|
-
|
|
191
|
+
Content of the sections (same in every app, reference Nexus): `ProfileIdentity` in Konto, `ThemeChoice` in
|
|
192
|
+
Darstellung, `LanguageChoice` + `TimezoneChoice` in Sprache und Region, `PasswordChange` + `SecurityFactors` in
|
|
193
|
+
Sicherheit. The app stores the time zone (`POST /auth/preferences {timezone}`), calls `setUserTimezone()` with
|
|
194
|
+
the value from `/auth/me` and formats dates with `timeZone: currentTz()`. `applyTheme(mode)` sets the dark class
|
|
195
|
+
for light, dark and system.
|
|
196
|
+
|
|
197
|
+
Other form fields and buttons (e.g. the name field) stay the app's own (shadcn), so they match the rest of the app.
|
|
192
198
|
|
|
193
199
|
## Two-factor card and license panel (React)
|
|
194
200
|
|
package/auth.css
CHANGED
|
@@ -112,3 +112,70 @@
|
|
|
112
112
|
min-width: 0; padding: 1.25rem; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--card));
|
|
113
113
|
color: hsl(var(--card-foreground)); display: flex; flex-direction: column; gap: 1rem;
|
|
114
114
|
}
|
|
115
|
+
|
|
116
|
+
/* Profile blocks (react/profile.js): identity, option cards, time zone list, password form. */
|
|
117
|
+
.scalio-profile-identity-wrap { display: flex; flex-direction: column; gap: 1rem; }
|
|
118
|
+
.scalio-profile-identity { display: flex; align-items: center; gap: 1rem; min-width: 0; }
|
|
119
|
+
.scalio-profile-avatar {
|
|
120
|
+
display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 3rem; height: 3rem;
|
|
121
|
+
background: hsl(var(--foreground)); color: hsl(var(--background)); font-size: 0.875rem; font-weight: 700;
|
|
122
|
+
}
|
|
123
|
+
.scalio-profile-who { min-width: 0; }
|
|
124
|
+
.scalio-profile-name { font-size: 0.875rem; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
125
|
+
.scalio-profile-mail {
|
|
126
|
+
font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; color: hsl(var(--muted-foreground));
|
|
127
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
128
|
+
}
|
|
129
|
+
.scalio-profile-options { display: grid; grid-template-columns: 1fr; gap: 0.75rem; }
|
|
130
|
+
@media (min-width: 640px) {
|
|
131
|
+
.scalio-profile-options.is-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
132
|
+
.scalio-profile-options.is-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
133
|
+
}
|
|
134
|
+
.scalio-profile-option {
|
|
135
|
+
display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.75rem 1rem; text-align: left;
|
|
136
|
+
border: 1px solid hsl(var(--input)); border-radius: 2px; background: hsl(var(--background)); color: hsl(var(--foreground));
|
|
137
|
+
font: inherit; cursor: pointer; transition: border-color 0.15s, background-color 0.15s;
|
|
138
|
+
}
|
|
139
|
+
.scalio-profile-option:hover { border-color: hsl(var(--muted-foreground)); }
|
|
140
|
+
.scalio-profile-option.is-on { border-color: hsl(var(--primary)); background: hsl(var(--primary) / 0.05); }
|
|
141
|
+
.scalio-profile-option:disabled { opacity: 0.6; cursor: default; }
|
|
142
|
+
.scalio-profile-option:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 0; }
|
|
143
|
+
.scalio-profile-option-label { display: flex; align-items: center; gap: 0.75rem; font-size: 0.875rem; font-weight: 600; }
|
|
144
|
+
.scalio-profile-option-label > .scalio-icon { width: 18px; height: 18px; color: hsl(var(--muted-foreground)); }
|
|
145
|
+
.scalio-profile-option.is-on .scalio-profile-option-label > .scalio-icon,
|
|
146
|
+
.scalio-profile-option-check { color: hsl(var(--primary)); width: 18px; height: 18px; }
|
|
147
|
+
.scalio-profile-flag { font-size: 1.25rem; line-height: 1; }
|
|
148
|
+
.scalio-profile-sublabel {
|
|
149
|
+
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;
|
|
150
|
+
font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: hsl(var(--muted-foreground));
|
|
151
|
+
}
|
|
152
|
+
.scalio-profile-sublabel > .scalio-icon { width: 14px; height: 14px; }
|
|
153
|
+
.scalio-profile-help { margin: 0.5rem 0 0; font-size: 0.75rem; color: hsl(var(--muted-foreground)); }
|
|
154
|
+
.scalio-profile-error { margin: 0; font-size: 0.875rem; color: hsl(var(--destructive)); }
|
|
155
|
+
.scalio-tz { position: relative; }
|
|
156
|
+
.scalio-tz-button { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; width: 100%; text-align: left; cursor: pointer; }
|
|
157
|
+
.scalio-tz-current { display: flex; align-items: center; gap: 0.5rem; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
158
|
+
.scalio-tz-button .scalio-icon { flex-shrink: 0; color: hsl(var(--muted-foreground)); }
|
|
159
|
+
.scalio-tz-panel {
|
|
160
|
+
position: absolute; z-index: 40; left: 0; right: 0; top: calc(100% + 4px); min-width: 280px; padding: 0.5rem;
|
|
161
|
+
border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--popover, var(--card))); color: hsl(var(--popover-foreground, var(--card-foreground)));
|
|
162
|
+
box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
|
|
163
|
+
}
|
|
164
|
+
.scalio-tz-list { margin: 0.5rem 0 0; padding: 0; list-style: none; max-height: 18rem; overflow-y: auto; }
|
|
165
|
+
.scalio-tz-item {
|
|
166
|
+
display: flex; align-items: center; gap: 0.5rem; width: 100%; padding: 0.375rem 0.5rem; border: 0; background: none;
|
|
167
|
+
color: inherit; font: inherit; font-size: 0.875rem; text-align: left; cursor: pointer;
|
|
168
|
+
}
|
|
169
|
+
.scalio-tz-item:hover, .scalio-tz-item:focus-visible { background: hsl(var(--muted)); outline: none; }
|
|
170
|
+
.scalio-tz-check { visibility: hidden; flex-shrink: 0; }
|
|
171
|
+
.scalio-tz-item.is-on .scalio-tz-check { visibility: visible; }
|
|
172
|
+
.scalio-tz-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
173
|
+
.scalio-tz-off { font-size: 0.75rem; color: hsl(var(--muted-foreground)); }
|
|
174
|
+
.scalio-tz-empty { padding: 0.5rem; font-size: 0.875rem; color: hsl(var(--muted-foreground)); }
|
|
175
|
+
.scalio-profile-pw { display: flex; flex-direction: column; gap: 1rem; }
|
|
176
|
+
.scalio-profile-pw-row { display: grid; grid-template-columns: 1fr; gap: 1rem; }
|
|
177
|
+
@media (min-width: 640px) { .scalio-profile-pw-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
178
|
+
.scalio-profile-field { display: flex; flex-direction: column; gap: 0.375rem; }
|
|
179
|
+
.scalio-profile-field .scalio-field-label { margin-top: 0; }
|
|
180
|
+
.scalio-profile-pw .scalio-profile-help { margin-top: -0.5rem; }
|
|
181
|
+
.scalio-profile-submit { align-self: flex-start; margin-top: 0; padding: 0 1rem; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scalio-cloud/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Scalio Control Room design tokens, Tailwind preset, self-hosted fonts, the shared app header, sign-in layout, sidebar and page blocks",
|
|
5
5
|
"license": "SEE LICENSE IN README.md",
|
|
6
6
|
"private": false,
|
package/react/icons.js
CHANGED
|
@@ -113,8 +113,24 @@ const ScrollText = icon([
|
|
|
113
113
|
["path", { d: "M15 12h-5M15 8h-5M19 17V5a2 2 0 0 0-2-2H4" }],
|
|
114
114
|
["path", { d: "M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3" }],
|
|
115
115
|
]);
|
|
116
|
+
// lucide "radio-tower": Scalio Beacon
|
|
117
|
+
const RadioTower = icon([
|
|
118
|
+
["path", { d: "M4.9 16.1C1 12.2 1 5.8 4.9 1.9" }],
|
|
119
|
+
["path", { d: "M7.8 4.7a6.14 6.14 0 0 0-.8 7.5" }],
|
|
120
|
+
["circle", { cx: 12, cy: 9, r: 2 }],
|
|
121
|
+
["path", { d: "M16.2 4.8c2 2 2.26 5.11.8 7.47" }],
|
|
122
|
+
["path", { d: "M19.1 1.9a9.96 9.96 0 0 1 0 14.1" }],
|
|
123
|
+
["path", { d: "M9.5 18h5" }],
|
|
124
|
+
["path", { d: "m8 22 4-11 4 11" }],
|
|
125
|
+
]);
|
|
116
126
|
const Info = icon([["circle", { cx: 12, cy: 12, r: 10 }], ["path", { d: "M12 16v-4M12 8h.01" }]]);
|
|
127
|
+
const Globe = icon([
|
|
128
|
+
["circle", { cx: 12, cy: 12, r: 10 }],
|
|
129
|
+
["path", { d: "M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" }],
|
|
130
|
+
["path", { d: "M2 12h20" }],
|
|
131
|
+
]);
|
|
132
|
+
const Clock = icon([["circle", { cx: 12, cy: 12, r: 10 }], ["path", { d: "M12 6v6l4 2" }]]);
|
|
117
133
|
|
|
118
134
|
module.exports = { Sun, Moon, Monitor, LifeBuoy, BookOpen, LogOut, Menu, Check, X, Search, ChevronDown, Fingerprint, FileStack, ShieldCheck, Hammer, Activity,
|
|
119
135
|
Smartphone, KeyRound, Trash, Copy, Puzzle, Plus, Alert, History, CornerDownLeft,
|
|
120
|
-
Users, Settings, ScrollText, Info };
|
|
136
|
+
Users, Settings, ScrollText, Info, RadioTower, Globe, Clock };
|
package/react/index.js
CHANGED
|
@@ -241,6 +241,7 @@ const DOCS_DOMAINS = {
|
|
|
241
241
|
citadel: "scaliocitadel.com",
|
|
242
242
|
foundry: "scaliofoundry.com",
|
|
243
243
|
rosetta: "scaliorosetta.com",
|
|
244
|
+
beacon: "scaliobeacon.com",
|
|
244
245
|
};
|
|
245
246
|
|
|
246
247
|
function manualUrl(product, opts) {
|
|
@@ -372,9 +373,10 @@ module.exports = {
|
|
|
372
373
|
VersionTag,
|
|
373
374
|
Icons,
|
|
374
375
|
LABELS,
|
|
376
|
+
labelsFor,
|
|
375
377
|
TEST_IDS,
|
|
376
378
|
initials,
|
|
377
379
|
};
|
|
378
|
-
Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./security"), require("./license"), require("./palette"), require("./unify"));
|
|
380
|
+
Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./security"), require("./license"), require("./palette"), require("./unify"), require("./profile"));
|
|
379
381
|
// After the exports above: auth.js uses LanguageToggle and ThemeToggle from this module.
|
|
380
382
|
Object.assign(module.exports, require("./auth"));
|
package/react/labels.js
CHANGED
|
@@ -224,11 +224,29 @@ const LABELS = {
|
|
|
224
224
|
security: "Sicherheit",
|
|
225
225
|
connections: "Verknüpfungen",
|
|
226
226
|
},
|
|
227
|
+
profile_: {
|
|
228
|
+
subtitle: "Persönliche Einstellungen für Ihr Konto",
|
|
229
|
+
language: "Anzeigesprache",
|
|
230
|
+
timezone: "Zeitzone",
|
|
231
|
+
timezoneAuto: "Automatisch (Browser: {tz})",
|
|
232
|
+
timezoneHelp: "Alle Daten und Uhrzeiten werden in dieser Zeitzone angezeigt.",
|
|
233
|
+
timezoneSearch: "Zeitzone suchen …",
|
|
234
|
+
timezoneNone: "Keine Zeitzone gefunden.",
|
|
235
|
+
pwCurrent: "Aktuelles Passwort",
|
|
236
|
+
pwNew: "Neues Passwort",
|
|
237
|
+
pwConfirm: "Neues bestätigen",
|
|
238
|
+
pwMin: "Mindestens {n} Zeichen.",
|
|
239
|
+
pwMismatch: "Die neuen Passwörter stimmen nicht überein.",
|
|
240
|
+
pwSubmit: "Passwort aktualisieren",
|
|
241
|
+
pwChanged: "Passwort geändert.",
|
|
242
|
+
saving: "Speichern …",
|
|
243
|
+
saved: "Einstellung gespeichert",
|
|
244
|
+
},
|
|
227
245
|
sectionHints: {
|
|
228
246
|
account: "Ihre Angaben. Die E-Mail-Adresse ist Ihr Anmeldename.",
|
|
229
247
|
appearance: "Hell, dunkel oder wie Ihr System.",
|
|
230
248
|
language: "Sprache der Oberfläche und Zeitzone für Zeitangaben.",
|
|
231
|
-
security: "Passwort und
|
|
249
|
+
security: "Passwort, Authenticator-App und Passkeys.",
|
|
232
250
|
connections: "Verbundene Konten für die Anmeldung.",
|
|
233
251
|
},
|
|
234
252
|
},
|
|
@@ -448,11 +466,29 @@ const LABELS = {
|
|
|
448
466
|
security: "Security",
|
|
449
467
|
connections: "Connections",
|
|
450
468
|
},
|
|
469
|
+
profile_: {
|
|
470
|
+
subtitle: "Personal settings for your account",
|
|
471
|
+
language: "Display language",
|
|
472
|
+
timezone: "Time zone",
|
|
473
|
+
timezoneAuto: "Automatic (browser: {tz})",
|
|
474
|
+
timezoneHelp: "All dates and times are shown in this time zone.",
|
|
475
|
+
timezoneSearch: "Search time zone …",
|
|
476
|
+
timezoneNone: "No time zone found.",
|
|
477
|
+
pwCurrent: "Current password",
|
|
478
|
+
pwNew: "New password",
|
|
479
|
+
pwConfirm: "Confirm new",
|
|
480
|
+
pwMin: "At least {n} characters.",
|
|
481
|
+
pwMismatch: "The new passwords do not match.",
|
|
482
|
+
pwSubmit: "Update password",
|
|
483
|
+
pwChanged: "Password changed.",
|
|
484
|
+
saving: "Saving …",
|
|
485
|
+
saved: "Preference saved",
|
|
486
|
+
},
|
|
451
487
|
sectionHints: {
|
|
452
488
|
account: "Your details. Your email address is your sign-in name.",
|
|
453
489
|
appearance: "Light, dark or like your system.",
|
|
454
490
|
language: "Language of the interface and time zone for times.",
|
|
455
|
-
security: "Password and
|
|
491
|
+
security: "Password, authenticator app and passkeys.",
|
|
456
492
|
connections: "Linked accounts for signing in.",
|
|
457
493
|
},
|
|
458
494
|
},
|
|
@@ -470,6 +506,7 @@ function labelsFor(lang, overrides) {
|
|
|
470
506
|
edition: Object.assign({}, base.edition, o.edition),
|
|
471
507
|
accountTabs: Object.assign({}, base.accountTabs, o.accountTabs),
|
|
472
508
|
passwordPolicy: Object.assign({}, base.passwordPolicy, o.passwordPolicy),
|
|
509
|
+
profile_: Object.assign({}, base.profile_, o.profile_),
|
|
473
510
|
});
|
|
474
511
|
}
|
|
475
512
|
|
package/react/nav.js
CHANGED
|
@@ -306,6 +306,7 @@ const PRODUCTS = [
|
|
|
306
306
|
{ key: "rosetta", name: "Rosetta", icon: Icons.FileStack },
|
|
307
307
|
{ key: "foundry", name: "Foundry", icon: Icons.Hammer },
|
|
308
308
|
{ key: "citadel", name: "Citadel", icon: Icons.ShieldCheck },
|
|
309
|
+
{ key: "beacon", name: "Beacon", icon: Icons.RadioTower },
|
|
309
310
|
{ key: "pulse", name: "Pulse", icon: Icons.Activity, soon: true },
|
|
310
311
|
];
|
|
311
312
|
|
package/react/profile.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Profile page blocks, the same in every app (reference: Scalio Nexus, ADR 0042 Nachtrag 2026-10-31).
|
|
3
|
+
// Order on the page: ProfileSection account (ProfileIdentity), appearance (ThemeChoice), language
|
|
4
|
+
// (LanguageChoice + TimezoneChoice), security (PasswordChange + SecurityFactors), connections (app specific).
|
|
5
|
+
// Time zone helpers: an empty time zone means "automatic" (the browser's). Apps call setUserTimezone() with
|
|
6
|
+
// the value from /auth/me and format dates with currentTz().
|
|
7
|
+
const React = require("react");
|
|
8
|
+
const h = React.createElement;
|
|
9
|
+
const Icons = require("./icons");
|
|
10
|
+
const { labelsFor } = require("./labels");
|
|
11
|
+
|
|
12
|
+
function cx() {
|
|
13
|
+
return Array.prototype.slice.call(arguments).filter(Boolean).join(" ");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fill(text, values) {
|
|
17
|
+
return String(text || "").replace(/\{(\w+)\}/g, (m, k) => (values && values[k] !== undefined ? values[k] : m));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function errorText(err) {
|
|
21
|
+
const d = err && err.response && err.response.data && err.response.data.detail;
|
|
22
|
+
if (typeof d === "string") return d;
|
|
23
|
+
if (Array.isArray(d)) return d.map((x) => (x && x.msg) || String(x)).join(" ");
|
|
24
|
+
if (d && typeof d.message === "string") return d.message;
|
|
25
|
+
return (err && err.message) || String(err);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// --- time zone ---------------------------------------------------------------------------------------
|
|
29
|
+
let userTz = "";
|
|
30
|
+
function browserTz() {
|
|
31
|
+
try {
|
|
32
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return "UTC";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function setUserTimezone(tz) {
|
|
38
|
+
userTz = tz || "";
|
|
39
|
+
}
|
|
40
|
+
function currentTz() {
|
|
41
|
+
return userTz || browserTz();
|
|
42
|
+
}
|
|
43
|
+
const FALLBACK_ZONES = ["UTC", "Europe/London", "Europe/Zurich", "Europe/Berlin", "Europe/Paris", "America/New_York",
|
|
44
|
+
"America/Chicago", "America/Denver", "America/Los_Angeles", "Asia/Dubai", "Asia/Kolkata", "Asia/Singapore", "Asia/Tokyo",
|
|
45
|
+
"Australia/Sydney"];
|
|
46
|
+
function listTimezones() {
|
|
47
|
+
let list = FALLBACK_ZONES;
|
|
48
|
+
try {
|
|
49
|
+
if (Intl.supportedValuesOf) list = Intl.supportedValuesOf("timeZone");
|
|
50
|
+
} catch (e) {
|
|
51
|
+
// keep the short list
|
|
52
|
+
}
|
|
53
|
+
return list.includes("UTC") ? list : ["UTC"].concat(list);
|
|
54
|
+
}
|
|
55
|
+
function tzOffset(tz) {
|
|
56
|
+
try {
|
|
57
|
+
const p = new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "shortOffset" }).formatToParts(new Date());
|
|
58
|
+
const part = p.find((x) => x.type === "timeZoneName");
|
|
59
|
+
return ((part && part.value) || "").replace("GMT", "UTC");
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- theme -------------------------------------------------------------------------------------------
|
|
66
|
+
// mode: light | dark | system. Returns the mode actually shown.
|
|
67
|
+
function resolveTheme(mode) {
|
|
68
|
+
if (mode === "dark" || mode === "light") return mode;
|
|
69
|
+
try {
|
|
70
|
+
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
71
|
+
} catch (e) {
|
|
72
|
+
return "light";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Sets the "dark" class on <html> for the mode and follows the system while the mode is "system".
|
|
76
|
+
// Returns a function that stops following.
|
|
77
|
+
function applyTheme(mode) {
|
|
78
|
+
const set = () => document.documentElement.classList.toggle("dark", resolveTheme(mode) === "dark");
|
|
79
|
+
set();
|
|
80
|
+
if (mode !== "system" || typeof window === "undefined" || !window.matchMedia) return () => {};
|
|
81
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
82
|
+
if (mq.addEventListener) mq.addEventListener("change", set);
|
|
83
|
+
else if (mq.addListener) mq.addListener(set);
|
|
84
|
+
return () => {
|
|
85
|
+
if (mq.removeEventListener) mq.removeEventListener("change", set);
|
|
86
|
+
else if (mq.removeListener) mq.removeListener(set);
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function normalizeTheme(value) {
|
|
90
|
+
return value === "dark" || value === "system" ? value : "light";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- blocks ------------------------------------------------------------------------------------------
|
|
94
|
+
function initials(name) {
|
|
95
|
+
return String(name || "?").trim().slice(0, 2).toUpperCase() || "?";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Letters, name, "email · role". children: extra content under it (e.g. a name field).
|
|
99
|
+
function ProfileIdentity({ name, email, role, children, testId }) {
|
|
100
|
+
return h(
|
|
101
|
+
"div",
|
|
102
|
+
{ className: "scalio-profile-identity-wrap" },
|
|
103
|
+
h(
|
|
104
|
+
"div",
|
|
105
|
+
{ className: "scalio-profile-identity", "data-testid": testId || "profile-identity" },
|
|
106
|
+
h("div", { className: "scalio-profile-avatar", "aria-hidden": "true" }, initials(name || email)),
|
|
107
|
+
h(
|
|
108
|
+
"div",
|
|
109
|
+
{ className: "scalio-profile-who" },
|
|
110
|
+
h("div", { className: "scalio-profile-name", "data-testid": "profile-name" }, name || email),
|
|
111
|
+
h("div", { className: "scalio-profile-mail", "data-testid": "profile-email" }, [email, role].filter(Boolean).join(" · "))
|
|
112
|
+
)
|
|
113
|
+
),
|
|
114
|
+
children || null
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function Option({ on, onClick, disabled, testId, icon, children }) {
|
|
119
|
+
return h(
|
|
120
|
+
"button",
|
|
121
|
+
{
|
|
122
|
+
type: "button",
|
|
123
|
+
role: "radio",
|
|
124
|
+
"aria-checked": !!on,
|
|
125
|
+
disabled: !!disabled,
|
|
126
|
+
onClick,
|
|
127
|
+
className: cx("scalio-profile-option", on && "is-on"),
|
|
128
|
+
"data-testid": testId,
|
|
129
|
+
},
|
|
130
|
+
h("span", { className: "scalio-profile-option-label" }, icon || null, h("span", null, children)),
|
|
131
|
+
on ? h(Icons.Check, { className: "scalio-icon scalio-profile-option-check" }) : null
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function SubLabel({ icon, children }) {
|
|
136
|
+
return h("div", { className: "scalio-profile-sublabel" }, icon || null, children);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const THEME_ICONS = { light: Icons.Sun, dark: Icons.Moon, system: Icons.Monitor };
|
|
140
|
+
|
|
141
|
+
function ThemeChoice({ theme, onChange, modes, lang, labels, testIdPrefix }) {
|
|
142
|
+
const L = labelsFor(lang, labels);
|
|
143
|
+
const list = modes || ["light", "dark", "system"];
|
|
144
|
+
const pre = testIdPrefix || "theme-option";
|
|
145
|
+
return h(
|
|
146
|
+
"div",
|
|
147
|
+
{ className: "scalio-profile-options is-3", role: "radiogroup", "aria-label": L.theme },
|
|
148
|
+
list.map((m) => {
|
|
149
|
+
const I = THEME_ICONS[m];
|
|
150
|
+
return h(Option, { key: m, on: theme === m, onClick: () => onChange(m), testId: `${pre}-${m}`, icon: I ? h(I, { className: "scalio-icon" }) : null }, L[m]);
|
|
151
|
+
})
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const LANGUAGES = [
|
|
156
|
+
{ code: "de", label: "Deutsch", flag: "🇩🇪" },
|
|
157
|
+
{ code: "en", label: "English", flag: "🇬🇧" },
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
// fixed: the language is set for the whole instance (the buttons are disabled, fixedHint explains why).
|
|
161
|
+
function LanguageChoice({ lang, onChange, languages, fixed, fixedHint, labels, testIdPrefix }) {
|
|
162
|
+
const L = labelsFor(lang, labels);
|
|
163
|
+
const pre = testIdPrefix || "lang-option";
|
|
164
|
+
return h(
|
|
165
|
+
"div",
|
|
166
|
+
{ "data-testid": "language-section" },
|
|
167
|
+
h(SubLabel, { icon: h(Icons.Globe, { className: "scalio-icon" }) }, L.profile_.language),
|
|
168
|
+
h(
|
|
169
|
+
"div",
|
|
170
|
+
{ className: "scalio-profile-options is-2", role: "radiogroup", "aria-label": L.profile_.language },
|
|
171
|
+
(languages || LANGUAGES).map((l) =>
|
|
172
|
+
h(Option, { key: l.code, on: lang === l.code, disabled: fixed, onClick: () => onChange(l.code), testId: `${pre}-${l.code}`,
|
|
173
|
+
icon: h("span", { className: "scalio-profile-flag", "aria-hidden": "true" }, l.flag) }, l.label)
|
|
174
|
+
)
|
|
175
|
+
),
|
|
176
|
+
fixed && fixedHint ? h("p", { className: "scalio-profile-help", "data-testid": "language-fixed" }, fixedHint) : null
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Searchable list of IANA time zones; the first entry "Automatic" saves an empty value.
|
|
181
|
+
function TimezoneChoice({ value, onChange, lang, labels, testId, defaultOpen }) {
|
|
182
|
+
const L = labelsFor(lang, labels);
|
|
183
|
+
const P = L.profile_;
|
|
184
|
+
const id = testId || "timezone-select";
|
|
185
|
+
const [open, setOpen] = React.useState(!!defaultOpen);
|
|
186
|
+
const [q, setQ] = React.useState("");
|
|
187
|
+
const ref = React.useRef(null);
|
|
188
|
+
const zones = React.useMemo(() => listTimezones().map((z) => ({ z, off: tzOffset(z) })), []);
|
|
189
|
+
React.useEffect(() => {
|
|
190
|
+
if (!open) return undefined;
|
|
191
|
+
const away = (e) => {
|
|
192
|
+
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
|
|
193
|
+
};
|
|
194
|
+
const esc = (e) => {
|
|
195
|
+
if (e.key === "Escape") setOpen(false);
|
|
196
|
+
};
|
|
197
|
+
document.addEventListener("mousedown", away);
|
|
198
|
+
document.addEventListener("keydown", esc);
|
|
199
|
+
return () => {
|
|
200
|
+
document.removeEventListener("mousedown", away);
|
|
201
|
+
document.removeEventListener("keydown", esc);
|
|
202
|
+
};
|
|
203
|
+
}, [open]);
|
|
204
|
+
const auto = fill(P.timezoneAuto, { tz: browserTz() });
|
|
205
|
+
const shown = value ? `${value.replace(/_/g, " ")} (${tzOffset(value)})` : auto;
|
|
206
|
+
const needle = q.trim().toLowerCase().replace(/\s+/g, "_");
|
|
207
|
+
const hits = needle ? zones.filter((x) => `${x.z} ${x.off}`.toLowerCase().includes(needle)) : zones;
|
|
208
|
+
const pick = (v) => {
|
|
209
|
+
setOpen(false);
|
|
210
|
+
setQ("");
|
|
211
|
+
if (v !== value) onChange(v);
|
|
212
|
+
};
|
|
213
|
+
const item = (key, v, label, off, tid) =>
|
|
214
|
+
h(
|
|
215
|
+
"li",
|
|
216
|
+
{ key },
|
|
217
|
+
h(
|
|
218
|
+
"button",
|
|
219
|
+
{ type: "button", className: cx("scalio-tz-item", v === (value || "") && "is-on"), onClick: () => pick(v), "data-testid": tid },
|
|
220
|
+
h(Icons.Check, { className: "scalio-icon scalio-tz-check" }),
|
|
221
|
+
h("span", { className: "scalio-tz-name" }, label),
|
|
222
|
+
off ? h("span", { className: "scalio-tz-off" }, off) : null
|
|
223
|
+
)
|
|
224
|
+
);
|
|
225
|
+
return h(
|
|
226
|
+
"div",
|
|
227
|
+
{ "data-testid": "timezone-section" },
|
|
228
|
+
h(SubLabel, { icon: h(Icons.Clock, { className: "scalio-icon" }) }, P.timezone),
|
|
229
|
+
h(
|
|
230
|
+
"div",
|
|
231
|
+
{ className: "scalio-tz", ref },
|
|
232
|
+
h(
|
|
233
|
+
"button",
|
|
234
|
+
{ type: "button", className: "scalio-input scalio-tz-button", "aria-haspopup": "listbox", "aria-expanded": open,
|
|
235
|
+
onClick: () => setOpen(!open), "data-testid": id },
|
|
236
|
+
h("span", { className: "scalio-tz-current" }, h(Icons.Globe, { className: "scalio-icon" }), h("span", null, shown)),
|
|
237
|
+
h(Icons.ChevronDown, { className: "scalio-icon" })
|
|
238
|
+
),
|
|
239
|
+
open
|
|
240
|
+
? h(
|
|
241
|
+
"div",
|
|
242
|
+
{ className: "scalio-tz-panel" },
|
|
243
|
+
h("input", { className: "scalio-input", autoFocus: true, value: q, placeholder: P.timezoneSearch, "aria-label": P.timezoneSearch,
|
|
244
|
+
onChange: (e) => setQ(e.target.value), "data-testid": `${id}-search` }),
|
|
245
|
+
h(
|
|
246
|
+
"ul",
|
|
247
|
+
{ className: "scalio-tz-list", role: "listbox" },
|
|
248
|
+
!needle || "automatic automatisch browser".includes(needle) ? item("__auto", "", auto, "", `${id}-auto`) : null,
|
|
249
|
+
hits.slice(0, 400).map((x) => item(x.z, x.z, x.z.replace(/_/g, " "), x.off, `${id}-opt-${x.z}`)),
|
|
250
|
+
hits.length === 0 ? h("li", { className: "scalio-tz-empty" }, P.timezoneNone) : null
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
: null
|
|
254
|
+
),
|
|
255
|
+
h("p", { className: "scalio-profile-help" }, P.timezoneHelp)
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// onSubmit(current, next) returns a promise; a rejection shows its message under the form.
|
|
260
|
+
function PasswordChange({ onSubmit, onDone, minLength, lang, labels, testIds }) {
|
|
261
|
+
const L = labelsFor(lang, labels);
|
|
262
|
+
const P = L.profile_;
|
|
263
|
+
const T = Object.assign({ form: "change-password-form", current: "cp-current", next: "cp-new", confirm: "cp-confirm", submit: "cp-submit", error: "cp-error" }, testIds);
|
|
264
|
+
const [pw, setPw] = React.useState({ current: "", next: "", confirm: "" });
|
|
265
|
+
const [error, setError] = React.useState("");
|
|
266
|
+
const [busy, setBusy] = React.useState(false);
|
|
267
|
+
const min = minLength || 0;
|
|
268
|
+
const submit = async (e) => {
|
|
269
|
+
e.preventDefault();
|
|
270
|
+
setError("");
|
|
271
|
+
if (pw.next !== pw.confirm) {
|
|
272
|
+
setError(P.pwMismatch);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
setBusy(true);
|
|
276
|
+
try {
|
|
277
|
+
const res = await onSubmit(pw.current, pw.next);
|
|
278
|
+
setPw({ current: "", next: "", confirm: "" });
|
|
279
|
+
if (onDone) onDone(res);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
setError(errorText(err));
|
|
282
|
+
} finally {
|
|
283
|
+
setBusy(false);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
const set = (k) => (e) => setPw(Object.assign({}, pw, { [k]: e.target.value }));
|
|
287
|
+
const field = (k, label, auto, extra) =>
|
|
288
|
+
h(
|
|
289
|
+
"div",
|
|
290
|
+
{ className: "scalio-profile-field" },
|
|
291
|
+
h("label", { className: "scalio-field-label", htmlFor: `pw-${k}` }, label),
|
|
292
|
+
h("input", Object.assign({ id: `pw-${k}`, type: "password", className: "scalio-input", value: pw[k], onChange: set(k), required: true,
|
|
293
|
+
autoComplete: auto, "data-testid": T[k] }, extra))
|
|
294
|
+
);
|
|
295
|
+
return h(
|
|
296
|
+
"form",
|
|
297
|
+
{ className: "scalio-profile-pw", onSubmit: submit, "data-testid": T.form },
|
|
298
|
+
field("current", P.pwCurrent, "current-password"),
|
|
299
|
+
h(
|
|
300
|
+
"div",
|
|
301
|
+
{ className: "scalio-profile-pw-row" },
|
|
302
|
+
field("next", P.pwNew, "new-password", min ? { minLength: min } : null),
|
|
303
|
+
field("confirm", P.pwConfirm, "new-password", min ? { minLength: min } : null)
|
|
304
|
+
),
|
|
305
|
+
min ? h("p", { className: "scalio-profile-help" }, fill(P.pwMin, { n: min })) : null,
|
|
306
|
+
error ? h("p", { className: "scalio-profile-error", role: "alert", "data-testid": T.error }, error) : null,
|
|
307
|
+
h("button", { type: "submit", className: "scalio-btn-primary scalio-profile-submit", disabled: busy, "data-testid": T.submit },
|
|
308
|
+
busy ? P.saving : P.pwSubmit)
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = {
|
|
313
|
+
ProfileIdentity,
|
|
314
|
+
ThemeChoice,
|
|
315
|
+
LanguageChoice,
|
|
316
|
+
TimezoneChoice,
|
|
317
|
+
PasswordChange,
|
|
318
|
+
PROFILE_LANGUAGES: LANGUAGES,
|
|
319
|
+
browserTz,
|
|
320
|
+
setUserTimezone,
|
|
321
|
+
currentTz,
|
|
322
|
+
listTimezones,
|
|
323
|
+
tzOffset,
|
|
324
|
+
resolveTheme,
|
|
325
|
+
applyTheme,
|
|
326
|
+
normalizeTheme,
|
|
327
|
+
};
|