@dignite/ng.site 1.0.0 → 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.
Files changed (28) hide show
  1. package/README.md +16 -2
  2. package/fesm2022/dignite-ng.site-config.mjs +10 -1
  3. package/fesm2022/dignite-ng.site-config.mjs.map +1 -1
  4. package/fesm2022/{dignite-ng.site-content-editor.component-iFWDeYJ9.mjs → dignite-ng.site-content-editor.component-DA6FaJSU.mjs} +2 -2
  5. package/fesm2022/{dignite-ng.site-content-editor.component-iFWDeYJ9.mjs.map → dignite-ng.site-content-editor.component-DA6FaJSU.mjs.map} +1 -1
  6. package/fesm2022/{dignite-ng.site-content-types.component-D2jMSSq6.mjs → dignite-ng.site-content-types.component-Dnqe8A4t.mjs} +27 -7
  7. package/fesm2022/dignite-ng.site-content-types.component-Dnqe8A4t.mjs.map +1 -0
  8. package/fesm2022/{dignite-ng.site-contents.component-DKuel8w-.mjs → dignite-ng.site-contents.component-Ch5GYIHE.mjs} +2 -2
  9. package/fesm2022/{dignite-ng.site-contents.component-DKuel8w-.mjs.map → dignite-ng.site-contents.component-Ch5GYIHE.mjs.map} +1 -1
  10. package/fesm2022/{dignite-ng.site-dignite-ng.site-CsKxqjY9.mjs → dignite-ng.site-dignite-ng.site-BMcNABSG.mjs} +283 -55
  11. package/fesm2022/dignite-ng.site-dignite-ng.site-BMcNABSG.mjs.map +1 -0
  12. package/fesm2022/{dignite-ng.site-fields.component-By_YyfIx.mjs → dignite-ng.site-fields.component-BaK27N9k.mjs} +5 -3
  13. package/fesm2022/dignite-ng.site-fields.component-BaK27N9k.mjs.map +1 -0
  14. package/fesm2022/{dignite-ng.site-pages.component-S9BEgC4W.mjs → dignite-ng.site-pages.component-BwGyK7jw.mjs} +28 -5
  15. package/fesm2022/dignite-ng.site-pages.component-BwGyK7jw.mjs.map +1 -0
  16. package/fesm2022/dignite-ng.site-setting-management.mjs +123 -0
  17. package/fesm2022/dignite-ng.site-setting-management.mjs.map +1 -0
  18. package/fesm2022/dignite-ng.site-theme-workbench.component-CA47DnTj.mjs +1206 -0
  19. package/fesm2022/dignite-ng.site-theme-workbench.component-CA47DnTj.mjs.map +1 -0
  20. package/fesm2022/dignite-ng.site.mjs +1 -1
  21. package/package.json +17 -1
  22. package/types/dignite-ng.site-config.d.ts +2 -1
  23. package/types/dignite-ng.site-setting-management.d.ts +41 -0
  24. package/types/dignite-ng.site.d.ts +386 -111
  25. package/fesm2022/dignite-ng.site-content-types.component-D2jMSSq6.mjs.map +0 -1
  26. package/fesm2022/dignite-ng.site-dignite-ng.site-CsKxqjY9.mjs.map +0 -1
  27. package/fesm2022/dignite-ng.site-fields.component-By_YyfIx.mjs.map +0 -1
  28. package/fesm2022/dignite-ng.site-pages.component-S9BEgC4W.mjs.map +0 -1
