feat(cms): add static pages module

This commit is contained in:
sdarbinyan
2026-07-10 13:52:01 +04:00
parent e2c8747fcc
commit 7d6c09a346
25 changed files with 890 additions and 159 deletions

View File

@@ -0,0 +1,52 @@
<section class="editor-section-card">
<div class="editor-actions">
<h2>{{ 'builder.staticPages' | translate }}</h2>
<button type="button" (click)="createPage()">{{ 'builder.createPage' | translate }}</button>
</div>
@if (validation().duplicateSlugs.length > 0) {
<p class="editor-error">{{ 'staticPages.duplicateSlug' | translate }}</p>
}
@if (validation().emptyTitles.length > 0) {
<p class="editor-error">{{ 'staticPages.emptyTitle' | translate }}</p>
}
<div class="stack-list">
@for (page of pages(); track page.id) {
<article class="sub-card">
<div class="editor-actions">
<h3>{{ page.id }}</h3>
<div class="editor-actions">
<button type="button" class="secondary" (click)="move(page.id, -1)"></button>
<button type="button" class="secondary" (click)="move(page.id, 1)"></button>
<button type="button" class="secondary" (click)="deletePage(page.id)">{{ 'builder.deletePage' | translate }}</button>
</div>
</div>
<div class="editor-grid three">
<label><span>{{ 'builder.pageId' | translate }}</span><input type="text" [ngModel]="page.id" (ngModelChange)="updatePage(page.id, { id: $event })" /></label>
<label><span>{{ 'builder.slug' | translate }}</span><input type="text" [ngModel]="page.slug" (ngModelChange)="updatePage(page.id, { slug: $event })" /></label>
<label><span>{{ 'builder.iconLabel' | translate }}</span><input type="text" [ngModel]="page.icon || ''" (ngModelChange)="updatePage(page.id, { icon: $event })" /></label>
<label class="toggle-row"><input type="checkbox" [checked]="page.showInFooter" (change)="updatePage(page.id, { showInFooter: $any($event.target).checked })" /><span>{{ 'builder.showInFooter' | translate }}</span></label>
<label class="toggle-row"><input type="checkbox" [checked]="page.showInHeader" (change)="updatePage(page.id, { showInHeader: $any($event.target).checked })" /><span>{{ 'builder.showInHeader' | translate }}</span></label>
<label class="toggle-row"><input type="checkbox" [checked]="page.showInSitemap" (change)="updatePage(page.id, { showInSitemap: $any($event.target).checked })" /><span>{{ 'builder.showInSitemap' | translate }}</span></label>
<label class="toggle-row"><input type="checkbox" [checked]="page.requiresAuthentication" (change)="updatePage(page.id, { requiresAuthentication: $any($event.target).checked })" /><span>{{ 'builder.requiresAuthentication' | translate }}</span></label>
<label><span>{{ 'builder.footerGroup' | translate }}</span><input type="text" [ngModel]="page.footerGroup || ''" (ngModelChange)="updatePage(page.id, { footerGroup: $event })" /></label>
<label><span>{{ 'builder.orderLabel' | translate }}</span><input type="number" [ngModel]="page.order" (ngModelChange)="updatePage(page.id, { order: +$event })" /></label>
</div>
@for (locale of ['en','ru','hy']; track locale) {
<div class="editor-grid two">
<label>
<span>{{ 'builder.translationTitle' | translate }} {{ locale }}</span>
<input type="text" [ngModel]="page.translations[locale]?.title || ''" (ngModelChange)="updateTranslation(page.id, locale, 'title', $event)" />
</label>
<label class="full">
<span>{{ 'builder.htmlPreview' | translate }} {{ locale }}</span>
<textarea rows="6" [ngModel]="page.translations[locale]?.html || ''" (ngModelChange)="updateTranslation(page.id, locale, 'html', $event)"></textarea>
</label>
</div>
}
</article>
}
</div>
</section>

View File

