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

@@ -1,8 +1,9 @@
import { MediaAsset, MediaListParams, MediaListResult } from './models/media-asset.model';
import { MediaAsset, MediaListParams, MediaListResult, MediaUploadOptions } from './models/media-asset.model';
export abstract class MediaRepository {
abstract list(params?: MediaListParams): Promise<MediaListResult>;
abstract upload(file: File): Promise<MediaAsset>;
abstract upload(file: File, options?: MediaUploadOptions): Promise<MediaAsset>;
abstract remove(id: string): Promise<void>;
abstract update(id: string, patch: Partial<Pick<MediaAsset, 'altText' | 'tags'>>): Promise<MediaAsset>;
abstract update(id: string, patch: Partial<Pick<MediaAsset, 'altText' | 'tags' | 'folder'>>): Promise<MediaAsset>;
abstract listFolders(): Promise<string[]>;
}

View File

@@ -1,11 +1,15 @@
import { Injectable } from '@angular/core';
import { MediaRepository } from './media-repository';
import { MediaAsset, MediaListParams, MediaListResult } from './models/media-asset.model';
import { MediaAsset, MediaListParams, MediaListResult, MediaUploadOptions } from './models/media-asset.model';
const DB_NAME = 'media-manager';
const DB_VERSION = 1;
const STORE_NAME = 'assets';
const DEFAULT_PAGE_SIZE = 24;
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
const MAX_IMAGE_DIMENSION = 2000;
const COMPRESS_QUALITY = 0.85;
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/svg+xml', 'application/pdf'];
interface StoredAssetRecord {
id: string;
@@ -16,6 +20,7 @@ interface StoredAssetRecord {
height?: number;
altText?: Record<string, string>;
tags?: string[];
folder?: string;
createdAt: string;
blob: Blob;
}
@@ -31,9 +36,10 @@ export class MockMediaRepository extends MediaRepository {
const search = (params.search ?? '').trim().toLowerCase();
const records = await this.getAllRecords();
const filtered = search
? records.filter(record => record.filename.toLowerCase().includes(search))
: records;
const filtered = records
.filter(record => !search || record.filename.toLowerCase().includes(search))
.filter(record => !params.folder || record.folder === params.folder)
.filter(record => !params.tag || (record.tags ?? []).includes(params.tag!));
filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
@@ -46,17 +52,21 @@ export class MockMediaRepository extends MediaRepository {
};
}
async upload(file: File): Promise<MediaAsset> {
const dimensions = await this.readImageDimensions(file);
async upload(file: File, options: MediaUploadOptions = {}): Promise<MediaAsset> {
this.validateFile(file);
const blob = await this.prepareBlob(file);
const dimensions = await this.readImageDimensions(blob, file.type);
const record: StoredAssetRecord = {
id: crypto.randomUUID(),
filename: file.name,
mimeType: file.type,
size: file.size,
size: blob.size,
width: dimensions?.width,
height: dimensions?.height,
folder: options.folder || undefined,
tags: options.tags,
createdAt: new Date().toISOString(),
blob: file,
blob,
};
const db = await this.openDb();
@@ -65,6 +75,67 @@ export class MockMediaRepository extends MediaRepository {
return this.toAsset(record);
}
async listFolders(): Promise<string[]> {
const records = await this.getAllRecords();
return [...new Set(records.map(record => record.folder).filter((folder): folder is string => !!folder))].sort();
}
private validateFile(file: File): void {
if (file.size > MAX_FILE_SIZE_BYTES) {
throw new Error(`File exceeds the ${MAX_FILE_SIZE_BYTES / (1024 * 1024)}MB limit.`);
}
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
throw new Error(`Unsupported file type: ${file.type || 'unknown'}.`);
}
}
private async prepareBlob(file: File): Promise<Blob> {
if (file.type === 'image/svg+xml') {
return this.sanitizeSvg(file);
}
if (file.type === 'image/gif') {
return file;
}
if (file.type.startsWith('image/')) {
return this.compressImage(file);
}
return file;
}
/** Strips <script> elements and on*="..." event-handler attributes before storing untrusted SVG markup. */
private async sanitizeSvg(file: File): Promise<Blob> {
const text = await file.text();
const sanitized = text
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/\son\w+\s*=\s*"[^"]*"/gi, '')
.replace(/\son\w+\s*=\s*'[^']*'/gi, '');
return new Blob([sanitized], { type: 'image/svg+xml' });
}
private async compressImage(file: File): Promise<Blob> {
try {
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, MAX_IMAGE_DIMENSION / Math.max(bitmap.width, bitmap.height));
const width = Math.round(bitmap.width * scale);
const height = Math.round(bitmap.height * scale);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
bitmap.close();
return file;
}
ctx.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
const outputType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve, outputType, COMPRESS_QUALITY));
return blob ?? file;
} catch {
return file;
}
}
async remove(id: string): Promise<void> {
const db = await this.openDb();
await this.runTransaction(db, 'readwrite', store => store.delete(id));
@@ -97,6 +168,7 @@ export class MockMediaRepository extends MediaRepository {
height: record.height,
altText: record.altText,
tags: record.tags,
folder: record.folder,
createdAt: record.createdAt,
};
}
@@ -119,12 +191,12 @@ export class MockMediaRepository extends MediaRepository {
}
}
private async readImageDimensions(file: File): Promise<{ width: number; height: number } | undefined> {
if (!file.type.startsWith('image/')) {
private async readImageDimensions(blob: Blob, mimeType: string): Promise<{ width: number; height: number } | undefined> {
if (!mimeType.startsWith('image/') || mimeType === 'image/svg+xml') {
return undefined;
}
try {
const bitmap = await createImageBitmap(file);
const bitmap = await createImageBitmap(blob);
const dimensions = { width: bitmap.width, height: bitmap.height };
bitmap.close();
return dimensions;

View File

@@ -9,6 +9,7 @@ export interface MediaAsset {
height?: number;
altText?: Record<string, string>;
tags?: string[];
folder?: string;
createdAt: string;
}
@@ -16,6 +17,13 @@ export interface MediaListParams {
page?: number;
pageSize?: number;
search?: string;
folder?: string;
tag?: string;
}
export interface MediaUploadOptions {
folder?: string;
tags?: string[];
}
export interface MediaListResult {