@meshmakers/octo-ui 3.3.920 → 3.3.930
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 +52 -0
- package/fesm2022/meshmakers-octo-ui-branding-settings.mjs +324 -0
- package/fesm2022/meshmakers-octo-ui-branding-settings.mjs.map +1 -0
- package/fesm2022/meshmakers-octo-ui-branding.mjs +1026 -0
- package/fesm2022/meshmakers-octo-ui-branding.mjs.map +1 -0
- package/fesm2022/meshmakers-octo-ui.mjs.map +1 -1
- package/package.json +9 -1
- package/types/meshmakers-octo-ui-branding-settings.d.ts +107 -0
- package/types/meshmakers-octo-ui-branding.d.ts +285 -0
|
@@ -0,0 +1,1026 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, Injectable, inject, signal, Injector, effect, makeEnvironmentProviders, DOCUMENT, RendererFactory2, input, computed, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
3
|
+
import { TitleStrategy } from '@angular/router';
|
|
4
|
+
import { Title } from '@angular/platform-browser';
|
|
5
|
+
import { firstValueFrom } from 'rxjs';
|
|
6
|
+
import * as i1 from 'apollo-angular';
|
|
7
|
+
import { gql } from 'apollo-angular';
|
|
8
|
+
import * as i1$1 from '@progress/kendo-angular-icons';
|
|
9
|
+
import { SVGIconModule } from '@progress/kendo-angular-icons';
|
|
10
|
+
import { lightbulbOutlineIcon, brightnessContrastIcon } from '@progress/kendo-svg-icons';
|
|
11
|
+
|
|
12
|
+
const OCTO_BRANDING_DEFAULTS = new InjectionToken('OCTO_BRANDING_DEFAULTS');
|
|
13
|
+
const OCTO_BRANDING_FALLBACK_ASSETS = new InjectionToken('OCTO_BRANDING_FALLBACK_ASSETS');
|
|
14
|
+
// Library defaults carry the Meshmakers brand mint (#65ceaf) as primary so
|
|
15
|
+
// an app that opts into `provideOctoBranding` but never persists a
|
|
16
|
+
// `SystemUIBranding` record renders with the publisher's identity rather
|
|
17
|
+
// than vanilla Kendo. Mint is also non-aggressive in mixed contexts (no
|
|
18
|
+
// alarm/error semantics like Kendo's tomato default), so tenant overrides
|
|
19
|
+
// almost always read as an intentional rebrand rather than a workaround.
|
|
20
|
+
// Apps override per-tenant via Settings page; the override completely
|
|
21
|
+
// replaces these baselines.
|
|
22
|
+
const NEUTRAL_LIGHT_THEME = {
|
|
23
|
+
primaryColor: '#65ceaf',
|
|
24
|
+
secondaryColor: '#5ac4be',
|
|
25
|
+
tertiaryColor: '#0b5c92',
|
|
26
|
+
neutralColor: '#6c757d',
|
|
27
|
+
backgroundColor: '#ffffff',
|
|
28
|
+
headerGradient: { startColor: '#ffffff', endColor: '#f8f9fa' },
|
|
29
|
+
footerGradient: { startColor: '#65ceaf', endColor: '#5ac4be' },
|
|
30
|
+
};
|
|
31
|
+
const NEUTRAL_DARK_THEME = {
|
|
32
|
+
primaryColor: '#65ceaf',
|
|
33
|
+
secondaryColor: '#5ac4be',
|
|
34
|
+
tertiaryColor: '#4a8eef',
|
|
35
|
+
neutralColor: '#94a3b8',
|
|
36
|
+
backgroundColor: '#1a1d20',
|
|
37
|
+
headerGradient: { startColor: '#1a1d20', endColor: '#2b2f33' },
|
|
38
|
+
footerGradient: { startColor: '#65ceaf', endColor: '#5ac4be' },
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Library-shipped neutral fallback. Apps that don't supply their own defaults
|
|
42
|
+
* see a working but visually-neutral baseline ("configure me"), not a crash.
|
|
43
|
+
*/
|
|
44
|
+
const NEUTRAL_BRANDING_DEFAULTS = {
|
|
45
|
+
rtId: null,
|
|
46
|
+
appName: 'App',
|
|
47
|
+
appTitle: 'App',
|
|
48
|
+
headerLogoUrl: null,
|
|
49
|
+
footerLogoUrl: null,
|
|
50
|
+
faviconUrl: null,
|
|
51
|
+
lightTheme: NEUTRAL_LIGHT_THEME,
|
|
52
|
+
darkTheme: NEUTRAL_DARK_THEME,
|
|
53
|
+
};
|
|
54
|
+
const NEUTRAL_FALLBACK_ASSETS = {};
|
|
55
|
+
|
|
56
|
+
const CreateBrandingDocumentDto = gql `
|
|
57
|
+
mutation createBranding($branding: SystemUIBrandingInput!) {
|
|
58
|
+
runtime {
|
|
59
|
+
systemUIBrandings {
|
|
60
|
+
create(entities: [$branding]) {
|
|
61
|
+
rtId
|
|
62
|
+
rtWellKnownName
|
|
63
|
+
appName
|
|
64
|
+
appTitle
|
|
65
|
+
headerLogo
|
|
66
|
+
footerLogo
|
|
67
|
+
favicon
|
|
68
|
+
lightTheme {
|
|
69
|
+
primaryColor
|
|
70
|
+
secondaryColor
|
|
71
|
+
tertiaryColor
|
|
72
|
+
neutralColor
|
|
73
|
+
backgroundColor
|
|
74
|
+
headerGradient {
|
|
75
|
+
startColor
|
|
76
|
+
endColor
|
|
77
|
+
}
|
|
78
|
+
footerGradient {
|
|
79
|
+
startColor
|
|
80
|
+
endColor
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
darkTheme {
|
|
84
|
+
primaryColor
|
|
85
|
+
secondaryColor
|
|
86
|
+
tertiaryColor
|
|
87
|
+
neutralColor
|
|
88
|
+
backgroundColor
|
|
89
|
+
headerGradient {
|
|
90
|
+
startColor
|
|
91
|
+
endColor
|
|
92
|
+
}
|
|
93
|
+
footerGradient {
|
|
94
|
+
startColor
|
|
95
|
+
endColor
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
`;
|
|
103
|
+
class CreateBrandingDtoGQL extends i1.Mutation {
|
|
104
|
+
document = CreateBrandingDocumentDto;
|
|
105
|
+
constructor(apollo) {
|
|
106
|
+
super(apollo);
|
|
107
|
+
}
|
|
108
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: CreateBrandingDtoGQL, deps: [{ token: i1.Apollo }], target: i0.ɵɵFactoryTarget.Injectable });
|
|
109
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: CreateBrandingDtoGQL, providedIn: 'root' });
|
|
110
|
+
}
|
|
111
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: CreateBrandingDtoGQL, decorators: [{
|
|
112
|
+
type: Injectable,
|
|
113
|
+
args: [{
|
|
114
|
+
providedIn: 'root'
|
|
115
|
+
}]
|
|
116
|
+
}], ctorParameters: () => [{ type: i1.Apollo }] });
|
|
117
|
+
|
|
118
|
+
const GetBrandingDocumentDto = gql `
|
|
119
|
+
query getBranding {
|
|
120
|
+
runtime {
|
|
121
|
+
systemUIBranding(
|
|
122
|
+
first: 1
|
|
123
|
+
fieldFilter: [{attributePath: "rtWellKnownName", operator: EQUALS, comparisonValue: "Branding"}]
|
|
124
|
+
) {
|
|
125
|
+
totalCount
|
|
126
|
+
items {
|
|
127
|
+
rtId
|
|
128
|
+
rtWellKnownName
|
|
129
|
+
appName
|
|
130
|
+
appTitle
|
|
131
|
+
headerLogo
|
|
132
|
+
footerLogo
|
|
133
|
+
favicon
|
|
134
|
+
lightTheme {
|
|
135
|
+
primaryColor
|
|
136
|
+
secondaryColor
|
|
137
|
+
tertiaryColor
|
|
138
|
+
neutralColor
|
|
139
|
+
backgroundColor
|
|
140
|
+
headerGradient {
|
|
141
|
+
startColor
|
|
142
|
+
endColor
|
|
143
|
+
}
|
|
144
|
+
footerGradient {
|
|
145
|
+
startColor
|
|
146
|
+
endColor
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
darkTheme {
|
|
150
|
+
primaryColor
|
|
151
|
+
secondaryColor
|
|
152
|
+
tertiaryColor
|
|
153
|
+
neutralColor
|
|
154
|
+
backgroundColor
|
|
155
|
+
headerGradient {
|
|
156
|
+
startColor
|
|
157
|
+
endColor
|
|
158
|
+
}
|
|
159
|
+
footerGradient {
|
|
160
|
+
startColor
|
|
161
|
+
endColor
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
`;
|
|
169
|
+
class GetBrandingDtoGQL extends i1.Query {
|
|
170
|
+
document = GetBrandingDocumentDto;
|
|
171
|
+
constructor(apollo) {
|
|
172
|
+
super(apollo);
|
|
173
|
+
}
|
|
174
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: GetBrandingDtoGQL, deps: [{ token: i1.Apollo }], target: i0.ɵɵFactoryTarget.Injectable });
|
|
175
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: GetBrandingDtoGQL, providedIn: 'root' });
|
|
176
|
+
}
|
|
177
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: GetBrandingDtoGQL, decorators: [{
|
|
178
|
+
type: Injectable,
|
|
179
|
+
args: [{
|
|
180
|
+
providedIn: 'root'
|
|
181
|
+
}]
|
|
182
|
+
}], ctorParameters: () => [{ type: i1.Apollo }] });
|
|
183
|
+
|
|
184
|
+
const UpdateBrandingDocumentDto = gql `
|
|
185
|
+
mutation updateBranding($branding: SystemUIBrandingInputUpdate!) {
|
|
186
|
+
runtime {
|
|
187
|
+
systemUIBrandings {
|
|
188
|
+
update(entities: [$branding]) {
|
|
189
|
+
rtId
|
|
190
|
+
rtWellKnownName
|
|
191
|
+
appName
|
|
192
|
+
appTitle
|
|
193
|
+
headerLogo
|
|
194
|
+
footerLogo
|
|
195
|
+
favicon
|
|
196
|
+
lightTheme {
|
|
197
|
+
primaryColor
|
|
198
|
+
secondaryColor
|
|
199
|
+
tertiaryColor
|
|
200
|
+
neutralColor
|
|
201
|
+
backgroundColor
|
|
202
|
+
headerGradient {
|
|
203
|
+
startColor
|
|
204
|
+
endColor
|
|
205
|
+
}
|
|
206
|
+
footerGradient {
|
|
207
|
+
startColor
|
|
208
|
+
endColor
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
darkTheme {
|
|
212
|
+
primaryColor
|
|
213
|
+
secondaryColor
|
|
214
|
+
tertiaryColor
|
|
215
|
+
neutralColor
|
|
216
|
+
backgroundColor
|
|
217
|
+
headerGradient {
|
|
218
|
+
startColor
|
|
219
|
+
endColor
|
|
220
|
+
}
|
|
221
|
+
footerGradient {
|
|
222
|
+
startColor
|
|
223
|
+
endColor
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
`;
|
|
231
|
+
class UpdateBrandingDtoGQL extends i1.Mutation {
|
|
232
|
+
document = UpdateBrandingDocumentDto;
|
|
233
|
+
constructor(apollo) {
|
|
234
|
+
super(apollo);
|
|
235
|
+
}
|
|
236
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: UpdateBrandingDtoGQL, deps: [{ token: i1.Apollo }], target: i0.ɵɵFactoryTarget.Injectable });
|
|
237
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: UpdateBrandingDtoGQL, providedIn: 'root' });
|
|
238
|
+
}
|
|
239
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: UpdateBrandingDtoGQL, decorators: [{
|
|
240
|
+
type: Injectable,
|
|
241
|
+
args: [{
|
|
242
|
+
providedIn: 'root'
|
|
243
|
+
}]
|
|
244
|
+
}], ctorParameters: () => [{ type: i1.Apollo }] });
|
|
245
|
+
|
|
246
|
+
const WELL_KNOWN_NAME = 'Branding';
|
|
247
|
+
/**
|
|
248
|
+
* Per-tenant branding. Source of truth is the `SystemUIBranding`
|
|
249
|
+
* CK runtime entity (one record per tenant, `rtWellKnownName = 'Branding'`).
|
|
250
|
+
* Loaded via GraphQL, mirrored into a signal for consumers.
|
|
251
|
+
*/
|
|
252
|
+
class BrandingDataSource {
|
|
253
|
+
getBrandingGQL = inject(GetBrandingDtoGQL);
|
|
254
|
+
createBrandingGQL = inject(CreateBrandingDtoGQL);
|
|
255
|
+
updateBrandingGQL = inject(UpdateBrandingDtoGQL);
|
|
256
|
+
defaults = inject(OCTO_BRANDING_DEFAULTS);
|
|
257
|
+
state = signal({ ...this.defaults }, ...(ngDevMode ? [{ debugName: "state" }] : /* istanbul ignore next */ []));
|
|
258
|
+
branding = this.state.asReadonly();
|
|
259
|
+
async load() {
|
|
260
|
+
// `no-cache` (not `network-only`): Apollo's `InMemoryCache` normalizes
|
|
261
|
+
// entities by `rtId`, but the singleton `RuntimeModelQuery` type has no
|
|
262
|
+
// id — cache writes for `runtime.systemUIBranding`
|
|
263
|
+
// clobber sibling fields (e.g. `runtime.basicTree` used by plant
|
|
264
|
+
// hierarchy + /location). `no-cache` fetches from network AND skips
|
|
265
|
+
// writing to cache, so /location keeps its hierarchy cache intact.
|
|
266
|
+
const result = await firstValueFrom(this.getBrandingGQL.fetch({ fetchPolicy: 'no-cache' }));
|
|
267
|
+
const item = result.data?.runtime?.systemUIBranding?.items?.[0];
|
|
268
|
+
if (!item) {
|
|
269
|
+
this.state.set({ ...this.defaults });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
this.state.set(await this.mapFromServer(item));
|
|
273
|
+
}
|
|
274
|
+
async save(update) {
|
|
275
|
+
const current = this.state();
|
|
276
|
+
const input = await this.buildInput(update);
|
|
277
|
+
const saved = current.rtId
|
|
278
|
+
? await this.runUpdate(current.rtId, input)
|
|
279
|
+
: await this.runCreate(input);
|
|
280
|
+
const mapped = await this.mapFromServer(saved);
|
|
281
|
+
this.state.set(mapped);
|
|
282
|
+
return mapped;
|
|
283
|
+
}
|
|
284
|
+
async resetToDefaults() {
|
|
285
|
+
await this.save({
|
|
286
|
+
appName: this.defaults.appName,
|
|
287
|
+
appTitle: this.defaults.appTitle,
|
|
288
|
+
headerLogoFile: null,
|
|
289
|
+
footerLogoFile: null,
|
|
290
|
+
faviconFile: null,
|
|
291
|
+
lightTheme: this.defaults.lightTheme,
|
|
292
|
+
darkTheme: this.defaults.darkTheme,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
async buildInput(update) {
|
|
296
|
+
const [headerLogo, footerLogo, favicon] = await Promise.all([
|
|
297
|
+
this.prepareBinary(update.headerLogoFile),
|
|
298
|
+
this.prepareBinary(update.footerLogoFile),
|
|
299
|
+
this.prepareBinary(update.faviconFile),
|
|
300
|
+
]);
|
|
301
|
+
const input = {
|
|
302
|
+
rtWellKnownName: WELL_KNOWN_NAME,
|
|
303
|
+
appName: update.appName,
|
|
304
|
+
appTitle: update.appTitle,
|
|
305
|
+
lightTheme: this.paletteToInput(update.lightTheme),
|
|
306
|
+
darkTheme: update.darkTheme ? this.paletteToInput(update.darkTheme) : null,
|
|
307
|
+
};
|
|
308
|
+
// Tri-state: set the field only on replace (bytes) or clear (null);
|
|
309
|
+
// omit when undefined so the server keeps the current blob.
|
|
310
|
+
if (headerLogo !== undefined)
|
|
311
|
+
input.headerLogo = headerLogo;
|
|
312
|
+
if (footerLogo !== undefined)
|
|
313
|
+
input.footerLogo = footerLogo;
|
|
314
|
+
if (favicon !== undefined)
|
|
315
|
+
input.favicon = favicon;
|
|
316
|
+
return input;
|
|
317
|
+
}
|
|
318
|
+
async runCreate(input) {
|
|
319
|
+
const result = await firstValueFrom(this.createBrandingGQL.mutate({
|
|
320
|
+
variables: { branding: input },
|
|
321
|
+
fetchPolicy: 'no-cache',
|
|
322
|
+
}));
|
|
323
|
+
const item = result.data?.runtime?.systemUIBrandings?.create?.[0];
|
|
324
|
+
if (!item) {
|
|
325
|
+
throw new Error('createBranding returned no entity');
|
|
326
|
+
}
|
|
327
|
+
return item;
|
|
328
|
+
}
|
|
329
|
+
async runUpdate(rtId, input) {
|
|
330
|
+
const result = await firstValueFrom(this.updateBrandingGQL.mutate({
|
|
331
|
+
variables: { branding: { rtId, item: input } },
|
|
332
|
+
fetchPolicy: 'no-cache',
|
|
333
|
+
}));
|
|
334
|
+
const item = result.data?.runtime?.systemUIBrandings?.update?.[0];
|
|
335
|
+
if (!item) {
|
|
336
|
+
throw new Error('updateBranding returned no entity');
|
|
337
|
+
}
|
|
338
|
+
return item;
|
|
339
|
+
}
|
|
340
|
+
paletteToInput(palette) {
|
|
341
|
+
return {
|
|
342
|
+
primaryColor: palette.primaryColor,
|
|
343
|
+
secondaryColor: palette.secondaryColor,
|
|
344
|
+
tertiaryColor: palette.tertiaryColor,
|
|
345
|
+
neutralColor: palette.neutralColor,
|
|
346
|
+
backgroundColor: palette.backgroundColor,
|
|
347
|
+
headerGradient: {
|
|
348
|
+
startColor: palette.headerGradient.startColor,
|
|
349
|
+
endColor: palette.headerGradient.endColor,
|
|
350
|
+
},
|
|
351
|
+
footerGradient: {
|
|
352
|
+
startColor: palette.footerGradient.startColor,
|
|
353
|
+
endColor: palette.footerGradient.endColor,
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
async mapFromServer(item) {
|
|
358
|
+
// `Binary` attribute in the ckType surfaces as GraphQL `[Byte]` (raw byte
|
|
359
|
+
// array scalar). Client converts to a data URL locally for <img src>.
|
|
360
|
+
// No second HTTP round-trip like the `BinaryLinked`/`LargeBinaryInfo`
|
|
361
|
+
// pattern (which would return a downloadUri to fetch).
|
|
362
|
+
const [headerLogoUrl, footerLogoUrl, faviconUrl] = await Promise.all([
|
|
363
|
+
this.bytesToDataUrl(item.headerLogo),
|
|
364
|
+
this.bytesToDataUrl(item.footerLogo),
|
|
365
|
+
this.bytesToDataUrl(item.favicon),
|
|
366
|
+
]);
|
|
367
|
+
return {
|
|
368
|
+
rtId: item.rtId,
|
|
369
|
+
appName: item.appName ?? this.defaults.appName,
|
|
370
|
+
appTitle: item.appTitle ?? this.defaults.appTitle,
|
|
371
|
+
headerLogoUrl: headerLogoUrl,
|
|
372
|
+
footerLogoUrl: footerLogoUrl,
|
|
373
|
+
faviconUrl: faviconUrl,
|
|
374
|
+
lightTheme: this.paletteFromServer(item.lightTheme, this.defaults.lightTheme) ??
|
|
375
|
+
this.defaults.lightTheme,
|
|
376
|
+
darkTheme: this.paletteFromServer(item.darkTheme, this.defaults.darkTheme ?? this.defaults.lightTheme),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
// Defaults are passed in so light/dark palettes fall back to their own
|
|
380
|
+
// defaults instead of always using lightTheme defaults (matters when only
|
|
381
|
+
// partial dark palette comes from server).
|
|
382
|
+
paletteFromServer(palette, defaults) {
|
|
383
|
+
if (!palette)
|
|
384
|
+
return null;
|
|
385
|
+
const { headerGradient, footerGradient } = palette;
|
|
386
|
+
return {
|
|
387
|
+
primaryColor: palette.primaryColor ?? defaults.primaryColor,
|
|
388
|
+
secondaryColor: palette.secondaryColor ?? defaults.secondaryColor,
|
|
389
|
+
tertiaryColor: palette.tertiaryColor ?? defaults.tertiaryColor,
|
|
390
|
+
neutralColor: palette.neutralColor ?? defaults.neutralColor,
|
|
391
|
+
backgroundColor: palette.backgroundColor ?? defaults.backgroundColor,
|
|
392
|
+
headerGradient: headerGradient
|
|
393
|
+
? { startColor: headerGradient.startColor, endColor: headerGradient.endColor }
|
|
394
|
+
: defaults.headerGradient,
|
|
395
|
+
footerGradient: footerGradient
|
|
396
|
+
? { startColor: footerGradient.startColor, endColor: footerGradient.endColor }
|
|
397
|
+
: defaults.footerGradient,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Convert a byte array from a GraphQL `[Byte]` field into a data URL.
|
|
402
|
+
* Returns null for null/undefined/empty input. MIME type is detected from
|
|
403
|
+
* the first few bytes (magic numbers) so <img src> works without a
|
|
404
|
+
* server-provided content-type.
|
|
405
|
+
*/
|
|
406
|
+
async bytesToDataUrl(bytes) {
|
|
407
|
+
if (!bytes || bytes.length === 0)
|
|
408
|
+
return null;
|
|
409
|
+
// Codegen types `[Byte]` entries as `number | null`; strip nulls.
|
|
410
|
+
const clean = bytes.filter((b) => typeof b === 'number');
|
|
411
|
+
if (clean.length === 0)
|
|
412
|
+
return null;
|
|
413
|
+
const uint8 = new Uint8Array(clean);
|
|
414
|
+
const mime = this.detectImageMime(uint8) ?? 'application/octet-stream';
|
|
415
|
+
try {
|
|
416
|
+
return await this.blobToDataUrl(new Blob([uint8], { type: mime }));
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
console.warn('[BrandingDataSource] Failed to decode image bytes', error);
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
detectImageMime(bytes) {
|
|
424
|
+
if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47)
|
|
425
|
+
return 'image/png';
|
|
426
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
|
|
427
|
+
return 'image/jpeg';
|
|
428
|
+
if (bytes.length >= 6 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46)
|
|
429
|
+
return 'image/gif';
|
|
430
|
+
if (bytes.length >= 12 && bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50)
|
|
431
|
+
return 'image/webp';
|
|
432
|
+
if (bytes.length >= 4 && bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0x01 && bytes[3] === 0x00)
|
|
433
|
+
return 'image/vnd.microsoft.icon';
|
|
434
|
+
if (bytes.length >= 5 && bytes[0] === 0x3c && bytes[1] === 0x3f && bytes[2] === 0x78 && bytes[3] === 0x6d && bytes[4] === 0x6c)
|
|
435
|
+
return 'image/svg+xml';
|
|
436
|
+
// UTF-8 BOM-less SVG starts with '<svg'
|
|
437
|
+
if (bytes.length >= 4 && bytes[0] === 0x3c && bytes[1] === 0x73 && bytes[2] === 0x76 && bytes[3] === 0x67)
|
|
438
|
+
return 'image/svg+xml';
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
blobToDataUrl(blob) {
|
|
442
|
+
return new Promise((resolve, reject) => {
|
|
443
|
+
const reader = new FileReader();
|
|
444
|
+
reader.onload = () => {
|
|
445
|
+
const result = reader.result;
|
|
446
|
+
if (typeof result === 'string') {
|
|
447
|
+
resolve(result);
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
reject(new Error('FileReader returned non-string result'));
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
reader.onerror = () => reject(reader.error ?? new Error('FileReader failed'));
|
|
454
|
+
reader.readAsDataURL(blob);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Three-value binary resolution for save():
|
|
459
|
+
* - `null` → caller wants the asset cleared; forward null to the server.
|
|
460
|
+
* - `undefined` → caller left the slot untouched; omit the field so the
|
|
461
|
+
* server keeps the existing blob.
|
|
462
|
+
* - `File` → caller uploaded a new asset; serialize to byte array.
|
|
463
|
+
*/
|
|
464
|
+
async prepareBinary(file) {
|
|
465
|
+
if (file === null)
|
|
466
|
+
return null;
|
|
467
|
+
if (file === undefined)
|
|
468
|
+
return undefined;
|
|
469
|
+
const buffer = await file.arrayBuffer();
|
|
470
|
+
return Array.from(new Uint8Array(buffer));
|
|
471
|
+
}
|
|
472
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: BrandingDataSource, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
473
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: BrandingDataSource, providedIn: 'root' });
|
|
474
|
+
}
|
|
475
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: BrandingDataSource, decorators: [{
|
|
476
|
+
type: Injectable,
|
|
477
|
+
args: [{ providedIn: 'root' }]
|
|
478
|
+
}] });
|
|
479
|
+
|
|
480
|
+
const OCTO_TITLE_TRANSLATOR = new InjectionToken('OCTO_TITLE_TRANSLATOR');
|
|
481
|
+
/**
|
|
482
|
+
* `TitleStrategy` that composes `<branding.appTitle> | <route.breadcrumb>`
|
|
483
|
+
* into `document.title` on every navigation, and re-applies on
|
|
484
|
+
* `BrandingDataSource.branding()` changes (e.g. after Settings save).
|
|
485
|
+
*
|
|
486
|
+
* Wire as the application's `TitleStrategy`:
|
|
487
|
+
* ```ts
|
|
488
|
+
* { provide: TitleStrategy, useExisting: AppTitleService }
|
|
489
|
+
* ```
|
|
490
|
+
*
|
|
491
|
+
* Optional translation of the breadcrumb key is supplied through
|
|
492
|
+
* {@link OCTO_TITLE_TRANSLATOR}; without it the key from `route.data` is used
|
|
493
|
+
* as-is.
|
|
494
|
+
*
|
|
495
|
+
* `BrandingDataSource` is resolved lazily via `injector.get()` rather than
|
|
496
|
+
* field-injected. The router constructs `TitleStrategy` eagerly during
|
|
497
|
+
* `APP_INITIALIZER`, before the host's tenant config has loaded. A direct
|
|
498
|
+
* field-inject of `BrandingDataSource` here would chain into Apollo's
|
|
499
|
+
* factory, which reads `tenantId` while it is still `undefined` and bakes
|
|
500
|
+
* `/tenants/undefined/GraphQL` into the request URI. Lazy resolution defers
|
|
501
|
+
* the chain to the first navigation, by which time config is loaded.
|
|
502
|
+
*/
|
|
503
|
+
class AppTitleService extends TitleStrategy {
|
|
504
|
+
title = inject(Title);
|
|
505
|
+
translator = inject(OCTO_TITLE_TRANSLATOR, {
|
|
506
|
+
optional: true,
|
|
507
|
+
});
|
|
508
|
+
injector = inject(Injector);
|
|
509
|
+
lastRouterState;
|
|
510
|
+
brandingEffectInitialized = false;
|
|
511
|
+
updateTitle(routerState) {
|
|
512
|
+
this.lastRouterState = routerState;
|
|
513
|
+
this.applyTitle();
|
|
514
|
+
this.ensureBrandingSubscription();
|
|
515
|
+
}
|
|
516
|
+
ensureBrandingSubscription() {
|
|
517
|
+
if (this.brandingEffectInitialized)
|
|
518
|
+
return;
|
|
519
|
+
this.brandingEffectInitialized = true;
|
|
520
|
+
effect(() => this.applyTitle(), { injector: this.injector });
|
|
521
|
+
}
|
|
522
|
+
applyTitle() {
|
|
523
|
+
if (!this.lastRouterState)
|
|
524
|
+
return;
|
|
525
|
+
const data = this.injector.get(BrandingDataSource).branding();
|
|
526
|
+
const baseTitle = data.appTitle || data.appName;
|
|
527
|
+
const breadcrumbKey = this.getDeepestBreadcrumb(this.lastRouterState.root);
|
|
528
|
+
if (breadcrumbKey) {
|
|
529
|
+
const resolved = this.translator
|
|
530
|
+
? this.translator(breadcrumbKey)
|
|
531
|
+
: breadcrumbKey;
|
|
532
|
+
this.title.setTitle(`${baseTitle} | ${resolved}`);
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
this.title.setTitle(baseTitle);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
getDeepestBreadcrumb(route) {
|
|
539
|
+
let breadcrumb = typeof route.data['breadcrumb'] === 'string'
|
|
540
|
+
? route.data['breadcrumb']
|
|
541
|
+
: undefined;
|
|
542
|
+
for (const child of route.children) {
|
|
543
|
+
const childBreadcrumb = this.getDeepestBreadcrumb(child);
|
|
544
|
+
if (childBreadcrumb) {
|
|
545
|
+
breadcrumb = childBreadcrumb;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return breadcrumb;
|
|
549
|
+
}
|
|
550
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AppTitleService, deps: null, target: i0.ɵɵFactoryTarget.Injectable });
|
|
551
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AppTitleService, providedIn: 'root' });
|
|
552
|
+
}
|
|
553
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AppTitleService, decorators: [{
|
|
554
|
+
type: Injectable,
|
|
555
|
+
args: [{ providedIn: 'root' }]
|
|
556
|
+
}] });
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Registers branding tokens, defaults, fallback assets, and (by default) the
|
|
560
|
+
* `TitleStrategy` that drives `document.title` from the branding signal.
|
|
561
|
+
*
|
|
562
|
+
* Does **not** load the tenant's branding record. Loading must happen after
|
|
563
|
+
* authentication is settled (Apollo's URI bakes in the tenant ID at the time
|
|
564
|
+
* the first request fires; loading too early targets
|
|
565
|
+
* `/tenants/undefined/GraphQL`). The host shell typically does:
|
|
566
|
+
*
|
|
567
|
+
* ```ts
|
|
568
|
+
* inject(BrandingApplicationService); // start the apply effect
|
|
569
|
+
* effect(() => {
|
|
570
|
+
* if (this.auth.isAuthenticated()) {
|
|
571
|
+
* this.brandingDataSource.load();
|
|
572
|
+
* }
|
|
573
|
+
* });
|
|
574
|
+
* ```
|
|
575
|
+
*
|
|
576
|
+
* `BrandingApplicationService` is `providedIn: 'root'`, so the host only has
|
|
577
|
+
* to inject it once to activate the apply effect.
|
|
578
|
+
*/
|
|
579
|
+
function provideOctoBranding(config) {
|
|
580
|
+
const merged = {
|
|
581
|
+
...NEUTRAL_BRANDING_DEFAULTS,
|
|
582
|
+
...(config?.defaults ?? {}),
|
|
583
|
+
lightTheme: {
|
|
584
|
+
...NEUTRAL_BRANDING_DEFAULTS.lightTheme,
|
|
585
|
+
...(config?.defaults?.lightTheme ?? {}),
|
|
586
|
+
},
|
|
587
|
+
darkTheme: config?.defaults?.darkTheme === null
|
|
588
|
+
? null
|
|
589
|
+
: {
|
|
590
|
+
...(NEUTRAL_BRANDING_DEFAULTS.darkTheme ??
|
|
591
|
+
NEUTRAL_BRANDING_DEFAULTS.lightTheme),
|
|
592
|
+
...(config?.defaults?.darkTheme ?? {}),
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
const providers = [
|
|
596
|
+
{ provide: OCTO_BRANDING_DEFAULTS, useValue: merged },
|
|
597
|
+
{
|
|
598
|
+
provide: OCTO_BRANDING_FALLBACK_ASSETS,
|
|
599
|
+
useValue: config?.fallbackAssets ?? NEUTRAL_FALLBACK_ASSETS,
|
|
600
|
+
},
|
|
601
|
+
];
|
|
602
|
+
if (config?.registerTitleStrategy !== false) {
|
|
603
|
+
providers.push({ provide: TitleStrategy, useExisting: AppTitleService });
|
|
604
|
+
}
|
|
605
|
+
return makeEnvironmentProviders(providers);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Convenience barrel re-exporting branding model interfaces from ./models/. */
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Light/dark mode toggle backed by `localStorage` + `<html data-theme="...">`.
|
|
612
|
+
* Pure mode toggle; palette generation lives in `BrandingApplicationService`.
|
|
613
|
+
*/
|
|
614
|
+
class ThemeService {
|
|
615
|
+
document = inject(DOCUMENT);
|
|
616
|
+
renderer;
|
|
617
|
+
isDarkSignal = signal(false, ...(ngDevMode ? [{ debugName: "isDarkSignal" }] : /* istanbul ignore next */ []));
|
|
618
|
+
isDark = this.isDarkSignal.asReadonly();
|
|
619
|
+
constructor() {
|
|
620
|
+
this.renderer = inject(RendererFactory2).createRenderer(null, null);
|
|
621
|
+
this.isDarkSignal.set(this.getInitialThemeIsDark());
|
|
622
|
+
this.applyTheme();
|
|
623
|
+
}
|
|
624
|
+
toggleTheme() {
|
|
625
|
+
this.setDark(!this.isDarkSignal());
|
|
626
|
+
}
|
|
627
|
+
setDark(isDark) {
|
|
628
|
+
this.isDarkSignal.set(isDark);
|
|
629
|
+
try {
|
|
630
|
+
this.document.defaultView?.localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
// localStorage may be unavailable (private mode, SSR) — degrade silently.
|
|
634
|
+
}
|
|
635
|
+
this.applyTheme();
|
|
636
|
+
}
|
|
637
|
+
applyTheme() {
|
|
638
|
+
const html = this.document.documentElement;
|
|
639
|
+
this.renderer.setAttribute(html, 'data-theme', this.isDarkSignal() ? 'dark' : 'light');
|
|
640
|
+
}
|
|
641
|
+
getInitialThemeIsDark() {
|
|
642
|
+
let stored = null;
|
|
643
|
+
try {
|
|
644
|
+
stored = this.document.defaultView?.localStorage.getItem('theme') ?? null;
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
stored = null;
|
|
648
|
+
}
|
|
649
|
+
if (stored === 'dark')
|
|
650
|
+
return true;
|
|
651
|
+
if (stored === 'light')
|
|
652
|
+
return false;
|
|
653
|
+
return (this.document.defaultView?.matchMedia?.('(prefers-color-scheme: dark)')
|
|
654
|
+
.matches ?? false);
|
|
655
|
+
}
|
|
656
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: ThemeService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
657
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: ThemeService, providedIn: 'root' });
|
|
658
|
+
}
|
|
659
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: ThemeService, decorators: [{
|
|
660
|
+
type: Injectable,
|
|
661
|
+
args: [{ providedIn: 'root' }]
|
|
662
|
+
}], ctorParameters: () => [] });
|
|
663
|
+
|
|
664
|
+
const DEFAULT_THEME_SWITCHER_MESSAGES = {
|
|
665
|
+
toggleToDark: 'Switch to dark mode',
|
|
666
|
+
toggleToLight: 'Switch to light mode',
|
|
667
|
+
unavailable: 'Theme switching is unavailable for this tenant',
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
class ThemeSwitcherComponent {
|
|
671
|
+
messages = input(DEFAULT_THEME_SWITCHER_MESSAGES, ...(ngDevMode ? [{ debugName: "messages" }] : /* istanbul ignore next */ []));
|
|
672
|
+
themeService = inject(ThemeService);
|
|
673
|
+
branding = inject(BrandingDataSource);
|
|
674
|
+
isDark = this.themeService.isDark;
|
|
675
|
+
available = computed(() => this.branding.branding().darkTheme !== null, ...(ngDevMode ? [{ debugName: "available" }] : /* istanbul ignore next */ []));
|
|
676
|
+
ariaLabel = computed(() => {
|
|
677
|
+
if (!this.available())
|
|
678
|
+
return this.messages().unavailable;
|
|
679
|
+
return this.isDark()
|
|
680
|
+
? this.messages().toggleToLight
|
|
681
|
+
: this.messages().toggleToDark;
|
|
682
|
+
}, ...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
|
|
683
|
+
lightIcon = lightbulbOutlineIcon;
|
|
684
|
+
darkIcon = brightnessContrastIcon;
|
|
685
|
+
onToggle() {
|
|
686
|
+
if (!this.available())
|
|
687
|
+
return;
|
|
688
|
+
this.themeService.setDark(!this.isDark());
|
|
689
|
+
}
|
|
690
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: ThemeSwitcherComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
691
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.9", type: ThemeSwitcherComponent, isStandalone: true, selector: "mm-theme-switcher", inputs: { messages: { classPropertyName: "messages", publicName: "messages", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<button\n type=\"button\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-pressed]=\"isDark()\"\n [disabled]=\"!available()\"\n (click)=\"onToggle()\"\n>\n <kendo-svg-icon [icon]=\"isDark() ? lightIcon : darkIcon\" />\n</button>\n", styles: [":host{display:inline-flex}button{appearance:none;background:transparent;border:0;padding:var(--kendo-spacing-2);cursor:pointer;color:inherit}button[disabled]{cursor:not-allowed;opacity:.5}button:focus-visible{outline:2px solid currentColor;outline-offset:2px}\n"], dependencies: [{ kind: "ngmodule", type: SVGIconModule }, { kind: "component", type: i1$1.SVGIconComponent, selector: "kendo-svg-icon, kendo-svgicon", inputs: ["icon"], exportAs: ["kendoSVGIcon"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
692
|
+
}
|
|
693
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: ThemeSwitcherComponent, decorators: [{
|
|
694
|
+
type: Component,
|
|
695
|
+
args: [{ selector: 'mm-theme-switcher', standalone: true, imports: [SVGIconModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<button\n type=\"button\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-pressed]=\"isDark()\"\n [disabled]=\"!available()\"\n (click)=\"onToggle()\"\n>\n <kendo-svg-icon [icon]=\"isDark() ? lightIcon : darkIcon\" />\n</button>\n", styles: [":host{display:inline-flex}button{appearance:none;background:transparent;border:0;padding:var(--kendo-spacing-2);cursor:pointer;color:inherit}button[disabled]{cursor:not-allowed;opacity:.5}button:focus-visible{outline:2px solid currentColor;outline-offset:2px}\n"] }]
|
|
696
|
+
}], propDecorators: { messages: [{ type: i0.Input, args: [{ isSignal: true, alias: "messages", required: false }] }] } });
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Writes tenant branding (Kendo color vars, gradients, favicon, document
|
|
700
|
+
* title) inline on <html>. An effect re-applies the correct palette when
|
|
701
|
+
* either the theme mode or the branding data changes, so mode toggles after
|
|
702
|
+
* a save don't leave stale inline values behind.
|
|
703
|
+
*/
|
|
704
|
+
class BrandingApplicationService {
|
|
705
|
+
document = inject(DOCUMENT);
|
|
706
|
+
themeService = inject(ThemeService);
|
|
707
|
+
brandingDataSource = inject(BrandingDataSource);
|
|
708
|
+
fallbackAssets = inject(OCTO_BRANDING_FALLBACK_ASSETS, {
|
|
709
|
+
optional: true,
|
|
710
|
+
});
|
|
711
|
+
// Cleared before every apply so unset palette fields fall through to the
|
|
712
|
+
// static layer-2 brand-default instead of sticking from a prior save.
|
|
713
|
+
static BRANDED_VARS = [
|
|
714
|
+
...['primary', 'secondary', 'tertiary'].flatMap((c) => [
|
|
715
|
+
`--kendo-color-${c}`,
|
|
716
|
+
`--kendo-color-${c}-hover`,
|
|
717
|
+
`--kendo-color-${c}-active`,
|
|
718
|
+
`--kendo-color-on-${c}`,
|
|
719
|
+
`--kendo-color-${c}-subtle`,
|
|
720
|
+
`--kendo-color-${c}-subtle-hover`,
|
|
721
|
+
`--kendo-color-${c}-subtle-active`,
|
|
722
|
+
`--kendo-color-${c}-emphasis`,
|
|
723
|
+
`--kendo-color-${c}-on-subtle`,
|
|
724
|
+
`--kendo-color-${c}-on-surface`,
|
|
725
|
+
]),
|
|
726
|
+
'--kendo-color-base',
|
|
727
|
+
'--kendo-color-base-hover',
|
|
728
|
+
'--kendo-color-base-active',
|
|
729
|
+
'--kendo-color-on-base',
|
|
730
|
+
'--kendo-color-base-subtle',
|
|
731
|
+
'--kendo-color-base-subtle-hover',
|
|
732
|
+
'--kendo-color-base-subtle-active',
|
|
733
|
+
'--kendo-color-base-emphasis',
|
|
734
|
+
'--kendo-color-base-on-subtle',
|
|
735
|
+
'--kendo-color-subtle',
|
|
736
|
+
'--kendo-color-border',
|
|
737
|
+
'--kendo-color-border-alt',
|
|
738
|
+
'--kendo-color-app-surface',
|
|
739
|
+
'--kendo-color-on-app-surface',
|
|
740
|
+
'--app-header-gradient-start',
|
|
741
|
+
'--app-header-gradient-end',
|
|
742
|
+
'--app-header-text',
|
|
743
|
+
'--app-footer-gradient-start',
|
|
744
|
+
'--app-footer-gradient-end',
|
|
745
|
+
'--app-footer-text',
|
|
746
|
+
];
|
|
747
|
+
constructor() {
|
|
748
|
+
effect(() => {
|
|
749
|
+
const isDark = this.themeService.isDark();
|
|
750
|
+
const data = this.brandingDataSource.branding();
|
|
751
|
+
// Tenant disabled dark-theme override (single-theme app) — force light
|
|
752
|
+
// mode. Without this guard a stale 'dark' in localStorage (or system
|
|
753
|
+
// prefers-color-scheme) carries over and we'd render light palette
|
|
754
|
+
// values into html[data-theme='dark']: surface ladder collapses to
|
|
755
|
+
// white and text becomes unreadable. ThemeSwitcherComponent also
|
|
756
|
+
// disables the toggle when darkTheme is null so the user can't
|
|
757
|
+
// re-enter this state interactively.
|
|
758
|
+
if (data.darkTheme === null && isDark) {
|
|
759
|
+
this.themeService.setDark(false);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const palette = isDark ? data.darkTheme : data.lightTheme;
|
|
763
|
+
this.apply(data, palette);
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
apply(data, palette) {
|
|
767
|
+
this.clearInlineBrandingVars();
|
|
768
|
+
this.applyThemeColors(palette);
|
|
769
|
+
this.applyFavicon(data.faviconUrl);
|
|
770
|
+
// document.title is owned by AppTitleService (TitleStrategy) — it composes
|
|
771
|
+
// `${branding().appName} | ${routeBreadcrumb}` per navigation. Setting
|
|
772
|
+
// title here would race AppTitleService and overwrite the route portion
|
|
773
|
+
// (e.g. /settings would flicker from "TecLink | Settings" to just "TecLink"
|
|
774
|
+
// when branding finishes loading). The tab updates on next navigation
|
|
775
|
+
// after a tenant edits appName/appTitle in Settings — acceptable trade-off.
|
|
776
|
+
}
|
|
777
|
+
applyThemeColors(palette) {
|
|
778
|
+
const root = this.document.documentElement;
|
|
779
|
+
if (palette.primaryColor) {
|
|
780
|
+
this.applyColorPalette(root, 'primary', palette.primaryColor);
|
|
781
|
+
}
|
|
782
|
+
if (palette.secondaryColor) {
|
|
783
|
+
this.applyColorPalette(root, 'secondary', palette.secondaryColor);
|
|
784
|
+
}
|
|
785
|
+
if (palette.tertiaryColor) {
|
|
786
|
+
this.applyColorPalette(root, 'tertiary', palette.tertiaryColor);
|
|
787
|
+
}
|
|
788
|
+
if (palette.neutralColor) {
|
|
789
|
+
this.applyNeutralColors(root, palette.neutralColor);
|
|
790
|
+
}
|
|
791
|
+
this.setIf(root, '--app-header-gradient-start', palette.headerGradient.startColor);
|
|
792
|
+
this.setIf(root, '--app-header-gradient-end', palette.headerGradient.endColor);
|
|
793
|
+
if (palette.headerGradient.startColor) {
|
|
794
|
+
root.style.setProperty('--app-header-text', this.getContrastColor(palette.headerGradient.startColor));
|
|
795
|
+
}
|
|
796
|
+
this.setIf(root, '--app-footer-gradient-start', palette.footerGradient.startColor);
|
|
797
|
+
this.setIf(root, '--app-footer-gradient-end', palette.footerGradient.endColor);
|
|
798
|
+
if (palette.footerGradient.startColor) {
|
|
799
|
+
root.style.setProperty('--app-footer-text', this.getContrastColor(palette.footerGradient.startColor));
|
|
800
|
+
}
|
|
801
|
+
if (palette.backgroundColor) {
|
|
802
|
+
// Only write the root surface — --kendo-color-surface / -surface-alt /
|
|
803
|
+
// -base / -border are aliased in styles.scss to --color-surface-* shades
|
|
804
|
+
// that derive from --color-surface (= app-surface) via mode-aware
|
|
805
|
+
// color-mix. Writing them directly here would collapse the shade ladder
|
|
806
|
+
// (all surfaces flat = same colour → transparent-looking components).
|
|
807
|
+
root.style.setProperty('--kendo-color-app-surface', palette.backgroundColor);
|
|
808
|
+
// Contrast text on the surface — without this, a dark backgroundColor in
|
|
809
|
+
// light mode (or vice versa) renders text against an inverted surface
|
|
810
|
+
// and becomes unreadable. --color-on-surface in styles.scss aliases this.
|
|
811
|
+
root.style.setProperty('--kendo-color-on-app-surface', this.getContrastColor(palette.backgroundColor));
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
applyFavicon(faviconUrl) {
|
|
815
|
+
const href = faviconUrl ?? this.fallbackAssets?.favicon;
|
|
816
|
+
if (!href)
|
|
817
|
+
return;
|
|
818
|
+
let link = this.document.querySelector("link[rel~='icon']");
|
|
819
|
+
if (!link) {
|
|
820
|
+
link = this.document.createElement('link');
|
|
821
|
+
link.rel = 'icon';
|
|
822
|
+
this.document.head.appendChild(link);
|
|
823
|
+
}
|
|
824
|
+
link.href = href;
|
|
825
|
+
}
|
|
826
|
+
clearInlineBrandingVars() {
|
|
827
|
+
const root = this.document.documentElement;
|
|
828
|
+
for (const name of BrandingApplicationService.BRANDED_VARS) {
|
|
829
|
+
root.style.removeProperty(name);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
setIf(root, name, value) {
|
|
833
|
+
if (value)
|
|
834
|
+
root.style.setProperty(name, value);
|
|
835
|
+
}
|
|
836
|
+
applyColorPalette(root, colorName, baseColor) {
|
|
837
|
+
const palette = this.generatePalette(baseColor);
|
|
838
|
+
root.style.setProperty(`--kendo-color-${colorName}`, palette[40]);
|
|
839
|
+
root.style.setProperty(`--kendo-color-${colorName}-hover`, palette[50]);
|
|
840
|
+
root.style.setProperty(`--kendo-color-${colorName}-active`, palette[60]);
|
|
841
|
+
root.style.setProperty(`--kendo-color-on-${colorName}`, this.getContrastColor(palette[40]));
|
|
842
|
+
root.style.setProperty(`--kendo-color-${colorName}-subtle`, palette[90]);
|
|
843
|
+
root.style.setProperty(`--kendo-color-${colorName}-subtle-hover`, palette[80]);
|
|
844
|
+
root.style.setProperty(`--kendo-color-${colorName}-subtle-active`, palette[70]);
|
|
845
|
+
root.style.setProperty(`--kendo-color-${colorName}-emphasis`, palette[70]);
|
|
846
|
+
root.style.setProperty(`--kendo-color-${colorName}-on-subtle`, palette[20]);
|
|
847
|
+
root.style.setProperty(`--kendo-color-${colorName}-on-surface`, palette[40]);
|
|
848
|
+
}
|
|
849
|
+
applyNeutralColors(root, baseColor) {
|
|
850
|
+
const palette = this.generatePalette(baseColor);
|
|
851
|
+
root.style.setProperty('--kendo-color-base', palette[95]);
|
|
852
|
+
root.style.setProperty('--kendo-color-base-hover', palette[90]);
|
|
853
|
+
root.style.setProperty('--kendo-color-base-active', palette[80]);
|
|
854
|
+
root.style.setProperty('--kendo-color-on-base', palette[20]);
|
|
855
|
+
root.style.setProperty('--kendo-color-base-subtle', palette[90]);
|
|
856
|
+
root.style.setProperty('--kendo-color-base-subtle-hover', palette[80]);
|
|
857
|
+
root.style.setProperty('--kendo-color-base-subtle-active', palette[70]);
|
|
858
|
+
root.style.setProperty('--kendo-color-base-emphasis', palette[70]);
|
|
859
|
+
root.style.setProperty('--kendo-color-base-on-subtle', palette[20]);
|
|
860
|
+
root.style.setProperty('--kendo-color-subtle', palette[40]);
|
|
861
|
+
root.style.setProperty('--kendo-color-border', palette[80]);
|
|
862
|
+
root.style.setProperty('--kendo-color-border-alt', palette[70]);
|
|
863
|
+
}
|
|
864
|
+
// ---------------------------------------------------------------------------
|
|
865
|
+
// Palette generation (ported from energy-community theme.service.ts)
|
|
866
|
+
// ---------------------------------------------------------------------------
|
|
867
|
+
generatePalette(baseColor) {
|
|
868
|
+
const rgb = this.hexToRgb(baseColor);
|
|
869
|
+
const hsl = this.rgbToHsl(rgb.r, rgb.g, rgb.b);
|
|
870
|
+
const palette = {
|
|
871
|
+
0: '#000000',
|
|
872
|
+
100: '#ffffff',
|
|
873
|
+
};
|
|
874
|
+
palette[40] = baseColor;
|
|
875
|
+
const baseL = hsl.l;
|
|
876
|
+
const baseS = hsl.s;
|
|
877
|
+
const baseH = hsl.h;
|
|
878
|
+
const lighten = (amount) => Math.min(1, baseL + amount);
|
|
879
|
+
const darken = (amount) => Math.max(0, baseL - amount);
|
|
880
|
+
palette[50] = this.hslToHex(baseH, Math.min(1, baseS * 1.15), darken(0.12));
|
|
881
|
+
palette[60] = this.hslToHex(baseH, Math.min(1, baseS * 1.08), darken(0.08));
|
|
882
|
+
palette[70] = this.hslToHex(baseH, Math.max(0.25, baseS * 0.85), lighten(0.08));
|
|
883
|
+
palette[80] = this.hslToHex(baseH, Math.max(0.15, baseS * 0.6), lighten(0.22));
|
|
884
|
+
palette[90] = this.hslToHex(baseH, Math.max(0.08, baseS * 0.35), lighten(0.38));
|
|
885
|
+
palette[95] = this.hslToHex(baseH, Math.max(0.04, baseS * 0.2), lighten(0.43));
|
|
886
|
+
palette[98] = this.hslToHex(baseH, Math.max(0.02, baseS * 0.12), lighten(0.46));
|
|
887
|
+
palette[99] = this.hslToHex(baseH, Math.max(0.01, baseS * 0.08), lighten(0.47));
|
|
888
|
+
palette[10] = this.hslToHex(baseH, Math.min(1, baseS * 1.4), darken(0.72));
|
|
889
|
+
palette[20] = this.hslToHex(baseH, Math.min(1, baseS * 1.25), darken(0.62));
|
|
890
|
+
palette[25] = this.hslToHex(baseH, Math.min(1, baseS * 1.2), darken(0.57));
|
|
891
|
+
palette[30] = this.hslToHex(baseH, Math.min(1, baseS * 1.15), darken(0.52));
|
|
892
|
+
palette[35] = this.hslToHex(baseH, Math.min(1, baseS * 1.1), darken(0.42));
|
|
893
|
+
return palette;
|
|
894
|
+
}
|
|
895
|
+
hexToRgb(color) {
|
|
896
|
+
const hexResult = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
|
|
897
|
+
if (hexResult) {
|
|
898
|
+
return {
|
|
899
|
+
r: parseInt(hexResult[1], 16),
|
|
900
|
+
g: parseInt(hexResult[2], 16),
|
|
901
|
+
b: parseInt(hexResult[3], 16),
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
const rgbResult = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(color);
|
|
905
|
+
if (rgbResult) {
|
|
906
|
+
return {
|
|
907
|
+
r: parseInt(rgbResult[1], 10),
|
|
908
|
+
g: parseInt(rgbResult[2], 10),
|
|
909
|
+
b: parseInt(rgbResult[3], 10),
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
return { r: 0, g: 0, b: 0 };
|
|
913
|
+
}
|
|
914
|
+
rgbToHsl(r, g, b) {
|
|
915
|
+
const rn = r / 255;
|
|
916
|
+
const gn = g / 255;
|
|
917
|
+
const bn = b / 255;
|
|
918
|
+
const max = Math.max(rn, gn, bn);
|
|
919
|
+
const min = Math.min(rn, gn, bn);
|
|
920
|
+
let h = 0;
|
|
921
|
+
let s = 0;
|
|
922
|
+
const l = (max + min) / 2;
|
|
923
|
+
if (max !== min) {
|
|
924
|
+
const d = max - min;
|
|
925
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
926
|
+
switch (max) {
|
|
927
|
+
case rn:
|
|
928
|
+
h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
|
|
929
|
+
break;
|
|
930
|
+
case gn:
|
|
931
|
+
h = ((bn - rn) / d + 2) / 6;
|
|
932
|
+
break;
|
|
933
|
+
case bn:
|
|
934
|
+
h = ((rn - gn) / d + 4) / 6;
|
|
935
|
+
break;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return { h, s, l };
|
|
939
|
+
}
|
|
940
|
+
hslToHex(h, s, l) {
|
|
941
|
+
let r;
|
|
942
|
+
let g;
|
|
943
|
+
let b;
|
|
944
|
+
if (s === 0) {
|
|
945
|
+
r = l;
|
|
946
|
+
g = l;
|
|
947
|
+
b = l;
|
|
948
|
+
}
|
|
949
|
+
else {
|
|
950
|
+
const hue2rgb = (p, q, t) => {
|
|
951
|
+
let tn = t;
|
|
952
|
+
if (tn < 0)
|
|
953
|
+
tn += 1;
|
|
954
|
+
if (tn > 1)
|
|
955
|
+
tn -= 1;
|
|
956
|
+
if (tn < 1 / 6)
|
|
957
|
+
return p + (q - p) * 6 * tn;
|
|
958
|
+
if (tn < 1 / 2)
|
|
959
|
+
return q;
|
|
960
|
+
if (tn < 2 / 3)
|
|
961
|
+
return p + (q - p) * (2 / 3 - tn) * 6;
|
|
962
|
+
return p;
|
|
963
|
+
};
|
|
964
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
965
|
+
const p = 2 * l - q;
|
|
966
|
+
r = hue2rgb(p, q, h + 1 / 3);
|
|
967
|
+
g = hue2rgb(p, q, h);
|
|
968
|
+
b = hue2rgb(p, q, h - 1 / 3);
|
|
969
|
+
}
|
|
970
|
+
return this.rgbToHex(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255));
|
|
971
|
+
}
|
|
972
|
+
rgbToHex(r, g, b) {
|
|
973
|
+
return ('#' +
|
|
974
|
+
[r, g, b]
|
|
975
|
+
.map((x) => {
|
|
976
|
+
const hex = Math.round(x).toString(16);
|
|
977
|
+
return hex.length === 1 ? '0' + hex : hex;
|
|
978
|
+
})
|
|
979
|
+
.join(''));
|
|
980
|
+
}
|
|
981
|
+
getContrastColor(hex) {
|
|
982
|
+
const rgb = this.hexToRgb(hex);
|
|
983
|
+
const brightness = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000;
|
|
984
|
+
return brightness > 128 ? '#000000' : '#ffffff';
|
|
985
|
+
}
|
|
986
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: BrandingApplicationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
987
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: BrandingApplicationService, providedIn: 'root' });
|
|
988
|
+
}
|
|
989
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: BrandingApplicationService, decorators: [{
|
|
990
|
+
type: Injectable,
|
|
991
|
+
args: [{ providedIn: 'root' }]
|
|
992
|
+
}], ctorParameters: () => [] });
|
|
993
|
+
|
|
994
|
+
function createBrandingStub(overrides) {
|
|
995
|
+
const branding = signal({
|
|
996
|
+
...NEUTRAL_BRANDING_DEFAULTS,
|
|
997
|
+
...(overrides ?? {}),
|
|
998
|
+
}, ...(ngDevMode ? [{ debugName: "branding" }] : /* istanbul ignore next */ []));
|
|
999
|
+
return {
|
|
1000
|
+
branding,
|
|
1001
|
+
load: () => Promise.resolve(),
|
|
1002
|
+
save: () => Promise.resolve(branding()),
|
|
1003
|
+
resetToDefaults: () => Promise.resolve(),
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
/** Convenience: returns a Provider that injects the stub as BrandingDataSource. */
|
|
1007
|
+
function provideBrandingTesting(stub = createBrandingStub()) {
|
|
1008
|
+
return { provide: BrandingDataSource, useValue: stub };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Public API of the branding feature folder. Re-exported by
|
|
1012
|
+
// octo-ui/src/public-api.ts to expose under @meshmakers/octo-ui.
|
|
1013
|
+
//
|
|
1014
|
+
// Note: the heavy admin-only `SettingsPageComponent` (a.k.a.
|
|
1015
|
+
// `mm-branding-settings`) and `BRANDING_ROUTES` live in the dedicated
|
|
1016
|
+
// secondary entry point `@meshmakers/octo-ui/branding-settings` so apps that
|
|
1017
|
+
// only need the lightweight branding pieces (logo, theme switcher, services)
|
|
1018
|
+
// don't pull in form/Kendo modules they don't use.
|
|
1019
|
+
// Configuration & tokens
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Generated bundle index. Do not edit.
|
|
1023
|
+
*/
|
|
1024
|
+
|
|
1025
|
+
export { AppTitleService, BrandingApplicationService, BrandingDataSource, DEFAULT_THEME_SWITCHER_MESSAGES, NEUTRAL_BRANDING_DEFAULTS, NEUTRAL_FALLBACK_ASSETS, OCTO_BRANDING_DEFAULTS, OCTO_BRANDING_FALLBACK_ASSETS, OCTO_TITLE_TRANSLATOR, ThemeService, ThemeSwitcherComponent, createBrandingStub, provideBrandingTesting, provideOctoBranding };
|
|
1026
|
+
//# sourceMappingURL=meshmakers-octo-ui-branding.mjs.map
|