feat(sprint18): editor autosave/reset, admin auth, QR reuse, Ed25519 prep
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
- Project editor: persist draft to localStorage, restore on reload, last-saved/draft-restored status indicators, section/whole-draft reset with confirmation. - Extract shared QR/polling/expiry engine from TelegramLoginComponent (shared/qr-login) and reuse it for a new admin login flow. - Admin authentication kept fully separate from customer session: own cookie/localStorage keys, signals, guard, and header interceptor (core/admin-auth). - ?login=true / ?adminLogin=true open the respective login dialog for manual testing. - Ed25519 challenge/verify interfaces (fail-closed no-op binding) ready for backend delivery. - Document autosave/reset/admin-auth/QR-reuse/Ed25519 model and the remaining full-field-coverage gap in docs/Project-Editor.md.
This commit is contained in:
@@ -1,8 +1,16 @@
|
||||
<div class="project-editor-save-bar">
|
||||
@if (draftRestored()) {
|
||||
<div class="project-editor-save-bar-notice">
|
||||
<span>{{ 'builder.draftRestored' | translate }}</span>
|
||||
<button type="button" (click)="dismissDraftRestoredNotice()">{{ 'builder.dismiss' | translate }}</button>
|
||||
</div>
|
||||
}
|
||||
<div class="project-editor-save-bar-status">
|
||||
<span>{{ (status() === 'published' ? 'builder.statusPublished' : 'builder.statusDraft') | translate }}</span>
|
||||
@if (dirty()) {
|
||||
<span class="project-editor-save-bar-dirty">{{ 'builder.unsavedChanges' | translate }}</span>
|
||||
} @else if (lastSavedAt()) {
|
||||
<span class="project-editor-save-bar-saved-at">{{ 'builder.lastSaved' | translate }}: {{ formatSavedAt(lastSavedAt()!) }}</span>
|
||||
}
|
||||
@if (issues().length > 0) {
|
||||
<ul class="project-editor-save-bar-issues">
|
||||
@@ -13,6 +21,7 @@
|
||||
}
|
||||
</div>
|
||||
<div class="project-editor-save-bar-actions">
|
||||
<button type="button" class="project-editor-save-bar-reset" (click)="resetDraft()">{{ 'builder.resetDraft' | translate }}</button>
|
||||
<button type="button" (click)="save()">{{ 'builder.save' | translate }}</button>
|
||||
<button type="button" [disabled]="issues().length > 0" (click)="publish()">{{ 'builder.publish' | translate }}</button>
|
||||
</div>
|
||||
|
||||
@@ -25,3 +25,27 @@
|
||||
.project-editor-save-bar-actions button + button {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.project-editor-save-bar-saved-at {
|
||||
color: var(--muted-foreground, #6b7280);
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.project-editor-save-bar-notice {
|
||||
position: absolute;
|
||||
top: -2.5rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--info-bg, #eff6ff);
|
||||
color: var(--info, #1d4ed8);
|
||||
border-bottom: 1px solid var(--border, #ddd);
|
||||
}
|
||||
|
||||
.project-editor-save-bar-reset {
|
||||
color: var(--danger, #b91c1c);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../../../i18n/translate.service';
|
||||
import { ProjectEditorFacade } from '../../facade/project-editor.facade';
|
||||
|
||||
@Component({
|
||||
@@ -12,9 +13,12 @@ import { ProjectEditorFacade } from '../../facade/project-editor.facade';
|
||||
})
|
||||
export class ProjectEditorSaveBarComponent {
|
||||
private readonly facade = inject(ProjectEditorFacade);
|
||||
private readonly translate = inject(TranslateService);
|
||||
readonly dirty = this.facade.dirty;
|
||||
readonly status = this.facade.status;
|
||||
readonly issues = this.facade.validationIssues;
|
||||
readonly lastSavedAt = this.facade.lastSavedAt;
|
||||
readonly draftRestored = this.facade.draftRestored;
|
||||
|
||||
save(): void {
|
||||
this.facade.save();
|
||||
@@ -23,4 +27,18 @@ export class ProjectEditorSaveBarComponent {
|
||||
publish(): void {
|
||||
this.facade.publish();
|
||||
}
|
||||
|
||||
resetDraft(): void {
|
||||
if (confirm(this.translate.t('builder.confirmResetDraft'))) {
|
||||
this.facade.resetDraft();
|
||||
}
|
||||
}
|
||||
|
||||
formatSavedAt(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleTimeString();
|
||||
}
|
||||
|
||||
dismissDraftRestoredNotice(): void {
|
||||
this.facade.dismissDraftRestoredNotice();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { LocaleSyncService } from '../services/locale-sync.service';
|
||||
import { ProjectEditorState } from '../models/project-editor.model';
|
||||
import { PlatformRuntimeService } from '../../../core/runtime/platform-runtime.service';
|
||||
import { ProjectValidator } from '../services/project-validator.service';
|
||||
import { ProjectEditorDraftStorageService } from '../services/project-editor-draft-storage.service';
|
||||
import { EDITOR_SECTION_BOOTSTRAP_KEYS } from '../models/project-editor.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProjectEditorFacade {
|
||||
@@ -18,13 +20,17 @@ export class ProjectEditorFacade {
|
||||
private readonly localeSync = inject(LocaleSyncService);
|
||||
private readonly runtime = inject(PlatformRuntimeService);
|
||||
private readonly validator = inject(ProjectValidator);
|
||||
private readonly draftStorage = inject(ProjectEditorDraftStorageService);
|
||||
|
||||
private readonly state = signal<ProjectEditorState>({
|
||||
bootstrap: null,
|
||||
originalBootstrap: null,
|
||||
importError: null,
|
||||
activeSection: 'general',
|
||||
status: 'draft',
|
||||
lastSavedBootstrap: null,
|
||||
lastSavedAt: null,
|
||||
draftRestored: false,
|
||||
});
|
||||
|
||||
readonly bootstrap = computed(() => this.state().bootstrap);
|
||||
@@ -33,6 +39,8 @@ export class ProjectEditorFacade {
|
||||
readonly homepagePage = computed(() => this.bootstrap()?.pages.find(page => page.key === 'home' || page.route.path === '/') ?? null);
|
||||
readonly homepageWidgets = computed(() => this.homepagePage()?.sections.flatMap(section => section.widgets.map(widget => ({ sectionId: section.id, sectionType: section.type, widget }))) ?? []);
|
||||
readonly status = computed(() => this.state().status);
|
||||
readonly lastSavedAt = computed(() => this.state().lastSavedAt);
|
||||
readonly draftRestored = computed(() => this.state().draftRestored);
|
||||
readonly validationIssues = computed(() => {
|
||||
const current = this.bootstrap();
|
||||
return current ? this.validator.validate(current) : [];
|
||||
@@ -49,7 +57,19 @@ export class ProjectEditorFacade {
|
||||
this.configService.loadBootstrap(true).pipe(take(1)).subscribe({
|
||||
next: config => {
|
||||
const normalized = this.normalize(JSON.parse(JSON.stringify(config)) as BootstrapConfig);
|
||||
this.state.update(current => ({ ...current, bootstrap: normalized, importError: null, lastSavedBootstrap: normalized, status: 'draft' }));
|
||||
const storedDraft = this.draftStorage.load(normalized.tenant.id);
|
||||
const restoredFromDraft = storedDraft !== null;
|
||||
const bootstrap = restoredFromDraft ? this.normalize(storedDraft!.bootstrap) : normalized;
|
||||
this.state.update(current => ({
|
||||
...current,
|
||||
bootstrap,
|
||||
originalBootstrap: normalized,
|
||||
importError: null,
|
||||
lastSavedBootstrap: normalized,
|
||||
lastSavedAt: restoredFromDraft ? storedDraft!.savedAt : null,
|
||||
status: 'draft',
|
||||
draftRestored: restoredFromDraft,
|
||||
}));
|
||||
},
|
||||
error: () => this.state.update(current => ({ ...current, bootstrap: null, importError: 'builder.importError' })),
|
||||
});
|
||||
@@ -61,10 +81,13 @@ export class ProjectEditorFacade {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.update(state => ({
|
||||
...state,
|
||||
bootstrap: this.normalize(updater(JSON.parse(JSON.stringify(current)) as BootstrapConfig)),
|
||||
}));
|
||||
const next = this.normalize(updater(JSON.parse(JSON.stringify(current)) as BootstrapConfig));
|
||||
this.state.update(state => ({ ...state, bootstrap: next, draftRestored: false }));
|
||||
this.draftStorage.save(next);
|
||||
}
|
||||
|
||||
dismissDraftRestoredNotice(): void {
|
||||
this.state.update(current => ({ ...current, draftRestored: false }));
|
||||
}
|
||||
|
||||
exportBootstrap(): string {
|
||||
@@ -115,7 +138,35 @@ export class ProjectEditorFacade {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
this.state.update(state => ({ ...state, lastSavedBootstrap: JSON.parse(JSON.stringify(current)) }));
|
||||
const savedAt = this.draftStorage.save(current);
|
||||
this.state.update(state => ({ ...state, lastSavedBootstrap: JSON.parse(JSON.stringify(current)), lastSavedAt: savedAt }));
|
||||
}
|
||||
|
||||
/** Reverts one section's bootstrap keys to the originally loaded/published snapshot. Caller is responsible for confirmation UX. */
|
||||
resetSection(sectionId: ProjectEditorState['activeSection']): void {
|
||||
const original = this.state().originalBootstrap;
|
||||
const keys = EDITOR_SECTION_BOOTSTRAP_KEYS[sectionId];
|
||||
if (!original || !keys) {
|
||||
return;
|
||||
}
|
||||
this.updateBootstrap(current => {
|
||||
const patch: Partial<BootstrapConfig> = {};
|
||||
for (const key of keys) {
|
||||
(patch as Record<string, unknown>)[key] = JSON.parse(JSON.stringify(original[key]));
|
||||
}
|
||||
return { ...current, ...patch };
|
||||
});
|
||||
}
|
||||
|
||||
/** Discards the entire draft, reverting to the originally loaded/published bootstrap. Caller is responsible for confirmation UX. */
|
||||
resetDraft(): void {
|
||||
const original = this.state().originalBootstrap;
|
||||
if (!original) {
|
||||
return;
|
||||
}
|
||||
const reset = this.normalize(JSON.parse(JSON.stringify(original)) as BootstrapConfig);
|
||||
this.draftStorage.clear();
|
||||
this.state.update(state => ({ ...state, bootstrap: reset, lastSavedAt: null, draftRestored: false }));
|
||||
}
|
||||
|
||||
publish(): boolean {
|
||||
@@ -124,10 +175,13 @@ export class ProjectEditorFacade {
|
||||
return false;
|
||||
}
|
||||
this.runtime.reloadFromBootstrap(current);
|
||||
const savedAt = this.draftStorage.save(current);
|
||||
this.state.update(state => ({
|
||||
...state,
|
||||
status: 'published',
|
||||
lastSavedBootstrap: JSON.parse(JSON.stringify(current)),
|
||||
originalBootstrap: JSON.parse(JSON.stringify(current)),
|
||||
lastSavedAt: savedAt,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -16,12 +16,28 @@ export type ProjectEditorSectionId =
|
||||
|
||||
export interface ProjectEditorState {
|
||||
bootstrap: BootstrapConfig | null;
|
||||
originalBootstrap: BootstrapConfig | null;
|
||||
importError: string | null;
|
||||
activeSection: ProjectEditorSectionId;
|
||||
status: 'draft' | 'published';
|
||||
lastSavedBootstrap: BootstrapConfig | null;
|
||||
lastSavedAt: number | null;
|
||||
draftRestored: boolean;
|
||||
}
|
||||
|
||||
export const EDITOR_SECTION_BOOTSTRAP_KEYS: Partial<Record<ProjectEditorSectionId, (keyof BootstrapConfig)[]>> = {
|
||||
general: ['tenant', 'seo'],
|
||||
branding: ['branding'],
|
||||
theme: ['theme'],
|
||||
header: ['header'],
|
||||
footer: ['footer', 'company'],
|
||||
homepage: ['pages'],
|
||||
widgets: ['pages'],
|
||||
features: ['featureFlags', 'userExperience', 'catalog', 'productPage'],
|
||||
languages: ['localization'],
|
||||
navigation: ['navigation'],
|
||||
};
|
||||
|
||||
export interface ProjectEditorWidgetPreset {
|
||||
id: string;
|
||||
type: string;
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
</section>
|
||||
} @else {
|
||||
<section class="project-editor-stack">
|
||||
@if (canResetActiveSection()) {
|
||||
<div class="project-editor-section-actions">
|
||||
<button type="button" (click)="resetActiveSection()">{{ 'builder.resetSection' | translate }}</button>
|
||||
</div>
|
||||
}
|
||||
@switch (activeSection()) {
|
||||
@case ('general') { <app-project-editor-general-section /> }
|
||||
@case ('branding') { <app-project-editor-branding-section /> }
|
||||
|
||||
@@ -25,6 +25,20 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.project-editor-section-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.project-editor-section-actions button {
|
||||
color: #b91c1c;
|
||||
background: transparent;
|
||||
border: 1px solid #d3dad9;
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-editor-empty {
|
||||
min-height: 240px;
|
||||
display: grid;
|
||||
|
||||
@@ -16,9 +16,10 @@ import { ProjectEditorLanguagesSectionComponent } from '../sections/languages-se
|
||||
import { ProjectEditorNavigationSectionComponent } from '../sections/navigation-section.component';
|
||||
import { ProjectEditorPreviewSectionComponent } from '../sections/preview-section.component';
|
||||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../../i18n/translate.service';
|
||||
import { StaticPagesEditorComponent } from '../../content-management/components/static-pages-editor.component';
|
||||
import { ProjectEditorSaveBarComponent } from '../components/save-bar/project-editor-save-bar.component';
|
||||
import { ProjectEditorSectionId } from '../models/project-editor.model';
|
||||
import { EDITOR_SECTION_BOOTSTRAP_KEYS, ProjectEditorSectionId } from '../models/project-editor.model';
|
||||
|
||||
const KNOWN_SECTIONS: ProjectEditorSectionId[] = [
|
||||
'general', 'branding', 'theme', 'header', 'footer', 'homepage', 'widgets', 'static-pages', 'features', 'languages', 'navigation', 'preview'
|
||||
@@ -51,8 +52,10 @@ const KNOWN_SECTIONS: ProjectEditorSectionId[] = [
|
||||
export class ProjectEditorPageComponent {
|
||||
readonly facade = inject(ProjectEditorFacade);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly translate = inject(TranslateService);
|
||||
readonly bootstrap = this.facade.bootstrap;
|
||||
readonly activeSection = this.facade.activeSection;
|
||||
readonly canResetActiveSection = () => !!EDITOR_SECTION_BOOTSTRAP_KEYS[this.activeSection()];
|
||||
|
||||
private readonly routeSection = toSignal(
|
||||
this.route.paramMap.pipe(map(params => params.get('section') as ProjectEditorSectionId | null)),
|
||||
@@ -69,6 +72,12 @@ export class ProjectEditorPageComponent {
|
||||
});
|
||||
}
|
||||
|
||||
resetActiveSection(): void {
|
||||
if (confirm(this.translate.t('builder.confirmResetSection'))) {
|
||||
this.facade.resetSection(this.activeSection());
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('window:beforeunload', ['$event'])
|
||||
warnBeforeUnload(event: BeforeUnloadEvent): void {
|
||||
if (this.facade.dirty()) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BootstrapConfig } from '../../../shared/models/config';
|
||||
|
||||
const DRAFT_STORAGE_KEY = 'projectEditor.draftBootstrap.v1';
|
||||
const DRAFT_SAVED_AT_KEY = 'projectEditor.draftSavedAt.v1';
|
||||
|
||||
interface StoredDraft {
|
||||
tenantId: string;
|
||||
bootstrap: BootstrapConfig;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProjectEditorDraftStorageService {
|
||||
save(bootstrap: BootstrapConfig): number {
|
||||
const savedAt = Date.now();
|
||||
const payload: StoredDraft = { tenantId: bootstrap.tenant.id, bootstrap, savedAt };
|
||||
try {
|
||||
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(payload));
|
||||
localStorage.setItem(DRAFT_SAVED_AT_KEY, String(savedAt));
|
||||
} catch {
|
||||
// storage unavailable (private mode / quota) - draft simply won't persist
|
||||
}
|
||||
return savedAt;
|
||||
}
|
||||
|
||||
load(tenantId: string): { bootstrap: BootstrapConfig; savedAt: number } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(DRAFT_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as StoredDraft;
|
||||
if (parsed.tenantId !== tenantId) {
|
||||
return null;
|
||||
}
|
||||
return { bootstrap: parsed.bootstrap, savedAt: parsed.savedAt };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
try {
|
||||
localStorage.removeItem(DRAFT_STORAGE_KEY);
|
||||
localStorage.removeItem(DRAFT_SAVED_AT_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user