@trismegisto/spine 2.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.
- package/LICENSE +56 -0
- package/README.md +207 -0
- package/fesm2022/trismegisto-spine-audit.mjs +1299 -0
- package/fesm2022/trismegisto-spine-audit.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-auto-service-auto-service-audit.component-CCiz-q8o.mjs +37 -0
- package/fesm2022/trismegisto-spine-auto-service-auto-service-audit.component-CCiz-q8o.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-auto-service-auto-service-client-picker.component-Z0wCANF6.mjs +51 -0
- package/fesm2022/trismegisto-spine-auto-service-auto-service-client-picker.component-Z0wCANF6.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-auto-service-auto-service-client.component-BYkUOwWL.mjs +595 -0
- package/fesm2022/trismegisto-spine-auto-service-auto-service-client.component-BYkUOwWL.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-auto-service-auto-service-groups.component-CqIVe2LU.mjs +357 -0
- package/fesm2022/trismegisto-spine-auto-service-auto-service-groups.component-CqIVe2LU.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-auto-service-auto-service.component-B5tyJ2zY.mjs +477 -0
- package/fesm2022/trismegisto-spine-auto-service-auto-service.component-B5tyJ2zY.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-auto-service-home.component-vl3BPFnk.mjs +238 -0
- package/fesm2022/trismegisto-spine-auto-service-home.component-vl3BPFnk.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-auto-service.mjs +154 -0
- package/fesm2022/trismegisto-spine-auto-service.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-graphql.mjs +347 -0
- package/fesm2022/trismegisto-spine-graphql.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-layout.mjs +2848 -0
- package/fesm2022/trismegisto-spine-layout.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-modal.mjs +141 -0
- package/fesm2022/trismegisto-spine-modal.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-notification.mjs +122 -0
- package/fesm2022/trismegisto-spine-notification.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-routing.mjs +398 -0
- package/fesm2022/trismegisto-spine-routing.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-shell.mjs +409 -0
- package/fesm2022/trismegisto-spine-shell.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-side-panel.mjs +142 -0
- package/fesm2022/trismegisto-spine-side-panel.mjs.map +1 -0
- package/fesm2022/trismegisto-spine.mjs +2574 -0
- package/fesm2022/trismegisto-spine.mjs.map +1 -0
- package/package.json +94 -0
- package/types/trismegisto-spine-audit.d.ts +948 -0
- package/types/trismegisto-spine-audit.d.ts.map +1 -0
- package/types/trismegisto-spine-auto-service.d.ts +110 -0
- package/types/trismegisto-spine-auto-service.d.ts.map +1 -0
- package/types/trismegisto-spine-graphql.d.ts +72 -0
- package/types/trismegisto-spine-graphql.d.ts.map +1 -0
- package/types/trismegisto-spine-layout.d.ts +1054 -0
- package/types/trismegisto-spine-layout.d.ts.map +1 -0
- package/types/trismegisto-spine-modal.d.ts +68 -0
- package/types/trismegisto-spine-modal.d.ts.map +1 -0
- package/types/trismegisto-spine-notification.d.ts +62 -0
- package/types/trismegisto-spine-notification.d.ts.map +1 -0
- package/types/trismegisto-spine-routing.d.ts +205 -0
- package/types/trismegisto-spine-routing.d.ts.map +1 -0
- package/types/trismegisto-spine-shell.d.ts +143 -0
- package/types/trismegisto-spine-shell.d.ts.map +1 -0
- package/types/trismegisto-spine-side-panel.d.ts +66 -0
- package/types/trismegisto-spine-side-panel.d.ts.map +1 -0
- package/types/trismegisto-spine.d.ts +1161 -0
- package/types/trismegisto-spine.d.ts.map +1 -0
|
@@ -0,0 +1,2848 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { inject, ChangeDetectorRef, ChangeDetectionStrategy, Component, InjectionToken, input, signal, computed, Optional, Inject, Injectable, Injector, afterNextRender, ViewChild, effect, HostListener, DOCUMENT, isDevMode, DestroyRef, output, ElementRef, Directive, ViewEncapsulation } from '@angular/core';
|
|
3
|
+
import * as i3 from '@angular/router';
|
|
4
|
+
import { RouterLink, Router, NavigationEnd, NavigationSkipped, RouterOutlet } from '@angular/router';
|
|
5
|
+
import { BehaviorSubject, filter, take, Subject, firstValueFrom } from 'rxjs';
|
|
6
|
+
import { IconComponent, IconButtonComponent, LoadingSquareComponent, HermesShortcutService, HERMES_MESSAGES, HeaderComponent as HeaderComponent$1, InputComponent, MenuButtonComponent, AccountAvatarComponent, ModalComponent, createPersistedWorkspaceStore, HERMES_WORKSPACE_PERSISTENCE_CONFIG, findPanelTabs, SidebarComponent as SidebarComponent$1, SidebarNodeComponent, ButtonComponent, CarouselComponent, ShortcutListComponent, HermesToastService, SegmentedControlComponent, HermesThemeService, HermesSidebarService, ComboboxComponent, SettingsDialogComponent, SettingsRowComponent, SettingsSectionComponent, SelectComponent, ThemePickerComponent, BreadcrumbComponent, TooltipDirective, AlertComponent, SkeletonComponent, WorkspaceComponent } from '@trismegisto/angular';
|
|
7
|
+
import { ModalComponent as ModalComponent$1 } from '@trismegisto/spine/modal';
|
|
8
|
+
import { NotificationComponent } from '@trismegisto/spine/notification';
|
|
9
|
+
import { SidePanelComponent } from '@trismegisto/spine/side-panel';
|
|
10
|
+
import * as i2$1 from '@trismegisto/spine';
|
|
11
|
+
import { SPINE_MESSAGES, SPINE_CONFIG, ThemeService, resolveSpineLogo, SPINE_NEWS, spineRoutesOf, routerIdOf, SPINE_ADMIN_API, SpinePreferencesService, ThemePresetService, DensityService, APP_PACKAGE_INFO, SpineQuickAccessService } from '@trismegisto/spine';
|
|
12
|
+
import * as i2 from '@angular/common';
|
|
13
|
+
import { CommonModule, NgTemplateOutlet, NgComponentOutlet } from '@angular/common';
|
|
14
|
+
import * as i1$1 from '@angular/forms';
|
|
15
|
+
import { FormsModule, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
|
|
16
|
+
import * as i1 from '@orfeu/angular/core';
|
|
17
|
+
import { ViewService, AuthorizationService, RealmAppsService } from '@orfeu/angular/core';
|
|
18
|
+
import { formatMessage, HERMES_FONTS } from '@trismegisto/primitives';
|
|
19
|
+
import { toSignal, takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
20
|
+
import { resolveEffectiveRouteChain, ComponentDistributorService, componentRouterId, LoggerService } from '@trismegisto/spine/routing';
|
|
21
|
+
import { renderIconSvg } from '@trismegisto/icons';
|
|
22
|
+
import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http';
|
|
23
|
+
import { Title, DomSanitizer } from '@angular/platform-browser';
|
|
24
|
+
import DOMPurify from 'dompurify';
|
|
25
|
+
import { Marked } from 'marked';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A grade de apps não cabe no `MenuItem` de linha única do `hm-menu` (ícone grande + nome
|
|
29
|
+
* embaixo, em coluna) — em vez de forçar isso num componente pensado pra lista de ações, usa
|
|
30
|
+
* um popover próprio (mesmo padrão do backdrop-fecha-fora da busca global do header).
|
|
31
|
+
*/
|
|
32
|
+
class AppLauncherComponent {
|
|
33
|
+
constructor(authService, realmAppsService) {
|
|
34
|
+
this.authService = authService;
|
|
35
|
+
this.realmAppsService = realmAppsService;
|
|
36
|
+
this.text = inject(SPINE_MESSAGES).header;
|
|
37
|
+
this.cdr = inject(ChangeDetectorRef);
|
|
38
|
+
this.apps = [];
|
|
39
|
+
this.loading = false;
|
|
40
|
+
this.open = false;
|
|
41
|
+
}
|
|
42
|
+
async ngOnInit() {
|
|
43
|
+
this.loading = true;
|
|
44
|
+
const token = this.authService.getToken();
|
|
45
|
+
// O client atual (o app em que o usuário já está) já vem excluído aqui —
|
|
46
|
+
// OpenIDController.view no argos filtra por client_id no próprio backend
|
|
47
|
+
// (apps/argos/auth/server/.../openid.controller.ts), então não há
|
|
48
|
+
// necessidade de repetir essa checagem no front.
|
|
49
|
+
try {
|
|
50
|
+
this.apps = (await this.realmAppsService.getApplications(token)) ?? [];
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Sem a lista não há o que oferecer: o botão some, em vez de ficar carregando para sempre.
|
|
54
|
+
this.apps = [];
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
this.loading = false;
|
|
58
|
+
// A lista chega fora de qualquer evento do template: sem isto, app zoneless não redesenha.
|
|
59
|
+
this.cdr.markForCheck();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
toggle() {
|
|
63
|
+
this.open = !this.open;
|
|
64
|
+
}
|
|
65
|
+
close() {
|
|
66
|
+
this.open = false;
|
|
67
|
+
}
|
|
68
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: AppLauncherComponent, deps: [{ token: i1.AuthorizationService }, { token: i1.RealmAppsService }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
69
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.24", type: AppLauncherComponent, isStandalone: true, selector: "spine-app-launcher", ngImport: i0, template: "<div class=\"app-launcher-trigger\" data-tour=\"apps\" *ngIf=\"loading || apps.length > 0\">\n <hm-icon-button emphasis=\"subtle\" icon=\"grid\" [label]=\"text.apps\" (clicked)=\"toggle()\"></hm-icon-button>\n\n <button type=\"button\" class=\"app-launcher-backdrop\" *ngIf=\"open\" (click)=\"close()\" aria-hidden=\"true\"></button>\n\n <div class=\"app-launcher-menu\" *ngIf=\"open\">\n <div class=\"app-launcher-header\">{{ text.apps }}</div>\n <div class=\"app-launcher-grid\">\n <div class=\"app-launcher-loading\" *ngIf=\"loading\">\n <hm-loading-square></hm-loading-square>\n </div>\n <a *ngFor=\"let app of apps\" [href]=\"app.app_url\" class=\"app-launcher-card\" target=\"_blank\"\n rel=\"noopener noreferrer\">\n <div class=\"app-launcher-icon\">\n <hm-icon shape=\"applications\" size=\"32\"></hm-icon>\n </div>\n <span class=\"app-launcher-name\">{{ app.name }}</span>\n </a>\n </div>\n </div>\n</div>\n", styles: ["@charset \"UTF-8\";:host{display:contents}.app-launcher-trigger{position:relative;display:flex;align-items:center;height:100%}.app-launcher-backdrop{position:fixed;inset:0;z-index:1199;border:none;background:transparent;cursor:default}.app-launcher-menu{position:absolute;top:calc(100% + 8px);left:0;z-index:1200;min-width:300px;background:var(--hm-surface);color:var(--hm-text);border:1px solid var(--hm-border);border-radius:12px;box-shadow:var(--hm-shadow-lg, 0 14px 38px rgba(0, 36, 65, .22));overflow:hidden}.app-launcher-header{padding:10px 16px 8px;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--hm-border)}.app-launcher-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;padding:12px}.app-launcher-card{display:flex;flex-direction:column;align-items:center;justify-content:flex-start;padding:12px 8px;border-radius:4px;text-decoration:none;color:inherit;cursor:pointer;transition:background .15s ease}.app-launcher-card:hover{background:var(--hm-hover);text-decoration:none;color:inherit}.app-launcher-icon{margin-bottom:6px;color:var(--hm-action)}.app-launcher-name{font-size:11px;text-align:center;line-height:1.3;word-break:break-word;max-width:80px}.app-launcher-loading{grid-column:1/-1;display:flex;justify-content:center;padding:20px}@media(max-width:767px){.app-launcher-trigger{display:none}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: IconComponent, selector: "hm-icon", inputs: ["shape", "direction", "size", "solid", "badge", "status", "flip", "inverse", "label"] }, { kind: "component", type: IconButtonComponent, selector: "hm-icon-button", inputs: ["label", "icon", "solid", "iconDirection", "emphasis", "intent", "size", "disabled", "toggle", "pressed", "keyShortcuts"], outputs: ["pressedChange", "clicked"] }, { kind: "component", type: LoadingSquareComponent, selector: "hm-loading-square" }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
|
|
70
|
+
}
|
|
71
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: AppLauncherComponent, decorators: [{
|
|
72
|
+
type: Component,
|
|
73
|
+
args: [{ selector: 'spine-app-launcher', imports: [CommonModule, IconComponent, IconButtonComponent, LoadingSquareComponent], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"app-launcher-trigger\" data-tour=\"apps\" *ngIf=\"loading || apps.length > 0\">\n <hm-icon-button emphasis=\"subtle\" icon=\"grid\" [label]=\"text.apps\" (clicked)=\"toggle()\"></hm-icon-button>\n\n <button type=\"button\" class=\"app-launcher-backdrop\" *ngIf=\"open\" (click)=\"close()\" aria-hidden=\"true\"></button>\n\n <div class=\"app-launcher-menu\" *ngIf=\"open\">\n <div class=\"app-launcher-header\">{{ text.apps }}</div>\n <div class=\"app-launcher-grid\">\n <div class=\"app-launcher-loading\" *ngIf=\"loading\">\n <hm-loading-square></hm-loading-square>\n </div>\n <a *ngFor=\"let app of apps\" [href]=\"app.app_url\" class=\"app-launcher-card\" target=\"_blank\"\n rel=\"noopener noreferrer\">\n <div class=\"app-launcher-icon\">\n <hm-icon shape=\"applications\" size=\"32\"></hm-icon>\n </div>\n <span class=\"app-launcher-name\">{{ app.name }}</span>\n </a>\n </div>\n </div>\n</div>\n", styles: ["@charset \"UTF-8\";:host{display:contents}.app-launcher-trigger{position:relative;display:flex;align-items:center;height:100%}.app-launcher-backdrop{position:fixed;inset:0;z-index:1199;border:none;background:transparent;cursor:default}.app-launcher-menu{position:absolute;top:calc(100% + 8px);left:0;z-index:1200;min-width:300px;background:var(--hm-surface);color:var(--hm-text);border:1px solid var(--hm-border);border-radius:12px;box-shadow:var(--hm-shadow-lg, 0 14px 38px rgba(0, 36, 65, .22));overflow:hidden}.app-launcher-header{padding:10px 16px 8px;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--hm-border)}.app-launcher-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;padding:12px}.app-launcher-card{display:flex;flex-direction:column;align-items:center;justify-content:flex-start;padding:12px 8px;border-radius:4px;text-decoration:none;color:inherit;cursor:pointer;transition:background .15s ease}.app-launcher-card:hover{background:var(--hm-hover);text-decoration:none;color:inherit}.app-launcher-icon{margin-bottom:6px;color:var(--hm-action)}.app-launcher-name{font-size:11px;text-align:center;line-height:1.3;word-break:break-word;max-width:80px}.app-launcher-loading{grid-column:1/-1;display:flex;justify-content:center;padding:20px}@media(max-width:767px){.app-launcher-trigger{display:none}}\n"] }]
|
|
74
|
+
}], ctorParameters: () => [{ type: i1.AuthorizationService }, { type: i1.RealmAppsService }] });
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Componente de marca que o header do shell desenha no lugar da imagem de `SpineConfig.logo` (tem
|
|
78
|
+
* prioridade sobre ela). Injeção em vez de projeção de conteúdo porque o `LayoutComponent` é montado
|
|
79
|
+
* pelo `Router`, não pelo template do app — não existe `<ng-content>` alcançável de fora. O componente
|
|
80
|
+
* fica dentro do link da marca (que leva à home): não ponha link nem botão nele, e se ele for só
|
|
81
|
+
* imagem, dê nome acessível ou deixe o `appName` visível (`showAppName`, padrão `true`).
|
|
82
|
+
* `null` (padrão) usa a imagem. Ex.: `{ provide: SPINE_BRAND, useValue: MarcaComponent }` no
|
|
83
|
+
* `app.config.ts`.
|
|
84
|
+
*/
|
|
85
|
+
const SPINE_BRAND = new InjectionToken('SPINE_BRAND', {
|
|
86
|
+
providedIn: 'root',
|
|
87
|
+
factory: () => null,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Marca do header: logo do app (`SpineConfig.logo`, padrão `assets/images/logo.svg`) + `appName`, dentro
|
|
92
|
+
* de um link para a home. Ordem: componente de `SPINE_BRAND` > imagem > só o nome.
|
|
93
|
+
*
|
|
94
|
+
* - Tema escuro usa `logo.dark` quando houver; se ele falhar, volta para `logo.light`.
|
|
95
|
+
* - Imagem que não carrega (404/erro) some — nada de ícone quebrado — e o nome aparece mesmo com
|
|
96
|
+
* `showAppName: false`, para o link nunca ficar sem texto.
|
|
97
|
+
* - Texto alternativo: `logo.alt` se configurado; senão o `appName` quando o nome está escondido, e vazio
|
|
98
|
+
* (imagem decorativa) quando o nome já está ao lado — o leitor de tela não lê o nome duas vezes.
|
|
99
|
+
*/
|
|
100
|
+
class SpineBrandComponent {
|
|
101
|
+
constructor() {
|
|
102
|
+
/** Rota da home (primeira rota navegável); `null` desenha a marca sem link. */
|
|
103
|
+
this.homeRoute = input(null, ...(ngDevMode ? [{ debugName: "homeRoute" }] : /* istanbul ignore next */ []));
|
|
104
|
+
/** Dica do link (ex.: "Página inicial"). */
|
|
105
|
+
this.label = input('', ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
|
|
106
|
+
this.config = inject(SPINE_CONFIG, { optional: true });
|
|
107
|
+
this.theme = inject(ThemeService);
|
|
108
|
+
this.brand = inject(SPINE_BRAND);
|
|
109
|
+
this.appName = this.config?.appName ?? 'Hermes';
|
|
110
|
+
this.sources = resolveSpineLogo(this.config?.logo);
|
|
111
|
+
this.showAppName = this.config?.showAppName !== false;
|
|
112
|
+
/** Endereços que já deram erro nesta sessão do componente. */
|
|
113
|
+
this.failed = signal(new Set(), ...(ngDevMode ? [{ debugName: "failed" }] : /* istanbul ignore next */ []));
|
|
114
|
+
/** Imagem a mostrar agora (tema atual, sem as que falharam); `null` = nenhuma. */
|
|
115
|
+
this.src = computed(() => {
|
|
116
|
+
const logo = this.sources;
|
|
117
|
+
if (!logo || this.brand)
|
|
118
|
+
return null;
|
|
119
|
+
const failed = this.failed();
|
|
120
|
+
if (this.theme.current === 'dark' && logo.dark && !failed.has(logo.dark))
|
|
121
|
+
return logo.dark;
|
|
122
|
+
return failed.has(logo.light) ? null : logo.light;
|
|
123
|
+
}, ...(ngDevMode ? [{ debugName: "src" }] : /* istanbul ignore next */ []));
|
|
124
|
+
this.nameVisible = computed(() => this.showAppName || (!this.brand && !this.src()), ...(ngDevMode ? [{ debugName: "nameVisible" }] : /* istanbul ignore next */ []));
|
|
125
|
+
this.alt = computed(() => this.sources?.alt ?? (this.nameVisible() ? '' : this.appName), ...(ngDevMode ? [{ debugName: "alt" }] : /* istanbul ignore next */ []));
|
|
126
|
+
}
|
|
127
|
+
onError(src) {
|
|
128
|
+
if (this.failed().has(src))
|
|
129
|
+
return;
|
|
130
|
+
this.failed.update((set) => new Set(set).add(src));
|
|
131
|
+
}
|
|
132
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineBrandComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
133
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: SpineBrandComponent, isStandalone: true, selector: "spine-brand", inputs: { homeRoute: { classPropertyName: "homeRoute", publicName: "homeRoute", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
|
|
134
|
+
@if (homeRoute()) {
|
|
135
|
+
<a class="brand" [routerLink]="homeRoute()" [title]="label()">
|
|
136
|
+
<ng-container *ngTemplateOutlet="content" />
|
|
137
|
+
</a>
|
|
138
|
+
} @else {
|
|
139
|
+
<span class="brand" [title]="label()">
|
|
140
|
+
<ng-container *ngTemplateOutlet="content" />
|
|
141
|
+
</span>
|
|
142
|
+
}
|
|
143
|
+
<ng-template #content>
|
|
144
|
+
@if (brand) {
|
|
145
|
+
<ng-container *ngComponentOutlet="brand" />
|
|
146
|
+
} @else if (src(); as s) {
|
|
147
|
+
<img class="brand__logo" [src]="s" [alt]="alt()" (error)="onError(s)" />
|
|
148
|
+
}
|
|
149
|
+
@if (nameVisible()) {
|
|
150
|
+
<span class="brand__name">{{ appName }}</span>
|
|
151
|
+
}
|
|
152
|
+
</ng-template>
|
|
153
|
+
`, isInline: true, styles: ["@charset \"UTF-8\";:host{display:contents}.brand{display:inline-flex;align-items:center;gap:var(--hm-space-xs);min-width:0;color:inherit;font-weight:var(--hm-font-weight-bold);text-decoration:none;margin-inline-start:var(--hm-space-sm)}.brand:focus-visible{outline:var(--hm-border-thin) solid var(--hm-accent);outline-offset:var(--hm-space-micro);border-radius:var(--hm-radius-micro)}.brand__logo{display:block;flex:none;height:var(--hm-icon-xl);width:auto;max-width:calc(var(--hm-icon-xl) * 6);object-fit:contain}.brand__name{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}@media(max-width:767px){.brand{margin-inline-start:0}.brand__logo{max-width:calc(var(--hm-icon-xl) * 4)}}\n"], dependencies: [{ kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
154
|
+
}
|
|
155
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineBrandComponent, decorators: [{
|
|
156
|
+
type: Component,
|
|
157
|
+
args: [{ selector: 'spine-brand', imports: [RouterLink, NgTemplateOutlet, NgComponentOutlet], template: `
|
|
158
|
+
@if (homeRoute()) {
|
|
159
|
+
<a class="brand" [routerLink]="homeRoute()" [title]="label()">
|
|
160
|
+
<ng-container *ngTemplateOutlet="content" />
|
|
161
|
+
</a>
|
|
162
|
+
} @else {
|
|
163
|
+
<span class="brand" [title]="label()">
|
|
164
|
+
<ng-container *ngTemplateOutlet="content" />
|
|
165
|
+
</span>
|
|
166
|
+
}
|
|
167
|
+
<ng-template #content>
|
|
168
|
+
@if (brand) {
|
|
169
|
+
<ng-container *ngComponentOutlet="brand" />
|
|
170
|
+
} @else if (src(); as s) {
|
|
171
|
+
<img class="brand__logo" [src]="s" [alt]="alt()" (error)="onError(s)" />
|
|
172
|
+
}
|
|
173
|
+
@if (nameVisible()) {
|
|
174
|
+
<span class="brand__name">{{ appName }}</span>
|
|
175
|
+
}
|
|
176
|
+
</ng-template>
|
|
177
|
+
`, changeDetection: ChangeDetectionStrategy.OnPush, styles: ["@charset \"UTF-8\";:host{display:contents}.brand{display:inline-flex;align-items:center;gap:var(--hm-space-xs);min-width:0;color:inherit;font-weight:var(--hm-font-weight-bold);text-decoration:none;margin-inline-start:var(--hm-space-sm)}.brand:focus-visible{outline:var(--hm-border-thin) solid var(--hm-accent);outline-offset:var(--hm-space-micro);border-radius:var(--hm-radius-micro)}.brand__logo{display:block;flex:none;height:var(--hm-icon-xl);width:auto;max-width:calc(var(--hm-icon-xl) * 6);object-fit:contain}.brand__name{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}@media(max-width:767px){.brand{margin-inline-start:0}.brand__logo{max-width:calc(var(--hm-icon-xl) * 4)}}\n"] }]
|
|
178
|
+
}], propDecorators: { homeRoute: [{ type: i0.Input, args: [{ isSignal: true, alias: "homeRoute", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }] } });
|
|
179
|
+
|
|
180
|
+
// Entra na chave de "já visto": trocar a versão faz o tour aparecer de novo para todo mundo.
|
|
181
|
+
const ONBOARDING_VERSION = 'v3';
|
|
182
|
+
// Partes do shell chegam depois do login (abas, barra de página): o tour espera por elas, mas não
|
|
183
|
+
// para sempre. Os passos cujo alvo não apareceu no prazo ficam de fora.
|
|
184
|
+
const TARGET_WAIT_MS = 3000;
|
|
185
|
+
const TARGET_POLL_MS = 150;
|
|
186
|
+
/**
|
|
187
|
+
* Primeiro elemento do seletor que está de fato na tela (tem tamanho e conteúdo). Não usa
|
|
188
|
+
* `offsetParent`: ele é `null` pra `position: fixed` (a sidebar) e pra `display: contents`, e o
|
|
189
|
+
* passo sumia do tour mesmo com o alvo visível. Com as abas vivas do workspace há uma barra de
|
|
190
|
+
* página por aba — só a da aba ativa tem tamanho, e é ela que o holofote precisa achar.
|
|
191
|
+
*/
|
|
192
|
+
function findTourTarget(selector) {
|
|
193
|
+
for (const el of Array.from(document.querySelectorAll(selector))) {
|
|
194
|
+
const r = el.getBoundingClientRect();
|
|
195
|
+
if (r.width > 0 && r.height > 0 && el.children.length > 0)
|
|
196
|
+
return el;
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Onboarding interativo próprio (sem dependência externa). O overlay/holofote e o
|
|
202
|
+
* balão são renderizados pelo OnboardingOverlayComponent, que escuta este serviço.
|
|
203
|
+
* Aparece uma vez por máquina (flag em localStorage), namespaced por cliente.
|
|
204
|
+
*/
|
|
205
|
+
class OnboardingService {
|
|
206
|
+
constructor(config) {
|
|
207
|
+
this.text = inject(SPINE_MESSAGES).tour;
|
|
208
|
+
/** Passo atual (null = tour inativo). O overlay component reage a isto. */
|
|
209
|
+
this.current$ = new BehaviorSubject(null);
|
|
210
|
+
this.steps = [];
|
|
211
|
+
this.index = 0;
|
|
212
|
+
this.waiting = null;
|
|
213
|
+
const ns = config?.clientId ?? 'app';
|
|
214
|
+
this.doneKey = `${ns}_onboarding_${ONBOARDING_VERSION}`;
|
|
215
|
+
}
|
|
216
|
+
get done() {
|
|
217
|
+
try {
|
|
218
|
+
return localStorage.getItem(this.doneKey) === 'done';
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
markDone() {
|
|
225
|
+
try {
|
|
226
|
+
localStorage.setItem(this.doneKey, 'done');
|
|
227
|
+
}
|
|
228
|
+
catch { /* sem localStorage → não insiste */ }
|
|
229
|
+
}
|
|
230
|
+
/** Dispara o tour só se ainda não foi visto nesta máquina. Chamar após o login. */
|
|
231
|
+
maybeStart() {
|
|
232
|
+
if (this.done)
|
|
233
|
+
return;
|
|
234
|
+
setTimeout(() => this.start(), 700); // garante header/breadcrumb renderizados
|
|
235
|
+
}
|
|
236
|
+
/** Reabre o tour manualmente (menu Ajuda), ignorando a flag. */
|
|
237
|
+
restart() {
|
|
238
|
+
this.start();
|
|
239
|
+
}
|
|
240
|
+
/** Passos no idioma ativo (o texto é lido do catálogo ao começar o tour). Descrições com `<b>`. */
|
|
241
|
+
allSteps() {
|
|
242
|
+
const t = this.text;
|
|
243
|
+
return [
|
|
244
|
+
{ title: t.welcomeTitle, description: t.welcomeText },
|
|
245
|
+
{ target: '[data-tour="apps"]', title: t.appsTitle, description: t.appsText },
|
|
246
|
+
{ target: '[data-tour="search"]', title: t.searchTitle, description: t.searchText },
|
|
247
|
+
{ target: '[data-tour="nav"] .hm-sidebar__content', title: t.navModulesTitle, description: t.navModulesText },
|
|
248
|
+
{ target: '[data-tour="nav"] .hm-sidebar__footer', title: t.navModeTitle, description: t.navModeText },
|
|
249
|
+
{ target: '[data-tour="tabs"] hm-tab-bar', title: t.tabsTitle, description: t.tabsText },
|
|
250
|
+
{ target: '[data-tour="tabs"]', title: t.splitTitle, description: t.splitText },
|
|
251
|
+
{ target: '[data-tour="breadcrumb"]', title: t.whereTitle, description: t.whereText },
|
|
252
|
+
{ target: '[data-tour="theme"]', title: t.themeTitle, description: t.themeText },
|
|
253
|
+
{ target: '[data-tour="density"]', title: t.densityTitle, description: t.densityText },
|
|
254
|
+
{ target: '[data-tour="fullscreen"]', title: t.fullscreenTitle, description: t.fullscreenText },
|
|
255
|
+
{ target: '[data-tour="profile"]', title: t.profileTitle, description: t.profileText },
|
|
256
|
+
{ title: t.doneTitle, description: t.doneText },
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Começa do primeiro passo assim que todos os alvos estão na tela, ou no fim do prazo só com os passos
|
|
261
|
+
* que têm alvo visível (e os sem alvo). Sem nenhum passo, só marca como visto. Não bloqueia: confere
|
|
262
|
+
* de novo a cada `TARGET_POLL_MS`.
|
|
263
|
+
*/
|
|
264
|
+
start() {
|
|
265
|
+
if (this.waiting !== null)
|
|
266
|
+
clearTimeout(this.waiting);
|
|
267
|
+
this.waiting = null;
|
|
268
|
+
const steps = this.allSteps();
|
|
269
|
+
const present = (step) => !step.target || findTourTarget(step.target) !== null;
|
|
270
|
+
const attempt = (waited) => {
|
|
271
|
+
if (!steps.every(present) && waited < TARGET_WAIT_MS) {
|
|
272
|
+
this.waiting = setTimeout(() => attempt(waited + TARGET_POLL_MS), TARGET_POLL_MS);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
this.waiting = null;
|
|
276
|
+
this.steps = steps.filter(present);
|
|
277
|
+
this.index = 0;
|
|
278
|
+
if (!this.steps.length) {
|
|
279
|
+
this.markDone();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
this.emit();
|
|
283
|
+
};
|
|
284
|
+
attempt(0);
|
|
285
|
+
}
|
|
286
|
+
emit() {
|
|
287
|
+
this.current$.next({ step: this.steps[this.index], index: this.index, total: this.steps.length });
|
|
288
|
+
}
|
|
289
|
+
next() {
|
|
290
|
+
if (this.index >= this.steps.length - 1) {
|
|
291
|
+
this.finish();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
this.index++;
|
|
295
|
+
this.emit();
|
|
296
|
+
}
|
|
297
|
+
prev() {
|
|
298
|
+
if (this.index <= 0)
|
|
299
|
+
return;
|
|
300
|
+
this.index--;
|
|
301
|
+
this.emit();
|
|
302
|
+
}
|
|
303
|
+
/** Encerra o tour (concluído ou pulado) e marca como visto. */
|
|
304
|
+
finish() {
|
|
305
|
+
this.markDone();
|
|
306
|
+
this.current$.next(null);
|
|
307
|
+
}
|
|
308
|
+
get isFirst() { return this.index === 0; }
|
|
309
|
+
get isLast() { return this.index === this.steps.length - 1; }
|
|
310
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: OnboardingService, deps: [{ token: SPINE_CONFIG, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
311
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: OnboardingService, providedIn: 'root' }); }
|
|
312
|
+
}
|
|
313
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: OnboardingService, decorators: [{
|
|
314
|
+
type: Injectable,
|
|
315
|
+
args: [{ providedIn: 'root' }]
|
|
316
|
+
}], ctorParameters: () => [{ type: undefined, decorators: [{
|
|
317
|
+
type: Optional
|
|
318
|
+
}, {
|
|
319
|
+
type: Inject,
|
|
320
|
+
args: [SPINE_CONFIG]
|
|
321
|
+
}] }] });
|
|
322
|
+
|
|
323
|
+
/** Intervalo entre o fim do tour (ou a entrada, sem tour) e o painel de novidades, para os dois não se
|
|
324
|
+
* emendarem na tela. */
|
|
325
|
+
const AFTER_TOUR_MS = 800;
|
|
326
|
+
/** Tempo mínimo desde a entrada antes de oferecer — o tour, se devido, já começou (ele espera 700ms). */
|
|
327
|
+
const FIRST_CHECK_MS = 1500;
|
|
328
|
+
/**
|
|
329
|
+
* Painel de novidades do app (ver `provideSpineNews`). Decide o que está devido — ainda não visto
|
|
330
|
+
* NESTE navegador e ainda dentro do `until` — e abre sozinho uma vez, depois do tour de boas-vindas.
|
|
331
|
+
* O "já visto" é por app (`hermes_<clientId>_news_seen`): novidade de um app não some por ter sido
|
|
332
|
+
* vista em outro no mesmo domínio.
|
|
333
|
+
*/
|
|
334
|
+
class SpineNewsService {
|
|
335
|
+
constructor() {
|
|
336
|
+
this.onboarding = inject(OnboardingService);
|
|
337
|
+
this.items = (inject(SPINE_NEWS, { optional: true }) ?? []).flat();
|
|
338
|
+
/** Slides em exibição (os devidos, ou todos os vigentes quando reaberto pelo menu). */
|
|
339
|
+
this.slides = signal([], ...(ngDevMode ? [{ debugName: "slides" }] : /* istanbul ignore next */ []));
|
|
340
|
+
this.isOpen = signal(false, ...(ngDevMode ? [{ debugName: "isOpen" }] : /* istanbul ignore next */ []));
|
|
341
|
+
/** Há alguma novidade vigente — o item "Novidades" do menu só aparece com isto. */
|
|
342
|
+
this.hasAny = computed(() => this.current().length > 0, ...(ngDevMode ? [{ debugName: "hasAny" }] : /* istanbul ignore next */ []));
|
|
343
|
+
const ns = inject(SPINE_CONFIG, { optional: true })?.clientId;
|
|
344
|
+
this.seenKey = `${ns ? `hermes_${ns}` : 'hermes'}_news_seen`;
|
|
345
|
+
}
|
|
346
|
+
/** Chamado uma vez pelo layout na entrada: abre as devidas, esperando o tour terminar. */
|
|
347
|
+
scheduleOnEntry() {
|
|
348
|
+
if (this.due().length === 0)
|
|
349
|
+
return;
|
|
350
|
+
setTimeout(() => {
|
|
351
|
+
this.onboarding.current$
|
|
352
|
+
.pipe(filter((step) => step === null), take(1))
|
|
353
|
+
.subscribe(() => setTimeout(() => this.showDue(), AFTER_TOUR_MS));
|
|
354
|
+
}, FIRST_CHECK_MS);
|
|
355
|
+
}
|
|
356
|
+
/** Marca como vistas todas as novidades devidas e as abre num único painel. */
|
|
357
|
+
showDue() {
|
|
358
|
+
const due = this.due();
|
|
359
|
+
if (due.length === 0)
|
|
360
|
+
return;
|
|
361
|
+
this.markSeen(due.map((i) => i.id));
|
|
362
|
+
this.open(due);
|
|
363
|
+
}
|
|
364
|
+
/** Reabre todas as vigentes (menu do perfil), vistas ou não. */
|
|
365
|
+
showAll() {
|
|
366
|
+
this.open(this.current());
|
|
367
|
+
}
|
|
368
|
+
close() {
|
|
369
|
+
this.isOpen.set(false);
|
|
370
|
+
}
|
|
371
|
+
open(items) {
|
|
372
|
+
const slides = items.flatMap((i) => i.slides);
|
|
373
|
+
if (slides.length === 0)
|
|
374
|
+
return;
|
|
375
|
+
this.slides.set(slides);
|
|
376
|
+
this.isOpen.set(true);
|
|
377
|
+
}
|
|
378
|
+
/** Vigentes: sem `until`, ou com `until` ainda não passado (comparação por data, AAAA-MM-DD). */
|
|
379
|
+
current() {
|
|
380
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
381
|
+
return this.items.filter((i) => !i.until || today <= i.until);
|
|
382
|
+
}
|
|
383
|
+
due() {
|
|
384
|
+
const seen = this.readSeen();
|
|
385
|
+
return this.current().filter((i) => !seen.has(i.id));
|
|
386
|
+
}
|
|
387
|
+
readSeen() {
|
|
388
|
+
try {
|
|
389
|
+
return new Set(JSON.parse(localStorage.getItem(this.seenKey) || '[]'));
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return new Set();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
markSeen(ids) {
|
|
396
|
+
try {
|
|
397
|
+
const seen = this.readSeen();
|
|
398
|
+
ids.forEach((id) => seen.add(id));
|
|
399
|
+
localStorage.setItem(this.seenKey, JSON.stringify([...seen]));
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
/* sem localStorage: mostra de novo na próxima entrada, sem quebrar nada */
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineNewsService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
406
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineNewsService, providedIn: 'root' }); }
|
|
407
|
+
}
|
|
408
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineNewsService, decorators: [{
|
|
409
|
+
type: Injectable,
|
|
410
|
+
args: [{ providedIn: 'root' }]
|
|
411
|
+
}], ctorParameters: () => [] });
|
|
412
|
+
|
|
413
|
+
/** Seções que o próprio spine já traz. `key` é também o valor de `?settings=` na URL. */
|
|
414
|
+
const SPINE_SETTINGS_KEYS = {
|
|
415
|
+
appearance: 'aparencia',
|
|
416
|
+
region: 'regiao',
|
|
417
|
+
shortcuts: 'atalhos',
|
|
418
|
+
account: 'conta',
|
|
419
|
+
about: 'sobre',
|
|
420
|
+
};
|
|
421
|
+
const SPINE_SETTINGS_SECTIONS = new InjectionToken('SPINE_SETTINGS_SECTIONS');
|
|
422
|
+
/** Registra uma seção do app no painel de configurações (aparece depois das do spine, antes de
|
|
423
|
+
* "Sobre"). Pode ser chamado várias vezes, uma por seção. */
|
|
424
|
+
function provideSettingsSection(section) {
|
|
425
|
+
return { provide: SPINE_SETTINGS_SECTIONS, useValue: section, multi: true };
|
|
426
|
+
}
|
|
427
|
+
/** Abre/fecha o painel de configurações de qualquer lugar (menu do perfil, atalho, link). */
|
|
428
|
+
class SpineSettingsService {
|
|
429
|
+
constructor() {
|
|
430
|
+
this.isOpen = signal(false, ...(ngDevMode ? [{ debugName: "isOpen" }] : /* istanbul ignore next */ []));
|
|
431
|
+
/** Seção pedida na abertura (vazio = a primeira). */
|
|
432
|
+
this.section = signal('', ...(ngDevMode ? [{ debugName: "section" }] : /* istanbul ignore next */ []));
|
|
433
|
+
}
|
|
434
|
+
open(section = '') {
|
|
435
|
+
this.section.set(section);
|
|
436
|
+
this.isOpen.set(true);
|
|
437
|
+
}
|
|
438
|
+
close() {
|
|
439
|
+
this.isOpen.set(false);
|
|
440
|
+
}
|
|
441
|
+
toggle() {
|
|
442
|
+
if (this.isOpen())
|
|
443
|
+
this.close();
|
|
444
|
+
else
|
|
445
|
+
this.open();
|
|
446
|
+
}
|
|
447
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineSettingsService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
448
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineSettingsService, providedIn: 'root' }); }
|
|
449
|
+
}
|
|
450
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineSettingsService, decorators: [{
|
|
451
|
+
type: Injectable,
|
|
452
|
+
args: [{ providedIn: 'root' }]
|
|
453
|
+
}] });
|
|
454
|
+
|
|
455
|
+
/** `G 1` … `G 9`: um registro só (o dígito sai do evento); a referência identifica o item no painel. */
|
|
456
|
+
const FAVORITE_KEYS = Array.from({ length: 9 }, (_, i) => `G ${i + 1}`);
|
|
457
|
+
/** Como os favoritos aparecem no painel: uma linha "G depois 1…9" em vez de nove alternativas. */
|
|
458
|
+
const FAVORITE_DISPLAY_KEYS = 'G 1…9';
|
|
459
|
+
/**
|
|
460
|
+
* Atalhos de teclado globais do sistema, sobre o `HermesShortcutService` da lib (um listener só,
|
|
461
|
+
* sequência `G H`, conflito, campo editável, interruptor de tecla única da WCAG 2.1.4).
|
|
462
|
+
*
|
|
463
|
+
* `setActions` registra os atalhos do shell: `G H` (Home), `G 1`…`G 9` (favoritos), `Mod+,`
|
|
464
|
+
* (configurações), `Mod+.` (tema), `Mod+Shift+F` (tela cheia), `Mod+Shift+L` (sair) e `?` (painel
|
|
465
|
+
* de atalhos, via `help$`). Os combos com `Mod` valem também com o foco num campo; `G …` e `?`, não.
|
|
466
|
+
*
|
|
467
|
+
* O painel (`spine-shortcuts-cheatsheet` e a seção Atalhos das configurações) usa o
|
|
468
|
+
* `hm-shortcut-list` com `panelItems()`: tudo o que está registrado no `HermesShortcutService`
|
|
469
|
+
* (inclusive o que o app liga com `[hmShortcut]`) mais o que o spine só exibe e outra peça dispara
|
|
470
|
+
* (`Mod+K` do `hm-header`, `Alt+←/→` do navegador, `Esc` dos diálogos).
|
|
471
|
+
*/
|
|
472
|
+
class ShortcutService {
|
|
473
|
+
constructor() {
|
|
474
|
+
this.actions = {};
|
|
475
|
+
this.removers = [];
|
|
476
|
+
this.favKey = `${inject(SPINE_CONFIG, { optional: true })?.clientId ?? 'app'}_nav_favorites`;
|
|
477
|
+
this.hermes = inject(HermesShortcutService);
|
|
478
|
+
this.lib = inject(HERMES_MESSAGES);
|
|
479
|
+
this.text = inject(SPINE_MESSAGES).shortcuts;
|
|
480
|
+
/** Emite quando o usuário pede o painel de atalhos (tecla "?" ou menu do perfil). */
|
|
481
|
+
this.help$ = new Subject();
|
|
482
|
+
/** Teclas (referência) → descrição viva, para o painel acompanhar a troca de idioma. */
|
|
483
|
+
this.own = new Map();
|
|
484
|
+
/**
|
|
485
|
+
* O que o painel lista (`<hm-shortcut-list [registered]="false" [items]="panelItems()">`): os
|
|
486
|
+
* atalhos registrados no `HermesShortcutService`, com os favoritos numa linha só, e os só exibidos.
|
|
487
|
+
* Acompanha registro/remoção e a troca de idioma do catálogo da lib.
|
|
488
|
+
*/
|
|
489
|
+
this.panelItems = computed(() => {
|
|
490
|
+
const registered = this.hermes.list().map((item) => {
|
|
491
|
+
const describe = this.own.get(item.keys);
|
|
492
|
+
if (!describe)
|
|
493
|
+
return item;
|
|
494
|
+
return { ...item, description: describe(), keys: item.keys === FAVORITE_KEYS ? FAVORITE_DISPLAY_KEYS : item.keys };
|
|
495
|
+
});
|
|
496
|
+
const display = this.displayOnly();
|
|
497
|
+
// Ordem do painel antigo: busca e histórico abrem a navegação, Esc fecha o geral.
|
|
498
|
+
return [display[0], ...registered, display[1], display[2]];
|
|
499
|
+
}, ...(ngDevMode ? [{ debugName: "panelItems" }] : /* istanbul ignore next */ []));
|
|
500
|
+
}
|
|
501
|
+
/** Os atalhos do shell. `describe` lê o catálogo na hora (o idioma pode mudar depois do registro). */
|
|
502
|
+
definitions() {
|
|
503
|
+
const t = this.text;
|
|
504
|
+
return [
|
|
505
|
+
{ keys: ['G H'], describe: () => t.goHome, group: 'navigation', handler: () => this.navigate('/home') },
|
|
506
|
+
{ keys: FAVORITE_KEYS, describe: () => t.goFavorite, group: 'navigation', handler: (e) => this.goToFavorite(e) },
|
|
507
|
+
{ keys: ['Mod+,'], describe: () => t.openSettings, group: 'actions', allowInInputs: true, handler: () => this.call(this.actions.openSettings) },
|
|
508
|
+
{ keys: ['Mod+.'], describe: () => t.toggleTheme, group: 'actions', allowInInputs: true, handler: () => this.call(this.actions.toggleTheme) },
|
|
509
|
+
{ keys: ['Mod+Shift+F'], describe: () => t.fullscreen, group: 'actions', allowInInputs: true, handler: () => this.call(this.actions.toggleFullscreen) },
|
|
510
|
+
{ keys: ['Mod+Shift+L'], describe: () => t.logout, group: 'actions', allowInInputs: true, handler: () => this.call(this.actions.logout) },
|
|
511
|
+
{ keys: ['?'], describe: () => t.showHelp, group: 'general', handler: () => this.help$.next() },
|
|
512
|
+
];
|
|
513
|
+
}
|
|
514
|
+
/** Só exibidos: quem dispara é outra peça (hm-header, navegador, diálogos). */
|
|
515
|
+
displayOnly() {
|
|
516
|
+
const t = this.text;
|
|
517
|
+
return [
|
|
518
|
+
{ keys: 'Mod+K', description: t.openSearch, group: 'navigation' },
|
|
519
|
+
{ keys: ['Alt+Left', 'Alt+Right'], description: t.history, group: 'navigation' },
|
|
520
|
+
{ keys: 'Esc', description: t.closeOverlay, group: 'general' },
|
|
521
|
+
];
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Lista do painel em texto, no idioma e na plataforma atuais.
|
|
525
|
+
* @deprecated Use o `hm-shortcut-list` com `panelItems()` (ou `HermesShortcutService.list()`).
|
|
526
|
+
* Mantido para quem montava o próprio painel; sai numa próxima versão.
|
|
527
|
+
*/
|
|
528
|
+
get shortcuts() {
|
|
529
|
+
return this.panelItems().map((item) => ({
|
|
530
|
+
keys: this.keysText(item.keys),
|
|
531
|
+
description: item.description,
|
|
532
|
+
group: toSpineGroup(item.group),
|
|
533
|
+
}));
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Atalhos por grupo, na ordem navegação → ações → geral, com o nome do grupo traduzido.
|
|
537
|
+
* @deprecated Use o `hm-shortcut-list` (agrupa, traduz e desenha as teclas). Sai numa próxima versão.
|
|
538
|
+
*/
|
|
539
|
+
groups() {
|
|
540
|
+
const all = this.shortcuts;
|
|
541
|
+
return ['navigation', 'actions', 'general']
|
|
542
|
+
.map((id) => ({ id, name: this.hermes.groupName(id), items: all.filter((s) => s.group === id) }))
|
|
543
|
+
.filter((g) => g.items.length);
|
|
544
|
+
}
|
|
545
|
+
/** Liga as ações e registra os atalhos no `HermesShortcutService` (idempotente: chamadas seguintes só trocam as ações). */
|
|
546
|
+
setActions(actions) {
|
|
547
|
+
this.actions = { ...this.actions, ...actions };
|
|
548
|
+
if (this.removers.length)
|
|
549
|
+
return;
|
|
550
|
+
this.removers = this.definitions().map(({ describe, ...definition }) => {
|
|
551
|
+
// Teclas sempre em lista própria: a referência identifica o registro do spine no painel.
|
|
552
|
+
this.own.set(definition.keys, describe);
|
|
553
|
+
const remove = this.hermes.register({ ...definition, description: describe() });
|
|
554
|
+
return () => {
|
|
555
|
+
remove();
|
|
556
|
+
this.own.delete(definition.keys);
|
|
557
|
+
};
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
ngOnDestroy() {
|
|
561
|
+
this.removers.forEach((remove) => remove());
|
|
562
|
+
this.removers = [];
|
|
563
|
+
}
|
|
564
|
+
// ── interno ────────────────────────────────────────────────────────────────
|
|
565
|
+
/** Ação ausente devolve `false`: o evento segue sem `preventDefault` (o navegador faz o dele). */
|
|
566
|
+
call(fn) {
|
|
567
|
+
if (!fn)
|
|
568
|
+
return false;
|
|
569
|
+
fn();
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
572
|
+
navigate(url) {
|
|
573
|
+
return this.call(this.actions.navigate && (() => this.actions.navigate(url)));
|
|
574
|
+
}
|
|
575
|
+
/** Navega para o N-ésimo favorito (1-based) lido da store da navegação; N = o dígito do evento. */
|
|
576
|
+
goToFavorite(event) {
|
|
577
|
+
const digit = /^[1-9]$/.test(event.key) ? event.key : /^Digit([1-9])$/.exec(event.code)?.[1];
|
|
578
|
+
if (!digit)
|
|
579
|
+
return false;
|
|
580
|
+
let url = null;
|
|
581
|
+
try {
|
|
582
|
+
const list = JSON.parse(localStorage.getItem(this.favKey) || '[]');
|
|
583
|
+
const fav = Array.isArray(list) ? list[Number(digit) - 1] : null;
|
|
584
|
+
url = fav?.redirect || (typeof fav?.id === 'string' && fav.id.startsWith('/') ? fav.id : null);
|
|
585
|
+
}
|
|
586
|
+
catch { /* store inválida → ignora */ }
|
|
587
|
+
return url ? this.navigate(url) : false;
|
|
588
|
+
}
|
|
589
|
+
/** Texto corrido das teclas para a API antiga (`shortcuts`): "Ctrl + K", "G depois H", "… ou …". */
|
|
590
|
+
keysText(keys) {
|
|
591
|
+
const { then, or } = this.lib.shortcuts;
|
|
592
|
+
const join = this.hermes.platform === 'mac' ? '' : ' + ';
|
|
593
|
+
return this.hermes
|
|
594
|
+
.format(keys)
|
|
595
|
+
.map((steps) => steps.map((step) => step.map((key) => key.label).join(join)).join(` ${then} `))
|
|
596
|
+
.join(` ${or} `);
|
|
597
|
+
}
|
|
598
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: ShortcutService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
599
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: ShortcutService, providedIn: 'root' }); }
|
|
600
|
+
}
|
|
601
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: ShortcutService, decorators: [{
|
|
602
|
+
type: Injectable,
|
|
603
|
+
args: [{ providedIn: 'root' }]
|
|
604
|
+
}] });
|
|
605
|
+
function toSpineGroup(group) {
|
|
606
|
+
return group === 'navigation' || group === 'actions' ? group : 'general';
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const MAX_RECENT_SEARCH = 6;
|
|
610
|
+
class HeaderComponent {
|
|
611
|
+
constructor(authService, themeService, themePresetService, densityService, viewService, router, onboarding, shortcuts, config) {
|
|
612
|
+
this.authService = authService;
|
|
613
|
+
this.themeService = themeService;
|
|
614
|
+
this.themePresetService = themePresetService;
|
|
615
|
+
this.densityService = densityService;
|
|
616
|
+
this.viewService = viewService;
|
|
617
|
+
this.router = router;
|
|
618
|
+
this.onboarding = onboarding;
|
|
619
|
+
this.shortcuts = shortcuts;
|
|
620
|
+
this.isFullscreen = !!document.fullscreenElement;
|
|
621
|
+
this.element = document.documentElement;
|
|
622
|
+
this.headerBlocked = false;
|
|
623
|
+
/** route_redirect da primeira rota navegável da view atual — usado pela marca do header.
|
|
624
|
+
* null quando a view não tem nenhuma rota (nesse caso a marca não é link). */
|
|
625
|
+
this.homeRoute = null;
|
|
626
|
+
this.injector = inject(Injector);
|
|
627
|
+
this.text = inject(SPINE_MESSAGES).header;
|
|
628
|
+
this.fmt = formatMessage;
|
|
629
|
+
this.settings = inject(SpineSettingsService);
|
|
630
|
+
this.news = inject(SpineNewsService);
|
|
631
|
+
this.quickQuery = '';
|
|
632
|
+
this.quickOpen = false;
|
|
633
|
+
this.quickResults = [];
|
|
634
|
+
this.quickActive = 0;
|
|
635
|
+
this.quickIndex = [];
|
|
636
|
+
/** Histórico de buscas (mostrado ao focar com o campo vazio). */
|
|
637
|
+
this.recentSearches = [];
|
|
638
|
+
/** Verdadeiro quando o painel está exibindo o histórico (campo vazio). */
|
|
639
|
+
this.showingRecent = false;
|
|
640
|
+
this.appName = config?.appName ?? 'Hermes';
|
|
641
|
+
const ns = config?.clientId ?? 'app';
|
|
642
|
+
this.recentSearchKey = `${ns}_header_recent_searches`;
|
|
643
|
+
}
|
|
644
|
+
async ngOnInit() {
|
|
645
|
+
this.updateView(this.viewService.currentView);
|
|
646
|
+
this.viewService.currentView$.subscribe((data) => this.updateView(data));
|
|
647
|
+
// Depois de trocar de badge, a view/rotas mudam — sem isso o usuário fica
|
|
648
|
+
// parado numa rota que pode não existir mais pro badge novo. Se o badge novo
|
|
649
|
+
// não tiver nenhuma view disponível, manda pro no-permission em vez de travar.
|
|
650
|
+
//
|
|
651
|
+
// badgeChanged$ dispara ANTES do ViewService.currentView ser atualizado
|
|
652
|
+
// (AuthorizationService.switchBadge emite o evento em applyToken() e só
|
|
653
|
+
// depois faz `await view.update(token)`), então não dá pra usar
|
|
654
|
+
// currentView na hora do evento — ele ainda é a view do badge anterior.
|
|
655
|
+
// Por isso esperamos a próxima emissão *diferente* de currentView$ antes
|
|
656
|
+
// de decidir o redirect.
|
|
657
|
+
this.authService.badgeChanged$.subscribe(() => {
|
|
658
|
+
const staleView = this.viewService.currentView;
|
|
659
|
+
this.viewService.currentView$
|
|
660
|
+
.pipe(filter((v) => v !== staleView), take(1))
|
|
661
|
+
.subscribe((data) => {
|
|
662
|
+
const firstRoute = this.findFirstNavigable(spineRoutesOf(data));
|
|
663
|
+
this.router.navigate([firstRoute ?? '/no-permission']);
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
this.recentSearches = this.loadRecent();
|
|
667
|
+
}
|
|
668
|
+
updateView(data) {
|
|
669
|
+
const routes = spineRoutesOf(data);
|
|
670
|
+
this.quickIndex = this.flattenRoutes(routes, []);
|
|
671
|
+
this.homeRoute = this.findFirstNavigable(routes);
|
|
672
|
+
}
|
|
673
|
+
/** Primeira rota navegável (DFS), atravessando grupos "transparentes" sem route_redirect. */
|
|
674
|
+
findFirstNavigable(routes) {
|
|
675
|
+
for (const r of routes || []) {
|
|
676
|
+
if (!r)
|
|
677
|
+
continue;
|
|
678
|
+
if (r.route_redirect)
|
|
679
|
+
return r.route_redirect;
|
|
680
|
+
if (r.routes?.length) {
|
|
681
|
+
const found = this.findFirstNavigable(r.routes);
|
|
682
|
+
if (found)
|
|
683
|
+
return found;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
setTheme(theme) {
|
|
689
|
+
this.themeService.setTheme(theme);
|
|
690
|
+
}
|
|
691
|
+
setDensity() { this.densityService.toggleDensity(); }
|
|
692
|
+
get isCompactDensity() { return this.densityService.current.name === 'compact'; }
|
|
693
|
+
toggleFullscreen() {
|
|
694
|
+
if (!document.fullscreenElement) {
|
|
695
|
+
this.element.requestFullscreen()
|
|
696
|
+
.then(() => { this.isFullscreen = true; })
|
|
697
|
+
.catch(err => console.error(`Erro ao ativar tela cheia: ${err.message}`));
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
document.exitFullscreen()
|
|
701
|
+
.then(() => { this.isFullscreen = false; })
|
|
702
|
+
.catch(err => console.error(`Erro ao sair da tela cheia: ${err.message}`));
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
get badge() { return this.authService.badge; }
|
|
706
|
+
get badges() { return this.authService.badges; }
|
|
707
|
+
get profile() { return this.authService.profile; }
|
|
708
|
+
async switchBadge(badgeTypeId) {
|
|
709
|
+
await this.authService.switchBadge(badgeTypeId);
|
|
710
|
+
}
|
|
711
|
+
logout() { this.authService.logout(); }
|
|
712
|
+
/** Reabre o tour guiado de onboarding. */
|
|
713
|
+
startTour() { this.onboarding.restart(); }
|
|
714
|
+
/** Abre o painel de atalhos de teclado. */
|
|
715
|
+
openShortcuts() { this.shortcuts.help$.next(); }
|
|
716
|
+
/** Menu do perfil: achatado (badges, se houver mais de um, + ações), já que `hm-menu`
|
|
717
|
+
* não tem submenu aninhado como o `hermes-dropdown` antigo tinha para a troca de crachá. */
|
|
718
|
+
get profileMenuItems() {
|
|
719
|
+
const items = [];
|
|
720
|
+
// Vários níveis de acesso: um item que abre um segundo menu (MenuItem.children) em vez de listar
|
|
721
|
+
// todos os níveis direto no menu do perfil.
|
|
722
|
+
if (this.badges.length > 1) {
|
|
723
|
+
items.push({
|
|
724
|
+
id: 'badges',
|
|
725
|
+
label: this.text.accessLevels,
|
|
726
|
+
icon: 'shield-check',
|
|
727
|
+
children: this.badges.map(b => ({ id: `badge:${b.id}`, label: b.name, icon: 'shield-check', disabled: b.id === this.badge?.id })),
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
items.push({ id: 'settings', label: this.text.settings, icon: 'cog', separatorAbove: items.length > 0 });
|
|
731
|
+
items.push({ id: 'theme', label: this.text.appearance, icon: 'color-palette' });
|
|
732
|
+
items.push({ id: 'shortcuts', label: this.text.shortcuts, icon: 'keyboard' });
|
|
733
|
+
items.push({ id: 'tour', label: this.text.tour, icon: 'play' });
|
|
734
|
+
// Só quando o app registrou alguma novidade vigente (provideSpineNews).
|
|
735
|
+
if (this.news.hasAny())
|
|
736
|
+
items.push({ id: 'news', label: this.text.news, icon: 'star' });
|
|
737
|
+
items.push({ id: 'logout', label: this.text.logout, icon: 'logout', danger: true, separatorAbove: true });
|
|
738
|
+
return items;
|
|
739
|
+
}
|
|
740
|
+
onProfileMenuChosen(id) {
|
|
741
|
+
if (id.startsWith('badge:')) {
|
|
742
|
+
void this.switchBadge(id.slice('badge:'.length));
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
switch (id) {
|
|
746
|
+
case 'tour':
|
|
747
|
+
this.startTour();
|
|
748
|
+
return;
|
|
749
|
+
case 'news':
|
|
750
|
+
this.news.showAll();
|
|
751
|
+
return;
|
|
752
|
+
case 'shortcuts':
|
|
753
|
+
this.openShortcuts();
|
|
754
|
+
return;
|
|
755
|
+
case 'settings':
|
|
756
|
+
this.settings.open();
|
|
757
|
+
return;
|
|
758
|
+
// "Aparência" abre o painel direto na seção (era o modal de tema, que virou parte dela).
|
|
759
|
+
case 'theme':
|
|
760
|
+
this.settings.open(SPINE_SETTINGS_KEYS.appearance);
|
|
761
|
+
return;
|
|
762
|
+
case 'logout':
|
|
763
|
+
this.logout();
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
/** Achata a árvore de rotas em itens navegáveis (com trilha de ancestrais). */
|
|
768
|
+
flattenRoutes(routes, trail) {
|
|
769
|
+
const skip = new Set(['search', 'theme', 'logout']);
|
|
770
|
+
const out = [];
|
|
771
|
+
for (const r of routes || []) {
|
|
772
|
+
if (!r)
|
|
773
|
+
continue;
|
|
774
|
+
const name = r.route_display_name || '';
|
|
775
|
+
if (r.route_redirect && name && !skip.has(r.route_type)) {
|
|
776
|
+
out.push({ name, icon: r.route_icon || 'application', redirect: r.route_redirect, path: trail.join(' › ') });
|
|
777
|
+
}
|
|
778
|
+
if (r.routes?.length)
|
|
779
|
+
out.push(...this.flattenRoutes(r.routes, name ? [...trail, name] : trail));
|
|
780
|
+
}
|
|
781
|
+
return out;
|
|
782
|
+
}
|
|
783
|
+
/** Chamado pelo `(searchRequested)` do `hm-header` (clique no campo ou `Ctrl K`, já tratado
|
|
784
|
+
* internamente por ele) — abre o modal de busca, mesmo padrão do `docs/website`
|
|
785
|
+
* (`openSearch()` em `app.component.ts`: `@if` monta o `hm-modal` só quando aberto). */
|
|
786
|
+
onSearchRequested() {
|
|
787
|
+
this.quickQuery = '';
|
|
788
|
+
this.quickOpen = true;
|
|
789
|
+
this.onQuickSearch();
|
|
790
|
+
// O `#quickBox` (e o input do hm-input dentro dele) só existe no DOM depois do `@if`/`hm-modal` montarem — foca assim que
|
|
791
|
+
// a próxima renderização terminar (mesmo problema documentado em WorkspaceNavService: query
|
|
792
|
+
// de view não existe antes do primeiro ciclo depois do `@if` virar true).
|
|
793
|
+
afterNextRender(() => this.quickBox?.nativeElement?.querySelector('input')?.focus(), { injector: this.injector });
|
|
794
|
+
}
|
|
795
|
+
/** `(openChange)` do `hm-modal` — fecha ao clicar fora/Esc/botão de fechar. */
|
|
796
|
+
onQuickModalOpenChange(open) {
|
|
797
|
+
this.quickOpen = open;
|
|
798
|
+
if (!open) {
|
|
799
|
+
this.quickQuery = '';
|
|
800
|
+
this.quickResults = [];
|
|
801
|
+
this.showingRecent = false;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
onQuickSearch() {
|
|
805
|
+
const q = (this.quickQuery || '').trim().toLowerCase();
|
|
806
|
+
this.quickActive = 0;
|
|
807
|
+
// Campo vazio: mostra o histórico (recalculado na hora, para refletir as
|
|
808
|
+
// últimas navegações) em vez de nada.
|
|
809
|
+
if (!q) {
|
|
810
|
+
this.showingRecent = true;
|
|
811
|
+
this.recentSearches = this.loadRecent();
|
|
812
|
+
this.quickResults = this.recentSearches.slice(0, 8);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
this.showingRecent = false;
|
|
816
|
+
const seen = new Set();
|
|
817
|
+
this.quickResults = this.quickIndex
|
|
818
|
+
.filter((x) => (x.name.toLowerCase().includes(q) || x.path.toLowerCase().includes(q)))
|
|
819
|
+
.filter((x) => (seen.has(x.redirect) ? false : (seen.add(x.redirect), true)))
|
|
820
|
+
.slice(0, 8);
|
|
821
|
+
}
|
|
822
|
+
quickGo(item) {
|
|
823
|
+
if (!item?.redirect)
|
|
824
|
+
return;
|
|
825
|
+
this.pushRecent(item);
|
|
826
|
+
this.onQuickModalOpenChange(false);
|
|
827
|
+
this.router.navigateByUrl(item.redirect);
|
|
828
|
+
}
|
|
829
|
+
quickMove(delta) {
|
|
830
|
+
if (!this.quickResults.length)
|
|
831
|
+
return;
|
|
832
|
+
this.quickActive = (this.quickActive + delta + this.quickResults.length) % this.quickResults.length;
|
|
833
|
+
}
|
|
834
|
+
// ─── Histórico de buscas ────────────────────────────────────────────────────
|
|
835
|
+
readJson(key) {
|
|
836
|
+
try {
|
|
837
|
+
const v = JSON.parse(localStorage.getItem(key) || '[]');
|
|
838
|
+
return Array.isArray(v) ? v : [];
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
return [];
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
loadRecent() {
|
|
845
|
+
return this.readJson(this.recentSearchKey)
|
|
846
|
+
.filter((x) => x?.redirect && x?.name)
|
|
847
|
+
.map((x) => ({ name: x.name, icon: x.icon || 'application', redirect: x.redirect, path: x.path || '' }))
|
|
848
|
+
.slice(0, MAX_RECENT_SEARCH);
|
|
849
|
+
}
|
|
850
|
+
pushRecent(item) {
|
|
851
|
+
const entry = { name: item.name, icon: item.icon || 'application', redirect: item.redirect, path: item.path || '' };
|
|
852
|
+
const next = [entry, ...this.recentSearches.filter((x) => x.redirect !== entry.redirect)].slice(0, MAX_RECENT_SEARCH);
|
|
853
|
+
this.recentSearches = next;
|
|
854
|
+
try {
|
|
855
|
+
localStorage.setItem(this.recentSearchKey, JSON.stringify(next));
|
|
856
|
+
}
|
|
857
|
+
catch { /* storage cheio/indisponível */ }
|
|
858
|
+
}
|
|
859
|
+
clearRecent(event) {
|
|
860
|
+
event?.preventDefault();
|
|
861
|
+
event?.stopPropagation();
|
|
862
|
+
this.recentSearches = [];
|
|
863
|
+
try {
|
|
864
|
+
localStorage.removeItem(this.recentSearchKey);
|
|
865
|
+
}
|
|
866
|
+
catch { /* noop */ }
|
|
867
|
+
this.quickResults = [];
|
|
868
|
+
}
|
|
869
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: HeaderComponent, deps: [{ token: i1.AuthorizationService }, { token: i2$1.ThemeService }, { token: i2$1.ThemePresetService }, { token: i2$1.DensityService }, { token: i1.ViewService }, { token: i3.Router }, { token: OnboardingService }, { token: ShortcutService }, { token: SPINE_CONFIG, optional: true }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
870
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: HeaderComponent, isStandalone: true, selector: "spine-header", viewQueries: [{ propertyName: "quickBox", first: true, predicate: ["quickBox"], descendants: true }], ngImport: i0, template: "<hm-header [class.header-blocked]=\"headerBlocked\" (searchRequested)=\"onSearchRequested()\">\n <div *ngIf=\"headerBlocked\" class=\"header-overlay\"></div>\n\n <spine-app-launcher></spine-app-launcher>\n\n <!-- Marca: logo do app (SpineConfig.logo, padr\u00E3o assets/images/logo.svg) + SpineConfig.appName,\n levando \u00E0 primeira rota naveg\u00E1vel. Ver SpineBrandComponent. -->\n <spine-brand [homeRoute]=\"homeRoute\" [label]=\"text.home\" />\n\n <div actions class=\"h-right\">\n <!-- tema claro/escuro -->\n <hm-icon-button emphasis=\"subtle\" data-tour=\"theme\" (clicked)=\"setTheme(themeService.current == 'light' ? 'dark' : 'light')\"\n [icon]=\"themeService.current == 'light' ? 'moon' : 'sun'\"\n [label]=\"themeService.current == 'light' ? text.themeDark : text.themeLight\">\n </hm-icon-button>\n\n <!-- densidade -->\n <hm-icon-button emphasis=\"subtle\" data-tour=\"density\" (clicked)=\"setDensity()\"\n [icon]=\"isCompactDensity ? 'density-comfortable' : 'density-compact'\"\n [label]=\"isCompactDensity ? text.densityComfortable : text.densityCompact\">\n </hm-icon-button>\n\n <!-- tela cheia -->\n <hm-icon-button emphasis=\"subtle\" data-tour=\"fullscreen\" (clicked)=\"toggleFullscreen()\"\n [icon]=\"isFullscreen ? 'shrink' : 'resize'\"\n [label]=\"isFullscreen ? text.exitFullscreen : text.fullscreen\">\n </hm-icon-button>\n\n <span class=\"h-divider\" aria-hidden=\"true\"></span>\n\n <!-- perfil: crach\u00E1, ajuda, tema e sair ficam dentro do menu -->\n <hm-menu-button class=\"h-profile\" data-tour=\"profile\" [label]=\"profile.name ?? text.user\" align=\"end\" [items]=\"profileMenuItems\" (chosen)=\"onProfileMenuChosen($event)\">\n <hm-account-avatar [name]=\"profile.name ?? text.user\" [imageUrl]=\"profile.picture\"></hm-account-avatar>\n <span class=\"h-profile-info\">\n <span class=\"h-profile-name\">{{ profile.name ?? text.user }}</span>\n <span class=\"h-profile-role\" *ngIf=\"badge\">{{ badge?.name }}</span>\n </span>\n </hm-menu-button>\n </div>\n</hm-header>\n\n<!-- Busca global (quick-launcher) \u2014 mesmo padr\u00E3o do docs/website: modal aberto por\n (searchRequested), n\u00E3o um overlay pr\u00F3prio sempre presente no DOM. -->\n@if (quickOpen) {\n <hm-modal [open]=\"quickOpen\" (openChange)=\"onQuickModalOpenChange($event)\" [heading]=\"text.search\" size=\"medium\">\n <!-- Mesmo padr\u00E3o da busca do design system: hm-input + lista simples (nome \u00B7 onde). Setas/Enter navegam. -->\n <div class=\"search\" data-tour=\"search\" #quickBox\n (keydown.arrowdown)=\"$event.preventDefault(); quickMove(1)\"\n (keydown.arrowup)=\"$event.preventDefault(); quickMove(-1)\"\n (keydown.enter)=\"quickGo(quickResults[quickActive])\">\n <hm-input [label]=\"text.search\" [placeholder]=\"text.searchPlaceholder\" icon=\"search\"\n [(ngModel)]=\"quickQuery\" (ngModelChange)=\"onQuickSearch()\" />\n\n @if (showingRecent && quickResults.length) {\n <div class=\"search__recent\">\n <span>{{ text.recentSearches }}</span>\n <button type=\"button\" (mousedown)=\"clearRecent($event)\">{{ text.clearRecent }}</button>\n </div>\n }\n @if (quickResults.length) {\n <ul class=\"search__results\">\n @for (r of quickResults; track $index; let i = $index) {\n <li>\n <button type=\"button\" [class.active]=\"i === quickActive\" (mouseenter)=\"quickActive = i\" (mousedown)=\"quickGo(r)\">\n <strong><hm-icon [shape]=\"showingRecent ? 'history' : r.icon\" />{{ r.name }}</strong>\n @if (r.path) { <span>{{ r.path }}</span> }\n </button>\n </li>\n }\n </ul>\n } @else if (quickQuery) {\n <p class=\"search__empty\">{{ fmt(text.noResults, { term: quickQuery }) }}</p>\n }\n </div>\n </hm-modal>\n}\n\n", styles: ["@charset \"UTF-8\";:host{display:contents}.header-overlay{position:absolute;inset:0;z-index:9999;cursor:not-allowed}.h-btn{display:inline-flex;align-items:center;justify-content:center;gap:3px;height:40px;min-width:40px;padding:0 10px;border:none;background:transparent;cursor:pointer;border-radius:9px;text-decoration:none;color:var(--hm-navbar-text);outline:none}.h-btn:focus-visible,.h-btn:hover{background:var(--hm-navbar-hover)}.h-btn.active{background:var(--hm-navbar-hover)}.h-right{display:flex;align-items:center;gap:var(--hm-header-action-gap)}.h-divider{width:1px;height:24px;margin:0 4px;background:var(--hm-navbar-hover)}.h-profile{display:inline-flex;align-items:center;gap:6px;height:40px;padding:0 4px 0 2px}.h-profile-info{display:flex;flex-direction:column;min-width:0;max-width:140px;line-height:1.2;text-align:left}.h-profile-name{font-size:12.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.h-profile-role{color:color-mix(in srgb,var(--hm-navbar-text) 65%,transparent);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.search{display:grid;gap:var(--hm-space-sm);min-width:min(480px,70vw)}.search__recent{display:flex;align-items:center;justify-content:space-between;color:var(--hm-muted);font-size:var(--hm-font-size-xs)}.search__recent button{border:none;background:none;color:var(--hm-action);font:inherit;cursor:pointer}.search__recent button:hover{text-decoration:underline}.search__results{display:grid;gap:var(--hm-space-micro);max-height:320px;margin:0;padding:0;overflow-y:auto;list-style:none}.search__results button{display:flex;width:100%;align-items:baseline;justify-content:space-between;gap:var(--hm-space-sm);padding:var(--hm-space-nano) var(--hm-space-sm);border:none;border-radius:var(--hm-radius-control);background:none;color:var(--hm-text);cursor:pointer;text-align:start}.search__results button:hover,.search__results button:focus-visible,.search__results button.active{background:var(--hm-nav-hover)}.search__results button strong{display:inline-flex;align-items:center;gap:var(--hm-space-xs);font-weight:600}.search__results button strong hm-icon{color:var(--hm-muted)}.search__results button span{color:var(--hm-muted);font-size:var(--hm-font-size-xs);text-align:end}.search__empty{margin:0;padding:var(--hm-space-nano) var(--hm-space-sm);color:var(--hm-muted);font-size:var(--hm-font-size-sm)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: HeaderComponent$1, selector: "hm-header", inputs: ["searchPlaceholder", "searchLabel"], outputs: ["searchRequested"] }, { kind: "component", type: IconButtonComponent, selector: "hm-icon-button", inputs: ["label", "icon", "solid", "iconDirection", "emphasis", "intent", "size", "disabled", "toggle", "pressed", "keyShortcuts"], outputs: ["pressedChange", "clicked"] }, { kind: "component", type: IconComponent, selector: "hm-icon", inputs: ["shape", "direction", "size", "solid", "badge", "status", "flip", "inverse", "label"] }, { kind: "component", type: InputComponent, selector: "hm-input", inputs: ["value", "label", "type", "placeholder", "icon", "labelHidden", "hint", "prefix", "suffix", "prefixIcon", "mask", "maskValue", "min", "max", "step", "stepButtons", "maxLength", "autocomplete", "inputMode"], outputs: ["valueChange"] }, { kind: "component", type: MenuButtonComponent, selector: "hm-menu-button", inputs: ["label", "icon", "items", "showArrow", "align", "side", "size", "shape", "layer", "closeOnCheck", "disabled"], outputs: ["chosen", "checkedChange"] }, { kind: "component", type: AccountAvatarComponent, selector: "hm-account-avatar", inputs: ["name", "imageUrl", "size", "loading"] }, { kind: "component", type: ModalComponent, selector: "hm-modal", inputs: ["open", "heading", "size", "closeLabel", "closable", "beforeClose"], outputs: ["openChange", "closed"] }, { kind: "component", type: AppLauncherComponent, selector: "spine-app-launcher" }, { kind: "component", type: SpineBrandComponent, selector: "spine-brand", inputs: ["homeRoute", "label"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
|
|
871
|
+
}
|
|
872
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: HeaderComponent, decorators: [{
|
|
873
|
+
type: Component,
|
|
874
|
+
args: [{ selector: 'spine-header', imports: [
|
|
875
|
+
CommonModule, FormsModule,
|
|
876
|
+
HeaderComponent$1, IconButtonComponent, IconComponent, InputComponent, MenuButtonComponent, AccountAvatarComponent, ModalComponent,
|
|
877
|
+
AppLauncherComponent, SpineBrandComponent,
|
|
878
|
+
], changeDetection: ChangeDetectionStrategy.Eager, template: "<hm-header [class.header-blocked]=\"headerBlocked\" (searchRequested)=\"onSearchRequested()\">\n <div *ngIf=\"headerBlocked\" class=\"header-overlay\"></div>\n\n <spine-app-launcher></spine-app-launcher>\n\n <!-- Marca: logo do app (SpineConfig.logo, padr\u00E3o assets/images/logo.svg) + SpineConfig.appName,\n levando \u00E0 primeira rota naveg\u00E1vel. Ver SpineBrandComponent. -->\n <spine-brand [homeRoute]=\"homeRoute\" [label]=\"text.home\" />\n\n <div actions class=\"h-right\">\n <!-- tema claro/escuro -->\n <hm-icon-button emphasis=\"subtle\" data-tour=\"theme\" (clicked)=\"setTheme(themeService.current == 'light' ? 'dark' : 'light')\"\n [icon]=\"themeService.current == 'light' ? 'moon' : 'sun'\"\n [label]=\"themeService.current == 'light' ? text.themeDark : text.themeLight\">\n </hm-icon-button>\n\n <!-- densidade -->\n <hm-icon-button emphasis=\"subtle\" data-tour=\"density\" (clicked)=\"setDensity()\"\n [icon]=\"isCompactDensity ? 'density-comfortable' : 'density-compact'\"\n [label]=\"isCompactDensity ? text.densityComfortable : text.densityCompact\">\n </hm-icon-button>\n\n <!-- tela cheia -->\n <hm-icon-button emphasis=\"subtle\" data-tour=\"fullscreen\" (clicked)=\"toggleFullscreen()\"\n [icon]=\"isFullscreen ? 'shrink' : 'resize'\"\n [label]=\"isFullscreen ? text.exitFullscreen : text.fullscreen\">\n </hm-icon-button>\n\n <span class=\"h-divider\" aria-hidden=\"true\"></span>\n\n <!-- perfil: crach\u00E1, ajuda, tema e sair ficam dentro do menu -->\n <hm-menu-button class=\"h-profile\" data-tour=\"profile\" [label]=\"profile.name ?? text.user\" align=\"end\" [items]=\"profileMenuItems\" (chosen)=\"onProfileMenuChosen($event)\">\n <hm-account-avatar [name]=\"profile.name ?? text.user\" [imageUrl]=\"profile.picture\"></hm-account-avatar>\n <span class=\"h-profile-info\">\n <span class=\"h-profile-name\">{{ profile.name ?? text.user }}</span>\n <span class=\"h-profile-role\" *ngIf=\"badge\">{{ badge?.name }}</span>\n </span>\n </hm-menu-button>\n </div>\n</hm-header>\n\n<!-- Busca global (quick-launcher) \u2014 mesmo padr\u00E3o do docs/website: modal aberto por\n (searchRequested), n\u00E3o um overlay pr\u00F3prio sempre presente no DOM. -->\n@if (quickOpen) {\n <hm-modal [open]=\"quickOpen\" (openChange)=\"onQuickModalOpenChange($event)\" [heading]=\"text.search\" size=\"medium\">\n <!-- Mesmo padr\u00E3o da busca do design system: hm-input + lista simples (nome \u00B7 onde). Setas/Enter navegam. -->\n <div class=\"search\" data-tour=\"search\" #quickBox\n (keydown.arrowdown)=\"$event.preventDefault(); quickMove(1)\"\n (keydown.arrowup)=\"$event.preventDefault(); quickMove(-1)\"\n (keydown.enter)=\"quickGo(quickResults[quickActive])\">\n <hm-input [label]=\"text.search\" [placeholder]=\"text.searchPlaceholder\" icon=\"search\"\n [(ngModel)]=\"quickQuery\" (ngModelChange)=\"onQuickSearch()\" />\n\n @if (showingRecent && quickResults.length) {\n <div class=\"search__recent\">\n <span>{{ text.recentSearches }}</span>\n <button type=\"button\" (mousedown)=\"clearRecent($event)\">{{ text.clearRecent }}</button>\n </div>\n }\n @if (quickResults.length) {\n <ul class=\"search__results\">\n @for (r of quickResults; track $index; let i = $index) {\n <li>\n <button type=\"button\" [class.active]=\"i === quickActive\" (mouseenter)=\"quickActive = i\" (mousedown)=\"quickGo(r)\">\n <strong><hm-icon [shape]=\"showingRecent ? 'history' : r.icon\" />{{ r.name }}</strong>\n @if (r.path) { <span>{{ r.path }}</span> }\n </button>\n </li>\n }\n </ul>\n } @else if (quickQuery) {\n <p class=\"search__empty\">{{ fmt(text.noResults, { term: quickQuery }) }}</p>\n }\n </div>\n </hm-modal>\n}\n\n", styles: ["@charset \"UTF-8\";:host{display:contents}.header-overlay{position:absolute;inset:0;z-index:9999;cursor:not-allowed}.h-btn{display:inline-flex;align-items:center;justify-content:center;gap:3px;height:40px;min-width:40px;padding:0 10px;border:none;background:transparent;cursor:pointer;border-radius:9px;text-decoration:none;color:var(--hm-navbar-text);outline:none}.h-btn:focus-visible,.h-btn:hover{background:var(--hm-navbar-hover)}.h-btn.active{background:var(--hm-navbar-hover)}.h-right{display:flex;align-items:center;gap:var(--hm-header-action-gap)}.h-divider{width:1px;height:24px;margin:0 4px;background:var(--hm-navbar-hover)}.h-profile{display:inline-flex;align-items:center;gap:6px;height:40px;padding:0 4px 0 2px}.h-profile-info{display:flex;flex-direction:column;min-width:0;max-width:140px;line-height:1.2;text-align:left}.h-profile-name{font-size:12.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.h-profile-role{color:color-mix(in srgb,var(--hm-navbar-text) 65%,transparent);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.search{display:grid;gap:var(--hm-space-sm);min-width:min(480px,70vw)}.search__recent{display:flex;align-items:center;justify-content:space-between;color:var(--hm-muted);font-size:var(--hm-font-size-xs)}.search__recent button{border:none;background:none;color:var(--hm-action);font:inherit;cursor:pointer}.search__recent button:hover{text-decoration:underline}.search__results{display:grid;gap:var(--hm-space-micro);max-height:320px;margin:0;padding:0;overflow-y:auto;list-style:none}.search__results button{display:flex;width:100%;align-items:baseline;justify-content:space-between;gap:var(--hm-space-sm);padding:var(--hm-space-nano) var(--hm-space-sm);border:none;border-radius:var(--hm-radius-control);background:none;color:var(--hm-text);cursor:pointer;text-align:start}.search__results button:hover,.search__results button:focus-visible,.search__results button.active{background:var(--hm-nav-hover)}.search__results button strong{display:inline-flex;align-items:center;gap:var(--hm-space-xs);font-weight:600}.search__results button strong hm-icon{color:var(--hm-muted)}.search__results button span{color:var(--hm-muted);font-size:var(--hm-font-size-xs);text-align:end}.search__empty{margin:0;padding:var(--hm-space-nano) var(--hm-space-sm);color:var(--hm-muted);font-size:var(--hm-font-size-sm)}\n"] }]
|
|
879
|
+
}], ctorParameters: () => [{ type: i1.AuthorizationService }, { type: i2$1.ThemeService }, { type: i2$1.ThemePresetService }, { type: i2$1.DensityService }, { type: i1.ViewService }, { type: i3.Router }, { type: OnboardingService }, { type: ShortcutService }, { type: undefined, decorators: [{
|
|
880
|
+
type: Optional
|
|
881
|
+
}, {
|
|
882
|
+
type: Inject,
|
|
883
|
+
args: [SPINE_CONFIG]
|
|
884
|
+
}] }], propDecorators: { quickBox: [{
|
|
885
|
+
type: ViewChild,
|
|
886
|
+
args: ['quickBox']
|
|
887
|
+
}] } });
|
|
888
|
+
|
|
889
|
+
const HOME_PATH = '/home';
|
|
890
|
+
/**
|
|
891
|
+
* Dono do `WorkspaceStore` do shell — mesmo padrão do `WorkspaceNavService` do docs/website
|
|
892
|
+
* (`packages/hermes-ui/docs/website/src/app/workspace-nav.service.ts`), adaptado pra ler a
|
|
893
|
+
* árvore de rotas do BACKEND (`ViewService.currentView`) em vez de um `page-registry.ts`
|
|
894
|
+
* estático. Cada página visitada vira uma aba, pertencente a um painel; a navegação real do
|
|
895
|
+
* `Router` (routerLink, busca, sidebar) sempre atualiza a aba ativa do painel FOCADO.
|
|
896
|
+
*/
|
|
897
|
+
class SpineWorkspaceNavService {
|
|
898
|
+
constructor() {
|
|
899
|
+
this.router = inject(Router);
|
|
900
|
+
this.viewService = inject(ViewService);
|
|
901
|
+
this.store = createPersistedWorkspaceStore({ tabs: [this.tabFor(HOME_PATH)], activeId: HOME_PATH }, (id) => (this.chainFor(id) ? this.tabFor(id) : null), inject(HERMES_WORKSPACE_PERSISTENCE_CONFIG));
|
|
902
|
+
this.state = signal(this.store.getState(), ...(ngDevMode ? [{ debugName: "state" }] : /* istanbul ignore next */ []));
|
|
903
|
+
this.root = computed(() => this.state().root, ...(ngDevMode ? [{ debugName: "root" }] : /* istanbul ignore next */ []));
|
|
904
|
+
this.focusedPanelId = computed(() => this.state().focusedPanelId, ...(ngDevMode ? [{ debugName: "focusedPanelId" }] : /* istanbul ignore next */ []));
|
|
905
|
+
/** Cadeia resolvida (header→…→page) da aba ativa do painel FOCADO — é o que `spine-sidebar`
|
|
906
|
+
* (menu) e `spine-navigation` (breadcrumb) leem pra saber "onde estamos", no lugar do antigo
|
|
907
|
+
* `viewService.logControl$`/`breadCrumb$` globais (que só existiam porque havia uma página
|
|
908
|
+
* "atual" só; agora cada painel tem a sua). */
|
|
909
|
+
this.focusedChain = computed(() => {
|
|
910
|
+
const s = this.state();
|
|
911
|
+
const activeId = findPanelTabs(s.root, s.focusedPanelId)?.activeId;
|
|
912
|
+
return activeId ? this.chainFor(activeId) : null;
|
|
913
|
+
}, ...(ngDevMode ? [{ debugName: "focusedChain" }] : /* istanbul ignore next */ []));
|
|
914
|
+
this.lastKnownPath = '';
|
|
915
|
+
this.store.subscribe((s) => this.state.set(s));
|
|
916
|
+
this.router.events.subscribe((event) => {
|
|
917
|
+
if (!(event instanceof NavigationEnd))
|
|
918
|
+
return;
|
|
919
|
+
const path = event.urlAfterRedirects.split(/[?#]/)[0] || HOME_PATH;
|
|
920
|
+
this.lastKnownPath = path;
|
|
921
|
+
this.store.navigateActiveTab(this.state().focusedPanelId, this.tabFor(event.urlAfterRedirects));
|
|
922
|
+
});
|
|
923
|
+
// Mesma corrida documentada no WorkspaceNavService do docs/website: pula a 1ª execução, senão
|
|
924
|
+
// o estado inicial (hardcoded ou recém-restaurado do sessionStorage) vence a navegação real
|
|
925
|
+
// (mais lenta, resolve async) e qualquer link direto/F5 cai sempre em HOME_PATH.
|
|
926
|
+
let first = true;
|
|
927
|
+
effect(() => {
|
|
928
|
+
const s = this.state();
|
|
929
|
+
const activeId = findPanelTabs(s.root, s.focusedPanelId)?.activeId;
|
|
930
|
+
if (first) {
|
|
931
|
+
first = false;
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (!activeId || activeId === this.lastKnownPath)
|
|
935
|
+
return;
|
|
936
|
+
this.lastKnownPath = activeId;
|
|
937
|
+
void this.router.navigateByUrl(activeId);
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
/** Resolve a cadeia de rotas pra uma URL, direto na view atual — sem `Router`, sem `async`
|
|
941
|
+
* (o componente da folha só é carregado por quem for de fato desenhar o painel). Desce sozinho
|
|
942
|
+
* por nós organizacionais (com filhos, sem conteúdo próprio) — o rótulo/ícone da aba e o que a
|
|
943
|
+
* sidebar/breadcrumb mostram refletem o destino DE VERDADE, não a rota-container clicada. */
|
|
944
|
+
chainFor(url) {
|
|
945
|
+
const currentRoutes = spineRoutesOf(this.viewService.currentView);
|
|
946
|
+
return resolveEffectiveRouteChain(currentRoutes, (u) => this.viewService.getLevel(u), (u, level) => this.viewService.getUrlAtLevel(u, level), url);
|
|
947
|
+
}
|
|
948
|
+
tabFor(url) {
|
|
949
|
+
const path = url.split(/[?#]/)[0] || HOME_PATH;
|
|
950
|
+
const leaf = this.chainFor(path)?.at(-1);
|
|
951
|
+
return {
|
|
952
|
+
id: path,
|
|
953
|
+
label: leaf?.route_display_name ?? path,
|
|
954
|
+
icon: leaf?.route_icon ?? undefined,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
/** O `+` de um painel: abre a home ali, como um navegador. */
|
|
958
|
+
openNewTab(panelId) {
|
|
959
|
+
this.store.openTab(panelId, this.tabFor(HOME_PATH));
|
|
960
|
+
}
|
|
961
|
+
/** Navegação LOCAL a um painel específico (usada pelo `PanelLocalLinkDirective`) — troca o
|
|
962
|
+
* ASSUNTO da aba ativa daquele painel, não empilha uma aba nova. */
|
|
963
|
+
navigateLocally(panelId, href) {
|
|
964
|
+
this.store.navigateActiveTab(panelId, this.tabFor(href));
|
|
965
|
+
}
|
|
966
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineWorkspaceNavService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
967
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineWorkspaceNavService, providedIn: 'root' }); }
|
|
968
|
+
}
|
|
969
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineWorkspaceNavService, decorators: [{
|
|
970
|
+
type: Injectable,
|
|
971
|
+
args: [{ providedIn: 'root' }]
|
|
972
|
+
}], ctorParameters: () => [] });
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* `Route` (árvore do backend) → `SidebarNode` (genérico, o que `hm-sidebar-node` sabe desenhar).
|
|
976
|
+
* Sem filtrar por `route_type`: a árvore real mistura tipos livremente por PAPEL, não por
|
|
977
|
+
* profundidade fixa (achado real, 2026-09-28, com a árvore de verdade em mãos — 'menu'/'item'/
|
|
978
|
+
* 'submenu'/'page' aparecem intercalados; um nó 'item' pode ter filhos 'submenu' que têm filhos
|
|
979
|
+
* 'page', por exemplo "Sistema"). `hm-sidebar-node` já decide sozinho link-vs-grupo pela presença
|
|
980
|
+
* de filhos + profundidade restante — não precisa saber o `route_type` pra nada.
|
|
981
|
+
*/
|
|
982
|
+
function toSidebarNode(route) {
|
|
983
|
+
return {
|
|
984
|
+
// Página: o router (a tela); grupo (sem router próprio): a rota.
|
|
985
|
+
id: routerIdOf(route) ?? route.routed_id,
|
|
986
|
+
label: route.route_display_name ?? '',
|
|
987
|
+
icon: route.route_icon ?? undefined,
|
|
988
|
+
route: route.route_redirect ?? '',
|
|
989
|
+
children: (route.routes ?? []).map(toSidebarNode),
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Menu lateral fixo — TODA a navegação do shell mora aqui agora, nunca no header (decisão
|
|
994
|
+
* explícita do usuário: "não deve ter ícones de navegação no menu superior, deve ter um menu
|
|
995
|
+
* lateral de navegação fixa" — o antigo `h-nav`/`hm-menu-button` de nível 'header'/'menu' do
|
|
996
|
+
* `header.component.html` foi removido, essa navegação virou `modules` abaixo).
|
|
997
|
+
*
|
|
998
|
+
* Dois níveis, um de cada vez — MESMO padrão de back/forward do `docs/website`'s sidebar (achado
|
|
999
|
+
* real, 2026-09-28: a primeira versão mostrava os dois SEMPRE juntos, empilhados, achando que o
|
|
1000
|
+
* modelo de rotas do backend não permitia "sair" de um módulo — errado; nada impede mostrar só um
|
|
1001
|
+
* nível por vez com um botão de voltar, é só um `signal` local):
|
|
1002
|
+
* - `modules` (nível 1, 'header', de `ViewService.currentView` direto — não muda com a navegação
|
|
1003
|
+
* do painel): a lista pra trocar de módulo.
|
|
1004
|
+
* - `menus`: a cadeia resolvida da aba ativa do painel FOCADO
|
|
1005
|
+
* (`SpineWorkspaceNavService.focusedChain`) — os itens do módulo ATUAL.
|
|
1006
|
+
* `viewingModules` decide qual dos dois aparece; volta pra `false` sozinho sempre que o módulo
|
|
1007
|
+
* atual muda (navegação real feita, mesma UX do `leaveModule()` do docs).
|
|
1008
|
+
*/
|
|
1009
|
+
class SidebarComponent {
|
|
1010
|
+
constructor() {
|
|
1011
|
+
this.text = inject(SPINE_MESSAGES).sidebar;
|
|
1012
|
+
this.fmt = formatMessage;
|
|
1013
|
+
this.workspace = inject(SpineWorkspaceNavService);
|
|
1014
|
+
this.viewService = inject(ViewService);
|
|
1015
|
+
this.authService = inject(AuthorizationService);
|
|
1016
|
+
this.realmApps = inject(RealmAppsService);
|
|
1017
|
+
/** Aplicações do realm (o app atual já vem excluído pelo backend) — exibidas só no mobile. */
|
|
1018
|
+
this.apps = signal([], ...(ngDevMode ? [{ debugName: "apps" }] : /* istanbul ignore next */ []));
|
|
1019
|
+
this.currentView = toSignal(this.viewService.currentView$, { initialValue: this.viewService.currentView });
|
|
1020
|
+
/** Sem nenhum crachá (nenhum grupo do app — ver `ViewGuard`, tela `/blocked`), o menu fica vazio
|
|
1021
|
+
* mesmo que a view traga rotas públicas: listar o que leva de volta ao "Acesso bloqueado" não
|
|
1022
|
+
* ajuda. `badges` não é sinal, mas a view sempre é refeita depois do token (`view.update`), e é
|
|
1023
|
+
* ela que recalcula isto. */
|
|
1024
|
+
this.modules = computed(() => {
|
|
1025
|
+
// Irmãos de mesmo endereço já fundidos: um "laboratorio" só, não um por nível (ver `mergeRouteTree`).
|
|
1026
|
+
const routes = spineRoutesOf(this.currentView());
|
|
1027
|
+
if (this.authService.authenticated && !this.authService.badges.length)
|
|
1028
|
+
return [];
|
|
1029
|
+
return routes.map(toSidebarNode);
|
|
1030
|
+
}, ...(ngDevMode ? [{ debugName: "modules" }] : /* istanbul ignore next */ []));
|
|
1031
|
+
this.currentModule = computed(() => this.workspace.focusedChain()?.[0] ?? null, ...(ngDevMode ? [{ debugName: "currentModule" }] : /* istanbul ignore next */ []));
|
|
1032
|
+
/** Filhos diretos do módulo atual — sem filtrar por `route_type` (ver `toSidebarNode`). */
|
|
1033
|
+
this.menus = computed(() => (this.currentModule()?.routes ?? []).map(toSidebarNode), ...(ngDevMode ? [{ debugName: "menus" }] : /* istanbul ignore next */ []));
|
|
1034
|
+
/** Nem todo nível 1 é um "módulo" navegável — "Início"/"Monitoração" na árvore real são
|
|
1035
|
+
* `route_type: 'page'` SEM filhos, folhas diretas (achado real, 2026-09-28: mostravam
|
|
1036
|
+
* cabeçalho + botão voltar + área de conteúdo vazia, como se fossem módulos de verdade). Só
|
|
1037
|
+
* entra no modo "dentro do módulo" quando há algo pra mostrar como submenu. */
|
|
1038
|
+
this.currentModuleHasChildren = computed(() => (this.currentModule()?.routes?.length ?? 0) > 0, ...(ngDevMode ? [{ debugName: "currentModuleHasChildren" }] : /* istanbul ignore next */ []));
|
|
1039
|
+
/** `true` = mostrando a lista de módulos; `false` = mostrando o menu do módulo atual. Cai pra
|
|
1040
|
+
* `true` sozinho quando o módulo atual não tem submenu nenhum — nesse caso não existe "dentro
|
|
1041
|
+
* do módulo" pra mostrar. */
|
|
1042
|
+
this.wantsModules = signal(false, ...(ngDevMode ? [{ debugName: "wantsModules" }] : /* istanbul ignore next */ []));
|
|
1043
|
+
this.viewingModules = computed(() => this.wantsModules() || !this.currentModuleHasChildren(), ...(ngDevMode ? [{ debugName: "viewingModules" }] : /* istanbul ignore next */ []));
|
|
1044
|
+
/** Sem teto artificial de nível de grupo — o template não enxerga o global `Infinity`, só
|
|
1045
|
+
* membros da classe. Ver comentário no `.html`: o backend já limita a árvore sozinho. */
|
|
1046
|
+
this.maxDepth = Infinity;
|
|
1047
|
+
void this.realmApps.getApplications(this.authService.getToken()).then(list => this.apps.set(list ?? []), () => this.apps.set([]));
|
|
1048
|
+
// Navegou de verdade (clicou num módulo, ou entrou direto por link/F5) -> volta pro menu do
|
|
1049
|
+
// módulo novo. Só faz sentido reagir à IDENTIDADE do módulo (router da página ou, num grupo, a
|
|
1050
|
+
// rota -- módulo costuma ser grupo, sem router próprio), não ao objeto inteiro
|
|
1051
|
+
// (a cadeia inteira muda a cada troca de aba dentro do MESMO módulo também).
|
|
1052
|
+
let lastModuleId;
|
|
1053
|
+
effect(() => {
|
|
1054
|
+
const module = this.currentModule();
|
|
1055
|
+
const id = module ? routerIdOf(module) ?? module.routed_id : null;
|
|
1056
|
+
if (id !== lastModuleId) {
|
|
1057
|
+
lastModuleId = id;
|
|
1058
|
+
this.wantsModules.set(false);
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
// O efeito acima não cobre voltar pro MESMO módulo: dentro de "Sistema", "← Módulos" e
|
|
1062
|
+
// clicar em "Sistema" de novo navega (a rota muda), mas a identidade do módulo é a mesma — o
|
|
1063
|
+
// efeito não dispara e a lista de módulos ficava na tela (achado real, 2026-09-29: só
|
|
1064
|
+
// funcionava passando por outro módulo antes). Qualquer navegação concluída é um "entrar".
|
|
1065
|
+
// NavigationSkipped também: clicar no módulo cuja página já está aberta cai no ViewGuard
|
|
1066
|
+
// (`/sys` cancela e redireciona pra `/sys/about/resources`), e o redirect pra URL ATUAL é
|
|
1067
|
+
// descartado pelo Router como "mesma URL" — não emite NavigationEnd nenhum.
|
|
1068
|
+
inject(Router).events.pipe(filter(e => e instanceof NavigationEnd || e instanceof NavigationSkipped), takeUntilDestroyed()).subscribe(() => this.wantsModules.set(false));
|
|
1069
|
+
}
|
|
1070
|
+
showModules() {
|
|
1071
|
+
this.wantsModules.set(true);
|
|
1072
|
+
}
|
|
1073
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SidebarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
1074
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: SidebarComponent, isStandalone: true, selector: "spine-sidebar", ngImport: i0, template: "<hm-sidebar\n *ngIf=\"modules().length != 0 || menus().length != 0\"\n [label]=\"!viewingModules() && currentModule() ? fmt(text.moduleNavigation, { module: currentModule()!.route_display_name ?? '' }) : text.modules\"\n [heading]=\"!viewingModules() ? (currentModule()?.route_display_name ?? '') : ''\"\n [headingIcon]=\"currentModule()?.route_icon ?? 'grid'\"\n [backLabel]=\"!viewingModules() && modules().length > 1 ? text.modules : ''\"\n [direction]=\"viewingModules() ? 'back' : 'forward'\"\n (back)=\"showModules()\"\n>\n <!-- S\u00F3 no mobile: o seletor de aplica\u00E7\u00F5es do header some e vem para c\u00E1. -->\n @if (apps().length) {\n <section class=\"spine-sidebar-apps\" [attr.aria-label]=\"text.apps\">\n <p class=\"spine-sidebar-apps__title\"><hm-icon shape=\"grid\" /> {{ text.apps }}</p>\n @for (app of apps(); track app.app_url) {\n <a class=\"spine-sidebar-apps__link\" [href]=\"app.app_url\" target=\"_blank\" rel=\"noopener noreferrer\">\n <hm-icon shape=\"applications\" />\n <span>{{ app.name }}</span>\n </a>\n }\n </section>\n }\n @if (viewingModules()) {\n @for (m of modules(); track m.id) {\n <hm-sidebar-node [node]=\"m\" [depth]=\"0\" />\n }\n } @else {\n @for (menu of menus(); track menu.id) {\n <!-- Infinity, n\u00E3o um n\u00FAmero fixo: o backend do spine j\u00E1 usa `route_type` pra limitar a\n \u00E1rvore a 6 n\u00EDveis (header\u2192navigation\u2192menu\u2192item\u2192submenu\u2192page \u2014 ver `route.model.ts`),\n ent\u00E3o n\u00E3o h\u00E1 necessidade de um segundo teto aqui. Um teto baixo (achado ao vivo,\n 2026-09-28: `depth=2`) for\u00E7ava o pen\u00FAltimo n\u00EDvel a virar link plano mesmo tendo\n filhos reais (ex.: \"submenu\" com p\u00E1ginas embaixo sumia da \u00E1rvore vis\u00EDvel). -->\n <hm-sidebar-node [node]=\"menu\" [depth]=\"maxDepth\" />\n }\n }\n</hm-sidebar>\n", styles: [".spine-sidebar-apps{display:none}@media(max-width:767px){.spine-sidebar-apps{display:flex;flex-direction:column;gap:var(--hm-space-micro);margin-bottom:var(--hm-space-sm);padding-bottom:var(--hm-space-sm);border-bottom:var(--hm-border-hairline) solid var(--hm-divider)}.spine-sidebar-apps__title{display:flex;align-items:center;gap:var(--hm-space-nano);margin:0 var(--hm-space-xxxs);color:var(--hm-muted);font-size:var(--hm-font-size-xs);font-weight:var(--hm-font-weight-semibold);letter-spacing:.04em;text-transform:uppercase}.spine-sidebar-apps__link{display:flex;align-items:center;gap:var(--hm-space-nano);min-height:var(--hm-space-2xl);padding:var(--hm-space-nano) var(--hm-space-xxxs);border-radius:var(--hm-radius-control);color:inherit;font-size:var(--hm-font-size-sm);text-decoration:none}.spine-sidebar-apps__link:hover,.spine-sidebar-apps__link:focus-visible{background:var(--hm-hover)}}:host{display:flex;flex-direction:column;height:100%}:host ::ng-deep .hm-sidebar{height:100%;flex-grow:1}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: SidebarComponent$1, selector: "hm-sidebar", inputs: ["label", "heading", "headingIcon", "backLabel", "direction", "collapsible"], outputs: ["back"] }, { kind: "component", type: SidebarNodeComponent, selector: "hm-sidebar-node", inputs: ["node", "depth"] }, { kind: "component", type: IconComponent, selector: "hm-icon", inputs: ["shape", "direction", "size", "solid", "badge", "status", "flip", "inverse", "label"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
1075
|
+
}
|
|
1076
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SidebarComponent, decorators: [{
|
|
1077
|
+
type: Component,
|
|
1078
|
+
args: [{ selector: 'spine-sidebar', imports: [CommonModule, SidebarComponent$1, SidebarNodeComponent, IconComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: "<hm-sidebar\n *ngIf=\"modules().length != 0 || menus().length != 0\"\n [label]=\"!viewingModules() && currentModule() ? fmt(text.moduleNavigation, { module: currentModule()!.route_display_name ?? '' }) : text.modules\"\n [heading]=\"!viewingModules() ? (currentModule()?.route_display_name ?? '') : ''\"\n [headingIcon]=\"currentModule()?.route_icon ?? 'grid'\"\n [backLabel]=\"!viewingModules() && modules().length > 1 ? text.modules : ''\"\n [direction]=\"viewingModules() ? 'back' : 'forward'\"\n (back)=\"showModules()\"\n>\n <!-- S\u00F3 no mobile: o seletor de aplica\u00E7\u00F5es do header some e vem para c\u00E1. -->\n @if (apps().length) {\n <section class=\"spine-sidebar-apps\" [attr.aria-label]=\"text.apps\">\n <p class=\"spine-sidebar-apps__title\"><hm-icon shape=\"grid\" /> {{ text.apps }}</p>\n @for (app of apps(); track app.app_url) {\n <a class=\"spine-sidebar-apps__link\" [href]=\"app.app_url\" target=\"_blank\" rel=\"noopener noreferrer\">\n <hm-icon shape=\"applications\" />\n <span>{{ app.name }}</span>\n </a>\n }\n </section>\n }\n @if (viewingModules()) {\n @for (m of modules(); track m.id) {\n <hm-sidebar-node [node]=\"m\" [depth]=\"0\" />\n }\n } @else {\n @for (menu of menus(); track menu.id) {\n <!-- Infinity, n\u00E3o um n\u00FAmero fixo: o backend do spine j\u00E1 usa `route_type` pra limitar a\n \u00E1rvore a 6 n\u00EDveis (header\u2192navigation\u2192menu\u2192item\u2192submenu\u2192page \u2014 ver `route.model.ts`),\n ent\u00E3o n\u00E3o h\u00E1 necessidade de um segundo teto aqui. Um teto baixo (achado ao vivo,\n 2026-09-28: `depth=2`) for\u00E7ava o pen\u00FAltimo n\u00EDvel a virar link plano mesmo tendo\n filhos reais (ex.: \"submenu\" com p\u00E1ginas embaixo sumia da \u00E1rvore vis\u00EDvel). -->\n <hm-sidebar-node [node]=\"menu\" [depth]=\"maxDepth\" />\n }\n }\n</hm-sidebar>\n", styles: [".spine-sidebar-apps{display:none}@media(max-width:767px){.spine-sidebar-apps{display:flex;flex-direction:column;gap:var(--hm-space-micro);margin-bottom:var(--hm-space-sm);padding-bottom:var(--hm-space-sm);border-bottom:var(--hm-border-hairline) solid var(--hm-divider)}.spine-sidebar-apps__title{display:flex;align-items:center;gap:var(--hm-space-nano);margin:0 var(--hm-space-xxxs);color:var(--hm-muted);font-size:var(--hm-font-size-xs);font-weight:var(--hm-font-weight-semibold);letter-spacing:.04em;text-transform:uppercase}.spine-sidebar-apps__link{display:flex;align-items:center;gap:var(--hm-space-nano);min-height:var(--hm-space-2xl);padding:var(--hm-space-nano) var(--hm-space-xxxs);border-radius:var(--hm-radius-control);color:inherit;font-size:var(--hm-font-size-sm);text-decoration:none}.spine-sidebar-apps__link:hover,.spine-sidebar-apps__link:focus-visible{background:var(--hm-hover)}}:host{display:flex;flex-direction:column;height:100%}:host ::ng-deep .hm-sidebar{height:100%;flex-grow:1}\n"] }]
|
|
1079
|
+
}], ctorParameters: () => [] });
|
|
1080
|
+
|
|
1081
|
+
// Geometria do tour: o recorte iluminado (holofote) em volta do alvo e onde o balão cabe. Funções
|
|
1082
|
+
// puras, sem DOM — o componente mede e aplica.
|
|
1083
|
+
/** Folga do holofote em volta do alvo (cada lado). */
|
|
1084
|
+
const TOUR_SPOT_PADDING = 6;
|
|
1085
|
+
/** Distância entre o holofote e o balão. */
|
|
1086
|
+
const TOUR_CARD_GAP = 14;
|
|
1087
|
+
/** Distância mínima do balão às bordas da janela — a mesma do `max-width: calc(100vw - 24px)` do SCSS. */
|
|
1088
|
+
const TOUR_EDGE = 12;
|
|
1089
|
+
/** Largura do balão no SCSS (`.ob-card`), usada enquanto ele ainda não foi medido. */
|
|
1090
|
+
const TOUR_CARD_WIDTH = 332;
|
|
1091
|
+
/** Altura provável do balão antes da primeira medida (título + duas linhas + rodapé). */
|
|
1092
|
+
const TOUR_CARD_HEIGHT_GUESS = 190;
|
|
1093
|
+
/** Holofote: o retângulo do alvo com a mesma folga nos quatro lados. */
|
|
1094
|
+
function tourSpotlight(target, padding = TOUR_SPOT_PADDING) {
|
|
1095
|
+
return {
|
|
1096
|
+
top: target.top - padding,
|
|
1097
|
+
left: target.left - padding,
|
|
1098
|
+
width: target.width + padding * 2,
|
|
1099
|
+
height: target.height + padding * 2,
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
/** Largura que o balão de fato ocupa: a do CSS, limitada pela janela menos as margens. */
|
|
1103
|
+
function tourCardWidth(width, viewport) {
|
|
1104
|
+
return Math.max(0, Math.min(width, viewport.width - TOUR_EDGE * 2));
|
|
1105
|
+
}
|
|
1106
|
+
/** Prende `value` entre a margem e o maior início que ainda deixa `size` dentro de `room`. */
|
|
1107
|
+
function clampStart(value, size, room) {
|
|
1108
|
+
return Math.max(TOUR_EDGE, Math.min(value, room - TOUR_EDGE - size));
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* Onde o balão fica. Sem holofote: no meio da janela (`centered`). Com holofote, a primeira posição que
|
|
1112
|
+
* cabe: embaixo, em cima (ambas alinhadas à esquerda do holofote), à direita, à esquerda (ambas
|
|
1113
|
+
* alinhadas ao topo dele) e, se nada couber, no meio da janela por cima do alvo.
|
|
1114
|
+
*/
|
|
1115
|
+
function placeTourCard(spot, card, viewport) {
|
|
1116
|
+
const width = tourCardWidth(card.width, viewport);
|
|
1117
|
+
const height = card.height;
|
|
1118
|
+
const centerTop = Math.max(TOUR_EDGE, (viewport.height - height) / 2);
|
|
1119
|
+
const centerLeft = Math.max(TOUR_EDGE, (viewport.width - width) / 2);
|
|
1120
|
+
if (!spot)
|
|
1121
|
+
return { top: centerTop, left: centerLeft, centered: true };
|
|
1122
|
+
const left = clampStart(spot.left, width, viewport.width);
|
|
1123
|
+
const top = clampStart(spot.top, height, viewport.height);
|
|
1124
|
+
const below = spot.top + spot.height + TOUR_CARD_GAP;
|
|
1125
|
+
if (below + height <= viewport.height - TOUR_EDGE)
|
|
1126
|
+
return { top: below, left, centered: false };
|
|
1127
|
+
const above = spot.top - TOUR_CARD_GAP - height;
|
|
1128
|
+
if (above >= TOUR_EDGE)
|
|
1129
|
+
return { top: above, left, centered: false };
|
|
1130
|
+
const right = spot.left + spot.width + TOUR_CARD_GAP;
|
|
1131
|
+
if (right + width <= viewport.width - TOUR_EDGE)
|
|
1132
|
+
return { top, left: right, centered: false };
|
|
1133
|
+
const leftSide = spot.left - TOUR_CARD_GAP - width;
|
|
1134
|
+
if (leftSide >= TOUR_EDGE)
|
|
1135
|
+
return { top, left: leftSide, centered: false };
|
|
1136
|
+
return {
|
|
1137
|
+
top: clampStart(centerTop, height, viewport.height),
|
|
1138
|
+
left: clampStart(centerLeft, width, viewport.width),
|
|
1139
|
+
centered: false,
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Desenho do tour guiado: escurece a tela, recorta um holofote sobre o alvo do passo e põe o balão
|
|
1145
|
+
* (título, texto, progresso e navegação) onde couber. Segue o `OnboardingService`; recalcula a cada
|
|
1146
|
+
* passo e quando a janela rola ou muda de tamanho.
|
|
1147
|
+
*/
|
|
1148
|
+
class OnboardingOverlayComponent {
|
|
1149
|
+
constructor(onboarding) {
|
|
1150
|
+
this.onboarding = onboarding;
|
|
1151
|
+
this.text = inject(SPINE_MESSAGES).tour;
|
|
1152
|
+
this.fmt = formatMessage;
|
|
1153
|
+
this.step = null;
|
|
1154
|
+
this.index = 0;
|
|
1155
|
+
this.total = 0;
|
|
1156
|
+
/** holofote (null = passo centralizado, sem recorte). */
|
|
1157
|
+
this.spot = null;
|
|
1158
|
+
/** posição do balão. */
|
|
1159
|
+
this.card = { top: 0, left: 0, centered: true };
|
|
1160
|
+
this.cdr = inject(ChangeDetectorRef);
|
|
1161
|
+
this.injector = inject(Injector);
|
|
1162
|
+
}
|
|
1163
|
+
get isFirst() { return this.onboarding.isFirst; }
|
|
1164
|
+
get isLast() { return this.onboarding.isLast; }
|
|
1165
|
+
ngOnInit() {
|
|
1166
|
+
this.sub = this.onboarding.current$.subscribe((cur) => {
|
|
1167
|
+
// O passo chega de fora da detecção de mudanças (timer do serviço, clique no balão): marca a
|
|
1168
|
+
// view, senão um app sem zone.js não redesenha.
|
|
1169
|
+
this.cdr.markForCheck();
|
|
1170
|
+
if (!cur) {
|
|
1171
|
+
this.step = null;
|
|
1172
|
+
this.spot = null;
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
this.step = cur.step;
|
|
1176
|
+
this.index = cur.index;
|
|
1177
|
+
this.total = cur.total;
|
|
1178
|
+
this.layout();
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
ngOnDestroy() {
|
|
1182
|
+
this.sub?.unsubscribe();
|
|
1183
|
+
this.reposition?.destroy();
|
|
1184
|
+
}
|
|
1185
|
+
/** Holofote e balão do passo atual: primeiro com a altura estimada, depois com a medida real. */
|
|
1186
|
+
layout() {
|
|
1187
|
+
const step = this.step;
|
|
1188
|
+
if (!step)
|
|
1189
|
+
return;
|
|
1190
|
+
const target = step.target ? findTourTarget(step.target) : null;
|
|
1191
|
+
if (target)
|
|
1192
|
+
this.bringIntoView(target);
|
|
1193
|
+
this.spot = target ? tourSpotlight(target.getBoundingClientRect()) : null;
|
|
1194
|
+
this.card = placeTourCard(this.spot, this.cardSize(TOUR_CARD_HEIGHT_GUESS), this.viewport());
|
|
1195
|
+
this.positionCard();
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* Refina a posição depois que o balão foi desenhado (a altura depende do texto). Roda depois do
|
|
1199
|
+
* render e só marca a view se a posição mudou: sem NG0100 e sem precisar do zone.js.
|
|
1200
|
+
*/
|
|
1201
|
+
positionCard() {
|
|
1202
|
+
this.reposition?.destroy();
|
|
1203
|
+
this.reposition = afterNextRender({
|
|
1204
|
+
read: () => {
|
|
1205
|
+
const height = this.cardRef?.nativeElement.offsetHeight ?? 0;
|
|
1206
|
+
if (!this.step || !height)
|
|
1207
|
+
return;
|
|
1208
|
+
const next = placeTourCard(this.spot, this.cardSize(height), this.viewport());
|
|
1209
|
+
if (!this.samePosition(next)) {
|
|
1210
|
+
this.card = next;
|
|
1211
|
+
this.cdr.markForCheck();
|
|
1212
|
+
}
|
|
1213
|
+
},
|
|
1214
|
+
}, { injector: this.injector });
|
|
1215
|
+
}
|
|
1216
|
+
/** Alvo fora da faixa visível: rola até o meio antes de medir (o `scroll` da janela refaz a conta). */
|
|
1217
|
+
bringIntoView(target) {
|
|
1218
|
+
const rect = target.getBoundingClientRect();
|
|
1219
|
+
if (rect.top >= 0 && rect.bottom <= window.innerHeight)
|
|
1220
|
+
return;
|
|
1221
|
+
const calm = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
1222
|
+
target.scrollIntoView({ block: 'center', inline: 'nearest', behavior: calm ? 'auto' : 'smooth' });
|
|
1223
|
+
}
|
|
1224
|
+
/** Largura que o CSS dá ao balão (medida quando já existe) e a altura informada. */
|
|
1225
|
+
cardSize(height) {
|
|
1226
|
+
const measured = this.cardRef?.nativeElement.offsetWidth ?? 0;
|
|
1227
|
+
return { width: measured || TOUR_CARD_WIDTH, height };
|
|
1228
|
+
}
|
|
1229
|
+
viewport() {
|
|
1230
|
+
return { width: window.innerWidth, height: window.innerHeight };
|
|
1231
|
+
}
|
|
1232
|
+
samePosition(next) {
|
|
1233
|
+
return next.top === this.card.top && next.left === this.card.left && next.centered === this.card.centered;
|
|
1234
|
+
}
|
|
1235
|
+
onViewportChange() {
|
|
1236
|
+
if (this.step)
|
|
1237
|
+
this.layout();
|
|
1238
|
+
}
|
|
1239
|
+
// ── ações ──
|
|
1240
|
+
next() { this.onboarding.next(); }
|
|
1241
|
+
prev() { this.onboarding.prev(); }
|
|
1242
|
+
skip() { this.onboarding.finish(); }
|
|
1243
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: OnboardingOverlayComponent, deps: [{ token: OnboardingService }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
1244
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.24", type: OnboardingOverlayComponent, isStandalone: true, selector: "spine-onboarding-overlay", host: { listeners: { "window:resize": "onViewportChange()", "window:scroll": "onViewportChange()" } }, viewQueries: [{ propertyName: "cardRef", first: true, predicate: ["cardEl"], descendants: true }], ngImport: i0, template: "<div class=\"ob-root\" *ngIf=\"step\">\n <!-- Holofote: recorte iluminado sobre o alvo (o escuro vem do box-shadow gigante). -->\n <div class=\"ob-spot\" *ngIf=\"spot\"\n [style.top.px]=\"spot.top\" [style.left.px]=\"spot.left\"\n [style.width.px]=\"spot.width\" [style.height.px]=\"spot.height\"></div>\n\n <!-- Passos centralizados (sem alvo): escurece a tela inteira. -->\n <div class=\"ob-dim\" *ngIf=\"!spot\"></div>\n\n <!-- Bal\u00E3o -->\n <div #cardEl class=\"ob-card\" [class.is-centered]=\"card.centered\"\n [style.top.px]=\"card.top\" [style.left.px]=\"card.left\">\n <div class=\"ob-card__head\">\n <h3 class=\"ob-card__title\">{{ step.title }}</h3>\n <span class=\"ob-progress\">{{ fmt(text.progress, { current: index + 1, total: total }) }}</span>\n </div>\n <p class=\"ob-card__desc\" [innerHTML]=\"step.description\"></p>\n\n <div class=\"ob-card__foot\">\n <hm-button class=\"ob-skip\" emphasis=\"minimal\" (clicked)=\"skip()\">{{ text.skip }}</hm-button>\n <div class=\"ob-card__foot-actions\">\n <hm-button *ngIf=\"!isFirst\" emphasis=\"medium\" (clicked)=\"prev()\">{{ text.back }}</hm-button>\n <hm-button (clicked)=\"next()\">\n {{ isLast ? text.finish : text.next }}\n </hm-button>\n </div>\n </div>\n </div>\n</div>\n", styles: ["@charset \"UTF-8\";:host{--ob-overlay: rgba(0, 0, 0, .55)}.ob-root{position:fixed;inset:0;z-index:10000}.ob-dim{position:absolute;inset:0;background:var(--ob-overlay)}.ob-spot{position:fixed;border-radius:var(--hm-radius-control);box-shadow:0 0 0 9999px var(--ob-overlay);pointer-events:none;transition:top .25s ease,left .25s ease,width .25s ease,height .25s ease}.ob-card{position:fixed;width:332px;max-width:calc(100vw - 24px);background:var(--hm-surface);color:var(--hm-text);border:1px solid var(--hm-border);border-radius:var(--hm-radius-control);padding:20px;box-shadow:var(--hm-shadow-lg, 0 14px 38px rgba(0, 36, 65, .22));transition:top .2s ease,left .2s ease}.ob-card__head{display:flex;align-items:baseline;gap:8px;margin-bottom:8px}.ob-card__title{margin:0;min-width:0;flex:1 1 auto;font-size:16px;font-weight:700;line-height:1.3;color:var(--hm-text)}.ob-card__desc{margin:0;font-size:13px;line-height:1.6;color:var(--hm-muted)}.ob-card__desc ::ng-deep b{color:var(--hm-text);font-weight:700}.ob-card__foot{display:flex;align-items:center;gap:8px;margin-top:18px}.ob-card__foot-actions{display:flex;align-items:center;gap:8px;margin-left:auto}.ob-progress{flex:none;font-size:11px;font-weight:600;letter-spacing:.03em;color:var(--hm-muted);white-space:nowrap}.ob-skip{margin-right:auto}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: ButtonComponent, selector: "hm-button", inputs: ["emphasis", "intent", "size", "type", "icon", "iconDirection", "iconEnd", "disabled", "loading", "busy", "loadingState", "block", "link", "newTab", "caret", "inMenu", "toggle", "pressed"], outputs: ["loadingStateChange", "pressedChange", "clicked"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
|
|
1245
|
+
}
|
|
1246
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: OnboardingOverlayComponent, decorators: [{
|
|
1247
|
+
type: Component,
|
|
1248
|
+
args: [{ selector: 'spine-onboarding-overlay', imports: [CommonModule, ButtonComponent], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"ob-root\" *ngIf=\"step\">\n <!-- Holofote: recorte iluminado sobre o alvo (o escuro vem do box-shadow gigante). -->\n <div class=\"ob-spot\" *ngIf=\"spot\"\n [style.top.px]=\"spot.top\" [style.left.px]=\"spot.left\"\n [style.width.px]=\"spot.width\" [style.height.px]=\"spot.height\"></div>\n\n <!-- Passos centralizados (sem alvo): escurece a tela inteira. -->\n <div class=\"ob-dim\" *ngIf=\"!spot\"></div>\n\n <!-- Bal\u00E3o -->\n <div #cardEl class=\"ob-card\" [class.is-centered]=\"card.centered\"\n [style.top.px]=\"card.top\" [style.left.px]=\"card.left\">\n <div class=\"ob-card__head\">\n <h3 class=\"ob-card__title\">{{ step.title }}</h3>\n <span class=\"ob-progress\">{{ fmt(text.progress, { current: index + 1, total: total }) }}</span>\n </div>\n <p class=\"ob-card__desc\" [innerHTML]=\"step.description\"></p>\n\n <div class=\"ob-card__foot\">\n <hm-button class=\"ob-skip\" emphasis=\"minimal\" (clicked)=\"skip()\">{{ text.skip }}</hm-button>\n <div class=\"ob-card__foot-actions\">\n <hm-button *ngIf=\"!isFirst\" emphasis=\"medium\" (clicked)=\"prev()\">{{ text.back }}</hm-button>\n <hm-button (clicked)=\"next()\">\n {{ isLast ? text.finish : text.next }}\n </hm-button>\n </div>\n </div>\n </div>\n</div>\n", styles: ["@charset \"UTF-8\";:host{--ob-overlay: rgba(0, 0, 0, .55)}.ob-root{position:fixed;inset:0;z-index:10000}.ob-dim{position:absolute;inset:0;background:var(--ob-overlay)}.ob-spot{position:fixed;border-radius:var(--hm-radius-control);box-shadow:0 0 0 9999px var(--ob-overlay);pointer-events:none;transition:top .25s ease,left .25s ease,width .25s ease,height .25s ease}.ob-card{position:fixed;width:332px;max-width:calc(100vw - 24px);background:var(--hm-surface);color:var(--hm-text);border:1px solid var(--hm-border);border-radius:var(--hm-radius-control);padding:20px;box-shadow:var(--hm-shadow-lg, 0 14px 38px rgba(0, 36, 65, .22));transition:top .2s ease,left .2s ease}.ob-card__head{display:flex;align-items:baseline;gap:8px;margin-bottom:8px}.ob-card__title{margin:0;min-width:0;flex:1 1 auto;font-size:16px;font-weight:700;line-height:1.3;color:var(--hm-text)}.ob-card__desc{margin:0;font-size:13px;line-height:1.6;color:var(--hm-muted)}.ob-card__desc ::ng-deep b{color:var(--hm-text);font-weight:700}.ob-card__foot{display:flex;align-items:center;gap:8px;margin-top:18px}.ob-card__foot-actions{display:flex;align-items:center;gap:8px;margin-left:auto}.ob-progress{flex:none;font-size:11px;font-weight:600;letter-spacing:.03em;color:var(--hm-muted);white-space:nowrap}.ob-skip{margin-right:auto}\n"] }]
|
|
1249
|
+
}], ctorParameters: () => [{ type: OnboardingService }], propDecorators: { cardRef: [{
|
|
1250
|
+
type: ViewChild,
|
|
1251
|
+
args: ['cardEl']
|
|
1252
|
+
}], onViewportChange: [{
|
|
1253
|
+
type: HostListener,
|
|
1254
|
+
args: ['window:resize']
|
|
1255
|
+
}, {
|
|
1256
|
+
type: HostListener,
|
|
1257
|
+
args: ['window:scroll']
|
|
1258
|
+
}] } });
|
|
1259
|
+
|
|
1260
|
+
const LIGHT_FALLBACK = {
|
|
1261
|
+
background: '#f3f4f6',
|
|
1262
|
+
icon: '#2f6fdb',
|
|
1263
|
+
badge: '#ffffff',
|
|
1264
|
+
badgeBorder: '#d5d9e0',
|
|
1265
|
+
label: '#1e2329',
|
|
1266
|
+
font: 'sans-serif',
|
|
1267
|
+
};
|
|
1268
|
+
function readNewsPalette(doc) {
|
|
1269
|
+
const style = doc.body ? doc.defaultView?.getComputedStyle(doc.body) : undefined;
|
|
1270
|
+
const pick = (prop, fallback) => (style?.getPropertyValue(prop) ?? '').trim() || fallback;
|
|
1271
|
+
return {
|
|
1272
|
+
background: pick('--hm-surface-raised', LIGHT_FALLBACK.background),
|
|
1273
|
+
icon: pick('--hm-accent', LIGHT_FALLBACK.icon),
|
|
1274
|
+
badge: pick('--hm-surface', LIGHT_FALLBACK.badge),
|
|
1275
|
+
badgeBorder: pick('--hm-divider', LIGHT_FALLBACK.badgeBorder),
|
|
1276
|
+
label: pick('--hm-text', LIGHT_FALLBACK.label),
|
|
1277
|
+
font: (style?.fontFamily ?? '').trim() || LIGHT_FALLBACK.font,
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
const XML_ENTITIES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
1281
|
+
function xml(value) {
|
|
1282
|
+
return value.replace(/[&<>"']/g, (ch) => XML_ENTITIES[ch]);
|
|
1283
|
+
}
|
|
1284
|
+
const WIDTH = 640;
|
|
1285
|
+
const HEIGHT = 360;
|
|
1286
|
+
const CX = WIDTH / 2;
|
|
1287
|
+
const CY = 168;
|
|
1288
|
+
/** Places a catalog icon (24-unit grid, stroked with currentColor) as a nested, tinted <svg>. */
|
|
1289
|
+
function icon(shape, x, y, size, color) {
|
|
1290
|
+
return renderIconSvg(shape).replace(/^<svg\b/, `<svg x="${x}" y="${y}" width="${size}" height="${size}" color="${xml(color)}"`);
|
|
1291
|
+
}
|
|
1292
|
+
/** Four-point twinkle, the "something new" cue drawn around the hero icon. */
|
|
1293
|
+
function sparkle(x, y, r, color, opacity) {
|
|
1294
|
+
const k = r * 0.28;
|
|
1295
|
+
const d = `M${x} ${y - r}Q${x + k} ${y - k} ${x + r} ${y}Q${x + k} ${y + k} ${x} ${y + r}Q${x - k} ${y + k} ${x - r} ${y}Q${x - k} ${y - k} ${x} ${y - r}Z`;
|
|
1296
|
+
return `<path d="${d}" fill="${xml(color)}" opacity="${opacity}"/>`;
|
|
1297
|
+
}
|
|
1298
|
+
function round(n) {
|
|
1299
|
+
return Math.round(n * 10) / 10;
|
|
1300
|
+
}
|
|
1301
|
+
function buildNewsIllustration(illustration, palette) {
|
|
1302
|
+
const satellites = illustration.satellites ?? [];
|
|
1303
|
+
const orbiting = satellites.length > 0;
|
|
1304
|
+
const c = {
|
|
1305
|
+
bg: xml(palette.background),
|
|
1306
|
+
accent: xml(palette.icon),
|
|
1307
|
+
badge: xml(palette.badge),
|
|
1308
|
+
border: xml(palette.badgeBorder),
|
|
1309
|
+
label: xml(palette.label),
|
|
1310
|
+
};
|
|
1311
|
+
const heroRadius = orbiting ? 58 : 92;
|
|
1312
|
+
const heroIcon = orbiting ? 64 : 104;
|
|
1313
|
+
const parts = [`<rect width="${WIDTH}" height="${HEIGHT}" rx="28" fill="${c.bg}"/>`];
|
|
1314
|
+
if (orbiting) {
|
|
1315
|
+
parts.push(`<ellipse cx="${CX}" cy="${CY}" rx="214" ry="104" fill="none" stroke="${c.accent}" stroke-opacity=".22" stroke-width="2" stroke-dasharray="2 10" stroke-linecap="round"/>`);
|
|
1316
|
+
}
|
|
1317
|
+
else {
|
|
1318
|
+
parts.push(`<circle cx="${CX}" cy="${CY + 8}" r="${heroRadius + 34}" fill="${c.accent}" opacity=".07"/>`);
|
|
1319
|
+
}
|
|
1320
|
+
parts.push(`<circle cx="${CX}" cy="${CY}" r="${heroRadius}" fill="${c.badge}" stroke="${c.border}" stroke-width="2"/>`, `<circle cx="${CX}" cy="${CY}" r="${heroRadius - 10}" fill="${c.accent}" opacity=".1"/>`, icon(illustration.icon, CX - heroIcon / 2, CY - heroIcon / 2, heroIcon, palette.icon), sparkle(CX + heroRadius + 14, CY - heroRadius + 4, 13, palette.icon, 0.85), sparkle(CX - heroRadius - 8, CY + heroRadius - 18, 8, palette.icon, 0.5));
|
|
1321
|
+
satellites.forEach((sat, i) => {
|
|
1322
|
+
// Start at the top-left so a lone satellite never sits on the hero's sparkle.
|
|
1323
|
+
const angle = -Math.PI * 0.75 + (i * 2 * Math.PI) / satellites.length;
|
|
1324
|
+
const x = round(CX + 214 * Math.cos(angle));
|
|
1325
|
+
const y = round(CY + 104 * Math.sin(angle));
|
|
1326
|
+
parts.push(`<g transform="translate(${x} ${y})"><g class="sat" style="animation-delay:${-(i * 1.3).toFixed(1)}s">`, `<circle r="28" fill="${c.badge}" stroke="${c.border}" stroke-width="2"/>`, icon(sat.icon, -14, -14, 28, palette.icon), `<text y="48" text-anchor="middle" font-size="15" fill="${c.label}">${xml(sat.label)}</text>`, `</g></g>`);
|
|
1327
|
+
});
|
|
1328
|
+
const style = orbiting
|
|
1329
|
+
? '<style>.sat{animation:drift 5.6s ease-in-out infinite alternate}' +
|
|
1330
|
+
'@keyframes drift{from{transform:translateY(-5px)}to{transform:translateY(5px)}}' +
|
|
1331
|
+
'@media (prefers-reduced-motion:reduce){.sat{animation:none}}</style>'
|
|
1332
|
+
: '';
|
|
1333
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${WIDTH} ${HEIGHT}" width="${WIDTH}" height="${HEIGHT}" ` +
|
|
1334
|
+
`font-family="${xml(palette.font)}">${style}${parts.join('')}</svg>`;
|
|
1335
|
+
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* Painel de novidades: um diálogo com carrossel — "olha o que mudou", não tutorial (esse é o tour
|
|
1340
|
+
* de boas-vindas). Montado uma vez no layout; quem decide quando abre é o `SpineNewsService`.
|
|
1341
|
+
*/
|
|
1342
|
+
class SpineNewsComponent {
|
|
1343
|
+
constructor() {
|
|
1344
|
+
this.news = inject(SpineNewsService);
|
|
1345
|
+
this.text = inject(SPINE_MESSAGES).news;
|
|
1346
|
+
this.document = inject(DOCUMENT);
|
|
1347
|
+
/** O hm-carousel pede `image`/`alt` sempre presentes (vazios = sem imagem / decorativa). A
|
|
1348
|
+
* ilustração é desenhada na hora de abrir, com as cores do tema daquele momento. */
|
|
1349
|
+
this.slides = computed(() => {
|
|
1350
|
+
if (!this.news.isOpen())
|
|
1351
|
+
return [];
|
|
1352
|
+
const palette = readNewsPalette(this.document);
|
|
1353
|
+
return this.news.slides().map((s) => ({
|
|
1354
|
+
title: s.title,
|
|
1355
|
+
text: s.text,
|
|
1356
|
+
image: s.image ?? (s.illustration ? buildNewsIllustration(s.illustration, palette) : ''),
|
|
1357
|
+
// Ilustração é decorativa: o título e o texto do slide já dizem tudo.
|
|
1358
|
+
alt: s.image ? (s.alt ?? '') : '',
|
|
1359
|
+
}));
|
|
1360
|
+
}, ...(ngDevMode ? [{ debugName: "slides" }] : /* istanbul ignore next */ []));
|
|
1361
|
+
}
|
|
1362
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineNewsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
1363
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: SpineNewsComponent, isStandalone: true, selector: "spine-news", ngImport: i0, template: `
|
|
1364
|
+
@if (news.isOpen()) {
|
|
1365
|
+
<hm-modal [heading]="text.heading" size="medium" [open]="true" (closed)="news.close()">
|
|
1366
|
+
<hm-carousel [label]="text.carousel" [slides]="slides()" />
|
|
1367
|
+
<ng-container footer>
|
|
1368
|
+
<hm-button (clicked)="news.close()">{{ text.dismiss }}</hm-button>
|
|
1369
|
+
</ng-container>
|
|
1370
|
+
</hm-modal>
|
|
1371
|
+
}
|
|
1372
|
+
`, isInline: true, dependencies: [{ kind: "component", type: ModalComponent, selector: "hm-modal", inputs: ["open", "heading", "size", "closeLabel", "closable", "beforeClose"], outputs: ["openChange", "closed"] }, { kind: "component", type: CarouselComponent, selector: "hm-carousel", inputs: ["label", "slides", "interval"] }, { kind: "component", type: ButtonComponent, selector: "hm-button", inputs: ["emphasis", "intent", "size", "type", "icon", "iconDirection", "iconEnd", "disabled", "loading", "busy", "loadingState", "block", "link", "newTab", "caret", "inMenu", "toggle", "pressed"], outputs: ["loadingStateChange", "pressedChange", "clicked"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
1373
|
+
}
|
|
1374
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineNewsComponent, decorators: [{
|
|
1375
|
+
type: Component,
|
|
1376
|
+
args: [{
|
|
1377
|
+
selector: 'spine-news',
|
|
1378
|
+
imports: [ModalComponent, CarouselComponent, ButtonComponent],
|
|
1379
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1380
|
+
template: `
|
|
1381
|
+
@if (news.isOpen()) {
|
|
1382
|
+
<hm-modal [heading]="text.heading" size="medium" [open]="true" (closed)="news.close()">
|
|
1383
|
+
<hm-carousel [label]="text.carousel" [slides]="slides()" />
|
|
1384
|
+
<ng-container footer>
|
|
1385
|
+
<hm-button (clicked)="news.close()">{{ text.dismiss }}</hm-button>
|
|
1386
|
+
</ng-container>
|
|
1387
|
+
</hm-modal>
|
|
1388
|
+
}
|
|
1389
|
+
`,
|
|
1390
|
+
}]
|
|
1391
|
+
}] });
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* Painel de atalhos (cheatsheet). Abre com "?" (via `ShortcutService.help$`) e lista, com o
|
|
1395
|
+
* `hm-shortcut-list` da lib, os atalhos agrupados — com busca e o interruptor dos atalhos de tecla
|
|
1396
|
+
* única (WCAG 2.1.4; a escolha persiste no `SpinePreferencesService`). Também é o último passo do
|
|
1397
|
+
* onboarding.
|
|
1398
|
+
*/
|
|
1399
|
+
class ShortcutsCheatsheetComponent {
|
|
1400
|
+
constructor() {
|
|
1401
|
+
this.open = false;
|
|
1402
|
+
this.text = inject(SPINE_MESSAGES).shortcuts;
|
|
1403
|
+
this.shortcuts = inject(ShortcutService);
|
|
1404
|
+
this.onboarding = inject(OnboardingService);
|
|
1405
|
+
/** Atalhos do painel: registrados (favoritos numa linha só) + os que outra peça dispara. */
|
|
1406
|
+
this.items = this.shortcuts.panelItems;
|
|
1407
|
+
/** Atalhos de tecla única ligados — sem eles o "?" da dica não funciona. */
|
|
1408
|
+
this.characterKeys = inject(HermesShortcutService).characterKeys;
|
|
1409
|
+
}
|
|
1410
|
+
/** Dica com a tecla no meio: o texto do catálogo partido em volta de `{key}`. */
|
|
1411
|
+
get hintParts() {
|
|
1412
|
+
return this.text.hint.split('{key}');
|
|
1413
|
+
}
|
|
1414
|
+
/** @deprecated O painel agora é o `hm-shortcut-list`; use `ShortcutService.panelItems()`. Sai numa próxima versão. */
|
|
1415
|
+
get groups() {
|
|
1416
|
+
return this.shortcuts.groups();
|
|
1417
|
+
}
|
|
1418
|
+
/** @deprecated As teclas são desenhadas pelo `hm-shortcut-list`. Sai numa próxima versão. */
|
|
1419
|
+
chips(keys) {
|
|
1420
|
+
return keys.split('+').map((k) => k.trim()).filter(Boolean);
|
|
1421
|
+
}
|
|
1422
|
+
/** Reabre o tour de onboarding e fecha este painel. */
|
|
1423
|
+
replayTour() {
|
|
1424
|
+
this.open = false;
|
|
1425
|
+
this.onboarding.restart();
|
|
1426
|
+
}
|
|
1427
|
+
ngOnInit() {
|
|
1428
|
+
this.sub = this.shortcuts.help$.subscribe(() => (this.open = !this.open));
|
|
1429
|
+
}
|
|
1430
|
+
ngOnDestroy() {
|
|
1431
|
+
this.sub?.unsubscribe();
|
|
1432
|
+
}
|
|
1433
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: ShortcutsCheatsheetComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
1434
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: ShortcutsCheatsheetComponent, isStandalone: true, selector: "spine-shortcuts-cheatsheet", ngImport: i0, template: "<hm-modal [open]=\"open\" (openChange)=\"open = $event\" [heading]=\"text.heading\" size=\"large\">\n <!-- Com a tecla \u00FAnica desligada (WCAG 2.1.4) o \"?\" n\u00E3o abre o painel: a dica some junto. -->\n @if (characterKeys()) {\n <p class=\"sc-hint\">\n {{ hintParts[0] }}<kbd class=\"sc-chip\">?</kbd>{{ hintParts[1] }}\n </p>\n }\n\n <hm-shortcut-list searchable characterKeysToggle [registered]=\"false\" [items]=\"items()\" />\n\n <ng-container footer>\n <hm-button emphasis=\"medium\" icon=\"play\" (clicked)=\"replayTour()\">{{ text.replayTour }}</hm-button>\n <hm-button (clicked)=\"open = false\">{{ text.dismiss }}</hm-button>\n </ng-container>\n</hm-modal>\n", styles: ["@charset \"UTF-8\";.sc-hint{display:flex;flex-wrap:wrap;align-items:center;gap:var(--hm-space-micro);margin:0 0 var(--hm-space-md);padding:var(--hm-space-xxxs) var(--hm-space-xs);border:var(--hm-border-hairline) solid var(--hm-border);border-radius:var(--hm-radius-control);background:var(--hm-hover);color:var(--hm-text);font-size:var(--hm-font-size-xs)}.sc-chip{display:inline-flex;align-items:center;justify-content:center;min-inline-size:1.75rem;padding:0 var(--hm-space-micro);border:var(--hm-border-hairline) solid var(--hm-border);border-radius:var(--hm-radius-micro);background:var(--hm-surface-raised);color:var(--hm-text);font-family:var(--hm-font-family);font-size:var(--hm-font-size-xs);font-weight:var(--hm-font-weight-bold);line-height:var(--hm-line-height-normal)}@media(forced-colors:active){.sc-chip{border-color:ButtonBorder;background:ButtonFace;color:ButtonText}}\n"], dependencies: [{ kind: "component", type: ModalComponent, selector: "hm-modal", inputs: ["open", "heading", "size", "closeLabel", "closable", "beforeClose"], outputs: ["openChange", "closed"] }, { kind: "component", type: ButtonComponent, selector: "hm-button", inputs: ["emphasis", "intent", "size", "type", "icon", "iconDirection", "iconEnd", "disabled", "loading", "busy", "loadingState", "block", "link", "newTab", "caret", "inMenu", "toggle", "pressed"], outputs: ["loadingStateChange", "pressedChange", "clicked"] }, { kind: "component", type: ShortcutListComponent, selector: "hm-shortcut-list", inputs: ["items", "registered", "searchable", "characterKeysToggle", "headingLevel"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
|
|
1435
|
+
}
|
|
1436
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: ShortcutsCheatsheetComponent, decorators: [{
|
|
1437
|
+
type: Component,
|
|
1438
|
+
args: [{ selector: 'spine-shortcuts-cheatsheet', imports: [ModalComponent, ButtonComponent, ShortcutListComponent], changeDetection: ChangeDetectionStrategy.Eager, template: "<hm-modal [open]=\"open\" (openChange)=\"open = $event\" [heading]=\"text.heading\" size=\"large\">\n <!-- Com a tecla \u00FAnica desligada (WCAG 2.1.4) o \"?\" n\u00E3o abre o painel: a dica some junto. -->\n @if (characterKeys()) {\n <p class=\"sc-hint\">\n {{ hintParts[0] }}<kbd class=\"sc-chip\">?</kbd>{{ hintParts[1] }}\n </p>\n }\n\n <hm-shortcut-list searchable characterKeysToggle [registered]=\"false\" [items]=\"items()\" />\n\n <ng-container footer>\n <hm-button emphasis=\"medium\" icon=\"play\" (clicked)=\"replayTour()\">{{ text.replayTour }}</hm-button>\n <hm-button (clicked)=\"open = false\">{{ text.dismiss }}</hm-button>\n </ng-container>\n</hm-modal>\n", styles: ["@charset \"UTF-8\";.sc-hint{display:flex;flex-wrap:wrap;align-items:center;gap:var(--hm-space-micro);margin:0 0 var(--hm-space-md);padding:var(--hm-space-xxxs) var(--hm-space-xs);border:var(--hm-border-hairline) solid var(--hm-border);border-radius:var(--hm-radius-control);background:var(--hm-hover);color:var(--hm-text);font-size:var(--hm-font-size-xs)}.sc-chip{display:inline-flex;align-items:center;justify-content:center;min-inline-size:1.75rem;padding:0 var(--hm-space-micro);border:var(--hm-border-hairline) solid var(--hm-border);border-radius:var(--hm-radius-micro);background:var(--hm-surface-raised);color:var(--hm-text);font-family:var(--hm-font-family);font-size:var(--hm-font-size-xs);font-weight:var(--hm-font-weight-bold);line-height:var(--hm-line-height-normal)}@media(forced-colors:active){.sc-chip{border-color:ButtonBorder;background:ButtonFace;color:ButtonText}}\n"] }]
|
|
1439
|
+
}] });
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* Tokens de aplicação pessoais (`x-app-token`) do usuário logado — `argos-admin /me/app-tokens`.
|
|
1443
|
+
* Sempre da aplicação LOGADA: o servidor tira o client do próprio access-token da sessão, então
|
|
1444
|
+
* aqui não existe escolha de aplicação.
|
|
1445
|
+
* Autentica com o access-token da sessão (o servidor recusa um app-token aqui: token não cria
|
|
1446
|
+
* token). `available` é falso quando o app não tem a URL do argos-admin (`SPINE_ADMIN_API`).
|
|
1447
|
+
*/
|
|
1448
|
+
class SpineAppTokensService {
|
|
1449
|
+
constructor() {
|
|
1450
|
+
this.http = inject(HttpClient);
|
|
1451
|
+
this.auth = inject(AuthorizationService);
|
|
1452
|
+
this.adminApi = inject(SPINE_ADMIN_API, { optional: true });
|
|
1453
|
+
this.available = !!this.adminApi;
|
|
1454
|
+
}
|
|
1455
|
+
get baseUrl() {
|
|
1456
|
+
return `${this.adminApi}/me/app-tokens`;
|
|
1457
|
+
}
|
|
1458
|
+
get headers() {
|
|
1459
|
+
return new HttpHeaders({ 'x-access-token': this.auth.getToken() ?? '' });
|
|
1460
|
+
}
|
|
1461
|
+
list() {
|
|
1462
|
+
return firstValueFrom(this.http.get(this.baseUrl, { headers: this.headers }));
|
|
1463
|
+
}
|
|
1464
|
+
options() {
|
|
1465
|
+
return firstValueFrom(this.http.get(`${this.baseUrl}/options`, { headers: this.headers }));
|
|
1466
|
+
}
|
|
1467
|
+
create(body) {
|
|
1468
|
+
return firstValueFrom(this.http.post(this.baseUrl, body, { headers: this.headers }));
|
|
1469
|
+
}
|
|
1470
|
+
revoke(id) {
|
|
1471
|
+
return firstValueFrom(this.http.delete(`${this.baseUrl}/${id}`, { headers: this.headers }));
|
|
1472
|
+
}
|
|
1473
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineAppTokensService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
1474
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineAppTokensService, providedIn: 'root' }); }
|
|
1475
|
+
}
|
|
1476
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineAppTokensService, decorators: [{
|
|
1477
|
+
type: Injectable,
|
|
1478
|
+
args: [{ providedIn: 'root' }]
|
|
1479
|
+
}] });
|
|
1480
|
+
|
|
1481
|
+
/** Validades oferecidas (dias). O servidor recusa acima de 365 — o CHECK no banco também. */
|
|
1482
|
+
/**
|
|
1483
|
+
* Tokens de aplicação do usuário (seção Conta do painel de configurações): lista, criação e
|
|
1484
|
+
* revogação. O valor do token só aparece uma vez, logo depois de criado, e some quando o painel
|
|
1485
|
+
* fecha (o componente vive dentro do `@if` do painel).
|
|
1486
|
+
*/
|
|
1487
|
+
class AppTokensComponent {
|
|
1488
|
+
constructor() {
|
|
1489
|
+
this.text = inject(SPINE_MESSAGES).appTokens;
|
|
1490
|
+
this.fmt = formatMessage;
|
|
1491
|
+
this.locale = inject(SPINE_MESSAGES);
|
|
1492
|
+
this.service = inject(SpineAppTokensService);
|
|
1493
|
+
this.toast = inject(HermesToastService);
|
|
1494
|
+
this.preferences = inject(SpinePreferencesService);
|
|
1495
|
+
this.available = this.service.available;
|
|
1496
|
+
this.loading = signal(true, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
|
|
1497
|
+
this.loadError = signal('', ...(ngDevMode ? [{ debugName: "loadError" }] : /* istanbul ignore next */ []));
|
|
1498
|
+
this.tokens = signal([], ...(ngDevMode ? [{ debugName: "tokens" }] : /* istanbul ignore next */ []));
|
|
1499
|
+
this.maxActive = signal(10, ...(ngDevMode ? [{ debugName: "maxActive" }] : /* istanbul ignore next */ []));
|
|
1500
|
+
this.validCount = computed(() => this.tokens().filter((t) => !t.expired).length, ...(ngDevMode ? [{ debugName: "validCount" }] : /* istanbul ignore next */ []));
|
|
1501
|
+
this.atLimit = computed(() => this.validCount() >= this.maxActive(), ...(ngDevMode ? [{ debugName: "atLimit" }] : /* istanbul ignore next */ []));
|
|
1502
|
+
// ── criação ─────────────────────────────────────────────────────────────────
|
|
1503
|
+
this.creating = signal(false, ...(ngDevMode ? [{ debugName: "creating" }] : /* istanbul ignore next */ []));
|
|
1504
|
+
this.submitting = signal(false, ...(ngDevMode ? [{ debugName: "submitting" }] : /* istanbul ignore next */ []));
|
|
1505
|
+
/** Nome da aplicação logada (a única para a qual dá pra criar token aqui). */
|
|
1506
|
+
this.clientName = signal('', ...(ngDevMode ? [{ debugName: "clientName" }] : /* istanbul ignore next */ []));
|
|
1507
|
+
this.badges = signal([], ...(ngDevMode ? [{ debugName: "badges" }] : /* istanbul ignore next */ []));
|
|
1508
|
+
this.badgeId = signal('', ...(ngDevMode ? [{ debugName: "badgeId" }] : /* istanbul ignore next */ []));
|
|
1509
|
+
this.validity = signal('90', ...(ngDevMode ? [{ debugName: "validity" }] : /* istanbul ignore next */ []));
|
|
1510
|
+
this.nameControl = new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(80)] });
|
|
1511
|
+
this.validityOptions = computed(() => [
|
|
1512
|
+
{ value: '30', label: this.text.days30 },
|
|
1513
|
+
{ value: '90', label: this.text.days90 },
|
|
1514
|
+
{ value: '180', label: this.text.months6 },
|
|
1515
|
+
{ value: '365', label: this.text.year1 },
|
|
1516
|
+
], ...(ngDevMode ? [{ debugName: "validityOptions" }] : /* istanbul ignore next */ []));
|
|
1517
|
+
this.badgeOptions = computed(() => this.badges().map((b) => ({ value: b.id, label: b.name })), ...(ngDevMode ? [{ debugName: "badgeOptions" }] : /* istanbul ignore next */ []));
|
|
1518
|
+
/** Token recém-criado, mostrado uma única vez. */
|
|
1519
|
+
this.created = signal(null, ...(ngDevMode ? [{ debugName: "created" }] : /* istanbul ignore next */ []));
|
|
1520
|
+
/** Token com a revogação aguardando confirmação (segundo clique). */
|
|
1521
|
+
this.confirmingRevoke = signal(null, ...(ngDevMode ? [{ debugName: "confirmingRevoke" }] : /* istanbul ignore next */ []));
|
|
1522
|
+
this.canSubmit = computed(() => !!this.badgeId() && !this.submitting(), ...(ngDevMode ? [{ debugName: "canSubmit" }] : /* istanbul ignore next */ []));
|
|
1523
|
+
if (this.available)
|
|
1524
|
+
void this.load();
|
|
1525
|
+
else
|
|
1526
|
+
this.loading.set(false);
|
|
1527
|
+
}
|
|
1528
|
+
async load() {
|
|
1529
|
+
this.loading.set(true);
|
|
1530
|
+
this.loadError.set('');
|
|
1531
|
+
try {
|
|
1532
|
+
const list = await this.service.list();
|
|
1533
|
+
this.tokens.set(list.tokens);
|
|
1534
|
+
this.maxActive.set(list.max_active);
|
|
1535
|
+
}
|
|
1536
|
+
catch (err) {
|
|
1537
|
+
this.loadError.set(this.errorText(err, this.text.loadError));
|
|
1538
|
+
}
|
|
1539
|
+
finally {
|
|
1540
|
+
this.loading.set(false);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
async startCreate() {
|
|
1544
|
+
this.created.set(null);
|
|
1545
|
+
this.nameControl.reset('');
|
|
1546
|
+
this.validity.set('90');
|
|
1547
|
+
this.creating.set(true);
|
|
1548
|
+
try {
|
|
1549
|
+
const { client, badges } = await this.service.options();
|
|
1550
|
+
this.clientName.set(client.name ?? '');
|
|
1551
|
+
this.badges.set(badges);
|
|
1552
|
+
// Um nível só: já vem escolhido. Vários: o de maior nível vem primeiro (ordem do servidor).
|
|
1553
|
+
this.badgeId.set(badges[0]?.id ?? '');
|
|
1554
|
+
}
|
|
1555
|
+
catch (err) {
|
|
1556
|
+
this.toast.error(this.errorText(err, this.text.badgesError));
|
|
1557
|
+
this.creating.set(false);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
cancelCreate() {
|
|
1561
|
+
this.creating.set(false);
|
|
1562
|
+
}
|
|
1563
|
+
async submit() {
|
|
1564
|
+
this.nameControl.markAsTouched();
|
|
1565
|
+
if (!this.canSubmit() || this.nameControl.invalid)
|
|
1566
|
+
return;
|
|
1567
|
+
this.submitting.set(true);
|
|
1568
|
+
try {
|
|
1569
|
+
const name = this.nameControl.value.trim();
|
|
1570
|
+
const result = await this.service.create({
|
|
1571
|
+
badge_id: this.badgeId(),
|
|
1572
|
+
name,
|
|
1573
|
+
validity_days: Number(this.validity()),
|
|
1574
|
+
});
|
|
1575
|
+
this.created.set({ name, token: result.token, expiresAt: result.expires_at });
|
|
1576
|
+
this.creating.set(false);
|
|
1577
|
+
await this.load();
|
|
1578
|
+
}
|
|
1579
|
+
catch (err) {
|
|
1580
|
+
this.toast.error(this.errorText(err, this.text.createError));
|
|
1581
|
+
}
|
|
1582
|
+
finally {
|
|
1583
|
+
this.submitting.set(false);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
async copyCreated() {
|
|
1587
|
+
const token = this.created()?.token;
|
|
1588
|
+
if (!token)
|
|
1589
|
+
return;
|
|
1590
|
+
try {
|
|
1591
|
+
await navigator.clipboard.writeText(token);
|
|
1592
|
+
this.toast.success(this.text.copied);
|
|
1593
|
+
}
|
|
1594
|
+
catch {
|
|
1595
|
+
this.toast.error(this.text.copyFailed);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
dismissCreated() {
|
|
1599
|
+
this.created.set(null);
|
|
1600
|
+
}
|
|
1601
|
+
async revoke(token) {
|
|
1602
|
+
if (this.confirmingRevoke() !== token.id) {
|
|
1603
|
+
this.confirmingRevoke.set(token.id);
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
this.confirmingRevoke.set(null);
|
|
1607
|
+
try {
|
|
1608
|
+
await this.service.revoke(token.id);
|
|
1609
|
+
this.tokens.update((list) => list.filter((t) => t.id !== token.id));
|
|
1610
|
+
this.toast.success(formatMessage(this.text.revoked, { name: token.name }));
|
|
1611
|
+
}
|
|
1612
|
+
catch (err) {
|
|
1613
|
+
this.toast.error(this.errorText(err, this.text.revokeError));
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
formatDate(value) {
|
|
1617
|
+
if (!value)
|
|
1618
|
+
return this.text.never;
|
|
1619
|
+
return new Intl.DateTimeFormat(this.locale.meta.locale, { dateStyle: 'short', timeStyle: 'short', timeZone: this.preferences.effectiveTimeZone() })
|
|
1620
|
+
.format(new Date(value));
|
|
1621
|
+
}
|
|
1622
|
+
errorText(err, fallback) {
|
|
1623
|
+
const detail = err instanceof HttpErrorResponse ? err.error?.detail : undefined;
|
|
1624
|
+
return detail || fallback;
|
|
1625
|
+
}
|
|
1626
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: AppTokensComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
1627
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: AppTokensComponent, isStandalone: true, selector: "spine-app-tokens", ngImport: i0, template: "@if (!available) {\n <p class=\"sp-tokens__note\">{{ text.unavailable }}</p>\n} @else {\n <!-- Token rec\u00E9m-criado: \u00FAnica vez em que o valor aparece. -->\n @if (created(); as c) {\n <div class=\"sp-tokens__created\" role=\"status\">\n <p class=\"sp-tokens__created-title\">\n <hm-icon shape=\"check-circle\" /> {{ fmt(text.created, { name: c.name }) }}\n </p>\n <code class=\"sp-tokens__secret\">{{ c.token }}</code>\n <p class=\"sp-tokens__note\" [innerHTML]=\"fmt(text.sendHeader, { date: formatDate(c.expiresAt) })\"></p>\n <div class=\"sp-tokens__actions\">\n <hm-button emphasis=\"high\" icon=\"copy\" (clicked)=\"copyCreated()\">{{ text.copyToken }}</hm-button>\n <hm-button emphasis=\"minimal\" (clicked)=\"dismissCreated()\">{{ text.saved }}</hm-button>\n </div>\n </div>\n }\n\n @if (loading()) {\n <p class=\"sp-tokens__note\">{{ text.loading }}</p>\n } @else if (loadError()) {\n <p class=\"sp-tokens__note sp-tokens__note--error\">{{ loadError() }}</p>\n } @else {\n @if (tokens().length) {\n <ul class=\"sp-tokens__list\">\n @for (t of tokens(); track t.id) {\n <li class=\"sp-tokens__item\" [class.sp-tokens__item--expired]=\"t.expired\">\n <span class=\"sp-tokens__item-main\">\n <strong>{{ t.name }}</strong>\n <span class=\"sp-tokens__meta\">{{ t.badge_name }} \u00B7 <code>{{ t.prefix }}</code></span>\n <span class=\"sp-tokens__meta\">\n @if (t.expired) { <span class=\"sp-tokens__expired\">{{ fmt(text.expiredOn, { date: formatDate(t.expires_at) }) }}</span> }\n @else { {{ fmt(text.expiresOn, { date: formatDate(t.expires_at) }) }} }\n \u00B7 {{ fmt(text.lastUsed, { date: formatDate(t.last_used_at) }) }}\n </span>\n </span>\n <hm-button emphasis=\"minimal\" intent=\"danger\" (clicked)=\"revoke(t)\">\n {{ confirmingRevoke() === t.id ? text.confirmRevoke : text.revoke }}\n </hm-button>\n </li>\n }\n </ul>\n } @else if (!creating()) {\n <p class=\"sp-tokens__note\">{{ text.empty }}</p>\n }\n\n @if (creating()) {\n <form class=\"sp-tokens__form\" (submit)=\"$event.preventDefault(); submit()\">\n @if (!badges().length) {\n <p class=\"sp-tokens__note\">{{ text.noBadges }}</p>\n } @else {\n @if (clientName()) {\n <p class=\"sp-tokens__note\" [innerHTML]=\"fmt(text.appOnly, { name: clientName() })\"></p>\n }\n @if (badgeOptions().length > 1) {\n <div class=\"sp-tokens__field\">\n <span class=\"sp-tokens__label\">{{ text.accessLevel }}</span>\n <hm-segmented-control emphasis=\"action\" [label]=\"text.accessLevel\" [options]=\"badgeOptions()\" [value]=\"badgeId()\" (valueChange)=\"badgeId.set($event)\" />\n </div>\n }\n <hm-input [label]=\"text.name\" [placeholder]=\"text.namePlaceholder\" [formControl]=\"nameControl\" [messages]=\"{ required: text.nameRequired, maxlength: text.nameMax }\" />\n <div class=\"sp-tokens__field\">\n <span class=\"sp-tokens__label\">{{ text.validity }}</span>\n <hm-segmented-control emphasis=\"action\" [label]=\"text.validity\" [options]=\"validityOptions()\" [value]=\"validity()\" (valueChange)=\"validity.set($event)\" />\n </div>\n }\n <div class=\"sp-tokens__actions\">\n @if (badges().length) {\n <hm-button type=\"submit\" emphasis=\"high\" icon=\"key\" [disabled]=\"!canSubmit()\" [loading]=\"submitting()\">{{ text.create }}</hm-button>\n }\n <hm-button emphasis=\"minimal\" (clicked)=\"cancelCreate()\">{{ text.cancel }}</hm-button>\n </div>\n </form>\n } @else {\n <div class=\"sp-tokens__actions\">\n <hm-button emphasis=\"medium\" icon=\"plus\" [disabled]=\"atLimit()\" (clicked)=\"startCreate()\">{{ text.newToken }}</hm-button>\n <span class=\"sp-tokens__note\">{{ fmt(text.validCount, { valid: validCount(), max: maxActive() }) }}</span>\n </div>\n @if (atLimit()) {\n <p class=\"sp-tokens__note\">{{ text.limit }}</p>\n }\n }\n }\n}\n", styles: [":host{display:flex;flex-direction:column;gap:var(--hm-space-xxxs);width:100%}.sp-tokens__note{margin:0;color:var(--hm-muted);font-size:var(--hm-font-size-xs)}.sp-tokens__note--error{color:var(--hm-danger)}.sp-tokens__note code{font-family:Geist Mono,ui-monospace,monospace}.sp-tokens__list{display:flex;flex-direction:column;margin:0;padding:0;list-style:none;border:var(--hm-border-hairline) solid var(--hm-border);border-radius:var(--hm-radius-control)}.sp-tokens__item{display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-xxxs);padding:var(--hm-space-xxxs) var(--hm-space-xs)}.sp-tokens__item+.sp-tokens__item{border-top:var(--hm-border-hairline) solid var(--hm-divider)}.sp-tokens__item--expired .sp-tokens__item-main strong{color:var(--hm-muted)}.sp-tokens__item-main{display:flex;min-width:0;flex-direction:column;gap:var(--hm-space-micro);color:var(--hm-text);font-size:var(--hm-font-size-sm)}.sp-tokens__meta{color:var(--hm-muted);font-size:var(--hm-font-size-xs)}.sp-tokens__meta code{font-family:Geist Mono,ui-monospace,monospace}.sp-tokens__expired{color:var(--hm-danger)}.sp-tokens__actions{display:flex;flex-wrap:wrap;align-items:center;gap:var(--hm-space-xxxs)}.sp-tokens__form{display:flex;flex-direction:column;gap:var(--hm-space-xs);padding:var(--hm-space-xs);border:var(--hm-border-hairline) solid var(--hm-border);border-radius:var(--hm-radius-control);background:var(--hm-surface-raised)}.sp-tokens__field{display:flex;flex-direction:column;gap:var(--hm-space-micro)}.sp-tokens__label{color:var(--hm-text);font-size:var(--hm-font-size-sm);font-weight:600}.sp-tokens__created{display:flex;flex-direction:column;gap:var(--hm-space-xxxs);padding:var(--hm-space-xs);border:var(--hm-border-hairline) solid var(--hm-success);border-radius:var(--hm-radius-control);background:var(--hm-success-bg)}.sp-tokens__created-title{display:flex;align-items:center;gap:var(--hm-space-nano);margin:0;color:var(--hm-text);font-size:var(--hm-font-size-sm);font-weight:600}.sp-tokens__created-title hm-icon{color:var(--hm-success)}.sp-tokens__secret{display:block;padding:var(--hm-space-nano) var(--hm-space-xxxs);border-radius:var(--hm-radius-control);background:var(--hm-surface);color:var(--hm-text);font-family:Geist Mono,ui-monospace,monospace;font-size:var(--hm-font-size-xs);overflow-wrap:anywhere;-webkit-user-select:all;user-select:all}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "component", type: ButtonComponent, selector: "hm-button", inputs: ["emphasis", "intent", "size", "type", "icon", "iconDirection", "iconEnd", "disabled", "loading", "busy", "loadingState", "block", "link", "newTab", "caret", "inMenu", "toggle", "pressed"], outputs: ["loadingStateChange", "pressedChange", "clicked"] }, { kind: "component", type: InputComponent, selector: "hm-input", inputs: ["value", "label", "type", "placeholder", "icon", "labelHidden", "hint", "prefix", "suffix", "prefixIcon", "mask", "maskValue", "min", "max", "step", "stepButtons", "maxLength", "autocomplete", "inputMode"], outputs: ["valueChange"] }, { kind: "component", type: IconComponent, selector: "hm-icon", inputs: ["shape", "direction", "size", "solid", "badge", "status", "flip", "inverse", "label"] }, { kind: "component", type: SegmentedControlComponent, selector: "hm-segmented-control", inputs: ["options", "value", "label", "size", "emphasis"], outputs: ["valueChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
1628
|
+
}
|
|
1629
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: AppTokensComponent, decorators: [{
|
|
1630
|
+
type: Component,
|
|
1631
|
+
args: [{ selector: 'spine-app-tokens', imports: [ReactiveFormsModule, ButtonComponent, InputComponent, IconComponent, SegmentedControlComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: "@if (!available) {\n <p class=\"sp-tokens__note\">{{ text.unavailable }}</p>\n} @else {\n <!-- Token rec\u00E9m-criado: \u00FAnica vez em que o valor aparece. -->\n @if (created(); as c) {\n <div class=\"sp-tokens__created\" role=\"status\">\n <p class=\"sp-tokens__created-title\">\n <hm-icon shape=\"check-circle\" /> {{ fmt(text.created, { name: c.name }) }}\n </p>\n <code class=\"sp-tokens__secret\">{{ c.token }}</code>\n <p class=\"sp-tokens__note\" [innerHTML]=\"fmt(text.sendHeader, { date: formatDate(c.expiresAt) })\"></p>\n <div class=\"sp-tokens__actions\">\n <hm-button emphasis=\"high\" icon=\"copy\" (clicked)=\"copyCreated()\">{{ text.copyToken }}</hm-button>\n <hm-button emphasis=\"minimal\" (clicked)=\"dismissCreated()\">{{ text.saved }}</hm-button>\n </div>\n </div>\n }\n\n @if (loading()) {\n <p class=\"sp-tokens__note\">{{ text.loading }}</p>\n } @else if (loadError()) {\n <p class=\"sp-tokens__note sp-tokens__note--error\">{{ loadError() }}</p>\n } @else {\n @if (tokens().length) {\n <ul class=\"sp-tokens__list\">\n @for (t of tokens(); track t.id) {\n <li class=\"sp-tokens__item\" [class.sp-tokens__item--expired]=\"t.expired\">\n <span class=\"sp-tokens__item-main\">\n <strong>{{ t.name }}</strong>\n <span class=\"sp-tokens__meta\">{{ t.badge_name }} \u00B7 <code>{{ t.prefix }}</code></span>\n <span class=\"sp-tokens__meta\">\n @if (t.expired) { <span class=\"sp-tokens__expired\">{{ fmt(text.expiredOn, { date: formatDate(t.expires_at) }) }}</span> }\n @else { {{ fmt(text.expiresOn, { date: formatDate(t.expires_at) }) }} }\n \u00B7 {{ fmt(text.lastUsed, { date: formatDate(t.last_used_at) }) }}\n </span>\n </span>\n <hm-button emphasis=\"minimal\" intent=\"danger\" (clicked)=\"revoke(t)\">\n {{ confirmingRevoke() === t.id ? text.confirmRevoke : text.revoke }}\n </hm-button>\n </li>\n }\n </ul>\n } @else if (!creating()) {\n <p class=\"sp-tokens__note\">{{ text.empty }}</p>\n }\n\n @if (creating()) {\n <form class=\"sp-tokens__form\" (submit)=\"$event.preventDefault(); submit()\">\n @if (!badges().length) {\n <p class=\"sp-tokens__note\">{{ text.noBadges }}</p>\n } @else {\n @if (clientName()) {\n <p class=\"sp-tokens__note\" [innerHTML]=\"fmt(text.appOnly, { name: clientName() })\"></p>\n }\n @if (badgeOptions().length > 1) {\n <div class=\"sp-tokens__field\">\n <span class=\"sp-tokens__label\">{{ text.accessLevel }}</span>\n <hm-segmented-control emphasis=\"action\" [label]=\"text.accessLevel\" [options]=\"badgeOptions()\" [value]=\"badgeId()\" (valueChange)=\"badgeId.set($event)\" />\n </div>\n }\n <hm-input [label]=\"text.name\" [placeholder]=\"text.namePlaceholder\" [formControl]=\"nameControl\" [messages]=\"{ required: text.nameRequired, maxlength: text.nameMax }\" />\n <div class=\"sp-tokens__field\">\n <span class=\"sp-tokens__label\">{{ text.validity }}</span>\n <hm-segmented-control emphasis=\"action\" [label]=\"text.validity\" [options]=\"validityOptions()\" [value]=\"validity()\" (valueChange)=\"validity.set($event)\" />\n </div>\n }\n <div class=\"sp-tokens__actions\">\n @if (badges().length) {\n <hm-button type=\"submit\" emphasis=\"high\" icon=\"key\" [disabled]=\"!canSubmit()\" [loading]=\"submitting()\">{{ text.create }}</hm-button>\n }\n <hm-button emphasis=\"minimal\" (clicked)=\"cancelCreate()\">{{ text.cancel }}</hm-button>\n </div>\n </form>\n } @else {\n <div class=\"sp-tokens__actions\">\n <hm-button emphasis=\"medium\" icon=\"plus\" [disabled]=\"atLimit()\" (clicked)=\"startCreate()\">{{ text.newToken }}</hm-button>\n <span class=\"sp-tokens__note\">{{ fmt(text.validCount, { valid: validCount(), max: maxActive() }) }}</span>\n </div>\n @if (atLimit()) {\n <p class=\"sp-tokens__note\">{{ text.limit }}</p>\n }\n }\n }\n}\n", styles: [":host{display:flex;flex-direction:column;gap:var(--hm-space-xxxs);width:100%}.sp-tokens__note{margin:0;color:var(--hm-muted);font-size:var(--hm-font-size-xs)}.sp-tokens__note--error{color:var(--hm-danger)}.sp-tokens__note code{font-family:Geist Mono,ui-monospace,monospace}.sp-tokens__list{display:flex;flex-direction:column;margin:0;padding:0;list-style:none;border:var(--hm-border-hairline) solid var(--hm-border);border-radius:var(--hm-radius-control)}.sp-tokens__item{display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-xxxs);padding:var(--hm-space-xxxs) var(--hm-space-xs)}.sp-tokens__item+.sp-tokens__item{border-top:var(--hm-border-hairline) solid var(--hm-divider)}.sp-tokens__item--expired .sp-tokens__item-main strong{color:var(--hm-muted)}.sp-tokens__item-main{display:flex;min-width:0;flex-direction:column;gap:var(--hm-space-micro);color:var(--hm-text);font-size:var(--hm-font-size-sm)}.sp-tokens__meta{color:var(--hm-muted);font-size:var(--hm-font-size-xs)}.sp-tokens__meta code{font-family:Geist Mono,ui-monospace,monospace}.sp-tokens__expired{color:var(--hm-danger)}.sp-tokens__actions{display:flex;flex-wrap:wrap;align-items:center;gap:var(--hm-space-xxxs)}.sp-tokens__form{display:flex;flex-direction:column;gap:var(--hm-space-xs);padding:var(--hm-space-xs);border:var(--hm-border-hairline) solid var(--hm-border);border-radius:var(--hm-radius-control);background:var(--hm-surface-raised)}.sp-tokens__field{display:flex;flex-direction:column;gap:var(--hm-space-micro)}.sp-tokens__label{color:var(--hm-text);font-size:var(--hm-font-size-sm);font-weight:600}.sp-tokens__created{display:flex;flex-direction:column;gap:var(--hm-space-xxxs);padding:var(--hm-space-xs);border:var(--hm-border-hairline) solid var(--hm-success);border-radius:var(--hm-radius-control);background:var(--hm-success-bg)}.sp-tokens__created-title{display:flex;align-items:center;gap:var(--hm-space-nano);margin:0;color:var(--hm-text);font-size:var(--hm-font-size-sm);font-weight:600}.sp-tokens__created-title hm-icon{color:var(--hm-success)}.sp-tokens__secret{display:block;padding:var(--hm-space-nano) var(--hm-space-xxxs);border-radius:var(--hm-radius-control);background:var(--hm-surface);color:var(--hm-text);font-family:Geist Mono,ui-monospace,monospace;font-size:var(--hm-font-size-xs);overflow-wrap:anywhere;-webkit-user-select:all;user-select:all}\n"] }]
|
|
1632
|
+
}], ctorParameters: () => [] });
|
|
1633
|
+
|
|
1634
|
+
/** Dependências que valem mostrar em "Sobre" (as demais são detalhe de build). */
|
|
1635
|
+
// check-strings: off — nomes de produto, não se traduzem.
|
|
1636
|
+
const KEY_DEPENDENCIES = [
|
|
1637
|
+
{ name: '@angular/core', label: 'Angular' },
|
|
1638
|
+
{ name: '@trismegisto/angular', label: 'Hermes DS' },
|
|
1639
|
+
{ name: '@trismegisto/spine', label: 'Hermes Spine' },
|
|
1640
|
+
{ name: '@orfeu/angular', label: 'Orfeu' },
|
|
1641
|
+
{ name: 'apollo-angular', label: 'Apollo' },
|
|
1642
|
+
{ name: 'graphql', label: 'GraphQL' },
|
|
1643
|
+
{ name: 'rxjs', label: 'RxJS' },
|
|
1644
|
+
// check-strings: on
|
|
1645
|
+
];
|
|
1646
|
+
/**
|
|
1647
|
+
* Painel de configurações do shell (substitui as telas do antigo módulo "Sistema"): Aparência,
|
|
1648
|
+
* Região, Atalhos, Conta e Sobre, mais as seções que o app registrar com
|
|
1649
|
+
* `provideSettingsSection`. Tudo vale na hora — cada controle chama o serviço que já persiste
|
|
1650
|
+
* aquela preferência (tema, densidade, menu lateral, fuso).
|
|
1651
|
+
*
|
|
1652
|
+
* Aberto por `SpineSettingsService.open()` — menu do perfil, `Ctrl+,` ou `?settings=<seção>`.
|
|
1653
|
+
*/
|
|
1654
|
+
class SettingsPanelComponent {
|
|
1655
|
+
constructor() {
|
|
1656
|
+
this.settings = inject(SpineSettingsService);
|
|
1657
|
+
this.messages = inject(SPINE_MESSAGES);
|
|
1658
|
+
this.text = this.messages.settings;
|
|
1659
|
+
this.fmt = formatMessage;
|
|
1660
|
+
this.keys = SPINE_SETTINGS_KEYS;
|
|
1661
|
+
this.theme = inject(ThemeService);
|
|
1662
|
+
this.hermesTheme = inject(HermesThemeService);
|
|
1663
|
+
this.presets = inject(ThemePresetService);
|
|
1664
|
+
this.density = inject(DensityService);
|
|
1665
|
+
this.sidebar = inject(HermesSidebarService);
|
|
1666
|
+
this.preferences = inject(SpinePreferencesService);
|
|
1667
|
+
this.shortcutService = inject(ShortcutService);
|
|
1668
|
+
this.onboarding = inject(OnboardingService);
|
|
1669
|
+
this.auth = inject(AuthorizationService);
|
|
1670
|
+
this.toast = inject(HermesToastService);
|
|
1671
|
+
this.packageInfo = inject(APP_PACKAGE_INFO, { optional: true }) ?? {};
|
|
1672
|
+
this.config = inject(SPINE_CONFIG, { optional: true });
|
|
1673
|
+
this.appSections = inject(SPINE_SETTINGS_SECTIONS, { optional: true }) ?? [];
|
|
1674
|
+
this.appName = this.config?.appName ?? 'Hermes';
|
|
1675
|
+
// ── Aparência ───────────────────────────────────────────────────────────────
|
|
1676
|
+
this.themeOptions = computed(() => [
|
|
1677
|
+
{ value: 'light', label: this.text.themeLight, icon: 'sun' },
|
|
1678
|
+
{ value: 'dark', label: this.text.themeDark, icon: 'moon' },
|
|
1679
|
+
{ value: 'system', label: this.text.themeSystem, icon: 'computer' },
|
|
1680
|
+
], ...(ngDevMode ? [{ debugName: "themeOptions" }] : /* istanbul ignore next */ []));
|
|
1681
|
+
this.densityOptions = computed(() => [
|
|
1682
|
+
{ value: 'comfortable', label: this.text.comfortable },
|
|
1683
|
+
{ value: 'compact', label: this.text.compact },
|
|
1684
|
+
], ...(ngDevMode ? [{ debugName: "densityOptions" }] : /* istanbul ignore next */ []));
|
|
1685
|
+
// As fontes do config (as da lib, ou as do app via `provideUi({ theme: { fonts, replaceFonts } })`).
|
|
1686
|
+
this.fontOptions = (this.hermesTheme.fonts ?? HERMES_FONTS).map((f) => ({ value: f.id, label: f.label }));
|
|
1687
|
+
this.sidebarOptions = computed(() => [
|
|
1688
|
+
{ value: 'auto', label: this.text.sidebarAuto },
|
|
1689
|
+
{ value: 'fixed', label: this.text.sidebarFixed },
|
|
1690
|
+
{ value: 'compact', label: this.text.sidebarCompact },
|
|
1691
|
+
], ...(ngDevMode ? [{ debugName: "sidebarOptions" }] : /* istanbul ignore next */ []));
|
|
1692
|
+
this.themeMode = computed(() => this.hermesTheme.mode(), ...(ngDevMode ? [{ debugName: "themeMode" }] : /* istanbul ignore next */ []));
|
|
1693
|
+
this.appliedTheme = computed(() => this.hermesTheme.theme(), ...(ngDevMode ? [{ debugName: "appliedTheme" }] : /* istanbul ignore next */ []));
|
|
1694
|
+
this.family = computed(() => this.hermesTheme.family(), ...(ngDevMode ? [{ debugName: "family" }] : /* istanbul ignore next */ []));
|
|
1695
|
+
this.densityValue = computed(() => this.hermesTheme.density(), ...(ngDevMode ? [{ debugName: "densityValue" }] : /* istanbul ignore next */ []));
|
|
1696
|
+
this.fontValue = computed(() => this.hermesTheme.font(), ...(ngDevMode ? [{ debugName: "fontValue" }] : /* istanbul ignore next */ []));
|
|
1697
|
+
this.families = this.presets.families;
|
|
1698
|
+
// ── Região ──────────────────────────────────────────────────────────────────
|
|
1699
|
+
this.languageOptions = computed(() => {
|
|
1700
|
+
const appLanguage = this.preferences.languages.find((l) => l.value === this.preferences.appLanguage)?.label ?? this.preferences.appLanguage;
|
|
1701
|
+
return [
|
|
1702
|
+
{ value: '', label: formatMessage(this.text.languageAuto, { language: appLanguage }) },
|
|
1703
|
+
...this.preferences.languages,
|
|
1704
|
+
];
|
|
1705
|
+
}, ...(ngDevMode ? [{ debugName: "languageOptions" }] : /* istanbul ignore next */ []));
|
|
1706
|
+
/** ~400 fusos, cada um com o deslocamento calculado: só monta quando o painel abre pela
|
|
1707
|
+
* primeira vez, não no carregamento de toda página (o painel vive no layout desde o início). */
|
|
1708
|
+
this.timeZoneCache = null;
|
|
1709
|
+
this.timeZoneOptions = computed(() => [
|
|
1710
|
+
{ value: '', label: formatMessage(this.text.timeZoneAuto, { zone: this.preferences.browserTimeZone }) },
|
|
1711
|
+
...(this.timeZoneCache ??= this.preferences
|
|
1712
|
+
.supportedTimeZones()
|
|
1713
|
+
.map((tz) => ({ value: tz, label: `${tz.replace(/_/g, ' ')} (${offsetOf(tz)})` }))),
|
|
1714
|
+
], ...(ngDevMode ? [{ debugName: "timeZoneOptions" }] : /* istanbul ignore next */ []));
|
|
1715
|
+
/** Hora agora no fuso efetivo — prévia de como as datas vão aparecer. */
|
|
1716
|
+
this.timeZonePreview = computed(() => {
|
|
1717
|
+
const zone = this.preferences.effectiveTimeZone();
|
|
1718
|
+
try {
|
|
1719
|
+
return new Intl.DateTimeFormat(this.messages.meta.locale, { dateStyle: 'short', timeStyle: 'short', timeZone: zone }).format(new Date())
|
|
1720
|
+
+ ` (${offsetOf(zone)})`;
|
|
1721
|
+
}
|
|
1722
|
+
catch {
|
|
1723
|
+
return zone;
|
|
1724
|
+
}
|
|
1725
|
+
}, ...(ngDevMode ? [{ debugName: "timeZonePreview" }] : /* istanbul ignore next */ []));
|
|
1726
|
+
// ── Atalhos ─────────────────────────────────────────────────────────────────
|
|
1727
|
+
/** Atalhos registrados (favoritos numa linha só) + os que outra peça dispara — os mesmos do painel "?". */
|
|
1728
|
+
this.shortcutItems = this.shortcutService.panelItems;
|
|
1729
|
+
// ── Sobre ───────────────────────────────────────────────────────────────────
|
|
1730
|
+
this.dependencies = KEY_DEPENDENCIES
|
|
1731
|
+
.map((d) => ({ ...d, version: this.packageInfo.dependencies?.[d.name] }))
|
|
1732
|
+
.filter((d) => !!d.version);
|
|
1733
|
+
}
|
|
1734
|
+
setThemeMode(mode) {
|
|
1735
|
+
this.theme.setMode(mode);
|
|
1736
|
+
}
|
|
1737
|
+
setFamily(id) {
|
|
1738
|
+
const family = this.families.find((f) => f.id === id);
|
|
1739
|
+
if (family)
|
|
1740
|
+
this.presets.setPreset(family);
|
|
1741
|
+
}
|
|
1742
|
+
setDensity(value) {
|
|
1743
|
+
this.density.setDensity(value);
|
|
1744
|
+
}
|
|
1745
|
+
setFont(value) {
|
|
1746
|
+
this.hermesTheme.setFont(value);
|
|
1747
|
+
}
|
|
1748
|
+
setSidebarMode(value) {
|
|
1749
|
+
this.sidebar.setMode(value);
|
|
1750
|
+
}
|
|
1751
|
+
replayTour() {
|
|
1752
|
+
this.settings.close();
|
|
1753
|
+
this.onboarding.restart();
|
|
1754
|
+
}
|
|
1755
|
+
// ── Conta ───────────────────────────────────────────────────────────────────
|
|
1756
|
+
get profile() { return this.auth.profile; }
|
|
1757
|
+
get badge() { return this.auth.badge; }
|
|
1758
|
+
/** Validade da sessão lida do `exp` do próprio token (só leitura do payload, sem validar). */
|
|
1759
|
+
get sessionExpiry() {
|
|
1760
|
+
const exp = decodeTokenPayload(this.auth.getToken())?.['exp'];
|
|
1761
|
+
if (typeof exp !== 'number')
|
|
1762
|
+
return this.text.sessionUnknown;
|
|
1763
|
+
const at = new Date(exp * 1000);
|
|
1764
|
+
const minutes = Math.round((at.getTime() - Date.now()) / 60000);
|
|
1765
|
+
const when = new Intl.DateTimeFormat(this.messages.meta.locale, { timeStyle: 'short', timeZone: this.preferences.effectiveTimeZone() }).format(at);
|
|
1766
|
+
if (minutes <= 0)
|
|
1767
|
+
return formatMessage(this.text.sessionExpired, { time: when });
|
|
1768
|
+
return minutes < 60
|
|
1769
|
+
? formatMessage(this.text.sessionExpiresIn, { minutes, time: when })
|
|
1770
|
+
: formatMessage(this.text.sessionExpiresAt, { time: when });
|
|
1771
|
+
}
|
|
1772
|
+
async copyToken() {
|
|
1773
|
+
const token = this.auth.getToken();
|
|
1774
|
+
if (!token) {
|
|
1775
|
+
this.toast.warning(this.text.noSessionToken);
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
await this.copy(token, this.text.tokenCopied);
|
|
1779
|
+
}
|
|
1780
|
+
logout() {
|
|
1781
|
+
this.settings.close();
|
|
1782
|
+
this.auth.logout();
|
|
1783
|
+
}
|
|
1784
|
+
get buildDate() {
|
|
1785
|
+
const date = this.packageInfo.buildDate;
|
|
1786
|
+
return date ? new Intl.DateTimeFormat(this.messages.meta.locale, { dateStyle: 'short', timeStyle: 'short' }).format(new Date(date)) : '';
|
|
1787
|
+
}
|
|
1788
|
+
/** Texto pronto pra colar num chamado: app, versão, ambiente, navegador, tela, fuso e página. */
|
|
1789
|
+
async copySupportInfo() {
|
|
1790
|
+
const info = this.packageInfo;
|
|
1791
|
+
const s = this.messages.support;
|
|
1792
|
+
const theme = formatMessage(s.themeValue, {
|
|
1793
|
+
theme: this.appliedTheme(), mode: this.themeMode(), family: this.family(), density: this.densityValue(), font: this.fontValue(),
|
|
1794
|
+
});
|
|
1795
|
+
const lines = [
|
|
1796
|
+
`${s.application}: ${this.appName}${info.name ? ` (${info.name})` : ''}`,
|
|
1797
|
+
`${s.version}: ${info.version ?? '—'}`,
|
|
1798
|
+
info.buildDate ? `${s.build}: ${this.buildDate}` : '',
|
|
1799
|
+
info.environment ? `${s.environment}: ${info.environment}` : '',
|
|
1800
|
+
`${s.page}: ${location.href}`,
|
|
1801
|
+
`${s.browser}: ${navigator.userAgent}`,
|
|
1802
|
+
`${s.screen}: ${window.innerWidth}×${window.innerHeight} @${window.devicePixelRatio}x`,
|
|
1803
|
+
`${s.theme}: ${theme}`,
|
|
1804
|
+
`${s.timeZone}: ${this.preferences.effectiveTimeZone()}`,
|
|
1805
|
+
`${s.accessLevel}: ${this.badge?.name ?? '—'}`,
|
|
1806
|
+
`${s.date}: ${new Date().toISOString()}`,
|
|
1807
|
+
].filter(Boolean);
|
|
1808
|
+
await this.copy(lines.join('\n'), this.text.supportCopied);
|
|
1809
|
+
}
|
|
1810
|
+
async copy(text, success) {
|
|
1811
|
+
try {
|
|
1812
|
+
await navigator.clipboard.writeText(text);
|
|
1813
|
+
this.toast.success(success);
|
|
1814
|
+
}
|
|
1815
|
+
catch {
|
|
1816
|
+
this.toast.error(this.text.copyBlocked);
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
onOpenChange(open) {
|
|
1820
|
+
if (!open)
|
|
1821
|
+
this.settings.close();
|
|
1822
|
+
}
|
|
1823
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SettingsPanelComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
1824
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: SettingsPanelComponent, isStandalone: true, selector: "spine-settings-panel", ngImport: i0, template: "<!-- Montado s\u00F3 enquanto aberto: nada disto (lista de fusos, leitura do token...) custa no\n carregamento de toda p\u00E1gina. -->\n@if (settings.isOpen()) {\n <hm-settings-dialog\n [heading]=\"text.heading\"\n [open]=\"true\"\n (openChange)=\"onOpenChange($event)\"\n [activeSection]=\"settings.section()\"\n (activeSectionChange)=\"settings.section.set($event)\"\n >\n <!-- \u2500\u2500 Conta \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <hm-settings-section [key]=\"keys.account\" [label]=\"text.account\" icon=\"user\" [keywords]=\"text.accountKeywords\">\n <div class=\"sp-settings__profile\">\n <hm-account-avatar [name]=\"profile.name ?? text.user\" [imageUrl]=\"profile.picture\" />\n <span class=\"sp-settings__profile-text\">\n <strong>{{ profile.name ?? text.user }}</strong>\n @if (profile.role) { <span>{{ profile.role }}</span> }\n </span>\n </div>\n\n <!-- S\u00F3 informativo: a troca de n\u00EDvel fica no menu do perfil, perto de onde se trabalha. -->\n @if (badge) {\n <hm-settings-row [label]=\"text.accessLevel\" [keywords]=\"text.accessLevelKeywords\">\n <span class=\"sp-settings__value\">{{ badge.name }}</span>\n </hm-settings-row>\n }\n\n <hm-settings-row [label]=\"text.session\" [description]=\"text.sessionDescription\" [keywords]=\"text.sessionKeywords\">\n <span class=\"sp-settings__value\">{{ sessionExpiry }}</span>\n </hm-settings-row>\n\n <hm-settings-row [label]=\"text.accessToken\" [description]=\"text.accessTokenDescription\" [keywords]=\"text.accessTokenKeywords\">\n <hm-button emphasis=\"medium\" icon=\"copy\" (clicked)=\"copyToken()\">{{ text.copy }}</hm-button>\n </hm-settings-row>\n\n <hm-settings-row [label]=\"text.appTokens\" [stacked]=\"true\"\n [description]=\"text.appTokensDescription\"\n [keywords]=\"text.appTokensKeywords\">\n <spine-app-tokens />\n </hm-settings-row>\n\n <hm-settings-row [label]=\"text.logout\" [description]=\"text.logoutDescription\" [keywords]=\"text.logoutKeywords\">\n <hm-button emphasis=\"medium\" intent=\"danger\" icon=\"logout\" (clicked)=\"logout()\">{{ text.logout }}</hm-button>\n </hm-settings-row>\n </hm-settings-section>\n\n <!-- \u2500\u2500 Apar\u00EAncia \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <hm-settings-section [key]=\"keys.appearance\" [label]=\"text.appearance\" icon=\"color-palette\" [keywords]=\"text.appearanceKeywords\">\n <hm-settings-row [label]=\"text.theme\" [description]=\"text.themeDescription\" [keywords]=\"text.themeKeywords\">\n <hm-segmented-control emphasis=\"action\" [label]=\"text.theme\" [options]=\"themeOptions()\" [value]=\"themeMode()\" (valueChange)=\"setThemeMode($event)\" />\n </hm-settings-row>\n\n <hm-settings-row [label]=\"text.accent\" [description]=\"text.accentDescription\" [keywords]=\"text.accentKeywords\" [stacked]=\"true\">\n <hm-theme-picker\n [families]=\"families\"\n [family]=\"family()\"\n [theme]=\"appliedTheme()\"\n [showMode]=\"false\"\n (familyChosen)=\"setFamily($event)\"\n />\n </hm-settings-row>\n\n <hm-settings-row [label]=\"text.density\" [description]=\"text.densityDescription\" [keywords]=\"text.densityKeywords\">\n <hm-segmented-control emphasis=\"action\" [label]=\"text.density\" [options]=\"densityOptions()\" [value]=\"densityValue()\" (valueChange)=\"setDensity($event)\" />\n </hm-settings-row>\n\n <hm-settings-row [label]=\"text.font\" [description]=\"text.fontDescription\" [keywords]=\"text.fontKeywords\">\n <hm-segmented-control emphasis=\"action\" [label]=\"text.font\" [options]=\"fontOptions\" [value]=\"fontValue()\" (valueChange)=\"setFont($event)\" />\n </hm-settings-row>\n\n <hm-settings-row [label]=\"text.sidebar\" [description]=\"text.sidebarDescription\" [keywords]=\"text.sidebarKeywords\">\n <hm-segmented-control emphasis=\"action\" [label]=\"text.sidebar\" [options]=\"sidebarOptions()\" [value]=\"sidebar.mode()\" (valueChange)=\"setSidebarMode($event)\" />\n </hm-settings-row>\n </hm-settings-section>\n\n <!-- \u2500\u2500 Regi\u00E3o \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <hm-settings-section [key]=\"keys.region\" [label]=\"text.region\" icon=\"clock\" [keywords]=\"text.regionKeywords\">\n <hm-settings-row [label]=\"text.language\" [description]=\"text.languageDescription\" [keywords]=\"text.languageKeywords\">\n <hm-select\n class=\"sp-settings__language\"\n [label]=\"text.language\"\n [labelHidden]=\"true\"\n [value]=\"preferences.language()\"\n (valueChange)=\"preferences.setLanguage($event ?? '')\"\n >\n @for (option of languageOptions(); track option.value) {\n <option [value]=\"option.value\" [attr.lang]=\"option.value || null\">{{ option.label }}</option>\n }\n </hm-select>\n </hm-settings-row>\n\n <hm-settings-row [label]=\"text.timeZone\" [keywords]=\"text.timeZoneKeywords\">\n <span rowDescription class=\"sp-settings__hint\">{{ fmt(text.timeZoneHint, { now: timeZonePreview() }) }}</span>\n <hm-combobox\n class=\"sp-settings__timezone\"\n [label]=\"text.timeZone\"\n [labelHidden]=\"true\"\n icon=\"world\"\n [options]=\"timeZoneOptions()\"\n [value]=\"preferences.timeZone()\"\n (valueChange)=\"preferences.setTimeZone($event)\"\n />\n </hm-settings-row>\n </hm-settings-section>\n\n <!-- \u2500\u2500 Atalhos \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <hm-settings-section [key]=\"keys.shortcuts\" [label]=\"text.shortcuts\" icon=\"keyboard\" [description]=\"text.shortcutsDescription\" [keywords]=\"text.shortcutsKeywords\">\n <!-- Mesma lista do painel \"?\", sem busca (a do di\u00E1logo de configura\u00E7\u00F5es j\u00E1 cobre a se\u00E7\u00E3o);\n o interruptor de tecla \u00FAnica (WCAG 2.1.4) persiste no SpinePreferencesService. -->\n <hm-shortcut-list characterKeysToggle [registered]=\"false\" [items]=\"shortcutItems()\" [headingLevel]=\"4\" />\n <hm-settings-row [label]=\"text.tour\" [description]=\"text.tourDescription\" [keywords]=\"text.tourKeywords\">\n <hm-button emphasis=\"medium\" icon=\"play\" (clicked)=\"replayTour()\">{{ text.replayTour }}</hm-button>\n </hm-settings-row>\n </hm-settings-section>\n\n <!-- \u2500\u2500 Se\u00E7\u00F5es do app (provideSettingsSection) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n @for (s of appSections; track s.key) {\n <hm-settings-section [key]=\"s.key\" [label]=\"s.label\" [icon]=\"s.icon ?? 'cog'\" [description]=\"s.description ?? ''\" [keywords]=\"s.keywords ?? ''\">\n <ng-container *ngComponentOutlet=\"s.component\" />\n </hm-settings-section>\n }\n\n <!-- \u2500\u2500 Sobre \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <hm-settings-section [key]=\"keys.about\" [label]=\"text.about\" icon=\"info-circle\" [keywords]=\"text.aboutKeywords\">\n <hm-settings-row [label]=\"text.application\">\n <span class=\"sp-settings__value\">{{ appName }}@if (packageInfo.name) { <span class=\"sp-settings__muted\"> \u00B7 {{ packageInfo.name }}</span> }</span>\n </hm-settings-row>\n <hm-settings-row [label]=\"text.version\" [keywords]=\"text.versionKeywords\">\n <span class=\"sp-settings__value sp-settings__mono\">{{ packageInfo.version ?? '\u2014' }}</span>\n </hm-settings-row>\n @if (buildDate) {\n <hm-settings-row [label]=\"text.buildDate\">\n <span class=\"sp-settings__value\">{{ buildDate }}</span>\n </hm-settings-row>\n }\n @if (packageInfo.environment) {\n <hm-settings-row [label]=\"text.environment\" [keywords]=\"text.environmentKeywords\">\n <span class=\"sp-settings__value\">{{ packageInfo.environment }}</span>\n </hm-settings-row>\n }\n @if (packageInfo.repository?.url || packageInfo.docs || packageInfo.issues) {\n <hm-settings-row [label]=\"text.links\" [keywords]=\"text.linksKeywords\">\n <span class=\"sp-settings__links\">\n @if (packageInfo.docs) { <hm-button emphasis=\"minimal\" icon=\"book\" [link]=\"packageInfo.docs\" [newTab]=\"true\">{{ text.docs }}</hm-button> }\n @if (packageInfo.issues) { <hm-button emphasis=\"minimal\" icon=\"bug\" [link]=\"packageInfo.issues\" [newTab]=\"true\">{{ text.reportIssue }}</hm-button> }\n @if (packageInfo.repository?.url) { <hm-button emphasis=\"minimal\" icon=\"link\" [link]=\"packageInfo.repository!.url!\" [newTab]=\"true\">{{ text.repository }}</hm-button> }\n </span>\n </hm-settings-row>\n }\n @if (dependencies.length) {\n <hm-settings-row [label]=\"text.dependencies\" [description]=\"text.dependenciesDescription\" [keywords]=\"text.dependenciesKeywords\" [stacked]=\"true\">\n <dl class=\"sp-settings__deps\">\n @for (d of dependencies; track d.name) {\n <div><dt>{{ d.label }}</dt><dd class=\"sp-settings__mono\">{{ d.version }}</dd></div>\n }\n </dl>\n </hm-settings-row>\n }\n <hm-settings-row [label]=\"text.supportInfo\" [description]=\"text.supportInfoDescription\" [keywords]=\"text.supportInfoKeywords\">\n <hm-button emphasis=\"medium\" icon=\"clipboard\" (clicked)=\"copySupportInfo()\">{{ text.copy }}</hm-button>\n </hm-settings-row>\n </hm-settings-section>\n </hm-settings-dialog>\n}\n", styles: ["@charset \"UTF-8\";.sp-settings__hint{color:var(--hm-muted);font-size:var(--hm-font-size-xs)}.sp-settings__timezone{display:block;width:min(320px,100%)}.sp-settings__profile{display:flex;align-items:center;gap:var(--hm-space-xxxs);padding:var(--hm-space-xxxs) 0 var(--hm-space-sm)}.sp-settings__profile-text{display:flex;flex-direction:column;gap:var(--hm-space-micro);color:var(--hm-text)}.sp-settings__profile-text span{color:var(--hm-muted);font-size:var(--hm-font-size-sm)}.sp-settings__value{color:var(--hm-text);font-size:var(--hm-font-size-sm);text-align:end}.sp-settings__muted{color:var(--hm-muted)}.sp-settings__mono{font-family:Geist Mono,ui-monospace,monospace}.sp-settings__links{display:inline-flex;flex-wrap:wrap;justify-content:flex-end;gap:var(--hm-space-nano)}.sp-settings__deps{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:var(--hm-space-nano) var(--hm-space-sm);margin:0}.sp-settings__deps div{display:flex;justify-content:space-between;gap:var(--hm-space-xxxs);padding:var(--hm-space-nano) var(--hm-space-xxxs);border-radius:var(--hm-radius-control);background:var(--hm-hover)}.sp-settings__deps dt{color:var(--hm-muted);font-size:var(--hm-font-size-xs)}.sp-settings__deps dd{margin:0;color:var(--hm-text);font-size:var(--hm-font-size-xs)}\n"], dependencies: [{ kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }, { kind: "component", type: AppTokensComponent, selector: "spine-app-tokens" }, { kind: "component", type: AccountAvatarComponent, selector: "hm-account-avatar", inputs: ["name", "imageUrl", "size", "loading"] }, { kind: "component", type: ButtonComponent, selector: "hm-button", inputs: ["emphasis", "intent", "size", "type", "icon", "iconDirection", "iconEnd", "disabled", "loading", "busy", "loadingState", "block", "link", "newTab", "caret", "inMenu", "toggle", "pressed"], outputs: ["loadingStateChange", "pressedChange", "clicked"] }, { kind: "component", type: ComboboxComponent, selector: "hm-combobox", inputs: ["label", "options", "value", "placeholder", "icon", "hint", "help", "size", "labelHidden", "clearable", "requiredManual", "loading", "allowCustomValue"], outputs: ["valueChange"] }, { kind: "component", type: SegmentedControlComponent, selector: "hm-segmented-control", inputs: ["options", "value", "label", "size", "emphasis"], outputs: ["valueChange"] }, { kind: "component", type: SettingsDialogComponent, selector: "hm-settings-dialog", inputs: ["open", "heading", "activeSection", "closeHandler", "actions", "submitLabel", "cancelLabel"], outputs: ["openChange", "activeSectionChange", "closed", "submitted"] }, { kind: "component", type: SettingsRowComponent, selector: "hm-settings-row", inputs: ["label", "description", "keywords", "stacked"] }, { kind: "component", type: SettingsSectionComponent, selector: "hm-settings-section", inputs: ["key", "label", "icon", "description", "keywords", "control", "invalid"] }, { kind: "component", type: SelectComponent, selector: "hm-select", inputs: ["value", "label", "labelHidden", "hint"], outputs: ["valueChange"] }, { kind: "component", type: ShortcutListComponent, selector: "hm-shortcut-list", inputs: ["items", "registered", "searchable", "characterKeysToggle", "headingLevel"] }, { kind: "component", type: ThemePickerComponent, selector: "hm-theme-picker", inputs: ["families", "family", "theme", "showMode"], outputs: ["familyChosen", "themeChosen"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
1825
|
+
}
|
|
1826
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SettingsPanelComponent, decorators: [{
|
|
1827
|
+
type: Component,
|
|
1828
|
+
args: [{ selector: 'spine-settings-panel', imports: [
|
|
1829
|
+
NgComponentOutlet, AppTokensComponent,
|
|
1830
|
+
AccountAvatarComponent, ButtonComponent, ComboboxComponent, IconComponent,
|
|
1831
|
+
SegmentedControlComponent, SettingsDialogComponent, SettingsRowComponent, SettingsSectionComponent,
|
|
1832
|
+
SelectComponent, ShortcutListComponent, ThemePickerComponent,
|
|
1833
|
+
], changeDetection: ChangeDetectionStrategy.OnPush, template: "<!-- Montado s\u00F3 enquanto aberto: nada disto (lista de fusos, leitura do token...) custa no\n carregamento de toda p\u00E1gina. -->\n@if (settings.isOpen()) {\n <hm-settings-dialog\n [heading]=\"text.heading\"\n [open]=\"true\"\n (openChange)=\"onOpenChange($event)\"\n [activeSection]=\"settings.section()\"\n (activeSectionChange)=\"settings.section.set($event)\"\n >\n <!-- \u2500\u2500 Conta \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <hm-settings-section [key]=\"keys.account\" [label]=\"text.account\" icon=\"user\" [keywords]=\"text.accountKeywords\">\n <div class=\"sp-settings__profile\">\n <hm-account-avatar [name]=\"profile.name ?? text.user\" [imageUrl]=\"profile.picture\" />\n <span class=\"sp-settings__profile-text\">\n <strong>{{ profile.name ?? text.user }}</strong>\n @if (profile.role) { <span>{{ profile.role }}</span> }\n </span>\n </div>\n\n <!-- S\u00F3 informativo: a troca de n\u00EDvel fica no menu do perfil, perto de onde se trabalha. -->\n @if (badge) {\n <hm-settings-row [label]=\"text.accessLevel\" [keywords]=\"text.accessLevelKeywords\">\n <span class=\"sp-settings__value\">{{ badge.name }}</span>\n </hm-settings-row>\n }\n\n <hm-settings-row [label]=\"text.session\" [description]=\"text.sessionDescription\" [keywords]=\"text.sessionKeywords\">\n <span class=\"sp-settings__value\">{{ sessionExpiry }}</span>\n </hm-settings-row>\n\n <hm-settings-row [label]=\"text.accessToken\" [description]=\"text.accessTokenDescription\" [keywords]=\"text.accessTokenKeywords\">\n <hm-button emphasis=\"medium\" icon=\"copy\" (clicked)=\"copyToken()\">{{ text.copy }}</hm-button>\n </hm-settings-row>\n\n <hm-settings-row [label]=\"text.appTokens\" [stacked]=\"true\"\n [description]=\"text.appTokensDescription\"\n [keywords]=\"text.appTokensKeywords\">\n <spine-app-tokens />\n </hm-settings-row>\n\n <hm-settings-row [label]=\"text.logout\" [description]=\"text.logoutDescription\" [keywords]=\"text.logoutKeywords\">\n <hm-button emphasis=\"medium\" intent=\"danger\" icon=\"logout\" (clicked)=\"logout()\">{{ text.logout }}</hm-button>\n </hm-settings-row>\n </hm-settings-section>\n\n <!-- \u2500\u2500 Apar\u00EAncia \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <hm-settings-section [key]=\"keys.appearance\" [label]=\"text.appearance\" icon=\"color-palette\" [keywords]=\"text.appearanceKeywords\">\n <hm-settings-row [label]=\"text.theme\" [description]=\"text.themeDescription\" [keywords]=\"text.themeKeywords\">\n <hm-segmented-control emphasis=\"action\" [label]=\"text.theme\" [options]=\"themeOptions()\" [value]=\"themeMode()\" (valueChange)=\"setThemeMode($event)\" />\n </hm-settings-row>\n\n <hm-settings-row [label]=\"text.accent\" [description]=\"text.accentDescription\" [keywords]=\"text.accentKeywords\" [stacked]=\"true\">\n <hm-theme-picker\n [families]=\"families\"\n [family]=\"family()\"\n [theme]=\"appliedTheme()\"\n [showMode]=\"false\"\n (familyChosen)=\"setFamily($event)\"\n />\n </hm-settings-row>\n\n <hm-settings-row [label]=\"text.density\" [description]=\"text.densityDescription\" [keywords]=\"text.densityKeywords\">\n <hm-segmented-control emphasis=\"action\" [label]=\"text.density\" [options]=\"densityOptions()\" [value]=\"densityValue()\" (valueChange)=\"setDensity($event)\" />\n </hm-settings-row>\n\n <hm-settings-row [label]=\"text.font\" [description]=\"text.fontDescription\" [keywords]=\"text.fontKeywords\">\n <hm-segmented-control emphasis=\"action\" [label]=\"text.font\" [options]=\"fontOptions\" [value]=\"fontValue()\" (valueChange)=\"setFont($event)\" />\n </hm-settings-row>\n\n <hm-settings-row [label]=\"text.sidebar\" [description]=\"text.sidebarDescription\" [keywords]=\"text.sidebarKeywords\">\n <hm-segmented-control emphasis=\"action\" [label]=\"text.sidebar\" [options]=\"sidebarOptions()\" [value]=\"sidebar.mode()\" (valueChange)=\"setSidebarMode($event)\" />\n </hm-settings-row>\n </hm-settings-section>\n\n <!-- \u2500\u2500 Regi\u00E3o \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <hm-settings-section [key]=\"keys.region\" [label]=\"text.region\" icon=\"clock\" [keywords]=\"text.regionKeywords\">\n <hm-settings-row [label]=\"text.language\" [description]=\"text.languageDescription\" [keywords]=\"text.languageKeywords\">\n <hm-select\n class=\"sp-settings__language\"\n [label]=\"text.language\"\n [labelHidden]=\"true\"\n [value]=\"preferences.language()\"\n (valueChange)=\"preferences.setLanguage($event ?? '')\"\n >\n @for (option of languageOptions(); track option.value) {\n <option [value]=\"option.value\" [attr.lang]=\"option.value || null\">{{ option.label }}</option>\n }\n </hm-select>\n </hm-settings-row>\n\n <hm-settings-row [label]=\"text.timeZone\" [keywords]=\"text.timeZoneKeywords\">\n <span rowDescription class=\"sp-settings__hint\">{{ fmt(text.timeZoneHint, { now: timeZonePreview() }) }}</span>\n <hm-combobox\n class=\"sp-settings__timezone\"\n [label]=\"text.timeZone\"\n [labelHidden]=\"true\"\n icon=\"world\"\n [options]=\"timeZoneOptions()\"\n [value]=\"preferences.timeZone()\"\n (valueChange)=\"preferences.setTimeZone($event)\"\n />\n </hm-settings-row>\n </hm-settings-section>\n\n <!-- \u2500\u2500 Atalhos \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <hm-settings-section [key]=\"keys.shortcuts\" [label]=\"text.shortcuts\" icon=\"keyboard\" [description]=\"text.shortcutsDescription\" [keywords]=\"text.shortcutsKeywords\">\n <!-- Mesma lista do painel \"?\", sem busca (a do di\u00E1logo de configura\u00E7\u00F5es j\u00E1 cobre a se\u00E7\u00E3o);\n o interruptor de tecla \u00FAnica (WCAG 2.1.4) persiste no SpinePreferencesService. -->\n <hm-shortcut-list characterKeysToggle [registered]=\"false\" [items]=\"shortcutItems()\" [headingLevel]=\"4\" />\n <hm-settings-row [label]=\"text.tour\" [description]=\"text.tourDescription\" [keywords]=\"text.tourKeywords\">\n <hm-button emphasis=\"medium\" icon=\"play\" (clicked)=\"replayTour()\">{{ text.replayTour }}</hm-button>\n </hm-settings-row>\n </hm-settings-section>\n\n <!-- \u2500\u2500 Se\u00E7\u00F5es do app (provideSettingsSection) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n @for (s of appSections; track s.key) {\n <hm-settings-section [key]=\"s.key\" [label]=\"s.label\" [icon]=\"s.icon ?? 'cog'\" [description]=\"s.description ?? ''\" [keywords]=\"s.keywords ?? ''\">\n <ng-container *ngComponentOutlet=\"s.component\" />\n </hm-settings-section>\n }\n\n <!-- \u2500\u2500 Sobre \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <hm-settings-section [key]=\"keys.about\" [label]=\"text.about\" icon=\"info-circle\" [keywords]=\"text.aboutKeywords\">\n <hm-settings-row [label]=\"text.application\">\n <span class=\"sp-settings__value\">{{ appName }}@if (packageInfo.name) { <span class=\"sp-settings__muted\"> \u00B7 {{ packageInfo.name }}</span> }</span>\n </hm-settings-row>\n <hm-settings-row [label]=\"text.version\" [keywords]=\"text.versionKeywords\">\n <span class=\"sp-settings__value sp-settings__mono\">{{ packageInfo.version ?? '\u2014' }}</span>\n </hm-settings-row>\n @if (buildDate) {\n <hm-settings-row [label]=\"text.buildDate\">\n <span class=\"sp-settings__value\">{{ buildDate }}</span>\n </hm-settings-row>\n }\n @if (packageInfo.environment) {\n <hm-settings-row [label]=\"text.environment\" [keywords]=\"text.environmentKeywords\">\n <span class=\"sp-settings__value\">{{ packageInfo.environment }}</span>\n </hm-settings-row>\n }\n @if (packageInfo.repository?.url || packageInfo.docs || packageInfo.issues) {\n <hm-settings-row [label]=\"text.links\" [keywords]=\"text.linksKeywords\">\n <span class=\"sp-settings__links\">\n @if (packageInfo.docs) { <hm-button emphasis=\"minimal\" icon=\"book\" [link]=\"packageInfo.docs\" [newTab]=\"true\">{{ text.docs }}</hm-button> }\n @if (packageInfo.issues) { <hm-button emphasis=\"minimal\" icon=\"bug\" [link]=\"packageInfo.issues\" [newTab]=\"true\">{{ text.reportIssue }}</hm-button> }\n @if (packageInfo.repository?.url) { <hm-button emphasis=\"minimal\" icon=\"link\" [link]=\"packageInfo.repository!.url!\" [newTab]=\"true\">{{ text.repository }}</hm-button> }\n </span>\n </hm-settings-row>\n }\n @if (dependencies.length) {\n <hm-settings-row [label]=\"text.dependencies\" [description]=\"text.dependenciesDescription\" [keywords]=\"text.dependenciesKeywords\" [stacked]=\"true\">\n <dl class=\"sp-settings__deps\">\n @for (d of dependencies; track d.name) {\n <div><dt>{{ d.label }}</dt><dd class=\"sp-settings__mono\">{{ d.version }}</dd></div>\n }\n </dl>\n </hm-settings-row>\n }\n <hm-settings-row [label]=\"text.supportInfo\" [description]=\"text.supportInfoDescription\" [keywords]=\"text.supportInfoKeywords\">\n <hm-button emphasis=\"medium\" icon=\"clipboard\" (clicked)=\"copySupportInfo()\">{{ text.copy }}</hm-button>\n </hm-settings-row>\n </hm-settings-section>\n </hm-settings-dialog>\n}\n", styles: ["@charset \"UTF-8\";.sp-settings__hint{color:var(--hm-muted);font-size:var(--hm-font-size-xs)}.sp-settings__timezone{display:block;width:min(320px,100%)}.sp-settings__profile{display:flex;align-items:center;gap:var(--hm-space-xxxs);padding:var(--hm-space-xxxs) 0 var(--hm-space-sm)}.sp-settings__profile-text{display:flex;flex-direction:column;gap:var(--hm-space-micro);color:var(--hm-text)}.sp-settings__profile-text span{color:var(--hm-muted);font-size:var(--hm-font-size-sm)}.sp-settings__value{color:var(--hm-text);font-size:var(--hm-font-size-sm);text-align:end}.sp-settings__muted{color:var(--hm-muted)}.sp-settings__mono{font-family:Geist Mono,ui-monospace,monospace}.sp-settings__links{display:inline-flex;flex-wrap:wrap;justify-content:flex-end;gap:var(--hm-space-nano)}.sp-settings__deps{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:var(--hm-space-nano) var(--hm-space-sm);margin:0}.sp-settings__deps div{display:flex;justify-content:space-between;gap:var(--hm-space-xxxs);padding:var(--hm-space-nano) var(--hm-space-xxxs);border-radius:var(--hm-radius-control);background:var(--hm-hover)}.sp-settings__deps dt{color:var(--hm-muted);font-size:var(--hm-font-size-xs)}.sp-settings__deps dd{margin:0;color:var(--hm-text);font-size:var(--hm-font-size-xs)}\n"] }]
|
|
1834
|
+
}] });
|
|
1835
|
+
/** "UTC−03:00" para um fuso IANA. */
|
|
1836
|
+
function offsetOf(timeZone) {
|
|
1837
|
+
try {
|
|
1838
|
+
const part = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'longOffset' })
|
|
1839
|
+
.formatToParts(new Date())
|
|
1840
|
+
.find((p) => p.type === 'timeZoneName')?.value;
|
|
1841
|
+
return (part ?? 'GMT').replace('GMT', 'UTC').replace('-', '−') || 'UTC';
|
|
1842
|
+
}
|
|
1843
|
+
catch {
|
|
1844
|
+
return 'UTC';
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
function decodeTokenPayload(token) {
|
|
1848
|
+
const payload = token?.split('.')[1];
|
|
1849
|
+
if (!payload)
|
|
1850
|
+
return null;
|
|
1851
|
+
try {
|
|
1852
|
+
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '='));
|
|
1853
|
+
return JSON.parse(json);
|
|
1854
|
+
}
|
|
1855
|
+
catch {
|
|
1856
|
+
return null;
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
/**
|
|
1861
|
+
* Estado da documentação das telas: o que está registrado em `COMPONENT_ROUTES` com `document`,
|
|
1862
|
+
* filtrado pelas telas que existem na árvore da pessoa (documento de tela sem acesso nunca é oferecido),
|
|
1863
|
+
* e a leitura em andamento no modal `spine-docs`.
|
|
1864
|
+
*/
|
|
1865
|
+
class SpineDocsService {
|
|
1866
|
+
#distributor;
|
|
1867
|
+
#views;
|
|
1868
|
+
#messages;
|
|
1869
|
+
#isOpen;
|
|
1870
|
+
#current;
|
|
1871
|
+
#view;
|
|
1872
|
+
#content;
|
|
1873
|
+
#loading;
|
|
1874
|
+
#failed;
|
|
1875
|
+
/** Identifica a leitura vigente; respostas de leituras anteriores são ignoradas. */
|
|
1876
|
+
#ticket;
|
|
1877
|
+
/** A view do Orfeu não é um signal: este contador força a releitura da árvore. */
|
|
1878
|
+
#treeRevision;
|
|
1879
|
+
#offered;
|
|
1880
|
+
constructor() {
|
|
1881
|
+
this.#distributor = inject(ComponentDistributorService);
|
|
1882
|
+
this.#views = inject(ViewService);
|
|
1883
|
+
this.#messages = inject(SPINE_MESSAGES);
|
|
1884
|
+
/** Visão técnica liberada (`SpineConfig.technicalDocs`; sem configuração, só em desenvolvimento). */
|
|
1885
|
+
this.technicalAvailable = inject(SPINE_CONFIG, { optional: true })?.technicalDocs ?? isDevMode();
|
|
1886
|
+
this.#isOpen = signal(false, ...(ngDevMode ? [{ debugName: "#isOpen" }] : /* istanbul ignore next */ []));
|
|
1887
|
+
this.#current = signal(null, ...(ngDevMode ? [{ debugName: "#current" }] : /* istanbul ignore next */ []));
|
|
1888
|
+
this.#view = signal('usage', ...(ngDevMode ? [{ debugName: "#view" }] : /* istanbul ignore next */ []));
|
|
1889
|
+
this.#content = signal('', ...(ngDevMode ? [{ debugName: "#content" }] : /* istanbul ignore next */ []));
|
|
1890
|
+
this.#loading = signal(false, ...(ngDevMode ? [{ debugName: "#loading" }] : /* istanbul ignore next */ []));
|
|
1891
|
+
this.#failed = signal(false, ...(ngDevMode ? [{ debugName: "#failed" }] : /* istanbul ignore next */ []));
|
|
1892
|
+
/** Identifica a leitura vigente; respostas de leituras anteriores são ignoradas. */
|
|
1893
|
+
this.#ticket = 0;
|
|
1894
|
+
/** A view do Orfeu não é um signal: este contador força a releitura da árvore. */
|
|
1895
|
+
this.#treeRevision = signal(0, ...(ngDevMode ? [{ debugName: "#treeRevision" }] : /* istanbul ignore next */ []));
|
|
1896
|
+
this.isOpen = this.#isOpen.asReadonly();
|
|
1897
|
+
this.current = this.#current.asReadonly();
|
|
1898
|
+
this.view = this.#view.asReadonly();
|
|
1899
|
+
this.content = this.#content.asReadonly();
|
|
1900
|
+
this.loading = this.#loading.asReadonly();
|
|
1901
|
+
this.error = computed(() => (this.#failed() ? this.#messages.docs.loadError : ''), ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
1902
|
+
this.showTechnical = computed(() => this.technicalAvailable && !!this.#current()?.loadTechnical, ...(ngDevMode ? [{ debugName: "showTechnical" }] : /* istanbul ignore next */ []));
|
|
1903
|
+
this.#offered = computed(() => {
|
|
1904
|
+
this.#treeRevision();
|
|
1905
|
+
const screens = this.#screens();
|
|
1906
|
+
const offered = new Map();
|
|
1907
|
+
for (const registered of this.#distributor.entries) {
|
|
1908
|
+
const document = registered.document;
|
|
1909
|
+
if (!document)
|
|
1910
|
+
continue;
|
|
1911
|
+
const key = (componentRouterId(registered) ?? registered.source_template_router_id)?.toLowerCase();
|
|
1912
|
+
if (!key || offered.has(key))
|
|
1913
|
+
continue;
|
|
1914
|
+
const screen = screens.get(key);
|
|
1915
|
+
if (!screen)
|
|
1916
|
+
continue;
|
|
1917
|
+
offered.set(key, {
|
|
1918
|
+
...document,
|
|
1919
|
+
id: key,
|
|
1920
|
+
title: document.title ?? screen.label,
|
|
1921
|
+
group: document.group ?? screen.module,
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
return offered;
|
|
1925
|
+
}, ...(ngDevMode ? [{ debugName: "#offered" }] : /* istanbul ignore next */ []));
|
|
1926
|
+
this.index = computed(() => {
|
|
1927
|
+
const locale = this.#messages.meta.locale;
|
|
1928
|
+
const compare = (a, b) => a.localeCompare(b, locale);
|
|
1929
|
+
const byGroup = new Map();
|
|
1930
|
+
for (const entry of this.#offered().values()) {
|
|
1931
|
+
const list = byGroup.get(entry.group);
|
|
1932
|
+
if (list)
|
|
1933
|
+
list.push(entry);
|
|
1934
|
+
else
|
|
1935
|
+
byGroup.set(entry.group, [entry]);
|
|
1936
|
+
}
|
|
1937
|
+
return [...byGroup]
|
|
1938
|
+
.map(([title, entries]) => ({ title, entries: entries.sort((a, b) => compare(a.title, b.title)) }))
|
|
1939
|
+
.sort((a, b) => compare(a.title, b.title));
|
|
1940
|
+
}, ...(ngDevMode ? [{ debugName: "index" }] : /* istanbul ignore next */ []));
|
|
1941
|
+
// Quando a view muda (login, troca de crachá), o botão da barra da página acompanha sem esperar
|
|
1942
|
+
// a próxima abertura.
|
|
1943
|
+
const changes = this.#views.currentView$;
|
|
1944
|
+
// A emissão síncrona da inscrição é a view que o `#offered` já lê; só as seguintes importam.
|
|
1945
|
+
let subscribed = false;
|
|
1946
|
+
const subscription = changes?.subscribe?.(() => {
|
|
1947
|
+
if (subscribed)
|
|
1948
|
+
this.#refreshTree();
|
|
1949
|
+
});
|
|
1950
|
+
subscribed = true;
|
|
1951
|
+
if (subscription)
|
|
1952
|
+
inject(DestroyRef).onDestroy(() => subscription.unsubscribe());
|
|
1953
|
+
}
|
|
1954
|
+
documentFor(routerId, sourceTemplateRouterId) {
|
|
1955
|
+
const offered = this.#offered();
|
|
1956
|
+
for (const candidate of [routerId, sourceTemplateRouterId]) {
|
|
1957
|
+
if (!candidate)
|
|
1958
|
+
continue;
|
|
1959
|
+
const entry = offered.get(candidate.toLowerCase());
|
|
1960
|
+
if (entry)
|
|
1961
|
+
return entry;
|
|
1962
|
+
}
|
|
1963
|
+
return null;
|
|
1964
|
+
}
|
|
1965
|
+
openForRoute(routerId, sourceTemplateRouterId) {
|
|
1966
|
+
this.#refreshTree();
|
|
1967
|
+
this.#isOpen.set(true);
|
|
1968
|
+
const entry = this.documentFor(routerId, sourceTemplateRouterId);
|
|
1969
|
+
if (entry)
|
|
1970
|
+
void this.open(entry);
|
|
1971
|
+
}
|
|
1972
|
+
openIndex() {
|
|
1973
|
+
this.#refreshTree();
|
|
1974
|
+
this.#isOpen.set(true);
|
|
1975
|
+
}
|
|
1976
|
+
close() {
|
|
1977
|
+
this.#isOpen.set(false);
|
|
1978
|
+
}
|
|
1979
|
+
open(entry, view = 'usage') {
|
|
1980
|
+
this.#current.set(entry);
|
|
1981
|
+
return this.show(view);
|
|
1982
|
+
}
|
|
1983
|
+
async show(view) {
|
|
1984
|
+
const entry = this.#current();
|
|
1985
|
+
if (!entry)
|
|
1986
|
+
return;
|
|
1987
|
+
const technical = view === 'technical' && this.technicalAvailable ? entry.loadTechnical : undefined;
|
|
1988
|
+
const ticket = ++this.#ticket;
|
|
1989
|
+
this.#view.set(technical ? 'technical' : 'usage');
|
|
1990
|
+
this.#loading.set(true);
|
|
1991
|
+
this.#failed.set(false);
|
|
1992
|
+
let text = '';
|
|
1993
|
+
let failed = false;
|
|
1994
|
+
try {
|
|
1995
|
+
text = await (technical ?? entry.load)();
|
|
1996
|
+
}
|
|
1997
|
+
catch {
|
|
1998
|
+
failed = true;
|
|
1999
|
+
}
|
|
2000
|
+
if (ticket !== this.#ticket)
|
|
2001
|
+
return;
|
|
2002
|
+
this.#content.set(failed ? '' : text);
|
|
2003
|
+
this.#failed.set(failed);
|
|
2004
|
+
this.#loading.set(false);
|
|
2005
|
+
}
|
|
2006
|
+
#refreshTree() {
|
|
2007
|
+
this.#treeRevision.update((n) => n + 1);
|
|
2008
|
+
}
|
|
2009
|
+
/** Telas da árvore da pessoa, por router_id e por template, com rótulo e módulo de topo. */
|
|
2010
|
+
#screens() {
|
|
2011
|
+
const screens = new Map();
|
|
2012
|
+
const visit = (node, module) => {
|
|
2013
|
+
const info = { label: node.route_display_name ?? '', module };
|
|
2014
|
+
for (const key of [routerIdOf(node), node.source_template_router_id]) {
|
|
2015
|
+
if (key && !screens.has(key.toLowerCase()))
|
|
2016
|
+
screens.set(key.toLowerCase(), info);
|
|
2017
|
+
}
|
|
2018
|
+
for (const child of node.routes ?? [])
|
|
2019
|
+
visit(child, module);
|
|
2020
|
+
};
|
|
2021
|
+
for (const root of spineRoutesOf(this.#views.currentView))
|
|
2022
|
+
visit(root, root.route_display_name ?? '');
|
|
2023
|
+
return screens;
|
|
2024
|
+
}
|
|
2025
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineDocsService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
2026
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineDocsService, providedIn: 'root' }); }
|
|
2027
|
+
}
|
|
2028
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineDocsService, decorators: [{
|
|
2029
|
+
type: Injectable,
|
|
2030
|
+
args: [{ providedIn: 'root' }]
|
|
2031
|
+
}], ctorParameters: () => [] });
|
|
2032
|
+
|
|
2033
|
+
/**
|
|
2034
|
+
* Barra de contexto do topo de CADA painel do workspace: título e ícone da página e as
|
|
2035
|
+
* ações que valem pra página inteira (favoritar, copiar link, documentação) em cima; a descrição da
|
|
2036
|
+
* tela logo abaixo do título; a trilha (breadcrumb) embaixo.
|
|
2037
|
+
*
|
|
2038
|
+
* A descrição é do ROUTER (`web.router.description`, editada no Argos), não da tela: é aqui que todo
|
|
2039
|
+
* app mostra "o que esta página faz", então as telas NÃO repetem título+descrição internos.
|
|
2040
|
+
* Tela identificada pelo `router_id` do nó 'page' (`routerIdOf`), nunca pelo `web.routes.route_id`.
|
|
2041
|
+
*
|
|
2042
|
+
* Mora dentro do painel (não num shell global) porque cada painel do `hm-workspace` resolve a
|
|
2043
|
+
* própria aba: dois painéis lado a lado mostram duas trilhas diferentes ao mesmo tempo. Por isso
|
|
2044
|
+
* recebe a `chain` (header→…→page) do painel em vez de ler a "página atual" de algum serviço global.
|
|
2045
|
+
*
|
|
2046
|
+
* Só o painel focado alimenta os "recentes" (`recordRecent`) — os outros apenas exibem.
|
|
2047
|
+
*
|
|
2048
|
+
* Favoritos e recentes moram no `SpineQuickAccessService` (compartilhado com a tela inicial): o
|
|
2049
|
+
* coração daqui e a seção "Favoritos" do Início mudam juntos.
|
|
2050
|
+
*/
|
|
2051
|
+
class SpinePageBarComponent {
|
|
2052
|
+
openDocs() {
|
|
2053
|
+
this.docs.openForRoute(this.routerId(), this.sourceTemplateRouterId());
|
|
2054
|
+
}
|
|
2055
|
+
constructor() {
|
|
2056
|
+
this.text = inject(SPINE_MESSAGES).pageBar;
|
|
2057
|
+
/** Cadeia resolvida (header→…→page) do painel; `null` enquanto a URL não resolve. */
|
|
2058
|
+
this.chain = input(null, ...(ngDevMode ? [{ debugName: "chain" }] : /* istanbul ignore next */ []));
|
|
2059
|
+
/** Só o painel focado registra a página em "recentes". */
|
|
2060
|
+
this.recordRecent = input(false, ...(ngDevMode ? [{ debugName: "recordRecent" }] : /* istanbul ignore next */ []));
|
|
2061
|
+
this.linkCopied = signal(false, ...(ngDevMode ? [{ debugName: "linkCopied" }] : /* istanbul ignore next */ []));
|
|
2062
|
+
this.docs = inject(SpineDocsService);
|
|
2063
|
+
/** A página (último nó da cadeia). */
|
|
2064
|
+
this.page = computed(() => this.chain()?.at(-1) ?? null, ...(ngDevMode ? [{ debugName: "page" }] : /* istanbul ignore next */ []));
|
|
2065
|
+
/** router_id da página -- chave do documento dela (o router do template entra como alternativa). */
|
|
2066
|
+
this.routerId = computed(() => routerIdOf(this.page()), ...(ngDevMode ? [{ debugName: "routerId" }] : /* istanbul ignore next */ []));
|
|
2067
|
+
this.sourceTemplateRouterId = computed(() => this.page()?.source_template_router_id ?? null, ...(ngDevMode ? [{ debugName: "sourceTemplateRouterId" }] : /* istanbul ignore next */ []));
|
|
2068
|
+
this.hasDoc = computed(() => !!this.docs.documentFor(this.routerId(), this.sourceTemplateRouterId()), ...(ngDevMode ? [{ debugName: "hasDoc" }] : /* istanbul ignore next */ []));
|
|
2069
|
+
/** Descrição do router da página (`web.router.description`); vazia = sem a linha. */
|
|
2070
|
+
this.description = computed(() => this.page()?.description?.trim() || '', ...(ngDevMode ? [{ debugName: "description" }] : /* istanbul ignore next */ []));
|
|
2071
|
+
this.quickAccess = inject(SpineQuickAccessService);
|
|
2072
|
+
this.crumbs = computed(() => (this.chain() ?? [])
|
|
2073
|
+
.filter((r) => !!r.route_display_name)
|
|
2074
|
+
.map((r) => ({ label: r.route_display_name, link: r.route_redirect ?? '' })), ...(ngDevMode ? [{ debugName: "crumbs" }] : /* istanbul ignore next */ []));
|
|
2075
|
+
/** Degraus do `hm-breadcrumb`: nível sem endereço (grupo) vira texto, sem link. */
|
|
2076
|
+
this.trail = computed(() => this.crumbs().map(({ label, link }) => (link ? { label, routerLink: link } : { label })), ...(ngDevMode ? [{ debugName: "trail" }] : /* istanbul ignore next */ []));
|
|
2077
|
+
this.title = computed(() => this.crumbs().at(-1)?.label ?? '', ...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
|
|
2078
|
+
/** Ícone da própria página; cai no do módulo quando a página não tem. */
|
|
2079
|
+
this.icon = computed(() => {
|
|
2080
|
+
const chain = this.chain() ?? [];
|
|
2081
|
+
return chain.at(-1)?.route_icon || chain[0]?.route_icon || null;
|
|
2082
|
+
}, ...(ngDevMode ? [{ debugName: "icon" }] : /* istanbul ignore next */ []));
|
|
2083
|
+
/** A página do painel como item de favorito/recente, com a trilha ("onde fica") dela. */
|
|
2084
|
+
this.currentItem = computed(() => {
|
|
2085
|
+
const crumbs = this.crumbs();
|
|
2086
|
+
const last = crumbs.at(-1);
|
|
2087
|
+
if (!last?.link)
|
|
2088
|
+
return null;
|
|
2089
|
+
return {
|
|
2090
|
+
id: last.link,
|
|
2091
|
+
icon: this.icon() || 'application',
|
|
2092
|
+
displayName: last.label,
|
|
2093
|
+
redirect: last.link,
|
|
2094
|
+
trail: crumbs.slice(0, -1).map((c) => c.label),
|
|
2095
|
+
};
|
|
2096
|
+
}, ...(ngDevMode ? [{ debugName: "currentItem" }] : /* istanbul ignore next */ []));
|
|
2097
|
+
this.isFavorite = computed(() => this.quickAccess.isFavorite(this.currentItem()?.id), ...(ngDevMode ? [{ debugName: "isFavorite" }] : /* istanbul ignore next */ []));
|
|
2098
|
+
effect(() => {
|
|
2099
|
+
const item = this.currentItem();
|
|
2100
|
+
if (this.recordRecent() && item)
|
|
2101
|
+
this.quickAccess.recordVisit(item);
|
|
2102
|
+
});
|
|
2103
|
+
}
|
|
2104
|
+
toggleFavorite() {
|
|
2105
|
+
const item = this.currentItem();
|
|
2106
|
+
if (item)
|
|
2107
|
+
this.quickAccess.toggleFavorite(item);
|
|
2108
|
+
}
|
|
2109
|
+
copyLink() {
|
|
2110
|
+
try {
|
|
2111
|
+
void navigator.clipboard?.writeText(window.location.href);
|
|
2112
|
+
this.linkCopied.set(true);
|
|
2113
|
+
setTimeout(() => this.linkCopied.set(false), 2000);
|
|
2114
|
+
}
|
|
2115
|
+
catch {
|
|
2116
|
+
/* clipboard indisponível */
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpinePageBarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
2120
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: SpinePageBarComponent, isStandalone: true, selector: "spine-page-bar", inputs: { chain: { classPropertyName: "chain", publicName: "chain", isSignal: true, isRequired: false, transformFunction: null }, recordRecent: { classPropertyName: "recordRecent", publicName: "recordRecent", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
|
|
2121
|
+
@if (title(); as pageTitle) {
|
|
2122
|
+
<header class="spine-page-bar" data-tour="breadcrumb">
|
|
2123
|
+
<div class="spine-page-bar__identity">
|
|
2124
|
+
<div class="spine-page-bar__title-row">
|
|
2125
|
+
@if (icon(); as ic) {
|
|
2126
|
+
<hm-icon class="spine-page-bar__icon" [shape]="ic" />
|
|
2127
|
+
}
|
|
2128
|
+
<h1 class="spine-page-bar__title">{{ pageTitle }}</h1>
|
|
2129
|
+
|
|
2130
|
+
<span class="spine-page-bar__actions">
|
|
2131
|
+
<!-- O estado vai no nome ("Favoritar" / "Remover dos favoritos"). Sem aria-pressed: no host
|
|
2132
|
+
<hm-icon-button> ele caía num elemento sem papel, e não no <button> (o leitor não lia).
|
|
2133
|
+
A dica (hmTooltip) repete o nome para quem enxerga: só ícone, sem texto, o botão não diz
|
|
2134
|
+
o que faz até ser clicado. -->
|
|
2135
|
+
<hm-icon-button
|
|
2136
|
+
emphasis="subtle"
|
|
2137
|
+
icon="heart" [solid]="isFavorite()"
|
|
2138
|
+
[label]="isFavorite() ? text.unfavorite : text.favorite"
|
|
2139
|
+
[hmTooltip]="isFavorite() ? text.unfavorite : text.favorite"
|
|
2140
|
+
[class.spine-page-bar__fav--on]="isFavorite()"
|
|
2141
|
+
(clicked)="toggleFavorite()"
|
|
2142
|
+
/>
|
|
2143
|
+
<hm-icon-button
|
|
2144
|
+
emphasis="subtle"
|
|
2145
|
+
[icon]="linkCopied() ? 'check' : 'copy'"
|
|
2146
|
+
[label]="linkCopied() ? text.linkCopied : text.copyLink"
|
|
2147
|
+
[hmTooltip]="linkCopied() ? text.linkCopied : text.copyLink"
|
|
2148
|
+
(clicked)="copyLink()"
|
|
2149
|
+
/>
|
|
2150
|
+
<!-- Só quando a tela tem documento registrado (\`document\` no mapa de componentes). -->
|
|
2151
|
+
@if (hasDoc()) {
|
|
2152
|
+
<hm-icon-button emphasis="subtle" icon="doc-list" [label]="text.docs" [hmTooltip]="text.docs" (clicked)="openDocs()" />
|
|
2153
|
+
}
|
|
2154
|
+
</span>
|
|
2155
|
+
</div>
|
|
2156
|
+
|
|
2157
|
+
@if (description(); as desc) {
|
|
2158
|
+
<p class="spine-page-bar__description">{{ desc }}</p>
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
<!-- Trilha: o hm-breadcrumb da lib (nav nomeado, lista ordenada, aria-current no último degrau,
|
|
2162
|
+
recolhe o meio de trilha longa). Só com mais de um nível — numa página de nível 1 a trilha
|
|
2163
|
+
repetiria o título. -->
|
|
2164
|
+
@if (crumbs().length > 1) {
|
|
2165
|
+
<hm-breadcrumb class="spine-page-bar__trail" [items]="trail()" [label]="text.trail" />
|
|
2166
|
+
}
|
|
2167
|
+
</div>
|
|
2168
|
+
|
|
2169
|
+
<!-- Conteúdo projetado pela tela (botões dela). -->
|
|
2170
|
+
<div class="spine-page-bar__extra"><ng-content /></div>
|
|
2171
|
+
</header>
|
|
2172
|
+
}
|
|
2173
|
+
`, isInline: true, styles: [":host{display:block}.spine-page-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:var(--hm-space-sm);padding:var(--hm-space-sm) var(--hm-space-md);margin:0;background:var(--hm-surface-raised);border-block-end:var(--hm-border-hairline, 1px) solid var(--hm-divider)}.spine-page-bar__identity{display:flex;flex:1 1 auto;min-inline-size:0;flex-direction:column;gap:calc(var(--hm-space-md) - var(--hm-space-micro))}.spine-page-bar__title-row{display:flex;align-items:center;gap:var(--hm-space-xs);min-inline-size:0}.spine-page-bar__icon{flex:none;color:var(--hm-accent);font-size:var(--hm-font-size-xxl, 28px)}.spine-page-bar__title{min-inline-size:0;margin:0;overflow:hidden;color:var(--hm-text);font-size:var(--hm-font-size-xl, 22px);font-weight:var(--hm-font-weight-bold, 700);line-height:var(--hm-line-height-tight, 1.2);text-overflow:ellipsis;white-space:nowrap}.spine-page-bar__description{max-inline-size:80ch;margin:calc(var(--hm-space-nano) - (var(--hm-space-md) - var(--hm-space-micro))) 0 0;color:var(--hm-muted);font-size:var(--hm-font-size-sm, 14px);line-height:var(--hm-line-height-normal, 1.5)}.spine-page-bar__actions{display:inline-flex;flex:none;align-items:center;gap:var(--hm-space-micro);margin-inline-start:var(--hm-space-nano)}.spine-page-bar__fav--on{color:var(--hm-danger)}.spine-page-bar__trail{margin-inline-start:calc(-1 * var(--hm-space-micro));margin-block-end:calc(-1 * var(--hm-space-micro))}.spine-page-bar__extra{display:flex;flex:none;align-items:center;gap:var(--hm-space-xxxs)}.spine-page-bar__extra:empty{display:none}\n"], dependencies: [{ kind: "component", type: BreadcrumbComponent, selector: "hm-breadcrumb", inputs: ["items", "maxItems", "itemsBeforeCollapse", "itemsAfterCollapse", "label", "loading"], outputs: ["itemClick"] }, { kind: "component", type: IconComponent, selector: "hm-icon", inputs: ["shape", "direction", "size", "solid", "badge", "status", "flip", "inverse", "label"] }, { kind: "component", type: IconButtonComponent, selector: "hm-icon-button", inputs: ["label", "icon", "solid", "iconDirection", "emphasis", "intent", "size", "disabled", "toggle", "pressed", "keyShortcuts"], outputs: ["pressedChange", "clicked"] }, { kind: "directive", type: TooltipDirective, selector: "[hmTooltip]", inputs: ["hmTooltip", "hmTooltipSide", "hmTooltipDisabled", "hmTooltipShowDelay", "hmTooltipHideDelay"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
2174
|
+
}
|
|
2175
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpinePageBarComponent, decorators: [{
|
|
2176
|
+
type: Component,
|
|
2177
|
+
args: [{ selector: 'spine-page-bar', imports: [BreadcrumbComponent, IconComponent, IconButtonComponent, TooltipDirective], changeDetection: ChangeDetectionStrategy.OnPush, template: `
|
|
2178
|
+
@if (title(); as pageTitle) {
|
|
2179
|
+
<header class="spine-page-bar" data-tour="breadcrumb">
|
|
2180
|
+
<div class="spine-page-bar__identity">
|
|
2181
|
+
<div class="spine-page-bar__title-row">
|
|
2182
|
+
@if (icon(); as ic) {
|
|
2183
|
+
<hm-icon class="spine-page-bar__icon" [shape]="ic" />
|
|
2184
|
+
}
|
|
2185
|
+
<h1 class="spine-page-bar__title">{{ pageTitle }}</h1>
|
|
2186
|
+
|
|
2187
|
+
<span class="spine-page-bar__actions">
|
|
2188
|
+
<!-- O estado vai no nome ("Favoritar" / "Remover dos favoritos"). Sem aria-pressed: no host
|
|
2189
|
+
<hm-icon-button> ele caía num elemento sem papel, e não no <button> (o leitor não lia).
|
|
2190
|
+
A dica (hmTooltip) repete o nome para quem enxerga: só ícone, sem texto, o botão não diz
|
|
2191
|
+
o que faz até ser clicado. -->
|
|
2192
|
+
<hm-icon-button
|
|
2193
|
+
emphasis="subtle"
|
|
2194
|
+
icon="heart" [solid]="isFavorite()"
|
|
2195
|
+
[label]="isFavorite() ? text.unfavorite : text.favorite"
|
|
2196
|
+
[hmTooltip]="isFavorite() ? text.unfavorite : text.favorite"
|
|
2197
|
+
[class.spine-page-bar__fav--on]="isFavorite()"
|
|
2198
|
+
(clicked)="toggleFavorite()"
|
|
2199
|
+
/>
|
|
2200
|
+
<hm-icon-button
|
|
2201
|
+
emphasis="subtle"
|
|
2202
|
+
[icon]="linkCopied() ? 'check' : 'copy'"
|
|
2203
|
+
[label]="linkCopied() ? text.linkCopied : text.copyLink"
|
|
2204
|
+
[hmTooltip]="linkCopied() ? text.linkCopied : text.copyLink"
|
|
2205
|
+
(clicked)="copyLink()"
|
|
2206
|
+
/>
|
|
2207
|
+
<!-- Só quando a tela tem documento registrado (\`document\` no mapa de componentes). -->
|
|
2208
|
+
@if (hasDoc()) {
|
|
2209
|
+
<hm-icon-button emphasis="subtle" icon="doc-list" [label]="text.docs" [hmTooltip]="text.docs" (clicked)="openDocs()" />
|
|
2210
|
+
}
|
|
2211
|
+
</span>
|
|
2212
|
+
</div>
|
|
2213
|
+
|
|
2214
|
+
@if (description(); as desc) {
|
|
2215
|
+
<p class="spine-page-bar__description">{{ desc }}</p>
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
<!-- Trilha: o hm-breadcrumb da lib (nav nomeado, lista ordenada, aria-current no último degrau,
|
|
2219
|
+
recolhe o meio de trilha longa). Só com mais de um nível — numa página de nível 1 a trilha
|
|
2220
|
+
repetiria o título. -->
|
|
2221
|
+
@if (crumbs().length > 1) {
|
|
2222
|
+
<hm-breadcrumb class="spine-page-bar__trail" [items]="trail()" [label]="text.trail" />
|
|
2223
|
+
}
|
|
2224
|
+
</div>
|
|
2225
|
+
|
|
2226
|
+
<!-- Conteúdo projetado pela tela (botões dela). -->
|
|
2227
|
+
<div class="spine-page-bar__extra"><ng-content /></div>
|
|
2228
|
+
</header>
|
|
2229
|
+
}
|
|
2230
|
+
`, styles: [":host{display:block}.spine-page-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:var(--hm-space-sm);padding:var(--hm-space-sm) var(--hm-space-md);margin:0;background:var(--hm-surface-raised);border-block-end:var(--hm-border-hairline, 1px) solid var(--hm-divider)}.spine-page-bar__identity{display:flex;flex:1 1 auto;min-inline-size:0;flex-direction:column;gap:calc(var(--hm-space-md) - var(--hm-space-micro))}.spine-page-bar__title-row{display:flex;align-items:center;gap:var(--hm-space-xs);min-inline-size:0}.spine-page-bar__icon{flex:none;color:var(--hm-accent);font-size:var(--hm-font-size-xxl, 28px)}.spine-page-bar__title{min-inline-size:0;margin:0;overflow:hidden;color:var(--hm-text);font-size:var(--hm-font-size-xl, 22px);font-weight:var(--hm-font-weight-bold, 700);line-height:var(--hm-line-height-tight, 1.2);text-overflow:ellipsis;white-space:nowrap}.spine-page-bar__description{max-inline-size:80ch;margin:calc(var(--hm-space-nano) - (var(--hm-space-md) - var(--hm-space-micro))) 0 0;color:var(--hm-muted);font-size:var(--hm-font-size-sm, 14px);line-height:var(--hm-line-height-normal, 1.5)}.spine-page-bar__actions{display:inline-flex;flex:none;align-items:center;gap:var(--hm-space-micro);margin-inline-start:var(--hm-space-nano)}.spine-page-bar__fav--on{color:var(--hm-danger)}.spine-page-bar__trail{margin-inline-start:calc(-1 * var(--hm-space-micro));margin-block-end:calc(-1 * var(--hm-space-micro))}.spine-page-bar__extra{display:flex;flex:none;align-items:center;gap:var(--hm-space-xxxs)}.spine-page-bar__extra:empty{display:none}\n"] }]
|
|
2231
|
+
}], ctorParameters: () => [], propDecorators: { chain: [{ type: i0.Input, args: [{ isSignal: true, alias: "chain", required: false }] }], recordRecent: [{ type: i0.Input, args: [{ isSignal: true, alias: "recordRecent", required: false }] }] } });
|
|
2232
|
+
|
|
2233
|
+
// Componente já carregado para cada url, comum a todos os painéis: reabrir a mesma tela em outro
|
|
2234
|
+
// painel (ou voltar a ela) não passa de novo pelo distribuidor.
|
|
2235
|
+
const componentCache = new Map();
|
|
2236
|
+
/**
|
|
2237
|
+
* Conteúdo de um painel do `hm-workspace`. Como vários painéis ficam visíveis ao mesmo tempo, cada
|
|
2238
|
+
* um monta a sua tela a partir da própria `url`, sem `<router-outlet>`: o roteador só conhece uma
|
|
2239
|
+
* rota ativa, e aqui cada painel precisa manter a sua.
|
|
2240
|
+
*
|
|
2241
|
+
* Mostra o quadrado de carregamento enquanto o componente chega, a tela quando ele chega, ou o
|
|
2242
|
+
* aviso de "não encontrado" quando a url não leva a nenhuma rota com tela.
|
|
2243
|
+
*/
|
|
2244
|
+
class SpinePagePanelComponent {
|
|
2245
|
+
constructor() {
|
|
2246
|
+
this.text = inject(SPINE_MESSAGES).pagePanel;
|
|
2247
|
+
this.url = input.required(...(ngDevMode ? [{ debugName: "url" }] : /* istanbul ignore next */ []));
|
|
2248
|
+
/** Só o painel focado fala pela página inteira: visita recente, título da aba e rota no logger. */
|
|
2249
|
+
this.isFocusedPanel = input(true, ...(ngDevMode ? [{ debugName: "isFocusedPanel" }] : /* istanbul ignore next */ []));
|
|
2250
|
+
this.viewService = inject(ViewService);
|
|
2251
|
+
this.distributor = inject(ComponentDistributorService);
|
|
2252
|
+
this.logger = inject(LoggerService);
|
|
2253
|
+
this.title = inject(Title);
|
|
2254
|
+
this.loading = signal(true, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
|
|
2255
|
+
this.component = signal(null, ...(ngDevMode ? [{ debugName: "component" }] : /* istanbul ignore next */ []));
|
|
2256
|
+
this.chain = signal(null, ...(ngDevMode ? [{ debugName: "chain" }] : /* istanbul ignore next */ []));
|
|
2257
|
+
effect(() => {
|
|
2258
|
+
const url = this.url();
|
|
2259
|
+
void this.resolve(url);
|
|
2260
|
+
});
|
|
2261
|
+
// Com as abas mantidas vivas, o painel que recebe o foco de volta já tem a tela pronta; basta
|
|
2262
|
+
// reaplicar o que só o focado faz, sem carregar nada de novo.
|
|
2263
|
+
effect(() => {
|
|
2264
|
+
if (this.isFocusedPanel() && this.chain())
|
|
2265
|
+
this.afterResolved();
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
/** Cadeia de rotas da url (sem os nós que só agrupam) e o componente da última delas. */
|
|
2269
|
+
async resolve(url) {
|
|
2270
|
+
const chain = resolveEffectiveRouteChain(spineRoutesOf(this.viewService.currentView), (u) => this.viewService.getLevel(u), (u, level) => this.viewService.getUrlAtLevel(u, level), url);
|
|
2271
|
+
this.chain.set(chain);
|
|
2272
|
+
const leaf = chain?.at(-1) ?? null;
|
|
2273
|
+
if (!leaf) {
|
|
2274
|
+
this.component.set(null);
|
|
2275
|
+
this.loading.set(false);
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
const cached = componentCache.get(url);
|
|
2279
|
+
if (cached) {
|
|
2280
|
+
this.component.set(cached);
|
|
2281
|
+
this.loading.set(false);
|
|
2282
|
+
this.afterResolved();
|
|
2283
|
+
return;
|
|
2284
|
+
}
|
|
2285
|
+
this.loading.set(true);
|
|
2286
|
+
this.component.set(null);
|
|
2287
|
+
const component = await this.distributor.getComponent(routerIdOf(leaf) ?? '', leaf.source_template_router_id);
|
|
2288
|
+
// A url mudou enquanto o carregamento corria: este resultado já não interessa.
|
|
2289
|
+
if (url !== this.url())
|
|
2290
|
+
return;
|
|
2291
|
+
componentCache.set(url, component);
|
|
2292
|
+
this.component.set(component);
|
|
2293
|
+
this.loading.set(false);
|
|
2294
|
+
this.afterResolved();
|
|
2295
|
+
}
|
|
2296
|
+
/** O que acontece quando a tela do painel focado fica pronta. */
|
|
2297
|
+
afterResolved() {
|
|
2298
|
+
if (!this.isFocusedPanel())
|
|
2299
|
+
return;
|
|
2300
|
+
const chain = this.chain();
|
|
2301
|
+
if (chain)
|
|
2302
|
+
this.logger.setLogger(chain);
|
|
2303
|
+
const leafName = chain?.at(-1)?.route_display_name;
|
|
2304
|
+
if (leafName)
|
|
2305
|
+
this.title.setTitle(leafName);
|
|
2306
|
+
}
|
|
2307
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpinePagePanelComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
2308
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: SpinePagePanelComponent, isStandalone: true, selector: "spine-page-panel", inputs: { url: { classPropertyName: "url", publicName: "url", isSignal: true, isRequired: true, transformFunction: null }, isFocusedPanel: { classPropertyName: "isFocusedPanel", publicName: "isFocusedPanel", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
|
|
2309
|
+
<spine-page-bar [chain]="chain()" [recordRecent]="isFocusedPanel()" />
|
|
2310
|
+
<div class="spine-page-panel__content">
|
|
2311
|
+
@if (loading()) {
|
|
2312
|
+
<div class="spine-page-panel__loading"><hm-loading-square /></div>
|
|
2313
|
+
} @else if (component(); as cmp) {
|
|
2314
|
+
<ng-container [ngComponentOutlet]="cmp" />
|
|
2315
|
+
} @else {
|
|
2316
|
+
<div class="spine-page-panel__empty">
|
|
2317
|
+
<hm-icon shape="search-magnify" size="xl" />
|
|
2318
|
+
<p class="spine-page-panel__empty-title">{{ text.notFoundTitle }}</p>
|
|
2319
|
+
<p class="spine-page-panel__empty-description">{{ text.notFoundText }}</p>
|
|
2320
|
+
</div>
|
|
2321
|
+
}
|
|
2322
|
+
</div>
|
|
2323
|
+
`, isInline: true, styles: [":host{display:flex;flex-direction:column;block-size:100%;min-block-size:0}.spine-page-panel__content{flex:1;min-block-size:0;display:flex;flex-direction:column;padding:var(--hm-space-lg, 1.5rem) var(--hm-space-md, 1rem)}.spine-page-panel__loading{display:flex;flex:1;align-items:center;justify-content:center;min-block-size:50vh;padding:var(--hm-space-xl)}.spine-page-panel__empty{display:flex;flex:1;flex-direction:column;align-items:center;justify-content:center;gap:var(--hm-space-sm);padding:var(--hm-space-xl);text-align:center;color:var(--hm-muted)}.spine-page-panel__empty hm-icon{color:var(--hm-muted)}.spine-page-panel__empty-title{margin:0;color:var(--hm-text);font-size:var(--hm-font-size-lg, 18px);font-weight:600}.spine-page-panel__empty-description{margin:0;max-inline-size:32ch;font-size:var(--hm-font-size-sm, 14px)}\n"], dependencies: [{ kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }, { kind: "component", type: LoadingSquareComponent, selector: "hm-loading-square" }, { kind: "component", type: IconComponent, selector: "hm-icon", inputs: ["shape", "direction", "size", "solid", "badge", "status", "flip", "inverse", "label"] }, { kind: "component", type: SpinePageBarComponent, selector: "spine-page-bar", inputs: ["chain", "recordRecent"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
2324
|
+
}
|
|
2325
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpinePagePanelComponent, decorators: [{
|
|
2326
|
+
type: Component,
|
|
2327
|
+
args: [{ selector: 'spine-page-panel', imports: [NgComponentOutlet, LoadingSquareComponent, IconComponent, SpinePageBarComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
|
|
2328
|
+
<spine-page-bar [chain]="chain()" [recordRecent]="isFocusedPanel()" />
|
|
2329
|
+
<div class="spine-page-panel__content">
|
|
2330
|
+
@if (loading()) {
|
|
2331
|
+
<div class="spine-page-panel__loading"><hm-loading-square /></div>
|
|
2332
|
+
} @else if (component(); as cmp) {
|
|
2333
|
+
<ng-container [ngComponentOutlet]="cmp" />
|
|
2334
|
+
} @else {
|
|
2335
|
+
<div class="spine-page-panel__empty">
|
|
2336
|
+
<hm-icon shape="search-magnify" size="xl" />
|
|
2337
|
+
<p class="spine-page-panel__empty-title">{{ text.notFoundTitle }}</p>
|
|
2338
|
+
<p class="spine-page-panel__empty-description">{{ text.notFoundText }}</p>
|
|
2339
|
+
</div>
|
|
2340
|
+
}
|
|
2341
|
+
</div>
|
|
2342
|
+
`, styles: [":host{display:flex;flex-direction:column;block-size:100%;min-block-size:0}.spine-page-panel__content{flex:1;min-block-size:0;display:flex;flex-direction:column;padding:var(--hm-space-lg, 1.5rem) var(--hm-space-md, 1rem)}.spine-page-panel__loading{display:flex;flex:1;align-items:center;justify-content:center;min-block-size:50vh;padding:var(--hm-space-xl)}.spine-page-panel__empty{display:flex;flex:1;flex-direction:column;align-items:center;justify-content:center;gap:var(--hm-space-sm);padding:var(--hm-space-xl);text-align:center;color:var(--hm-muted)}.spine-page-panel__empty hm-icon{color:var(--hm-muted)}.spine-page-panel__empty-title{margin:0;color:var(--hm-text);font-size:var(--hm-font-size-lg, 18px);font-weight:600}.spine-page-panel__empty-description{margin:0;max-inline-size:32ch;font-size:var(--hm-font-size-sm, 14px)}\n"] }]
|
|
2343
|
+
}], ctorParameters: () => [], propDecorators: { url: [{ type: i0.Input, args: [{ isSignal: true, alias: "url", required: true }] }], isFocusedPanel: [{ type: i0.Input, args: [{ isSignal: true, alias: "isFocusedPanel", required: false }] }] } });
|
|
2344
|
+
|
|
2345
|
+
/**
|
|
2346
|
+
* Em painéis NÃO focados do `hm-workspace`, um `<a href>`/`[routerLink]` clicado navegaria o
|
|
2347
|
+
* `Router` de verdade — que só existe UM na aplicação, e está sempre atrelado ao painel FOCADO
|
|
2348
|
+
* (ver `SpineWorkspaceNavService`). Sem isto, clicar num link dentro de um painel secundário (um
|
|
2349
|
+
* item da sidebar, uma aba de submenu, um link de breadcrumb, qualquer `routerLink` dentro de uma
|
|
2350
|
+
* tela de feature) navegaria a tela ERRADA — o painel focado, não o painel onde o clique
|
|
2351
|
+
* aconteceu. Porte 1:1 do mesmo diretório do docs/website
|
|
2352
|
+
* (`docs/website/src/app/workspace/panel-local-link.directive.ts`) — genérico, sem nada
|
|
2353
|
+
* específico do site de documentação.
|
|
2354
|
+
*
|
|
2355
|
+
* Intercepta em fase de CAPTURA pra resolver a navegação LOCALMENTE antes do `RouterLink`
|
|
2356
|
+
* conseguir agir. Abrir em nova aba (Ctrl/⌘ + clique, clique do meio) continua funcionando
|
|
2357
|
+
* normalmente — só intercepta o clique comum.
|
|
2358
|
+
*/
|
|
2359
|
+
class PanelLocalLinkDirective {
|
|
2360
|
+
constructor() {
|
|
2361
|
+
/** `true` = intercepta (painel não-focado); `false` = deixa o `RouterLink` normal agir. */
|
|
2362
|
+
this.appPanelLocalLink = input(false, ...(ngDevMode ? [{ debugName: "appPanelLocalLink" }] : /* istanbul ignore next */ []));
|
|
2363
|
+
this.localNavigate = output();
|
|
2364
|
+
const host = inject(ElementRef).nativeElement;
|
|
2365
|
+
const destroyRef = inject(DestroyRef);
|
|
2366
|
+
const onClick = (event) => {
|
|
2367
|
+
if (!this.appPanelLocalLink())
|
|
2368
|
+
return;
|
|
2369
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
|
2370
|
+
return;
|
|
2371
|
+
const anchor = event.target.closest('a[href]');
|
|
2372
|
+
if (!anchor || !host.contains(anchor))
|
|
2373
|
+
return;
|
|
2374
|
+
const href = anchor.getAttribute('href');
|
|
2375
|
+
if (!href || !href.startsWith('/'))
|
|
2376
|
+
return; // só rotas internas do próprio app
|
|
2377
|
+
event.preventDefault();
|
|
2378
|
+
event.stopPropagation();
|
|
2379
|
+
this.localNavigate.emit(href);
|
|
2380
|
+
};
|
|
2381
|
+
host.addEventListener('click', onClick, true);
|
|
2382
|
+
destroyRef.onDestroy(() => host.removeEventListener('click', onClick, true));
|
|
2383
|
+
}
|
|
2384
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: PanelLocalLinkDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
2385
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.24", type: PanelLocalLinkDirective, isStandalone: true, selector: "[appPanelLocalLink]", inputs: { appPanelLocalLink: { classPropertyName: "appPanelLocalLink", publicName: "appPanelLocalLink", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { localNavigate: "localNavigate" }, ngImport: i0 }); }
|
|
2386
|
+
}
|
|
2387
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: PanelLocalLinkDirective, decorators: [{
|
|
2388
|
+
type: Directive,
|
|
2389
|
+
args: [{ selector: '[appPanelLocalLink]' }]
|
|
2390
|
+
}], ctorParameters: () => [], propDecorators: { appPanelLocalLink: [{ type: i0.Input, args: [{ isSignal: true, alias: "appPanelLocalLink", required: false }] }], localNavigate: [{ type: i0.Output, args: ["localNavigate"] }] } });
|
|
2391
|
+
|
|
2392
|
+
// Instância própria: não mexe nas opções globais do `marked` que o app possa usar.
|
|
2393
|
+
const parser = new Marked({ gfm: true, breaks: false, async: false });
|
|
2394
|
+
/**
|
|
2395
|
+
* Renderiza Markdown (GFM) como HTML com a tipografia do design system. O texto é tratado como dado:
|
|
2396
|
+
* o HTML gerado passa pelo DOMPurify antes de chegar ao DOM.
|
|
2397
|
+
*/
|
|
2398
|
+
class SpineMarkdownComponent {
|
|
2399
|
+
constructor() {
|
|
2400
|
+
/** Markdown cru. Vazio não desenha nada. */
|
|
2401
|
+
this.content = input('', ...(ngDevMode ? [{ debugName: "content" }] : /* istanbul ignore next */ []));
|
|
2402
|
+
this.#sanitizer = inject(DomSanitizer);
|
|
2403
|
+
this.html = computed(() => {
|
|
2404
|
+
const source = this.content();
|
|
2405
|
+
// Sem DOM (SSR) o DOMPurify não limpa nada; melhor não renderizar.
|
|
2406
|
+
if (!source || !DOMPurify.isSupported)
|
|
2407
|
+
return null;
|
|
2408
|
+
const clean = DOMPurify.sanitize(parser.parse(source));
|
|
2409
|
+
return this.#sanitizer.bypassSecurityTrustHtml(clean);
|
|
2410
|
+
}, ...(ngDevMode ? [{ debugName: "html" }] : /* istanbul ignore next */ []));
|
|
2411
|
+
}
|
|
2412
|
+
#sanitizer;
|
|
2413
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineMarkdownComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
2414
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: SpineMarkdownComponent, isStandalone: true, selector: "spine-markdown", inputs: { content: { classPropertyName: "content", publicName: "content", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
|
|
2415
|
+
@if (html(); as markup) {
|
|
2416
|
+
<div class="spine-md" [innerHTML]="markup"></div>
|
|
2417
|
+
}
|
|
2418
|
+
`, isInline: true, styles: [".spine-md{color:var(--hm-text);font-size:var(--hm-font-size-sm);line-height:var(--hm-line-height-normal);overflow-wrap:anywhere}.spine-md>:first-child{margin-block-start:0}.spine-md>:last-child{margin-block-end:0}.spine-md :is(h1,h2,h3,h4,h5,h6){margin-block:var(--hm-space-lg) var(--hm-space-sm);color:var(--hm-text);font-weight:var(--hm-font-weight-semibold);line-height:var(--hm-line-height-snug)}.spine-md h1{font-size:var(--hm-font-size-xxl)}.spine-md h2{font-size:var(--hm-font-size-xl)}.spine-md h3{font-size:var(--hm-font-size-lg)}.spine-md :is(h4,h5,h6){font-size:var(--hm-font-size-md)}.spine-md :is(p,ul,ol,blockquote,pre,table){margin-block:0 var(--hm-space-md)}.spine-md :is(ul,ol){padding-inline-start:var(--hm-space-lg)}.spine-md li+li{margin-block-start:var(--hm-space-micro)}.spine-md blockquote{margin-inline:0;padding-inline-start:var(--hm-space-md);border-inline-start:var(--hm-border-thin) solid var(--hm-divider);color:var(--hm-muted)}.spine-md code{padding-inline:var(--hm-space-micro);border-radius:var(--hm-radius-micro);background:var(--hm-surface-raised);font-family:var(--hm-font-mono);font-size:var(--hm-font-size-xs)}.spine-md pre{padding:var(--hm-space-sm) var(--hm-space-md);border-radius:var(--hm-radius);background:var(--hm-surface-raised);white-space:pre-wrap}.spine-md pre code{padding:0;background:none}.spine-md table{display:block;max-inline-size:100%;overflow-x:auto;border-collapse:collapse;overflow-wrap:normal}.spine-md :is(th,td){padding:var(--hm-space-xs) var(--hm-space-sm);border:var(--hm-border-hairline) solid var(--hm-divider);text-align:start;vertical-align:top}.spine-md th{background:var(--hm-surface-raised);font-weight:var(--hm-font-weight-semibold)}.spine-md hr{margin-block:var(--hm-space-lg);border:0;border-block-start:var(--hm-border-hairline) solid var(--hm-divider)}.spine-md a{color:var(--hm-accent);text-decoration:underline;text-underline-offset:.2em}.spine-md a:focus-visible{outline:var(--hm-border-thin) solid var(--hm-accent);outline-offset:var(--hm-border-hairline);border-radius:var(--hm-radius-micro)}.spine-md img{max-inline-size:100%;block-size:auto}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); }
|
|
2419
|
+
}
|
|
2420
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineMarkdownComponent, decorators: [{
|
|
2421
|
+
type: Component,
|
|
2422
|
+
args: [{ selector: 'spine-markdown', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: `
|
|
2423
|
+
@if (html(); as markup) {
|
|
2424
|
+
<div class="spine-md" [innerHTML]="markup"></div>
|
|
2425
|
+
}
|
|
2426
|
+
`, styles: [".spine-md{color:var(--hm-text);font-size:var(--hm-font-size-sm);line-height:var(--hm-line-height-normal);overflow-wrap:anywhere}.spine-md>:first-child{margin-block-start:0}.spine-md>:last-child{margin-block-end:0}.spine-md :is(h1,h2,h3,h4,h5,h6){margin-block:var(--hm-space-lg) var(--hm-space-sm);color:var(--hm-text);font-weight:var(--hm-font-weight-semibold);line-height:var(--hm-line-height-snug)}.spine-md h1{font-size:var(--hm-font-size-xxl)}.spine-md h2{font-size:var(--hm-font-size-xl)}.spine-md h3{font-size:var(--hm-font-size-lg)}.spine-md :is(h4,h5,h6){font-size:var(--hm-font-size-md)}.spine-md :is(p,ul,ol,blockquote,pre,table){margin-block:0 var(--hm-space-md)}.spine-md :is(ul,ol){padding-inline-start:var(--hm-space-lg)}.spine-md li+li{margin-block-start:var(--hm-space-micro)}.spine-md blockquote{margin-inline:0;padding-inline-start:var(--hm-space-md);border-inline-start:var(--hm-border-thin) solid var(--hm-divider);color:var(--hm-muted)}.spine-md code{padding-inline:var(--hm-space-micro);border-radius:var(--hm-radius-micro);background:var(--hm-surface-raised);font-family:var(--hm-font-mono);font-size:var(--hm-font-size-xs)}.spine-md pre{padding:var(--hm-space-sm) var(--hm-space-md);border-radius:var(--hm-radius);background:var(--hm-surface-raised);white-space:pre-wrap}.spine-md pre code{padding:0;background:none}.spine-md table{display:block;max-inline-size:100%;overflow-x:auto;border-collapse:collapse;overflow-wrap:normal}.spine-md :is(th,td){padding:var(--hm-space-xs) var(--hm-space-sm);border:var(--hm-border-hairline) solid var(--hm-divider);text-align:start;vertical-align:top}.spine-md th{background:var(--hm-surface-raised);font-weight:var(--hm-font-weight-semibold)}.spine-md hr{margin-block:var(--hm-space-lg);border:0;border-block-start:var(--hm-border-hairline) solid var(--hm-divider)}.spine-md a{color:var(--hm-accent);text-decoration:underline;text-underline-offset:.2em}.spine-md a:focus-visible{outline:var(--hm-border-thin) solid var(--hm-accent);outline-offset:var(--hm-border-hairline);border-radius:var(--hm-radius-micro)}.spine-md img{max-inline-size:100%;block-size:auto}\n"] }]
|
|
2427
|
+
}], propDecorators: { content: [{ type: i0.Input, args: [{ isSignal: true, alias: "content", required: false }] }] } });
|
|
2428
|
+
|
|
2429
|
+
let sequence = 0;
|
|
2430
|
+
/** Compara sem acento e sem caixa. */
|
|
2431
|
+
function fold(text) {
|
|
2432
|
+
return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
|
|
2433
|
+
}
|
|
2434
|
+
/**
|
|
2435
|
+
* Modal de documentação das telas: índice (com busca) à esquerda e leitura à direita. Montado uma vez
|
|
2436
|
+
* pelo `spine-layout`; abrir, fechar e o que se lê vêm todos do `SpineDocsService`.
|
|
2437
|
+
*/
|
|
2438
|
+
class SpineDocsComponent {
|
|
2439
|
+
constructor() {
|
|
2440
|
+
this.docs = inject(SpineDocsService);
|
|
2441
|
+
this.text = inject(SPINE_MESSAGES);
|
|
2442
|
+
this.query = signal('', ...(ngDevMode ? [{ debugName: "query" }] : /* istanbul ignore next */ []));
|
|
2443
|
+
this.groupId = `spine-docs-group-${++sequence}-`;
|
|
2444
|
+
this.viewOptions = computed(() => [
|
|
2445
|
+
{ value: 'usage', label: this.text.docs.usage },
|
|
2446
|
+
{ value: 'technical', label: this.text.docs.technical },
|
|
2447
|
+
], ...(ngDevMode ? [{ debugName: "viewOptions" }] : /* istanbul ignore next */ []));
|
|
2448
|
+
this.filtered = computed(() => {
|
|
2449
|
+
const needle = fold(this.query().trim());
|
|
2450
|
+
const groups = this.docs.index();
|
|
2451
|
+
if (!needle)
|
|
2452
|
+
return groups;
|
|
2453
|
+
return groups
|
|
2454
|
+
.map((group) => ({
|
|
2455
|
+
title: group.title,
|
|
2456
|
+
entries: group.entries.filter((entry) => fold(`${entry.title} ${group.title}`).includes(needle)),
|
|
2457
|
+
}))
|
|
2458
|
+
.filter((group) => group.entries.length > 0);
|
|
2459
|
+
}, ...(ngDevMode ? [{ debugName: "filtered" }] : /* istanbul ignore next */ []));
|
|
2460
|
+
this.readingKey = computed(() => [`${this.docs.current()?.id ?? ''}:${this.docs.view()}`], ...(ngDevMode ? [{ debugName: "readingKey" }] : /* istanbul ignore next */ []));
|
|
2461
|
+
}
|
|
2462
|
+
select(entry) {
|
|
2463
|
+
void this.docs.open(entry);
|
|
2464
|
+
}
|
|
2465
|
+
switchView(view) {
|
|
2466
|
+
void this.docs.show(view === 'technical' ? 'technical' : 'usage');
|
|
2467
|
+
}
|
|
2468
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineDocsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
2469
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: SpineDocsComponent, isStandalone: true, selector: "spine-docs", ngImport: i0, template: `
|
|
2470
|
+
@if (docs.isOpen()) {
|
|
2471
|
+
<hm-modal [heading]="text.docs.heading" size="large" [open]="true" (closed)="docs.close()">
|
|
2472
|
+
<div class="layout">
|
|
2473
|
+
<nav class="index" [attr.aria-label]="text.docs.index">
|
|
2474
|
+
<input
|
|
2475
|
+
class="filter"
|
|
2476
|
+
type="search"
|
|
2477
|
+
[placeholder]="text.docs.search"
|
|
2478
|
+
[attr.aria-label]="text.docs.search"
|
|
2479
|
+
[value]="query()"
|
|
2480
|
+
(input)="query.set($any($event.target).value)"
|
|
2481
|
+
/>
|
|
2482
|
+
@for (group of filtered(); track group.title; let g = $index) {
|
|
2483
|
+
<p class="group-title" [id]="groupId + g">{{ group.title }}</p>
|
|
2484
|
+
<ul class="entries" [attr.aria-labelledby]="groupId + g">
|
|
2485
|
+
@for (entry of group.entries; track entry.id) {
|
|
2486
|
+
<li>
|
|
2487
|
+
<button
|
|
2488
|
+
type="button"
|
|
2489
|
+
class="entry"
|
|
2490
|
+
[class.entry--active]="entry.id === docs.current()?.id"
|
|
2491
|
+
[attr.aria-current]="entry.id === docs.current()?.id ? 'true' : null"
|
|
2492
|
+
(click)="select(entry)"
|
|
2493
|
+
>
|
|
2494
|
+
{{ entry.title }}
|
|
2495
|
+
</button>
|
|
2496
|
+
</li>
|
|
2497
|
+
}
|
|
2498
|
+
</ul>
|
|
2499
|
+
} @empty {
|
|
2500
|
+
<p class="note">{{ query().trim() ? text.docs.noMatch : text.docs.empty }}</p>
|
|
2501
|
+
}
|
|
2502
|
+
</nav>
|
|
2503
|
+
|
|
2504
|
+
<section class="reader">
|
|
2505
|
+
@if (docs.current(); as entry) {
|
|
2506
|
+
<h3 class="reader-title">{{ entry.title }}</h3>
|
|
2507
|
+
@if (docs.showTechnical()) {
|
|
2508
|
+
<hm-segmented-control
|
|
2509
|
+
[label]="text.docs.view"
|
|
2510
|
+
[options]="viewOptions()"
|
|
2511
|
+
[value]="docs.view()"
|
|
2512
|
+
(valueChange)="switchView($event)"
|
|
2513
|
+
/>
|
|
2514
|
+
}
|
|
2515
|
+
<!-- A chave muda com o documento ou a visão: o bloco é recriado e entra de novo. -->
|
|
2516
|
+
@for (key of readingKey(); track key) {
|
|
2517
|
+
<div class="reading">
|
|
2518
|
+
@if (docs.loading()) {
|
|
2519
|
+
<hm-skeleton shape="text" [count]="6" [label]="text.docs.loading" />
|
|
2520
|
+
} @else if (docs.error()) {
|
|
2521
|
+
<hm-alert variant="error">{{ docs.error() }}</hm-alert>
|
|
2522
|
+
} @else {
|
|
2523
|
+
<spine-markdown [content]="docs.content()" />
|
|
2524
|
+
}
|
|
2525
|
+
</div>
|
|
2526
|
+
}
|
|
2527
|
+
} @else {
|
|
2528
|
+
<p class="note">{{ text.docs.invite }}</p>
|
|
2529
|
+
}
|
|
2530
|
+
</section>
|
|
2531
|
+
</div>
|
|
2532
|
+
</hm-modal>
|
|
2533
|
+
}
|
|
2534
|
+
`, isInline: true, styles: [".layout{display:grid;grid-template-columns:minmax(12rem,16rem) minmax(0,1fr);gap:var(--hm-space-lg);block-size:min(70vh,48rem);min-block-size:0}.index,.reader{min-block-size:0;overflow-y:auto;overscroll-behavior:contain}.index{display:flex;flex-direction:column;gap:var(--hm-space-xs);padding-inline-end:var(--hm-space-md);border-inline-end:var(--hm-border-hairline) solid var(--hm-divider)}.filter{box-sizing:border-box;inline-size:100%;min-block-size:var(--hm-control-height);margin-block-end:var(--hm-space-sm);padding-inline:var(--hm-space-sm);border:var(--hm-border-hairline) solid var(--hm-border);border-radius:var(--hm-radius-control);background:var(--hm-surface-raised);color:var(--hm-text);font:inherit;font-size:var(--hm-font-size-sm)}.filter::placeholder{color:var(--hm-placeholder)}.filter:focus-visible{outline:var(--hm-border-thin) solid var(--hm-accent);outline-offset:var(--hm-border-hairline)}.group-title{margin:var(--hm-space-sm) 0 0;color:var(--hm-muted);font-size:var(--hm-font-size-xs);font-weight:var(--hm-font-weight-semibold);text-transform:uppercase;letter-spacing:.04em}.entries{display:flex;flex-direction:column;gap:var(--hm-space-micro);margin:0;padding:0;list-style:none}.entry{inline-size:100%;padding:var(--hm-space-xs) var(--hm-space-sm);border:var(--hm-border-hairline) solid transparent;border-radius:var(--hm-radius-control);background:transparent;color:var(--hm-text);font:inherit;font-size:var(--hm-font-size-sm);text-align:start;cursor:pointer;transition:background-color var(--hm-duration-quick) var(--hm-ease)}.entry:hover{background:var(--hm-hover)}.entry:focus-visible{outline:var(--hm-border-thin) solid var(--hm-accent);outline-offset:calc(var(--hm-border-thin) * -1)}.entry--active{background:var(--hm-accent-subtle);color:var(--hm-accent-strong);font-weight:var(--hm-font-weight-semibold)}.reader{display:flex;flex-direction:column;gap:var(--hm-space-md);align-items:stretch}.reader-title{margin:0;color:var(--hm-text);font-size:var(--hm-font-size-lg);font-weight:var(--hm-font-weight-semibold)}.reading{animation:spine-docs-enter var(--hm-duration-standard) var(--hm-ease-decelerate)}.note{margin:0;color:var(--hm-muted);font-size:var(--hm-font-size-sm)}@keyframes spine-docs-enter{0%{opacity:0}to{opacity:1}}@media(max-width:767px){.layout{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,2fr) minmax(0,3fr)}.index{padding-inline-end:0;padding-block-end:var(--hm-space-md);border-inline-end:0;border-block-end:var(--hm-border-hairline) solid var(--hm-divider)}}@media(prefers-reduced-motion:reduce){.reading{animation:none}.entry{transition:none}}@media(forced-colors:active){.entry--active{border-color:Highlight}}\n"], dependencies: [{ kind: "component", type: ModalComponent, selector: "hm-modal", inputs: ["open", "heading", "size", "closeLabel", "closable", "beforeClose"], outputs: ["openChange", "closed"] }, { kind: "component", type: AlertComponent, selector: "hm-alert", inputs: ["variant", "appearance", "heading", "closable", "size", "showIcon", "link", "linkLabel", "linkTarget", "dismissKey", "duration"], outputs: ["closed"] }, { kind: "component", type: SkeletonComponent, selector: "hm-skeleton", inputs: ["shape", "count", "label"] }, { kind: "component", type: SegmentedControlComponent, selector: "hm-segmented-control", inputs: ["options", "value", "label", "size", "emphasis"], outputs: ["valueChange"] }, { kind: "component", type: SpineMarkdownComponent, selector: "spine-markdown", inputs: ["content"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
2535
|
+
}
|
|
2536
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineDocsComponent, decorators: [{
|
|
2537
|
+
type: Component,
|
|
2538
|
+
args: [{ selector: 'spine-docs', imports: [ModalComponent, AlertComponent, SkeletonComponent, SegmentedControlComponent, SpineMarkdownComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
|
|
2539
|
+
@if (docs.isOpen()) {
|
|
2540
|
+
<hm-modal [heading]="text.docs.heading" size="large" [open]="true" (closed)="docs.close()">
|
|
2541
|
+
<div class="layout">
|
|
2542
|
+
<nav class="index" [attr.aria-label]="text.docs.index">
|
|
2543
|
+
<input
|
|
2544
|
+
class="filter"
|
|
2545
|
+
type="search"
|
|
2546
|
+
[placeholder]="text.docs.search"
|
|
2547
|
+
[attr.aria-label]="text.docs.search"
|
|
2548
|
+
[value]="query()"
|
|
2549
|
+
(input)="query.set($any($event.target).value)"
|
|
2550
|
+
/>
|
|
2551
|
+
@for (group of filtered(); track group.title; let g = $index) {
|
|
2552
|
+
<p class="group-title" [id]="groupId + g">{{ group.title }}</p>
|
|
2553
|
+
<ul class="entries" [attr.aria-labelledby]="groupId + g">
|
|
2554
|
+
@for (entry of group.entries; track entry.id) {
|
|
2555
|
+
<li>
|
|
2556
|
+
<button
|
|
2557
|
+
type="button"
|
|
2558
|
+
class="entry"
|
|
2559
|
+
[class.entry--active]="entry.id === docs.current()?.id"
|
|
2560
|
+
[attr.aria-current]="entry.id === docs.current()?.id ? 'true' : null"
|
|
2561
|
+
(click)="select(entry)"
|
|
2562
|
+
>
|
|
2563
|
+
{{ entry.title }}
|
|
2564
|
+
</button>
|
|
2565
|
+
</li>
|
|
2566
|
+
}
|
|
2567
|
+
</ul>
|
|
2568
|
+
} @empty {
|
|
2569
|
+
<p class="note">{{ query().trim() ? text.docs.noMatch : text.docs.empty }}</p>
|
|
2570
|
+
}
|
|
2571
|
+
</nav>
|
|
2572
|
+
|
|
2573
|
+
<section class="reader">
|
|
2574
|
+
@if (docs.current(); as entry) {
|
|
2575
|
+
<h3 class="reader-title">{{ entry.title }}</h3>
|
|
2576
|
+
@if (docs.showTechnical()) {
|
|
2577
|
+
<hm-segmented-control
|
|
2578
|
+
[label]="text.docs.view"
|
|
2579
|
+
[options]="viewOptions()"
|
|
2580
|
+
[value]="docs.view()"
|
|
2581
|
+
(valueChange)="switchView($event)"
|
|
2582
|
+
/>
|
|
2583
|
+
}
|
|
2584
|
+
<!-- A chave muda com o documento ou a visão: o bloco é recriado e entra de novo. -->
|
|
2585
|
+
@for (key of readingKey(); track key) {
|
|
2586
|
+
<div class="reading">
|
|
2587
|
+
@if (docs.loading()) {
|
|
2588
|
+
<hm-skeleton shape="text" [count]="6" [label]="text.docs.loading" />
|
|
2589
|
+
} @else if (docs.error()) {
|
|
2590
|
+
<hm-alert variant="error">{{ docs.error() }}</hm-alert>
|
|
2591
|
+
} @else {
|
|
2592
|
+
<spine-markdown [content]="docs.content()" />
|
|
2593
|
+
}
|
|
2594
|
+
</div>
|
|
2595
|
+
}
|
|
2596
|
+
} @else {
|
|
2597
|
+
<p class="note">{{ text.docs.invite }}</p>
|
|
2598
|
+
}
|
|
2599
|
+
</section>
|
|
2600
|
+
</div>
|
|
2601
|
+
</hm-modal>
|
|
2602
|
+
}
|
|
2603
|
+
`, styles: [".layout{display:grid;grid-template-columns:minmax(12rem,16rem) minmax(0,1fr);gap:var(--hm-space-lg);block-size:min(70vh,48rem);min-block-size:0}.index,.reader{min-block-size:0;overflow-y:auto;overscroll-behavior:contain}.index{display:flex;flex-direction:column;gap:var(--hm-space-xs);padding-inline-end:var(--hm-space-md);border-inline-end:var(--hm-border-hairline) solid var(--hm-divider)}.filter{box-sizing:border-box;inline-size:100%;min-block-size:var(--hm-control-height);margin-block-end:var(--hm-space-sm);padding-inline:var(--hm-space-sm);border:var(--hm-border-hairline) solid var(--hm-border);border-radius:var(--hm-radius-control);background:var(--hm-surface-raised);color:var(--hm-text);font:inherit;font-size:var(--hm-font-size-sm)}.filter::placeholder{color:var(--hm-placeholder)}.filter:focus-visible{outline:var(--hm-border-thin) solid var(--hm-accent);outline-offset:var(--hm-border-hairline)}.group-title{margin:var(--hm-space-sm) 0 0;color:var(--hm-muted);font-size:var(--hm-font-size-xs);font-weight:var(--hm-font-weight-semibold);text-transform:uppercase;letter-spacing:.04em}.entries{display:flex;flex-direction:column;gap:var(--hm-space-micro);margin:0;padding:0;list-style:none}.entry{inline-size:100%;padding:var(--hm-space-xs) var(--hm-space-sm);border:var(--hm-border-hairline) solid transparent;border-radius:var(--hm-radius-control);background:transparent;color:var(--hm-text);font:inherit;font-size:var(--hm-font-size-sm);text-align:start;cursor:pointer;transition:background-color var(--hm-duration-quick) var(--hm-ease)}.entry:hover{background:var(--hm-hover)}.entry:focus-visible{outline:var(--hm-border-thin) solid var(--hm-accent);outline-offset:calc(var(--hm-border-thin) * -1)}.entry--active{background:var(--hm-accent-subtle);color:var(--hm-accent-strong);font-weight:var(--hm-font-weight-semibold)}.reader{display:flex;flex-direction:column;gap:var(--hm-space-md);align-items:stretch}.reader-title{margin:0;color:var(--hm-text);font-size:var(--hm-font-size-lg);font-weight:var(--hm-font-weight-semibold)}.reading{animation:spine-docs-enter var(--hm-duration-standard) var(--hm-ease-decelerate)}.note{margin:0;color:var(--hm-muted);font-size:var(--hm-font-size-sm)}@keyframes spine-docs-enter{0%{opacity:0}to{opacity:1}}@media(max-width:767px){.layout{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,2fr) minmax(0,3fr)}.index{padding-inline-end:0;padding-block-end:var(--hm-space-md);border-inline-end:0;border-block-end:var(--hm-border-hairline) solid var(--hm-divider)}}@media(prefers-reduced-motion:reduce){.reading{animation:none}.entry{transition:none}}@media(forced-colors:active){.entry--active{border-color:Highlight}}\n"] }]
|
|
2604
|
+
}] });
|
|
2605
|
+
|
|
2606
|
+
/**
|
|
2607
|
+
* Aviso discreto de conexão no topo do shell, sobre o `connectionState$` e o `reauthorizationRequired$` do
|
|
2608
|
+
* `@orfeu/angular`. A sessão nunca cai por nada disto (sem rede, Argos fora do ar, refresh recusado):
|
|
2609
|
+
* a lib segue tentando sozinha e este banner só conta o que está acontecendo.
|
|
2610
|
+
*
|
|
2611
|
+
* - `offline`: aviso (`warning`) — Argos inalcançável; a lib tenta de novo com backoff e na volta da rede/foco.
|
|
2612
|
+
* - `reconnecting` / `reauthorizing`: informativo (`info`), some quando volta a `online`.
|
|
2613
|
+
* - `reauthorizationRequired$`: o Argos recusou a renovação e a reautorização automática não aconteceu
|
|
2614
|
+
* (desligada, outra aba cuidando ou proteção contra loop). Oferece "Entrar novamente", que chama
|
|
2615
|
+
* `authorize(urlAtual)` — o login volta para a mesma tela. Nunca chama `logout()`.
|
|
2616
|
+
*
|
|
2617
|
+
* `hm-alert` em `appearance="banner"` e `size="small"`: faixa de largura total, sem cantos, com os tokens
|
|
2618
|
+
* de status do Hermes (`--hm-warning-*`/`--hm-info-*`); `role="status"` (não interrompe o leitor de tela).
|
|
2619
|
+
*/
|
|
2620
|
+
class SpineConnectionBannerComponent {
|
|
2621
|
+
constructor() {
|
|
2622
|
+
this.text = inject(SPINE_MESSAGES).session;
|
|
2623
|
+
this.auth = inject(AuthorizationService);
|
|
2624
|
+
this.router = inject(Router);
|
|
2625
|
+
this.state = toSignal(this.auth.connectionState$, { initialValue: this.auth.connectionState });
|
|
2626
|
+
this.reauthRequired = toSignal(this.auth.reauthorizationRequired$, {
|
|
2627
|
+
initialValue: this.auth.reauthorizationRequired,
|
|
2628
|
+
});
|
|
2629
|
+
/** O que mostrar: a decisão pendente da pessoa vence o estado da conexão; `online` não mostra nada. */
|
|
2630
|
+
this.notice = computed(() => (this.reauthRequired() ? 'reauthorizationRequired' : this.state()), ...(ngDevMode ? [{ debugName: "notice" }] : /* istanbul ignore next */ []));
|
|
2631
|
+
}
|
|
2632
|
+
signInAgain() {
|
|
2633
|
+
void this.auth.authorize(this.router.url);
|
|
2634
|
+
}
|
|
2635
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineConnectionBannerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
2636
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: SpineConnectionBannerComponent, isStandalone: true, selector: "spine-connection-banner", ngImport: i0, template: `
|
|
2637
|
+
@switch (notice()) {
|
|
2638
|
+
@case ('reauthorizationRequired') {
|
|
2639
|
+
<hm-alert appearance="banner" size="small" variant="warning">
|
|
2640
|
+
{{ text.reauthorizationRequired }}
|
|
2641
|
+
<hm-button actions emphasis="medium" size="small" icon="login" (clicked)="signInAgain()">{{ text.signInAgain }}</hm-button>
|
|
2642
|
+
</hm-alert>
|
|
2643
|
+
}
|
|
2644
|
+
@case ('offline') {
|
|
2645
|
+
<hm-alert appearance="banner" size="small" variant="warning">{{ text.offline }}</hm-alert>
|
|
2646
|
+
}
|
|
2647
|
+
@case ('reconnecting') {
|
|
2648
|
+
<hm-alert appearance="banner" size="small" variant="info">{{ text.reconnecting }}</hm-alert>
|
|
2649
|
+
}
|
|
2650
|
+
@case ('reauthorizing') {
|
|
2651
|
+
<hm-alert appearance="banner" size="small" variant="info">{{ text.reauthorizing }}</hm-alert>
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
`, isInline: true, styles: [":host{display:block;flex:none}\n"], dependencies: [{ kind: "component", type: AlertComponent, selector: "hm-alert", inputs: ["variant", "appearance", "heading", "closable", "size", "showIcon", "link", "linkLabel", "linkTarget", "dismissKey", "duration"], outputs: ["closed"] }, { kind: "component", type: ButtonComponent, selector: "hm-button", inputs: ["emphasis", "intent", "size", "type", "icon", "iconDirection", "iconEnd", "disabled", "loading", "busy", "loadingState", "block", "link", "newTab", "caret", "inMenu", "toggle", "pressed"], outputs: ["loadingStateChange", "pressedChange", "clicked"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
2655
|
+
}
|
|
2656
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineConnectionBannerComponent, decorators: [{
|
|
2657
|
+
type: Component,
|
|
2658
|
+
args: [{ selector: 'spine-connection-banner', imports: [AlertComponent, ButtonComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
|
|
2659
|
+
@switch (notice()) {
|
|
2660
|
+
@case ('reauthorizationRequired') {
|
|
2661
|
+
<hm-alert appearance="banner" size="small" variant="warning">
|
|
2662
|
+
{{ text.reauthorizationRequired }}
|
|
2663
|
+
<hm-button actions emphasis="medium" size="small" icon="login" (clicked)="signInAgain()">{{ text.signInAgain }}</hm-button>
|
|
2664
|
+
</hm-alert>
|
|
2665
|
+
}
|
|
2666
|
+
@case ('offline') {
|
|
2667
|
+
<hm-alert appearance="banner" size="small" variant="warning">{{ text.offline }}</hm-alert>
|
|
2668
|
+
}
|
|
2669
|
+
@case ('reconnecting') {
|
|
2670
|
+
<hm-alert appearance="banner" size="small" variant="info">{{ text.reconnecting }}</hm-alert>
|
|
2671
|
+
}
|
|
2672
|
+
@case ('reauthorizing') {
|
|
2673
|
+
<hm-alert appearance="banner" size="small" variant="info">{{ text.reauthorizing }}</hm-alert>
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
`, styles: [":host{display:block;flex:none}\n"] }]
|
|
2677
|
+
}] });
|
|
2678
|
+
|
|
2679
|
+
/**
|
|
2680
|
+
* Shell do spine — MESMA estrutura do `docs/website`'s `app.component.html`/`.scss` (decisão de
|
|
2681
|
+
* 2026-09-28, ver `hefesto-migration-plan.md`): `.shell` com altura de viewport de verdade
|
|
2682
|
+
* (`100dvh`, não `%` cascateado — `@trismegisto/core` não fixa altura de `html`/`body`, é
|
|
2683
|
+
* responsabilidade de quem consome, mesmo tanto quanto o docs faz no próprio `styles.scss`),
|
|
2684
|
+
* `hm-header` (utilitários — tema/densidade/apps/perfil, nunca navegação de página) + `.body`
|
|
2685
|
+
* (`hm-sidebar` fixa + `.main` com `hm-workspace`). **Sem barra de breadcrumb própria** — isso
|
|
2686
|
+
* era resíduo do spine antigo (`spine-navigation`), removido: o docs não tem uma, a navegação
|
|
2687
|
+
* inteira mora na sidebar. Entre o header e o corpo fica o `spine-connection-banner` (aviso de conexão
|
|
2688
|
+
* do `@orfeu/angular`, vazio quando `online`).
|
|
2689
|
+
*
|
|
2690
|
+
* O `<router-outlet>` que sobra aqui é só pra `/no-permission` (a única rota do shell com
|
|
2691
|
+
* `component` de verdade — o resto da árvore do backend nunca passa pelo `Router` pra renderizar,
|
|
2692
|
+
* só pra `ViewGuard` confirmar que a URL resolve). Fica sempre montado (nunca dentro de um `@if`)
|
|
2693
|
+
* e escondido via `[hidden]` quando não é a rota ativa — destruir/recriar o outlet num `@if`
|
|
2694
|
+
* arriscaria perder a ativação da rota se a navegação e a checagem de `isNoPermission` não
|
|
2695
|
+
* corressem na mesma volta de detecção de mudanças.
|
|
2696
|
+
*/
|
|
2697
|
+
class LayoutComponent {
|
|
2698
|
+
constructor(router, theme, auth, shortcuts, onboarding, news) {
|
|
2699
|
+
this.router = router;
|
|
2700
|
+
this.theme = theme;
|
|
2701
|
+
this.auth = auth;
|
|
2702
|
+
this.shortcuts = shortcuts;
|
|
2703
|
+
this.onboarding = onboarding;
|
|
2704
|
+
this.news = news;
|
|
2705
|
+
/** Aplica no boot o idioma e o fuso que a pessoa escolheu nas configurações. */
|
|
2706
|
+
this.preferences = inject(SpinePreferencesService);
|
|
2707
|
+
this.workspace = inject(SpineWorkspaceNavService);
|
|
2708
|
+
this.settings = inject(SpineSettingsService);
|
|
2709
|
+
this.isNoPermission = this.matchesNoPermission(this.router.url);
|
|
2710
|
+
this.router.events.pipe(filter((e) => e instanceof NavigationEnd))
|
|
2711
|
+
.subscribe(e => { this.isNoPermission = this.matchesNoPermission(e.urlAfterRedirects); });
|
|
2712
|
+
}
|
|
2713
|
+
matchesNoPermission(url) {
|
|
2714
|
+
return url.split(/[?#]/)[0] === '/no-permission';
|
|
2715
|
+
}
|
|
2716
|
+
ngOnInit() {
|
|
2717
|
+
// Liga os atalhos de teclado globais às ações reais do sistema.
|
|
2718
|
+
this.shortcuts.setActions({
|
|
2719
|
+
navigate: (url) => this.router.navigateByUrl(url),
|
|
2720
|
+
toggleTheme: () => this.theme.setTheme(this.theme.current === 'light' ? 'dark' : 'light'),
|
|
2721
|
+
toggleFullscreen: () => this.toggleFullscreen(),
|
|
2722
|
+
logout: () => this.auth.logout(),
|
|
2723
|
+
openSettings: () => this.settings.toggle(),
|
|
2724
|
+
});
|
|
2725
|
+
// Link direto pra uma seção do painel de configurações: `?settings=aparencia` (ou só
|
|
2726
|
+
// `?settings`). Tira o parâmetro da URL depois de abrir — senão ele volta a abrir o painel a
|
|
2727
|
+
// cada F5 e acompanha a URL copiada pra outra pessoa.
|
|
2728
|
+
const params = new URLSearchParams(window.location.search);
|
|
2729
|
+
if (params.has('settings')) {
|
|
2730
|
+
this.settings.open(params.get('settings') ?? '');
|
|
2731
|
+
void this.router.navigate([], { queryParams: { settings: null }, queryParamsHandling: 'merge', replaceUrl: true });
|
|
2732
|
+
}
|
|
2733
|
+
// Onboarding interativo na primeira entrada nesta máquina.
|
|
2734
|
+
this.onboarding.maybeStart();
|
|
2735
|
+
// Novidades do app (provideSpineNews): abrem sozinhas uma vez, depois do tour.
|
|
2736
|
+
this.news.scheduleOnEntry();
|
|
2737
|
+
}
|
|
2738
|
+
onLocalNavigate(panelId, href) {
|
|
2739
|
+
this.workspace.navigateLocally(panelId, href);
|
|
2740
|
+
}
|
|
2741
|
+
toggleFullscreen() {
|
|
2742
|
+
if (!document.fullscreenElement) {
|
|
2743
|
+
document.documentElement.requestFullscreen?.().catch(() => { });
|
|
2744
|
+
}
|
|
2745
|
+
else {
|
|
2746
|
+
document.exitFullscreen?.();
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: LayoutComponent, deps: [{ token: i3.Router }, { token: i2$1.ThemeService }, { token: i1.AuthorizationService }, { token: ShortcutService }, { token: OnboardingService }, { token: SpineNewsService }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
2750
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: LayoutComponent, isStandalone: true, selector: "spine-layout", ngImport: i0, template: `
|
|
2751
|
+
<div class="shell">
|
|
2752
|
+
<spine-header></spine-header>
|
|
2753
|
+
<!-- Aviso de conexão do @orfeu/angular (offline/reconectando/reautorizando): faixa entre o header e o corpo. -->
|
|
2754
|
+
<spine-connection-banner />
|
|
2755
|
+
<div class="body">
|
|
2756
|
+
@if (!isNoPermission) {
|
|
2757
|
+
<spine-sidebar class="sidebar" data-tour="nav"></spine-sidebar>
|
|
2758
|
+
}
|
|
2759
|
+
<div class="main" role="main">
|
|
2760
|
+
<div [hidden]="isNoPermission" class="workspace-area">
|
|
2761
|
+
<hm-workspace data-tour="tabs" [keepAlive]="true" [providedStore]="workspace.store" (newTabRequested)="workspace.openNewTab($event)">
|
|
2762
|
+
<ng-template #content let-tab let-panelId="panelId" let-active="active">
|
|
2763
|
+
<div
|
|
2764
|
+
class="panel-content"
|
|
2765
|
+
[appPanelLocalLink]="panelId !== workspace.focusedPanelId()"
|
|
2766
|
+
(localNavigate)="onLocalNavigate(panelId, $event)"
|
|
2767
|
+
>
|
|
2768
|
+
<spine-page-panel [url]="tab.id" [isFocusedPanel]="active && panelId === workspace.focusedPanelId()" />
|
|
2769
|
+
</div>
|
|
2770
|
+
</ng-template>
|
|
2771
|
+
</hm-workspace>
|
|
2772
|
+
</div>
|
|
2773
|
+
<router-outlet></router-outlet>
|
|
2774
|
+
</div>
|
|
2775
|
+
</div>
|
|
2776
|
+
</div>
|
|
2777
|
+
<spine-notification></spine-notification>
|
|
2778
|
+
<spine-side-panel />
|
|
2779
|
+
<spine-modal />
|
|
2780
|
+
<spine-shortcuts-cheatsheet></spine-shortcuts-cheatsheet>
|
|
2781
|
+
<spine-settings-panel></spine-settings-panel>
|
|
2782
|
+
<spine-docs />
|
|
2783
|
+
<spine-news />
|
|
2784
|
+
<spine-onboarding-overlay></spine-onboarding-overlay>
|
|
2785
|
+
`, isInline: true, styles: [":host{display:block}.shell{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--hm-surface);color:var(--hm-text)}.body{position:relative;display:flex;flex:1;min-height:0;overflow:hidden}.main{display:flex;flex:1;min-width:0;flex-direction:column}.workspace-area{flex:1;min-height:0;display:flex;flex-direction:column;--hm-tab-strip-bg: var(--hm-surface);--hm-tab-active-bg: var(--hm-surface-raised)}hm-workspace{flex:1;min-height:0}.panel-content{display:flex;flex-direction:column;height:100%;min-height:0}\n"], dependencies: [{ kind: "component", type: SpineNewsComponent, selector: "spine-news" }, { kind: "component", type: SpineDocsComponent, selector: "spine-docs" }, { kind: "component", type: HeaderComponent, selector: "spine-header" }, { kind: "component", type: SidebarComponent, selector: "spine-sidebar" }, { kind: "component", type: SpineConnectionBannerComponent, selector: "spine-connection-banner" }, { kind: "component", type: SpinePagePanelComponent, selector: "spine-page-panel", inputs: ["url", "isFocusedPanel"] }, { kind: "directive", type: PanelLocalLinkDirective, selector: "[appPanelLocalLink]", inputs: ["appPanelLocalLink"], outputs: ["localNavigate"] }, { kind: "component", type: WorkspaceComponent, selector: "hm-workspace", inputs: ["initialTabs", "initialActiveId", "keepAlive", "label", "providedStore"], outputs: ["stateChange", "newTabRequested"] }, { kind: "directive", type: RouterOutlet, selector: "router-outlet", inputs: ["name", "routerOutletData"], outputs: ["activate", "deactivate", "attach", "detach"], exportAs: ["outlet"] }, { kind: "component", type: ModalComponent$1, selector: "spine-modal" }, { kind: "component", type: NotificationComponent, selector: "spine-notification" }, { kind: "component", type: SidePanelComponent, selector: "spine-side-panel" }, { kind: "component", type: OnboardingOverlayComponent, selector: "spine-onboarding-overlay" }, { kind: "component", type: ShortcutsCheatsheetComponent, selector: "spine-shortcuts-cheatsheet" }, { kind: "component", type: SettingsPanelComponent, selector: "spine-settings-panel" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
2786
|
+
}
|
|
2787
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: LayoutComponent, decorators: [{
|
|
2788
|
+
type: Component,
|
|
2789
|
+
args: [{ selector: 'spine-layout', imports: [
|
|
2790
|
+
SpineNewsComponent,
|
|
2791
|
+
SpineDocsComponent,
|
|
2792
|
+
HeaderComponent,
|
|
2793
|
+
SidebarComponent,
|
|
2794
|
+
SpineConnectionBannerComponent,
|
|
2795
|
+
SpinePagePanelComponent,
|
|
2796
|
+
PanelLocalLinkDirective,
|
|
2797
|
+
WorkspaceComponent,
|
|
2798
|
+
RouterOutlet,
|
|
2799
|
+
ModalComponent$1,
|
|
2800
|
+
NotificationComponent,
|
|
2801
|
+
SidePanelComponent,
|
|
2802
|
+
OnboardingOverlayComponent,
|
|
2803
|
+
ShortcutsCheatsheetComponent,
|
|
2804
|
+
SettingsPanelComponent,
|
|
2805
|
+
], changeDetection: ChangeDetectionStrategy.OnPush, template: `
|
|
2806
|
+
<div class="shell">
|
|
2807
|
+
<spine-header></spine-header>
|
|
2808
|
+
<!-- Aviso de conexão do @orfeu/angular (offline/reconectando/reautorizando): faixa entre o header e o corpo. -->
|
|
2809
|
+
<spine-connection-banner />
|
|
2810
|
+
<div class="body">
|
|
2811
|
+
@if (!isNoPermission) {
|
|
2812
|
+
<spine-sidebar class="sidebar" data-tour="nav"></spine-sidebar>
|
|
2813
|
+
}
|
|
2814
|
+
<div class="main" role="main">
|
|
2815
|
+
<div [hidden]="isNoPermission" class="workspace-area">
|
|
2816
|
+
<hm-workspace data-tour="tabs" [keepAlive]="true" [providedStore]="workspace.store" (newTabRequested)="workspace.openNewTab($event)">
|
|
2817
|
+
<ng-template #content let-tab let-panelId="panelId" let-active="active">
|
|
2818
|
+
<div
|
|
2819
|
+
class="panel-content"
|
|
2820
|
+
[appPanelLocalLink]="panelId !== workspace.focusedPanelId()"
|
|
2821
|
+
(localNavigate)="onLocalNavigate(panelId, $event)"
|
|
2822
|
+
>
|
|
2823
|
+
<spine-page-panel [url]="tab.id" [isFocusedPanel]="active && panelId === workspace.focusedPanelId()" />
|
|
2824
|
+
</div>
|
|
2825
|
+
</ng-template>
|
|
2826
|
+
</hm-workspace>
|
|
2827
|
+
</div>
|
|
2828
|
+
<router-outlet></router-outlet>
|
|
2829
|
+
</div>
|
|
2830
|
+
</div>
|
|
2831
|
+
</div>
|
|
2832
|
+
<spine-notification></spine-notification>
|
|
2833
|
+
<spine-side-panel />
|
|
2834
|
+
<spine-modal />
|
|
2835
|
+
<spine-shortcuts-cheatsheet></spine-shortcuts-cheatsheet>
|
|
2836
|
+
<spine-settings-panel></spine-settings-panel>
|
|
2837
|
+
<spine-docs />
|
|
2838
|
+
<spine-news />
|
|
2839
|
+
<spine-onboarding-overlay></spine-onboarding-overlay>
|
|
2840
|
+
`, styles: [":host{display:block}.shell{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--hm-surface);color:var(--hm-text)}.body{position:relative;display:flex;flex:1;min-height:0;overflow:hidden}.main{display:flex;flex:1;min-width:0;flex-direction:column}.workspace-area{flex:1;min-height:0;display:flex;flex-direction:column;--hm-tab-strip-bg: var(--hm-surface);--hm-tab-active-bg: var(--hm-surface-raised)}hm-workspace{flex:1;min-height:0}.panel-content{display:flex;flex-direction:column;height:100%;min-height:0}\n"] }]
|
|
2841
|
+
}], ctorParameters: () => [{ type: i3.Router }, { type: i2$1.ThemeService }, { type: i1.AuthorizationService }, { type: ShortcutService }, { type: OnboardingService }, { type: SpineNewsService }] });
|
|
2842
|
+
|
|
2843
|
+
/**
|
|
2844
|
+
* Generated bundle index. Do not edit.
|
|
2845
|
+
*/
|
|
2846
|
+
|
|
2847
|
+
export { AppLauncherComponent, HeaderComponent, LayoutComponent, OnboardingOverlayComponent, OnboardingService, SPINE_BRAND, SPINE_SETTINGS_KEYS, SPINE_SETTINGS_SECTIONS, SettingsPanelComponent, ShortcutService, ShortcutsCheatsheetComponent, SidebarComponent, SpineAppTokensService, SpineBrandComponent, SpineConnectionBannerComponent, SpineDocsComponent, SpineDocsService, SpineMarkdownComponent, SpineNewsComponent, SpineNewsService, SpineSettingsService, findTourTarget, provideSettingsSection };
|
|
2848
|
+
//# sourceMappingURL=trismegisto-spine-layout.mjs.map
|