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"> <app-section-card [title]="'builder.branding' | translate">
<div class="editor-grid two"> <div class="editor-grid two">
<app-form-field [label]="'builder.logo' | translate" [hint]="'builder.logoDesc' | translate" [error]="fieldError('branding.logoUrl')"> <app-form-field [label]="'builder.logo' | translate" [hint]="'builder.logoDesc' | translate" [error]="fieldError('branding.logoUrl')">
<div class="media-field-row"> <app-image-field [value]="bootstrap.branding.logoUrl" (valueChange)="updateField('logoUrl', $event)" />
<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-form-field> </app-form-field>
<app-form-field [label]="'builder.smallLogo' | translate" [hint]="'builder.smallLogoDesc' | translate"> <app-form-field [label]="'builder.smallLogo' | translate" [hint]="'builder.smallLogoDesc' | translate">
<div class="media-field-row"> <app-image-field [value]="bootstrap.branding.logoCompactUrl || ''" (valueChange)="updateField('logoCompactUrl', $event)" />
<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-form-field> </app-form-field>
<app-form-field [label]="'builder.favicon' | translate" [hint]="'builder.faviconDesc' | translate"> <app-form-field [label]="'builder.favicon' | translate" [hint]="'builder.faviconDesc' | translate">
<div class="media-field-row"> <app-image-field [value]="bootstrap.branding.faviconUrl" (valueChange)="updateField('faviconUrl', $event)" />
<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-form-field> </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-input [ngModel]="bootstrap.seo.default.title" (ngModelChange)="updateMarketplaceTitle($event)" />
</app-form-field> </app-form-field>
</div> </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> </app-section-card>
} }

View File