@@ -0,0 +1,1206 @@
1
+ import * as i4 from '@abp/ng.core';
2
+ import { PermissionService, LocalizationService, CoreModule } from '@abp/ng.core';
3
+ import * as i2 from '@abp/ng.theme.shared';
4
+ import { ConfirmationService, ToasterService, Confirmation, ThemeSharedModule } from '@abp/ng.theme.shared';
5
+ import * as i0 from '@angular/core';
6
+ import { input, output, viewChild, signal, effect, ChangeDetectionStrategy, Component, inject, computed, untracked, HostListener } from '@angular/core';
7
+ import * as i1 from '@angular/forms';
8
+ import { FormsModule } from '@angular/forms';
9
+ import { forkJoin } from 'rxjs';
10
+ import { h as SiteThemeFileStatus, g as SiteThemeAdminService, P as PageAdminService, i as FieldAdminService, j as SiteThemeIssueSeverity, e as eSitePolicyNames, k as SiteThemeUsageKindDto } from './dignite-ng.site-dignite-ng.site-BMcNABSG.mjs';
11
+ import { css } from '@codemirror/lang-css';
12
+ import { html } from '@codemirror/lang-html';
13
+ import { javascript } from '@codemirror/lang-javascript';
14
+ import { Compartment, EditorState } from '@codemirror/state';
15
+ import { EditorView } from '@codemirror/view';
16
+ import { basicSetup } from 'codemirror';
17
+ import * as i1$1 from '@angular/common';
18
+
19
+ /**
20
+ * A code editor over CodeMirror 6, used for the files of a theme.
21
+ *
22
+ * A template is highlighted as HTML, not Scriban: it is an HTML document with Scriban's `{{ }}` in it, and there is
23
+ * no Scriban grammar for CodeMirror, so the `{{ }}` parts are not highlighted - only the markup around them. A static
24
+ * file is highlighted by its extension ({@link language}).
25
+ *
26
+ * {@link documentKey} names the file being edited. When it changes, the editor starts a new editing state for the new
27
+ * file, so undo never reaches into another file. While it stays the same, the document is owned by CodeMirror as the
28
+ * user types; {@link value} only matters when it differs from the document (the file was reloaded, say), in which
29
+ * case the whole document is replaced. A value the editor itself just emitted therefore comes back as a no-op.
30
+ */
31
+ class CodeEditorComponent {
32
+ constructor() {
33
+ this.value = input('', /* @ts-ignore */
34
+ ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
35
+ this.readonly = input(false, /* @ts-ignore */
36
+ ...(ngDevMode ? [{ debugName: "readonly" }] : /* istanbul ignore next */ []));
37
+ this.language = input('html', /* @ts-ignore */
38
+ ...(ngDevMode ? [{ debugName: "language" }] : /* istanbul ignore next */ []));
39
+ this.documentKey = input(undefined, /* @ts-ignore */
40
+ ...(ngDevMode ? [{ debugName: "documentKey" }] : /* istanbul ignore next */ []));
41
+ this.valueChange = output();
42
+ this.host = viewChild.required('host', /* @ts-ignore */
43
+ ...(ngDevMode ? [{ debugName: "host" }] : /* istanbul ignore next */ []));
44
+ this.readOnlyCompartment = new Compartment();
45
+ this.languageCompartment = new Compartment();
46
+ // A signal, so the effects below run again once the view exists - it is created in ngAfterViewInit, after
47
+ // their first run.
48
+ this.view = signal(undefined, /* @ts-ignore */
49
+ ...(ngDevMode ? [{ debugName: "view" }] : /* istanbul ignore next */ []));
50
+ effect(() => {
51
+ const view = this.view();
52
+ const key = this.documentKey();
53
+ const value = this.value();
54
+ if (!view) {
55
+ return;
56
+ }
57
+ if (key !== this.currentKey) {
58
+ this.currentKey = key;
59
+ view.setState(this.createState(value));
60
+ return;
61
+ }
62
+ if (value !== view.state.doc.toString()) {
63
+ view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
64
+ }
65
+ });
66
+ effect(() => {
67
+ const view = this.view();
68
+ const readonly = this.readonly();
69
+ const language = this.language();
70
+ view?.dispatch({
71
+ effects: [
72
+ this.readOnlyCompartment.reconfigure(readOnlyExtensions(readonly)),
73
+ this.languageCompartment.reconfigure(languageExtension(language)),
74
+ ],
75
+ });
76
+ });
77
+ }
78
+ ngAfterViewInit() {
79
+ this.currentKey = this.documentKey();
80
+ this.view.set(new EditorView({ parent: this.host().nativeElement, state: this.createState(this.value()) }));
81
+ }
82
+ createState(doc) {
83
+ return EditorState.create({
84
+ doc,
85
+ extensions: [
86
+ basicSetup,
87
+ this.languageCompartment.of(languageExtension(this.language())),
88
+ EditorView.lineWrapping,
89
+ EDITOR_THEME,
90
+ this.readOnlyCompartment.of(readOnlyExtensions(this.readonly())),
91
+ EditorView.updateListener.of(update => {
92
+ if (update.docChanged) {
93
+ this.valueChange.emit(update.state.doc.toString());
94
+ }
95
+ }),
96
+ ],
97
+ });
98
+ }
99
+ ngOnDestroy() {
100
+ this.view()?.destroy();
101
+ }
102
+ /** Puts the cursor at a 1-based line and column and scrolls it into view. Out-of-range values are clamped. */
103
+ goTo(line, column) {
104
+ const view = this.view();
105
+ if (!view) {
106
+ return;
107
+ }
108
+ const doc = view.state.doc;
109
+ const target = doc.line(Math.min(Math.max(Math.trunc(line) || 1, 1), doc.lines));
110
+ const position = Math.min(target.from + Math.max(Math.trunc(column) - 1, 0), target.to);
111
+ view.dispatch({
112
+ selection: { anchor: position },
113
+ effects: EditorView.scrollIntoView(position, { y: 'center' }),
114
+ });
115
+ view.focus();
116
+ }
117
+ /**
118
+ * Replaces the selection (or inserts at the cursor) with `text`, puts the cursor after it and focuses the editor. It
119
+ * is an ordinary transaction, so {@link valueChange} fires exactly as it does for typing. A read-only editor is left
120
+ * alone.
121
+ */
122
+ insert(text) {
123
+ const view = this.view();
124
+ if (!view || this.readonly()) {
125
+ return;
126
+ }
127
+ view.dispatch(view.state.update(view.state.replaceSelection(text), { scrollIntoView: true, userEvent: 'input' }));
128
+ view.focus();
129
+ }
130
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.2", ngImport: i0, type: CodeEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
131
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "22.2.2", type: CodeEditorComponent, isStandalone: true, selector: "site-code-editor", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, readonly: { classPropertyName: "readonly", publicName: "readonly", isSignal: true, isRequired: false, transformFunction: null }, language: { classPropertyName: "language", publicName: "language", isSignal: true, isRequired: false, transformFunction: null }, documentKey: { classPropertyName: "documentKey", publicName: "documentKey", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { valueChange: "valueChange" }, viewQueries: [{ propertyName: "host", first: true, predicate: ["host"], descendants: true, isSignal: true }], ngImport: i0, template: '<div #host></div>', isInline: true, styles: [":host{display:block}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
132
+ }
133
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.2", ngImport: i0, type: CodeEditorComponent, decorators: [{
134
+ type: Component,
135
+ args: [{ selector: 'site-code-editor', changeDetection: ChangeDetectionStrategy.OnPush, template: '<div #host></div>', styles: [":host{display:block}\n"] }]
136
+ }], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }], readonly: [{ type: i0.Input, args: [{ isSignal: true, alias: "readonly", required: false }] }], language: [{ type: i0.Input, args: [{ isSignal: true, alias: "language", required: false }] }], documentKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "documentKey", required: false }] }], valueChange: [{ type: i0.Output, args: ["valueChange"] }], host: [{ type: i0.ViewChild, args: ['host', { isSignal: true }] }] } });
137
+ function readOnlyExtensions(readonly) {
138
+ return [EditorState.readOnly.of(readonly), EditorView.editable.of(!readonly)];
139
+ }
140
+ function languageExtension(language) {
141
+ switch (language) {
142
+ case 'html':
143
+ return html();
144
+ case 'css':
145
+ return css();
146
+ case 'javascript':
147
+ return javascript();
148
+ default:
149
+ return [];
150
+ }
151
+ }
152
+ // The editor sits inside a Bootstrap card, so it inherits the card's colors rather than reading the body's:
153
+ // in LeptonX Lite's dim theme --bs-body-bg is dark while the card stays light, and an editor painted with the
154
+ // body tokens came out as a black box on a white card. Only the chrome (border, gutter, active line) reads
155
+ // theme variables, with Bootstrap's light values as fallbacks.
156
+ const EDITOR_THEME = EditorView.theme({
157
+ // The page that hosts the editor may size it: --site-code-editor-height fixes its height, and without it the
158
+ // editor grows with its text up to --site-code-editor-max-height.
159
+ '&': {
160
+ height: 'var(--site-code-editor-height, auto)',
161
+ minHeight: '24rem',
162
+ maxHeight: 'var(--site-code-editor-max-height, 75vh)',
163
+ border: '1px solid var(--bs-border-color, #dee2e6)',
164
+ borderRadius: 'var(--bs-border-radius, 0.375rem)',
165
+ backgroundColor: 'transparent',
166
+ },
167
+ '&.cm-focused': {
168
+ outline: 'none',
169
+ borderColor: 'var(--bs-primary, #0d6efd)',
170
+ },
171
+ '.cm-scroller': {
172
+ minHeight: '24rem',
173
+ fontFamily: 'var(--bs-font-monospace, monospace)',
174
+ },
175
+ '.cm-gutters': {
176
+ backgroundColor: 'transparent',
177
+ color: 'inherit',
178
+ opacity: '0.6',
179
+ border: 'none',
180
+ borderRight: '1px solid var(--bs-border-color, #dee2e6)',
181
+ },
182
+ '.cm-activeLine, .cm-activeLineGutter': {
183
+ backgroundColor: 'rgba(128, 128, 128, 0.12)',
184
+ },
185
+ });
186
+
187
+ /** Mirrors `SiteThemeConsts.PathPattern` (`src/Dignite.Site.Domain.Shared/Themes/SiteThemeConsts.cs`). */
188
+ const THEME_PATH_PATTERN = /^(?:[A-Za-z0-9_][A-Za-z0-9_-]*\/)*[A-Za-z0-9_][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*$/;
189
+ /** Mirrors `SiteThemeConsts.MaxPathLength`. */
190
+ const THEME_MAX_PATH_LENGTH = 256;
191
+ /** Mirrors the default `SiteThemeConsts.AssetExtensions`; a host may allow more, which the server then accepts. */
192
+ const THEME_ASSET_EXTENSIONS = [
193
+ 'css', 'js', 'mjs', 'json', 'map', 'txt', 'svg', 'xml', 'webmanifest', 'ics', 'csv',
194
+ ];
195
+ /** The path as the server stores it: trimmed, without a leading slash. */
196
+ function normalizeThemePath(path) {
197
+ return (path ?? '').trim().replace(/^\/+/, '');
198
+ }
199
+ /** The extension of the last segment, without the dot, or null - the server's `SiteThemePaths.GetExtension`. */
200
+ function extensionOf(path) {
201
+ const name = path.slice(path.lastIndexOf('/') + 1);
202
+ const dot = name.lastIndexOf('.');
203
+ return dot <= 0 || dot === name.length - 1 ? null : name.slice(dot + 1).toLowerCase();
204
+ }
205
+ /**
206
+ * Whether `name` is a language the browser knows, spelled the way it spells it (`ja`, `zh-Hans`; not `zh-hans`) -
207
+ * the server's check for the extension of a language version, which it makes with the runtime's cultures.
208
+ */
209
+ function isLanguageName(name) {
210
+ if (!/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(name)) {
211
+ return false;
212
+ }
213
+ try {
214
+ return (Intl.getCanonicalLocales(name)[0] === name &&
215
+ new Intl.DisplayNames(['en'], { type: 'language', fallback: 'none' }).of(name) !== undefined);
216
+ }
217
+ catch {
218
+ return false;
219
+ }
220
+ }
221
+ /**
222
+ * Whether the path names the version of a template for one language - `blog/post.ja` is the Japanese version of
223
+ * `blog/post` - which is a template, not a static file: the last segment has exactly one dot, followed by a language
224
+ * that is not a static file's extension. Mirrors the server's `SiteThemePaths.TryGetLanguageVersion`.
225
+ */
226
+ function isLanguageVersionPath(path) {
227
+ const name = path.slice(path.lastIndexOf('/') + 1);
228
+ const dot = name.indexOf('.');
229
+ if (dot <= 0 || dot !== name.lastIndexOf('.') || dot === name.length - 1) {
230
+ return false;
231
+ }
232
+ const extension = name.slice(dot + 1);
233
+ return !THEME_ASSET_EXTENSIONS.includes(extension.toLowerCase()) && isLanguageName(extension);
234
+ }
235
+ /** Whether the path names a static file (its last segment has an extension) rather than a Scriban template. */
236
+ function isAssetPath(path) {
237
+ return extensionOf(path) !== null && !isLanguageVersionPath(path);
238
+ }
239
+ /**
240
+ * Why a path cannot be saved, as a localization key, or null when it can. The server decides in the end (a host may
241
+ * allow more extensions); this only spares a round trip for the common mistakes.
242
+ */
243
+ function themePathProblem(path) {
244
+ const normalized = normalizeThemePath(path);
245
+ if (!normalized || normalized.length > THEME_MAX_PATH_LENGTH || !THEME_PATH_PATTERN.test(normalized)) {
246
+ return 'Site::ThemePathInvalid';
247
+ }
248
+ const extension = extensionOf(normalized);
249
+ if (extension !== null && !THEME_ASSET_EXTENSIONS.includes(extension) && !isLanguageVersionPath(normalized)) {
250
+ return 'Site::ThemePathExtensionNotAllowed';
251
+ }
252
+ return null;
253
+ }
254
+ function languageOf(path) {
255
+ if (isLanguageVersionPath(path)) {
256
+ return 'html';
257
+ }
258
+ switch (extensionOf(path)) {
259
+ case null:
260
+ case 'svg':
261
+ case 'xml':
262
+ return 'html';
263
+ case 'css':
264
+ return 'css';
265
+ case 'js':
266
+ case 'mjs':
267
+ case 'json':
268
+ case 'map':
269
+ case 'webmanifest':
270
+ return 'javascript';
271
+ default:
272
+ return 'text';
273
+ }
274
+ }
275
+ const LAYOUT_SKELETON = `<!DOCTYPE html>
276
+ <html lang="{{ culture }}">
277
+ <head>
278
+ <meta charset="utf-8" />
279
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
280
+ <title>{{ head?.meta_title ?? page.display_name }}</title>
281
+ {{ site_head }}
282
+ </head>
283
+ <body>
284
+ {{ body }}
285
+ </body>
286
+ </html>
287
+ `;
288
+ const PAGE_SKELETON = `<h1>{{ page.display_name }}</h1>
289
+ {{ if content }}
290
+ {{ for field in field_list }}
291
+ <section>
292
+ <h2>{{ field.display_name }}</h2>
293
+ {{ render_field field.name }}
294
+ </section>
295
+ {{ end }}
296
+ {{ else }}
297
+ {{ list = contents { max: 20 } }}
298
+ <ul>
299
+ {{ for item in list.items }}
300
+ <li><a href="{{ item.url }}">{{ item.slug }}</a></li>
301
+ {{ end }}
302
+ </ul>
303
+ {{ end }}
304
+ `;
305
+ const PARTIAL_SKELETON = `<div>
306
+ {{ page.display_name }}
307
+ </div>
308
+ `;
309
+ /**
310
+ * What a new file starts with, guessed from the suggested conventions only - it is a starting point, not a type: a
311
+ * static file starts empty, a file under `layouts/` as a layout, a name starting with `_` as a partial, anything else
312
+ * as a page template.
313
+ */
314
+ function skeletonFor(path) {
315
+ const normalized = normalizeThemePath(path);
316
+ const name = normalized.slice(normalized.lastIndexOf('/') + 1);
317
+ if (isAssetPath(normalized)) {
318
+ return '';
319
+ }
320
+ if (normalized.startsWith('layouts/')) {
321
+ return LAYOUT_SKELETON;
322
+ }
323
+ return name.startsWith('_') ? PARTIAL_SKELETON : PAGE_SKELETON;
324
+ }
325
+ /**
326
+ * The working copy's files as a tree: folders first, then files, each by name, ordinally. A folder exists only because
327
+ * a file is in it - the theme has no empty folders.
328
+ */
329
+ function buildThemeTree(files) {
330
+ const root = {
331
+ path: '', name: '', isFolder: true, isAsset: false, status: SiteThemeFileStatus.Unchanged, children: [], hasChanges: false,
332
+ };
333
+ for (const file of files) {
334
+ const segments = (file.path ?? '').split('/');
335
+ let folder = root;
336
+ segments.slice(0, -1).forEach((segment, index) => {
337
+ const path = segments.slice(0, index + 1).join('/');
338
+ let child = folder.children.find(node => node.isFolder && node.path === path);
339
+ if (!child) {
340
+ child = {
341
+ path, name: segment, isFolder: true, isAsset: false, status: SiteThemeFileStatus.Unchanged, children: [], hasChanges: false,
342
+ };
343
+ folder.children.push(child);
344
+ }
345
+ folder = child;
346
+ });
347
+ folder.children.push({
348
+ path: file.path ?? '',
349
+ name: segments[segments.length - 1],
350
+ isFolder: false,
351
+ isAsset: file.isAsset,
352
+ status: file.status,
353
+ children: [],
354
+ hasChanges: file.status !== SiteThemeFileStatus.Unchanged,
355
+ });
356
+ }
357
+ sortAndMark(root);
358
+ return root.children;
359
+ }
360
+ function sortAndMark(folder) {
361
+ folder.children.sort((a, b) => a.isFolder !== b.isFolder ? (a.isFolder ? -1 : 1) : a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
362
+ let changed = false;
363
+ for (const child of folder.children) {
364
+ if (child.isFolder) {
365
+ child.hasChanges = sortAndMark(child);
366
+ }
367
+ changed ||= child.hasChanges;
368
+ }
369
+ return changed;
370
+ }
371
+ /** Every folder on the way to `path`, outermost first: `blog`, `blog/archive` for `blog/archive/post`. */
372
+ function foldersOf(path) {
373
+ const segments = path.split('/');
374
+ return segments.slice(0, -1).map((_, index) => segments.slice(0, index + 1).join('/'));
375
+ }
376
+ /** The message of an ABP error response, with its validation details. */
377
+ function errorMessageOf(response) {
378
+ const error = response?.error?.error;
379
+ const details = (error?.validationErrors ?? []).map(entry => entry.message).filter(Boolean);
380
+ return [error?.message ?? response?.message, ...details].filter(Boolean).join('\n');
381
+ }
382
+ /** The theme path a page's `Template` renders through - the site's resolution: a trailing `.cshtml` is dropped. */
383
+ function pageTemplatePath(template) {
384
+ const name = (template ?? '').trim();
385
+ return normalizeThemePath(name.toLowerCase().endsWith('.cshtml') ? name.slice(0, -'.cshtml'.length) : name);
386
+ }
387
+ /** The field type of a page's SEO block: edited in its own panel and written by `site_head`, never by hand. */
388
+ const SEO_FIELD_TYPE = 'Seo';
389
+ /** A field name Scriban can read as a member (`content.fields.title`); anything else needs the indexer form. */
390
+ const SCRIBAN_MEMBER_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
391
+ /**
392
+ * The Scriban that prints a field of the current content. Plain values are read straight off `content.fields`; a date
393
+ * is formatted; everything composite or rich (editor text, matrix, table, a linked content, files) goes through
394
+ * `render_field`, which knows how to draw it. A name that is not a plain identifier (it may hold a hyphen) is read with
395
+ * the indexer, the only form Scriban parses for it.
396
+ */
397
+ function snippetFor(fieldTypeName, name) {
398
+ const member = SCRIBAN_MEMBER_NAME.test(name) ? `content.fields.${name}` : `content.fields["${name}"]`;
399
+ switch (fieldTypeName) {
400
+ case 'Text':
401
+ case 'Number':
402
+ case 'Boolean':
403
+ case 'Select':
404
+ case 'Tree':
405
+ return `{{ ${member} }}`;
406
+ case 'DateTime':
407
+ return `{{ ${member} | date.to_string "%Y-%m-%d" }}`;
408
+ default:
409
+ return `{{ render_field "${name}" }}`;
410
+ }
411
+ }
412
+ /**
413
+ * One group per content type of `page`, its fields in arrangement order, named as the content type names them, without
414
+ * the SEO block; a content type left with no field is left out. Empty until both the page and the field library are
415
+ * there.
416
+ */
417
+ function fieldGroupsFor(page, library) {
418
+ if (!page || !library) {
419
+ return [];
420
+ }
421
+ const fieldsById = new Map(library.map(field => [field.id, field]));
422
+ return (page.contentTypes ?? [])
423
+ .map(contentType => ({
424
+ id: contentType.id ?? contentType.name ?? '',
425
+ displayName: contentType.displayName || contentType.name || '',
426
+ fields: [...(contentType.fields ?? [])]
427
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
428
+ .flatMap(arranged => {
429
+ const field = fieldsById.get(arranged.fieldId ?? '');
430
+ if (!field?.name || field.fieldTypeName === SEO_FIELD_TYPE) {
431
+ return [];
432
+ }
433
+ const fieldTypeName = field.fieldTypeName ?? '';
434
+ return [
435
+ {
436
+ name: field.name,
437
+ displayName: arranged.displayName || field.displayName || field.name,
438
+ fieldTypeName,
439
+ snippet: snippetFor(fieldTypeName, field.name),
440
+ },
441
+ ];
442
+ }),
443
+ }))
444
+ .filter(group => group.fields.length > 0);
445
+ }
446
+
447
+ /** `Site:090003`: the working copy changed since the check the publish was based on. */
448
+ const CHANGED_SINCE_CHECK = 'Site:090003';
449
+ /**
450
+ * The theme workbench: the working copy's files in a tree on the left, open files in an editor in the middle, the
451
+ * check on the right, and publishing, the release history and the theme's settings above.
452
+ *
453
+ * Saving writes one file of the working copy in place and changes nothing on the site; publishing freezes the whole
454
+ * working copy into a release and makes it live. See the Scriban Templates page of the documentation for the model.
455
+ *
456
+ * Deliberately no `<form (ngSubmit)>` around the editor: ABP's `FormSubmitDirective` (pulled in by `CoreModule`)
457
+ * submits a form on every Enter keyup that does not come from a `<textarea>`, and CodeMirror is a contenteditable.
458
+ */
459
+ class ThemeWorkbenchComponent {
460
+ constructor() {
461
+ this.themeService = inject(SiteThemeAdminService);
462
+ this.pageService = inject(PageAdminService);
463
+ this.fieldService = inject(FieldAdminService);
464
+ this.permission = inject(PermissionService);
465
+ this.confirmation = inject(ConfirmationService);
466
+ this.toaster = inject(ToasterService);
467
+ this.localization = inject(LocalizationService);
468
+ this.statuses = SiteThemeFileStatus;
469
+ this.severities = SiteThemeIssueSeverity;
470
+ this.canEdit = this.permission.getGrantedPolicy(eSitePolicyNames.ThemesEdit);
471
+ this.canPublish = this.permission.getGrantedPolicy(eSitePolicyNames.ThemesPublish);
472
+ this.editor = viewChild(CodeEditorComponent, /* @ts-ignore */
473
+ ...(ngDevMode ? [{ debugName: "editor" }] : /* istanbul ignore next */ []));
474
+ // State that changes in an HTTP callback is a signal, not a plain field: the host layout's ancestors are OnPush,
475
+ // and a plain-field write in a response callback never marks them for check.
476
+ this.isLoading = signal(true, /* @ts-ignore */
477
+ ...(ngDevMode ? [{ debugName: "isLoading" }] : /* istanbul ignore next */ []));
478
+ this.isBusy = signal(false, /* @ts-ignore */
479
+ ...(ngDevMode ? [{ debugName: "isBusy" }] : /* istanbul ignore next */ []));
480
+ this.theme = signal(undefined, /* @ts-ignore */
481
+ ...(ngDevMode ? [{ debugName: "theme" }] : /* istanbul ignore next */ []));
482
+ this.files = signal([], /* @ts-ignore */
483
+ ...(ngDevMode ? [{ debugName: "files" }] : /* istanbul ignore next */ []));
484
+ this.collapsed = signal(new Set(), /* @ts-ignore */
485
+ ...(ngDevMode ? [{ debugName: "collapsed" }] : /* istanbul ignore next */ []));
486
+ this.openFiles = signal([], /* @ts-ignore */
487
+ ...(ngDevMode ? [{ debugName: "openFiles" }] : /* istanbul ignore next */ []));
488
+ this.activePath = signal(undefined, /* @ts-ignore */
489
+ ...(ngDevMode ? [{ debugName: "activePath" }] : /* istanbul ignore next */ []));
490
+ this.check = signal(undefined, /* @ts-ignore */
491
+ ...(ngDevMode ? [{ debugName: "check" }] : /* istanbul ignore next */ []));
492
+ this.isChecking = signal(false, /* @ts-ignore */
493
+ ...(ngDevMode ? [{ debugName: "isChecking" }] : /* istanbul ignore next */ []));
494
+ this.tree = computed(() => buildThemeTree(this.files()), /* @ts-ignore */
495
+ ...(ngDevMode ? [{ debugName: "tree" }] : /* istanbul ignore next */ []));
496
+ /** The tree flattened into the rows the template shows, folders that are collapsed left closed. */
497
+ this.treeRows = computed(() => {
498
+ const rows = [];
499
+ const collapsed = this.collapsed();
500
+ const walk = (nodes, depth) => {
501
+ for (const node of nodes) {
502
+ rows.push({ node, depth });
503
+ if (node.isFolder && !collapsed.has(node.path)) {
504
+ walk(node.children, depth + 1);
505
+ }
506
+ }
507
+ };
508
+ walk(this.tree(), 0);
509
+ return rows;
510
+ }, /* @ts-ignore */
511
+ ...(ngDevMode ? [{ debugName: "treeRows" }] : /* istanbul ignore next */ []));
512
+ this.activeFile = computed(() => this.openFiles().find(file => file.path === this.activePath()), /* @ts-ignore */
513
+ ...(ngDevMode ? [{ debugName: "activeFile" }] : /* istanbul ignore next */ []));
514
+ /** Whether the file in front can be the default layout: the last check lists it among the layouts. */
515
+ this.isLayoutInFront = computed(() => {
516
+ const path = this.activePath();
517
+ return !!path && (this.check()?.layouts ?? []).includes(path);
518
+ }, /* @ts-ignore */
519
+ ...(ngDevMode ? [{ debugName: "isLayoutInFront" }] : /* istanbul ignore next */ []));
520
+ /** Whether the file in front already is the default layout. */
521
+ this.isDefaultLayoutInFront = computed(() => {
522
+ const path = this.activePath();
523
+ return !!path && path === this.theme()?.defaultLayout;
524
+ }, /* @ts-ignore */
525
+ ...(ngDevMode ? [{ debugName: "isDefaultLayoutInFront" }] : /* istanbul ignore next */ []));
526
+ this.hasUnsavedFiles = computed(() => this.openFiles().some(file => isDirty(file)), /* @ts-ignore */
527
+ ...(ngDevMode ? [{ debugName: "hasUnsavedFiles" }] : /* istanbul ignore next */ []));
528
+ this.errorCount = computed(() => this.issuesOf(SiteThemeIssueSeverity.Error).length, /* @ts-ignore */
529
+ ...(ngDevMode ? [{ debugName: "errorCount" }] : /* istanbul ignore next */ []));
530
+ this.warningCount = computed(() => this.issuesOf(SiteThemeIssueSeverity.Warning).length, /* @ts-ignore */
531
+ ...(ngDevMode ? [{ debugName: "warningCount" }] : /* istanbul ignore next */ []));
532
+ // Dialogs.
533
+ this.isNewFileOpen = signal(false, /* @ts-ignore */
534
+ ...(ngDevMode ? [{ debugName: "isNewFileOpen" }] : /* istanbul ignore next */ []));
535
+ this.newFilePath = signal('', /* @ts-ignore */
536
+ ...(ngDevMode ? [{ debugName: "newFilePath" }] : /* istanbul ignore next */ []));
537
+ this.newFileError = signal(null, /* @ts-ignore */
538
+ ...(ngDevMode ? [{ debugName: "newFileError" }] : /* istanbul ignore next */ []));
539
+ this.isRenameOpen = signal(false, /* @ts-ignore */
540
+ ...(ngDevMode ? [{ debugName: "isRenameOpen" }] : /* istanbul ignore next */ []));
541
+ this.renameFrom = signal('', /* @ts-ignore */
542
+ ...(ngDevMode ? [{ debugName: "renameFrom" }] : /* istanbul ignore next */ []));
543
+ this.renameTo = signal('', /* @ts-ignore */
544
+ ...(ngDevMode ? [{ debugName: "renameTo" }] : /* istanbul ignore next */ []));
545
+ this.renameUpdatesReferences = signal(true, /* @ts-ignore */
546
+ ...(ngDevMode ? [{ debugName: "renameUpdatesReferences" }] : /* istanbul ignore next */ []));
547
+ this.renameError = signal(null, /* @ts-ignore */
548
+ ...(ngDevMode ? [{ debugName: "renameError" }] : /* istanbul ignore next */ []));
549
+ this.isPublishOpen = signal(false, /* @ts-ignore */
550
+ ...(ngDevMode ? [{ debugName: "isPublishOpen" }] : /* istanbul ignore next */ []));
551
+ this.publishCheck = signal(undefined, /* @ts-ignore */
552
+ ...(ngDevMode ? [{ debugName: "publishCheck" }] : /* istanbul ignore next */ []));
553
+ this.publishComment = signal('', /* @ts-ignore */
554
+ ...(ngDevMode ? [{ debugName: "publishComment" }] : /* istanbul ignore next */ []));
555
+ this.publishError = signal(null, /* @ts-ignore */
556
+ ...(ngDevMode ? [{ debugName: "publishError" }] : /* istanbul ignore next */ []));
557
+ this.isReleasesOpen = signal(false, /* @ts-ignore */
558
+ ...(ngDevMode ? [{ debugName: "isReleasesOpen" }] : /* istanbul ignore next */ []));
559
+ this.releases = signal([], /* @ts-ignore */
560
+ ...(ngDevMode ? [{ debugName: "releases" }] : /* istanbul ignore next */ []));
561
+ /** The "more" menu of a file in the tree: the file, and where it opens (fixed, so the tree's scrolling does not clip it). */
562
+ this.rowMenu = signal(null, /* @ts-ignore */
563
+ ...(ngDevMode ? [{ debugName: "rowMenu" }] : /* istanbul ignore next */ []));
564
+ /** The site's pages, to find those the template in front renders; empty without the Pages permission. */
565
+ this.pages = signal([], /* @ts-ignore */
566
+ ...(ngDevMode ? [{ debugName: "pages" }] : /* istanbul ignore next */ []));
567
+ /** The active pages whose template is the file in front. */
568
+ this.templatePages = computed(() => {
569
+ const path = this.activePath();
570
+ if (!path || isAssetPath(path)) {
571
+ return [];
572
+ }
573
+ return this.pages()
574
+ .filter(page => page.isActive && pageTemplatePath(page.template) === path)
575
+ .map(page => ({ id: page.id ?? '', name: page.displayName || page.name }));
576
+ }, /* @ts-ignore */
577
+ ...(ngDevMode ? [{ debugName: "templatePages" }] : /* istanbul ignore next */ []));
578
+ // The field sidebar.
579
+ /** The page the author picked to list the fields of; the first page of the template in front otherwise. */
580
+ this.chosenFieldsPageId = signal(undefined, /* @ts-ignore */
581
+ ...(ngDevMode ? [{ debugName: "chosenFieldsPageId" }] : /* istanbul ignore next */ []));
582
+ /** Pages read in full, with their content types, by id; null for one that could not be read. */
583
+ this.pageDetails = signal(new Map(), /* @ts-ignore */
584
+ ...(ngDevMode ? [{ debugName: "pageDetails" }] : /* istanbul ignore next */ []));
585
+ /** The field library, read once; undefined until then, empty when it could not be read (no Fields permission). */
586
+ this.fieldLibrary = signal(undefined, /* @ts-ignore */
587
+ ...(ngDevMode ? [{ debugName: "fieldLibrary" }] : /* istanbul ignore next */ []));
588
+ this.fieldLibraryRequested = false;
589
+ this.requestedPageIds = new Set();
590
+ /** Whether the fields show: the file in front is the template of a page. The check shows whatever the file. */
591
+ this.showsFields = computed(() => this.templatePages().length > 0, /* @ts-ignore */
592
+ ...(ngDevMode ? [{ debugName: "showsFields" }] : /* istanbul ignore next */ []));
593
+ this.fieldsPageId = computed(() => {
594
+ const ids = this.templatePages().map(page => page.id);
595
+ const chosen = this.chosenFieldsPageId();
596
+ return chosen && ids.includes(chosen) ? chosen : ids[0];
597
+ }, /* @ts-ignore */
598
+ ...(ngDevMode ? [{ debugName: "fieldsPageId" }] : /* istanbul ignore next */ []));
599
+ this.fieldGroups = computed(() => {
600
+ const id = this.fieldsPageId();
601
+ return fieldGroupsFor((id && this.pageDetails().get(id)) || undefined, this.fieldLibrary());
602
+ }, /* @ts-ignore */
603
+ ...(ngDevMode ? [{ debugName: "fieldGroups" }] : /* istanbul ignore next */ []));
604
+ this.isLoadingFields = computed(() => {
605
+ const id = this.fieldsPageId();
606
+ return this.fieldLibrary() === undefined || (!!id && !this.pageDetails().has(id));
607
+ }, /* @ts-ignore */
608
+ ...(ngDevMode ? [{ debugName: "isLoadingFields" }] : /* istanbul ignore next */ []));
609
+ /** The number of the check asked for last: only its answer counts, so an older one arriving late cannot overwrite it. */
610
+ this.checkSequence = 0;
611
+ /** Callbacks waiting for the answer of the check asked for last, including those of checks it took over from. */
612
+ this.pendingAfter = [];
613
+ // Reads what the sidebar lists when it is shown: the library once, each page once.
614
+ effect(() => {
615
+ if (!this.showsFields()) {
616
+ return;
617
+ }
618
+ const id = this.fieldsPageId();
619
+ untracked(() => {
620
+ this.ensureFieldLibrary();
621
+ if (id) {
622
+ this.ensurePageDetail(id);
623
+ }
624
+ });
625
+ });
626
+ }
627
+ ngOnInit() {
628
+ forkJoin([this.themeService.get(), this.themeService.getFiles()]).subscribe({
629
+ next: ([theme, files]) => {
630
+ this.theme.set(theme);
631
+ this.files.set(files.items ?? []);
632
+ this.isLoading.set(false);
633
+ this.runCheck();
634
+ },
635
+ error: () => this.isLoading.set(false),
636
+ });
637
+ if (this.permission.getGrantedPolicy(eSitePolicyNames.Pages)) {
638
+ this.pageService.getList({}).subscribe(result => this.pages.set(result.items ?? []));
639
+ }
640
+ }
641
+ /** Inserts a field's snippet into the file in front, at the cursor. */
642
+ insertField(field) {
643
+ this.editor()?.insert(field.snippet);
644
+ }
645
+ ensureFieldLibrary() {
646
+ if (this.fieldLibraryRequested) {
647
+ return;
648
+ }
649
+ this.fieldLibraryRequested = true;
650
+ // skipHandleError: without the Fields permission the sidebar says it has nothing to list; no toast.
651
+ this.fieldService.getList({}, { skipHandleError: true }).subscribe({
652
+ next: result => this.fieldLibrary.set(result.items ?? []),
653
+ error: () => this.fieldLibrary.set([]),
654
+ });
655
+ }
656
+ ensurePageDetail(id) {
657
+ if (this.requestedPageIds.has(id)) {
658
+ return;
659
+ }
660
+ this.requestedPageIds.add(id);
661
+ this.pageService.get(id, { skipHandleError: true }).subscribe({
662
+ next: page => this.pageDetails.update(details => new Map(details).set(id, page)),
663
+ error: () => this.pageDetails.update(details => new Map(details).set(id, null)),
664
+ });
665
+ }
666
+ /** Opens the "more" menu of a file under its button, or closes it when it is already open for that file. */
667
+ toggleRowMenu(event, path) {
668
+ // Not on to the document listener below, which would close the menu at once.
669
+ event.stopPropagation();
670
+ if (this.rowMenu()?.path === path) {
671
+ this.rowMenu.set(null);
672
+ return;
673
+ }
674
+ const rect = event.currentTarget.getBoundingClientRect();
675
+ this.rowMenu.set({ path, top: rect.bottom, right: window.innerWidth - rect.right });
676
+ }
677
+ /** Closes the "more" menu on a click anywhere, on Escape, and when the tree scrolls from under it. */
678
+ closeRowMenu() {
679
+ if (this.rowMenu()) {
680
+ this.rowMenu.set(null);
681
+ }
682
+ }
683
+ renameFromMenu() {
684
+ const path = this.rowMenu()?.path;
685
+ this.rowMenu.set(null);
686
+ if (path) {
687
+ this.openRename(path);
688
+ }
689
+ }
690
+ deleteFromMenu() {
691
+ const path = this.rowMenu()?.path;
692
+ this.rowMenu.set(null);
693
+ if (path) {
694
+ this.deleteFile(path);
695
+ }
696
+ }
697
+ /** Asks before leaving the page while a file has unsaved changes. */
698
+ onBeforeUnload(event) {
699
+ if (this.hasUnsavedFiles()) {
700
+ event.preventDefault();
701
+ }
702
+ }
703
+ /** Ctrl+S / Cmd+S saves the file in front, instead of the browser's "save page". */
704
+ onEditorKeydown(event) {
705
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
706
+ event.preventDefault();
707
+ this.save();
708
+ }
709
+ }
710
+ isDirty(file) {
711
+ return isDirty(file);
712
+ }
713
+ statusKey(status) {
714
+ switch (status) {
715
+ case SiteThemeFileStatus.Modified:
716
+ return 'Site::ThemeFileStatus:Modified';
717
+ case SiteThemeFileStatus.Added:
718
+ return 'Site::ThemeFileStatus:Added';
719
+ case SiteThemeFileStatus.Deleted:
720
+ return 'Site::ThemeFileStatus:Deleted';
721
+ default:
722
+ return null;
723
+ }
724
+ }
725
+ /** Whether the working copy's default layout is not the one the live release froze. */
726
+ defaultLayoutChanged(check) {
727
+ return !!check?.liveReleaseNumber && (check.defaultLayout ?? '') !== (check.liveDefaultLayout ?? '');
728
+ }
729
+ issuesOf(severity) {
730
+ return (this.check()?.issues ?? []).filter(issue => issue.severity === severity);
731
+ }
732
+ toggleFolder(path) {
733
+ const next = new Set(this.collapsed());
734
+ if (next.has(path)) {
735
+ next.delete(path);
736
+ }
737
+ else {
738
+ next.add(path);
739
+ }
740
+ this.collapsed.set(next);
741
+ }
742
+ select(node) {
743
+ if (node.isFolder) {
744
+ this.toggleFolder(node.path);
745
+ }
746
+ else if (node.status !== SiteThemeFileStatus.Deleted) {
747
+ this.openFile(node.path);
748
+ }
749
+ }
750
+ openFile(path, then) {
751
+ this.expandTo(path);
752
+ if (this.openFiles().some(file => file.path === path)) {
753
+ this.activePath.set(path);
754
+ then?.();
755
+ return;
756
+ }
757
+ this.themeService.getFile(path).subscribe(file => {
758
+ // The tabs of files that were only looked at give way to this one.
759
+ this.openFiles.update(files => [...files.filter(f => f.pinned || isDirty(f)), toOpenFile(file)]);
760
+ this.activePath.set(file.path);
761
+ // After the editor has taken the new document.
762
+ setTimeout(() => then?.());
763
+ });
764
+ }
765
+ closeFile(path) {
766
+ const file = this.openFiles().find(f => f.path === path);
767
+ if (!file) {
768
+ return;
769
+ }
770
+ const close = () => {
771
+ const files = this.openFiles();
772
+ const index = files.findIndex(f => f.path === path);
773
+ const remaining = files.filter(f => f.path !== path);
774
+ this.openFiles.set(remaining);
775
+ if (this.activePath() === path) {
776
+ this.activePath.set(remaining[Math.min(index, remaining.length - 1)]?.path);
777
+ }
778
+ };
779
+ if (!isDirty(file)) {
780
+ close();
781
+ return;
782
+ }
783
+ this.confirmation
784
+ .warn('Site::CloseUnsavedConfirm', 'AbpUi::AreYouSure', { messageLocalizationParams: [path] })
785
+ .subscribe(status => {
786
+ if (status === Confirmation.Status.confirm) {
787
+ close();
788
+ }
789
+ });
790
+ }
791
+ onEdit(body) {
792
+ const path = this.activePath();
793
+ this.openFiles.update(files => files.map(file => (file.path === path ? { ...file, body, pinned: file.pinned || body !== file.savedBody } : file)));
794
+ }
795
+ save(overwrite = false) {
796
+ const file = this.activeFile();
797
+ if (!file || !this.canEdit || this.isBusy()) {
798
+ return;
799
+ }
800
+ this.isBusy.set(true);
801
+ this.themeService
802
+ .saveFile({ path: file.path, body: file.body, concurrencyStamp: overwrite ? undefined : file.concurrencyStamp }, { skipHandleError: true })
803
+ .subscribe({
804
+ next: saved => {
805
+ this.isBusy.set(false);
806
+ this.replaceOpenFile(file.path, {
807
+ ...toOpenFile(saved),
808
+ body: this.bodyOf(file.path) ?? saved.body ?? '',
809
+ pinned: true,
810
+ });
811
+ this.toaster.success('Site::ThemeFileSaved');
812
+ this.refreshFiles();
813
+ this.runCheck();
814
+ },
815
+ error: (response) => {
816
+ this.isBusy.set(false);
817
+ if (response.status === 409) {
818
+ this.resolveConflict(file.path);
819
+ return;
820
+ }
821
+ this.toaster.error(errorMessageOf(response) || 'AbpUi::DefaultErrorMessage');
822
+ },
823
+ });
824
+ }
825
+ /** Someone saved the file after it was opened: overwrite their version with this one, or load theirs. */
826
+ resolveConflict(path) {
827
+ this.confirmation
828
+ .warn('Site::SaveConflictConfirm', 'AbpUi::AreYouSure', {
829
+ messageLocalizationParams: [path],
830
+ yesText: 'Site::Overwrite',
831
+ cancelText: 'Site::ReloadFile',
832
+ })
833
+ .subscribe(status => {
834
+ // Closing the dialog (Esc, the close button) decides neither: the edits stay, unsaved.
835
+ if (status === Confirmation.Status.confirm) {
836
+ this.save(true);
837
+ }
838
+ else if (status === Confirmation.Status.reject) {
839
+ this.reloadFile(path);
840
+ }
841
+ });
842
+ }
843
+ reloadFile(path) {
844
+ this.themeService.getFile(path).subscribe(file => {
845
+ this.replaceOpenFile(path, toOpenFile(file));
846
+ this.toaster.info('Site::ThemeFileReloaded', undefined, { messageLocalizationParams: [path] });
847
+ });
848
+ }
849
+ goToIssue(issue) {
850
+ if (!issue.path) {
851
+ return;
852
+ }
853
+ const line = issue.line;
854
+ const column = issue.column ?? 1;
855
+ this.openFile(issue.path, () => {
856
+ if (line) {
857
+ this.editor()?.goTo(line, column);
858
+ }
859
+ });
860
+ }
861
+ goToSyntaxError(error) {
862
+ this.editor()?.goTo(error.line, error.column);
863
+ }
864
+ /**
865
+ * Asks for the check of the working copy as it is saved now. Checks run after every change, so one can be asked for
866
+ * while another is still on its way; only the last one counts - the working copy it read is the newest. `after`
867
+ * runs with that answer, also when a later check took this one's place.
868
+ */
869
+ runCheck(after) {
870
+ const sequence = ++this.checkSequence;
871
+ if (after) {
872
+ this.pendingAfter.push(after);
873
+ }
874
+ this.isChecking.set(true);
875
+ this.themeService.check().subscribe({
876
+ next: check => {
877
+ if (sequence !== this.checkSequence) {
878
+ return;
879
+ }
880
+ this.check.set(check);
881
+ this.isChecking.set(false);
882
+ const callbacks = this.pendingAfter;
883
+ this.pendingAfter = [];
884
+ callbacks.forEach(callback => callback(check));
885
+ },
886
+ error: () => {
887
+ if (sequence === this.checkSequence) {
888
+ this.isChecking.set(false);
889
+ this.pendingAfter = [];
890
+ }
891
+ },
892
+ });
893
+ }
894
+ // New file.
895
+ openNewFile(folder) {
896
+ this.newFilePath.set(folder ? folder + '/' : '');
897
+ this.newFileError.set(null);
898
+ this.isNewFileOpen.set(true);
899
+ }
900
+ createFile() {
901
+ const path = normalizeThemePath(this.newFilePath());
902
+ const problem = themePathProblem(path);
903
+ if (problem) {
904
+ this.newFileError.set(this.localization.instant(problem));
905
+ return;
906
+ }
907
+ if (this.files().some(file => file.path === path && file.status !== SiteThemeFileStatus.Deleted)) {
908
+ this.newFileError.set(this.localization.instant('Site::ThemeFileExists', path));
909
+ return;
910
+ }
911
+ this.isBusy.set(true);
912
+ this.themeService.saveFile({ path, body: skeletonFor(path) }, { skipHandleError: true }).subscribe({
913
+ next: saved => {
914
+ this.isBusy.set(false);
915
+ this.isNewFileOpen.set(false);
916
+ this.refreshFiles();
917
+ this.runCheck();
918
+ this.openFiles.update(files => [...files.filter(f => f.path !== saved.path), { ...toOpenFile(saved), pinned: true }]);
919
+ this.activePath.set(saved.path);
920
+ this.expandTo(saved.path ?? '');
921
+ },
922
+ error: (response) => {
923
+ this.isBusy.set(false);
924
+ this.newFileError.set(errorMessageOf(response));
925
+ },
926
+ });
927
+ }
928
+ // Rename.
929
+ openRename(path) {
930
+ this.renameFrom.set(path);
931
+ this.renameTo.set(path);
932
+ this.renameUpdatesReferences.set(true);
933
+ this.renameError.set(null);
934
+ this.isRenameOpen.set(true);
935
+ }
936
+ rename() {
937
+ const from = this.renameFrom();
938
+ const to = normalizeThemePath(this.renameTo());
939
+ const problem = themePathProblem(to);
940
+ if (problem) {
941
+ this.renameError.set(this.localization.instant(problem));
942
+ return;
943
+ }
944
+ if (this.openFiles().some(file => isDirty(file) && file.path === from)) {
945
+ this.renameError.set(this.localization.instant('Site::SaveBeforeRename'));
946
+ return;
947
+ }
948
+ this.isBusy.set(true);
949
+ this.themeService
950
+ .renameFile({ path: from, newPath: to, updateReferences: this.renameUpdatesReferences() }, { skipHandleError: true })
951
+ .subscribe({
952
+ next: result => {
953
+ this.isBusy.set(false);
954
+ this.isRenameOpen.set(false);
955
+ // The renamed file keeps its tab, under its new path; files whose references were rewritten are reloaded
956
+ // when they have no unsaved changes of their own.
957
+ this.openFiles.update(files => files.map(file => (file.path === from ? { ...toOpenFile(result.file), pinned: file.pinned } : file)));
958
+ if (this.activePath() === from) {
959
+ this.activePath.set(result.file.path);
960
+ }
961
+ for (const path of result.updatedPaths ?? []) {
962
+ const open = this.openFiles().find(file => file.path === path);
963
+ if (open && !isDirty(open) && path !== result.file.path) {
964
+ this.reloadFile(path);
965
+ }
966
+ }
967
+ this.toaster.success('Site::ThemeFileRenamed', undefined, {
968
+ messageLocalizationParams: [String(result.updatedPaths?.length ?? 0)],
969
+ });
970
+ if (result.pagesStillUsingOldPath?.length) {
971
+ this.toaster.warn('Site::RenamePagesStillUsing', undefined, {
972
+ messageLocalizationParams: [result.pagesStillUsingOldPath.join(', ')],
973
+ });
974
+ }
975
+ this.refreshTheme();
976
+ this.refreshFiles();
977
+ this.runCheck();
978
+ },
979
+ error: (response) => {
980
+ this.isBusy.set(false);
981
+ this.renameError.set(errorMessageOf(response));
982
+ },
983
+ });
984
+ }
985
+ // Delete.
986
+ deleteFile(path) {
987
+ this.themeService.getFileUsages(path).subscribe(usages => {
988
+ const users = describeUsages(usages, kind => this.localization.instant('Site::ThemeUsage:' + SiteThemeUsageKindDto[kind]));
989
+ const confirm$ = users
990
+ ? this.confirmation.warn('Site::DeleteThemeFileUsedConfirm', 'AbpUi::AreYouSure', {
991
+ messageLocalizationParams: [path, users],
992
+ })
993
+ : this.confirmation.warn('Site::DeleteThemeFileConfirm', 'AbpUi::AreYouSure', {
994
+ messageLocalizationParams: [path],
995
+ });
996
+ confirm$.subscribe(status => {
997
+ if (status !== Confirmation.Status.confirm) {
998
+ return;
999
+ }
1000
+ this.themeService.deleteFile(path).subscribe(() => {
1001
+ this.openFiles.update(files => files.filter(file => file.path !== path));
1002
+ if (this.activePath() === path) {
1003
+ this.activePath.set(this.openFiles()[0]?.path);
1004
+ }
1005
+ this.toaster.success('AbpUi::SuccessfullyDeleted');
1006
+ this.refreshFiles();
1007
+ this.runCheck();
1008
+ });
1009
+ });
1010
+ });
1011
+ }
1012
+ // Preview.
1013
+ /**
1014
+ * Opens the site's home page in a new tab with the working copy as saved, or with a release, for this browser only;
1015
+ * the author follows the site's links from there. The tab is opened inside the click, so the browser lets it
1016
+ * through, and pointed at the signed link once it arrives; it gets no handle on this page, since the theme's scripts
1017
+ * run in it.
1018
+ */
1019
+ openPreview(releaseId) {
1020
+ const tab = window.open('', '_blank');
1021
+ if (!tab) {
1022
+ this.toaster.warn('Site::PreviewPopupBlocked');
1023
+ return;
1024
+ }
1025
+ tab.opener = null;
1026
+ if (!releaseId && this.hasUnsavedFiles()) {
1027
+ this.toaster.info('Site::PreviewUnsavedNotShown');
1028
+ }
1029
+ this.themeService.getPreviewLink(undefined, releaseId).subscribe({
1030
+ next: link => tab.location.replace(link.url),
1031
+ // The error itself is shown by the default handler.
1032
+ error: () => tab.close(),
1033
+ });
1034
+ }
1035
+ // Publish.
1036
+ openPublish() {
1037
+ this.publishComment.set('');
1038
+ this.publishError.set(null);
1039
+ this.publishCheck.set(undefined);
1040
+ this.isPublishOpen.set(true);
1041
+ this.runCheck(check => this.publishCheck.set(check));
1042
+ }
1043
+ /** Enter in the comment publishes, unless it only confirms a word in an input method (Chinese, Japanese). */
1044
+ publishOnEnter(event) {
1045
+ if (!event.isComposing) {
1046
+ this.publish();
1047
+ }
1048
+ }
1049
+ publish() {
1050
+ const check = this.publishCheck();
1051
+ if (!check?.fingerprint || check.hasErrors || !check.hasChanges || this.isBusy()) {
1052
+ return;
1053
+ }
1054
+ this.isBusy.set(true);
1055
+ this.themeService
1056
+ .publish({ fingerprint: check.fingerprint, comment: this.publishComment() || undefined }, { skipHandleError: true })
1057
+ .subscribe({
1058
+ next: release => {
1059
+ this.isBusy.set(false);
1060
+ this.isPublishOpen.set(false);
1061
+ this.toaster.success('Site::ThemePublished', undefined, { messageLocalizationParams: [String(release.number)] });
1062
+ this.refreshTheme();
1063
+ this.refreshFiles();
1064
+ this.runCheck();
1065
+ },
1066
+ error: (response) => {
1067
+ this.isBusy.set(false);
1068
+ this.publishError.set(errorMessageOf(response));
1069
+ if (response.error?.error?.code === CHANGED_SINCE_CHECK) {
1070
+ // Show what is there now; the author publishes again from the fresh check.
1071
+ this.runCheck(fresh => this.publishCheck.set(fresh));
1072
+ }
1073
+ },
1074
+ });
1075
+ }
1076
+ // Releases.
1077
+ openReleases() {
1078
+ this.isReleasesOpen.set(true);
1079
+ this.loadReleases();
1080
+ }
1081
+ loadReleases() {
1082
+ this.themeService.getReleases().subscribe(result => this.releases.set(result.items ?? []));
1083
+ }
1084
+ makeLive(release) {
1085
+ this.confirmation
1086
+ .warn('Site::MakeReleaseLiveConfirm', 'AbpUi::AreYouSure', { messageLocalizationParams: [String(release.number)] })
1087
+ .subscribe(status => {
1088
+ if (status !== Confirmation.Status.confirm || !release.id) {
1089
+ return;
1090
+ }
1091
+ this.themeService.setLiveRelease(release.id).subscribe(() => {
1092
+ this.toaster.success('Site::ReleaseMadeLive', undefined, { messageLocalizationParams: [String(release.number)] });
1093
+ this.loadReleases();
1094
+ this.refreshTheme();
1095
+ this.refreshFiles();
1096
+ this.runCheck();
1097
+ });
1098
+ });
1099
+ }
1100
+ restore(release) {
1101
+ this.confirmation
1102
+ .warn('Site::RestoreReleaseConfirm', 'AbpUi::AreYouSure', { messageLocalizationParams: [String(release.number)] })
1103
+ .subscribe(status => {
1104
+ if (status !== Confirmation.Status.confirm || !release.id) {
1105
+ return;
1106
+ }
1107
+ this.themeService.restoreRelease(release.id).subscribe(() => {
1108
+ // Every open file may have changed; open tabs would show what is no longer there.
1109
+ this.openFiles.set([]);
1110
+ this.activePath.set(undefined);
1111
+ this.isReleasesOpen.set(false);
1112
+ this.toaster.success('Site::ReleaseRestored', undefined, { messageLocalizationParams: [String(release.number)] });
1113
+ this.refreshTheme();
1114
+ this.refreshFiles();
1115
+ this.runCheck();
1116
+ });
1117
+ });
1118
+ }
1119
+ // Default layout.
1120
+ /**
1121
+ * Makes the file in front the default layout, from its own toolbar: it is offered only for a file the check lists as
1122
+ * a layout, and the server judges again, from what is saved - what it writes and calls, through what it includes.
1123
+ */
1124
+ setAsDefaultLayout() {
1125
+ const path = this.activePath();
1126
+ if (!path || this.isBusy() || this.isDefaultLayoutInFront()) {
1127
+ return;
1128
+ }
1129
+ this.isBusy.set(true);
1130
+ this.themeService
1131
+ .update({ defaultLayout: path, concurrencyStamp: this.theme()?.concurrencyStamp }, { skipHandleError: true })
1132
+ .subscribe({
1133
+ next: theme => {
1134
+ this.isBusy.set(false);
1135
+ this.theme.set(theme);
1136
+ this.toaster.success('Site::DefaultLayoutSet', undefined, { messageLocalizationParams: [path] });
1137
+ this.runCheck();
1138
+ },
1139
+ error: (response) => {
1140
+ this.isBusy.set(false);
1141
+ this.toaster.error(errorMessageOf(response) || 'AbpUi::DefaultErrorMessage');
1142
+ },
1143
+ });
1144
+ }
1145
+ languageOf(path) {
1146
+ return languageOf(path);
1147
+ }
1148
+ refreshTheme() {
1149
+ this.themeService.get().subscribe(theme => this.theme.set(theme));
1150
+ }
1151
+ refreshFiles() {
1152
+ this.themeService.getFiles().subscribe(files => this.files.set(files.items ?? []));
1153
+ }
1154
+ replaceOpenFile(path, file) {
1155
+ this.openFiles.update(files => files.map(f => (f.path === path ? { ...file, pinned: file.pinned || f.pinned } : f)));
1156
+ }
1157
+ bodyOf(path) {
1158
+ return this.openFiles().find(file => file.path === path)?.body;
1159
+ }
1160
+ expandTo(path) {
1161
+ const folders = foldersOf(path);
1162
+ if (folders.some(folder => this.collapsed().has(folder))) {
1163
+ const next = new Set(this.collapsed());
1164
+ folders.forEach(folder => next.delete(folder));
1165
+ this.collapsed.set(next);
1166
+ }
1167
+ }
1168
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.2", ngImport: i0, type: ThemeWorkbenchComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1169
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.2.2", type: ThemeWorkbenchComponent, isStandalone: true, selector: "site-theme-workbench", host: { listeners: { "document:click": "closeRowMenu()", "document:keydown.escape": "closeRowMenu()", "window:beforeunload": "onBeforeUnload($event)" } }, viewQueries: [{ propertyName: "editor", first: true, predicate: CodeEditorComponent, descendants: true, isSignal: true }], ngImport: i0, template: "@if (isLoading()) {\n <div class=\"text-center py-5\">\n <span class=\"spinner-border\" role=\"status\"></span>\n </div>\n} @else {\n <!-- Toolbar: the default layout, and the actions on the theme as a whole. The live release is marked in the history. -->\n <div class=\"card mb-3\">\n <div class=\"card-body d-flex flex-wrap align-items-center gap-2\">\n <div class=\"me-auto\">\n <h5 class=\"mb-0\">{{ 'Site::Theme' | abpLocalization }}</h5>\n <small class=\"text-muted\">\n {{ 'Site::DefaultLayout' | abpLocalization }}:\n <code>{{ theme()?.defaultLayout || ('Site::NoDefaultLayout' | abpLocalization) }}</code>\n </small>\n </div>\n <!-- Preview of the theme as a whole: the site's home page, from which the author follows the links. -->\n <button\n type=\"button\"\n class=\"btn btn-outline-secondary\"\n [title]=\"'Site::PreviewThemeHint' | abpLocalization\"\n (click)=\"openPreview()\"\n >\n <i class=\"fa fa-eye me-1\"></i>{{ 'Site::PreviewTheme' | abpLocalization }}\n </button>\n <button type=\"button\" class=\"btn btn-outline-secondary\" (click)=\"openReleases()\">\n <i class=\"fa fa-history me-1\"></i>{{ 'Site::Releases' | abpLocalization }}\n </button>\n @if (canPublish) {\n <button type=\"button\" class=\"btn btn-primary\" (click)=\"openPublish()\">\n <i class=\"fa fa-upload me-1\"></i>{{ 'Site::PublishTheme' | abpLocalization }}\n </button>\n }\n </div>\n </div>\n\n <div class=\"row g-3\">\n <!-- Left: the working copy's files. -->\n <div class=\"col-lg-3\">\n <div class=\"card h-100\">\n <div class=\"card-body\">\n <div class=\"d-flex align-items-center mb-2\">\n <h6 class=\"mb-0 me-auto\">{{ 'Site::ThemeFiles' | abpLocalization }}</h6>\n @if (canEdit) {\n <button\n type=\"button\"\n class=\"btn btn-sm btn-outline-primary\"\n (click)=\"openNewFile()\"\n [title]=\"'Site::NewThemeFile' | abpLocalization\"\n >\n <i class=\"fa fa-plus\"></i>\n </button>\n }\n </div>\n <div class=\"theme-tree\" role=\"tree\" (scroll)=\"closeRowMenu()\">\n @for (row of treeRows(); track row.node.path + row.node.isFolder) {\n <div\n class=\"theme-tree-row\"\n [class.active]=\"!row.node.isFolder && row.node.path === activePath()\"\n [class.deleted]=\"row.node.status === statuses.Deleted\"\n [style.padding-left.rem]=\"0.25 + row.depth * 1\"\n >\n <button\n type=\"button\"\n class=\"theme-tree-main\"\n role=\"treeitem\"\n [title]=\"row.node.path\"\n (click)=\"select(row.node)\"\n >\n @if (row.node.isFolder) {\n <i class=\"fa fa-fw\" [class.fa-folder]=\"collapsed().has(row.node.path)\" [class.fa-folder-open]=\"!collapsed().has(row.node.path)\"></i>\n } @else {\n <i class=\"fa fa-fw\" [class.fa-file-code]=\"row.node.isAsset\" [class.fa-file-lines]=\"!row.node.isAsset\"></i>\n }\n <span class=\"name\">{{ row.node.name }}</span>\n @if (!row.node.isFolder && statusKey(row.node.status); as key) {\n <span\n class=\"badge\"\n [class.text-bg-warning]=\"row.node.status === statuses.Modified\"\n [class.text-bg-success]=\"row.node.status === statuses.Added\"\n [class.text-bg-secondary]=\"row.node.status === statuses.Deleted\"\n [title]=\"key | abpLocalization\"\n >{{ (key + ':Short') | abpLocalization }}</span>\n } @else if (row.node.isFolder && row.node.hasChanges) {\n <span class=\"text-warning\" [title]=\"'Site::ThemeFolderHasChanges' | abpLocalization\">\u2022</span>\n }\n </button>\n <!-- Renaming and deleting are rare: behind a \"more\" button on the file, not in the editor's way. -->\n @if (canEdit && !row.node.isFolder && row.node.status !== statuses.Deleted) {\n <button\n type=\"button\"\n class=\"theme-tree-more\"\n [class.open]=\"rowMenu()?.path === row.node.path\"\n [attr.aria-label]=\"'Site::MoreActions' | abpLocalization\"\n [attr.aria-expanded]=\"rowMenu()?.path === row.node.path\"\n [title]=\"'Site::MoreActions' | abpLocalization\"\n (click)=\"toggleRowMenu($event, row.node.path)\"\n >\n <i class=\"fa fa-ellipsis-vertical\"></i>\n </button>\n }\n </div>\n } @empty {\n <p class=\"text-muted small mb-0\">{{ 'Site::ThemeNoFiles' | abpLocalization }}</p>\n }\n </div>\n </div>\n </div>\n </div>\n\n <!-- Middle: the open files. -->\n <div class=\"col-lg-6\">\n <div class=\"card h-100\">\n <div class=\"card-body\">\n @if (openFiles().length > 0) {\n <ul class=\"nav nav-tabs mb-2 flex-nowrap theme-tabs\">\n @for (file of openFiles(); track file.path) {\n <li class=\"nav-item\">\n <a\n href=\"javascript:void(0)\"\n class=\"nav-link theme-tab\"\n [class.active]=\"file.path === activePath()\"\n [class.fst-italic]=\"!file.pinned && !isDirty(file)\"\n [title]=\"file.path\"\n (click)=\"activePath.set(file.path)\"\n >\n {{ file.path.split('/').pop() }}@if (isDirty(file)) { <span class=\"text-warning\" [title]=\"'Site::UnsavedChanges' | abpLocalization\">\u25CF</span> }\n <button\n type=\"button\"\n class=\"btn-close btn-sm ms-1 align-middle\"\n style=\"font-size: 0.6rem\"\n [attr.aria-label]=\"'AbpUi::Close' | abpLocalization\"\n (click)=\"$event.stopPropagation(); closeFile(file.path)\"\n ></button>\n </a>\n </li>\n }\n </ul>\n\n @if (activeFile(); as file) {\n <div class=\"d-flex flex-wrap align-items-center gap-2 mb-2\">\n <code class=\"me-auto\">{{ file.path }}</code>\n @if (canEdit) {\n <!-- Only for a file the check lists as a layout; there is nothing to do when it already is the default. -->\n @if (isLayoutInFront()) {\n <button\n type=\"button\"\n class=\"btn btn-sm btn-outline-secondary\"\n [disabled]=\"isDefaultLayoutInFront() || isBusy()\"\n [title]=\"(isDefaultLayoutInFront() ? 'Site::IsDefaultLayoutHint' : 'Site::SetAsDefaultLayoutHint') | abpLocalization\"\n (click)=\"setAsDefaultLayout()\"\n >\n <i class=\"fa fa-star me-1\"></i>{{ 'Site::SetAsDefaultLayout' | abpLocalization }}\n </button>\n }\n <button\n type=\"button\"\n class=\"btn btn-sm btn-primary\"\n [disabled]=\"!isDirty(file) || isBusy()\"\n [title]=\"'Site::SaveShortcut' | abpLocalization\"\n (click)=\"save()\"\n >\n @if (isBusy()) {\n <span class=\"spinner-border spinner-border-sm me-1\" role=\"status\"></span>\n } @else {\n <i class=\"fa fa-save me-1\"></i>\n }\n {{ 'AbpUi::Save' | abpLocalization }}\n </button>\n }\n </div>\n\n <div (keydown)=\"onEditorKeydown($event)\">\n <site-code-editor\n [documentKey]=\"file.path\"\n [value]=\"file.body\"\n [language]=\"languageOf(file.path)\"\n [readonly]=\"!canEdit\"\n (valueChange)=\"onEdit($event)\"\n ></site-code-editor>\n </div>\n\n @if (file.syntaxErrors.length > 0) {\n <div class=\"alert alert-danger mt-2 mb-0\" role=\"alert\">\n <strong class=\"d-block mb-1\">{{ 'Site::ThemeSyntaxErrors' | abpLocalization }}</strong>\n <ul class=\"list-unstyled mb-0\">\n @for (error of file.syntaxErrors; track $index) {\n <li>\n <!-- A button, not a link: it moves the editor's cursor, it does not navigate. -->\n <button type=\"button\" class=\"btn btn-link btn-sm p-0 align-baseline\" (click)=\"goToSyntaxError(error)\">\n {{ error.line }}:{{ error.column }}\n </button>\n {{ error.message }}\n </li>\n }\n </ul>\n </div>\n }\n }\n } @else {\n <p class=\"text-muted mb-0\">{{ 'Site::ThemeNoFileOpen' | abpLocalization }}</p>\n }\n </div>\n </div>\n </div>\n\n <!-- Right: the fields of a page the template in front renders, when it is one; the check below it, always. -->\n <div class=\"col-lg-3\">\n <div class=\"card h-100\">\n <div class=\"card-body theme-issues\">\n @if (showsFields()) {\n <h6>{{ 'Site::ThemeFields' | abpLocalization }}</h6>\n <div class=\"theme-fields\">\n @if (templatePages().length > 1) {\n <label class=\"form-label small mb-1\" for=\"site-theme-fields-page\">{{ 'Site::ThemeFieldsPage' | abpLocalization }}</label>\n <select\n id=\"site-theme-fields-page\"\n class=\"form-select form-select-sm mb-2\"\n [ngModel]=\"fieldsPageId()\"\n (ngModelChange)=\"chosenFieldsPageId.set($event)\"\n >\n @for (page of templatePages(); track page.id) {\n <option [value]=\"page.id\">{{ page.name }}</option>\n }\n </select>\n } @else {\n <p class=\"small fw-bold mb-1\">{{ 'Site::ThemeFieldsOfPage' | abpLocalization: templatePages()[0].name }}</p>\n }\n <p class=\"small text-muted mb-2\">\n {{ (canEdit ? 'Site::ThemeFieldsHint' : 'Site::ThemeFieldsReadOnlyHint') | abpLocalization }}\n </p>\n @if (isLoadingFields()) {\n <div class=\"text-center py-3\"><span class=\"spinner-border spinner-border-sm\" role=\"status\"></span></div>\n } @else {\n @for (group of fieldGroups(); track group.id) {\n <div class=\"small fw-bold mb-1\">{{ group.displayName }}</div>\n <ul class=\"list-unstyled small mb-3\">\n @for (field of group.fields; track field.name) {\n <li class=\"mb-2\">\n <button\n type=\"button\"\n class=\"btn btn-link btn-sm p-0 align-baseline text-start\"\n [disabled]=\"!canEdit\"\n (click)=\"insertField(field)\"\n >\n {{ field.displayName }}\n </button>\n <span class=\"text-muted ms-1\">{{ field.fieldTypeName }}</span>\n <br />\n <code class=\"theme-field-snippet\">{{ field.snippet }}</code>\n </li>\n }\n </ul>\n } @empty {\n <p class=\"small text-muted mb-0\">{{ 'Site::ThemeFieldsNone' | abpLocalization }}</p>\n }\n }\n </div>\n <hr class=\"my-3\" />\n }\n\n <!-- The check: runs by itself after every change; the button asks for the result as it is now, for what\n happened elsewhere (another author, an AI over MCP, the Pages screen). -->\n <div class=\"d-flex align-items-center mb-2\">\n <h6 class=\"mb-0 me-auto\">{{ 'Site::ThemeCheck' | abpLocalization }}</h6>\n <button type=\"button\" class=\"btn btn-sm btn-outline-secondary\" (click)=\"runCheck()\" [disabled]=\"isChecking()\">\n @if (isChecking()) {\n <span class=\"spinner-border spinner-border-sm me-1\" role=\"status\"></span>\n } @else {\n <i class=\"fa fa-stethoscope me-1\"></i>\n }\n {{ 'Site::CheckTheme' | abpLocalization }}\n </button>\n </div>\n @if (check(); as current) {\n @if (current.issues.length === 0) {\n <p class=\"text-success small mb-2\"><i class=\"fa fa-check me-1\"></i>{{ 'Site::ThemeCheckClean' | abpLocalization }}</p>\n }\n @for (group of [severities.Error, severities.Warning]; track group) {\n @if (issuesOf(group).length > 0) {\n <div class=\"small fw-bold mb-1\" [class.text-danger]=\"group === severities.Error\" [class.text-warning]=\"group === severities.Warning\">\n {{ (group === severities.Error ? 'Site::ThemeErrors' : 'Site::ThemeWarnings') | abpLocalization }}\n ({{ issuesOf(group).length }})\n </div>\n <ul class=\"list-unstyled small mb-3\">\n @for (issue of issuesOf(group); track $index) {\n <li class=\"mb-2\">\n @if (issue.path) {\n <button type=\"button\" class=\"btn btn-link btn-sm p-0 align-baseline\" (click)=\"goToIssue(issue)\">\n {{ issue.path }}@if (issue.line) {:{{ issue.line }}}\n </button>\n <br />\n }\n {{ issue.message }}\n </li>\n }\n </ul>\n }\n }\n <p class=\"small text-muted mb-0\">\n @if (current.changes.length > 0) {\n {{ 'Site::ThemeUnpublishedChanges' | abpLocalization: current.changes.length.toString() }}\n }\n @if (defaultLayoutChanged(current)) {\n {{ 'Site::ThemeDefaultLayoutUnpublished' | abpLocalization: (current.liveDefaultLayout || ('Site::NoDefaultLayout' | abpLocalization)) }}\n }\n @if (!current.hasChanges) {\n {{ 'Site::ThemeNoUnpublishedChanges' | abpLocalization }}\n }\n </p>\n } @else {\n <p class=\"text-muted small mb-0\">{{ 'Site::ThemeCheckNotRun' | abpLocalization }}</p>\n }\n </div>\n </div>\n </div>\n </div>\n}\n\n<!-- The \"more\" menu of a file in the tree. Fixed, under its button: the tree scrolls and would clip it. -->\n@if (rowMenu(); as menu) {\n <div\n class=\"dropdown-menu show\"\n style=\"position: fixed\"\n [style.top.px]=\"menu.top\"\n [style.right.px]=\"menu.right\"\n (click)=\"$event.stopPropagation()\"\n >\n <button type=\"button\" class=\"dropdown-item\" (click)=\"renameFromMenu()\">\n <i class=\"fa fa-i-cursor fa-fw me-1\"></i>{{ 'Site::RenameThemeFile' | abpLocalization }}\n </button>\n <button type=\"button\" class=\"dropdown-item text-danger\" (click)=\"deleteFromMenu()\">\n <i class=\"fa fa-trash fa-fw me-1\"></i>{{ 'AbpUi::Delete' | abpLocalization }}\n </button>\n </div>\n}\n\n<!-- New file. -->\n<abp-modal [visible]=\"isNewFileOpen()\" (visibleChange)=\"isNewFileOpen.set($event)\" [busy]=\"isBusy()\">\n <ng-template #abpHeader>\n <h3>{{ 'Site::NewThemeFile' | abpLocalization }}</h3>\n </ng-template>\n <ng-template #abpBody>\n <label class=\"form-label\" for=\"site-theme-new-path\">{{ 'Site::ThemeFilePath' | abpLocalization }}</label>\n <input\n id=\"site-theme-new-path\"\n type=\"text\"\n class=\"form-control\"\n autocomplete=\"off\"\n [ngModel]=\"newFilePath()\"\n (ngModelChange)=\"newFilePath.set($event); newFileError.set(null)\"\n (keydown.enter)=\"createFile()\"\n />\n <small class=\"form-text text-muted\">{{ 'Site::ThemeFilePathHint' | abpLocalization }}</small>\n @if (newFileError(); as message) {\n <div class=\"alert alert-danger mt-2 mb-0\" style=\"white-space: pre-line\">{{ message }}</div>\n }\n </ng-template>\n <ng-template #abpFooter>\n <button type=\"button\" class=\"btn btn-secondary\" abpClose>{{ 'AbpUi::Cancel' | abpLocalization }}</button>\n <abp-button iconClass=\"fa fa-check\" [loading]=\"isBusy()\" (click)=\"createFile()\">\n {{ 'Site::CreateThemeFile' | abpLocalization }}\n </abp-button>\n </ng-template>\n</abp-modal>\n\n<!-- Rename. -->\n<abp-modal [visible]=\"isRenameOpen()\" (visibleChange)=\"isRenameOpen.set($event)\" [busy]=\"isBusy()\">\n <ng-template #abpHeader>\n <h3>{{ 'Site::RenameThemeFile' | abpLocalization }}</h3>\n </ng-template>\n <ng-template #abpBody>\n <p class=\"mb-2\"><code>{{ renameFrom() }}</code></p>\n <label class=\"form-label\" for=\"site-theme-rename-path\">{{ 'Site::ThemeFileNewPath' | abpLocalization }}</label>\n <input\n id=\"site-theme-rename-path\"\n type=\"text\"\n class=\"form-control\"\n autocomplete=\"off\"\n [ngModel]=\"renameTo()\"\n (ngModelChange)=\"renameTo.set($event); renameError.set(null)\"\n (keydown.enter)=\"rename()\"\n />\n <div class=\"form-check mt-2\">\n <input\n id=\"site-theme-rename-references\"\n type=\"checkbox\"\n class=\"form-check-input\"\n [ngModel]=\"renameUpdatesReferences()\"\n (ngModelChange)=\"renameUpdatesReferences.set($event)\"\n />\n <label class=\"form-check-label\" for=\"site-theme-rename-references\">\n {{ 'Site::ThemeRenameUpdateReferences' | abpLocalization }}\n </label>\n </div>\n @if (renameError(); as message) {\n <div class=\"alert alert-danger mt-2 mb-0\" style=\"white-space: pre-line\">{{ message }}</div>\n }\n </ng-template>\n <ng-template #abpFooter>\n <button type=\"button\" class=\"btn btn-secondary\" abpClose>{{ 'AbpUi::Cancel' | abpLocalization }}</button>\n <abp-button iconClass=\"fa fa-check\" [loading]=\"isBusy()\" (click)=\"rename()\">\n {{ 'Site::RenameThemeFile' | abpLocalization }}\n </abp-button>\n </ng-template>\n</abp-modal>\n\n<!-- Publish. -->\n<abp-modal [visible]=\"isPublishOpen()\" (visibleChange)=\"isPublishOpen.set($event)\" [busy]=\"isBusy()\" [options]=\"{ size: 'lg' }\">\n <ng-template #abpHeader>\n <h3>{{ 'Site::PublishThemeTitle' | abpLocalization }}</h3>\n </ng-template>\n <ng-template #abpBody>\n @if (publishCheck(); as current) {\n @if (current.hasErrors) {\n <div class=\"alert alert-danger\">\n <strong class=\"d-block mb-1\">{{ 'Site::PublishThemeHasErrors' | abpLocalization }}</strong>\n <ul class=\"mb-0\">\n @for (issue of current.issues; track $index) {\n @if (issue.severity === severities.Error) {\n <li>\n @if (issue.path) { <code>{{ issue.path }}@if (issue.line) {:{{ issue.line }}}</code> }\n {{ issue.message }}\n </li>\n }\n }\n </ul>\n </div>\n } @else if (!current.hasChanges) {\n <div class=\"alert alert-info\">{{ 'Site::PublishThemeNoChanges' | abpLocalization }}</div>\n } @else {\n <h6>{{ 'Site::PublishThemeChanges' | abpLocalization }}</h6>\n <ul class=\"list-unstyled small mb-3\">\n @for (change of current.changes; track change.path) {\n <li>\n <span\n class=\"badge me-1\"\n [class.text-bg-warning]=\"change.status === statuses.Modified\"\n [class.text-bg-success]=\"change.status === statuses.Added\"\n [class.text-bg-secondary]=\"change.status === statuses.Deleted\"\n >{{ statusKey(change.status)! | abpLocalization }}</span>\n <code>{{ change.path }}</code>\n </li>\n }\n @if (defaultLayoutChanged(current)) {\n <li>\n <span class=\"badge text-bg-warning me-1\">{{ 'Site::ThemeFileStatus:Modified' | abpLocalization }}</span>\n {{ 'Site::DefaultLayout' | abpLocalization }}:\n <code>{{ current.liveDefaultLayout || ('Site::NoDefaultLayout' | abpLocalization) }}</code>\n \u2192\n <code>{{ current.defaultLayout || ('Site::NoDefaultLayout' | abpLocalization) }}</code>\n </li>\n }\n </ul>\n @if (warningCount() > 0) {\n <p class=\"small text-warning\">{{ 'Site::PublishThemeWarnings' | abpLocalization: warningCount().toString() }}</p>\n }\n <label class=\"form-label\" for=\"site-theme-publish-comment\">{{ 'Site::ReleaseComment' | abpLocalization }}</label>\n <input\n id=\"site-theme-publish-comment\"\n type=\"text\"\n class=\"form-control\"\n autocomplete=\"off\"\n maxlength=\"256\"\n [placeholder]=\"'Site::ReleaseCommentPlaceholder' | abpLocalization\"\n [ngModel]=\"publishComment()\"\n (ngModelChange)=\"publishComment.set($event)\"\n (keydown.enter)=\"publishOnEnter($event)\"\n />\n }\n @if (publishError(); as message) {\n <div class=\"alert alert-danger mt-2 mb-0\" style=\"white-space: pre-line\">{{ message }}</div>\n }\n } @else {\n <div class=\"text-center py-3\"><span class=\"spinner-border\" role=\"status\"></span></div>\n }\n </ng-template>\n <ng-template #abpFooter>\n <button type=\"button\" class=\"btn btn-secondary\" abpClose>{{ 'AbpUi::Cancel' | abpLocalization }}</button>\n <abp-button\n iconClass=\"fa fa-upload\"\n [loading]=\"isBusy()\"\n [disabled]=\"!publishCheck() || publishCheck()!.hasErrors || !publishCheck()!.hasChanges\"\n (click)=\"publish()\"\n >\n {{ 'Site::PublishTheme' | abpLocalization }}\n </abp-button>\n </ng-template>\n</abp-modal>\n\n<!-- Releases. -->\n<abp-modal [visible]=\"isReleasesOpen()\" (visibleChange)=\"isReleasesOpen.set($event)\" [options]=\"{ size: 'xl' }\">\n <ng-template #abpHeader>\n <h3>{{ 'Site::Releases' | abpLocalization }}</h3>\n </ng-template>\n <ng-template #abpBody>\n <!-- The comment takes the room and wraps; the other columns keep their words on one line. -->\n <div class=\"table-responsive\">\n <table class=\"table table-sm align-middle mb-0\">\n <thead>\n <tr>\n <th class=\"text-nowrap\">#</th>\n <th class=\"w-100\">{{ 'Site::ReleaseComment' | abpLocalization }}</th>\n <th class=\"text-nowrap\">{{ 'Site::ReleaseChangedFiles' | abpLocalization }}</th>\n <th class=\"text-nowrap\">{{ 'Site::ReleaseTime' | abpLocalization }}</th>\n <th class=\"text-end text-nowrap\">{{ 'AbpUi::Actions' | abpLocalization }}</th>\n </tr>\n </thead>\n <tbody>\n @for (release of releases(); track release.id) {\n <tr>\n <td class=\"text-nowrap\">\n {{ release.number }}\n @if (release.isLive) {\n <span class=\"badge text-bg-success ms-1\">{{ 'Site::ReleaseLive' | abpLocalization }}</span>\n }\n </td>\n <td>{{ release.comment }}</td>\n <td class=\"text-nowrap\">{{ release.changedFileCount }}</td>\n <td class=\"text-nowrap\">{{ release.creationTime | date: 'short' }}</td>\n <td class=\"text-end text-nowrap\">\n <button\n type=\"button\"\n class=\"btn btn-sm btn-outline-secondary me-1\"\n [title]=\"'Site::PreviewReleaseHint' | abpLocalization\"\n (click)=\"openPreview(release.id)\"\n >\n <i class=\"fa fa-eye me-1\"></i>{{ 'Site::PreviewTheme' | abpLocalization }}\n </button>\n @if (canPublish && !release.isLive) {\n <button type=\"button\" class=\"btn btn-sm btn-outline-primary me-1\" (click)=\"makeLive(release)\">\n {{ 'Site::MakeReleaseLive' | abpLocalization }}\n </button>\n }\n @if (canEdit) {\n <button type=\"button\" class=\"btn btn-sm btn-outline-secondary\" (click)=\"restore(release)\">\n {{ 'Site::RestoreRelease' | abpLocalization }}\n </button>\n }\n </td>\n </tr>\n } @empty {\n <tr>\n <td colspan=\"5\" class=\"text-muted\">{{ 'Site::NothingPublished' | abpLocalization }}</td>\n </tr>\n }\n </tbody>\n </table>\n </div>\n </ng-template>\n <ng-template #abpFooter>\n <button type=\"button\" class=\"btn btn-secondary\" abpClose>{{ 'AbpUi::Close' | abpLocalization }}</button>\n </ng-template>\n</abp-modal>\n", styles: [":host{--site-code-editor-height: max(24rem, calc(100vh - 25rem) );--site-code-editor-max-height: none}.theme-tree{max-height:max(20rem,calc(100vh - 21rem));overflow-y:auto}.theme-tree-row{display:flex;align-items:center;width:100%;padding-right:.25rem;border-radius:var(--bs-border-radius-sm, .25rem)}.theme-tree-main{display:flex;align-items:center;gap:.25rem;flex:1 1 auto;min-width:0;border:none;background:transparent;text-align:start;padding:.15rem 0;color:inherit;white-space:nowrap}.theme-tree-row:hover{background-color:#8080801f}.theme-tree-more{flex:0 0 auto;border:none;background:transparent;color:inherit;padding:.1rem .4rem;opacity:0}.theme-tree-row:hover .theme-tree-more,.theme-tree-row:focus-within .theme-tree-more,.theme-tree-more.open{opacity:1}@media(hover:none){.theme-tree-more{opacity:1}}.theme-tree-row.active{background-color:rgba(var(--bs-primary-rgb, 13, 110, 253),.15)}.theme-tree-row .name{overflow:hidden;text-overflow:ellipsis;flex:1 1 auto}.theme-tree-row.deleted .name{text-decoration:line-through;opacity:.6}.theme-tab{white-space:nowrap}.theme-field-snippet{white-space:pre-wrap;word-break:break-all}.theme-tabs{overflow-x:auto;overflow-y:hidden}.theme-issues{max-height:max(20rem,calc(100vh - 17rem));overflow-y:auto}.theme-fields{max-height:max(10rem,38vh);overflow-y:auto}\n"], dependencies: [{ kind: "ngmodule", type: CoreModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox]:not([ngNoCva])[formControlName],input[type=checkbox]:not([ngNoCva])[formControl],input[type=checkbox]:not([ngNoCva])[ngModel]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple]):not([ngNoCva])[formControlName],select:not([multiple]):not([ngNoCva])[formControl],select:not([multiple]):not([ngNoCva])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.MaxLengthValidator, selector: "[maxlength][formControlName],[maxlength][formControl],[maxlength][ngModel]", inputs: ["maxlength"] }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: ThemeSharedModule }, { kind: "component", type: i2.ButtonComponent, selector: "abp-button", inputs: ["buttonId", "buttonClass", "buttonType", "formName", "iconClass", "loading", "disabled", "attributes"], outputs: ["click", "focus", "blur", "abpClick", "abpFocus", "abpBlur"] }, { kind: "component", type: i2.ModalComponent, selector: "abp-modal", inputs: ["visible", "busy", "options", "suppressUnsavedChangesWarning"], outputs: ["visibleChange", "init", "appear", "disappear"] }, { kind: "directive", type: i2.ModalCloseDirective, selector: "[abpClose]" }, { kind: "ngmodule", type: FormsModule }, { kind: "component", type: CodeEditorComponent, selector: "site-code-editor", inputs: ["value", "readonly", "language", "documentKey"], outputs: ["valueChange"] }, { kind: "pipe", type: i1$1.DatePipe, name: "date" }, { kind: "pipe", type: i4.LocalizationPipe, name: "abpLocalization" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1170
+ }
1171
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.2", ngImport: i0, type: ThemeWorkbenchComponent, decorators: [{
1172
+ type: Component,
1173
+ args: [{ selector: 'site-theme-workbench', changeDetection: ChangeDetectionStrategy.OnPush, imports: [CoreModule, ThemeSharedModule, FormsModule, CodeEditorComponent], template: "@if (isLoading()) {\n <div class=\"text-center py-5\">\n <span class=\"spinner-border\" role=\"status\"></span>\n </div>\n} @else {\n <!-- Toolbar: the default layout, and the actions on the theme as a whole. The live release is marked in the history. -->\n <div class=\"card mb-3\">\n <div class=\"card-body d-flex flex-wrap align-items-center gap-2\">\n <div class=\"me-auto\">\n <h5 class=\"mb-0\">{{ 'Site::Theme' | abpLocalization }}</h5>\n <small class=\"text-muted\">\n {{ 'Site::DefaultLayout' | abpLocalization }}:\n <code>{{ theme()?.defaultLayout || ('Site::NoDefaultLayout' | abpLocalization) }}</code>\n </small>\n </div>\n <!-- Preview of the theme as a whole: the site's home page, from which the author follows the links. -->\n <button\n type=\"button\"\n class=\"btn btn-outline-secondary\"\n [title]=\"'Site::PreviewThemeHint' | abpLocalization\"\n (click)=\"openPreview()\"\n >\n <i class=\"fa fa-eye me-1\"></i>{{ 'Site::PreviewTheme' | abpLocalization }}\n </button>\n <button type=\"button\" class=\"btn btn-outline-secondary\" (click)=\"openReleases()\">\n <i class=\"fa fa-history me-1\"></i>{{ 'Site::Releases' | abpLocalization }}\n </button>\n @if (canPublish) {\n <button type=\"button\" class=\"btn btn-primary\" (click)=\"openPublish()\">\n <i class=\"fa fa-upload me-1\"></i>{{ 'Site::PublishTheme' | abpLocalization }}\n </button>\n }\n </div>\n </div>\n\n <div class=\"row g-3\">\n <!-- Left: the working copy's files. -->\n <div class=\"col-lg-3\">\n <div class=\"card h-100\">\n <div class=\"card-body\">\n <div class=\"d-flex align-items-center mb-2\">\n <h6 class=\"mb-0 me-auto\">{{ 'Site::ThemeFiles' | abpLocalization }}</h6>\n @if (canEdit) {\n <button\n type=\"button\"\n class=\"btn btn-sm btn-outline-primary\"\n (click)=\"openNewFile()\"\n [title]=\"'Site::NewThemeFile' | abpLocalization\"\n >\n <i class=\"fa fa-plus\"></i>\n </button>\n }\n </div>\n <div class=\"theme-tree\" role=\"tree\" (scroll)=\"closeRowMenu()\">\n @for (row of treeRows(); track row.node.path + row.node.isFolder) {\n <div\n class=\"theme-tree-row\"\n [class.active]=\"!row.node.isFolder && row.node.path === activePath()\"\n [class.deleted]=\"row.node.status === statuses.Deleted\"\n [style.padding-left.rem]=\"0.25 + row.depth * 1\"\n >\n <button\n type=\"button\"\n class=\"theme-tree-main\"\n role=\"treeitem\"\n [title]=\"row.node.path\"\n (click)=\"select(row.node)\"\n >\n @if (row.node.isFolder) {\n <i class=\"fa fa-fw\" [class.fa-folder]=\"collapsed().has(row.node.path)\" [class.fa-folder-open]=\"!collapsed().has(row.node.path)\"></i>\n } @else {\n <i class=\"fa fa-fw\" [class.fa-file-code]=\"row.node.isAsset\" [class.fa-file-lines]=\"!row.node.isAsset\"></i>\n }\n <span class=\"name\">{{ row.node.name }}</span>\n @if (!row.node.isFolder && statusKey(row.node.status); as key) {\n <span\n class=\"badge\"\n [class.text-bg-warning]=\"row.node.status === statuses.Modified\"\n [class.text-bg-success]=\"row.node.status === statuses.Added\"\n [class.text-bg-secondary]=\"row.node.status === statuses.Deleted\"\n [title]=\"key | abpLocalization\"\n >{{ (key + ':Short') | abpLocalization }}</span>\n } @else if (row.node.isFolder && row.node.hasChanges) {\n <span class=\"text-warning\" [title]=\"'Site::ThemeFolderHasChanges' | abpLocalization\">\u2022</span>\n }\n </button>\n <!-- Renaming and deleting are rare: behind a \"more\" button on the file, not in the editor's way. -->\n @if (canEdit && !row.node.isFolder && row.node.status !== statuses.Deleted) {\n <button\n type=\"button\"\n class=\"theme-tree-more\"\n [class.open]=\"rowMenu()?.path === row.node.path\"\n [attr.aria-label]=\"'Site::MoreActions' | abpLocalization\"\n [attr.aria-expanded]=\"rowMenu()?.path === row.node.path\"\n [title]=\"'Site::MoreActions' | abpLocalization\"\n (click)=\"toggleRowMenu($event, row.node.path)\"\n >\n <i class=\"fa fa-ellipsis-vertical\"></i>\n </button>\n }\n </div>\n } @empty {\n <p class=\"text-muted small mb-0\">{{ 'Site::ThemeNoFiles' | abpLocalization }}</p>\n }\n </div>\n </div>\n </div>\n </div>\n\n <!-- Middle: the open files. -->\n <div class=\"col-lg-6\">\n <div class=\"card h-100\">\n <div class=\"card-body\">\n @if (openFiles().length > 0) {\n <ul class=\"nav nav-tabs mb-2 flex-nowrap theme-tabs\">\n @for (file of openFiles(); track file.path) {\n <li class=\"nav-item\">\n <a\n href=\"javascript:void(0)\"\n class=\"nav-link theme-tab\"\n [class.active]=\"file.path === activePath()\"\n [class.fst-italic]=\"!file.pinned && !isDirty(file)\"\n [title]=\"file.path\"\n (click)=\"activePath.set(file.path)\"\n >\n {{ file.path.split('/').pop() }}@if (isDirty(file)) { <span class=\"text-warning\" [title]=\"'Site::UnsavedChanges' | abpLocalization\">\u25CF</span> }\n <button\n type=\"button\"\n class=\"btn-close btn-sm ms-1 align-middle\"\n style=\"font-size: 0.6rem\"\n [attr.aria-label]=\"'AbpUi::Close' | abpLocalization\"\n (click)=\"$event.stopPropagation(); closeFile(file.path)\"\n ></button>\n </a>\n </li>\n }\n </ul>\n\n @if (activeFile(); as file) {\n <div class=\"d-flex flex-wrap align-items-center gap-2 mb-2\">\n <code class=\"me-auto\">{{ file.path }}</code>\n @if (canEdit) {\n <!-- Only for a file the check lists as a layout; there is nothing to do when it already is the default. -->\n @if (isLayoutInFront()) {\n <button\n type=\"button\"\n class=\"btn btn-sm btn-outline-secondary\"\n [disabled]=\"isDefaultLayoutInFront() || isBusy()\"\n [title]=\"(isDefaultLayoutInFront() ? 'Site::IsDefaultLayoutHint' : 'Site::SetAsDefaultLayoutHint') | abpLocalization\"\n (click)=\"setAsDefaultLayout()\"\n >\n <i class=\"fa fa-star me-1\"></i>{{ 'Site::SetAsDefaultLayout' | abpLocalization }}\n </button>\n }\n <button\n type=\"button\"\n class=\"btn btn-sm btn-primary\"\n [disabled]=\"!isDirty(file) || isBusy()\"\n [title]=\"'Site::SaveShortcut' | abpLocalization\"\n (click)=\"save()\"\n >\n @if (isBusy()) {\n <span class=\"spinner-border spinner-border-sm me-1\" role=\"status\"></span>\n } @else {\n <i class=\"fa fa-save me-1\"></i>\n }\n {{ 'AbpUi::Save' | abpLocalization }}\n </button>\n }\n </div>\n\n <div (keydown)=\"onEditorKeydown($event)\">\n <site-code-editor\n [documentKey]=\"file.path\"\n [value]=\"file.body\"\n [language]=\"languageOf(file.path)\"\n [readonly]=\"!canEdit\"\n (valueChange)=\"onEdit($event)\"\n ></site-code-editor>\n </div>\n\n @if (file.syntaxErrors.length > 0) {\n <div class=\"alert alert-danger mt-2 mb-0\" role=\"alert\">\n <strong class=\"d-block mb-1\">{{ 'Site::ThemeSyntaxErrors' | abpLocalization }}</strong>\n <ul class=\"list-unstyled mb-0\">\n @for (error of file.syntaxErrors; track $index) {\n <li>\n <!-- A button, not a link: it moves the editor's cursor, it does not navigate. -->\n <button type=\"button\" class=\"btn btn-link btn-sm p-0 align-baseline\" (click)=\"goToSyntaxError(error)\">\n {{ error.line }}:{{ error.column }}\n </button>\n {{ error.message }}\n </li>\n }\n </ul>\n </div>\n }\n }\n } @else {\n <p class=\"text-muted mb-0\">{{ 'Site::ThemeNoFileOpen' | abpLocalization }}</p>\n }\n </div>\n </div>\n </div>\n\n <!-- Right: the fields of a page the template in front renders, when it is one; the check below it, always. -->\n <div class=\"col-lg-3\">\n <div class=\"card h-100\">\n <div class=\"card-body theme-issues\">\n @if (showsFields()) {\n <h6>{{ 'Site::ThemeFields' | abpLocalization }}</h6>\n <div class=\"theme-fields\">\n @if (templatePages().length > 1) {\n <label class=\"form-label small mb-1\" for=\"site-theme-fields-page\">{{ 'Site::ThemeFieldsPage' | abpLocalization }}</label>\n <select\n id=\"site-theme-fields-page\"\n class=\"form-select form-select-sm mb-2\"\n [ngModel]=\"fieldsPageId()\"\n (ngModelChange)=\"chosenFieldsPageId.set($event)\"\n >\n @for (page of templatePages(); track page.id) {\n <option [value]=\"page.id\">{{ page.name }}</option>\n }\n </select>\n } @else {\n <p class=\"small fw-bold mb-1\">{{ 'Site::ThemeFieldsOfPage' | abpLocalization: templatePages()[0].name }}</p>\n }\n <p class=\"small text-muted mb-2\">\n {{ (canEdit ? 'Site::ThemeFieldsHint' : 'Site::ThemeFieldsReadOnlyHint') | abpLocalization }}\n </p>\n @if (isLoadingFields()) {\n <div class=\"text-center py-3\"><span class=\"spinner-border spinner-border-sm\" role=\"status\"></span></div>\n } @else {\n @for (group of fieldGroups(); track group.id) {\n <div class=\"small fw-bold mb-1\">{{ group.displayName }}</div>\n <ul class=\"list-unstyled small mb-3\">\n @for (field of group.fields; track field.name) {\n <li class=\"mb-2\">\n <button\n type=\"button\"\n class=\"btn btn-link btn-sm p-0 align-baseline text-start\"\n [disabled]=\"!canEdit\"\n (click)=\"insertField(field)\"\n >\n {{ field.displayName }}\n </button>\n <span class=\"text-muted ms-1\">{{ field.fieldTypeName }}</span>\n <br />\n <code class=\"theme-field-snippet\">{{ field.snippet }}</code>\n </li>\n }\n </ul>\n } @empty {\n <p class=\"small text-muted mb-0\">{{ 'Site::ThemeFieldsNone' | abpLocalization }}</p>\n }\n }\n </div>\n <hr class=\"my-3\" />\n }\n\n <!-- The check: runs by itself after every change; the button asks for the result as it is now, for what\n happened elsewhere (another author, an AI over MCP, the Pages screen). -->\n <div class=\"d-flex align-items-center mb-2\">\n <h6 class=\"mb-0 me-auto\">{{ 'Site::ThemeCheck' | abpLocalization }}</h6>\n <button type=\"button\" class=\"btn btn-sm btn-outline-secondary\" (click)=\"runCheck()\" [disabled]=\"isChecking()\">\n @if (isChecking()) {\n <span class=\"spinner-border spinner-border-sm me-1\" role=\"status\"></span>\n } @else {\n <i class=\"fa fa-stethoscope me-1\"></i>\n }\n {{ 'Site::CheckTheme' | abpLocalization }}\n </button>\n </div>\n @if (check(); as current) {\n @if (current.issues.length === 0) {\n <p class=\"text-success small mb-2\"><i class=\"fa fa-check me-1\"></i>{{ 'Site::ThemeCheckClean' | abpLocalization }}</p>\n }\n @for (group of [severities.Error, severities.Warning]; track group) {\n @if (issuesOf(group).length > 0) {\n <div class=\"small fw-bold mb-1\" [class.text-danger]=\"group === severities.Error\" [class.text-warning]=\"group === severities.Warning\">\n {{ (group === severities.Error ? 'Site::ThemeErrors' : 'Site::ThemeWarnings') | abpLocalization }}\n ({{ issuesOf(group).length }})\n </div>\n <ul class=\"list-unstyled small mb-3\">\n @for (issue of issuesOf(group); track $index) {\n <li class=\"mb-2\">\n @if (issue.path) {\n <button type=\"button\" class=\"btn btn-link btn-sm p-0 align-baseline\" (click)=\"goToIssue(issue)\">\n {{ issue.path }}@if (issue.line) {:{{ issue.line }}}\n </button>\n <br />\n }\n {{ issue.message }}\n </li>\n }\n </ul>\n }\n }\n <p class=\"small text-muted mb-0\">\n @if (current.changes.length > 0) {\n {{ 'Site::ThemeUnpublishedChanges' | abpLocalization: current.changes.length.toString() }}\n }\n @if (defaultLayoutChanged(current)) {\n {{ 'Site::ThemeDefaultLayoutUnpublished' | abpLocalization: (current.liveDefaultLayout || ('Site::NoDefaultLayout' | abpLocalization)) }}\n }\n @if (!current.hasChanges) {\n {{ 'Site::ThemeNoUnpublishedChanges' | abpLocalization }}\n }\n </p>\n } @else {\n <p class=\"text-muted small mb-0\">{{ 'Site::ThemeCheckNotRun' | abpLocalization }}</p>\n }\n </div>\n </div>\n </div>\n </div>\n}\n\n<!-- The \"more\" menu of a file in the tree. Fixed, under its button: the tree scrolls and would clip it. -->\n@if (rowMenu(); as menu) {\n <div\n class=\"dropdown-menu show\"\n style=\"position: fixed\"\n [style.top.px]=\"menu.top\"\n [style.right.px]=\"menu.right\"\n (click)=\"$event.stopPropagation()\"\n >\n <button type=\"button\" class=\"dropdown-item\" (click)=\"renameFromMenu()\">\n <i class=\"fa fa-i-cursor fa-fw me-1\"></i>{{ 'Site::RenameThemeFile' | abpLocalization }}\n </button>\n <button type=\"button\" class=\"dropdown-item text-danger\" (click)=\"deleteFromMenu()\">\n <i class=\"fa fa-trash fa-fw me-1\"></i>{{ 'AbpUi::Delete' | abpLocalization }}\n </button>\n </div>\n}\n\n<!-- New file. -->\n<abp-modal [visible]=\"isNewFileOpen()\" (visibleChange)=\"isNewFileOpen.set($event)\" [busy]=\"isBusy()\">\n <ng-template #abpHeader>\n <h3>{{ 'Site::NewThemeFile' | abpLocalization }}</h3>\n </ng-template>\n <ng-template #abpBody>\n <label class=\"form-label\" for=\"site-theme-new-path\">{{ 'Site::ThemeFilePath' | abpLocalization }}</label>\n <input\n id=\"site-theme-new-path\"\n type=\"text\"\n class=\"form-control\"\n autocomplete=\"off\"\n [ngModel]=\"newFilePath()\"\n (ngModelChange)=\"newFilePath.set($event); newFileError.set(null)\"\n (keydown.enter)=\"createFile()\"\n />\n <small class=\"form-text text-muted\">{{ 'Site::ThemeFilePathHint' | abpLocalization }}</small>\n @if (newFileError(); as message) {\n <div class=\"alert alert-danger mt-2 mb-0\" style=\"white-space: pre-line\">{{ message }}</div>\n }\n </ng-template>\n <ng-template #abpFooter>\n <button type=\"button\" class=\"btn btn-secondary\" abpClose>{{ 'AbpUi::Cancel' | abpLocalization }}</button>\n <abp-button iconClass=\"fa fa-check\" [loading]=\"isBusy()\" (click)=\"createFile()\">\n {{ 'Site::CreateThemeFile' | abpLocalization }}\n </abp-button>\n </ng-template>\n</abp-modal>\n\n<!-- Rename. -->\n<abp-modal [visible]=\"isRenameOpen()\" (visibleChange)=\"isRenameOpen.set($event)\" [busy]=\"isBusy()\">\n <ng-template #abpHeader>\n <h3>{{ 'Site::RenameThemeFile' | abpLocalization }}</h3>\n </ng-template>\n <ng-template #abpBody>\n <p class=\"mb-2\"><code>{{ renameFrom() }}</code></p>\n <label class=\"form-label\" for=\"site-theme-rename-path\">{{ 'Site::ThemeFileNewPath' | abpLocalization }}</label>\n <input\n id=\"site-theme-rename-path\"\n type=\"text\"\n class=\"form-control\"\n autocomplete=\"off\"\n [ngModel]=\"renameTo()\"\n (ngModelChange)=\"renameTo.set($event); renameError.set(null)\"\n (keydown.enter)=\"rename()\"\n />\n <div class=\"form-check mt-2\">\n <input\n id=\"site-theme-rename-references\"\n type=\"checkbox\"\n class=\"form-check-input\"\n [ngModel]=\"renameUpdatesReferences()\"\n (ngModelChange)=\"renameUpdatesReferences.set($event)\"\n />\n <label class=\"form-check-label\" for=\"site-theme-rename-references\">\n {{ 'Site::ThemeRenameUpdateReferences' | abpLocalization }}\n </label>\n </div>\n @if (renameError(); as message) {\n <div class=\"alert alert-danger mt-2 mb-0\" style=\"white-space: pre-line\">{{ message }}</div>\n }\n </ng-template>\n <ng-template #abpFooter>\n <button type=\"button\" class=\"btn btn-secondary\" abpClose>{{ 'AbpUi::Cancel' | abpLocalization }}</button>\n <abp-button iconClass=\"fa fa-check\" [loading]=\"isBusy()\" (click)=\"rename()\">\n {{ 'Site::RenameThemeFile' | abpLocalization }}\n </abp-button>\n </ng-template>\n</abp-modal>\n\n<!-- Publish. -->\n<abp-modal [visible]=\"isPublishOpen()\" (visibleChange)=\"isPublishOpen.set($event)\" [busy]=\"isBusy()\" [options]=\"{ size: 'lg' }\">\n <ng-template #abpHeader>\n <h3>{{ 'Site::PublishThemeTitle' | abpLocalization }}</h3>\n </ng-template>\n <ng-template #abpBody>\n @if (publishCheck(); as current) {\n @if (current.hasErrors) {\n <div class=\"alert alert-danger\">\n <strong class=\"d-block mb-1\">{{ 'Site::PublishThemeHasErrors' | abpLocalization }}</strong>\n <ul class=\"mb-0\">\n @for (issue of current.issues; track $index) {\n @if (issue.severity === severities.Error) {\n <li>\n @if (issue.path) { <code>{{ issue.path }}@if (issue.line) {:{{ issue.line }}}</code> }\n {{ issue.message }}\n </li>\n }\n }\n </ul>\n </div>\n } @else if (!current.hasChanges) {\n <div class=\"alert alert-info\">{{ 'Site::PublishThemeNoChanges' | abpLocalization }}</div>\n } @else {\n <h6>{{ 'Site::PublishThemeChanges' | abpLocalization }}</h6>\n <ul class=\"list-unstyled small mb-3\">\n @for (change of current.changes; track change.path) {\n <li>\n <span\n class=\"badge me-1\"\n [class.text-bg-warning]=\"change.status === statuses.Modified\"\n [class.text-bg-success]=\"change.status === statuses.Added\"\n [class.text-bg-secondary]=\"change.status === statuses.Deleted\"\n >{{ statusKey(change.status)! | abpLocalization }}</span>\n <code>{{ change.path }}</code>\n </li>\n }\n @if (defaultLayoutChanged(current)) {\n <li>\n <span class=\"badge text-bg-warning me-1\">{{ 'Site::ThemeFileStatus:Modified' | abpLocalization }}</span>\n {{ 'Site::DefaultLayout' | abpLocalization }}:\n <code>{{ current.liveDefaultLayout || ('Site::NoDefaultLayout' | abpLocalization) }}</code>\n \u2192\n <code>{{ current.defaultLayout || ('Site::NoDefaultLayout' | abpLocalization) }}</code>\n </li>\n }\n </ul>\n @if (warningCount() > 0) {\n <p class=\"small text-warning\">{{ 'Site::PublishThemeWarnings' | abpLocalization: warningCount().toString() }}</p>\n }\n <label class=\"form-label\" for=\"site-theme-publish-comment\">{{ 'Site::ReleaseComment' | abpLocalization }}</label>\n <input\n id=\"site-theme-publish-comment\"\n type=\"text\"\n class=\"form-control\"\n autocomplete=\"off\"\n maxlength=\"256\"\n [placeholder]=\"'Site::ReleaseCommentPlaceholder' | abpLocalization\"\n [ngModel]=\"publishComment()\"\n (ngModelChange)=\"publishComment.set($event)\"\n (keydown.enter)=\"publishOnEnter($event)\"\n />\n }\n @if (publishError(); as message) {\n <div class=\"alert alert-danger mt-2 mb-0\" style=\"white-space: pre-line\">{{ message }}</div>\n }\n } @else {\n <div class=\"text-center py-3\"><span class=\"spinner-border\" role=\"status\"></span></div>\n }\n </ng-template>\n <ng-template #abpFooter>\n <button type=\"button\" class=\"btn btn-secondary\" abpClose>{{ 'AbpUi::Cancel' | abpLocalization }}</button>\n <abp-button\n iconClass=\"fa fa-upload\"\n [loading]=\"isBusy()\"\n [disabled]=\"!publishCheck() || publishCheck()!.hasErrors || !publishCheck()!.hasChanges\"\n (click)=\"publish()\"\n >\n {{ 'Site::PublishTheme' | abpLocalization }}\n </abp-button>\n </ng-template>\n</abp-modal>\n\n<!-- Releases. -->\n<abp-modal [visible]=\"isReleasesOpen()\" (visibleChange)=\"isReleasesOpen.set($event)\" [options]=\"{ size: 'xl' }\">\n <ng-template #abpHeader>\n <h3>{{ 'Site::Releases' | abpLocalization }}</h3>\n </ng-template>\n <ng-template #abpBody>\n <!-- The comment takes the room and wraps; the other columns keep their words on one line. -->\n <div class=\"table-responsive\">\n <table class=\"table table-sm align-middle mb-0\">\n <thead>\n <tr>\n <th class=\"text-nowrap\">#</th>\n <th class=\"w-100\">{{ 'Site::ReleaseComment' | abpLocalization }}</th>\n <th class=\"text-nowrap\">{{ 'Site::ReleaseChangedFiles' | abpLocalization }}</th>\n <th class=\"text-nowrap\">{{ 'Site::ReleaseTime' | abpLocalization }}</th>\n <th class=\"text-end text-nowrap\">{{ 'AbpUi::Actions' | abpLocalization }}</th>\n </tr>\n </thead>\n <tbody>\n @for (release of releases(); track release.id) {\n <tr>\n <td class=\"text-nowrap\">\n {{ release.number }}\n @if (release.isLive) {\n <span class=\"badge text-bg-success ms-1\">{{ 'Site::ReleaseLive' | abpLocalization }}</span>\n }\n </td>\n <td>{{ release.comment }}</td>\n <td class=\"text-nowrap\">{{ release.changedFileCount }}</td>\n <td class=\"text-nowrap\">{{ release.creationTime | date: 'short' }}</td>\n <td class=\"text-end text-nowrap\">\n <button\n type=\"button\"\n class=\"btn btn-sm btn-outline-secondary me-1\"\n [title]=\"'Site::PreviewReleaseHint' | abpLocalization\"\n (click)=\"openPreview(release.id)\"\n >\n <i class=\"fa fa-eye me-1\"></i>{{ 'Site::PreviewTheme' | abpLocalization }}\n </button>\n @if (canPublish && !release.isLive) {\n <button type=\"button\" class=\"btn btn-sm btn-outline-primary me-1\" (click)=\"makeLive(release)\">\n {{ 'Site::MakeReleaseLive' | abpLocalization }}\n </button>\n }\n @if (canEdit) {\n <button type=\"button\" class=\"btn btn-sm btn-outline-secondary\" (click)=\"restore(release)\">\n {{ 'Site::RestoreRelease' | abpLocalization }}\n </button>\n }\n </td>\n </tr>\n } @empty {\n <tr>\n <td colspan=\"5\" class=\"text-muted\">{{ 'Site::NothingPublished' | abpLocalization }}</td>\n </tr>\n }\n </tbody>\n </table>\n </div>\n </ng-template>\n <ng-template #abpFooter>\n <button type=\"button\" class=\"btn btn-secondary\" abpClose>{{ 'AbpUi::Close' | abpLocalization }}</button>\n </ng-template>\n</abp-modal>\n", styles: [":host{--site-code-editor-height: max(24rem, calc(100vh - 25rem) );--site-code-editor-max-height: none}.theme-tree{max-height:max(20rem,calc(100vh - 21rem));overflow-y:auto}.theme-tree-row{display:flex;align-items:center;width:100%;padding-right:.25rem;border-radius:var(--bs-border-radius-sm, .25rem)}.theme-tree-main{display:flex;align-items:center;gap:.25rem;flex:1 1 auto;min-width:0;border:none;background:transparent;text-align:start;padding:.15rem 0;color:inherit;white-space:nowrap}.theme-tree-row:hover{background-color:#8080801f}.theme-tree-more{flex:0 0 auto;border:none;background:transparent;color:inherit;padding:.1rem .4rem;opacity:0}.theme-tree-row:hover .theme-tree-more,.theme-tree-row:focus-within .theme-tree-more,.theme-tree-more.open{opacity:1}@media(hover:none){.theme-tree-more{opacity:1}}.theme-tree-row.active{background-color:rgba(var(--bs-primary-rgb, 13, 110, 253),.15)}.theme-tree-row .name{overflow:hidden;text-overflow:ellipsis;flex:1 1 auto}.theme-tree-row.deleted .name{text-decoration:line-through;opacity:.6}.theme-tab{white-space:nowrap}.theme-field-snippet{white-space:pre-wrap;word-break:break-all}.theme-tabs{overflow-x:auto;overflow-y:hidden}.theme-issues{max-height:max(20rem,calc(100vh - 17rem));overflow-y:auto}.theme-fields{max-height:max(10rem,38vh);overflow-y:auto}\n"] }]
1174
+ }], ctorParameters: () => [], propDecorators: { editor: [{ type: i0.ViewChild, args: [i0.forwardRef(() => CodeEditorComponent), { isSignal: true }] }], closeRowMenu: [{
1175
+ type: HostListener,
1176
+ args: ['document:click']
1177
+ }, {
1178
+ type: HostListener,
1179
+ args: ['document:keydown.escape']
1180
+ }], onBeforeUnload: [{
1181
+ type: HostListener,
1182
+ args: ['window:beforeunload', ['$event']]
1183
+ }] } });
1184
+ function isDirty(file) {
1185
+ return file.body !== file.savedBody;
1186
+ }
1187
+ function toOpenFile(file) {
1188
+ return {
1189
+ path: file.path ?? '',
1190
+ isAsset: file.isAsset,
1191
+ body: file.body ?? '',
1192
+ savedBody: file.body ?? '',
1193
+ pinned: false,
1194
+ concurrencyStamp: file.concurrencyStamp,
1195
+ syntaxErrors: file.syntaxErrors ?? [],
1196
+ };
1197
+ }
1198
+ /** Who uses a file, in words: `Events (include), about (page)`; empty when nothing does. */
1199
+ function describeUsages(usages, kindName) {
1200
+ return (usages.usages ?? [])
1201
+ .map(usage => `${usage.pageName ?? usage.sourcePath ?? ''} (${kindName(usage.kind)})`.trim())
1202
+ .join(', ');
1203
+ }
1204
+
1205
+ export { ThemeWorkbenchComponent, describeUsages };
1206
+ //# sourceMappingURL=dignite-ng.site-theme-workbench.component-CA47DnTj.mjs.map