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:
sdarbinyan
2026-07-17 10:11:31 +04:00
parent 910690c4d1
commit 35ce9ee78a
9 changed files with 160 additions and 0 deletions

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -78,6 +78,7 @@
<app-button variant="ghost" size="sm" (click)="move(page.id, -1)">&uarr;</app-button>
<app-button variant="ghost" size="sm" (click)="move(page.id, 1)">&darr;</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') {
<app-button variant="secondary" size="sm" (click)="updatePage(page.id, { status: 'draft' })">{{ 'staticPages.unpublishPageAction' | translate }}</app-button>
} @else {
@@ -87,6 +88,10 @@
</div>
</div>
@if (previewOpenPageId() === page.id) {
<app-static-page-preview [html]="previewHtml(page)" [title]="previewTitle(page)" />
}
<div class="editor-grid three">
<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 })" />

View File

@@ -17,6 +17,7 @@ import { SelectComponent, SelectOption } from '../../../shared/ui/select/select.
import { ToggleComponent } from '../../../shared/ui/toggle/toggle.component';
import { MediaPickerComponent } from '../../../shared/media/media-picker/media-picker.component';
import { MediaAsset } from '../../../core/media/models/media-asset.model';
import { StaticPagePreviewComponent } from './static-page-preview/static-page-preview.component';
type StatusFilter = 'all' | ContentPageStatus;
const ALL_LOCALES_FILTER = 'all';
@@ -38,6 +39,7 @@ type MediaField = 'heroImage' | 'thumbnail';
SelectComponent,
ToggleComponent,
MediaPickerComponent,
StaticPagePreviewComponent,
],
templateUrl: './static-pages-editor.component.html',
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);
}
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;
private mediaPickerTarget: { pageId: string; field: MediaField } | null = null;