11-task plan extending the existing /builder editor: route-driven tabs, generic Languages/Navigation tabs, a dependency-free rich HTML editor, client-side draft/publish + validation, and a dirty-state guard. Notes the repo has no test runner configured, so tasks use manual verification instead of automated specs.
1818 lines
73 KiB
Markdown
1818 lines
73 KiB
Markdown
# Sprint 16 — Marketplace Project Editor MVP Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Extend the existing `/builder` Project Editor into a routed, deep-linkable editor (`/edit/...`) with Languages, Navigation, a real HTML editor, client-side draft/publish, validation, and dirty-state protection — all still editing the same `BootstrapConfig` the storefront consumes.
|
||
|
||
**Architecture:** Keep `ProjectEditorFacade` (Angular signals, no NgRx) as the single source of truth. Add new signal-backed facade actions and two new pure services (`LocaleSyncService`, `ProjectValidator`). Add new tab components following the existing `sections/*-section.component.ts` pattern. No new state-management library, no new npm dependency.
|
||
|
||
**Tech Stack:** Angular 21.1.5 standalone components, Signals, `@angular/forms` (`FormsModule` + `ngModel`), `@angular/router`, existing `i18n/translate.pipe` + `translations.ts`/`en.ts`/`ru.ts`/`hy.ts` dictionaries.
|
||
|
||
## Global Constraints
|
||
|
||
- Angular is actually **21.1.5** in `package.json`, not 20 as the platform vision doc says — this plan targets the real installed version; syntax used (`@if`/`@for`/`@switch`, signals) is valid in both.
|
||
- **No test runner is configured in this repo** (no karma/jasmine/jest in `package.json`, zero `*.spec.ts` files exist). Writing Jasmine/Karma spec files here would not run under any configured tool and would be a placeholder. Every task below substitutes a **manual verification procedure** (exact `ng serve` steps, exact clicks/values, exact expected result) for an automated test step. Standing up a real test harness is out of scope for this plan — flagged as a follow-up, not silently skipped.
|
||
- State management: signal-based facade + `computed`, no NgRx, matching every existing feature (`ProjectEditorFacade`, `AdminProductsFacade`).
|
||
- Every new user-facing string goes through `TranslatePipe` (`{{ 'builder.x' | translate }}`) and is added to `src/app/i18n/translations.ts` (interface) + `en.ts` + `ru.ts` + `hy.ts` (implementations) in the same task that introduces it.
|
||
- New standalone components go in `src/app/features/project-editor/{sections,components,services,models,guards}/` following existing naming (`*-section.component.ts` for tabs).
|
||
- No new npm dependencies are introduced by this plan.
|
||
- `BootstrapConfig` (`src/app/shared/models/config/bootstrap-config.model.ts`) remains the only configuration model. No parallel `ProjectConfig`/`EditorConfig` type is created anywhere in this plan.
|
||
- There is no `projectId` concept anywhere — a "project" is the tenant resolved from the current domain. No route or model in this plan introduces an id segment.
|
||
|
||
---
|
||
|
||
### Task 1: Route-driven tabs (`/edit/:section`)
|
||
|
||
**Files:**
|
||
- Modify: `src/app/app.routes.ts:42-50`
|
||
- Modify: `src/app/features/project-editor/pages/project-editor-page.component.ts`
|
||
- Modify: `src/app/features/project-editor/components/project-editor-nav.component.ts`
|
||
- Modify: `src/app/features/project-editor/components/project-editor-nav.component.html`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ProjectEditorFacade.setActiveSection(id: ProjectEditorSectionId)`, `ProjectEditorFacade.activeSection: Signal<ProjectEditorSectionId>` (both already exist, unchanged).
|
||
- Produces: route path `/edit/:section` that every later task's new tab reuses (Languages → `/edit/languages`, Navigation → `/edit/navigation`).
|
||
|
||
- [ ] **Step 1: Update routes**
|
||
|
||
Replace lines 42-50 of `src/app/app.routes.ts`:
|
||
|
||
```typescript
|
||
{
|
||
path: 'edit/:section',
|
||
loadComponent: () => import('./features/project-editor/pages/project-editor-page.component').then(m => m.ProjectEditorPageComponent)
|
||
},
|
||
{
|
||
path: 'edit',
|
||
redirectTo: 'edit/general',
|
||
pathMatch: 'full'
|
||
},
|
||
{
|
||
path: 'builder',
|
||
redirectTo: 'edit/general',
|
||
pathMatch: 'full'
|
||
},
|
||
{
|
||
path: 'project-editor',
|
||
redirectTo: 'edit/general',
|
||
pathMatch: 'full'
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 2: Read the route param in the page component**
|
||
|
||
Replace the full contents of `src/app/features/project-editor/pages/project-editor-page.component.ts`:
|
||
|
||
```typescript
|
||
import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core';
|
||
import { ActivatedRoute } from '@angular/router';
|
||
import { toSignal } from '@angular/core/rxjs-interop';
|
||
import { map } from 'rxjs/operators';
|
||
import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
||
import { ProjectEditorNavComponent } from '../components/project-editor-nav.component';
|
||
import { ProjectEditorGeneralSectionComponent } from '../sections/general-section.component';
|
||
import { ProjectEditorBrandingSectionComponent } from '../sections/branding-section.component';
|
||
import { ProjectEditorThemeSectionComponent } from '../sections/theme-section.component';
|
||
import { ProjectEditorHeaderSectionComponent } from '../sections/header-section.component';
|
||
import { ProjectEditorFooterSectionComponent } from '../sections/footer-section.component';
|
||
import { ProjectEditorHomepageSectionComponent } from '../sections/homepage-section.component';
|
||
import { ProjectEditorWidgetsSectionComponent } from '../sections/widgets-section.component';
|
||
import { ProjectEditorFeaturesSectionComponent } from '../sections/features-section.component';
|
||
import { ProjectEditorPreviewSectionComponent } from '../sections/preview-section.component';
|
||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||
import { StaticPagesEditorComponent } from '../../content-management/components/static-pages-editor.component';
|
||
import { ProjectEditorSectionId } from '../models/project-editor.model';
|
||
|
||
const KNOWN_SECTIONS: ProjectEditorSectionId[] = [
|
||
'general', 'branding', 'theme', 'header', 'footer', 'homepage', 'widgets', 'static-pages', 'features', 'preview'
|
||
];
|
||
|
||
@Component({
|
||
selector: 'app-project-editor-page',
|
||
standalone: true,
|
||
imports: [
|
||
TranslatePipe,
|
||
ProjectEditorNavComponent,
|
||
ProjectEditorGeneralSectionComponent,
|
||
ProjectEditorBrandingSectionComponent,
|
||
ProjectEditorThemeSectionComponent,
|
||
ProjectEditorHeaderSectionComponent,
|
||
ProjectEditorFooterSectionComponent,
|
||
ProjectEditorHomepageSectionComponent,
|
||
ProjectEditorWidgetsSectionComponent,
|
||
StaticPagesEditorComponent,
|
||
ProjectEditorFeaturesSectionComponent,
|
||
ProjectEditorPreviewSectionComponent,
|
||
],
|
||
templateUrl: './project-editor-page.component.html',
|
||
styleUrls: ['./project-editor-page.component.scss'],
|
||
changeDetection: ChangeDetectionStrategy.OnPush
|
||
})
|
||
export class ProjectEditorPageComponent {
|
||
readonly facade = inject(ProjectEditorFacade);
|
||
private readonly route = inject(ActivatedRoute);
|
||
readonly bootstrap = this.facade.bootstrap;
|
||
readonly activeSection = this.facade.activeSection;
|
||
|
||
private readonly routeSection = toSignal(
|
||
this.route.paramMap.pipe(map(params => params.get('section') as ProjectEditorSectionId | null)),
|
||
{ initialValue: null }
|
||
);
|
||
|
||
constructor() {
|
||
this.facade.loadBootstrap();
|
||
effect(() => {
|
||
const section = this.routeSection();
|
||
if (section && KNOWN_SECTIONS.includes(section)) {
|
||
this.facade.setActiveSection(section);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Make the nav a real router link**
|
||
|
||
Replace `src/app/features/project-editor/components/project-editor-nav.component.ts`:
|
||
|
||
```typescript
|
||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||
import { ProjectEditorSectionId } from '../models/project-editor.model';
|
||
|
||
@Component({
|
||
selector: 'app-project-editor-nav',
|
||
standalone: true,
|
||
imports: [TranslatePipe, RouterLink, RouterLinkActive],
|
||
templateUrl: './project-editor-nav.component.html',
|
||
styleUrls: ['./project-editor-nav.component.scss'],
|
||
changeDetection: ChangeDetectionStrategy.OnPush
|
||
})
|
||
export class ProjectEditorNavComponent {
|
||
readonly sections: Array<{ id: ProjectEditorSectionId; label: string }> = [
|
||
{ id: 'general', label: 'builder.general' },
|
||
{ id: 'branding', label: 'builder.branding' },
|
||
{ id: 'theme', label: 'builder.theme' },
|
||
{ id: 'header', label: 'builder.header' },
|
||
{ id: 'footer', label: 'builder.footer' },
|
||
{ id: 'homepage', label: 'builder.homepage' },
|
||
{ id: 'widgets', label: 'builder.widgets' },
|
||
{ id: 'static-pages', label: 'builder.staticPages' },
|
||
{ id: 'features', label: 'builder.marketplaceFeatures' },
|
||
{ id: 'preview', label: 'builder.preview' },
|
||
];
|
||
}
|
||
```
|
||
|
||
Replace `src/app/features/project-editor/components/project-editor-nav.component.html`:
|
||
|
||
```html
|
||
<nav class="editor-nav" [attr.aria-label]="'builder.title' | translate">
|
||
@for (section of sections; track section.id) {
|
||
<a
|
||
[routerLink]="['/edit', section.id]"
|
||
routerLinkActive="active"
|
||
class="editor-nav-link"
|
||
>{{ section.label | translate }}</a>
|
||
}
|
||
</nav>
|
||
```
|
||
|
||
Add to `src/app/features/project-editor/components/project-editor-nav.component.scss` (append; file already styles `.editor-nav` and buttons — add a matching anchor rule so links look identical to the old buttons):
|
||
|
||
```scss
|
||
.editor-nav-link {
|
||
text-decoration: none;
|
||
display: inline-block;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Manual verification**
|
||
|
||
Run `npx ng serve` (or the project's `npm run start`), open `http://localhost:4200/builder` — confirm it redirects to `/edit/general` and the General tab renders. Click each nav link; confirm the URL changes to `/edit/<section>` for each and the correct tab content shows. Reload the browser on `/edit/footer` directly; confirm the Footer tab renders (not General) — this proves the route param, not just the click, drives the active tab.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/app/app.routes.ts src/app/features/project-editor/pages/project-editor-page.component.ts src/app/features/project-editor/components/project-editor-nav.component.ts src/app/features/project-editor/components/project-editor-nav.component.html src/app/features/project-editor/components/project-editor-nav.component.scss
|
||
git commit -m "feat(project-editor): route-driven tabs under /edit/:section"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Languages tab + generic locale sync
|
||
|
||
**Files:**
|
||
- Create: `src/app/features/project-editor/services/locale-sync.service.ts`
|
||
- Create: `src/app/features/project-editor/sections/languages-section.component.ts`
|
||
- Create: `src/app/features/project-editor/sections/languages-section.component.html`
|
||
- Modify: `src/app/features/project-editor/models/project-editor.model.ts`
|
||
- Modify: `src/app/features/project-editor/facade/project-editor.facade.ts`
|
||
- Modify: `src/app/features/project-editor/pages/project-editor-page.component.ts`
|
||
- Modify: `src/app/features/project-editor/pages/project-editor-page.component.html`
|
||
- Modify: `src/app/features/project-editor/components/project-editor-nav.component.ts`
|
||
- Modify: `src/app/i18n/translations.ts`, `en.ts`, `ru.ts`, `hy.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `NavigationItemConfig`, `NavigationLocalizedText`, `FooterNavigationGroupConfig`, `BootstrapConfig` from `src/app/shared/models/config`; `StaticPageTranslationConfig`, `LocalizedTextContent` likewise.
|
||
- Produces: `LocaleSyncService.addLocale(bootstrap, locale): BootstrapConfig`, `LocaleSyncService.removeLocale(bootstrap, locale): BootstrapConfig`; `ProjectEditorFacade.addLocale(locale: string)`, `.removeLocale(locale: string)`, `.setDefaultLocale(locale: string)` — used by Task 3 (static pages) and Task 4 (navigation) indirectly, and directly by the new Languages tab.
|
||
|
||
- [ ] **Step 1: `LocaleSyncService`**
|
||
|
||
Create `src/app/features/project-editor/services/locale-sync.service.ts`:
|
||
|
||
```typescript
|
||
import { Injectable } from '@angular/core';
|
||
import { BootstrapConfig, FooterNavigationGroupConfig, NavigationItemConfig, NavigationLocalizedText } from '../../../shared/models/config';
|
||
|
||
type SyncMode = 'add' | 'remove';
|
||
|
||
@Injectable({ providedIn: 'root' })
|
||
export class LocaleSyncService {
|
||
addLocale(bootstrap: BootstrapConfig, locale: string): BootstrapConfig {
|
||
return this.sync(bootstrap, locale, 'add');
|
||
}
|
||
|
||
removeLocale(bootstrap: BootstrapConfig, locale: string): BootstrapConfig {
|
||
if (locale === bootstrap.localization.defaultLocale) {
|
||
return bootstrap;
|
||
}
|
||
return this.sync(bootstrap, locale, 'remove');
|
||
}
|
||
|
||
private sync(bootstrap: BootstrapConfig, rawLocale: string, mode: SyncMode): BootstrapConfig {
|
||
const locale = rawLocale.trim().toLowerCase();
|
||
if (!locale) {
|
||
return bootstrap;
|
||
}
|
||
|
||
const alreadyPresent = bootstrap.localization.supportedLocales.includes(locale);
|
||
if (mode === 'add' && alreadyPresent) {
|
||
return bootstrap;
|
||
}
|
||
if (mode === 'remove' && !alreadyPresent) {
|
||
return bootstrap;
|
||
}
|
||
|
||
const supportedLocales = mode === 'add'
|
||
? [...bootstrap.localization.supportedLocales, locale]
|
||
: bootstrap.localization.supportedLocales.filter(existing => existing !== locale);
|
||
|
||
return {
|
||
...bootstrap,
|
||
tenant: { ...bootstrap.tenant, supportedLocales },
|
||
localization: { ...bootstrap.localization, supportedLocales },
|
||
staticPages: this.syncStaticPages(bootstrap.staticPages, locale, mode),
|
||
navigation: {
|
||
...bootstrap.navigation,
|
||
header: this.syncNavItems(bootstrap.navigation.header, locale, mode),
|
||
footer: this.syncFooterNav(bootstrap.navigation.footer, locale, mode),
|
||
sidebar: bootstrap.navigation.sidebar ? this.syncNavItems(bootstrap.navigation.sidebar, locale, mode) : bootstrap.navigation.sidebar,
|
||
},
|
||
};
|
||
}
|
||
|
||
private syncStaticPages(staticPages: BootstrapConfig['staticPages'], locale: string, mode: SyncMode): BootstrapConfig['staticPages'] {
|
||
if (!staticPages || Array.isArray(staticPages)) {
|
||
return staticPages;
|
||
}
|
||
|
||
return Object.fromEntries(
|
||
Object.entries(staticPages).map(([id, page]) => [id, {
|
||
...page,
|
||
translations: this.syncRecord(page.translations, locale, mode, () => ({ title: '', html: '' })),
|
||
}])
|
||
);
|
||
}
|
||
|
||
private syncNavItems(items: NavigationItemConfig[], locale: string, mode: SyncMode): NavigationItemConfig[] {
|
||
return items.map(item => ({
|
||
...item,
|
||
label: this.syncLabel(item.label, locale, mode),
|
||
children: item.children ? this.syncNavItems(item.children, locale, mode) : item.children,
|
||
}));
|
||
}
|
||
|
||
private syncFooterNav(
|
||
footer: NavigationItemConfig[] | FooterNavigationGroupConfig[],
|
||
locale: string,
|
||
mode: SyncMode
|
||
): NavigationItemConfig[] | FooterNavigationGroupConfig[] {
|
||
if (footer.length === 0) {
|
||
return footer;
|
||
}
|
||
|
||
if ('items' in footer[0]) {
|
||
return (footer as FooterNavigationGroupConfig[]).map(group => ({
|
||
...group,
|
||
groupTitle: this.syncLabel(group.groupTitle, locale, mode),
|
||
items: group.items.map(item => ({ ...item, label: this.syncLabel(item.label, locale, mode) })),
|
||
}));
|
||
}
|
||
|
||
return this.syncNavItems(footer as NavigationItemConfig[], locale, mode);
|
||
}
|
||
|
||
private syncLabel(
|
||
label: string | NavigationLocalizedText | undefined,
|
||
locale: string,
|
||
mode: SyncMode
|
||
): string | NavigationLocalizedText | undefined {
|
||
if (label === undefined || typeof label === 'string') {
|
||
return label;
|
||
}
|
||
return this.syncRecord(label, locale, mode, () => Object.values(label)[0] ?? '');
|
||
}
|
||
|
||
private syncRecord<T>(
|
||
record: Record<string, T> | undefined,
|
||
locale: string,
|
||
mode: SyncMode,
|
||
createEmpty: () => T
|
||
): Record<string, T> {
|
||
const next: Record<string, T> = { ...(record ?? {}) };
|
||
if (mode === 'add') {
|
||
if (!(locale in next)) {
|
||
next[locale] = createEmpty();
|
||
}
|
||
} else {
|
||
delete next[locale];
|
||
}
|
||
return next;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Wire into the facade**
|
||
|
||
In `src/app/features/project-editor/facade/project-editor.facade.ts`, add the import and the three methods (insert after `setActiveSection`, before `private normalize`):
|
||
|
||
```typescript
|
||
import { LocaleSyncService } from '../services/locale-sync.service';
|
||
```
|
||
|
||
```typescript
|
||
private readonly localeSync = inject(LocaleSyncService);
|
||
```
|
||
|
||
```typescript
|
||
addLocale(locale: string): void {
|
||
this.updateBootstrap(current => this.localeSync.addLocale(current, locale));
|
||
}
|
||
|
||
removeLocale(locale: string): void {
|
||
this.updateBootstrap(current => this.localeSync.removeLocale(current, locale));
|
||
}
|
||
|
||
setDefaultLocale(locale: string): void {
|
||
this.updateBootstrap(current => ({
|
||
...current,
|
||
tenant: { ...current.tenant, defaultLocale: locale },
|
||
localization: { ...current.localization, defaultLocale: locale },
|
||
}));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add the section id**
|
||
|
||
In `src/app/features/project-editor/models/project-editor.model.ts`, change the union:
|
||
|
||
```typescript
|
||
export type ProjectEditorSectionId =
|
||
| 'general'
|
||
| 'branding'
|
||
| 'theme'
|
||
| 'header'
|
||
| 'footer'
|
||
| 'homepage'
|
||
| 'widgets'
|
||
| 'static-pages'
|
||
| 'features'
|
||
| 'languages'
|
||
| 'preview';
|
||
```
|
||
|
||
- [ ] **Step 4: Languages tab component**
|
||
|
||
Create `src/app/features/project-editor/sections/languages-section.component.ts`:
|
||
|
||
```typescript
|
||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||
import { FormsModule } from '@angular/forms';
|
||
import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||
|
||
@Component({
|
||
selector: 'app-project-editor-languages-section',
|
||
standalone: true,
|
||
imports: [FormsModule, TranslatePipe],
|
||
templateUrl: './languages-section.component.html',
|
||
styleUrls: ['./section.shared.scss'],
|
||
changeDetection: ChangeDetectionStrategy.OnPush
|
||
})
|
||
export class ProjectEditorLanguagesSectionComponent {
|
||
private readonly facade = inject(ProjectEditorFacade);
|
||
readonly bootstrap = this.facade.bootstrap;
|
||
readonly locales = computed(() => this.bootstrap()?.localization.supportedLocales ?? []);
|
||
readonly defaultLocale = computed(() => this.bootstrap()?.localization.defaultLocale ?? '');
|
||
readonly newLocale = signal('');
|
||
|
||
updateNewLocale(value: string): void {
|
||
this.newLocale.set(value);
|
||
}
|
||
|
||
addLocale(): void {
|
||
const code = this.newLocale().trim();
|
||
if (!code) {
|
||
return;
|
||
}
|
||
this.facade.addLocale(code);
|
||
this.newLocale.set('');
|
||
}
|
||
|
||
removeLocale(code: string): void {
|
||
this.facade.removeLocale(code);
|
||
}
|
||
|
||
setDefault(code: string): void {
|
||
this.facade.setDefaultLocale(code);
|
||
}
|
||
}
|
||
```
|
||
|
||
Create `src/app/features/project-editor/sections/languages-section.component.html`:
|
||
|
||
```html
|
||
<section class="editor-section-card">
|
||
<div class="editor-actions">
|
||
<h2>{{ 'builder.languagesTab' | translate }}</h2>
|
||
</div>
|
||
|
||
<div class="editor-actions">
|
||
<input
|
||
type="text"
|
||
[ngModel]="newLocale()"
|
||
(ngModelChange)="updateNewLocale($event)"
|
||
placeholder="de"
|
||
/>
|
||
<button type="button" (click)="addLocale()">{{ 'builder.addLanguage' | translate }}</button>
|
||
</div>
|
||
|
||
<div class="stack-list">
|
||
@for (code of locales(); track code) {
|
||
<article class="sub-card">
|
||
<div class="editor-actions">
|
||
<h3>{{ code }}</h3>
|
||
<div class="editor-actions">
|
||
@if (code === defaultLocale()) {
|
||
<span>{{ 'builder.defaultLanguageLabel' | translate }}</span>
|
||
} @else {
|
||
<button type="button" class="secondary" (click)="setDefault(code)">{{ 'builder.setDefaultLanguage' | translate }}</button>
|
||
<button type="button" class="secondary" (click)="removeLocale(code)">{{ 'builder.removeLanguage' | translate }}</button>
|
||
}
|
||
</div>
|
||
</div>
|
||
</article>
|
||
}
|
||
</div>
|
||
</section>
|
||
```
|
||
|
||
- [ ] **Step 5: Register the tab**
|
||
|
||
In `src/app/features/project-editor/pages/project-editor-page.component.ts`, add the import and register it in `imports`:
|
||
|
||
```typescript
|
||
import { ProjectEditorLanguagesSectionComponent } from '../sections/languages-section.component';
|
||
```
|
||
|
||
Add `ProjectEditorLanguagesSectionComponent` to the `imports` array (after `ProjectEditorFeaturesSectionComponent`), and add `'languages'` to `KNOWN_SECTIONS`:
|
||
|
||
```typescript
|
||
const KNOWN_SECTIONS: ProjectEditorSectionId[] = [
|
||
'general', 'branding', 'theme', 'header', 'footer', 'homepage', 'widgets', 'static-pages', 'features', 'languages', 'preview'
|
||
];
|
||
```
|
||
|
||
In `src/app/features/project-editor/pages/project-editor-page.component.html`, add a case inside the `@switch` (after the `features` case):
|
||
|
||
```html
|
||
@case ('languages') { <app-project-editor-languages-section /> }
|
||
```
|
||
|
||
In `src/app/features/project-editor/components/project-editor-nav.component.ts`, add to `sections` (after `features`, before `preview`):
|
||
|
||
```typescript
|
||
{ id: 'languages', label: 'builder.languagesTab' },
|
||
```
|
||
|
||
- [ ] **Step 6: i18n keys**
|
||
|
||
In `src/app/i18n/translations.ts`, inside the `builder: { ... }` interface block, add (after `livePreview: string;`):
|
||
|
||
```typescript
|
||
languagesTab: string;
|
||
addLanguage: string;
|
||
removeLanguage: string;
|
||
setDefaultLanguage: string;
|
||
defaultLanguageLabel: string;
|
||
```
|
||
|
||
In `src/app/i18n/en.ts`, inside `builder: { ... }`, add:
|
||
|
||
```typescript
|
||
languagesTab: 'Languages',
|
||
addLanguage: 'Add Language',
|
||
removeLanguage: 'Remove',
|
||
setDefaultLanguage: 'Set as Default',
|
||
defaultLanguageLabel: 'Default',
|
||
```
|
||
|
||
In `src/app/i18n/ru.ts`, inside `builder: { ... }`, add:
|
||
|
||
```typescript
|
||
languagesTab: 'Языки',
|
||
addLanguage: 'Добавить язык',
|
||
removeLanguage: 'Удалить',
|
||
setDefaultLanguage: 'Сделать языком по умолчанию',
|
||
defaultLanguageLabel: 'По умолчанию',
|
||
```
|
||
|
||
In `src/app/i18n/hy.ts`, inside `builder: { ... }`, add:
|
||
|
||
```typescript
|
||
languagesTab: 'Լեզուներ',
|
||
addLanguage: 'Ավելացնել լեզու',
|
||
removeLanguage: 'Հեռացնել',
|
||
setDefaultLanguage: 'Դարձնել լռելյայն',
|
||
defaultLanguageLabel: 'Լռելյայն',
|
||
```
|
||
|
||
- [ ] **Step 7: Manual verification**
|
||
|
||
Run `ng serve`, open `/edit/languages`. Confirm current locales (en, ru, hy) list with `en` marked default. Type `de` and click Add Language — confirm `de` appears in the list. Click "Set as Default" on `de` — confirm it now shows as default and `en` shows the remove/set-default buttons instead. Go to `/edit/preview`, click the export/refresh action, and inspect the exported JSON textarea: confirm `localization.supportedLocales` includes `"de"` and `localization.defaultLocale` is `"de"`, and (if any static pages exist) each static page's `translations` object now has a `"de"` key with `{ "title": "", "html": "" }`. Go back to `/edit/languages` and remove `de` — confirm it disappears from the list and from the exported JSON's `translations` objects. Attempt to remove the current default locale — confirm nothing happens (no button is shown for the default row).
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/app/features/project-editor/services/locale-sync.service.ts src/app/features/project-editor/sections/languages-section.component.ts src/app/features/project-editor/sections/languages-section.component.html src/app/features/project-editor/models/project-editor.model.ts src/app/features/project-editor/facade/project-editor.facade.ts src/app/features/project-editor/pages/project-editor-page.component.ts src/app/features/project-editor/pages/project-editor-page.component.html src/app/features/project-editor/components/project-editor-nav.component.ts src/app/i18n/translations.ts src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts
|
||
git commit -m "feat(project-editor): add Languages tab with generic locale sync"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Static pages editor uses supported locales dynamically
|
||
|
||
**Files:**
|
||
- Modify: `src/app/features/content-management/components/static-pages-editor.component.ts`
|
||
- Modify: `src/app/features/content-management/components/static-pages-editor.component.html`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ProjectEditorFacade.bootstrap` (existing), `bootstrap.localization.supportedLocales: string[]` (existing field).
|
||
- Produces: `locales: Signal<string[]>` computed on the component, consumed by its own template only.
|
||
|
||
- [ ] **Step 1: Add a `locales` computed**
|
||
|
||
In `src/app/features/content-management/components/static-pages-editor.component.ts`, add after the existing `readonly validation = ...` line:
|
||
|
||
```typescript
|
||
readonly locales = computed(() => this.bootstrap()?.localization.supportedLocales ?? ['en']);
|
||
```
|
||
|
||
- [ ] **Step 2: Use it in the template instead of the hardcoded array**
|
||
|
||
In `src/app/features/content-management/components/static-pages-editor.component.html`, change:
|
||
|
||
```html
|
||
@for (locale of ['en','ru','hy']; track locale) {
|
||
```
|
||
|
||
to:
|
||
|
||
```html
|
||
@for (locale of locales(); track locale) {
|
||
```
|
||
|
||
- [ ] **Step 3: Manual verification**
|
||
|
||
With the dev server running, go to `/edit/languages` and add `de`. Go to `/edit/static-pages` — confirm every page card now shows a fourth translation row for `de` (title input + HTML field) in addition to en/ru/hy. Remove `de` on the Languages tab, return to Static Pages, confirm the `de` row is gone.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/app/features/content-management/components/static-pages-editor.component.ts src/app/features/content-management/components/static-pages-editor.component.html
|
||
git commit -m "fix(content-management): static pages editor reads supported locales instead of hardcoding en/ru/hy"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Navigation tab
|
||
|
||
**Files:**
|
||
- Modify: `src/app/features/project-editor/facade/project-editor.facade.ts`
|
||
- Modify: `src/app/features/project-editor/models/project-editor.model.ts`
|
||
- Create: `src/app/features/project-editor/sections/navigation-section.component.ts`
|
||
- Create: `src/app/features/project-editor/sections/navigation-section.component.html`
|
||
- Modify: `src/app/features/project-editor/pages/project-editor-page.component.ts`
|
||
- Modify: `src/app/features/project-editor/pages/project-editor-page.component.html`
|
||
- Modify: `src/app/features/project-editor/components/project-editor-nav.component.ts`
|
||
- Modify: `src/app/i18n/translations.ts`, `en.ts`, `ru.ts`, `hy.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `NavigationItemConfig` from `src/app/shared/models/config` (`id`, `label`, `route`, `order`, `visible`, `children`).
|
||
- Produces: `ProjectEditorFacade.addNavLink(target)`, `.removeNavLink(target, id)`, `.updateNavLink(target, id, patch)`, `.reorderNavLink(target, id, direction)` — no other task depends on these, but they follow the same `target: 'header' | 'footer'` shape a future Sidebar tab could reuse.
|
||
|
||
- [ ] **Step 1: Facade actions**
|
||
|
||
In `src/app/features/project-editor/facade/project-editor.facade.ts`, add the import:
|
||
|
||
```typescript
|
||
import { NavigationItemConfig } from '../../../shared/models/config';
|
||
```
|
||
|
||
Add these methods after `setDefaultLocale` (from Task 2), before `private normalize`:
|
||
|
||
```typescript
|
||
addNavLink(target: 'header' | 'footer'): void {
|
||
this.updateBootstrap(current => {
|
||
const list = current.navigation[target];
|
||
if (!this.isFlatNavList(list)) {
|
||
return current;
|
||
}
|
||
const items = list as NavigationItemConfig[];
|
||
const newItem: NavigationItemConfig = {
|
||
id: `nav-${Date.now()}`,
|
||
label: 'New link',
|
||
route: '/',
|
||
order: items.length + 1,
|
||
visible: true,
|
||
};
|
||
return { ...current, navigation: { ...current.navigation, [target]: [...items, newItem] } };
|
||
});
|
||
}
|
||
|
||
removeNavLink(target: 'header' | 'footer', id: string): void {
|
||
this.updateBootstrap(current => {
|
||
const list = current.navigation[target];
|
||
if (!this.isFlatNavList(list)) {
|
||
return current;
|
||
}
|
||
return {
|
||
...current,
|
||
navigation: { ...current.navigation, [target]: (list as NavigationItemConfig[]).filter(item => item.id !== id) },
|
||
};
|
||
});
|
||
}
|
||
|
||
updateNavLink(target: 'header' | 'footer', id: string, patch: Partial<NavigationItemConfig>): void {
|
||
this.updateBootstrap(current => {
|
||
const list = current.navigation[target];
|
||
if (!this.isFlatNavList(list)) {
|
||
return current;
|
||
}
|
||
return {
|
||
...current,
|
||
navigation: {
|
||
...current.navigation,
|
||
[target]: (list as NavigationItemConfig[]).map(item => (item.id !== id ? item : { ...item, ...patch })),
|
||
},
|
||
};
|
||
});
|
||
}
|
||
|
||
reorderNavLink(target: 'header' | 'footer', id: string, direction: -1 | 1): void {
|
||
this.updateBootstrap(current => {
|
||
const list = current.navigation[target];
|
||
if (!this.isFlatNavList(list)) {
|
||
return current;
|
||
}
|
||
const items = [...(list as NavigationItemConfig[])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||
const index = items.findIndex(item => item.id === id);
|
||
const nextIndex = index + direction;
|
||
if (index < 0 || nextIndex < 0 || nextIndex >= items.length) {
|
||
return current;
|
||
}
|
||
const tmp = items[index];
|
||
items[index] = items[nextIndex];
|
||
items[nextIndex] = tmp;
|
||
return {
|
||
...current,
|
||
navigation: { ...current.navigation, [target]: items.map((item, order) => ({ ...item, order: order + 1 })) },
|
||
};
|
||
});
|
||
}
|
||
|
||
private isFlatNavList(list: NavigationItemConfig[] | { items: unknown }[]): list is NavigationItemConfig[] {
|
||
return list.length === 0 || !('items' in list[0]);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add the section id**
|
||
|
||
In `src/app/features/project-editor/models/project-editor.model.ts`, add `'navigation'` to the `ProjectEditorSectionId` union (next to `'languages'`):
|
||
|
||
```typescript
|
||
export type ProjectEditorSectionId =
|
||
| 'general'
|
||
| 'branding'
|
||
| 'theme'
|
||
| 'header'
|
||
| 'footer'
|
||
| 'homepage'
|
||
| 'widgets'
|
||
| 'static-pages'
|
||
| 'features'
|
||
| 'languages'
|
||
| 'navigation'
|
||
| 'preview';
|
||
```
|
||
|
||
- [ ] **Step 3: Navigation tab component**
|
||
|
||
Create `src/app/features/project-editor/sections/navigation-section.component.ts`:
|
||
|
||
```typescript
|
||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||
import { FormsModule } from '@angular/forms';
|
||
import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||
import { NavigationItemConfig } from '../../../shared/models/config';
|
||
|
||
@Component({
|
||
selector: 'app-project-editor-navigation-section',
|
||
standalone: true,
|
||
imports: [FormsModule, TranslatePipe],
|
||
templateUrl: './navigation-section.component.html',
|
||
styleUrls: ['./section.shared.scss'],
|
||
changeDetection: ChangeDetectionStrategy.OnPush
|
||
})
|
||
export class ProjectEditorNavigationSectionComponent {
|
||
private readonly facade = inject(ProjectEditorFacade);
|
||
readonly bootstrap = this.facade.bootstrap;
|
||
|
||
readonly headerLinks = computed(() => this.sorted(this.bootstrap()?.navigation.header ?? []));
|
||
readonly footerLinks = computed<NavigationItemConfig[] | null>(() => {
|
||
const footer = this.bootstrap()?.navigation.footer ?? [];
|
||
if (footer.length === 0) {
|
||
return [];
|
||
}
|
||
return 'items' in footer[0] ? null : this.sorted(footer as NavigationItemConfig[]);
|
||
});
|
||
|
||
labelOf(item: NavigationItemConfig): string {
|
||
if (typeof item.label === 'string' || !item.label) {
|
||
return item.label ?? '';
|
||
}
|
||
const defaultLocale = this.bootstrap()?.localization.defaultLocale ?? 'en';
|
||
return item.label[defaultLocale] ?? Object.values(item.label)[0] ?? '';
|
||
}
|
||
|
||
addLink(target: 'header' | 'footer'): void {
|
||
this.facade.addNavLink(target);
|
||
}
|
||
|
||
removeLink(target: 'header' | 'footer', id: string): void {
|
||
this.facade.removeNavLink(target, id);
|
||
}
|
||
|
||
move(target: 'header' | 'footer', id: string, direction: -1 | 1): void {
|
||
this.facade.reorderNavLink(target, id, direction);
|
||
}
|
||
|
||
updateLabel(target: 'header' | 'footer', id: string, value: string): void {
|
||
this.facade.updateNavLink(target, id, { label: value });
|
||
}
|
||
|
||
updateRoute(target: 'header' | 'footer', id: string, value: string): void {
|
||
this.facade.updateNavLink(target, id, { route: value });
|
||
}
|
||
|
||
updateVisible(target: 'header' | 'footer', id: string, value: boolean): void {
|
||
this.facade.updateNavLink(target, id, { visible: value });
|
||
}
|
||
|
||
private sorted(items: NavigationItemConfig[]): NavigationItemConfig[] {
|
||
return [...items].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||
}
|
||
}
|
||
```
|
||
|
||
Create `src/app/features/project-editor/sections/navigation-section.component.html`:
|
||
|
||
```html
|
||
<section class="editor-section-card">
|
||
<div class="editor-actions">
|
||
<h2>{{ 'builder.navigationHeader' | translate }}</h2>
|
||
<button type="button" (click)="addLink('header')">{{ 'builder.addLink' | translate }}</button>
|
||
</div>
|
||
|
||
<div class="stack-list">
|
||
@for (item of headerLinks(); track item.id) {
|
||
<article class="sub-card">
|
||
<div class="editor-actions">
|
||
<h3>{{ labelOf(item) }}</h3>
|
||
<div class="editor-actions">
|
||
<button type="button" class="secondary" (click)="move('header', item.id, -1)">↑</button>
|
||
<button type="button" class="secondary" (click)="move('header', item.id, 1)">↓</button>
|
||
<button type="button" class="secondary" (click)="removeLink('header', item.id)">{{ 'builder.removeLink' | translate }}</button>
|
||
</div>
|
||
</div>
|
||
<div class="editor-grid three">
|
||
<label><span>{{ 'builder.linkLabel' | translate }}</span><input type="text" [ngModel]="labelOf(item)" (ngModelChange)="updateLabel('header', item.id, $event)" /></label>
|
||
<label><span>{{ 'builder.linkUrl' | translate }}</span><input type="text" [ngModel]="item.route" (ngModelChange)="updateRoute('header', item.id, $event)" /></label>
|
||
<label class="toggle-row"><input type="checkbox" [checked]="item.visible !== false" (change)="updateVisible('header', item.id, $any($event.target).checked)" /><span>{{ 'builder.visible' | translate }}</span></label>
|
||
</div>
|
||
</article>
|
||
}
|
||
</div>
|
||
|
||
<div class="editor-actions">
|
||
<h2>{{ 'builder.navigationFooter' | translate }}</h2>
|
||
@if (footerLinks(); as footer) {
|
||
<button type="button" (click)="addLink('footer')">{{ 'builder.addLink' | translate }}</button>
|
||
}
|
||
</div>
|
||
|
||
@if (footerLinks(); as footer) {
|
||
<div class="stack-list">
|
||
@for (item of footer; track item.id) {
|
||
<article class="sub-card">
|
||
<div class="editor-actions">
|
||
<h3>{{ labelOf(item) }}</h3>
|
||
<div class="editor-actions">
|
||
<button type="button" class="secondary" (click)="move('footer', item.id, -1)">↑</button>
|
||
<button type="button" class="secondary" (click)="move('footer', item.id, 1)">↓</button>
|
||
<button type="button" class="secondary" (click)="removeLink('footer', item.id)">{{ 'builder.removeLink' | translate }}</button>
|
||
</div>
|
||
</div>
|
||
<div class="editor-grid three">
|
||
<label><span>{{ 'builder.linkLabel' | translate }}</span><input type="text" [ngModel]="labelOf(item)" (ngModelChange)="updateLabel('footer', item.id, $event)" /></label>
|
||
<label><span>{{ 'builder.linkUrl' | translate }}</span><input type="text" [ngModel]="item.route" (ngModelChange)="updateRoute('footer', item.id, $event)" /></label>
|
||
<label class="toggle-row"><input type="checkbox" [checked]="item.visible !== false" (change)="updateVisible('footer', item.id, $any($event.target).checked)" /><span>{{ 'builder.visible' | translate }}</span></label>
|
||
</div>
|
||
</article>
|
||
}
|
||
</div>
|
||
} @else {
|
||
<p class="editor-error">{{ 'builder.navigationFooterGrouped' | translate }}</p>
|
||
}
|
||
</section>
|
||
```
|
||
|
||
- [ ] **Step 4: Register the tab**
|
||
|
||
In `src/app/features/project-editor/pages/project-editor-page.component.ts`, add the import, add to `imports`, and add `'navigation'` to `KNOWN_SECTIONS`:
|
||
|
||
```typescript
|
||
import { ProjectEditorNavigationSectionComponent } from '../sections/navigation-section.component';
|
||
```
|
||
|
||
```typescript
|
||
const KNOWN_SECTIONS: ProjectEditorSectionId[] = [
|
||
'general', 'branding', 'theme', 'header', 'footer', 'homepage', 'widgets', 'static-pages', 'features', 'languages', 'navigation', 'preview'
|
||
];
|
||
```
|
||
|
||
In `src/app/features/project-editor/pages/project-editor-page.component.html`, add the case (after `languages`):
|
||
|
||
```html
|
||
@case ('navigation') { <app-project-editor-navigation-section /> }
|
||
```
|
||
|
||
In `src/app/features/project-editor/components/project-editor-nav.component.ts`, add (after `languages`, before `preview`):
|
||
|
||
```typescript
|
||
{ id: 'navigation', label: 'builder.navigationTab' },
|
||
```
|
||
|
||
- [ ] **Step 5: i18n keys**
|
||
|
||
In `src/app/i18n/translations.ts`, add to the `builder` interface (after the Task 2 keys):
|
||
|
||
```typescript
|
||
navigationTab: string;
|
||
navigationHeader: string;
|
||
navigationFooter: string;
|
||
navigationFooterGrouped: string;
|
||
addLink: string;
|
||
removeLink: string;
|
||
linkLabel: string;
|
||
linkUrl: string;
|
||
```
|
||
|
||
In `src/app/i18n/en.ts`, add:
|
||
|
||
```typescript
|
||
navigationTab: 'Navigation',
|
||
navigationHeader: 'Header Navigation',
|
||
navigationFooter: 'Footer Navigation',
|
||
navigationFooterGrouped: 'Footer navigation uses grouped columns and is not editable here yet — edit via the Footer tab.',
|
||
addLink: 'Add Link',
|
||
removeLink: 'Remove',
|
||
linkLabel: 'Label',
|
||
linkUrl: 'URL',
|
||
```
|
||
|
||
In `src/app/i18n/ru.ts`, add:
|
||
|
||
```typescript
|
||
navigationTab: 'Навигация',
|
||
navigationHeader: 'Навигация в шапке',
|
||
navigationFooter: 'Навигация в подвале',
|
||
navigationFooterGrouped: 'Навигация подвала сгруппирована по колонкам и пока недоступна для редактирования здесь — используйте вкладку "Подвал".',
|
||
addLink: 'Добавить ссылку',
|
||
removeLink: 'Удалить',
|
||
linkLabel: 'Название',
|
||
linkUrl: 'URL',
|
||
```
|
||
|
||
In `src/app/i18n/hy.ts`, add:
|
||
|
||
```typescript
|
||
navigationTab: 'Նավիգացիա',
|
||
navigationHeader: 'Վերին նավիգացիա',
|
||
navigationFooter: 'Ստորին նավիգացիա',
|
||
navigationFooterGrouped: 'Ստորին նավիգացիան խմբավորված է սյուներով և դեռ խմբագրելի չէ այստեղ. օգտագործեք «Footer» ներդիրը։',
|
||
addLink: 'Ավելացնել հղում',
|
||
removeLink: 'Հեռացնել',
|
||
linkLabel: 'Պիտակ',
|
||
linkUrl: 'URL',
|
||
```
|
||
|
||
- [ ] **Step 6: Manual verification**
|
||
|
||
Run `ng serve`, open `/edit/navigation`. Confirm the existing header nav items list, sorted by order. Click "Add Link" under Header Navigation — confirm a "New link" row appears at the bottom. Edit its label and URL — confirm the values persist (check via `/edit/preview` export JSON: `navigation.header` contains the new item with your edited `label`/`route`). Click ↑ on the new item — confirm it moves up one position and the exported JSON's `order` values update accordingly. Click "Remove" — confirm it disappears. If `navigation.footer` in this bootstrap is grouped (`FooterNavigationGroupConfig[]`), confirm the footer section shows the "not editable here yet" message instead of a broken list.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/app/features/project-editor/facade/project-editor.facade.ts src/app/features/project-editor/models/project-editor.model.ts src/app/features/project-editor/sections/navigation-section.component.ts src/app/features/project-editor/sections/navigation-section.component.html src/app/features/project-editor/pages/project-editor-page.component.ts src/app/features/project-editor/pages/project-editor-page.component.html src/app/features/project-editor/components/project-editor-nav.component.ts src/app/i18n/translations.ts src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts
|
||
git commit -m "feat(project-editor): add Navigation tab for header/flat footer nav"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Reusable rich HTML editor component
|
||
|
||
**Files:**
|
||
- Create: `src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.ts`
|
||
- Create: `src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.html`
|
||
- Create: `src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.scss`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing outside Angular core/browser APIs (`document.execCommand`, native `contentEditable`).
|
||
- Produces: `MarketplaceHtmlEditorComponent` with `@Input() html: string`, `@Output() htmlChange: EventEmitter<string>` — consumed by Task 6.
|
||
|
||
- [ ] **Step 1: Component**
|
||
|
||
Create `src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.ts`:
|
||
|
||
```typescript
|
||
import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, OnChanges, Output, SimpleChanges, ViewChild, signal } from '@angular/core';
|
||
|
||
export interface HtmlEditorToolbarCommand {
|
||
id: string;
|
||
label: string;
|
||
command: string;
|
||
value?: string;
|
||
}
|
||
|
||
export const HTML_EDITOR_TOOLBAR: HtmlEditorToolbarCommand[] = [
|
||
{ id: 'bold', label: 'B', command: 'bold' },
|
||
{ id: 'italic', label: 'I', command: 'italic' },
|
||
{ id: 'underline', label: 'U', command: 'underline' },
|
||
{ id: 'h2', label: 'H2', command: 'formatBlock', value: 'H2' },
|
||
{ id: 'h3', label: 'H3', command: 'formatBlock', value: 'H3' },
|
||
{ id: 'ul', label: 'List', command: 'insertUnorderedList' },
|
||
{ id: 'ol', label: '1,2,3', command: 'insertOrderedList' },
|
||
{ id: 'link', label: 'Link', command: 'createLink' },
|
||
{ id: 'image', label: 'Image', command: 'insertImage' },
|
||
{ id: 'table', label: 'Table', command: 'insertHTML', value: '<table><tr><td> </td><td> </td></tr></table>' },
|
||
];
|
||
|
||
@Component({
|
||
selector: 'app-marketplace-html-editor',
|
||
standalone: true,
|
||
imports: [],
|
||
templateUrl: './marketplace-html-editor.component.html',
|
||
styleUrls: ['./marketplace-html-editor.component.scss'],
|
||
changeDetection: ChangeDetectionStrategy.OnPush
|
||
})
|
||
export class MarketplaceHtmlEditorComponent implements OnChanges {
|
||
@Input() html = '';
|
||
@Output() htmlChange = new EventEmitter<string>();
|
||
@ViewChild('surface', { static: true }) surface!: ElementRef<HTMLDivElement>;
|
||
|
||
readonly toolbar = HTML_EDITOR_TOOLBAR;
|
||
readonly showCode = signal(false);
|
||
readonly codeValue = signal('');
|
||
|
||
ngOnChanges(changes: SimpleChanges): void {
|
||
if (changes['html'] && this.surface && this.surface.nativeElement.innerHTML !== (this.html || '')) {
|
||
this.surface.nativeElement.innerHTML = this.html || '';
|
||
}
|
||
}
|
||
|
||
runCommand(item: HtmlEditorToolbarCommand): void {
|
||
this.surface.nativeElement.focus();
|
||
if (item.command === 'createLink') {
|
||
const url = window.prompt('URL');
|
||
if (!url) {
|
||
return;
|
||
}
|
||
document.execCommand('createLink', false, url);
|
||
} else if (item.command === 'insertImage') {
|
||
const url = window.prompt('Image URL');
|
||
if (!url) {
|
||
return;
|
||
}
|
||
document.execCommand('insertImage', false, url);
|
||
} else {
|
||
document.execCommand(item.command, false, item.value);
|
||
}
|
||
this.emitChange();
|
||
}
|
||
|
||
onInput(): void {
|
||
this.emitChange();
|
||
}
|
||
|
||
toggleCode(): void {
|
||
if (!this.showCode()) {
|
||
this.codeValue.set(this.surface.nativeElement.innerHTML);
|
||
this.showCode.set(true);
|
||
return;
|
||
}
|
||
this.surface.nativeElement.innerHTML = this.codeValue();
|
||
this.showCode.set(false);
|
||
this.emitChange();
|
||
}
|
||
|
||
updateCode(value: string): void {
|
||
this.codeValue.set(value);
|
||
}
|
||
|
||
private emitChange(): void {
|
||
this.htmlChange.emit(this.surface.nativeElement.innerHTML);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Template**
|
||
|
||
Create `src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.html`:
|
||
|
||
```html
|
||
<div class="html-editor">
|
||
<div class="html-editor-toolbar">
|
||
@for (item of toolbar; track item.id) {
|
||
<button type="button" (click)="runCommand(item)">{{ item.label }}</button>
|
||
}
|
||
<button type="button" (click)="toggleCode()">{{ showCode() ? 'Preview' : 'Code' }}</button>
|
||
</div>
|
||
|
||
@if (showCode()) {
|
||
<textarea
|
||
class="html-editor-code"
|
||
rows="10"
|
||
[value]="codeValue()"
|
||
(input)="updateCode($any($event.target).value)"
|
||
></textarea>
|
||
} @else {
|
||
<div
|
||
#surface
|
||
class="html-editor-surface"
|
||
contenteditable="true"
|
||
(input)="onInput()"
|
||
></div>
|
||
}
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 3: Styles**
|
||
|
||
Create `src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.scss`:
|
||
|
||
```scss
|
||
.html-editor {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.html-editor-toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
.html-editor-surface {
|
||
min-height: 160px;
|
||
border: 1px solid var(--border, #ccc);
|
||
border-radius: 4px;
|
||
padding: 0.5rem;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.html-editor-code {
|
||
min-height: 160px;
|
||
font-family: monospace;
|
||
border: 1px solid var(--border, #ccc);
|
||
border-radius: 4px;
|
||
padding: 0.5rem;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Manual verification**
|
||
|
||
This component isn't wired into a page yet — verify it compiles: run `npx tsc -p tsconfig.app.json --noEmit`. Expected: no new errors referencing `marketplace-html-editor.component.ts`. (Full interactive verification happens in Task 6 once it's mounted somewhere.)
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/app/features/project-editor/components/html-editor/
|
||
git commit -m "feat(project-editor): add reusable contentEditable HTML editor component"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Replace the static-pages `<textarea>` with the HTML editor
|
||
|
||
**Files:**
|
||
- Modify: `src/app/features/content-management/components/static-pages-editor.component.ts`
|
||
- Modify: `src/app/features/content-management/components/static-pages-editor.component.html`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `MarketplaceHtmlEditorComponent` (`[html]` input, `(htmlChange)` output) from Task 5.
|
||
- Produces: nothing new — same `updateTranslation(id, locale, 'html', value)` method is now driven by `(htmlChange)` instead of `(ngModelChange)`.
|
||
|
||
- [ ] **Step 1: Import the component**
|
||
|
||
In `src/app/features/content-management/components/static-pages-editor.component.ts`, add the import and register it:
|
||
|
||
```typescript
|
||
import { MarketplaceHtmlEditorComponent } from '../../project-editor/components/html-editor/marketplace-html-editor.component';
|
||
```
|
||
|
||
Change the `imports` array from `[FormsModule, TranslatePipe]` to:
|
||
|
||
```typescript
|
||
imports: [FormsModule, TranslatePipe, MarketplaceHtmlEditorComponent],
|
||
```
|
||
|
||
- [ ] **Step 2: Swap the textarea for the editor**
|
||
|
||
In `src/app/features/content-management/components/static-pages-editor.component.html`, replace:
|
||
|
||
```html
|
||
<label class="full">
|
||
<span>{{ 'builder.htmlPreview' | translate }} {{ locale }}</span>
|
||
<textarea rows="6" [ngModel]="page.translations[locale]?.html || ''" (ngModelChange)="updateTranslation(page.id, locale, 'html', $event)"></textarea>
|
||
</label>
|
||
```
|
||
|
||
with:
|
||
|
||
```html
|
||
<label class="full">
|
||
<span>{{ 'builder.htmlPreview' | translate }} {{ locale }}</span>
|
||
<app-marketplace-html-editor
|
||
[html]="page.translations[locale]?.html || ''"
|
||
(htmlChange)="updateTranslation(page.id, locale, 'html', $event)"
|
||
/>
|
||
</label>
|
||
```
|
||
|
||
- [ ] **Step 3: Manual verification**
|
||
|
||
Run `ng serve`, open `/edit/static-pages`, create a page. Confirm each locale's HTML field is now the toolbar editor, not a plain textarea. Click Bold, type text — confirm it renders bold in the editing surface. Click "Code", confirm the raw HTML (e.g. `<b>...</b>`) shows in a textarea, edit it, click "Code" again (now labeled "Preview") — confirm the surface re-renders your manual HTML edit. Go to `/edit/preview`, export JSON, confirm the page's `translations.<locale>.html` contains the exact HTML you produced (bold tags etc.) — proving nothing sanitized it in the editor.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/app/features/content-management/components/static-pages-editor.component.ts src/app/features/content-management/components/static-pages-editor.component.html
|
||
git commit -m "feat(content-management): use rich HTML editor for static page content"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: `ProjectValidator`
|
||
|
||
**Files:**
|
||
- Create: `src/app/features/project-editor/services/project-validator.service.ts`
|
||
- Modify: `src/app/i18n/translations.ts`, `en.ts`, `ru.ts`, `hy.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `BootstrapConfig` (existing).
|
||
- Produces: `ProjectValidationIssue { code: string; message: string }`, `ProjectValidator.validate(bootstrap: BootstrapConfig): ProjectValidationIssue[]` — consumed by Task 8's facade `validationIssues` computed and Task 9's save bar.
|
||
|
||
- [ ] **Step 1: Service**
|
||
|
||
Create `src/app/features/project-editor/services/project-validator.service.ts`:
|
||
|
||
```typescript
|
||
import { Injectable } from '@angular/core';
|
||
import { BootstrapConfig } from '../../../shared/models/config';
|
||
|
||
export interface ProjectValidationIssue {
|
||
code: string;
|
||
message: string;
|
||
}
|
||
|
||
const HEX_COLOR = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||
const HTTP_URL = /^https?:\/\/\S+$/;
|
||
|
||
@Injectable({ providedIn: 'root' })
|
||
export class ProjectValidator {
|
||
validate(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||
return [
|
||
...this.brandingIssues(bootstrap),
|
||
...this.languageIssues(bootstrap),
|
||
...this.urlIssues(bootstrap),
|
||
...this.duplicateSlugIssues(bootstrap),
|
||
...this.homepageIssues(bootstrap),
|
||
...this.navigationIssues(bootstrap),
|
||
...this.colorIssues(bootstrap),
|
||
];
|
||
}
|
||
|
||
private brandingIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||
return bootstrap.branding.logoUrl ? [] : [{ code: 'missing-logo', message: 'builder.validationMissingLogo' }];
|
||
}
|
||
|
||
private languageIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||
return bootstrap.localization.supportedLocales.length > 0 ? [] : [{ code: 'no-languages', message: 'builder.validationNoLanguages' }];
|
||
}
|
||
|
||
private urlIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||
const url = bootstrap.tenant.websiteBaseUrl;
|
||
return !url || HTTP_URL.test(url) ? [] : [{ code: 'invalid-url', message: 'builder.validationInvalidUrl' }];
|
||
}
|
||
|
||
private duplicateSlugIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||
const staticPages = bootstrap.staticPages;
|
||
if (!staticPages || Array.isArray(staticPages)) {
|
||
return [];
|
||
}
|
||
const slugs = Object.values(staticPages).map(page => page.slug);
|
||
const hasDuplicates = slugs.some((slug, index) => slugs.indexOf(slug) !== index);
|
||
return hasDuplicates ? [{ code: 'duplicate-slugs', message: 'builder.validationDuplicateSlugs' }] : [];
|
||
}
|
||
|
||
private homepageIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||
const homePage = bootstrap.pages.find(page => page.key === 'home' || page.route.path === '/');
|
||
if (!homePage || homePage.sections.length === 0) {
|
||
return [{ code: 'empty-homepage', message: 'builder.validationEmptyHomepage' }];
|
||
}
|
||
const hasMissingWidgetType = homePage.sections.some(section => section.widgets.some(widget => !widget.type?.trim()));
|
||
return hasMissingWidgetType ? [{ code: 'missing-widget', message: 'builder.validationMissingWidget' }] : [];
|
||
}
|
||
|
||
private navigationIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||
const keyOf = (item: { label?: string | Record<string, string>; route?: string }): string =>
|
||
`${typeof item.label === 'string' ? item.label : JSON.stringify(item.label ?? {})}|${item.route ?? ''}`;
|
||
const keys = bootstrap.navigation.header.map(keyOf);
|
||
const hasDuplicates = keys.some((key, index) => keys.indexOf(key) !== index);
|
||
return hasDuplicates ? [{ code: 'duplicate-nav-links', message: 'builder.validationDuplicateNavLinks' }] : [];
|
||
}
|
||
|
||
private colorIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||
const invalid = Object.values(bootstrap.theme.palette).some(value => !HEX_COLOR.test(value));
|
||
return invalid ? [{ code: 'invalid-colors', message: 'builder.validationInvalidColors' }] : [];
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: i18n keys**
|
||
|
||
In `src/app/i18n/translations.ts`, add to the `builder` interface:
|
||
|
||
```typescript
|
||
validationMissingLogo: string;
|
||
validationNoLanguages: string;
|
||
validationInvalidUrl: string;
|
||
validationDuplicateSlugs: string;
|
||
validationEmptyHomepage: string;
|
||
validationMissingWidget: string;
|
||
validationDuplicateNavLinks: string;
|
||
validationInvalidColors: string;
|
||
```
|
||
|
||
In `src/app/i18n/en.ts`, add:
|
||
|
||
```typescript
|
||
validationMissingLogo: 'Branding is missing a logo.',
|
||
validationNoLanguages: 'No languages are configured.',
|
||
validationInvalidUrl: 'The marketplace URL is invalid.',
|
||
validationDuplicateSlugs: 'Two or more static pages share the same slug.',
|
||
validationEmptyHomepage: 'The homepage has no sections.',
|
||
validationMissingWidget: 'A homepage section has a widget with no type.',
|
||
validationDuplicateNavLinks: 'Two or more header navigation links are duplicates.',
|
||
validationInvalidColors: 'One or more theme colors are not valid hex colors.',
|
||
```
|
||
|
||
In `src/app/i18n/ru.ts`, add:
|
||
|
||
```typescript
|
||
validationMissingLogo: 'В брендинге отсутствует логотип.',
|
||
validationNoLanguages: 'Не настроены языки.',
|
||
validationInvalidUrl: 'Некорректный URL маркетплейса.',
|
||
validationDuplicateSlugs: 'Две или более статические страницы имеют одинаковый slug.',
|
||
validationEmptyHomepage: 'На главной странице нет секций.',
|
||
validationMissingWidget: 'В секции главной страницы есть виджет без типа.',
|
||
validationDuplicateNavLinks: 'Две или более ссылки в навигации шапки дублируются.',
|
||
validationInvalidColors: 'Один или несколько цветов темы указаны некорректно.',
|
||
```
|
||
|
||
In `src/app/i18n/hy.ts`, add:
|
||
|
||
```typescript
|
||
validationMissingLogo: 'Բրենդինգում բացակայում է լոգոն։',
|
||
validationNoLanguages: 'Կարգավորված լեզուներ չկան։',
|
||
validationInvalidUrl: 'Մարքեթփլեյսի URL-ը սխալ է։',
|
||
validationDuplicateSlugs: 'Երկու կամ ավելի ստատիկ էջեր ունեն նույն slug-ը։',
|
||
validationEmptyHomepage: 'Գլխավոր էջում սեկցիաներ չկան։',
|
||
validationMissingWidget: 'Գլխավոր էջի սեկցիաներից մեկն ունի վիջեթ առանց տիպի։',
|
||
validationDuplicateNavLinks: 'Վերնագրի նավիգացիայում կան կրկնվող հղումներ։',
|
||
validationInvalidColors: 'Թեմայի գույներից մեկը կամ մի քանիսը վավեր hex գույն չեն։',
|
||
```
|
||
|
||
- [ ] **Step 3: Manual verification**
|
||
|
||
This service isn't wired into the UI yet. Verify it compiles: `npx tsc -p tsconfig.app.json --noEmit`. Expected: no new errors. (Full interactive verification happens in Task 9.)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/app/features/project-editor/services/project-validator.service.ts src/app/i18n/translations.ts src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts
|
||
git commit -m "feat(project-editor): add ProjectValidator with MVP validation rules"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Draft/Publish state and dirty tracking on the facade
|
||
|
||
**Files:**
|
||
- Modify: `src/app/features/project-editor/models/project-editor.model.ts`
|
||
- Modify: `src/app/features/project-editor/facade/project-editor.facade.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ProjectValidator.validate` (Task 7), `PlatformRuntimeService.reloadFromBootstrap` (existing, `src/app/core/runtime/platform-runtime.service.ts:60`).
|
||
- Produces: `ProjectEditorFacade.status: Signal<'draft' | 'published'>`, `.dirty: Signal<boolean>`, `.validationIssues: Signal<ProjectValidationIssue[]>`, `.save(): void`, `.publish(): boolean` — consumed by Task 9 (save bar) and Task 10 (dirty guard).
|
||
|
||
- [ ] **Step 1: Extend the state shape**
|
||
|
||
In `src/app/features/project-editor/models/project-editor.model.ts`, change `ProjectEditorState`:
|
||
|
||
```typescript
|
||
export interface ProjectEditorState {
|
||
bootstrap: BootstrapConfig | null;
|
||
importError: string | null;
|
||
activeSection: ProjectEditorSectionId;
|
||
status: 'draft' | 'published';
|
||
lastSavedBootstrap: BootstrapConfig | null;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update the facade**
|
||
|
||
In `src/app/features/project-editor/facade/project-editor.facade.ts`, add imports:
|
||
|
||
```typescript
|
||
import { PlatformRuntimeService } from '../../../core/runtime/platform-runtime.service';
|
||
import { ProjectValidator } from '../services/project-validator.service';
|
||
```
|
||
|
||
Add injected services (with the others at the top of the class):
|
||
|
||
```typescript
|
||
private readonly runtime = inject(PlatformRuntimeService);
|
||
private readonly validator = inject(ProjectValidator);
|
||
```
|
||
|
||
Update the initial state:
|
||
|
||
```typescript
|
||
private readonly state = signal<ProjectEditorState>({
|
||
bootstrap: null,
|
||
importError: null,
|
||
activeSection: 'general',
|
||
status: 'draft',
|
||
lastSavedBootstrap: null,
|
||
});
|
||
```
|
||
|
||
Add these computed signals (after `homepageWidgets`):
|
||
|
||
```typescript
|
||
readonly status = computed(() => this.state().status);
|
||
readonly validationIssues = computed(() => {
|
||
const current = this.bootstrap();
|
||
return current ? this.validator.validate(current) : [];
|
||
});
|
||
readonly dirty = computed(() => {
|
||
const current = this.bootstrap();
|
||
if (!current) {
|
||
return false;
|
||
}
|
||
return JSON.stringify(current) !== JSON.stringify(this.state().lastSavedBootstrap);
|
||
});
|
||
```
|
||
|
||
Update `loadBootstrap()` to seed `lastSavedBootstrap`:
|
||
|
||
```typescript
|
||
loadBootstrap(): void {
|
||
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' }));
|
||
},
|
||
error: () => this.state.update(current => ({ ...current, bootstrap: null, importError: 'builder.importError' })),
|
||
});
|
||
}
|
||
```
|
||
|
||
Add `save()` and `publish()` after `setDefaultLocale` / nav actions (from Tasks 2 and 4):
|
||
|
||
```typescript
|
||
save(): void {
|
||
const current = this.state().bootstrap;
|
||
if (!current) {
|
||
return;
|
||
}
|
||
this.state.update(state => ({ ...state, lastSavedBootstrap: JSON.parse(JSON.stringify(current)) }));
|
||
}
|
||
|
||
publish(): boolean {
|
||
const current = this.state().bootstrap;
|
||
if (!current || this.validationIssues().length > 0) {
|
||
return false;
|
||
}
|
||
this.runtime.reloadFromBootstrap(current);
|
||
this.state.update(state => ({
|
||
...state,
|
||
status: 'published',
|
||
lastSavedBootstrap: JSON.parse(JSON.stringify(current)),
|
||
}));
|
||
return true;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Manual verification**
|
||
|
||
This isn't wired to any button yet. Verify it compiles: `npx tsc -p tsconfig.app.json --noEmit`. Expected: no new errors — in particular no error about `PlatformRuntimeService` not having `reloadFromBootstrap` (it does, confirmed at `src/app/core/runtime/platform-runtime.service.ts:60`). Full interactive verification happens in Task 9.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/app/features/project-editor/models/project-editor.model.ts src/app/features/project-editor/facade/project-editor.facade.ts
|
||
git commit -m "feat(project-editor): add draft/publish status, dirty tracking, save/publish to facade"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Sticky Save/Publish bar
|
||
|
||
**Files:**
|
||
- Create: `src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.ts`
|
||
- Create: `src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.html`
|
||
- Create: `src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.scss`
|
||
- Modify: `src/app/features/project-editor/pages/project-editor-page.component.ts`
|
||
- Modify: `src/app/features/project-editor/pages/project-editor-page.component.html`
|
||
- Modify: `src/app/i18n/translations.ts`, `en.ts`, `ru.ts`, `hy.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ProjectEditorFacade.dirty`, `.status`, `.validationIssues`, `.save()`, `.publish()` (Task 8).
|
||
- Produces: nothing consumed elsewhere.
|
||
|
||
- [ ] **Step 1: Component**
|
||
|
||
Create `src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.ts`:
|
||
|
||
```typescript
|
||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||
import { ProjectEditorFacade } from '../../facade/project-editor.facade';
|
||
|
||
@Component({
|
||
selector: 'app-project-editor-save-bar',
|
||
standalone: true,
|
||
imports: [TranslatePipe],
|
||
templateUrl: './project-editor-save-bar.component.html',
|
||
styleUrls: ['./project-editor-save-bar.component.scss'],
|
||
changeDetection: ChangeDetectionStrategy.OnPush
|
||
})
|
||
export class ProjectEditorSaveBarComponent {
|
||
private readonly facade = inject(ProjectEditorFacade);
|
||
readonly dirty = this.facade.dirty;
|
||
readonly status = this.facade.status;
|
||
readonly issues = this.facade.validationIssues;
|
||
|
||
save(): void {
|
||
this.facade.save();
|
||
}
|
||
|
||
publish(): void {
|
||
this.facade.publish();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Template**
|
||
|
||
Create `src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.html`:
|
||
|
||
```html
|
||
<div class="project-editor-save-bar">
|
||
<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>
|
||
}
|
||
@if (issues().length > 0) {
|
||
<ul class="project-editor-save-bar-issues">
|
||
@for (issue of issues(); track issue.code) {
|
||
<li>{{ issue.message | translate }}</li>
|
||
}
|
||
</ul>
|
||
}
|
||
</div>
|
||
<div class="project-editor-save-bar-actions">
|
||
<button type="button" (click)="save()">{{ 'builder.save' | translate }}</button>
|
||
<button type="button" [disabled]="issues().length > 0" (click)="publish()">{{ 'builder.publish' | translate }}</button>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 3: Styles**
|
||
|
||
Create `src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.scss`:
|
||
|
||
```scss
|
||
.project-editor-save-bar {
|
||
position: sticky;
|
||
bottom: 0;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 1rem;
|
||
padding: 0.75rem 1rem;
|
||
background: var(--surface, #fff);
|
||
border-top: 1px solid var(--border, #ddd);
|
||
z-index: 5;
|
||
}
|
||
|
||
.project-editor-save-bar-dirty {
|
||
color: var(--warning, #b45309);
|
||
margin-left: 0.5rem;
|
||
}
|
||
|
||
.project-editor-save-bar-issues {
|
||
margin: 0.25rem 0 0;
|
||
padding-left: 1.25rem;
|
||
color: var(--danger, #b91c1c);
|
||
}
|
||
|
||
.project-editor-save-bar-actions button + button {
|
||
margin-left: 0.5rem;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Mount it in the page**
|
||
|
||
In `src/app/features/project-editor/pages/project-editor-page.component.ts`, add the import and register in `imports`:
|
||
|
||
```typescript
|
||
import { ProjectEditorSaveBarComponent } from '../components/save-bar/project-editor-save-bar.component';
|
||
```
|
||
|
||
In `src/app/features/project-editor/pages/project-editor-page.component.html`, add the save bar after the closing `</section>` of `project-editor-stack` (still inside the `@else` block, as a sibling after the stack section):
|
||
|
||
```html
|
||
<app-project-editor-save-bar />
|
||
```
|
||
|
||
- [ ] **Step 5: i18n keys**
|
||
|
||
In `src/app/i18n/translations.ts`, add to the `builder` interface:
|
||
|
||
```typescript
|
||
statusDraft: string;
|
||
statusPublished: string;
|
||
unsavedChanges: string;
|
||
save: string;
|
||
publish: string;
|
||
```
|
||
|
||
In `src/app/i18n/en.ts`, add:
|
||
|
||
```typescript
|
||
statusDraft: 'Draft',
|
||
statusPublished: 'Published',
|
||
unsavedChanges: 'Unsaved changes',
|
||
save: 'Save',
|
||
publish: 'Publish',
|
||
```
|
||
|
||
In `src/app/i18n/ru.ts`, add:
|
||
|
||
```typescript
|
||
statusDraft: 'Черновик',
|
||
statusPublished: 'Опубликовано',
|
||
unsavedChanges: 'Есть несохранённые изменения',
|
||
save: 'Сохранить',
|
||
publish: 'Опубликовать',
|
||
```
|
||
|
||
In `src/app/i18n/hy.ts`, add:
|
||
|
||
```typescript
|
||
statusDraft: 'Սևագիր',
|
||
statusPublished: 'Հրապարակված',
|
||
unsavedChanges: 'Չպահված փոփոխություններ',
|
||
save: 'Պահպանել',
|
||
publish: 'Հրապարակել',
|
||
```
|
||
|
||
- [ ] **Step 6: Manual verification**
|
||
|
||
Run `ng serve`, open `/edit/general`. Confirm the sticky bar at the bottom shows "Draft" and no "Unsaved changes" label initially. Change the marketplace name — confirm "Unsaved changes" appears immediately. If your test bootstrap has no `branding.logoUrl`, confirm a validation message ("Branding is missing a logo.") appears in the bar and the Publish button is disabled; set a logo URL on the Branding tab and confirm the message disappears and Publish becomes enabled. Click Save — confirm "Unsaved changes" disappears (status stays Draft). Click Publish — confirm status flips to "Published".
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/app/features/project-editor/components/save-bar/ src/app/features/project-editor/pages/project-editor-page.component.ts src/app/features/project-editor/pages/project-editor-page.component.html src/app/i18n/translations.ts src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts
|
||
git commit -m "feat(project-editor): add sticky save/publish bar with validation summary"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Warn before losing unsaved changes
|
||
|
||
**Files:**
|
||
- Create: `src/app/features/project-editor/guards/project-editor-dirty.guard.ts`
|
||
- Modify: `src/app/app.routes.ts`
|
||
- Modify: `src/app/features/project-editor/pages/project-editor-page.component.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ProjectEditorFacade.dirty` (Task 8).
|
||
- Produces: `projectEditorDirtyGuard: CanDeactivateFn<ProjectEditorPageComponent>`, wired as `canDeactivate` on the `edit/:section` route.
|
||
|
||
- [ ] **Step 1: Guard**
|
||
|
||
Create `src/app/features/project-editor/guards/project-editor-dirty.guard.ts`:
|
||
|
||
```typescript
|
||
import { inject } from '@angular/core';
|
||
import { CanDeactivateFn } from '@angular/router';
|
||
import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
||
import { ProjectEditorPageComponent } from '../pages/project-editor-page.component';
|
||
|
||
export const projectEditorDirtyGuard: CanDeactivateFn<ProjectEditorPageComponent> = () => {
|
||
const facade = inject(ProjectEditorFacade);
|
||
if (!facade.dirty()) {
|
||
return true;
|
||
}
|
||
return window.confirm('You have unsaved changes. Leave anyway?');
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 2: Wire it into the route**
|
||
|
||
In `src/app/app.routes.ts`, update the `edit/:section` route added in Task 1:
|
||
|
||
```typescript
|
||
{
|
||
path: 'edit/:section',
|
||
loadComponent: () => import('./features/project-editor/pages/project-editor-page.component').then(m => m.ProjectEditorPageComponent),
|
||
canDeactivate: [() => import('./features/project-editor/guards/project-editor-dirty.guard').then(m => m.projectEditorDirtyGuard())]
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 3: Warn on tab/window close**
|
||
|
||
In `src/app/features/project-editor/pages/project-editor-page.component.ts`, add `HostListener` import and a listener method:
|
||
|
||
```typescript
|
||
import { ChangeDetectionStrategy, Component, HostListener, effect, inject } from '@angular/core';
|
||
```
|
||
|
||
Add inside the class body:
|
||
|
||
```typescript
|
||
@HostListener('window:beforeunload', ['$event'])
|
||
warnBeforeUnload(event: BeforeUnloadEvent): void {
|
||
if (this.facade.dirty()) {
|
||
event.preventDefault();
|
||
event.returnValue = '';
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Manual verification**
|
||
|
||
Run `ng serve`, open `/edit/general`, change the marketplace name (dirty state is now true). Try to navigate to `/catalog` via the URL bar or a link — confirm a native "Leave site?" confirm dialog appears; cancel it and confirm you're still on the editor. Confirm it, and confirm you land on `/catalog`. Reload the editor, make a change, then try closing/reloading the tab — confirm the browser's native "leave site" prompt appears (exact wording is browser-controlled, not customizable — this is expected `beforeunload` behavior).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/app/features/project-editor/guards/project-editor-dirty.guard.ts src/app/app.routes.ts src/app/features/project-editor/pages/project-editor-page.component.ts
|
||
git commit -m "feat(project-editor): warn before leaving with unsaved changes"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Documentation
|
||
|
||
**Files:**
|
||
- Modify: `docs/Project-Editor.md`
|
||
- Create: `docs/context/features/project-editor/FACTS.jsonl`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing (documentation only).
|
||
- Produces: nothing consumed by code — this is the "every new feature must include documentation" deliverable from the platform's coding rules and this plan's Global Constraints.
|
||
|
||
- [ ] **Step 1: Update `docs/Project-Editor.md`**
|
||
|
||
Replace the `## Supported Sections` list item order and add new subsections after `Preview`, and replace `## Future Backend Endpoints` with a section documenting the current client-side draft/publish behavior and the still-needed backend contract. Insert after the existing `- Preview` bullet block (before `## Preview Strategy`):
|
||
|
||
```markdown
|
||
- Languages (Sprint 16)
|
||
- add/remove supported locale
|
||
- set default locale
|
||
- generically syncs translation keys across static pages and navigation labels (`LocaleSyncService`)
|
||
- Navigation (Sprint 16)
|
||
- header navigation: add/remove/reorder/edit label/URL/visibility
|
||
- flat footer navigation: same actions
|
||
- grouped footer navigation (column-based) is read-only in this tab for now
|
||
```
|
||
|
||
Replace the `## Future Backend Endpoints` section with:
|
||
|
||
```markdown
|
||
## Draft / Publish (Sprint 16)
|
||
|
||
There is still no backend draft/publish API. This sprint models it client-side
|
||
in `ProjectEditorFacade`:
|
||
- `status: 'draft' | 'published'` and `dirty` (diffed against the
|
||
last-saved snapshot) live in facade state.
|
||
- `save()` snapshots the current in-memory bootstrap as "last saved" (no
|
||
network call yet).
|
||
- `publish()` runs `ProjectValidator`, and if there are no issues, applies
|
||
the bootstrap via `PlatformRuntimeService.reloadFromBootstrap` and marks
|
||
status `published`.
|
||
|
||
**Backend gap, not yet implemented:** real persistence needs
|
||
`PUT /builder/bootstrap/draft` and `POST /builder/bootstrap/publish`
|
||
endpoints so drafts/publishes survive a reload and are shared across editors.
|
||
|
||
## Validation
|
||
|
||
`ProjectValidator` (`services/project-validator.service.ts`) runs on every
|
||
render of the save bar: missing logo, no languages, invalid marketplace URL,
|
||
duplicate static-page slugs, empty homepage, a homepage widget with no
|
||
`type`, duplicate header navigation links, invalid theme colors. Publish is
|
||
blocked while any issue is present; Save is not.
|
||
|
||
## Rich HTML editing
|
||
|
||
Static page HTML is edited via `MarketplaceHtmlEditorComponent`
|
||
(`components/html-editor/`), a `contentEditable` + toolbar component with no
|
||
external dependency. It emits raw HTML on every change and never sanitizes —
|
||
sanitization remains a storefront-render concern.
|
||
```
|
||
|
||
- [ ] **Step 2: Fact pack**
|
||
|
||
Create the directory and file `docs/context/features/project-editor/FACTS.jsonl`:
|
||
|
||
```
|
||
{"id":"PE-20260713T010000Z-0001","subject":"project-editor-routing","predicate":"is","object":"flat routes under /edit/:section (no projectId — a project is the domain-resolved tenant); /builder and /project-editor redirect to /edit/general","src":["docs/superpowers/specs/2026-07-13-marketplace-project-editor-sprint16-design.md","src/app/app.routes.ts"],"status":"active","kind":"decision","updated_at":"2026-07-13T01:00:00Z","confidence":"high","tags":["project-editor","routing"]}
|
||
{"id":"PE-20260713T010000Z-0002","subject":"locale-sync","predicate":"is-implemented-by","object":"LocaleSyncService, which generically adds/removes a locale key across static page translations and navigation labels without per-field hardcoding","src":["src/app/features/project-editor/services/locale-sync.service.ts"],"status":"active","kind":"implemented","confidence":"high","updated_at":"2026-07-13T01:00:00Z","tags":["project-editor","i18n"]}
|
||
{"id":"PE-20260713T010000Z-0003","subject":"draft-publish-flow","predicate":"is","object":"client-side only (ProjectEditorFacade.status/dirty/save/publish) because no backend draft/publish endpoint exists yet; PUT /builder/bootstrap/draft and POST /builder/bootstrap/publish are the documented backend gap","src":["docs/Project-Editor.md","src/app/features/project-editor/facade/project-editor.facade.ts"],"status":"active","kind":"constraint","confidence":"high","updated_at":"2026-07-13T01:00:00Z","tags":["project-editor","backend-gap"]}
|
||
{"id":"PE-20260713T010000Z-0004","subject":"html-editing","predicate":"uses","object":"MarketplaceHtmlEditorComponent, a contentEditable + toolbar component with no external rich-text dependency; emits raw HTML, never sanitizes during editing","src":["src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.ts"],"status":"active","kind":"decision","confidence":"high","updated_at":"2026-07-13T01:00:00Z","tags":["project-editor","html-editor"]}
|
||
{"id":"PE-20260713T010000Z-0005","subject":"navigation-tab","predicate":"supports","object":"header navigation and flat-list footer navigation (add/remove/reorder/edit); grouped-column footer navigation is read-only until a future sprint","src":["src/app/features/project-editor/sections/navigation-section.component.ts"],"status":"active","kind":"constraint","confidence":"high","updated_at":"2026-07-13T01:00:00Z","tags":["project-editor","navigation"]}
|
||
```
|
||
|
||
- [ ] **Step 3: Manual verification**
|
||
|
||
Read both files back to confirm they render/parse: `cat docs/Project-Editor.md` (visually check the new sections landed in the right place) and validate the JSONL parses one-object-per-line: run `node -e "require('fs').readFileSync('docs/context/features/project-editor/FACTS.jsonl','utf8').trim().split('\n').forEach(l => JSON.parse(l))"` — expected: no output (no thrown error) means every line is valid JSON.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add docs/Project-Editor.md docs/context/features/project-editor/FACTS.jsonl
|
||
git commit -m "docs(project-editor): document Sprint 16 tabs, draft/publish gap, and validation rules"
|
||
```
|
||
|
||
---
|
||
|
||
## Follow-ups (explicitly out of scope, do not fold into the above)
|
||
|
||
- No test runner exists in this repo (`package.json` has none). Recommend a follow-up to add Karma+Jasmine or Jest so this feature (and everything else) gets real automated tests.
|
||
- Real backend `PUT /builder/bootstrap/draft` / `POST /builder/bootstrap/publish` endpoints.
|
||
- A genuinely separate Angular app/deployment for `admin.marketplace.com`, replacing this sprint's same-build flat routes.
|
||
- Grouped (column-based) footer navigation editing.
|