feat(media): add MediaAsset model, MediaRepository contract, mock IndexedDB adapter
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Implements ADR-0002. MediaRepository is an abstract-class DI token (matching the Ed25519VerificationService pattern in app.config.ts) bound to MockMediaRepository, an IndexedDB-backed implementation storing blobs directly with lazily-created/revoked object URLs. Swapping to a real HttpMediaRepository later is a one-line provider change. No UI yet (Sprint 4 Task 3).
This commit is contained in:
@@ -11,6 +11,8 @@ import { adminAuthHeadersInterceptor } from './core/admin-auth/admin-auth-header
|
||||
import { Ed25519VerificationService } from './core/admin-auth/ed25519-verification.model';
|
||||
import { NoopEd25519VerificationService } from './core/admin-auth/noop-ed25519-verification.service';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
import { MediaRepository } from './core/media/media-repository';
|
||||
import { MockMediaRepository } from './core/media/mock-media-repository.service';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
@@ -24,6 +26,7 @@ export const appConfig: ApplicationConfig = {
|
||||
withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor])
|
||||
),
|
||||
{ provide: Ed25519VerificationService, useClass: NoopEd25519VerificationService },
|
||||
{ provide: MediaRepository, useClass: MockMediaRepository },
|
||||
provideServiceWorker('ngsw-worker.js', {
|
||||
enabled: !isDevMode(),
|
||||
registrationStrategy: 'registerWhenStable:30000'
|
||||
|
||||
8
src/app/core/media/media-repository.ts
Normal file
8
src/app/core/media/media-repository.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { MediaAsset, MediaListParams, MediaListResult } from './models/media-asset.model';
|
||||
|
||||
export abstract class MediaRepository {
|
||||
abstract list(params?: MediaListParams): Promise<MediaListResult>;
|
||||
abstract upload(file: File): Promise<MediaAsset>;
|
||||
abstract remove(id: string): Promise<void>;
|
||||
abstract update(id: string, patch: Partial<Pick<MediaAsset, 'altText' | 'tags'>>): Promise<MediaAsset>;
|
||||
}
|
||||
184
src/app/core/media/mock-media-repository.service.ts
Normal file
184
src/app/core/media/mock-media-repository.service.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { MediaRepository } from './media-repository';
|
||||
import { MediaAsset, MediaListParams, MediaListResult } from './models/media-asset.model';
|
||||
|
||||
const DB_NAME = 'media-manager';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'assets';
|
||||
const DEFAULT_PAGE_SIZE = 24;
|
||||
|
||||
interface StoredAssetRecord {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
altText?: Record<string, string>;
|
||||
tags?: string[];
|
||||
createdAt: string;
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockMediaRepository extends MediaRepository {
|
||||
private db: Promise<IDBDatabase> | null = null;
|
||||
private readonly objectUrls = new Map<string, string>();
|
||||
|
||||
async list(params: MediaListParams = {}): Promise<MediaListResult> {
|
||||
const page = params.page ?? 1;
|
||||
const pageSize = params.pageSize ?? DEFAULT_PAGE_SIZE;
|
||||
const search = (params.search ?? '').trim().toLowerCase();
|
||||
|
||||
const records = await this.getAllRecords();
|
||||
const filtered = search
|
||||
? records.filter(record => record.filename.toLowerCase().includes(search))
|
||||
: records;
|
||||
|
||||
filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
|
||||
const start = (page - 1) * pageSize;
|
||||
const pageItems = filtered.slice(start, start + pageSize);
|
||||
|
||||
return {
|
||||
items: pageItems.map(record => this.toAsset(record)),
|
||||
total: filtered.length,
|
||||
};
|
||||
}
|
||||
|
||||
async upload(file: File): Promise<MediaAsset> {
|
||||
const dimensions = await this.readImageDimensions(file);
|
||||
const record: StoredAssetRecord = {
|
||||
id: crypto.randomUUID(),
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
width: dimensions?.width,
|
||||
height: dimensions?.height,
|
||||
createdAt: new Date().toISOString(),
|
||||
blob: file,
|
||||
};
|
||||
|
||||
const db = await this.openDb();
|
||||
await this.runTransaction(db, 'readwrite', store => store.add(record));
|
||||
|
||||
return this.toAsset(record);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const db = await this.openDb();
|
||||
await this.runTransaction(db, 'readwrite', store => store.delete(id));
|
||||
this.revokeObjectUrl(id);
|
||||
}
|
||||
|
||||
async update(id: string, patch: Partial<Pick<MediaAsset, 'altText' | 'tags'>>): Promise<MediaAsset> {
|
||||
const db = await this.openDb();
|
||||
const existing = await this.getRecord(db, id);
|
||||
if (!existing) {
|
||||
throw new Error(`Media asset not found: ${id}`);
|
||||
}
|
||||
|
||||
const updated: StoredAssetRecord = { ...existing, ...patch };
|
||||
await this.runTransaction(db, 'readwrite', store => store.put(updated));
|
||||
|
||||
return this.toAsset(updated);
|
||||
}
|
||||
|
||||
private toAsset(record: StoredAssetRecord): MediaAsset {
|
||||
const url = this.getObjectUrl(record.id, record.blob);
|
||||
return {
|
||||
id: record.id,
|
||||
url,
|
||||
thumbnailUrl: url,
|
||||
filename: record.filename,
|
||||
mimeType: record.mimeType,
|
||||
size: record.size,
|
||||
width: record.width,
|
||||
height: record.height,
|
||||
altText: record.altText,
|
||||
tags: record.tags,
|
||||
createdAt: record.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
private getObjectUrl(id: string, blob: Blob): string {
|
||||
const cached = this.objectUrls.get(id);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
this.objectUrls.set(id, url);
|
||||
return url;
|
||||
}
|
||||
|
||||
private revokeObjectUrl(id: string): void {
|
||||
const url = this.objectUrls.get(id);
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
this.objectUrls.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
private async readImageDimensions(file: File): Promise<{ width: number; height: number } | undefined> {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const dimensions = { width: bitmap.width, height: bitmap.height };
|
||||
bitmap.close();
|
||||
return dimensions;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private openDb(): Promise<IDBDatabase> {
|
||||
if (!this.db) {
|
||||
this.db = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
|
||||
private getAllRecords(): Promise<StoredAssetRecord[]> {
|
||||
return this.openDb().then(db => new Promise((resolve, reject) => {
|
||||
const store = db.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME);
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result as StoredAssetRecord[]);
|
||||
request.onerror = () => reject(request.error);
|
||||
}));
|
||||
}
|
||||
|
||||
private getRecord(db: IDBDatabase, id: string): Promise<StoredAssetRecord | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = db.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME);
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => resolve(request.result as StoredAssetRecord | undefined);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
private runTransaction(
|
||||
db: IDBDatabase,
|
||||
mode: IDBTransactionMode,
|
||||
action: (store: IDBObjectStore) => IDBRequest
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, mode);
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
action(store);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
24
src/app/core/media/models/media-asset.model.ts
Normal file
24
src/app/core/media/models/media-asset.model.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface MediaAsset {
|
||||
id: string;
|
||||
url: string;
|
||||
thumbnailUrl?: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
altText?: Record<string, string>;
|
||||
tags?: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface MediaListParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface MediaListResult {
|
||||
items: MediaAsset[];
|
||||
total: number;
|
||||
}
|
||||
Reference in New Issue
Block a user