feat(builder): implement reusable media library
Media dashboard (real total/images/SVG/logos/unused/storage/alt-coverage, computed from actual assets + a bootstrap usage scan, no fabricated stats); gallery gets grid/list toggle, type filter, sort, drag-and-drop + multi-file upload with cancel/retry and friendly error mapping, lazy thumbnails, multi-select with bulk delete/download/export-metadata; asset details drawer shows real dimensions/size/format/date/usage locations (walks bootstrap config for exact URL matches, reports not-used rather than guessing) plus editable alt text/caption/description/decorative flag with missing-alt warning; shared MediaPickerComponent (already the one reusable picker used by content management) gains type filter, a recent shortcut, and keyboard grid navigation.
This commit is contained in:
@@ -4,6 +4,6 @@ export abstract class MediaRepository {
|
||||
abstract list(params?: MediaListParams): Promise<MediaListResult>;
|
||||
abstract upload(file: File, options?: MediaUploadOptions): Promise<MediaAsset>;
|
||||
abstract remove(id: string): Promise<void>;
|
||||
abstract update(id: string, patch: Partial<Pick<MediaAsset, 'altText' | 'tags' | 'folder'>>): Promise<MediaAsset>;
|
||||
abstract update(id: string, patch: Partial<Pick<MediaAsset, 'altText' | 'tags' | 'folder' | 'caption' | 'description' | 'decorative'>>): Promise<MediaAsset>;
|
||||
abstract listFolders(): Promise<string[]>;
|
||||
}
|
||||
|
||||
65
src/app/core/media/media-usage.service.ts
Normal file
65
src/app/core/media/media-usage.service.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BootstrapConfig } from '../../shared/models/config';
|
||||
|
||||
export interface MediaUsageEntry {
|
||||
labelKey: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const TOP_LEVEL_LABEL_KEYS: Record<string, string> = {
|
||||
branding: 'mediaLibrary.usage.brand',
|
||||
theme: 'mediaLibrary.usage.theme',
|
||||
header: 'mediaLibrary.usage.header',
|
||||
footer: 'mediaLibrary.usage.footer',
|
||||
staticPages: 'mediaLibrary.usage.staticPage',
|
||||
pages: 'mediaLibrary.usage.homepage',
|
||||
catalog: 'mediaLibrary.usage.catalog',
|
||||
productPage: 'mediaLibrary.usage.productPage',
|
||||
widgetRegistry: 'mediaLibrary.usage.widget',
|
||||
company: 'mediaLibrary.usage.company',
|
||||
seo: 'mediaLibrary.usage.seo',
|
||||
navigation: 'mediaLibrary.usage.navigation',
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds every place a media asset's URL is actually referenced inside the current
|
||||
* bootstrap config, by walking the object rather than guessing from a fixed schema.
|
||||
* Never fabricates usage - an asset with no matches is reported as unused.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MediaUsageService {
|
||||
findUsages(bootstrap: BootstrapConfig | null, url: string): MediaUsageEntry[] {
|
||||
if (!bootstrap || !url) {
|
||||
return [];
|
||||
}
|
||||
const entries: MediaUsageEntry[] = [];
|
||||
for (const [topKey, value] of Object.entries(bootstrap as unknown as Record<string, unknown>)) {
|
||||
this.walk(value, url, [topKey], entries, 0);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private walk(value: unknown, url: string, path: string[], entries: MediaUsageEntry[], depth: number): void {
|
||||
if (depth > 8 || value == null) {
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
if (value === url) {
|
||||
entries.push({
|
||||
labelKey: TOP_LEVEL_LABEL_KEYS[path[0]] ?? 'mediaLibrary.usage.other',
|
||||
detail: path.join(' › '),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => this.walk(item, url, [...path, String(index)], entries, depth + 1));
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
||||
this.walk(nested, url, [...path, key], entries, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { MediaRepository } from './media-repository';
|
||||
import { MediaAsset, MediaListParams, MediaListResult, MediaUploadOptions } from './models/media-asset.model';
|
||||
import { MediaAsset, MediaAssetKind, MediaListParams, MediaListResult, MediaUploadOptions } from './models/media-asset.model';
|
||||
|
||||
const DB_NAME = 'media-manager';
|
||||
const DB_VERSION = 1;
|
||||
@@ -19,12 +19,22 @@ interface StoredAssetRecord {
|
||||
width?: number;
|
||||
height?: number;
|
||||
altText?: Record<string, string>;
|
||||
caption?: string;
|
||||
description?: string;
|
||||
decorative?: boolean;
|
||||
tags?: string[];
|
||||
folder?: string;
|
||||
createdAt: string;
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
function assetKind(mimeType: string): MediaAssetKind {
|
||||
if (mimeType === 'image/svg+xml') return 'svg';
|
||||
if (mimeType === 'application/pdf') return 'pdf';
|
||||
if (mimeType.startsWith('image/')) return 'image';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockMediaRepository extends MediaRepository {
|
||||
private db: Promise<IDBDatabase> | null = null;
|
||||
@@ -39,9 +49,17 @@ export class MockMediaRepository extends MediaRepository {
|
||||
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!));
|
||||
.filter(record => !params.tag || (record.tags ?? []).includes(params.tag!))
|
||||
.filter(record => !params.kind || assetKind(record.mimeType) === params.kind);
|
||||
|
||||
filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
const sort = params.sort ?? 'recent';
|
||||
if (sort === 'name') {
|
||||
filtered.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
} else if (sort === 'size') {
|
||||
filtered.sort((a, b) => b.size - a.size);
|
||||
} else {
|
||||
filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
const start = (page - 1) * pageSize;
|
||||
const pageItems = filtered.slice(start, start + pageSize);
|
||||
@@ -142,7 +160,7 @@ export class MockMediaRepository extends MediaRepository {
|
||||
this.revokeObjectUrl(id);
|
||||
}
|
||||
|
||||
async update(id: string, patch: Partial<Pick<MediaAsset, 'altText' | 'tags'>>): Promise<MediaAsset> {
|
||||
async update(id: string, patch: Partial<Pick<MediaAsset, 'altText' | 'tags' | 'folder' | 'caption' | 'description' | 'decorative'>>): Promise<MediaAsset> {
|
||||
const db = await this.openDb();
|
||||
const existing = await this.getRecord(db, id);
|
||||
if (!existing) {
|
||||
@@ -167,6 +185,9 @@ export class MockMediaRepository extends MediaRepository {
|
||||
width: record.width,
|
||||
height: record.height,
|
||||
altText: record.altText,
|
||||
caption: record.caption,
|
||||
description: record.description,
|
||||
decorative: record.decorative,
|
||||
tags: record.tags,
|
||||
folder: record.folder,
|
||||
createdAt: record.createdAt,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export type MediaAssetKind = 'image' | 'svg' | 'pdf' | 'other';
|
||||
|
||||
export interface MediaAsset {
|
||||
id: string;
|
||||
url: string;
|
||||
@@ -8,17 +10,26 @@ export interface MediaAsset {
|
||||
width?: number;
|
||||
height?: number;
|
||||
altText?: Record<string, string>;
|
||||
/** Plain (non-localized) caption shown under the asset - matches the existing single-string convention used for page hero captions. */
|
||||
caption?: string;
|
||||
description?: string;
|
||||
/** Marks the asset as intentionally decorative, suppressing the missing-alt-text warning without requiring empty alt text everywhere. */
|
||||
decorative?: boolean;
|
||||
tags?: string[];
|
||||
folder?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type MediaSort = 'recent' | 'name' | 'size';
|
||||
|
||||
export interface MediaListParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
folder?: string;
|
||||
tag?: string;
|
||||
kind?: MediaAssetKind;
|
||||
sort?: MediaSort;
|
||||
}
|
||||
|
||||
export interface MediaUploadOptions {
|
||||
|
||||
Reference in New Issue
Block a user