feat(static-pages): media picker + per-page modified indicator

Milestone 4 of the Static Pages Module sprint.

- heroImage/thumbnail now wire through the existing MediaPickerComponent
  (same media-field-row + "choose image" pattern as branding-section), not
  plain URL text alone. gallery stays a lightweight CSV field ("future
  ready" per the brief - no dedicated multi-upload UI this sprint).
- ProjectEditorFacade: add originalStaticPages, a narrow computed exposing
  the originally loaded/published staticPages snapshot (mirrors the facade's
  existing pattern of small single-purpose computeds).
- StaticPagesEditorComponent: isModified(page) diffs a page against its
  normalized original snapshot, reusing ContentManagementFacade.pages() for
  normalization rather than reimplementing it. Renders as an amber
  "unsaved changes" badge per page.

Draft/published status UI, publish/unpublish actions, and the plain-text
media fields landed already in M2; this milestone completes M4's remaining
scope (visual media picker + modified indicator) without duplicating that
work.

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:05:50 +04:00
parent bfee935798
commit 910690c4d1
3 changed files with 56 additions and 2 deletions

View File

@@ -55,6 +55,9 @@
@if (!page.enabled) {
<app-badge variant="neutral">{{ 'staticPages.disabledBadge' | translate }}</app-badge>
}
@if (isModified(page)) {
<app-badge variant="info">{{ 'builder.unsavedChanges' | translate }}</app-badge>
}
@if (hasDuplicateSlug(page)) {
<app-badge variant="danger">{{ 'staticPages.duplicateSlug' | translate }}</app-badge>
}
@@ -114,10 +117,16 @@
<div class="editor-grid three">
<app-form-field [label]="'staticPages.heroImageLabel' | translate">
<app-input [ngModel]="page.heroImage || ''" (ngModelChange)="updatePage(page.id, { heroImage: $event })" />
<div class="media-field-row">
<app-input [ngModel]="page.heroImage || ''" (ngModelChange)="updatePage(page.id, { heroImage: $event })" />
<app-button variant="secondary" size="sm" (click)="openMediaPicker(page.id, 'heroImage')">{{ 'adminCategories.chooseImage' | translate }}</app-button>
</div>
</app-form-field>
<app-form-field [label]="'staticPages.thumbnailLabel' | translate">
<app-input [ngModel]="page.thumbnail || ''" (ngModelChange)="updatePage(page.id, { thumbnail: $event })" />
<div class="media-field-row">
<app-input [ngModel]="page.thumbnail || ''" (ngModelChange)="updatePage(page.id, { thumbnail: $event })" />
<app-button variant="secondary" size="sm" (click)="openMediaPicker(page.id, 'thumbnail')">{{ 'adminCategories.chooseImage' | translate }}</app-button>
</div>
</app-form-field>
<app-form-field [label]="'staticPages.galleryLabel' | translate">
<app-input [ngModel]="(page.gallery || []).join(', ')" (ngModelChange)="updateGallery(page.id, $event)" />
@@ -177,4 +186,5 @@
</app-card>
}
</div>
<app-media-picker [open]="mediaPickerOpen" (selected)="onImagePicked($event)" (closed)="mediaPickerOpen = false" />
</section>

View File

@@ -3,6 +3,7 @@ import { FormsModule } from '@angular/forms';
import { ProjectEditorFacade } from '../../project-editor/facade/project-editor.facade';
import { ContentManagementFacade } from '../facade/content-management.facade';
import { ContentPage, ContentPageSeoConfig, ContentPageStatus } from '../models/content-page.model';
import { BootstrapConfig } from '../../../shared/models/config';
import { TranslatePipe } from '../../../i18n/translate.pipe';
import { TranslateService } from '../../../i18n/translate.service';
import { MarketplaceHtmlEditorComponent } from '../../project-editor/components/html-editor/marketplace-html-editor.component';
@@ -14,9 +15,12 @@ import { BadgeComponent } from '../../../shared/ui/badge/badge.component';
import { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state.component';
import { SelectComponent, SelectOption } from '../../../shared/ui/select/select.component';
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';
type StatusFilter = 'all' | ContentPageStatus;
const ALL_LOCALES_FILTER = 'all';
type MediaField = 'heroImage' | 'thumbnail';
@Component({
selector: 'app-static-pages-editor',
@@ -33,6 +37,7 @@ const ALL_LOCALES_FILTER = 'all';
EmptyStateComponent,
SelectComponent,
ToggleComponent,
MediaPickerComponent,
],
templateUrl: './static-pages-editor.component.html',
styleUrls: ['../../project-editor/sections/section.shared.scss', './static-pages-editor.component.scss'],
@@ -92,6 +97,43 @@ export class StaticPagesEditorComponent {
return visible.length > 0 && visible.every(page => this.selectedIds().has(page.id));
});
/**
* The originally loaded/published pages, normalized through the same
* ContentManagementFacade.pages() path as the live editor list, keyed by
* id - reused (not reimplemented) for the per-page modified indicator.
*/
private readonly originalPagesById = computed<Map<string, ContentPage>>(() => {
const staticPages = this.projectEditor.originalStaticPages();
if (!staticPages) {
return new Map();
}
const normalized = this.contentFacade.pages({ staticPages } as BootstrapConfig);
return new Map(normalized.map(page => [page.id, page]));
});
isModified(page: ContentPage): boolean {
const original = this.originalPagesById().get(page.id);
if (!original) {
return true; // a page that didn't exist in the original snapshot is new/modified.
}
return JSON.stringify(page) !== JSON.stringify(original);
}
protected mediaPickerOpen = false;
private mediaPickerTarget: { pageId: string; field: MediaField } | null = null;
openMediaPicker(pageId: string, field: MediaField): void {
this.mediaPickerTarget = { pageId, field };
this.mediaPickerOpen = true;
}
onImagePicked(asset: MediaAsset): void {
if (this.mediaPickerTarget) {
this.updatePage(this.mediaPickerTarget.pageId, { [this.mediaPickerTarget.field]: asset.url });
}
this.mediaPickerOpen = false;
}
createPage(): void {
const slug = `custom-page-${this.allPages().length + 1}`;
const page: ContentPage = {

View File

@@ -63,6 +63,8 @@ export class ProjectEditorFacade {
readonly lastSavedAt = computed(() => this.state().lastSavedAt);
readonly lastPublishedAt = computed(() => this.state().lastPublishedAt);
readonly draftRestored = computed(() => this.state().draftRestored);
/** The originally loaded/published `staticPages` snapshot, for per-page modified-state comparison in the Static Pages editor. */
readonly originalStaticPages = computed(() => this.state().originalBootstrap?.staticPages ?? null);
readonly validationIssues = computed(() => {
const current = this.bootstrap();
return current ? this.validator.validate(current) : [];