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) {
|
@if (!page.enabled) {
|
||||||
<app-badge variant="neutral">{{ 'staticPages.disabledBadge' | translate }}</app-badge>
|
<app-badge variant="neutral">{{ 'staticPages.disabledBadge' | translate }}</app-badge>
|
||||||
}
|
}
|
||||||
|
@if (isModified(page)) {
|
||||||
|
<app-badge variant="info">{{ 'builder.unsavedChanges' | translate }}</app-badge>
|
||||||
|
}
|
||||||
@if (hasDuplicateSlug(page)) {
|
@if (hasDuplicateSlug(page)) {
|
||||||
<app-badge variant="danger">{{ 'staticPages.duplicateSlug' | translate }}</app-badge>
|
<app-badge variant="danger">{{ 'staticPages.duplicateSlug' | translate }}</app-badge>
|
||||||
}
|
}
|
||||||
@@ -114,10 +117,16 @@
|
|||||||
|
|
||||||
<div class="editor-grid three">
|
<div class="editor-grid three">
|
||||||
<app-form-field [label]="'staticPages.heroImageLabel' | translate">
|
<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>
|
||||||
<app-form-field [label]="'staticPages.thumbnailLabel' | translate">
|
<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>
|
||||||
<app-form-field [label]="'staticPages.galleryLabel' | translate">
|
<app-form-field [label]="'staticPages.galleryLabel' | translate">
|
||||||
<app-input [ngModel]="(page.gallery || []).join(', ')" (ngModelChange)="updateGallery(page.id, $event)" />
|
<app-input [ngModel]="(page.gallery || []).join(', ')" (ngModelChange)="updateGallery(page.id, $event)" />
|
||||||
@@ -177,4 +186,5 @@
|
|||||||
</app-card>
|
</app-card>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<app-media-picker [open]="mediaPickerOpen" (selected)="onImagePicked($event)" (closed)="mediaPickerOpen = false" />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { ProjectEditorFacade } from '../../project-editor/facade/project-editor.facade';
|
import { ProjectEditorFacade } from '../../project-editor/facade/project-editor.facade';
|
||||||
import { ContentManagementFacade } from '../facade/content-management.facade';
|
import { ContentManagementFacade } from '../facade/content-management.facade';
|
||||||
import { ContentPage, ContentPageSeoConfig, ContentPageStatus } from '../models/content-page.model';
|
import { ContentPage, ContentPageSeoConfig, ContentPageStatus } from '../models/content-page.model';
|
||||||
|
import { BootstrapConfig } from '../../../shared/models/config';
|
||||||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||||
import { TranslateService } from '../../../i18n/translate.service';
|
import { TranslateService } from '../../../i18n/translate.service';
|
||||||
import { MarketplaceHtmlEditorComponent } from '../../project-editor/components/html-editor/marketplace-html-editor.component';
|
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 { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state.component';
|
||||||
import { SelectComponent, SelectOption } from '../../../shared/ui/select/select.component';
|
import { SelectComponent, SelectOption } from '../../../shared/ui/select/select.component';
|
||||||
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 { MediaAsset } from '../../../core/media/models/media-asset.model';
|
||||||
|
|
||||||
type StatusFilter = 'all' | ContentPageStatus;
|
type StatusFilter = 'all' | ContentPageStatus;
|
||||||
const ALL_LOCALES_FILTER = 'all';
|
const ALL_LOCALES_FILTER = 'all';
|
||||||
|
type MediaField = 'heroImage' | 'thumbnail';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-static-pages-editor',
|
selector: 'app-static-pages-editor',
|
||||||
@@ -33,6 +37,7 @@ const ALL_LOCALES_FILTER = 'all';
|
|||||||
EmptyStateComponent,
|
EmptyStateComponent,
|
||||||
SelectComponent,
|
SelectComponent,
|
||||||
ToggleComponent,
|
ToggleComponent,
|
||||||
|
MediaPickerComponent,
|
||||||
],
|
],
|
||||||
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'],
|
||||||
@@ -92,6 +97,43 @@ export class StaticPagesEditorComponent {
|
|||||||
return visible.length > 0 && visible.every(page => this.selectedIds().has(page.id));
|
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 {
|
createPage(): void {
|
||||||
const slug = `custom-page-${this.allPages().length + 1}`;
|
const slug = `custom-page-${this.allPages().length + 1}`;
|
||||||
const page: ContentPage = {
|
const page: ContentPage = {
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export class ProjectEditorFacade {
|
|||||||
readonly lastSavedAt = computed(() => this.state().lastSavedAt);
|
readonly lastSavedAt = computed(() => this.state().lastSavedAt);
|
||||||
readonly lastPublishedAt = computed(() => this.state().lastPublishedAt);
|
readonly lastPublishedAt = computed(() => this.state().lastPublishedAt);
|
||||||
readonly draftRestored = computed(() => this.state().draftRestored);
|
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(() => {
|
readonly validationIssues = computed(() => {
|
||||||
const current = this.bootstrap();
|
const current = this.bootstrap();
|
||||||
return current ? this.validator.validate(current) : [];
|
return current ? this.validator.validate(current) : [];
|
||||||
|
|||||||
Reference in New Issue
Block a user