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 } }));
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Ապրանքներ չեն գտնվել',
|
||||
|
||||
@@ -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: 'Товары не найдены',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface BrandingConfig {
|
||||
logoCompactUrl?: UrlString;
|
||||
faviconUrl: UrlString;
|
||||
appIconUrl?: UrlString;
|
||||
socialImageUrl?: UrlString;
|
||||
galleryUrls?: UrlString[];
|
||||
supportEmail?: string;
|
||||
supportPhone?: string;
|
||||
}
|
||||
|
||||
19
src/app/shared/ui/image-field/image-field.component.html
Normal file
19
src/app/shared/ui/image-field/image-field.component.html
Normal 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" />
|
||||
34
src/app/shared/ui/image-field/image-field.component.scss
Normal file
34
src/app/shared/ui/image-field/image-field.component.scss
Normal 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;
|
||||
}
|
||||
41
src/app/shared/ui/image-field/image-field.component.ts
Normal file
41
src/app/shared/ui/image-field/image-field.component.ts
Normal 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('');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user