feat(media): reusable media management

Sprint 22.

- MediaAsset gains folder (flat) and MediaListParams gains folder/tag
  filters; MediaRepository.listFolders() derives the folder list from
  existing records
- upload validation: 10MB size cap, mime allow-list (jpeg/png/webp/gif/
  svg+xml/pdf), real error messages surfaced through MediaLibraryFacade
  instead of a generic swallowed string
- SVG uploads are sanitized (script tags and on*= attributes stripped)
  before storage
- raster images (excl. gif) are downscaled to a 2000px max dimension and
  re-encoded via canvas before storage - compression, not a crop UI
- tag editing (window.prompt, comma-separated) via
  MediaLibraryFacade.updateTags()
- MediaPickerComponent wired into Project Editor branding (logo, compact
  logo, favicon) alongside its existing category/product usage - confirmed
  no image fields exist on Static Pages or as a dedicated hero field to
  wire

docs/ADMIN.md updated with the new Sprint 22 section including the storage
abstraction note (MediaRepository was already the abstraction).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-07-15 10:42:23 +04:00
parent 60c63a0d2a
commit 30afb5d778
14 changed files with 324 additions and 33 deletions

View File

@@ -3,17 +3,27 @@
<h2>{{ 'builder.branding' | translate }}</h2>
<div class="editor-grid two">
<app-form-field [label]="'builder.logo' | translate" [hint]="'builder.logoDesc' | translate">
<app-input [ngModel]="bootstrap.branding.logoUrl" (ngModelChange)="updateField('logoUrl', $event)" />
<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-form-field>
<app-form-field [label]="'builder.smallLogo' | translate" [hint]="'builder.smallLogoDesc' | translate">
<app-input [ngModel]="bootstrap.branding.logoCompactUrl || ''" (ngModelChange)="updateField('logoCompactUrl', $event)" />
<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-form-field>
<app-form-field [label]="'builder.favicon' | translate" [hint]="'builder.faviconDesc' | translate">
<app-input [ngModel]="bootstrap.branding.faviconUrl" (ngModelChange)="updateField('faviconUrl', $event)" />
<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-form-field>
<app-form-field [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" />
</section>
}

View File

@@ -4,11 +4,16 @@ 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 { MediaPickerComponent } from '../../../shared/media/media-picker/media-picker.component';
import { MediaAsset } from '../../../core/media/models/media-asset.model';
type BrandingImageField = 'logoUrl' | 'logoCompactUrl' | 'faviconUrl';
@Component({
selector: 'app-project-editor-branding-section',
standalone: true,
imports: [FormsModule, TranslatePipe, InputComponent, FormFieldComponent],
imports: [FormsModule, TranslatePipe, InputComponent, FormFieldComponent, ButtonComponent, MediaPickerComponent],
templateUrl: './branding-section.component.html',
styleUrls: ['./section.shared.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -17,6 +22,9 @@ export class ProjectEditorBrandingSectionComponent {
private readonly facade = inject(ProjectEditorFacade);
readonly bootstrap = this.facade.bootstrap;
protected mediaPickerOpen = false;
private mediaPickerTarget: BrandingImageField | null = null;
updateField<K extends 'logoUrl' | 'logoCompactUrl' | 'faviconUrl' | 'brandName'>(key: K, value: string): void {
this.facade.updateBootstrap(current => ({
...current,
@@ -24,6 +32,18 @@ export class ProjectEditorBrandingSectionComponent {
}));
}
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,

View File

@@ -11,6 +11,13 @@
gap: 14px;
}
.media-field-row {
display: flex;
gap: 8px;
align-items: center;
}
.media-field-row app-input { flex: 1; }
.editor-grid {
display: grid;
gap: 12px;