feat(project-editor): reusable image-field (thumbnail+replace+remove), branding OG image+gallery

- shared app-image-field component: thumbnail preview, replace, remove, opens media-picker
- branding: add socialImageUrl + galleryUrls fields, wire to new image-field
- footer: logo + payment icon fields use image-field (drop manual media-picker plumbing)
- i18n: common.remove, adminCategories.replaceImage, builder.socialImage/gallery keys (en/ru/hy)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-07-17 15:22:45 +04:00
parent feda0f685f
commit b183410888
12 changed files with 175 additions and 71 deletions

View File

@@ -2,27 +2,32 @@
<app-section-card [title]="'builder.branding' | translate">
<div class="editor-grid two">
<app-form-field [label]="'builder.logo' | translate" [hint]="'builder.logoDesc' | translate" [error]="fieldError('branding.logoUrl')">
<div class="media-field-row">
<app-input [ngModel]="bootstrap.branding.logoUrl" (ngModelChange)="updateField('logoUrl', $event)" />
<app-button variant="secondary" size="sm" (click)="openMediaPicker('logoUrl')">{{ 'adminCategories.chooseImage' | translate }}</app-button>
</div>
<app-image-field [value]="bootstrap.branding.logoUrl" (valueChange)="updateField('logoUrl', $event)" />
</app-form-field>
<app-form-field [label]="'builder.smallLogo' | translate" [hint]="'builder.smallLogoDesc' | translate">
<div class="media-field-row">
<app-input [ngModel]="bootstrap.branding.logoCompactUrl || ''" (ngModelChange)="updateField('logoCompactUrl', $event)" />
<app-button variant="secondary" size="sm" (click)="openMediaPicker('logoCompactUrl')">{{ 'adminCategories.chooseImage' | translate }}</app-button>
</div>
<app-image-field [value]="bootstrap.branding.logoCompactUrl || ''" (valueChange)="updateField('logoCompactUrl', $event)" />
</app-form-field>
<app-form-field [label]="'builder.favicon' | translate" [hint]="'builder.faviconDesc' | translate">
<div class="media-field-row">
<app-input [ngModel]="bootstrap.branding.faviconUrl" (ngModelChange)="updateField('faviconUrl', $event)" />
<app-button variant="secondary" size="sm" (click)="openMediaPicker('faviconUrl')">{{ 'adminCategories.chooseImage' | translate }}</app-button>
</div>
<app-image-field [value]="bootstrap.branding.faviconUrl" (valueChange)="updateField('faviconUrl', $event)" />
</app-form-field>
<app-form-field [label]="'builder.marketplaceTitle' | translate" [hint]="'builder.marketplaceTitleDesc' | translate">
<app-form-field [label]="'builder.socialImage' | translate" [hint]="'builder.socialImageDesc' | translate">
<app-image-field [value]="bootstrap.branding.socialImageUrl || ''" (valueChange)="updateField('socialImageUrl', $event)" />
</app-form-field>
<app-form-field class="full" [label]="'builder.marketplaceTitle' | translate" [hint]="'builder.marketplaceTitleDesc' | translate">
<app-input [ngModel]="bootstrap.seo.default.title" (ngModelChange)="updateMarketplaceTitle($event)" />
</app-form-field>
</div>
<app-media-picker [open]="mediaPickerOpen" (selected)="onImagePicked($event)" (closed)="mediaPickerOpen = false" />
<app-form-field class="full" [label]="'builder.gallery' | translate" [hint]="'builder.galleryDesc' | translate">
<div class="stack-list">
@for (url of bootstrap.branding.galleryUrls ?? []; track $index) {
<div class="sub-card media-field-row">
<app-image-field [value]="url" (valueChange)="updateGalleryImage($index, $event)" />
<app-button variant="ghost" size="sm" (click)="removeGalleryImage($index)">{{ 'common.remove' | translate }}</app-button>
</div>
}
<app-button variant="secondary" size="sm" (click)="addGalleryImage()">{{ 'builder.addGalleryImage' | translate }}</app-button>
</div>
</app-form-field>
</app-section-card>
}

View File