@@ -6,16 +6,15 @@ import { TranslateService } from '../../../i18n/translate.service';
import { InputComponent } from '../../../shared/ui/input/input.component'; import { InputComponent } from '../../../shared/ui/input/input.component';
import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component';
import { ButtonComponent } from '../../../shared/ui/button/button.component'; import { ButtonComponent } from '../../../shared/ui/button/button.component';
import { MediaPickerComponent } from '../../../shared/media/media-picker/media-picker.component'; import { ImageFieldComponent } from '../../../shared/ui/image-field/image-field.component';
import { MediaAsset } from '../../../core/media/models/media-asset.model';
import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component'; import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component';
type BrandingImageField = 'logoUrl' | 'logoCompactUrl' | 'faviconUrl'; type BrandingImageField = 'logoUrl' | 'logoCompactUrl' | 'faviconUrl' | 'socialImageUrl';
@Component({ @Component({
selector: 'app-project-editor-branding-section', selector: 'app-project-editor-branding-section',
standalone: true, standalone: true,
imports: [FormsModule, TranslatePipe, InputComponent, FormFieldComponent, ButtonComponent, MediaPickerComponent, SectionCardComponent], imports: [FormsModule, TranslatePipe, InputComponent, FormFieldComponent, ButtonComponent, ImageFieldComponent, SectionCardComponent],
templateUrl: './branding-section.component.html', templateUrl: './branding-section.component.html',
styleUrls: ['./section.shared.scss'], styleUrls: ['./section.shared.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -29,32 +28,41 @@ export class ProjectEditorBrandingSectionComponent {
return messageKey ? this.translate.t(messageKey) : null; return messageKey ? this.translate.t(messageKey) : null;
}; };
protected mediaPickerOpen = false; updateField<K extends BrandingImageField | 'brandName'>(key: K, value: string): void {
private mediaPickerTarget: BrandingImageField | null = null;
updateField<K extends 'logoUrl' | 'logoCompactUrl' | 'faviconUrl' | 'brandName'>(key: K, value: string): void {
this.facade.updateBootstrap(current => ({ this.facade.updateBootstrap(current => ({
...current, ...current,
branding: { ...current.branding, [key]: value } 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 { updateMarketplaceTitle(value: string): void {
this.facade.updateBootstrap(current => ({ this.facade.updateBootstrap(current => ({
...current, ...current,
seo: { ...current.seo, default: { ...current.seo.default, title: value } } 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 [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.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"> <app-form-field class="full" [label]="'builder.footerLogo' | translate" [hint]="'builder.footerLogoDesc' | translate">
<div class="media-field-row"> <app-image-field [value]="bootstrap.footer?.logoUrl || ''" (valueChange)="updateLogo($event)" />
<app-input [ngModel]="bootstrap.footer?.logoUrl || ''" (ngModelChange)="updateLogo($event)" />
<app-button variant="secondary" size="sm" (click)="openLogoPicker()">{{ 'adminCategories.chooseImage' | translate }}</app-button>
</div>
</app-form-field> </app-form-field>
<div class="full"> <div class="full">
@@ -24,8 +21,7 @@
> >
<ng-template let-row let-index="index"> <ng-template let-row let-index="index">
<div class="media-field-row"> <div class="media-field-row">
<app-input [ngModel]="row.src" (ngModelChange)="updatePaymentIconSrc(index, $event)" [placeholder]="'builder.iconLabel' | translate" /> <app-image-field [value]="row.src" (valueChange)="updatePaymentIconSrc(index, $event)" />
<app-button variant="secondary" size="sm" (click)="openPaymentIconPicker(index)">{{ 'adminCategories.chooseImage' | translate }}</app-button>
<app-input [ngModel]="row.alt" (ngModelChange)="updatePaymentIconAlt(index, $event)" [placeholder]="'builder.iconAltLabel' | translate" /> <app-input [ngModel]="row.alt" (ngModelChange)="updatePaymentIconAlt(index, $event)" [placeholder]="'builder.iconAltLabel' | translate" />
</div> </div>
</ng-template> </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> <app-form-field class="full" [label]="'builder.staticPages' | translate" [hint]="'builder.staticPagesFieldDesc' | translate"><app-input [ngModel]="staticPagesValue()" (ngModelChange)="updateStaticPages($event)" /></app-form-field>
</div> </div>
<app-media-picker [open]="mediaPickerOpen" (selected)="onImagePicked($event)" (closed)="mediaPickerOpen = false" />
</app-section-card> </app-section-card>
} }

View File

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

View File

@@ -427,6 +427,9 @@ export const en: Translations = {
logo: 'Logo', logo: 'Logo',
smallLogo: 'Small Logo', smallLogo: 'Small Logo',
favicon: 'Favicon', favicon: 'Favicon',
socialImage: 'Social Share Image',
gallery: 'Gallery',
addGalleryImage: 'Add image',
marketplaceTitle: 'Marketplace Title', marketplaceTitle: 'Marketplace Title',
primaryColor: 'Primary Color', primaryColor: 'Primary Color',
secondaryColor: 'Secondary Color', secondaryColor: 'Secondary Color',
@@ -572,6 +575,8 @@ export const en: Translations = {
logoDesc: 'Full logo image URL, shown in the header on desktop.', logoDesc: 'Full logo image URL, shown in the header on desktop.',
smallLogoDesc: 'Compact logo image URL, used where space is limited (e.g. mobile header).', smallLogoDesc: 'Compact logo image URL, used where space is limited (e.g. mobile header).',
faviconDesc: 'Browser tab icon image URL. Should be a small square image.', faviconDesc: 'Browser tab icon image URL. Should be a small square image.',
socialImageDesc: 'Default image shown when the marketplace is shared on social media (Open Graph).',
galleryDesc: 'Extra brand images available for future use across the storefront.',
marketplaceTitleDesc: 'The page title used by search engines and browser tabs.', marketplaceTitleDesc: 'The page title used by search engines and browser tabs.',
primaryColorDesc: 'The main brand color, used on primary buttons and highlights. Must be a valid hex color.', primaryColorDesc: 'The main brand color, used on primary buttons and highlights. Must be a valid hex color.',
secondaryColorDesc: 'A supporting brand color used for secondary UI elements. Must be a valid hex color.', secondaryColorDesc: 'A supporting brand color used for secondary UI elements. Must be a valid hex color.',
@@ -765,6 +770,7 @@ export const en: Translations = {
common: { common: {
retry: 'Try again', retry: 'Try again',
loading: 'Loading...', loading: 'Loading...',
remove: 'Remove',
}, },
location: { location: {
allRegions: 'All regions', allRegions: 'All regions',
@@ -923,6 +929,7 @@ export const en: Translations = {
}, },
adminCategories: { adminCategories: {
chooseImage: 'Choose image', chooseImage: 'Choose image',
replaceImage: 'Replace image',
}, },
adminProducts: { adminProducts: {
emptyTitle: 'No products found', emptyTitle: 'No products found',

View File

@@ -427,6 +427,9 @@ export const hy: Translations = {
logo: 'Լոգո', logo: 'Լոգո',
smallLogo: 'Փոքր լոգո', smallLogo: 'Փոքր լոգո',
favicon: 'Favicon', favicon: 'Favicon',
socialImage: 'Սոցիալական պատկեր',
gallery: 'Պատկերասրահ',
addGalleryImage: 'Ավելացնել պատկեր',
marketplaceTitle: 'Մարքեթփլեյսի վերնագիր', marketplaceTitle: 'Մարքեթփլեյսի վերնագիր',
primaryColor: 'Հիմնական գույն', primaryColor: 'Հիմնական գույն',
secondaryColor: 'Երկրորդական գույն', secondaryColor: 'Երկրորդական գույն',
@@ -572,6 +575,8 @@ export const hy: Translations = {
logoDesc: 'Ամբողջական լոգոյի պատկերի URL, ցուցադրվում է header-ում դեսքթոփի վրա։', logoDesc: 'Ամբողջական լոգոյի պատկերի URL, ցուցադրվում է header-ում դեսքթոփի վրա։',
smallLogoDesc: 'Կոմպակտ լոգոյի URL՝ սահմանափակ տարածքի համար (օր․՝ mobile header)։', smallLogoDesc: 'Կոմպակտ լոգոյի URL՝ սահմանափակ տարածքի համար (օր․՝ mobile header)։',
faviconDesc: 'Բրաուզերի ներդիրի պատկերակի URL։ Պետք է լինի փոքր քառակուսի պատկեր։', faviconDesc: 'Բրաուզերի ներդիրի պատկերակի URL։ Պետք է լինի փոքր քառակուսի պատկեր։',
socialImageDesc: 'Կանխադրված պատկեր, երբ մարքեթփլեյսը կիսվում է սոցիալական ցանցերում (Open Graph)։',
galleryDesc: 'Լրացուցիչ բրենդային պատկերներ ապագա օգտագործման համար։',
marketplaceTitleDesc: 'Էջի վերնագիրը որոնողական համակարգերի և բրաուզերի ներդիրների համար։', marketplaceTitleDesc: 'Էջի վերնագիրը որոնողական համակարգերի և բրաուզերի ներդիրների համար։',
primaryColorDesc: 'Բրենդի հիմնական գույնը՝ հիմնական կոճակների և ընդգծումների համար։ Պետք է վավեր hex գույն լինի։', primaryColorDesc: 'Բրենդի հիմնական գույնը՝ հիմնական կոճակների և ընդգծումների համար։ Պետք է վավեր hex գույն լինի։',
secondaryColorDesc: 'Բրենդի երկրորդական գույնը՝ երկրորդական տարրերի համար։ Պետք է վավեր hex գույն լինի։', secondaryColorDesc: 'Բրենդի երկրորդական գույնը՝ երկրորդական տարրերի համար։ Պետք է վավեր hex գույն լինի։',
@@ -765,6 +770,7 @@ export const hy: Translations = {
common: { common: {
retry: 'Փորձել կրկին', retry: 'Փորձել կրկին',
loading: 'Բեռնում...', loading: 'Բեռնում...',
remove: 'Հեռացնել',
}, },
location: { location: {
allRegions: 'Բոլոր տարածաշրջանները', allRegions: 'Բոլոր տարածաշրջանները',
@@ -918,6 +924,7 @@ export const hy: Translations = {
}, },
adminCategories: { adminCategories: {
chooseImage: 'Ընտրել պատկեր', chooseImage: 'Ընտրել պատկեր',
replaceImage: 'Փոխարինել պատկերը',
}, },
adminProducts: { adminProducts: {
emptyTitle: 'Ապրանքներ չեն գտնվել', emptyTitle: 'Ապրանքներ չեն գտնվել',

View File

@@ -427,6 +427,9 @@ export const ru: Translations = {
logo: 'Логотип', logo: 'Логотип',
smallLogo: 'Малый логотип', smallLogo: 'Малый логотип',
favicon: 'Favicon', favicon: 'Favicon',
socialImage: 'Изображение для соцсетей',
gallery: 'Галерея',
addGalleryImage: 'Добавить изображение',
marketplaceTitle: 'Заголовок маркетплейса', marketplaceTitle: 'Заголовок маркетплейса',
primaryColor: 'Основной цвет', primaryColor: 'Основной цвет',
secondaryColor: 'Вторичный цвет', secondaryColor: 'Вторичный цвет',
@@ -572,6 +575,8 @@ export const ru: Translations = {
logoDesc: 'URL полного логотипа, отображается в шапке на десктопе.', logoDesc: 'URL полного логотипа, отображается в шапке на десктопе.',
smallLogoDesc: 'URL компактного логотипа для ограниченного пространства (например, мобильная шапка).', smallLogoDesc: 'URL компактного логотипа для ограниченного пространства (например, мобильная шапка).',
faviconDesc: 'URL иконки вкладки браузера. Должен быть небольшим квадратным изображением.', faviconDesc: 'URL иконки вкладки браузера. Должен быть небольшим квадратным изображением.',
socialImageDesc: 'Изображение по умолчанию при публикации маркетплейса в соцсетях (Open Graph).',
galleryDesc: 'Дополнительные брендовые изображения для будущего использования в витрине.',
marketplaceTitleDesc: 'Заголовок страницы для поисковых систем и вкладок браузера.', marketplaceTitleDesc: 'Заголовок страницы для поисковых систем и вкладок браузера.',
primaryColorDesc: 'Основной цвет бренда для главных кнопок и акцентов. Должен быть корректным hex-цветом.', primaryColorDesc: 'Основной цвет бренда для главных кнопок и акцентов. Должен быть корректным hex-цветом.',
secondaryColorDesc: 'Дополнительный цвет бренда для второстепенных элементов. Должен быть корректным hex-цветом.', secondaryColorDesc: 'Дополнительный цвет бренда для второстепенных элементов. Должен быть корректным hex-цветом.',
@@ -765,6 +770,7 @@ export const ru: Translations = {
common: { common: {
retry: 'Попробовать снова', retry: 'Попробовать снова',
loading: 'Загрузка...', loading: 'Загрузка...',
remove: 'Удалить',
}, },
location: { location: {
allRegions: 'Все регионы', allRegions: 'Все регионы',
@@ -918,6 +924,7 @@ export const ru: Translations = {
}, },
adminCategories: { adminCategories: {
chooseImage: 'Выбрать изображение', chooseImage: 'Выбрать изображение',
replaceImage: 'Заменить изображение',
}, },
adminProducts: { adminProducts: {
emptyTitle: 'Товары не найдены', emptyTitle: 'Товары не найдены',

View File

@@ -425,6 +425,9 @@ export interface Translations {
logo: string; logo: string;
smallLogo: string; smallLogo: string;
favicon: string; favicon: string;
socialImage: string;
gallery: string;
addGalleryImage: string;
marketplaceTitle: string; marketplaceTitle: string;
primaryColor: string; primaryColor: string;
secondaryColor: string; secondaryColor: string;
@@ -570,6 +573,8 @@ export interface Translations {
logoDesc: string; logoDesc: string;
smallLogoDesc: string; smallLogoDesc: string;
faviconDesc: string; faviconDesc: string;
socialImageDesc: string;
galleryDesc: string;
marketplaceTitleDesc: string; marketplaceTitleDesc: string;
primaryColorDesc: string; primaryColorDesc: string;
secondaryColorDesc: string; secondaryColorDesc: string;
@@ -763,6 +768,7 @@ export interface Translations {
common: { common: {
retry: string; retry: string;
loading: string; loading: string;
remove: string;
}; };
location: { location: {
allRegions: string; allRegions: string;
@@ -930,6 +936,7 @@ export interface Translations {
// anywhere, so it rendered as the literal string "adminCategories.chooseImage". // anywhere, so it rendered as the literal string "adminCategories.chooseImage".
adminCategories: { adminCategories: {
chooseImage: string; chooseImage: string;
replaceImage: string;
}; };
adminProducts: { adminProducts: {
emptyTitle: string; emptyTitle: string;

View File

@@ -8,6 +8,8 @@ export interface BrandingConfig {
logoCompactUrl?: UrlString; logoCompactUrl?: UrlString;
faviconUrl: UrlString; faviconUrl: UrlString;
appIconUrl?: UrlString; appIconUrl?: UrlString;
socialImageUrl?: UrlString;
galleryUrls?: UrlString[];
supportEmail?: string; supportEmail?: string;
supportPhone?: string; supportPhone?: string;
} }

View File

@@ -0,0 +1,19 @@
<div class="image-field">
@if (value()) {
<img class="image-field__thumb" [src]="value()" alt="" />
} @else {
<div class="image-field__thumb image-field__thumb--empty"></div>
}
<div class="image-field__controls">
<app-input [ngModel]="value() || ''" [disabled]="disabled()" (ngModelChange)="setValue($event)" />
<div class="image-field__actions">
<app-button variant="secondary" size="sm" [disabled]="disabled()" (click)="openPicker()">
{{ (value() ? 'adminCategories.replaceImage' : 'adminCategories.chooseImage') | translate }}
</app-button>
@if (value()) {
<app-button variant="ghost" size="sm" [disabled]="disabled()" (click)="remove()">{{ 'common.remove' | translate }}</app-button>
}
</div>
</div>
</div>
<app-media-picker [open]="pickerOpen" (selected)="onPicked($event)" (closed)="pickerOpen = false" />

View File

@@ -0,0 +1,34 @@
:host {
display: block;
}
.image-field {
display: flex;
gap: 12px;
align-items: flex-start;
}
.image-field__thumb {
width: 56px;
height: 56px;
border-radius: 10px;
border: 1px solid var(--border-color, #d3dad9);
object-fit: cover;
background: #fbfcfc;
flex-shrink: 0;
}
.image-field__thumb--empty {
background: repeating-conic-gradient(#f0f2f1 0% 25%, #fbfcfc 0% 50%) 0 0 / 12px 12px;
}
.image-field__controls {
flex: 1;
display: grid;
gap: 8px;
}
.image-field__actions {
display: flex;
gap: 8px;
}

View File

@@ -0,0 +1,41 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TranslatePipe } from '../../../i18n/translate.pipe';
import { MediaAsset } from '../../../core/media/models/media-asset.model';
import { InputComponent } from '../input/input.component';
import { ButtonComponent } from '../button/button.component';
import { MediaPickerComponent } from '../../media/media-picker/media-picker.component';
@Component({
selector: 'app-image-field',
standalone: true,
imports: [FormsModule, TranslatePipe, InputComponent, ButtonComponent, MediaPickerComponent],
templateUrl: './image-field.component.html',
styleUrl: './image-field.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ImageFieldComponent {
readonly value = input<string | null | undefined>('');
readonly disabled = input(false);
readonly valueChange = output<string>();
protected pickerOpen = false;
protected setValue(next: string): void {
this.valueChange.emit(next);
}
protected openPicker(): void {
this.pickerOpen = true;
}
protected onPicked(asset: MediaAsset): void {
this.valueChange.emit(asset.url);
this.pickerOpen = false;
}
protected remove(): void {
this.valueChange.emit('');
}
}