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:
@@ -1,6 +1,6 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { MediaRepository } from '../../../../core/media/media-repository';
|
||||
import { MediaAsset } from '../../../../core/media/models/media-asset.model';
|
||||
import { MediaAsset, MediaUploadOptions } from '../../../../core/media/models/media-asset.model';
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
@@ -15,6 +15,8 @@ export class MediaLibraryFacade {
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly search = signal('');
|
||||
readonly page = signal(1);
|
||||
readonly folder = signal<string | null>(null);
|
||||
readonly folders = signal<string[]>([]);
|
||||
|
||||
async load(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
@@ -24,9 +26,11 @@ export class MediaLibraryFacade {
|
||||
page: this.page(),
|
||||
pageSize: PAGE_SIZE,
|
||||
search: this.search(),
|
||||
folder: this.folder() ?? undefined,
|
||||
});
|
||||
this.items.set(result.items);
|
||||
this.total.set(result.total);
|
||||
this.folders.set(await this.repository.listFolders());
|
||||
} catch {
|
||||
this.error.set('Failed to load media assets.');
|
||||
} finally {
|
||||
@@ -40,25 +44,41 @@ export class MediaLibraryFacade {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
setFolder(value: string | null): void {
|
||||
this.folder.set(value);
|
||||
this.page.set(1);
|
||||
void this.load();
|
||||
}
|
||||
|
||||
setPage(page: number): void {
|
||||
this.page.set(page);
|
||||
void this.load();
|
||||
}
|
||||
|
||||
async upload(file: File): Promise<void> {
|
||||
async upload(file: File, options: MediaUploadOptions = {}): Promise<void> {
|
||||
this.uploading.set(true);
|
||||
this.error.set(null);
|
||||
try {
|
||||
await this.repository.upload(file);
|
||||
await this.repository.upload(file, { folder: this.folder() ?? undefined, ...options });
|
||||
this.page.set(1);
|
||||
await this.load();
|
||||
} catch {
|
||||
this.error.set('Failed to upload file.');
|
||||
} catch (error: unknown) {
|
||||
this.error.set(error instanceof Error ? error.message : 'Failed to upload file.');
|
||||
} finally {
|
||||
this.uploading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async updateTags(id: string, tags: string[]): Promise<void> {
|
||||
this.error.set(null);
|
||||
try {
|
||||
await this.repository.update(id, { tags });
|
||||
await this.load();
|
||||
} catch {
|
||||
this.error.set('Failed to update tags.');
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
this.error.set(null);
|
||||
try {
|
||||
|
||||
@@ -7,10 +7,17 @@
|
||||
(ngModelChange)="facade.setSearch($event)"
|
||||
[placeholder]="'mediaLibrary.searchPlaceholder' | translate"
|
||||
/>
|
||||
<select [ngModel]="facade.folder()" (ngModelChange)="facade.setFolder($event || null)">
|
||||
<option [ngValue]="null">{{ 'mediaLibrary.allFolders' | translate }}</option>
|
||||
@for (folder of facade.folders(); track folder) {
|
||||
<option [ngValue]="folder">{{ folder }}</option>
|
||||
}
|
||||
</select>
|
||||
<app-button variant="secondary" (click)="createFolder()">{{ 'mediaLibrary.newFolder' | translate }}</app-button>
|
||||
<input
|
||||
#fileInput
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml,application/pdf"
|
||||
hidden
|
||||
(change)="onFileSelected($event)"
|
||||
/>
|
||||
@@ -20,6 +27,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (facade.error()) {
|
||||
<p class="media-page__error">{{ facade.error() }}</p>
|
||||
}
|
||||
|
||||
@if (facade.loading()) {
|
||||
<div class="media-page__grid">
|
||||
@for (i of [1, 2, 3, 4, 5, 6]; track i) {
|
||||
@@ -44,10 +55,16 @@
|
||||
<div class="media-tile__meta">
|
||||
<span class="media-tile__filename">{{ asset.filename }}</span>
|
||||
<span class="media-tile__size">{{ formatSize(asset.size) }}</span>
|
||||
@if (asset.tags?.length) {
|
||||
<span class="media-tile__tags">{{ asset.tags!.join(', ') }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="media-tile__row">
|
||||
<app-button variant="secondary" size="sm" (click)="editTags(asset)">{{ 'mediaLibrary.editTags' | translate }}</app-button>
|
||||
<app-button variant="danger" size="sm" (click)="confirmDelete(asset)">
|
||||
{{ 'mediaLibrary.delete' | translate }}
|
||||
</app-button>
|
||||
</div>
|
||||
<app-button variant="danger" size="sm" [fullWidth]="true" (click)="confirmDelete(asset)">
|
||||
{{ 'mediaLibrary.delete' | translate }}
|
||||
</app-button>
|
||||
</div>
|
||||
</app-card>
|
||||
}
|
||||
|
||||
@@ -73,3 +73,27 @@
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.media-page__error {
|
||||
color: var(--color-danger, #d9433c);
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.media-tile__tags {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.media-tile__row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.media-page__toolbar-actions select {
|
||||
min-height: 40px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border-color, #d3dad9);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,16 @@ export class MediaLibraryPageComponent implements OnInit {
|
||||
await this.facade.upload(file);
|
||||
}
|
||||
|
||||
protected async editTags(asset: MediaAsset): Promise<void> {
|
||||
const current = (asset.tags ?? []).join(', ');
|
||||
const next = window.prompt('Tags (comma-separated):', current);
|
||||
if (next === null) {
|
||||
return;
|
||||
}
|
||||
const tags = next.split(',').map(tag => tag.trim()).filter(Boolean);
|
||||
await this.facade.updateTags(asset.id, tags);
|
||||
}
|
||||
|
||||
protected confirmDelete(asset: MediaAsset): void {
|
||||
this.pendingDelete.set(asset);
|
||||
}
|
||||
@@ -74,6 +84,14 @@ export class MediaLibraryPageComponent implements OnInit {
|
||||
this.pendingDelete.set(null);
|
||||
}
|
||||
|
||||
protected createFolder(): void {
|
||||
const name = window.prompt('New folder name:');
|
||||
if (!name?.trim()) {
|
||||
return;
|
||||
}
|
||||
this.facade.setFolder(name.trim());
|
||||
}
|
||||
|
||||
protected formatSize(bytes: number): string {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user