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:
sdarbinyan
2026-07-18 16:09:47 +04:00
parent b31c75214d
commit 5d52d81c7d
21 changed files with 1255 additions and 136 deletions

View File

@@ -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[]>;
}

View 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);
}
}
}
}

View File

@@ -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,

View File

@@ -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 {