@reforgium/presentia 1.0.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.
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, signal, computed, inject, afterRenderEffect, Injectable, input, TemplateRef, ViewContainerRef, effect, Directive, Pipe, makeEnvironmentProviders, LOCALE_ID, DestroyRef } from '@angular/core';
|
|
3
|
+
import { SELECTED_LANG, SELECTED_THEME, CURRENT_DEVICE, deepEqual } from '@reforgium/internal';
|
|
4
|
+
import { BreakpointObserver } from '@angular/cdk/layout';
|
|
5
|
+
import { fromEvent, debounceTime, startWith, filter as filter$1, map } from 'rxjs';
|
|
6
|
+
import { HttpClient } from '@angular/common/http';
|
|
7
|
+
import { Title, Meta } from '@angular/platform-browser';
|
|
8
|
+
import { Router, ActivatedRoute, NavigationEnd } from '@angular/router';
|
|
9
|
+
import { filter } from 'rxjs/operators';
|
|
10
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
11
|
+
|
|
12
|
+
const defaultBreakpoints = {
|
|
13
|
+
'mobile': '(max-width: 719px)',
|
|
14
|
+
'tablet': '(min-width: 720px) and (max-width: 1399px)',
|
|
15
|
+
'desktop-s': '(min-width: 1400px) and (max-width: 1919px)',
|
|
16
|
+
'desktop': '(min-width: 1920px)',
|
|
17
|
+
};
|
|
18
|
+
const DEVICE_BREAKPOINTS = new InjectionToken('RE_DEVICE_BREAKPOINTS', {
|
|
19
|
+
providedIn: 'root',
|
|
20
|
+
factory: () => defaultBreakpoints,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Сервис `AdaptiveService` отвечает за определение типа устройства, размеров окна и ориентации экрана.
|
|
25
|
+
*
|
|
26
|
+
* Используется для построения адаптивных интерфейсов, изменения поведения компонентов и стилей
|
|
27
|
+
* в зависимости от текущего устройства или размера экрана.
|
|
28
|
+
*
|
|
29
|
+
* Основные возможности:
|
|
30
|
+
* - Реактивное отслеживание текущего устройства (`desktop`, `tablet`, `mobile`).
|
|
31
|
+
* - Поддержка вычисляемых признаков (`isDesktop`, `isPortrait`).
|
|
32
|
+
* - Автоматическое обновление при изменении размера окна и пересечении брейкпоинтов.
|
|
33
|
+
*
|
|
34
|
+
* Реализация основана на:
|
|
35
|
+
* - `BreakpointObserver` из Angular CDK — для наблюдения за медиа-запросами.
|
|
36
|
+
* - `signal` и `computed` — для реактивного состояния без зон.
|
|
37
|
+
* - `fromEvent(window, 'resize')` — для обновления размеров окна с дебаунсом.
|
|
38
|
+
*
|
|
39
|
+
* Сервис зарегистрирован как `providedIn: 'root'` и доступен во всём приложении.
|
|
40
|
+
*/
|
|
41
|
+
class AdaptiveService {
|
|
42
|
+
/** @internal Сигнал текущего типа устройства. */
|
|
43
|
+
#device = signal('desktop', ...(ngDevMode ? [{ debugName: "#device" }] : []));
|
|
44
|
+
/** @internal Сигналы текущей ширины и высоты окна. */
|
|
45
|
+
#width = signal(0, ...(ngDevMode ? [{ debugName: "#width" }] : []));
|
|
46
|
+
#height = signal(0, ...(ngDevMode ? [{ debugName: "#height" }] : []));
|
|
47
|
+
/**
|
|
48
|
+
* Текущий тип устройства (reactive signal).
|
|
49
|
+
* Возможные значения: `'desktop' | 'tablet' | 'mobile'`.
|
|
50
|
+
*
|
|
51
|
+
* Обновляется автоматически при изменении ширины экрана
|
|
52
|
+
* или при пересечении заданных брейкпоинтов (`DEVICE_BREAKPOINTS`).
|
|
53
|
+
*/
|
|
54
|
+
device = this.#device.asReadonly();
|
|
55
|
+
/**
|
|
56
|
+
* Текущая ширина окна браузера в пикселях.
|
|
57
|
+
* Обновляется реактивно при событии `resize`.
|
|
58
|
+
*/
|
|
59
|
+
width = this.#width.asReadonly();
|
|
60
|
+
/**
|
|
61
|
+
* Текущая высота окна браузера в пикселях.
|
|
62
|
+
* Обновляется реактивно при событии `resize`.
|
|
63
|
+
*/
|
|
64
|
+
height = this.#height.asReadonly();
|
|
65
|
+
/**
|
|
66
|
+
* Вычисляемый сигнал, показывающий, является ли текущее устройство десктопом.
|
|
67
|
+
* Используется для условного рендера или настройки макета.
|
|
68
|
+
*/
|
|
69
|
+
isDesktop = computed(() => this.#device() === 'desktop', ...(ngDevMode ? [{ debugName: "isDesktop" }] : []));
|
|
70
|
+
/**
|
|
71
|
+
* Вычисляемый сигнал, определяющий, находится ли экран в портретной ориентации.
|
|
72
|
+
* Возвращает `true`, если высота окна больше ширины.
|
|
73
|
+
*/
|
|
74
|
+
isPortrait = computed(() => this.#height() > this.#width(), ...(ngDevMode ? [{ debugName: "isPortrait" }] : []));
|
|
75
|
+
deviceBreakpoints = inject(DEVICE_BREAKPOINTS);
|
|
76
|
+
devicePriority = Object.keys(this.deviceBreakpoints);
|
|
77
|
+
breakpointObserver = inject(BreakpointObserver);
|
|
78
|
+
constructor() {
|
|
79
|
+
afterRenderEffect(() => {
|
|
80
|
+
this.#width.set(window.innerWidth);
|
|
81
|
+
this.#height.set(window.innerHeight);
|
|
82
|
+
});
|
|
83
|
+
this.breakpointObserver.observe(Object.values(this.deviceBreakpoints)).subscribe((state) => {
|
|
84
|
+
const device = this.devicePriority.find((key) => state.breakpoints[this.deviceBreakpoints[key]]);
|
|
85
|
+
this.#device.set(device ?? 'desktop');
|
|
86
|
+
});
|
|
87
|
+
fromEvent(window, 'resize')
|
|
88
|
+
.pipe(debounceTime(100))
|
|
89
|
+
.subscribe(() => {
|
|
90
|
+
this.#width.set(window.innerWidth);
|
|
91
|
+
this.#height.set(window.innerHeight);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: AdaptiveService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
95
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: AdaptiveService, providedIn: 'root' });
|
|
96
|
+
}
|
|
97
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: AdaptiveService, decorators: [{
|
|
98
|
+
type: Injectable,
|
|
99
|
+
args: [{
|
|
100
|
+
providedIn: 'root',
|
|
101
|
+
}]
|
|
102
|
+
}], ctorParameters: () => [] });
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Структурная директива `*ssIfDevice`.
|
|
106
|
+
*
|
|
107
|
+
* Показывает или скрывает элемент в зависимости от текущего устройства,
|
|
108
|
+
* определяемого через `AdaptiveService`.
|
|
109
|
+
*
|
|
110
|
+
* Пример:
|
|
111
|
+
* ```html
|
|
112
|
+
* <div *ssIfDevice="'desktop'">Только для десктопа</div>
|
|
113
|
+
* <div *ssIfDevice="['mobile', 'tablet']">Для мобильных и планшетов</div>
|
|
114
|
+
* <div *ssIfDevice="'mobile'; inverse: true">Скрыть на мобильных</div>
|
|
115
|
+
* ```
|
|
116
|
+
*
|
|
117
|
+
* Параметры:
|
|
118
|
+
* - `ssIfDevice` — одно или несколько значений `Devices` (`'desktop' | 'tablet' | 'mobile'`)
|
|
119
|
+
* - `inverse` — инвертирует условие показа
|
|
120
|
+
*
|
|
121
|
+
* Работает реактивно: при изменении типа устройства в `AdaptiveService`
|
|
122
|
+
* шаблон автоматически добавляется или удаляется из DOM.
|
|
123
|
+
*/
|
|
124
|
+
class IfDeviceDirective {
|
|
125
|
+
device = input(undefined, { ...(ngDevMode ? { debugName: "device" } : {}), alias: 'ssIfDevice' });
|
|
126
|
+
inverse = input(false, ...(ngDevMode ? [{ debugName: "inverse" }] : []));
|
|
127
|
+
tpl = inject(TemplateRef);
|
|
128
|
+
vcr = inject(ViewContainerRef);
|
|
129
|
+
adaptive = inject(AdaptiveService);
|
|
130
|
+
allowedDevices = [];
|
|
131
|
+
hasView = false;
|
|
132
|
+
currentDevice = this.adaptive.device;
|
|
133
|
+
constructor() {
|
|
134
|
+
effect(() => {
|
|
135
|
+
const device = this.device();
|
|
136
|
+
if (device) {
|
|
137
|
+
this.allowedDevices = Array.isArray(device) ? device : [device];
|
|
138
|
+
}
|
|
139
|
+
this.updateView();
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
updateView() {
|
|
143
|
+
const isAllowed = this.allowedDevices.includes(this.currentDevice());
|
|
144
|
+
const shouldShow = this.inverse() ? !isAllowed : isAllowed;
|
|
145
|
+
if (shouldShow && !this.hasView) {
|
|
146
|
+
this.vcr.createEmbeddedView(this.tpl);
|
|
147
|
+
this.hasView = true;
|
|
148
|
+
}
|
|
149
|
+
else if (!shouldShow && this.hasView) {
|
|
150
|
+
this.vcr.clear();
|
|
151
|
+
this.hasView = false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: IfDeviceDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
155
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.3", type: IfDeviceDirective, isStandalone: true, selector: "[reIfDevice]", inputs: { device: { classPropertyName: "device", publicName: "ssIfDevice", isSignal: true, isRequired: false, transformFunction: null }, inverse: { classPropertyName: "inverse", publicName: "inverse", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
|
|
156
|
+
}
|
|
157
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: IfDeviceDirective, decorators: [{
|
|
158
|
+
type: Directive,
|
|
159
|
+
args: [{
|
|
160
|
+
selector: '[reIfDevice]',
|
|
161
|
+
standalone: true,
|
|
162
|
+
}]
|
|
163
|
+
}], ctorParameters: () => [], propDecorators: { device: [{ type: i0.Input, args: [{ isSignal: true, alias: "ssIfDevice", required: false }] }], inverse: [{ type: i0.Input, args: [{ isSignal: true, alias: "inverse", required: false }] }] } });
|
|
164
|
+
|
|
165
|
+
const LANG_CONFIG = new InjectionToken('RE_LANG_CONFIG');
|
|
166
|
+
|
|
167
|
+
const innerLangVal = Symbol('reInnerLangVal');
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* LangService provides functionality for managing and tracking language settings
|
|
171
|
+
* and translations in the application. It is designed to handle localization needs,
|
|
172
|
+
* loading language resources, caching translations, and dynamically applying translations
|
|
173
|
+
* throughout the application.
|
|
174
|
+
*/
|
|
175
|
+
class LangService {
|
|
176
|
+
config = inject(LANG_CONFIG);
|
|
177
|
+
http = inject(HttpClient);
|
|
178
|
+
#lang = signal(this.getStoredLang(), ...(ngDevMode ? [{ debugName: "#lang" }] : []));
|
|
179
|
+
#cache = signal({}, ...(ngDevMode ? [{ debugName: "#cache" }] : []));
|
|
180
|
+
#loadedNamespaces = new Set();
|
|
181
|
+
#pendingLoads = new Map();
|
|
182
|
+
/**
|
|
183
|
+
* Computed property determining the current language setting.
|
|
184
|
+
*
|
|
185
|
+
* - If private method `#lang` returns 'ru', this property will return 'ru'.
|
|
186
|
+
* - If `#lang` returns another value, the `config.kgValue` property is checked:
|
|
187
|
+
* - If `config.kgValue` is defined, the property will return its value.
|
|
188
|
+
* - If `config.kgValue` is not defined, the property will return default value 'kg'.
|
|
189
|
+
*/
|
|
190
|
+
currentLang = computed(() => {
|
|
191
|
+
const lang = this.#lang();
|
|
192
|
+
return lang === 'ru' ? 'ru' : (this.config.kgValue ?? 'kg');
|
|
193
|
+
}, ...(ngDevMode ? [{ debugName: "currentLang" }] : []));
|
|
194
|
+
/**
|
|
195
|
+
* Extracts readonly value from private property `#lang` and assigns it to `innerLangVal`.
|
|
196
|
+
* Expected that property `#lang` has `asReadonly` method that returns immutable representation.
|
|
197
|
+
*/
|
|
198
|
+
[innerLangVal] = this.#lang.asReadonly();
|
|
199
|
+
/**
|
|
200
|
+
* Sets the current language for the application.
|
|
201
|
+
*
|
|
202
|
+
* @param {Langs | 'ky'} lang - The language to set.
|
|
203
|
+
* Accepts predefined type `Langs` or 'ky' to set Kyrgyz language.
|
|
204
|
+
* @return {void} Returns no value.
|
|
205
|
+
*/
|
|
206
|
+
setLang(lang) {
|
|
207
|
+
const langVal = lang === 'ky' ? 'kg' : lang;
|
|
208
|
+
if (langVal !== this.#lang()) {
|
|
209
|
+
this.#lang.set(langVal);
|
|
210
|
+
localStorage.setItem('lang', langVal);
|
|
211
|
+
const namespaces = Array.from(this.#loadedNamespaces.values()).map((key) => key.split('.')[1]);
|
|
212
|
+
this.#loadedNamespaces.clear();
|
|
213
|
+
namespaces.forEach((ns) => void this.loadNamespace(ns));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Gets value based on provided query and optionally applies specified parameters.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} query - Query string used to retrieve desired value.
|
|
220
|
+
* @param {LangParams} [params] - Optional parameters to apply to retrieved value.
|
|
221
|
+
* @return {string} Retrieved value after optional parameter application,
|
|
222
|
+
* or default value if query is not found.
|
|
223
|
+
*/
|
|
224
|
+
get(query, params) {
|
|
225
|
+
const value = this.getChainedValue(query);
|
|
226
|
+
if (params) {
|
|
227
|
+
return this.applyParams((value ?? query), params);
|
|
228
|
+
}
|
|
229
|
+
return value ?? this.config.defaultValue ?? query;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Observes changes to specified translation key and dynamically computes its value.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} query - Translation key to observe, typically in format "namespace.key".
|
|
235
|
+
* @param {LangParams} [params] - Optional parameters for interpolation or
|
|
236
|
+
* dynamic content replacement in translation value.
|
|
237
|
+
* @return {Signal<string>} Computed value that dynamically updates
|
|
238
|
+
* with translation matching provided query and parameters.
|
|
239
|
+
*/
|
|
240
|
+
observe(query, params) {
|
|
241
|
+
const [ns] = query.split('.');
|
|
242
|
+
if (!this.#loadedNamespaces.has(this.makeNamespaceKey(ns))) {
|
|
243
|
+
void this.loadNamespace(ns);
|
|
244
|
+
}
|
|
245
|
+
return computed(() => this.get(query, params));
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Loads specified namespace, ensuring its caching and availability for use.
|
|
249
|
+
*
|
|
250
|
+
* @param {string} ns - Namespace name to load.
|
|
251
|
+
* @return {Promise<void>} Promise that resolves on successful namespace load,
|
|
252
|
+
* or rejects when error occurs during process.
|
|
253
|
+
*/
|
|
254
|
+
async loadNamespace(ns) {
|
|
255
|
+
const key = this.makeNamespaceKey(ns);
|
|
256
|
+
if (this.#loadedNamespaces.has(key)) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (this.#pendingLoads.has(key)) {
|
|
260
|
+
return this.#pendingLoads.get(key);
|
|
261
|
+
}
|
|
262
|
+
const promise = new Promise((resolve, reject) => {
|
|
263
|
+
this.http.get(this.makeUrl(ns)).subscribe({
|
|
264
|
+
next: (res) => {
|
|
265
|
+
const existing = this.#cache();
|
|
266
|
+
const resolved = Array.isArray(res) ? this.parseModelToRecord(res) : this.parseAssetToRecord(ns, res);
|
|
267
|
+
this.#cache.set({ ...existing, ...resolved });
|
|
268
|
+
this.#loadedNamespaces.add(key);
|
|
269
|
+
resolve();
|
|
270
|
+
},
|
|
271
|
+
error: (err) => {
|
|
272
|
+
this.#pendingLoads.delete(key);
|
|
273
|
+
reject(err);
|
|
274
|
+
},
|
|
275
|
+
complete: () => this.#pendingLoads.delete(key),
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
this.#pendingLoads.set(key, promise);
|
|
279
|
+
return promise;
|
|
280
|
+
}
|
|
281
|
+
parseModelToRecord(model) {
|
|
282
|
+
const records = {};
|
|
283
|
+
for (const item of model) {
|
|
284
|
+
records[item.code] = item.localization;
|
|
285
|
+
}
|
|
286
|
+
return records;
|
|
287
|
+
}
|
|
288
|
+
parseAssetToRecord(page, data) {
|
|
289
|
+
const records = {};
|
|
290
|
+
for (const [k, v] of Object.entries(data)) {
|
|
291
|
+
records[`${page}.${k}`] = v;
|
|
292
|
+
}
|
|
293
|
+
return records;
|
|
294
|
+
}
|
|
295
|
+
applyParams(text, params) {
|
|
296
|
+
return text.replace(/\{\{(.*?)}}/g, (_, k) => params[k.trim()] ?? '');
|
|
297
|
+
}
|
|
298
|
+
getChainedValue(query = '', source = this.#cache()) {
|
|
299
|
+
const [page, key, ...normalizedPath] = query.split('.');
|
|
300
|
+
return [`${page}.${key}`, ...normalizedPath].reduce((acc, key) => (typeof acc === 'object' ? (acc[key] ?? acc[query]) : undefined), source);
|
|
301
|
+
}
|
|
302
|
+
getStoredLang() {
|
|
303
|
+
return localStorage.getItem('lang') || this.config.defaultLang || 'ru';
|
|
304
|
+
}
|
|
305
|
+
makeUrl(ns) {
|
|
306
|
+
const suffix = this.config.isFromAssets ? `.${this.#lang()}.json` : `?language=${this.#lang()}`;
|
|
307
|
+
return `${this.config.url}/${ns}${suffix}`;
|
|
308
|
+
}
|
|
309
|
+
makeNamespaceKey(ns) {
|
|
310
|
+
return `${this.#lang()}.${ns}`;
|
|
311
|
+
}
|
|
312
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: LangService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
313
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: LangService, providedIn: 'root' });
|
|
314
|
+
}
|
|
315
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: LangService, decorators: [{
|
|
316
|
+
type: Injectable,
|
|
317
|
+
args: [{ providedIn: 'root' }]
|
|
318
|
+
}] });
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Кастомный Angular-пайп, который преобразует языковой ключ и дополнительные параметры в локализованную строку.
|
|
322
|
+
*
|
|
323
|
+
* Пайп объявлен как standalone и impure — то есть он может использоваться без подключения модуля
|
|
324
|
+
* и будет пересчитываться при изменении состояния (например, при смене языка).
|
|
325
|
+
*
|
|
326
|
+
* В своей работе пайп наблюдает и кэширует языковые ключи для повышения производительности,
|
|
327
|
+
* используя LangService для получения переведённых строк в зависимости от текущего языка приложения.
|
|
328
|
+
*
|
|
329
|
+
* Трансформация заключается в том, чтобы принять строку-ключ и необязательные параметры,
|
|
330
|
+
* сформировать ключ для кэша и вернуть локализованное значение, соответствующее этому запросу.
|
|
331
|
+
*
|
|
332
|
+
* @implements {PipeTransform}
|
|
333
|
+
*/
|
|
334
|
+
class LangPipe {
|
|
335
|
+
lang = inject(LangService);
|
|
336
|
+
cache = new Map();
|
|
337
|
+
transform(query, params) {
|
|
338
|
+
const key = query + JSON.stringify(params);
|
|
339
|
+
if (!this.cache.has(key)) {
|
|
340
|
+
this.cache.set(key, this.lang.observe(query, params));
|
|
341
|
+
}
|
|
342
|
+
return this.cache.get(key)();
|
|
343
|
+
}
|
|
344
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: LangPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
|
|
345
|
+
static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.0.3", ngImport: i0, type: LangPipe, isStandalone: true, name: "lang", pure: false });
|
|
346
|
+
}
|
|
347
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: LangPipe, decorators: [{
|
|
348
|
+
type: Pipe,
|
|
349
|
+
args: [{ name: 'lang', standalone: true, pure: false }]
|
|
350
|
+
}] });
|
|
351
|
+
|
|
352
|
+
const themes = {
|
|
353
|
+
light: 'light',
|
|
354
|
+
dark: 'dark',
|
|
355
|
+
};
|
|
356
|
+
const darkThemePrefix = 're-dark';
|
|
357
|
+
|
|
358
|
+
const defaultThemeConfig = {
|
|
359
|
+
defaultTheme: themes.light,
|
|
360
|
+
};
|
|
361
|
+
const THEME_CONFIG = new InjectionToken('RE_THEME_CONFIG');
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Service for managing application theme.
|
|
365
|
+
*
|
|
366
|
+
* Allows getting the current theme and switching between light and dark.
|
|
367
|
+
* Automatically saves selected theme to `localStorage` and applies CSS class to `<html>` element.
|
|
368
|
+
*
|
|
369
|
+
* Example:
|
|
370
|
+
* ```ts
|
|
371
|
+
* const theme = inject(ThemeService);
|
|
372
|
+
* theme.switch('dark');
|
|
373
|
+
* console.log(theme.theme()); // 'dark'
|
|
374
|
+
* ```
|
|
375
|
+
*/
|
|
376
|
+
class ThemeService {
|
|
377
|
+
config = inject(THEME_CONFIG);
|
|
378
|
+
themeDefault = this.config?.defaultTheme || themes.light;
|
|
379
|
+
#theme = signal(this.themeDefault, ...(ngDevMode ? [{ debugName: "#theme" }] : []));
|
|
380
|
+
/**
|
|
381
|
+
* Current active theme (`light` or `dark`).
|
|
382
|
+
*
|
|
383
|
+
* Value is reactive — can be used in template or `computed`.
|
|
384
|
+
* ```html
|
|
385
|
+
* <div [class.dark]="themeService.theme() === 'dark'"></div>
|
|
386
|
+
* <div [class]="themeService.theme()"></div>
|
|
387
|
+
* ```
|
|
388
|
+
*/
|
|
389
|
+
theme = computed(() => this.#theme(), ...(ngDevMode ? [{ debugName: "theme" }] : []));
|
|
390
|
+
/**
|
|
391
|
+
* Convenient flag returning `true` if light theme is active.
|
|
392
|
+
* Suitable for conditional style application or resource selection.
|
|
393
|
+
*/
|
|
394
|
+
isLight = computed(() => this.#theme() === themes.light, ...(ngDevMode ? [{ debugName: "isLight" }] : []));
|
|
395
|
+
constructor() {
|
|
396
|
+
effect(() => {
|
|
397
|
+
const theme = localStorage.getItem('theme') || this.themeDefault;
|
|
398
|
+
this.switch(theme);
|
|
399
|
+
});
|
|
400
|
+
effect(() => localStorage.setItem('theme', this.#theme()));
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Switches theme.
|
|
404
|
+
*
|
|
405
|
+
* If parameter is not provided — performs toggle between `light` and `dark`.
|
|
406
|
+
* Also automatically updates `<html>` class and saves selection to `localStorage`.
|
|
407
|
+
*
|
|
408
|
+
* @param theme — explicit theme value (`'light'` or `'dark'`).
|
|
409
|
+
*/
|
|
410
|
+
switch(theme) {
|
|
411
|
+
const html = document.querySelector('html');
|
|
412
|
+
const newTheme = theme ?? (this.#theme() === themes.light ? themes.dark : themes.light);
|
|
413
|
+
newTheme === themes.dark && html.classList.add(darkThemePrefix);
|
|
414
|
+
newTheme === themes.light && html.classList.remove(darkThemePrefix);
|
|
415
|
+
this.#theme.set(newTheme);
|
|
416
|
+
}
|
|
417
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ThemeService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
418
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ThemeService, providedIn: 'root' });
|
|
419
|
+
}
|
|
420
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ThemeService, decorators: [{
|
|
421
|
+
type: Injectable,
|
|
422
|
+
args: [{
|
|
423
|
+
providedIn: 'root',
|
|
424
|
+
}]
|
|
425
|
+
}], ctorParameters: () => [] });
|
|
426
|
+
|
|
427
|
+
function provideReInit(config) {
|
|
428
|
+
return makeEnvironmentProviders([
|
|
429
|
+
{ provide: SELECTED_LANG, deps: [LangService], useFactory: (ls) => ls[innerLangVal] },
|
|
430
|
+
{ provide: SELECTED_THEME, deps: [ThemeService], useFactory: (ls) => ls.theme },
|
|
431
|
+
{ provide: CURRENT_DEVICE, deps: [AdaptiveService], useFactory: (ls) => ls.device },
|
|
432
|
+
{ provide: DEVICE_BREAKPOINTS, useValue: config.breakpoints || defaultBreakpoints },
|
|
433
|
+
{ provide: THEME_CONFIG, useValue: config.theme || defaultThemeConfig },
|
|
434
|
+
{ provide: LANG_CONFIG, useValue: config.locale || {} },
|
|
435
|
+
{ provide: LOCALE_ID, useValue: config.locale.defaultLang ?? 'ru' },
|
|
436
|
+
]);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Service for managing page SEO metadata.
|
|
441
|
+
*
|
|
442
|
+
* Allows centrally setting title, description, keywords,
|
|
443
|
+
* Open Graph and Twitter Card tags, canonical link, and JSON-LD schema.
|
|
444
|
+
*
|
|
445
|
+
* Example:
|
|
446
|
+
* ```ts
|
|
447
|
+
* const seo = inject(SeoService);
|
|
448
|
+
* seo.setTitle('Home Page');
|
|
449
|
+
* seo.setDescription('Site Description');
|
|
450
|
+
* seo.setOg({ title: 'Home', type: 'website' });
|
|
451
|
+
* ```
|
|
452
|
+
*/
|
|
453
|
+
class SeoService {
|
|
454
|
+
title = inject(Title);
|
|
455
|
+
meta = inject(Meta);
|
|
456
|
+
/**
|
|
457
|
+
* Sets page title (`<title>`).
|
|
458
|
+
* @param value title text
|
|
459
|
+
*/
|
|
460
|
+
setTitle(value) {
|
|
461
|
+
this.title.setTitle(value);
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Sets page description (`<meta name="description">`).
|
|
465
|
+
* @param desc brief content description
|
|
466
|
+
*/
|
|
467
|
+
setDescription(desc) {
|
|
468
|
+
this.upsert({ name: 'description', content: desc });
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Sets page keywords (`<meta name="keywords">`).
|
|
472
|
+
* @param keywords array of keywords
|
|
473
|
+
*/
|
|
474
|
+
setKeywords(keywords) {
|
|
475
|
+
this.upsert({ name: 'keywords', content: keywords.join(', ') });
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Sets directives for search engines (`<meta name="robots">`).
|
|
479
|
+
* @param value value, e.g. `"index,follow"`
|
|
480
|
+
*/
|
|
481
|
+
setRobots(value) {
|
|
482
|
+
this.upsert({ name: 'robots', content: value }); // e.g. "index,follow"
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Sets canonical link (`<link rel="canonical">`).
|
|
486
|
+
* @param url absolute URL of canonical page
|
|
487
|
+
*/
|
|
488
|
+
setCanonical(url) {
|
|
489
|
+
this.upsertLink('canonical', url);
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Sets Open Graph meta tags.
|
|
493
|
+
* @param opts object with Open Graph fields (`title`, `description`, `image`, `url`, `type`, etc.)
|
|
494
|
+
*/
|
|
495
|
+
setOg(opts) {
|
|
496
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
497
|
+
v && this.upsert({ property: `og:${k}`, content: v });
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Sets Twitter Card meta tags.
|
|
502
|
+
* @param opts object with Twitter Card fields (`card`, `title`, `description`, `image`, etc.)
|
|
503
|
+
*/
|
|
504
|
+
setTwitter(opts) {
|
|
505
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
506
|
+
v && this.upsert({ name: `twitter:${k}`, content: v });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Embeds JSON-LD schema (structured data) in `<head>`.
|
|
511
|
+
* @param schema schema object (will be serialized to JSON)
|
|
512
|
+
*/
|
|
513
|
+
setJsonLd(schema) {
|
|
514
|
+
const id = 'app-jsonld';
|
|
515
|
+
let el = document.getElementById(id);
|
|
516
|
+
if (!el) {
|
|
517
|
+
el = document.createElement('script');
|
|
518
|
+
el.type = 'application/ld+json';
|
|
519
|
+
el.id = id;
|
|
520
|
+
document.head.appendChild(el);
|
|
521
|
+
}
|
|
522
|
+
el.text = JSON.stringify(schema);
|
|
523
|
+
}
|
|
524
|
+
upsert(tag) {
|
|
525
|
+
if (tag.name) {
|
|
526
|
+
this.meta.updateTag(tag, `name='${tag.name}'`);
|
|
527
|
+
}
|
|
528
|
+
else if (tag.property) {
|
|
529
|
+
this.meta.updateTag(tag, `property='${tag.property}'`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
upsertLink(rel, href) {
|
|
533
|
+
const head = document.head;
|
|
534
|
+
let link = head.querySelector(`link[rel="${rel}"]`);
|
|
535
|
+
if (!link) {
|
|
536
|
+
link = document.createElement('link');
|
|
537
|
+
link.rel = rel;
|
|
538
|
+
head.appendChild(link);
|
|
539
|
+
}
|
|
540
|
+
link.href = href;
|
|
541
|
+
}
|
|
542
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: SeoService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
543
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: SeoService });
|
|
544
|
+
}
|
|
545
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: SeoService, decorators: [{
|
|
546
|
+
type: Injectable
|
|
547
|
+
}] });
|
|
548
|
+
|
|
549
|
+
class SeoRouteListener {
|
|
550
|
+
router = inject(Router);
|
|
551
|
+
seo = inject(SeoService);
|
|
552
|
+
ar = inject(ActivatedRoute);
|
|
553
|
+
destroyRef = inject(DestroyRef);
|
|
554
|
+
init(baseUrl) {
|
|
555
|
+
const sub = this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe(() => {
|
|
556
|
+
const route = this.deepest(this.ar);
|
|
557
|
+
const data = route.snapshot.data;
|
|
558
|
+
const url = data.canonical ?? baseUrl.replace(/\/+$/, '') + this.router.url;
|
|
559
|
+
data.title && this.seo.setTitle(data.title);
|
|
560
|
+
data.description && this.seo.setDescription(data.description);
|
|
561
|
+
data.twitter && this.seo.setTwitter(data.twitter);
|
|
562
|
+
data.jsonld && this.seo.setJsonLd(data.jsonld);
|
|
563
|
+
this.seo.setRobots(data.robots ?? 'index,follow');
|
|
564
|
+
this.seo.setCanonical(url);
|
|
565
|
+
this.seo.setOg({
|
|
566
|
+
title: data.title,
|
|
567
|
+
description: data.description,
|
|
568
|
+
url: url,
|
|
569
|
+
...data.og,
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
this.destroyRef.onDestroy(() => sub.unsubscribe());
|
|
573
|
+
}
|
|
574
|
+
deepest(r) {
|
|
575
|
+
let cur = r;
|
|
576
|
+
while (cur.firstChild) {
|
|
577
|
+
cur = cur.firstChild;
|
|
578
|
+
}
|
|
579
|
+
return cur;
|
|
580
|
+
}
|
|
581
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: SeoRouteListener, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
582
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: SeoRouteListener, providedIn: 'root' });
|
|
583
|
+
}
|
|
584
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: SeoRouteListener, decorators: [{
|
|
585
|
+
type: Injectable,
|
|
586
|
+
args: [{ providedIn: 'root' }]
|
|
587
|
+
}] });
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Reactive snapshot of the current route (the deepest active route).
|
|
591
|
+
* Updates on every `NavigationEnd` event.
|
|
592
|
+
*
|
|
593
|
+
* Provides:
|
|
594
|
+
* - `params` / `query` — strings (as in Angular Router)
|
|
595
|
+
* - `data` — arbitrary data (from route configuration/resolvers)
|
|
596
|
+
* - `url` — string assembled from `UrlSegment[]`
|
|
597
|
+
* - `fragment` — hash (#section)
|
|
598
|
+
* - `selectData(key)` — type-safe selector for `data`
|
|
599
|
+
* - `state` — combined computed object (convenient for single subscriber)
|
|
600
|
+
*/
|
|
601
|
+
class RouteWatcher {
|
|
602
|
+
router = inject(Router);
|
|
603
|
+
destroyRef = inject(DestroyRef);
|
|
604
|
+
#params = signal({}, ...(ngDevMode ? [{ debugName: "#params" }] : []));
|
|
605
|
+
#query = signal({}, ...(ngDevMode ? [{ debugName: "#query" }] : []));
|
|
606
|
+
#data = signal({}, ...(ngDevMode ? [{ debugName: "#data" }] : []));
|
|
607
|
+
#url = signal('', ...(ngDevMode ? [{ debugName: "#url" }] : []));
|
|
608
|
+
#fragment = signal(null, ...(ngDevMode ? [{ debugName: "#fragment" }] : []));
|
|
609
|
+
/** Signal for tracking and retrieving URL parameters */
|
|
610
|
+
params = this.#params.asReadonly();
|
|
611
|
+
/** Signal for tracking and retrieving query parameters */
|
|
612
|
+
query = this.#query.asReadonly();
|
|
613
|
+
/** Signal for tracking and retrieving route data */
|
|
614
|
+
data = this.#data.asReadonly();
|
|
615
|
+
/** Signal for tracking and retrieving URL */
|
|
616
|
+
url = this.#url.asReadonly();
|
|
617
|
+
/** Signal for tracking and retrieving URL fragment */
|
|
618
|
+
fragment = this.#fragment.asReadonly();
|
|
619
|
+
/** Combined computed snapshot (to avoid multiple effects) */
|
|
620
|
+
state = computed(() => ({
|
|
621
|
+
params: this.#params(),
|
|
622
|
+
query: this.#query(),
|
|
623
|
+
data: this.#data(),
|
|
624
|
+
url: this.#url(),
|
|
625
|
+
fragment: this.#fragment(),
|
|
626
|
+
}), ...(ngDevMode ? [{ debugName: "state" }] : []));
|
|
627
|
+
constructor() {
|
|
628
|
+
const read = () => {
|
|
629
|
+
const snap = this.deepestSnapshot();
|
|
630
|
+
const url = joinUrl(snap.url);
|
|
631
|
+
!deepEqual(snap.params, this.#params()) && this.#params.set(snap.params);
|
|
632
|
+
!deepEqual(snap.queryParams, this.#query()) && this.#query.set(snap.queryParams);
|
|
633
|
+
!deepEqual(snap.data, this.#data()) && this.#data.set(snap.data);
|
|
634
|
+
this.#url() !== url && this.#url.set(url);
|
|
635
|
+
this.#fragment() !== snap.fragment && this.#fragment.set(snap.fragment ?? null);
|
|
636
|
+
};
|
|
637
|
+
read();
|
|
638
|
+
this.router.events
|
|
639
|
+
.pipe(startWith(new NavigationEnd(0, this.router.url, this.router.url)), filter$1((e) => e instanceof NavigationEnd), map(() => true), takeUntilDestroyed(this.destroyRef))
|
|
640
|
+
.subscribe(() => read());
|
|
641
|
+
}
|
|
642
|
+
/** Convenient selector for a data key with type-safe casting */
|
|
643
|
+
selectData(key) {
|
|
644
|
+
return computed(() => this.#data()[key]);
|
|
645
|
+
}
|
|
646
|
+
deepestSnapshot() {
|
|
647
|
+
// work with snapshot — we need a "frozen" point at NavigationEnd moment
|
|
648
|
+
let snap = this.router.routerState.snapshot.root;
|
|
649
|
+
while (snap.firstChild) {
|
|
650
|
+
snap = snap.firstChild;
|
|
651
|
+
}
|
|
652
|
+
return snap;
|
|
653
|
+
}
|
|
654
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: RouteWatcher, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
655
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: RouteWatcher, providedIn: 'root' });
|
|
656
|
+
}
|
|
657
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: RouteWatcher, decorators: [{
|
|
658
|
+
type: Injectable,
|
|
659
|
+
args: [{ providedIn: 'root' }]
|
|
660
|
+
}], ctorParameters: () => [] });
|
|
661
|
+
/** Joins `UrlSegment[]` into a path string */
|
|
662
|
+
function joinUrl(segments) {
|
|
663
|
+
return segments.length ? segments.map((s) => s.path).join('/') : '';
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Generated bundle index. Do not edit.
|
|
668
|
+
*/
|
|
669
|
+
|
|
670
|
+
export { AdaptiveService, DEVICE_BREAKPOINTS, IfDeviceDirective, LANG_CONFIG, LangPipe, LangService, RouteWatcher, SeoRouteListener, SeoService, THEME_CONFIG, ThemeService, darkThemePrefix, defaultBreakpoints, defaultThemeConfig, provideReInit, themes };
|
|
671
|
+
//# sourceMappingURL=reforgium-presentia.mjs.map
|