diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 0e14baa..be8e9f2 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -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' diff --git a/src/app/core/media/media-repository.ts b/src/app/core/media/media-repository.ts new file mode 100644 index 0000000..cf6dddd --- /dev/null +++ b/src/app/core/media/media-repository.ts @@ -0,0 +1,8 @@ +import { MediaAsset, MediaListParams, MediaListResult } from './models/media-asset.model'; + +export abstract class MediaRepository { + abstract list(params?: MediaListParams): Promise; + abstract upload(file: File): Promise; + abstract remove(id: string): Promise; + abstract update(id: string, patch: Partial>): Promise; +} diff --git a/src/app/core/media/mock-media-repository.service.ts b/src/app/core/media/mock-media-repository.service.ts new file mode 100644 index 0000000..0696acc --- /dev/null +++ b/src/app/core/media/mock-media-repository.service.ts @@ -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; + tags?: string[]; + createdAt: string; + blob: Blob; +} + +@Injectable({ providedIn: 'root' }) +export class MockMediaRepository extends MediaRepository { + private db: Promise | null = null; + private readonly objectUrls = new Map(); + + async list(params: MediaListParams = {}): Promise { + 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 { + 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 { + const db = await this.openDb(); + await this.runTransaction(db, 'readwrite', store => store.delete(id)); + this.revokeObjectUrl(id); + } + + async update(id: string, patch: Partial>): Promise { + 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 { + 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 { + 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 { + 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 { + 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); + }); + } +} diff --git a/src/app/core/media/models/media-asset.model.ts b/src/app/core/media/models/media-asset.model.ts new file mode 100644 index 0000000..fef9f67 --- /dev/null +++ b/src/app/core/media/models/media-asset.model.ts @@ -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; + tags?: string[]; + createdAt: string; +} + +export interface MediaListParams { + page?: number; + pageSize?: number; + search?: string; +} + +export interface MediaListResult { + items: MediaAsset[]; + total: number; +}