@@ -6,16 +6,15 @@ import { TranslateService } from '../../../i18n/translate.service';
import { InputComponent } from '../../../shared/ui/input/input.component';
import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component';
import { ButtonComponent } from '../../../shared/ui/button/button.component';
import { MediaPickerComponent } from '../../../shared/media/media-picker/media-picker.component';
import { MediaAsset } from '../../../core/media/models/media-asset.model';
import { ImageFieldComponent } from '../../../shared/ui/image-field/image-field.component';
import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component';
type BrandingImageField = 'logoUrl' | 'logoCompactUrl' | 'faviconUrl';
type BrandingImageField = 'logoUrl' | 'logoCompactUrl' | 'faviconUrl' | 'socialImageUrl';
@Component({
selector: 'app-project-editor-branding-section',
standalone: true,
imports: [FormsModule, TranslatePipe, InputComponent, FormFieldComponent, ButtonComponent, MediaPickerComponent, SectionCardComponent],
imports: [FormsModule, TranslatePipe, InputComponent, FormFieldComponent, ButtonComponent, ImageFieldComponent, SectionCardComponent],
templateUrl: './branding-section.component.html',
styleUrls: ['./section.shared.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -29,32 +28,41 @@ export class ProjectEditorBrandingSectionComponent {
return messageKey ? this.translate.t(messageKey) : null;
};
protected mediaPickerOpen = false;
private mediaPickerTarget: BrandingImageField | null = null;
updateField<K extends 'logoUrl' | 'logoCompactUrl' | 'faviconUrl' | 'brandName'>(key: K, value: string): void {
updateField<K extends BrandingImageField | 'brandName'>(key: K, value: string): void {
this.facade.updateBootstrap(current => ({
...current,
branding: { ...current.branding, [key]: value }
}));
}
openMediaPicker(target: BrandingImageField): void {
this.mediaPickerTarget = target;
this.mediaPickerOpen = true;
}
onImagePicked(asset: MediaAsset): void {
if (this.mediaPickerTarget) {
this.updateField(this.mediaPickerTarget, asset.url);
}
this.mediaPickerOpen = false;
}
updateMarketplaceTitle(value: string): void {
this.facade.updateBootstrap(current => ({
...current,
seo: { ...current.seo, default: { ...current.seo.default, title: value } }
}));
}
addGalleryImage(): void {
this.facade.updateBootstrap(current => ({
...current,
branding: { ...current.branding, galleryUrls: [...(current.branding.galleryUrls ?? []), ''] }
}));
}
updateGalleryImage(index: number, url: string): void {
this.facade.updateBootstrap(current => ({
...current,
branding: {
...current.branding,
galleryUrls: (current.branding.galleryUrls ?? []).map((existing, i) => (i === index ? url : existing))
}
}));
}
removeGalleryImage(index: number): void {
this.facade.updateBootstrap(current => ({
...current,
branding: { ...current.branding, galleryUrls: (current.branding.galleryUrls ?? []).filter((_, i) => i !== index) }
}));
}
}

View File

@@ -7,10 +7,7 @@
<app-form-field [label]="'builder.email' | translate" [hint]="'builder.emailDesc' | translate"><app-input [ngModel]="bootstrap.company.contacts.email" (ngModelChange)="updateEmail($event)" /></app-form-field>
<app-form-field class="full" [label]="'builder.copyright' | translate" [hint]="'builder.copyrightDesc' | translate"><app-input [ngModel]="copyrightValue()" (ngModelChange)="updateCopyright($event)" /></app-form-field>
<app-form-field class="full" [label]="'builder.footerLogo' | translate" [hint]="'builder.footerLogoDesc' | translate">
<div class="media-field-row">
<app-input [ngModel]="bootstrap.footer?.logoUrl || ''" (ngModelChange)="updateLogo($event)" />
<app-button variant="secondary" size="sm" (click)="openLogoPicker()">{{ 'adminCategories.chooseImage' | translate }}</app-button>
</div>
<app-image-field [value]="bootstrap.footer?.logoUrl || ''" (valueChange)="updateLogo($event)" />
</app-form-field>
<div class="full">
@@ -24,8 +21,7 @@
>
<ng-template let-row let-index="index">
<div class="media-field-row">
<app-input [ngModel]="row.src" (ngModelChange)="updatePaymentIconSrc(index, $event)" [placeholder]="'builder.iconLabel' | translate" />
<app-button variant="secondary" size="sm" (click)="openPaymentIconPicker(index)">{{ 'adminCategories.chooseImage' | translate }}</app-button>
<app-image-field [value]="row.src" (valueChange)="updatePaymentIconSrc(index, $event)" />
<app-input [ngModel]="row.alt" (ngModelChange)="updatePaymentIconAlt(index, $event)" [placeholder]="'builder.iconAltLabel' | translate" />
</div>
</ng-template>
@@ -60,6 +56,5 @@
<app-form-field class="full" [label]="'builder.staticPages' | translate" [hint]="'builder.staticPagesFieldDesc' | translate"><app-input [ngModel]="staticPagesValue()" (ngModelChange)="updateStaticPages($event)" /></app-form-field>
</div>
<app-media-picker [open]="mediaPickerOpen" (selected)="onImagePicked($event)" (closed)="mediaPickerOpen = false" />
</app-section-card>
}

View File

@@ -4,16 +4,12 @@ import { ProjectEditorFacade } from '../facade/project-editor.facade';
import { TranslatePipe } from '../../../i18n/translate.pipe';
import { InputComponent } from '../../../shared/ui/input/input.component';
import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component';
import { ButtonComponent } from '../../../shared/ui/button/button.component';
import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component';
import { KeyValueEditorComponent } from '../../../shared/ui/key-value-editor/key-value-editor.component';
import { MediaPickerComponent } from '../../../shared/media/media-picker/media-picker.component';
import { MediaAsset } from '../../../core/media/models/media-asset.model';
import { ImageFieldComponent } from '../../../shared/ui/image-field/image-field.component';
import { FooterPaymentIconConfig, FooterSocialLinkConfig } from '../../../shared/models/config';
import { isValidHttpUrl } from '../schema/validators/primitives';
type FooterMediaTarget = 'logo' | { type: 'paymentIcon'; index: number };
@Component({
selector: 'app-project-editor-footer-section',
standalone: true,
@@ -22,10 +18,9 @@ type FooterMediaTarget = 'logo' | { type: 'paymentIcon'; index: number };
TranslatePipe,
InputComponent,
FormFieldComponent,
ButtonComponent,
SectionCardComponent,
KeyValueEditorComponent,
MediaPickerComponent
ImageFieldComponent
],
templateUrl: './footer-section.component.html',
styleUrls: ['./section.shared.scss'],
@@ -43,9 +38,6 @@ export class ProjectEditorFooterSectionComponent {
readonly paymentIconRows = computed<FooterPaymentIconConfig[]>(() => this.bootstrap()?.footer?.paymentIcons ?? []);
readonly socialLinkRows = computed<FooterSocialLinkConfig[]>(() => this.bootstrap()?.footer?.socialLinks ?? []);
protected mediaPickerOpen = false;
private mediaPickerTarget: FooterMediaTarget | null = null;
readonly createPaymentIconRow = (): FooterPaymentIconConfig => ({ src: '', alt: '' });
readonly createSocialLinkRow = (): FooterSocialLinkConfig => {
@@ -67,26 +59,6 @@ export class ProjectEditorFooterSectionComponent {
this.facade.updateBootstrap(current => ({ ...current, footer: { ...current.footer, logoUrl: value } }));
}
openLogoPicker(): void {
this.mediaPickerTarget = 'logo';
this.mediaPickerOpen = true;
}
openPaymentIconPicker(index: number): void {
this.mediaPickerTarget = { type: 'paymentIcon', index };
this.mediaPickerOpen = true;
}
onImagePicked(asset: MediaAsset): void {
const target = this.mediaPickerTarget;
if (target === 'logo') {
this.updateLogo(asset.url);
} else if (target?.type === 'paymentIcon') {
this.updatePaymentIconSrc(target.index, asset.url);
}
this.mediaPickerOpen = false;
}
onPaymentIconsChange(rows: FooterPaymentIconConfig[]): void {
this.facade.updateBootstrap(current => ({ ...current, footer: { ...current.footer, paymentIcons: rows } }));
}