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="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 })" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -748,6 +748,10 @@ export const en: Translations = {
|
||||
customTemplateLabel: 'Custom template',
|
||||
seoRobotsLabel: 'Robots',
|
||||
disabledBadge: 'Disabled',
|
||||
previewDesktop: 'Desktop',
|
||||
previewTablet: 'Tablet',
|
||||
previewMobile: 'Mobile',
|
||||
previewToggle: 'Preview',
|
||||
},
|
||||
widgets: {
|
||||
unavailable: 'Widget unavailable',
|
||||
|
||||
@@ -748,6 +748,10 @@ export const hy: Translations = {
|
||||
customTemplateLabel: 'Հատուկ ձևանմուշ',
|
||||
seoRobotsLabel: 'Robots',
|
||||
disabledBadge: 'Անջատված',
|
||||
previewDesktop: 'Դեսքթոփ',
|
||||
previewTablet: 'Պլանշետ',
|
||||
previewMobile: 'Հեռախոս',
|
||||
previewToggle: 'Նախադիտում',
|
||||
},
|
||||
widgets: {
|
||||
unavailable: 'Վիջեթը հասանելի չէ',
|
||||
|
||||
@@ -748,6 +748,10 @@ export const ru: Translations = {
|
||||
customTemplateLabel: 'Пользовательский шаблон',
|
||||
seoRobotsLabel: 'Robots',
|
||||
disabledBadge: 'Отключена',
|
||||
previewDesktop: 'Десктоп',
|
||||
previewTablet: 'Планшет',
|
||||
previewMobile: 'Телефон',
|
||||
previewToggle: 'Превью',
|
||||
},
|
||||
widgets: {
|
||||
unavailable: 'Виджет недоступен',
|
||||
|
||||
@@ -746,6 +746,10 @@ export interface Translations {
|
||||
customTemplateLabel: string;
|
||||
seoRobotsLabel: string;
|
||||
disabledBadge: string;
|
||||
previewDesktop: string;
|
||||
previewTablet: string;
|
||||
previewMobile: string;
|
||||
previewToggle: string;
|
||||
};
|
||||
widgets: {
|
||||
unavailable: string;
|
||||
|
||||
Reference in New Issue
Block a user