feat: Page Editor UX Phase 2 - unsaved changes panel, property search, reset property, empty states
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

- ProjectEditorFacade.resetField(key): field-level revert-to-original, reusing getByPath + a new immutable setByPath (write-side counterpart, scalar/object dot-paths only).
- app-form-field gains showReset/resetLabel/(resetClicked) - a reusable per-field reset affordance, wired on branding logo/title and theme primary/background color.
- Save bar: unsaved-changes count is now clickable, expanding a field-level diff list (reuses facade.changeSummary(), already built for the Preview tab) with jump-to-section links.
- BuilderPropertySearchComponent: filters EditorSchemaService.all() by translated label/hint, jumps to the owning section - no new registry, reuses the existing schema.
- navigation-section: app-empty-state (existing component) added for empty header/footer link lists.
- Reset Section and Draft/Published badge were already implemented; not touched.
- New copy added as translation keys (en/ru/hy).
This commit is contained in:
sdarbinyan
2026-07-27 11:12:35 +04:00
parent 42f11dd8c0
commit 65c6d6f5d1
23 changed files with 432 additions and 9 deletions

View File

@@ -0,0 +1,32 @@
<div class="builder-property-search">
<label class="builder-property-search__label" for="builder-property-search-input">
<app-icon name="search" [size]="14" aria-hidden="true" />
<span class="sr-only">{{ 'builder.propertySearchLabel' | translate }}</span>
</label>
<input
id="builder-property-search-input"
type="search"
class="builder-property-search__input"
[placeholder]="'builder.propertySearchPlaceholder' | translate"
[attr.aria-label]="'builder.propertySearchLabel' | translate"
[ngModel]="query()"
(ngModelChange)="query.set($event)"
(focus)="onFocus()"
(blur)="onBlur()"
/>
@if (open() && query().trim()) {
<ul class="builder-property-search__results" [attr.aria-label]="'builder.propertySearchResultsLabel' | translate">
@for (result of results(); track result.key) {
<li>
<a [routerLink]="['/edit', result.sectionId] | langRoute" (click)="selectResult()">
<span class="builder-property-search__result-label">{{ result.label }}</span>
<span class="builder-property-search__result-section">{{ result.sectionLabel }}</span>
</a>
</li>
} @empty {
<li class="builder-property-search__empty">{{ 'builder.propertySearchNoResults' | translate }}</li>
}
</ul>
}
</div>

View File

