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:
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) : [];
|
||||
|
||||
Reference in New Issue
Block a user