feat(cms): add static pages module
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
}, {})
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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)) : {};
|
||||
}
|
||||
}
|
||||
@@ -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 }>; };
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
@@ -22,6 +22,7 @@ export class ProjectEditorNavComponent {
|
||||
{ id: 'footer', label: 'builder.footer' },
|
||||
{ id: 'homepage', label: 'builder.homepage' },
|
||||
{ id: 'widgets', label: 'builder.widgets' },
|
||||
{ id: 'static-pages', label: 'builder.staticPages' },
|
||||
{ id: 'features', label: 'builder.marketplaceFeatures' },
|
||||
{ id: 'preview', label: 'builder.preview' },
|
||||
];
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ProjectEditorSectionId =
|
||||
| 'footer'
|
||||
| 'homepage'
|
||||
| 'widgets'
|
||||
| 'static-pages'
|
||||
| 'features'
|
||||
| 'preview';
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
@case ('footer') { <app-project-editor-footer-section /> }
|
||||
@case ('homepage') { <app-project-editor-homepage-section /> }
|
||||
@case ('widgets') { <app-project-editor-widgets-section /> }
|
||||
@case ('static-pages') { <app-static-pages-editor /> }
|
||||
@case ('features') { <app-project-editor-features-section /> }
|
||||
@case ('preview') { <app-project-editor-preview-section /> }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ProjectEditorWidgetsSectionComponent } from '../sections/widgets-sectio
|
||||
import { ProjectEditorFeaturesSectionComponent } from '../sections/features-section.component';
|
||||
import { ProjectEditorPreviewSectionComponent } from '../sections/preview-section.component';
|
||||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||
import { StaticPagesEditorComponent } from '../../content-management/components/static-pages-editor.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-project-editor-page',
|
||||
@@ -25,6 +26,7 @@ import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||
ProjectEditorFooterSectionComponent,
|
||||
ProjectEditorHomepageSectionComponent,
|
||||
ProjectEditorWidgetsSectionComponent,
|
||||
StaticPagesEditorComponent,
|
||||
ProjectEditorFeaturesSectionComponent,
|
||||
ProjectEditorPreviewSectionComponent,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user