From b1834108883b2b7f66164eebef9582a1b0d01ca9 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Fri, 17 Jul 2026 15:22:45 +0400 Subject: [PATCH] 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 --- .../sections/branding-section.component.html | 33 +++++++------ .../sections/branding-section.component.ts | 48 +++++++++++-------- .../sections/footer-section.component.html | 9 +--- .../sections/footer-section.component.ts | 32 +------------ src/app/i18n/en.ts | 7 +++ src/app/i18n/hy.ts | 7 +++ src/app/i18n/ru.ts | 7 +++ src/app/i18n/translations.ts | 7 +++ .../shared/models/config/branding.model.ts | 2 + .../ui/image-field/image-field.component.html | 19 ++++++++ .../ui/image-field/image-field.component.scss | 34 +++++++++++++ .../ui/image-field/image-field.component.ts | 41 ++++++++++++++++ 12 files changed, 175 insertions(+), 71 deletions(-) create mode 100644 src/app/shared/ui/image-field/image-field.component.html create mode 100644 src/app/shared/ui/image-field/image-field.component.scss create mode 100644 src/app/shared/ui/image-field/image-field.component.ts diff --git a/src/app/features/project-editor/sections/branding-section.component.html b/src/app/features/project-editor/sections/branding-section.component.html index be0bdea..a94628a 100644 --- a/src/app/features/project-editor/sections/branding-section.component.html +++ b/src/app/features/project-editor/sections/branding-section.component.html @@ -2,27 +2,32 @@
-
- - {{ 'adminCategories.chooseImage' | translate }} -
+
-
- - {{ 'adminCategories.chooseImage' | translate }} -
+
-
- - {{ 'adminCategories.chooseImage' | translate }} -
+
- + + + +
- + + +
+ @for (url of bootstrap.branding.galleryUrls ?? []; track $index) { +
+ + {{ 'common.remove' | translate }} +
+ } + {{ 'builder.addGalleryImage' | translate }} +
+
} diff --git a/src/app/features/project-editor/sections/branding-section.component.ts b/src/app/features/project-editor/sections/branding-section.component.ts index af523fb..5498d25 100644 --- a/src/app/features/project-editor/sections/branding-section.component.ts +++ b/src/app/features/project-editor/sections/branding-section.component.ts @@ -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(key: K, value: string): void { + updateField(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) } + })); + } } diff --git a/src/app/features/project-editor/sections/footer-section.component.html b/src/app/features/project-editor/sections/footer-section.component.html index 8f4e65f..d6e8a29 100644 --- a/src/app/features/project-editor/sections/footer-section.component.html +++ b/src/app/features/project-editor/sections/footer-section.component.html @@ -7,10 +7,7 @@ -
- - {{ 'adminCategories.chooseImage' | translate }} -
+
@@ -24,8 +21,7 @@ >
- - {{ 'adminCategories.chooseImage' | translate }} +
@@ -60,6 +56,5 @@
- } diff --git a/src/app/features/project-editor/sections/footer-section.component.ts b/src/app/features/project-editor/sections/footer-section.component.ts index 753b7af..512c4b0 100644 --- a/src/app/features/project-editor/sections/footer-section.component.ts +++ b/src/app/features/project-editor/sections/footer-section.component.ts @@ -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(() => this.bootstrap()?.footer?.paymentIcons ?? []); readonly socialLinkRows = computed(() => 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 } })); } diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 0983504..9d19903 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -427,6 +427,9 @@ export const en: Translations = { logo: 'Logo', smallLogo: 'Small Logo', favicon: 'Favicon', + socialImage: 'Social Share Image', + gallery: 'Gallery', + addGalleryImage: 'Add image', marketplaceTitle: 'Marketplace Title', primaryColor: 'Primary Color', secondaryColor: 'Secondary Color', @@ -572,6 +575,8 @@ export const en: Translations = { 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).', 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.', 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.', @@ -765,6 +770,7 @@ export const en: Translations = { common: { retry: 'Try again', loading: 'Loading...', + remove: 'Remove', }, location: { allRegions: 'All regions', @@ -923,6 +929,7 @@ export const en: Translations = { }, adminCategories: { chooseImage: 'Choose image', + replaceImage: 'Replace image', }, adminProducts: { emptyTitle: 'No products found', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 2af2787..59c8cfd 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -427,6 +427,9 @@ export const hy: Translations = { logo: 'Լոգո', smallLogo: 'Փոքր լոգո', favicon: 'Favicon', + socialImage: 'Սոցիալական պատկեր', + gallery: 'Պատկերասրահ', + addGalleryImage: 'Ավելացնել պատկեր', marketplaceTitle: 'Մարքեթփլեյսի վերնագիր', primaryColor: 'Հիմնական գույն', secondaryColor: 'Երկրորդական գույն', @@ -572,6 +575,8 @@ export const hy: Translations = { logoDesc: 'Ամբողջական լոգոյի պատկերի URL, ցուցադրվում է header-ում դեսքթոփի վրա։', smallLogoDesc: 'Կոմպակտ լոգոյի URL՝ սահմանափակ տարածքի համար (օր․՝ mobile header)։', faviconDesc: 'Բրաուզերի ներդիրի պատկերակի URL։ Պետք է լինի փոքր քառակուսի պատկեր։', + socialImageDesc: 'Կանխադրված պատկեր, երբ մարքեթփլեյսը կիսվում է սոցիալական ցանցերում (Open Graph)։', + galleryDesc: 'Լրացուցիչ բրենդային պատկերներ ապագա օգտագործման համար։', marketplaceTitleDesc: 'Էջի վերնագիրը որոնողական համակարգերի և բրաուզերի ներդիրների համար։', primaryColorDesc: 'Բրենդի հիմնական գույնը՝ հիմնական կոճակների և ընդգծումների համար։ Պետք է վավեր hex գույն լինի։', secondaryColorDesc: 'Բրենդի երկրորդական գույնը՝ երկրորդական տարրերի համար։ Պետք է վավեր hex գույն լինի։', @@ -765,6 +770,7 @@ export const hy: Translations = { common: { retry: 'Փորձել կրկին', loading: 'Բեռնում...', + remove: 'Հեռացնել', }, location: { allRegions: 'Բոլոր տարածաշրջանները', @@ -918,6 +924,7 @@ export const hy: Translations = { }, adminCategories: { chooseImage: 'Ընտրել պատկեր', + replaceImage: 'Փոխարինել պատկերը', }, adminProducts: { emptyTitle: 'Ապրանքներ չեն գտնվել', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 44d36af..8ea4a84 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -427,6 +427,9 @@ export const ru: Translations = { logo: 'Логотип', smallLogo: 'Малый логотип', favicon: 'Favicon', + socialImage: 'Изображение для соцсетей', + gallery: 'Галерея', + addGalleryImage: 'Добавить изображение', marketplaceTitle: 'Заголовок маркетплейса', primaryColor: 'Основной цвет', secondaryColor: 'Вторичный цвет', @@ -572,6 +575,8 @@ export const ru: Translations = { logoDesc: 'URL полного логотипа, отображается в шапке на десктопе.', smallLogoDesc: 'URL компактного логотипа для ограниченного пространства (например, мобильная шапка).', faviconDesc: 'URL иконки вкладки браузера. Должен быть небольшим квадратным изображением.', + socialImageDesc: 'Изображение по умолчанию при публикации маркетплейса в соцсетях (Open Graph).', + galleryDesc: 'Дополнительные брендовые изображения для будущего использования в витрине.', marketplaceTitleDesc: 'Заголовок страницы для поисковых систем и вкладок браузера.', primaryColorDesc: 'Основной цвет бренда для главных кнопок и акцентов. Должен быть корректным hex-цветом.', secondaryColorDesc: 'Дополнительный цвет бренда для второстепенных элементов. Должен быть корректным hex-цветом.', @@ -765,6 +770,7 @@ export const ru: Translations = { common: { retry: 'Попробовать снова', loading: 'Загрузка...', + remove: 'Удалить', }, location: { allRegions: 'Все регионы', @@ -918,6 +924,7 @@ export const ru: Translations = { }, adminCategories: { chooseImage: 'Выбрать изображение', + replaceImage: 'Заменить изображение', }, adminProducts: { emptyTitle: 'Товары не найдены', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 675276b..2314754 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -425,6 +425,9 @@ export interface Translations { logo: string; smallLogo: string; favicon: string; + socialImage: string; + gallery: string; + addGalleryImage: string; marketplaceTitle: string; primaryColor: string; secondaryColor: string; @@ -570,6 +573,8 @@ export interface Translations { logoDesc: string; smallLogoDesc: string; faviconDesc: string; + socialImageDesc: string; + galleryDesc: string; marketplaceTitleDesc: string; primaryColorDesc: string; secondaryColorDesc: string; @@ -763,6 +768,7 @@ export interface Translations { common: { retry: string; loading: string; + remove: string; }; location: { allRegions: string; @@ -930,6 +936,7 @@ export interface Translations { // anywhere, so it rendered as the literal string "adminCategories.chooseImage". adminCategories: { chooseImage: string; + replaceImage: string; }; adminProducts: { emptyTitle: string; diff --git a/src/app/shared/models/config/branding.model.ts b/src/app/shared/models/config/branding.model.ts index 86f434b..60ec290 100644 --- a/src/app/shared/models/config/branding.model.ts +++ b/src/app/shared/models/config/branding.model.ts @@ -8,6 +8,8 @@ export interface BrandingConfig { logoCompactUrl?: UrlString; faviconUrl: UrlString; appIconUrl?: UrlString; + socialImageUrl?: UrlString; + galleryUrls?: UrlString[]; supportEmail?: string; supportPhone?: string; } diff --git a/src/app/shared/ui/image-field/image-field.component.html b/src/app/shared/ui/image-field/image-field.component.html new file mode 100644 index 0000000..fffedc4 --- /dev/null +++ b/src/app/shared/ui/image-field/image-field.component.html @@ -0,0 +1,19 @@ +
+ @if (value()) { + + } @else { +
+ } +
+ +
+ + {{ (value() ? 'adminCategories.replaceImage' : 'adminCategories.chooseImage') | translate }} + + @if (value()) { + {{ 'common.remove' | translate }} + } +
+
+
+ diff --git a/src/app/shared/ui/image-field/image-field.component.scss b/src/app/shared/ui/image-field/image-field.component.scss new file mode 100644 index 0000000..9d3f4e0 --- /dev/null +++ b/src/app/shared/ui/image-field/image-field.component.scss @@ -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; +} diff --git a/src/app/shared/ui/image-field/image-field.component.ts b/src/app/shared/ui/image-field/image-field.component.ts new file mode 100644 index 0000000..4c524c6 --- /dev/null +++ b/src/app/shared/ui/image-field/image-field.component.ts @@ -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(''); + readonly disabled = input(false); + + readonly valueChange = output(); + + 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(''); + } +}