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:
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 } }));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user