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,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[]>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user