feat(media): add MediaAsset model, MediaRepository contract, mock IndexedDB adapter
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:
sdarbinyan
2026-07-15 04:58:20 +04:00
parent b75e753d98
commit c663c9099c
4 changed files with 219 additions and 0 deletions

View File

@@ -11,6 +11,8 @@ import { adminAuthHeadersInterceptor } from './core/admin-auth/admin-auth-header
import { Ed25519VerificationService } from './core/admin-auth/ed25519-verification.model'; import { Ed25519VerificationService } from './core/admin-auth/ed25519-verification.model';
import { NoopEd25519VerificationService } from './core/admin-auth/noop-ed25519-verification.service'; import { NoopEd25519VerificationService } from './core/admin-auth/noop-ed25519-verification.service';
import { provideServiceWorker } from '@angular/service-worker'; 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 = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
@@ -24,6 +26,7 @@ export const appConfig: ApplicationConfig = {
withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor]) withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor])
), ),
{ provide: Ed25519VerificationService, useClass: NoopEd25519VerificationService }, { provide: Ed25519VerificationService, useClass: NoopEd25519VerificationService },
{ provide: MediaRepository, useClass: MockMediaRepository },
provideServiceWorker('ngsw-worker.js', { provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(), enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000' registrationStrategy: 'registerWhenStable:30000'

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

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

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