@@ -0,0 +1,76 @@
.builder-property-search {
position: relative;
}
.builder-property-search__label {
position: absolute;
left: var(--space-sm, 0.5rem);
top: 50%;
transform: translateY(-50%);
display: flex;
color: var(--text-secondary, #6b7280);
pointer-events: none;
}
.builder-property-search__input {
width: 100%;
height: 36px;
padding: 0 var(--space-sm, 0.5rem) 0 32px;
border: 1px solid var(--border-color, #e4e4e7);
border-radius: var(--radius-md, 6px);
background: var(--bg-primary, #fff);
color: var(--text-primary, #1f322d);
font-family: inherit;
font-size: var(--font-size-sm, 0.8125rem);
&:focus-visible {
outline: none;
border-color: var(--primary-color, #2f6e5d);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color, #2f6e5d) 25%, transparent);
}
}
.builder-property-search__results {
position: absolute;
z-index: 50;
top: calc(100% + 4px);
left: 0;
right: 0;
margin: 0;
padding: var(--space-xs, 0.25rem);
list-style: none;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e4e4e7);
border-radius: var(--radius-md, 6px);
box-shadow: var(--shadow-md, 0 4px 12px rgba(0, 0, 0, 0.08));
max-height: 300px;
overflow-y: auto;
a {
display: flex;
justify-content: space-between;
gap: var(--space-sm, 0.5rem);
padding: var(--space-xs, 0.25rem) var(--space-sm, 0.5rem);
border-radius: var(--radius-sm, 4px);
color: var(--text-primary, #1f322d);
text-decoration: none;
font-size: var(--font-size-sm, 0.8125rem);
&:hover,
&:focus-visible {
background: var(--bg-secondary, #f4f4f5);
outline: none;
}
}
}
.builder-property-search__result-section {
color: var(--text-secondary, #6b7280);
font-size: var(--font-size-xs, 0.75rem);
}
.builder-property-search__empty {
padding: var(--space-xs, 0.25rem) var(--space-sm, 0.5rem);
color: var(--text-secondary, #6b7280);
font-size: var(--font-size-sm, 0.8125rem);
}

View File

@@ -0,0 +1,92 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { EditorSchemaService } from '../../schema/editor-schema.service';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { TranslateService } from '../../../../i18n/translate.service';
import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
import { IconComponent } from '../../../../shared/ui/icon/icon.component';
import { RouterLink } from '@angular/router';
interface PropertySearchResult {
key: string;
label: string;
hint: string;
sectionId: string;
sectionLabel: string;
}
const SECTION_LABEL_KEYS: Record<string, string> = {
general: 'builder.general',
branding: 'builder.branding',
theme: 'builder.theme',
header: 'builder.header',
footer: 'builder.footer',
homepage: 'builder.homepage',
widgets: 'builder.widgets',
'static-pages': 'builder.staticPages',
features: 'builder.marketplaceFeatures',
languages: 'builder.languagesTab',
navigation: 'builder.navigationTab',
preview: 'builder.preview',
};
/**
* Filters the field-schema registry (EditorSchemaService.all(), the single source of
* truth already used for validation/readiness) by translated label/hint text, so a
* non-technical admin can find "where do I change X" without knowing which section
* it lives in. Selecting a result jumps to that field's section.
*/
@Component({
selector: 'app-builder-property-search',
standalone: true,
imports: [FormsModule, TranslatePipe, RouterLink, LangRoutePipe, IconComponent],
templateUrl: './builder-property-search.component.html',
styleUrl: './builder-property-search.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BuilderPropertySearchComponent {
private readonly schema = inject(EditorSchemaService);
private readonly translate = inject(TranslateService);
readonly query = signal('');
readonly open = signal(false);
private readonly allResults = computed<PropertySearchResult[]>(() =>
this.schema.all().map(field => ({
key: field.key,
label: this.translate.t(field.labelKey),
hint: field.hintKey ? this.translate.t(field.hintKey) : '',
sectionId: field.section,
sectionLabel: this.translate.t(SECTION_LABEL_KEYS[field.section] ?? field.section),
}))
);
readonly results = computed<PropertySearchResult[]>(() => {
const term = this.query().trim().toLowerCase();
if (!term) {
return [];
}
return this.allResults()
.filter(result =>
result.label.toLowerCase().includes(term) ||
result.hint.toLowerCase().includes(term) ||
result.sectionLabel.toLowerCase().includes(term)
)
.slice(0, 8);
});
onFocus(): void {
this.open.set(true);
}
onBlur(): void {
// Deferred so a click on a result (which fires before blur settles) still registers.
setTimeout(() => this.open.set(false), 150);
}
/** RouterLink on the result anchor handles navigation; this just resets the search UI after the click. */
selectResult(): void {
this.query.set('');
this.open.set(false);
}
}

View File

@@ -8,7 +8,9 @@
<div class="project-editor-save-bar-status" role="status" aria-live="polite"> <div class="project-editor-save-bar-status" role="status" aria-live="polite">
<span>{{ (status() === 'published' ? 'builder.statusPublished' : 'builder.statusDraft') | translate }}</span> <span>{{ (status() === 'published' ? 'builder.statusPublished' : 'builder.statusDraft') | translate }}</span>
@if (dirty()) { @if (dirty()) {
<span class="project-editor-save-bar-dirty">{{ 'builder.unsavedChanges' | translate }}</span> <button type="button" class="project-editor-save-bar-dirty project-editor-save-bar-dirty--toggle" [attr.aria-expanded]="changesOpen()" (click)="toggleChanges()">
{{ 'builder.unsavedChanges' | translate }} ({{ changeSummary().length }})
</button>
} @else if (lastSavedAt()) { } @else if (lastSavedAt()) {
<span class="project-editor-save-bar-saved-at">{{ 'builder.lastSaved' | translate }}: {{ formatSavedAt(lastSavedAt()!) }}</span> <span class="project-editor-save-bar-saved-at">{{ 'builder.lastSaved' | translate }}: {{ formatSavedAt(lastSavedAt()!) }}</span>
} }
@@ -20,6 +22,18 @@
</ul> </ul>
} }
</div> </div>
@if (changesOpen() && changeSummary().length > 0) {
<ul class="project-editor-save-bar-changes" [attr.aria-label]="'builder.unsavedChangesPanelTitle' | translate">
@for (row of changeSummary(); track row.fieldKey) {
<li>
<a [routerLink]="row.section ? (['/edit', row.section] | langRoute) : null" (click)="toggleChanges()">
<span class="project-editor-save-bar-changes__field">{{ row.labelKey | translate }}</span>
<span class="project-editor-save-bar-changes__diff">{{ row.before || '—' }} &rarr; {{ row.after || '—' }}</span>
</a>
</li>
}
</ul>
}
<div class="project-editor-save-bar-actions"> <div class="project-editor-save-bar-actions">
<app-button variant="ghost" size="sm" [disabled]="!canUndo()" (click)="undo()">{{ 'builder.undo' | translate }}</app-button> <app-button variant="ghost" size="sm" [disabled]="!canUndo()" (click)="undo()">{{ 'builder.undo' | translate }}</app-button>
<app-button variant="ghost" size="sm" [disabled]="!canRedo()" (click)="redo()">{{ 'builder.redo' | translate }}</app-button> <app-button variant="ghost" size="sm" [disabled]="!canRedo()" (click)="redo()">{{ 'builder.redo' | translate }}</app-button>

View File

@@ -16,6 +16,62 @@
margin-left: 0.5rem; margin-left: 0.5rem;
} }
.project-editor-save-bar-dirty--toggle {
border: none;
background: none;
padding: 0;
font: inherit;
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
&:focus-visible {
outline: 2px solid var(--primary-color, #2f6e5d);
outline-offset: 2px;
}
}
.project-editor-save-bar-changes {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
margin: 0;
padding: var(--space-xs, 0.25rem);
list-style: none;
max-height: 220px;
overflow-y: auto;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #ddd);
border-bottom: none;
box-shadow: var(--shadow-md, 0 -4px 12px rgba(0, 0, 0, 0.08));
a {
display: flex;
justify-content: space-between;
gap: var(--space-sm, 0.5rem);
padding: var(--space-xs, 0.25rem) var(--space-sm, 0.5rem);
border-radius: var(--radius-sm, 4px);
color: var(--text-primary, #1f322d);
text-decoration: none;
font-size: var(--font-size-sm, 0.8125rem);
&:hover,
&:focus-visible {
background: var(--bg-secondary, #f4f4f5);
outline: none;
}
}
}
.project-editor-save-bar-changes__diff {
color: var(--text-secondary, #6b7280);
font-size: var(--font-size-xs, 0.75rem);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-editor-save-bar-issues { .project-editor-save-bar-issues {
margin: 0.25rem 0 0; margin: 0.25rem 0 0;
padding-left: 1.25rem; padding-left: 1.25rem;

View File

@@ -1,6 +1,8 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { TranslateService } from '../../../../i18n/translate.service'; import { TranslateService } from '../../../../i18n/translate.service';
import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
import { ProjectEditorFacade } from '../../facade/project-editor.facade'; import { ProjectEditorFacade } from '../../facade/project-editor.facade';
import { ButtonComponent } from '../../../../shared/ui/button/button.component'; import { ButtonComponent } from '../../../../shared/ui/button/button.component';
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component'; import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
@@ -8,7 +10,7 @@ import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/con
@Component({ @Component({
selector: 'app-project-editor-save-bar', selector: 'app-project-editor-save-bar',
standalone: true, standalone: true,
imports: [TranslatePipe, ButtonComponent, ConfirmDialogComponent], imports: [TranslatePipe, ButtonComponent, ConfirmDialogComponent, RouterLink, LangRoutePipe],
templateUrl: './project-editor-save-bar.component.html', templateUrl: './project-editor-save-bar.component.html',
styleUrls: ['./project-editor-save-bar.component.scss'], styleUrls: ['./project-editor-save-bar.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -22,6 +24,12 @@ export class ProjectEditorSaveBarComponent {
readonly hasBlockingIssues = this.facade.hasBlockingIssues; readonly hasBlockingIssues = this.facade.hasBlockingIssues;
readonly canUndo = this.facade.canUndo; readonly canUndo = this.facade.canUndo;
readonly canRedo = this.facade.canRedo; readonly canRedo = this.facade.canRedo;
readonly changeSummary = this.facade.changeSummary;
readonly changesOpen = signal(false);
toggleChanges(): void {
this.changesOpen.update(open => !open);
}
undo(): void { undo(): void {
this.facade.undo(); this.facade.undo();

View File

@@ -453,6 +453,34 @@ export class ProjectEditorFacade {
}); });
} }
/**
* Reverts a single schema-registered field to its originally loaded/published value.
* Field-level counterpart to resetSection - same "revert to original" semantics, scoped to
* one dot-path instead of a whole bootstrap key. No confirmation needed: it's a single-value
* change that flows through updateBootstrap like any edit, so undo covers it.
*/
resetField(key: string): void {
const original = this.state().originalBootstrap;
if (!original) {
return;
}
const value = JSON.parse(JSON.stringify(this.schema.getByPath(original, key)));
this.updateBootstrap(current => this.setByPath(current, key, value));
}
/** Immutable dot-path setter, the write-side counterpart to EditorSchemaService.getByPath. Only used for resetField's scalar/object schema paths - none of the registered field keys index into array elements. */
private setByPath<T extends object>(source: T, key: string, value: unknown): T {
const [head, ...rest] = key.split('.');
if (rest.length === 0) {
return { ...source, [head]: value };
}
const child = (source as Record<string, unknown>)[head];
return {
...source,
[head]: this.setByPath(typeof child === 'object' && child !== null ? child : {}, rest.join('.'), value),
};
}
/** Discards the entire draft, reverting to the originally loaded/published bootstrap. Caller is responsible for confirmation UX. */ /** Discards the entire draft, reverting to the originally loaded/published bootstrap. Caller is responsible for confirmation UX. */
resetDraft(): void { resetDraft(): void {
const original = this.state().originalBootstrap; const original = this.state().originalBootstrap;

View File

@@ -13,6 +13,7 @@
<app-icon name="home" [size]="16" /> <app-icon name="home" [size]="16" />
{{ 'builder.backToDashboard' | translate }} {{ 'builder.backToDashboard' | translate }}
</a> </a>
<app-builder-property-search class="project-editor-layout__search" />
<app-project-editor-nav (linkClicked)="closeMobileDrawer()" /> <app-project-editor-nav (linkClicked)="closeMobileDrawer()" />
</aside> </aside>

View File

@@ -309,6 +309,11 @@
color: var(--text-primary); color: var(--text-primary);
} }
.project-editor-layout__search {
display: block;
margin-bottom: var(--space-md, 1rem);
}
.project-editor-live-preview__hint { .project-editor-live-preview__hint {
margin: 0 0 var(--space-sm, 0.5rem); margin: 0 0 var(--space-sm, 0.5rem);
font-size: var(--font-size-xs, 0.75rem); font-size: var(--font-size-xs, 0.75rem);

View File

@@ -26,6 +26,7 @@ import { ProjectEditorSaveBarComponent } from '../components/save-bar/project-ed
import { EDITOR_SECTION_BOOTSTRAP_KEYS, ProjectEditorSectionId } from '../models/project-editor.model'; import { EDITOR_SECTION_BOOTSTRAP_KEYS, ProjectEditorSectionId } from '../models/project-editor.model';
import { BUILDER_GROUPS, SECTION_TO_GROUP } from '../builder/builder-groups.model'; import { BUILDER_GROUPS, SECTION_TO_GROUP } from '../builder/builder-groups.model';
import { BuilderLivePreviewComponent } from '../components/live-preview/builder-live-preview.component'; import { BuilderLivePreviewComponent } from '../components/live-preview/builder-live-preview.component';
import { BuilderPropertySearchComponent } from '../components/property-search/builder-property-search.component';
/** Sections that currently expose highlight-mapped fields (see HighlightSourceDirective usages) - showing the panel elsewhere would just be an inert, unhighlightable diagram. */ /** Sections that currently expose highlight-mapped fields (see HighlightSourceDirective usages) - showing the panel elsewhere would just be an inert, unhighlightable diagram. */
const SECTIONS_WITH_LIVE_PREVIEW: ProjectEditorSectionId[] = ['branding', 'theme', 'homepage']; const SECTIONS_WITH_LIVE_PREVIEW: ProjectEditorSectionId[] = ['branding', 'theme', 'homepage'];
@@ -73,6 +74,7 @@ const SECTION_LABEL_KEYS: Record<ProjectEditorSectionId, string> = {
ButtonComponent, ButtonComponent,
ConfirmDialogComponent, ConfirmDialogComponent,
BuilderLivePreviewComponent, BuilderLivePreviewComponent,
BuilderPropertySearchComponent,
], ],
templateUrl: './project-editor-page.component.html', templateUrl: './project-editor-page.component.html',
styleUrls: ['./project-editor-page.component.scss'], styleUrls: ['./project-editor-page.component.scss'],

View File

@@ -9,6 +9,9 @@
[error]="fieldError('branding.logoUrl')" [error]="fieldError('branding.logoUrl')"
[usedBy]="[('builder.usedByHeader' | translate), ('builder.usedByFooter' | translate)]" [usedBy]="[('builder.usedByHeader' | translate), ('builder.usedByFooter' | translate)]"
[usedByLabel]="'builder.usedByLabel' | translate" [usedByLabel]="'builder.usedByLabel' | translate"
[showReset]="isFieldModified('branding.logoUrl')"
[resetLabel]="'builder.resetField' | translate"
(resetClicked)="resetField('branding.logoUrl')"
> >
<app-image-field [value]="bootstrap.branding.logoUrl" (valueChange)="updateField('logoUrl', $event)" /> <app-image-field [value]="bootstrap.branding.logoUrl" (valueChange)="updateField('logoUrl', $event)" />
</app-form-field> </app-form-field>
@@ -28,6 +31,9 @@
[hint]="'builder.marketplaceTitleDesc' | translate" [hint]="'builder.marketplaceTitleDesc' | translate"
[usedBy]="[('builder.usedByHeader' | translate), ('builder.usedBySeo' | translate)]" [usedBy]="[('builder.usedByHeader' | translate), ('builder.usedBySeo' | translate)]"
[usedByLabel]="'builder.usedByLabel' | translate" [usedByLabel]="'builder.usedByLabel' | translate"
[showReset]="isFieldModified('seo.default.title')"
[resetLabel]="'builder.resetField' | translate"
(resetClicked)="resetField('seo.default.title')"
> >
<app-input [ngModel]="bootstrap.seo.default.title" (ngModelChange)="updateMarketplaceTitle($event)" /> <app-input [ngModel]="bootstrap.seo.default.title" (ngModelChange)="updateMarketplaceTitle($event)" />
</app-form-field> </app-form-field>

View File

@@ -29,6 +29,10 @@ export class ProjectEditorBrandingSectionComponent {
const messageKey = this.facade.fieldError(key); const messageKey = this.facade.fieldError(key);
return messageKey ? this.translate.t(messageKey) : null; return messageKey ? this.translate.t(messageKey) : null;
}; };
readonly isFieldModified = (key: string): boolean => this.facade.modifiedFields().has(key);
resetField(key: string): void {
this.facade.resetField(key);
}
updateField<K extends BrandingImageField | 'brandName'>(key: K, value: string): void { updateField<K extends BrandingImageField | 'brandName'>(key: K, value: string): void {
this.facade.updateBootstrap(current => ({ this.facade.updateBootstrap(current => ({

View File

@@ -11,6 +11,9 @@
</div> </div>
<div class="stack-list"> <div class="stack-list">
@if (headerLinks().length === 0) {
<app-empty-state [title]="'builder.navigationHeaderEmptyTitle' | translate" [description]="'builder.navigationHeaderEmptyDesc' | translate" />
}
@for (item of headerLinks(); track item.id) { @for (item of headerLinks(); track item.id) {
<article class="sub-card"> <article class="sub-card">
<div class="editor-actions"> <div class="editor-actions">
@@ -45,6 +48,9 @@
@if (footerLinks(); as footer) { @if (footerLinks(); as footer) {
<div class="stack-list"> <div class="stack-list">
@if (footer.length === 0) {
<app-empty-state [title]="'builder.navigationFooterEmptyTitle' | translate" [description]="'builder.navigationFooterEmptyDesc' | translate" />
}
@for (item of footer; track item.id) { @for (item of footer; track item.id) {
<article class="sub-card"> <article class="sub-card">
<div class="editor-actions"> <div class="editor-actions">

View File

@@ -10,12 +10,13 @@ import { SectionCardComponent } from '../../../shared/ui/section-card/section-ca
import { ToggleComponent } from '../../../shared/ui/toggle/toggle.component'; import { ToggleComponent } from '../../../shared/ui/toggle/toggle.component';
import { LocaleTabsComponent } from '../../../shared/ui/locale-tabs/locale-tabs.component'; import { LocaleTabsComponent } from '../../../shared/ui/locale-tabs/locale-tabs.component';
import { SelectComponent, SelectOption } from '../../../shared/ui/select/select.component'; import { SelectComponent, SelectOption } from '../../../shared/ui/select/select.component';
import { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state.component';
import { ContentManagementFacade } from '../../content-management/facade/content-management.facade'; import { ContentManagementFacade } from '../../content-management/facade/content-management.facade';
@Component({ @Component({
selector: 'app-project-editor-navigation-section', selector: 'app-project-editor-navigation-section',
standalone: true, standalone: true,
imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, SectionCardComponent, ToggleComponent, LocaleTabsComponent, SelectComponent], imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, SectionCardComponent, ToggleComponent, LocaleTabsComponent, SelectComponent, EmptyStateComponent],
templateUrl: './navigation-section.component.html', templateUrl: './navigation-section.component.html',
styleUrls: ['./section.shared.scss'], styleUrls: ['./section.shared.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -8,6 +8,9 @@
[error]="fieldError('theme.palette.primary')" [error]="fieldError('theme.palette.primary')"
[usedBy]="[('builder.usedByButtons' | translate), ('builder.usedByLinks' | translate), ('builder.usedByBadges' | translate)]" [usedBy]="[('builder.usedByButtons' | translate), ('builder.usedByLinks' | translate), ('builder.usedByBadges' | translate)]"
[usedByLabel]="'builder.usedByLabel' | translate" [usedByLabel]="'builder.usedByLabel' | translate"
[showReset]="isFieldModified('theme.palette.primary')"
[resetLabel]="'builder.resetField' | translate"
(resetClicked)="resetField('theme.palette.primary')"
><app-color-picker [ariaLabel]="'builder.primaryColor' | translate" [ngModel]="bootstrap.theme.palette.primary" (ngModelChange)="updatePalette('primary', $event)" /></app-form-field> ><app-color-picker [ariaLabel]="'builder.primaryColor' | translate" [ngModel]="bootstrap.theme.palette.primary" (ngModelChange)="updatePalette('primary', $event)" /></app-form-field>
<app-form-field [label]="'builder.secondaryColor' | translate" [hint]="'builder.secondaryColorDesc' | translate" [error]="fieldError('theme.palette.secondary')"><app-color-picker [ariaLabel]="'builder.secondaryColor' | translate" [ngModel]="bootstrap.theme.palette.secondary" (ngModelChange)="updatePalette('secondary', $event)" /></app-form-field> <app-form-field [label]="'builder.secondaryColor' | translate" [hint]="'builder.secondaryColorDesc' | translate" [error]="fieldError('theme.palette.secondary')"><app-color-picker [ariaLabel]="'builder.secondaryColor' | translate" [ngModel]="bootstrap.theme.palette.secondary" (ngModelChange)="updatePalette('secondary', $event)" /></app-form-field>
<app-form-field <app-form-field
@@ -17,6 +20,9 @@
[error]="fieldError('theme.palette.backgroundPrimary')" [error]="fieldError('theme.palette.backgroundPrimary')"
[usedBy]="[('builder.usedByPageBackground' | translate)]" [usedBy]="[('builder.usedByPageBackground' | translate)]"
[usedByLabel]="'builder.usedByLabel' | translate" [usedByLabel]="'builder.usedByLabel' | translate"
[showReset]="isFieldModified('theme.palette.backgroundPrimary')"
[resetLabel]="'builder.resetField' | translate"
(resetClicked)="resetField('theme.palette.backgroundPrimary')"
><app-color-picker [ariaLabel]="'builder.backgroundColor' | translate" [ngModel]="bootstrap.theme.palette.backgroundPrimary" (ngModelChange)="updatePalette('backgroundPrimary', $event)" /></app-form-field> ><app-color-picker [ariaLabel]="'builder.backgroundColor' | translate" [ngModel]="bootstrap.theme.palette.backgroundPrimary" (ngModelChange)="updatePalette('backgroundPrimary', $event)" /></app-form-field>
<app-form-field [label]="'builder.surfaceColor' | translate" [hint]="'builder.surfaceColorDesc' | translate" [error]="fieldError('theme.palette.backgroundSecondary')"><app-color-picker [ariaLabel]="'builder.surfaceColor' | translate" [ngModel]="bootstrap.theme.palette.backgroundSecondary" (ngModelChange)="updatePalette('backgroundSecondary', $event)" /></app-form-field> <app-form-field [label]="'builder.surfaceColor' | translate" [hint]="'builder.surfaceColorDesc' | translate" [error]="fieldError('theme.palette.backgroundSecondary')"><app-color-picker [ariaLabel]="'builder.surfaceColor' | translate" [ngModel]="bootstrap.theme.palette.backgroundSecondary" (ngModelChange)="updatePalette('backgroundSecondary', $event)" /></app-form-field>
<app-form-field [label]="'builder.textColor' | translate" [hint]="'builder.textColorDesc' | translate" [error]="fieldError('theme.palette.textPrimary')"><app-color-picker [ariaLabel]="'builder.textColor' | translate" [ngModel]="bootstrap.theme.palette.textPrimary" (ngModelChange)="updatePalette('textPrimary', $event)" /></app-form-field> <app-form-field [label]="'builder.textColor' | translate" [hint]="'builder.textColorDesc' | translate" [error]="fieldError('theme.palette.textPrimary')"><app-color-picker [ariaLabel]="'builder.textColor' | translate" [ngModel]="bootstrap.theme.palette.textPrimary" (ngModelChange)="updatePalette('textPrimary', $event)" /></app-form-field>

View File

@@ -38,6 +38,10 @@ export class ProjectEditorThemeSectionComponent {
const messageKey = this.facade.fieldError(key); const messageKey = this.facade.fieldError(key);
return messageKey ? this.translate.t(messageKey) : null; return messageKey ? this.translate.t(messageKey) : null;
}; };
readonly isFieldModified = (key: string): boolean => this.facade.modifiedFields().has(key);
resetField(key: string): void {
this.facade.resetField(key);
}
readonly themeModeOptions: ThemeModeOption[] = [ readonly themeModeOptions: ThemeModeOption[] = [
{ value: 'light', labelKey: 'builder.themeModeLight', descKey: 'builder.themeModeLightDesc' }, { value: 'light', labelKey: 'builder.themeModeLight', descKey: 'builder.themeModeLightDesc' },

View File

@@ -880,6 +880,16 @@ export const en: Translations = {
usedByLinks: 'Links', usedByLinks: 'Links',
usedByBadges: 'Badges', usedByBadges: 'Badges',
usedByPageBackground: 'Page background', usedByPageBackground: 'Page background',
resetField: 'Reset',
propertySearchLabel: 'Search settings',
propertySearchPlaceholder: 'Search settings…',
propertySearchResultsLabel: 'Search results',
propertySearchNoResults: 'No matching settings.',
unsavedChangesPanelTitle: 'Unsaved changes',
navigationHeaderEmptyTitle: 'No header links yet',
navigationHeaderEmptyDesc: 'Add a link so customers can navigate from the header.',
navigationFooterEmptyTitle: 'No footer links yet',
navigationFooterEmptyDesc: 'Add a link so customers can navigate from the footer.',
addBlockHint: 'Add a block to your homepage:', addBlockHint: 'Add a block to your homepage:',
dragToReorder: 'Drag to reorder', dragToReorder: 'Drag to reorder',
blockRemoveConfirm: 'Remove this block from the homepage?', blockRemoveConfirm: 'Remove this block from the homepage?',

View File

@@ -880,6 +880,16 @@ export const hy: Translations = {
usedByLinks: 'Հղումներ', usedByLinks: 'Հղումներ',
usedByBadges: 'Կրծքանշաններ', usedByBadges: 'Կրծքանշաններ',
usedByPageBackground: 'Էջի ֆոն', usedByPageBackground: 'Էջի ֆոն',
resetField: 'Վերականգնել',
propertySearchLabel: 'Որոնել կարգավորումներ',
propertySearchPlaceholder: 'Որոնել կարգավորումներ…',
propertySearchResultsLabel: 'Որոնման արդյունքներ',
propertySearchNoResults: 'Համընկնումներ չեն գտնվել։',
unsavedChangesPanelTitle: 'Չպահպանված փոփոխություններ',
navigationHeaderEmptyTitle: 'Վերնագրում հղումներ դեռ չկան',
navigationHeaderEmptyDesc: 'Ավելացրեք հղում, որպեսզի գնորդները կարողանան նավարկել վերնագրից։',
navigationFooterEmptyTitle: 'Ստորագրում հղումներ դեռ չկան',
navigationFooterEmptyDesc: 'Ավելացրեք հղում, որպեսզի գնորդները կարողանան նավարկել ստորագրից։',
addBlockHint: 'Ավելացնել բլոկ գլխավոր էջում.', addBlockHint: 'Ավելացնել բլոկ գլխավոր էջում.',
dragToReorder: 'Քաշեք՝ կարգը փոխելու համար', dragToReorder: 'Քաշեք՝ կարգը փոխելու համար',
blockRemoveConfirm: 'Հեռացնե՞լ այս բլոկը գլխավոր էջից։', blockRemoveConfirm: 'Հեռացնե՞լ այս բլոկը գլխավոր էջից։',

View File

@@ -880,6 +880,16 @@ export const ru: Translations = {
usedByLinks: 'Ссылки', usedByLinks: 'Ссылки',
usedByBadges: 'Значки', usedByBadges: 'Значки',
usedByPageBackground: 'Фон страницы', usedByPageBackground: 'Фон страницы',
resetField: 'Сбросить',
propertySearchLabel: 'Поиск настроек',
propertySearchPlaceholder: 'Поиск настроек…',
propertySearchResultsLabel: 'Результаты поиска',
propertySearchNoResults: 'Совпадений не найдено.',
unsavedChangesPanelTitle: 'Несохранённые изменения',
navigationHeaderEmptyTitle: 'В шапке пока нет ссылок',
navigationHeaderEmptyDesc: 'Добавьте ссылку, чтобы покупатели могли перемещаться из шапки сайта.',
navigationFooterEmptyTitle: 'В подвале пока нет ссылок',
navigationFooterEmptyDesc: 'Добавьте ссылку, чтобы покупатели могли перемещаться из подвала сайта.',
addBlockHint: 'Добавить блок на главную страницу:', addBlockHint: 'Добавить блок на главную страницу:',
dragToReorder: 'Перетащите, чтобы изменить порядок', dragToReorder: 'Перетащите, чтобы изменить порядок',
blockRemoveConfirm: 'Удалить этот блок с главной страницы?', blockRemoveConfirm: 'Удалить этот блок с главной страницы?',

View File

@@ -881,6 +881,16 @@ export interface Translations {
usedByLinks: string; usedByLinks: string;
usedByBadges: string; usedByBadges: string;
usedByPageBackground: string; usedByPageBackground: string;
resetField: string;
propertySearchLabel: string;
propertySearchPlaceholder: string;
propertySearchResultsLabel: string;
propertySearchNoResults: string;
unsavedChangesPanelTitle: string;
navigationHeaderEmptyTitle: string;
navigationHeaderEmptyDesc: string;
navigationFooterEmptyTitle: string;
navigationFooterEmptyDesc: string;
blockRemoveConfirm: string; blockRemoveConfirm: string;
blockHero: string; blockHero: string;
blockHeroDesc: string; blockHeroDesc: string;

View File

@@ -1,11 +1,19 @@
<div class="app-form-field"> <div class="app-form-field">
@if (label()) { @if (label()) {
<div class="app-form-field__label-row">
<label class="app-form-field__label" [for]="fieldId"> <label class="app-form-field__label" [for]="fieldId">
{{ label() }} {{ label() }}
@if (required()) { @if (required()) {
<span class="app-form-field__required" aria-hidden="true">*</span> <span class="app-form-field__required" aria-hidden="true">*</span>
} }
</label> </label>
@if (showReset()) {
<button type="button" class="app-form-field__reset" [attr.aria-label]="resetLabel()" (click)="resetClicked.emit()">
<app-icon name="refresh" [size]="12" />
{{ resetLabel() }}
</button>
}
</div>
} }
<div class="app-form-field__control"> <div class="app-form-field__control">

View File

@@ -4,12 +4,40 @@
gap: var(--space-xs, 0.25rem); gap: var(--space-xs, 0.25rem);
} }
.app-form-field__label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm, 0.5rem);
}
.app-form-field__label { .app-form-field__label {
font-size: var(--font-size-base, 0.875rem); font-size: var(--font-size-base, 0.875rem);
font-weight: var(--font-weight-semibold, 600); font-weight: var(--font-weight-semibold, 600);
color: var(--text-primary, #1f322d); color: var(--text-primary, #1f322d);
} }
.app-form-field__reset {
display: inline-flex;
align-items: center;
gap: 0.2rem;
border: none;
background: none;
padding: 0;
color: var(--text-secondary, #6b7280);
font-size: var(--font-size-xs, 0.75rem);
cursor: pointer;
&:hover {
color: var(--primary-color, #2f6e5d);
}
&:focus-visible {
outline: 2px solid var(--primary-color, #2f6e5d);
outline-offset: 2px;
}
}
.app-form-field__required { .app-form-field__required {
color: var(--error-color, #c0392b); color: var(--error-color, #c0392b);
margin-left: 0.125rem; margin-left: 0.125rem;

View File

@@ -1,4 +1,5 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { IconComponent } from '../icon/icon.component';
let nextFormFieldId = 0; let nextFormFieldId = 0;
@@ -14,6 +15,7 @@ export abstract class FormFieldContext {
@Component({ @Component({
selector: 'app-form-field', selector: 'app-form-field',
standalone: true, standalone: true,
imports: [IconComponent],
templateUrl: './form-field.component.html', templateUrl: './form-field.component.html',
styleUrl: './form-field.component.scss', styleUrl: './form-field.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -27,6 +29,10 @@ export class FormFieldComponent implements FormFieldContext {
/** Optional "Where is this used?" list, e.g. ['Buttons', 'Links', 'Badges']. Already-translated strings. */ /** Optional "Where is this used?" list, e.g. ['Buttons', 'Links', 'Badges']. Already-translated strings. */
readonly usedBy = input<readonly string[] | null>(null); readonly usedBy = input<readonly string[] | null>(null);
readonly usedByLabel = input<string | null>(null); readonly usedByLabel = input<string | null>(null);
/** Shows a small "reset to original" button next to the label when the field has been changed. */
readonly showReset = input(false);
readonly resetLabel = input<string | null>(null);
readonly resetClicked = output<void>();
readonly fieldId = `app-form-field-${++nextFormFieldId}`; readonly fieldId = `app-form-field-${++nextFormFieldId}`;
protected readonly hintId = `${this.fieldId}-hint`; protected readonly hintId = `${this.fieldId}-hint`;