feat(static-pages): live device preview (desktop/tablet/mobile)
Milestone 5 of the Static Pages Module sprint. - New StaticPagePreviewComponent: client-side, sanitized HTML preview at desktop/tablet(768px)/mobile(375px) widths, entirely without navigation or publish. Uses the same DomSanitizer.sanitize(SecurityContext.HTML, ...) pattern as the real storefront renderer (StaticPageComponent), so what authors preview here matches what will actually render live. - Wired into static-pages-editor as a per-page collapsible "Preview" toggle, showing the default-locale (or first available) translation's html/title. - i18n: staticPages.previewDesktop/Tablet/Mobile/Toggle in interface + en/ru/hy. Gate: tsc --noEmit, npm test (57/57), arch:check, build all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
<div class="static-page-preview">
|
||||||
|
<div class="static-page-preview__toolbar">
|
||||||
|
@for (device of devices; track device.id) {
|
||||||
|
<app-button
|
||||||
|
[variant]="activeDevice() === device.id ? 'primary' : 'secondary'"
|
||||||
|
size="sm"
|
||||||
|
(click)="setDevice(device.id)"
|
||||||
|
>{{ device.labelKey | translate }}</app-button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="static-page-preview__viewport">
|
||||||
|
<div class="static-page-preview__frame" [style.width]="activeWidth()">
|
||||||
|
@if (title()) {
|
||||||
|
<h1 class="static-page-preview__title">{{ title() }}</h1>
|
||||||
|
}
|
||||||
|
<div class="static-page-preview__content" [innerHTML]="safeHtml()"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
.static-page-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.static-page-preview__toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.static-page-preview__viewport {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-secondary, #f5f5f5);
|
||||||
|
border-radius: var(--radius-md, 12px);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.static-page-preview__frame {
|
||||||
|
max-width: 100%;
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
border: 1px solid var(--border-color, #d3dad9);
|
||||||
|
border-radius: var(--radius-md, 12px);
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.static-page-preview__title {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1e3c38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.static-page-preview__content {
|
||||||
|
color: var(--text-primary, #1e3c38);
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.static-page-preview__frame {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, SecurityContext, computed, inject, input, signal } from '@angular/core';
|
||||||
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
|
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||||
|
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||||
|
|
||||||
|
export type PreviewDevice = 'desktop' | 'tablet' | 'mobile';
|
||||||
|
|
||||||
|
interface DevicePreset {
|
||||||
|
readonly id: PreviewDevice;
|
||||||
|
readonly labelKey: string;
|
||||||
|
readonly width: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEVICE_PRESETS: readonly DevicePreset[] = [
|
||||||
|
{ id: 'desktop', labelKey: 'staticPages.previewDesktop', width: '100%' },
|
||||||
|
{ id: 'tablet', labelKey: 'staticPages.previewTablet', width: '768px' },
|
||||||
|
{ id: 'mobile', labelKey: 'staticPages.previewMobile', width: '375px' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side, sanitized preview of a static page's HTML at desktop/tablet/
|
||||||
|
* mobile widths - no navigation, no publish, no dependency on
|
||||||
|
* ProjectEditorPreviewService's full-app reload. Uses the same
|
||||||
|
* DomSanitizer.sanitize(SecurityContext.HTML, ...) pattern as the real
|
||||||
|
* storefront renderer (StaticPageComponent) so what you preview here is
|
||||||
|
* exactly what will render live.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-static-page-preview',
|
||||||
|
standalone: true,
|
||||||
|
imports: [TranslatePipe, ButtonComponent],
|
||||||
|
templateUrl: './static-page-preview.component.html',
|
||||||
|
styleUrl: './static-page-preview.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class StaticPagePreviewComponent {
|
||||||
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
|
|
||||||
|
readonly html = input.required<string>();
|
||||||
|
readonly title = input<string>('');
|
||||||
|
|
||||||
|
readonly devices = DEVICE_PRESETS;
|
||||||
|
readonly activeDevice = signal<PreviewDevice>('desktop');
|
||||||
|
|
||||||
|
readonly activeWidth = computed(() => this.devices.find(device => device.id === this.activeDevice())?.width ?? '100%');
|
||||||
|
|
||||||
|
readonly safeHtml = computed<SafeHtml>(() => {
|
||||||
|
const sanitized = this.sanitizer.sanitize(SecurityContext.HTML, this.html()) ?? '';
|
||||||
|
return this.sanitizer.bypassSecurityTrustHtml(sanitized);
|
||||||
|
});
|
||||||
|
|
||||||
|
setDevice(device: PreviewDevice): void {
|
||||||
|
this.activeDevice.set(device);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,7 @@
|
|||||||
<app-button variant="ghost" size="sm" (click)="move(page.id, -1)">↑</app-button>
|
<app-button variant="ghost" size="sm" (click)="move(page.id, -1)">↑</app-button>
|
||||||
<app-button variant="ghost" size="sm" (click)="move(page.id, 1)">↓</app-button>
|
<app-button variant="ghost" size="sm" (click)="move(page.id, 1)">↓</app-button>
|
||||||
<app-button variant="secondary" size="sm" (click)="duplicatePage(page.id)">{{ 'staticPages.duplicatePage' | translate }}</app-button>
|
<app-button variant="secondary" size="sm" (click)="duplicatePage(page.id)">{{ 'staticPages.duplicatePage' | translate }}</app-button>
|
||||||
|
<app-button variant="secondary" size="sm" (click)="togglePreview(page.id)">{{ 'staticPages.previewToggle' | translate }}</app-button>
|
||||||
@if (page.status === 'published') {
|
@if (page.status === 'published') {
|
||||||
<app-button variant="secondary" size="sm" (click)="updatePage(page.id, { status: 'draft' })">{{ 'staticPages.unpublishPageAction' | translate }}</app-button>
|
<app-button variant="secondary" size="sm" (click)="updatePage(page.id, { status: 'draft' })">{{ 'staticPages.unpublishPageAction' | translate }}</app-button>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -87,6 +88,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (previewOpenPageId() === page.id) {
|
||||||
|
<app-static-page-preview [html]="previewHtml(page)" [title]="previewTitle(page)" />
|
||||||
|
}
|
||||||
|
|
||||||
<div class="editor-grid three">
|
<div class="editor-grid three">
|
||||||
<app-form-field [label]="'builder.pageId' | translate" [error]="hasEmptyTitle(page) ? ('staticPages.emptyTitle' | translate) : null">
|
<app-form-field [label]="'builder.pageId' | translate" [error]="hasEmptyTitle(page) ? ('staticPages.emptyTitle' | translate) : null">
|
||||||
<app-input [ngModel]="page.id" (ngModelChange)="updatePage(page.id, { id: $event })" />
|
<app-input [ngModel]="page.id" (ngModelChange)="updatePage(page.id, { id: $event })" />
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { SelectComponent, SelectOption } from '../../../shared/ui/select/select.
|
|||||||
import { ToggleComponent } from '../../../shared/ui/toggle/toggle.component';
|
import { ToggleComponent } from '../../../shared/ui/toggle/toggle.component';
|
||||||
import { MediaPickerComponent } from '../../../shared/media/media-picker/media-picker.component';
|
import { MediaPickerComponent } from '../../../shared/media/media-picker/media-picker.component';
|
||||||
import { MediaAsset } from '../../../core/media/models/media-asset.model';
|
import { MediaAsset } from '../../../core/media/models/media-asset.model';
|
||||||
|
import { StaticPagePreviewComponent } from './static-page-preview/static-page-preview.component';
|
||||||
|
|
||||||
type StatusFilter = 'all' | ContentPageStatus;
|
type StatusFilter = 'all' | ContentPageStatus;
|
||||||
const ALL_LOCALES_FILTER = 'all';
|
const ALL_LOCALES_FILTER = 'all';
|
||||||
@@ -38,6 +39,7 @@ type MediaField = 'heroImage' | 'thumbnail';
|
|||||||
SelectComponent,
|
SelectComponent,
|
||||||
ToggleComponent,
|
ToggleComponent,
|
||||||
MediaPickerComponent,
|
MediaPickerComponent,
|
||||||
|
StaticPagePreviewComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './static-pages-editor.component.html',
|
templateUrl: './static-pages-editor.component.html',
|
||||||
styleUrls: ['../../project-editor/sections/section.shared.scss', './static-pages-editor.component.scss'],
|
styleUrls: ['../../project-editor/sections/section.shared.scss', './static-pages-editor.component.scss'],
|
||||||
@@ -119,6 +121,22 @@ export class StaticPagesEditorComponent {
|
|||||||
return JSON.stringify(page) !== JSON.stringify(original);
|
return JSON.stringify(page) !== JSON.stringify(original);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readonly previewOpenPageId = signal<string | null>(null);
|
||||||
|
|
||||||
|
togglePreview(pageId: string): void {
|
||||||
|
this.previewOpenPageId.set(this.previewOpenPageId() === pageId ? null : pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
previewHtml(page: ContentPage): string {
|
||||||
|
const defaultLocale = this.bootstrap()?.localization.defaultLocale ?? 'en';
|
||||||
|
return page.translations[defaultLocale]?.html ?? Object.values(page.translations)[0]?.html ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
previewTitle(page: ContentPage): string {
|
||||||
|
const defaultLocale = this.bootstrap()?.localization.defaultLocale ?? 'en';
|
||||||
|
return page.translations[defaultLocale]?.title || page.title;
|
||||||
|
}
|
||||||
|
|
||||||
protected mediaPickerOpen = false;
|
protected mediaPickerOpen = false;
|
||||||
private mediaPickerTarget: { pageId: string; field: MediaField } | null = null;
|
private mediaPickerTarget: { pageId: string; field: MediaField } | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -748,6 +748,10 @@ export const en: Translations = {
|
|||||||
customTemplateLabel: 'Custom template',
|
customTemplateLabel: 'Custom template',
|
||||||
seoRobotsLabel: 'Robots',
|
seoRobotsLabel: 'Robots',
|
||||||
disabledBadge: 'Disabled',
|
disabledBadge: 'Disabled',
|
||||||
|
previewDesktop: 'Desktop',
|
||||||
|
previewTablet: 'Tablet',
|
||||||
|
previewMobile: 'Mobile',
|
||||||
|
previewToggle: 'Preview',
|
||||||
},
|
},
|
||||||
widgets: {
|
widgets: {
|
||||||
unavailable: 'Widget unavailable',
|
unavailable: 'Widget unavailable',
|
||||||
|
|||||||
@@ -748,6 +748,10 @@ export const hy: Translations = {
|
|||||||
customTemplateLabel: 'Հատուկ ձևանմուշ',
|
customTemplateLabel: 'Հատուկ ձևանմուշ',
|
||||||
seoRobotsLabel: 'Robots',
|
seoRobotsLabel: 'Robots',
|
||||||
disabledBadge: 'Անջատված',
|
disabledBadge: 'Անջատված',
|
||||||
|
previewDesktop: 'Դեսքթոփ',
|
||||||
|
previewTablet: 'Պլանշետ',
|
||||||
|
previewMobile: 'Հեռախոս',
|
||||||
|
previewToggle: 'Նախադիտում',
|
||||||
},
|
},
|
||||||
widgets: {
|
widgets: {
|
||||||
unavailable: 'Վիջեթը հասանելի չէ',
|
unavailable: 'Վիջեթը հասանելի չէ',
|
||||||
|
|||||||
@@ -748,6 +748,10 @@ export const ru: Translations = {
|
|||||||
customTemplateLabel: 'Пользовательский шаблон',
|
customTemplateLabel: 'Пользовательский шаблон',
|
||||||
seoRobotsLabel: 'Robots',
|
seoRobotsLabel: 'Robots',
|
||||||
disabledBadge: 'Отключена',
|
disabledBadge: 'Отключена',
|
||||||
|
previewDesktop: 'Десктоп',
|
||||||
|
previewTablet: 'Планшет',
|
||||||
|
previewMobile: 'Телефон',
|
||||||
|
previewToggle: 'Превью',
|
||||||
},
|
},
|
||||||
widgets: {
|
widgets: {
|
||||||
unavailable: 'Виджет недоступен',
|
unavailable: 'Виджет недоступен',
|
||||||
|
|||||||
@@ -746,6 +746,10 @@ export interface Translations {
|
|||||||
customTemplateLabel: string;
|
customTemplateLabel: string;
|
||||||
seoRobotsLabel: string;
|
seoRobotsLabel: string;
|
||||||
disabledBadge: string;
|
disabledBadge: string;
|
||||||
|
previewDesktop: string;
|
||||||
|
previewTablet: string;
|
||||||
|
previewMobile: string;
|
||||||
|
previewToggle: string;
|
||||||
};
|
};
|
||||||
widgets: {
|
widgets: {
|
||||||
unavailable: string;
|
unavailable: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user