@@ -0,0 +1,108 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ProjectEditorFacade } from '../../project-editor/facade/project-editor.facade';
import { ContentManagementFacade } from '../facade/content-management.facade';
import { ContentPage } from '../models/content-page.model';
import { TranslatePipe } from '../../../i18n/translate.pipe';
@Component({
selector: 'app-static-pages-editor',
standalone: true,
imports: [FormsModule, TranslatePipe],
templateUrl: './static-pages-editor.component.html',
styleUrls: ['../../project-editor/sections/section.shared.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class StaticPagesEditorComponent {
private readonly projectEditor = inject(ProjectEditorFacade);
private readonly contentFacade = inject(ContentManagementFacade);
readonly bootstrap = this.projectEditor.bootstrap;
readonly pages = computed(() => this.contentFacade.pages(this.bootstrap()));
readonly validation = computed(() => this.contentFacade.validatePages(this.bootstrap()));
createPage(): void {
const page: ContentPage = {
id: `page-${Date.now()}`,
slug: `custom-page-${this.pages().length + 1}`,
title: '',
order: this.pages().length + 1,
showInFooter: false,
showInHeader: false,
showInSitemap: true,
visibility: { desktop: true, tablet: true, mobile: true },
requiresAuthentication: false,
translations: {
en: { title: '', html: '' },
ru: { title: '', html: '' },
hy: { title: '', html: '' },
},
};
this.persist([...this.pages(), page]);
}
deletePage(id: string): void {
this.persist(this.pages().filter(page => page.id !== id));
}
updatePage(id: string, patch: Partial<ContentPage>): void {
this.persist(this.pages().map(page => page.id !== id ? page : ({ ...page, ...patch })));
}
updateTranslation(id: string, locale: string, field: 'title' | 'html', value: string): void {
this.persist(this.pages().map(page => page.id !== id ? page : ({
...page,
translations: {
...page.translations,
[locale]: {
...(page.translations[locale] ?? {}),
[field]: value,
}
}
})));
}
move(id: string, direction: -1 | 1): void {
const pages = [...this.pages()].sort((a, b) => a.order - b.order);
const index = pages.findIndex(page => page.id === id);
const nextIndex = index + direction;
if (index < 0 || nextIndex < 0 || nextIndex >= pages.length) {
return;
}
const current = pages[index];
pages[index] = pages[nextIndex];
pages[nextIndex] = current;
this.persist(pages.map((page, order) => ({ ...page, order: order + 1 })));
}
private persist(pages: ContentPage[]): void {
this.projectEditor.updateBootstrap(current => ({
...current,
staticPages: pages.reduce<Record<string, any>>((acc, page) => {
const record = {
id: page.id,
slug: page.slug,
title: Object.fromEntries(Object.entries(page.translations).map(([locale, translation]) => [locale, translation.title || page.title]).filter(([, value]) => !!value)),
html: Object.fromEntries(Object.entries(page.translations).map(([locale, translation]) => [locale, translation.html || ''])),
showInFooter: page.showInFooter,
showInHeader: page.showInHeader,
showInSitemap: page.showInSitemap,
icon: page.icon,
order: page.order,
visibility: page.visibility,
requiresAuthentication: page.requiresAuthentication,
footerGroup: page.footerGroup,
translations: page.translations,
seo: page.seo,
visible: true,
route: `/${page.slug}`,
content: Object.fromEntries(Object.entries(page.translations).map(([locale, translation]) => [locale, translation.html || ''])),
};
acc[page.id] = record;
return acc;
}, {})
}));
}
}

View File

@@ -0,0 +1,24 @@
import { Injectable, computed, inject } from '@angular/core';
import { BootstrapConfig } from '../../../shared/models/config';
import { ContentPageService } from '../services/content-page.service';
@Injectable({ providedIn: 'root' })
export class ContentManagementFacade {
private readonly service = inject(ContentPageService);
pages(bootstrap: BootstrapConfig | null) {
return bootstrap ? this.service.normalizePages(bootstrap.staticPages) : [];
}
resolvePage(bootstrap: BootstrapConfig, keyOrSlug: string, locale: string) {
return this.service.resolvePage(bootstrap, keyOrSlug, locale);
}
validatePages(bootstrap: BootstrapConfig | null) {
return this.service.validatePages(this.pages(bootstrap));
}
toBootstrapRecord(bootstrap: BootstrapConfig | null) {
return bootstrap ? this.service.toBootstrapRecord(this.pages(bootstrap)) : {};
}
}

View File

@@ -0,0 +1,58 @@
import { LocalizedHtmlContent, LocalizedTextContent } from '../../../shared/models/config';
export interface ContentPageSeoConfig {
title?: string;
description?: string;
keywords?: string;
canonical?: string;
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
}
export interface ContentPageTranslation {
title?: string;
html?: string;
seo?: ContentPageSeoConfig;
}
export interface ContentPage {
id: string;
slug: string;
title: string;
icon?: string;
order: number;
showInFooter: boolean;
showInHeader: boolean;
showInSitemap: boolean;
visibility: {
desktop?: boolean;
tablet?: boolean;
mobile?: boolean;
};
requiresAuthentication: boolean;
footerGroup?: string;
translations: Record<string, ContentPageTranslation>;
seo?: ContentPageSeoConfig;
}
export interface ContentPageBootstrapInput {
id: string;
slug: string;
title?: string | LocalizedTextContent;
showInFooter?: boolean;
showInHeader?: boolean;
showInSitemap?: boolean;
icon?: string;
order?: number;
visibility?: {
desktop?: boolean;
tablet?: boolean;
mobile?: boolean;
};
requiresAuthentication?: boolean;
footerGroup?: string;
translations?: Record<string, ContentPageTranslation>;
html?: string | LocalizedHtmlContent;
seo?: ContentPageSeoConfig | { title?: string | LocalizedTextContent; description?: string | LocalizedTextContent; keywords?: string; canonical?: string; ogTitle?: string | LocalizedTextContent; ogDescription?: string | LocalizedTextContent; ogImage?: string; robots?: string; metaTags?: Array<{ name?: string; property?: string; content: string }>; };
}

View File

@@ -0,0 +1,11 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { StaticPagesEditorComponent } from '../components/static-pages-editor.component';
@Component({
selector: 'app-content-management-page',
standalone: true,
imports: [StaticPagesEditorComponent],
template: `<app-static-pages-editor />`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ContentManagementPageComponent {}

View File

@@ -0,0 +1,196 @@
import { Injectable } from '@angular/core';
import { BootstrapConfig, LocalizedHtmlContent, LocalizedTextContent, ResolvedStaticPage, StaticPageConfig, StaticPagesConfig } from '../../../shared/models/config';
import { ContentPage, ContentPageBootstrapInput } from '../models/content-page.model';
@Injectable({ providedIn: 'root' })
export class ContentPageService {
normalizePages(config: StaticPagesConfig | undefined): ContentPage[] {
if (!config) {
return [];
}
if (Array.isArray(config)) {
return config.map((page, index) => ({
id: page.key,
slug: this.normalizeSlug(typeof page.route === 'string' ? page.route : page.route?.path ?? page.key),
title: typeof page.title === 'string' ? page.title : page.title?.['en'] ?? page.key,
icon: undefined,
order: index + 1,
showInFooter: false,
showInHeader: false,
showInSitemap: true,
visibility: { desktop: true, tablet: true, mobile: true },
requiresAuthentication: false,
footerGroup: undefined,
translations: this.normalizeLegacyTranslations(page.title, page.content),
seo: undefined,
}));
}
return Object.values(config)
.map((page, index) => this.normalizePage(page as ContentPageBootstrapInput, index))
.sort((left, right) => left.order - right.order);
}
resolvePage(bootstrap: BootstrapConfig, keyOrSlug: string, locale: string): ResolvedStaticPage | null {
const normalizedPages = this.normalizePages(bootstrap.staticPages);
const target = normalizedPages.find(page => page.id === keyOrSlug || page.slug === this.normalizeSlug(keyOrSlug));
if (!target) {
return null;
}
const translation = target.translations[locale] ?? target.translations['en'] ?? Object.values(target.translations)[0] ?? {};
return {
key: target.id,
id: target.id,
slug: target.slug,
route: `/${target.slug}`,
title: translation.title || target.title,
html: translation.html || '',
icon: target.icon,
requiresAuthentication: target.requiresAuthentication,
seo: translation.seo ?? target.seo,
};
}
validatePages(pages: ContentPage[]): { duplicateSlugs: string[]; emptyTitles: string[] } {
const seen = new Set<string>();
const duplicateSlugs = new Set<string>();
const emptyTitles = new Set<string>();
for (const page of pages) {
const slug = this.normalizeSlug(page.slug);
if (seen.has(slug)) {
duplicateSlugs.add(slug);
}
seen.add(slug);
const hasTitle = page.title.trim().length > 0 || Object.values(page.translations).some(translation => (translation.title ?? '').trim().length > 0);
if (!hasTitle) {
emptyTitles.add(page.id);
}
}
return {
duplicateSlugs: [...duplicateSlugs],
emptyTitles: [...emptyTitles],
};
}
toBootstrapRecord(pages: ContentPage[]): Record<string, StaticPageConfig> {
return pages.reduce<Record<string, StaticPageConfig>>((acc, page) => {
const htmlMap = Object.entries(page.translations).reduce<LocalizedHtmlContent>((result, [locale, translation]) => {
if ((translation.html ?? '').trim()) {
result[locale] = translation.html!;
}
return result;
}, {});
const titleMap = Object.entries(page.translations).reduce<LocalizedTextContent>((result, [locale, translation]) => {
if ((translation.title ?? '').trim()) {
result[locale] = translation.title!;
}
return result;
}, {});
acc[page.id] = {
id: page.id,
slug: this.normalizeSlug(page.slug),
title: Object.keys(titleMap).length > 0 ? titleMap : page.title,
html: htmlMap,
showInFooter: page.showInFooter,
showInHeader: page.showInHeader,
showInSitemap: page.showInSitemap,
icon: page.icon,
order: page.order,
visibility: page.visibility,
requiresAuthentication: page.requiresAuthentication,
footerGroup: page.footerGroup,
translations: page.translations,
seo: page.seo,
visible: true,
route: `/${this.normalizeSlug(page.slug)}`,
content: htmlMap,
};
return acc;
}, {});
}
private normalizePage(page: ContentPageBootstrapInput, index: number): ContentPage {
const titleMap = typeof page.title === 'object' && page.title ? page.title : {};
const htmlMap = typeof page.html === 'object' && page.html ? page.html : {};
const translations = { ...(page.translations ?? {}) };
for (const [locale, value] of Object.entries(titleMap)) {
translations[locale] = { ...(translations[locale] ?? {}), title: value };
}
for (const [locale, value] of Object.entries(htmlMap)) {
translations[locale] = { ...(translations[locale] ?? {}), html: value };
}
return {
id: page.id,
slug: this.normalizeSlug(page.slug || page.id),
title: typeof page.title === 'string' ? page.title : Object.values(titleMap)[0] ?? page.id,
icon: page.icon,
order: page.order ?? index + 1,
showInFooter: page.showInFooter !== false,
showInHeader: page.showInHeader === true,
showInSitemap: page.showInSitemap !== false,
visibility: page.visibility ?? { desktop: true, tablet: true, mobile: true },
requiresAuthentication: page.requiresAuthentication === true,
footerGroup: page.footerGroup,
translations,
seo: this.normalizeSeo(page.seo),
};
}
private normalizeLegacyTranslations(title: LegacyTitle, content: LegacyContent): Record<string, { title?: string; html?: string }> {
const translations: Record<string, { title?: string; html?: string }> = {};
if (typeof title === 'string') {
translations['en'] = { ...(translations['en'] ?? {}), title };
} else {
for (const [locale, value] of Object.entries(title ?? {})) {
translations[locale] = { ...(translations[locale] ?? {}), title: value };
}
}
if (typeof content === 'string') {
translations['en'] = { ...(translations['en'] ?? {}), html: content };
} else if (content && typeof content === 'object' && 'value' in content) {
translations['en'] = { ...(translations['en'] ?? {}), html: content.value ?? '' };
} else {
for (const [locale, value] of Object.entries((content ?? {}) as LocalizedHtmlContent)) {
translations[locale] = { ...(translations[locale] ?? {}), html: value };
}
}
return translations;
}
private normalizeSlug(value: string): string {
return value.replace(/^\/+/, '').trim();
}
private normalizeSeo(input: ContentPageBootstrapInput['seo']): ContentPage['seo'] {
if (!input) {
return undefined;
}
const first = (value?: string | LocalizedTextContent) => typeof value === 'string' ? value : value?.['en'] ?? Object.values(value ?? {})[0];
return {
title: first(input.title),
description: first(input.description),
keywords: input.keywords,
canonical: input.canonical,
ogTitle: first(input.ogTitle),
ogDescription: first(input.ogDescription),
ogImage: input.ogImage,
};
}
}
type LegacyTitle = string | LocalizedTextContent | undefined;
type LegacyContent = string | LocalizedHtmlContent | { value?: string; contentType?: string; source?: string } | undefined;