From 5d52d81c7d7da8166727c2491096f264e7e6bc6d Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 18 Jul 2026 16:09:47 +0400 Subject: [PATCH] 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. --- src/app/core/media/media-repository.ts | 2 +- src/app/core/media/media-usage.service.ts | 65 ++++++ .../media/mock-media-repository.service.ts | 29 ++- .../core/media/models/media-asset.model.ts | 11 + .../asset-details-drawer.component.html | 71 +++++++ .../asset-details-drawer.component.scss | 75 +++++++ .../asset-details-drawer.component.ts | 69 +++++++ .../asset-tile/asset-tile.component.html | 36 ++++ .../asset-tile/asset-tile.component.scss | 91 +++++++++ .../asset-tile/asset-tile.component.ts | 48 +++++ .../media/facade/media-library.facade.ts | 149 +++++++++++++- .../media/media-library-page.component.html | 104 +++++++--- .../media/media-library-page.component.scss | 112 +++++----- .../media/media-library-page.component.ts | 193 +++++++++++++++--- src/app/i18n/en.ts | 65 ++++++ src/app/i18n/hy.ts | 65 ++++++ src/app/i18n/ru.ts | 65 ++++++ src/app/i18n/translations.ts | 65 ++++++ .../media-picker/media-picker.component.html | 17 +- .../media-picker/media-picker.component.scss | 5 + .../media-picker/media-picker.component.ts | 54 ++++- 21 files changed, 1255 insertions(+), 136 deletions(-) create mode 100644 src/app/core/media/media-usage.service.ts create mode 100644 src/app/features/backoffice/media/components/asset-details-drawer/asset-details-drawer.component.html create mode 100644 src/app/features/backoffice/media/components/asset-details-drawer/asset-details-drawer.component.scss create mode 100644 src/app/features/backoffice/media/components/asset-details-drawer/asset-details-drawer.component.ts create mode 100644 src/app/features/backoffice/media/components/asset-tile/asset-tile.component.html create mode 100644 src/app/features/backoffice/media/components/asset-tile/asset-tile.component.scss create mode 100644 src/app/features/backoffice/media/components/asset-tile/asset-tile.component.ts diff --git a/src/app/core/media/media-repository.ts b/src/app/core/media/media-repository.ts index 35cb6a6..b180cfa 100644 --- a/src/app/core/media/media-repository.ts +++ b/src/app/core/media/media-repository.ts @@ -4,6 +4,6 @@ export abstract class MediaRepository { abstract list(params?: MediaListParams): Promise; abstract upload(file: File, options?: MediaUploadOptions): Promise; abstract remove(id: string): Promise; - abstract update(id: string, patch: Partial>): Promise; + abstract update(id: string, patch: Partial>): Promise; abstract listFolders(): Promise; } diff --git a/src/app/core/media/media-usage.service.ts b/src/app/core/media/media-usage.service.ts new file mode 100644 index 0000000..f684b4a --- /dev/null +++ b/src/app/core/media/media-usage.service.ts @@ -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 = { + 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)) { + 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)) { + this.walk(nested, url, [...path, key], entries, depth + 1); + } + } + } +} diff --git a/src/app/core/media/mock-media-repository.service.ts b/src/app/core/media/mock-media-repository.service.ts index 09815f7..c8d465a 100644 --- a/src/app/core/media/mock-media-repository.service.ts +++ b/src/app/core/media/mock-media-repository.service.ts @@ -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; + 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 | 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>): Promise { + async update(id: string, patch: Partial>): Promise { 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, diff --git a/src/app/core/media/models/media-asset.model.ts b/src/app/core/media/models/media-asset.model.ts index 608f2d4..d7a0782 100644 --- a/src/app/core/media/models/media-asset.model.ts +++ b/src/app/core/media/models/media-asset.model.ts @@ -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; + /** 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 { diff --git a/src/app/features/backoffice/media/components/asset-details-drawer/asset-details-drawer.component.html b/src/app/features/backoffice/media/components/asset-details-drawer/asset-details-drawer.component.html new file mode 100644 index 0000000..b5c87e8 --- /dev/null +++ b/src/app/features/backoffice/media/components/asset-details-drawer/asset-details-drawer.component.html @@ -0,0 +1,71 @@ + + @if (asset(); as item) { +
+
+ @if (isImage()) { + + } @else { +
{{ item.mimeType }}
+ } +
+ +
+
+
{{ 'mediaLibrary.filename' | translate }}
+
{{ item.filename }}
+
{{ 'mediaLibrary.format' | translate }}
+
{{ item.mimeType }}
+
{{ 'mediaLibrary.fileSize' | translate }}
+
{{ formatSize(item.size) }}
+
{{ 'mediaLibrary.dimensions' | translate }}
+
{{ dimensionsLabel() || ('mediaLibrary.unknownValue' | translate) }}
+
{{ 'mediaLibrary.uploadDate' | translate }}
+
{{ formatDate(item.createdAt) }}
+
{{ 'mediaLibrary.lastUsed' | translate }}
+
{{ 'mediaLibrary.unknownValue' | translate }}
+
{{ 'mediaLibrary.usageCountLabel' | translate }}
+
{{ usages().length }}
+
+
+ +
+

{{ 'mediaLibrary.usedIn' | translate }}

+ @if (usages().length === 0) { +

{{ 'mediaLibrary.notUsed' | translate }}

+ } @else { +
    + @for (usage of usages(); track usage.detail) { +
  • {{ usage.labelKey | translate }} {{ usage.detail }}
  • + } +
+ } +
+ +
+ @if (isImage()) { + + + + + @if (!item.decorative && !item.altText?.['en']) { +

{{ 'mediaLibrary.missingAltWarning' | translate }}

+ } + } + + + + + + +
+ +
+ {{ 'mediaLibrary.delete' | translate }} + {{ 'mediaLibrary.cancel' | translate }} +
+
+ } +
diff --git a/src/app/features/backoffice/media/components/asset-details-drawer/asset-details-drawer.component.scss b/src/app/features/backoffice/media/components/asset-details-drawer/asset-details-drawer.component.scss new file mode 100644 index 0000000..42c3792 --- /dev/null +++ b/src/app/features/backoffice/media/components/asset-details-drawer/asset-details-drawer.component.scss @@ -0,0 +1,75 @@ +.asset-details { + display: grid; + gap: 16px; +} + +.asset-details__preview img { + max-width: 100%; + max-height: 220px; + border-radius: 8px; + object-fit: contain; +} + +.asset-details__file { + padding: 20px; + border-radius: 8px; + background: var(--surface-muted, #eef2f0); + text-align: center; +} + +.asset-details__facts dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 12px; + margin: 0; +} + +.asset-details__facts dt { + color: var(--text-tertiary, #9aa6a2); + font-size: 0.8rem; +} + +.asset-details__facts dd { + margin: 0; + font-size: 0.85rem; + color: var(--text-primary, #1e3c38); +} + +.asset-details__usage h4 { + margin: 0 0 6px; + font-size: 0.9rem; +} + +.asset-details__usage ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 6px; + font-size: 0.82rem; +} + +.asset-details__hint { + margin: 0; + color: var(--text-secondary, #5f6e6a); + font-size: 0.85rem; +} + +.asset-details__edit { + display: grid; + gap: 12px; + border-top: 1px solid var(--border-subtle, #e7ece9); + padding-top: 12px; +} + +.asset-details__warning { + margin: 0; + color: var(--danger, #c0392b); + font-size: 0.8rem; +} + +.asset-details__actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} diff --git a/src/app/features/backoffice/media/components/asset-details-drawer/asset-details-drawer.component.ts b/src/app/features/backoffice/media/components/asset-details-drawer/asset-details-drawer.component.ts new file mode 100644 index 0000000..5c0306f --- /dev/null +++ b/src/app/features/backoffice/media/components/asset-details-drawer/asset-details-drawer.component.ts @@ -0,0 +1,69 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Output, computed, input, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { TranslatePipe } from '../../../../../i18n/translate.pipe'; +import { ButtonComponent } from '../../../../../shared/ui/button/button.component'; +import { InputComponent } from '../../../../../shared/ui/input/input.component'; +import { FormFieldComponent } from '../../../../../shared/ui/form-field/form-field.component'; +import { ToggleComponent } from '../../../../../shared/ui/toggle/toggle.component'; +import { DialogComponent } from '../../../../../shared/ui/dialog/dialog.component'; +import { BadgeComponent } from '../../../../../shared/ui/badge/badge.component'; +import { MediaAsset } from '../../../../../core/media/models/media-asset.model'; +import { MediaUsageEntry } from '../../../../../core/media/media-usage.service'; + +export interface AssetDetailsPatch { + altText?: Record; + caption?: string; + description?: string; + decorative?: boolean; +} + +@Component({ + selector: 'app-asset-details-drawer', + standalone: true, + imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, FormFieldComponent, ToggleComponent, DialogComponent, BadgeComponent], + templateUrl: './asset-details-drawer.component.html', + styleUrl: './asset-details-drawer.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AssetDetailsDrawerComponent { + readonly asset = input(null); + readonly usages = input([]); + + @Output() close = new EventEmitter(); + @Output() save = new EventEmitter(); + @Output() remove = new EventEmitter(); + + readonly isImage = computed(() => this.asset()?.mimeType.startsWith('image/') ?? false); + readonly dimensionsLabel = computed(() => { + const asset = this.asset(); + return asset?.width && asset?.height ? `${asset.width}×${asset.height}` : null; + }); + + formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + + formatDate(iso: string): string { + return new Date(iso).toLocaleString(); + } + + onAltChange(value: string): void { + const asset = this.asset(); + if (!asset) return; + this.save.emit({ altText: { ...(asset.altText ?? {}), en: value } }); + } + + onCaptionChange(value: string): void { + this.save.emit({ caption: value }); + } + + onDescriptionChange(value: string): void { + this.save.emit({ description: value }); + } + + onDecorativeChange(value: boolean): void { + this.save.emit({ decorative: value }); + } +} diff --git a/src/app/features/backoffice/media/components/asset-tile/asset-tile.component.html b/src/app/features/backoffice/media/components/asset-tile/asset-tile.component.html new file mode 100644 index 0000000..7c92b8c --- /dev/null +++ b/src/app/features/backoffice/media/components/asset-tile/asset-tile.component.html @@ -0,0 +1,36 @@ + + + + + +
+ {{ 'mediaLibrary.delete' | translate }} +
+
diff --git a/src/app/features/backoffice/media/components/asset-tile/asset-tile.component.scss b/src/app/features/backoffice/media/components/asset-tile/asset-tile.component.scss new file mode 100644 index 0000000..deed043 --- /dev/null +++ b/src/app/features/backoffice/media/components/asset-tile/asset-tile.component.scss @@ -0,0 +1,91 @@ +.asset-tile { + position: relative; + display: block; +} + +.asset-tile--selected { + outline: 2px solid var(--brand-primary, #1e8a6e); + outline-offset: 2px; +} + +.asset-tile__select { + position: absolute; + top: 8px; + left: 8px; + z-index: 1; +} + +.asset-tile__surface { + all: unset; + display: grid; + gap: 8px; + width: 100%; + cursor: pointer; + box-sizing: border-box; + + &:focus-visible { + outline: 2px solid var(--brand-primary, #1e8a6e); + outline-offset: 3px; + border-radius: 8px; + } +} + +.asset-tile__preview { + width: 100%; + height: 120px; + object-fit: cover; + border-radius: 8px; + background: var(--surface-muted, #eef2f0); +} + +.asset-tile__preview--file { + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + color: var(--text-secondary, #5f6e6a); + text-align: center; + padding: 8px; +} + +.asset-tile__meta { + display: grid; + gap: 4px; +} + +.asset-tile__filename { + font-size: 0.8rem; + font-weight: 700; + color: var(--text-primary, #1e3c38); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.asset-tile__sub { + font-size: 0.72rem; + color: var(--text-tertiary, #9aa6a2); +} + +.asset-tile__badges { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.asset-tile__actions { + display: flex; + justify-content: flex-end; + margin-top: 6px; +} + +.asset-tile--list { + .asset-tile__surface { + grid-template-columns: 72px 1fr; + align-items: center; + } + + .asset-tile__preview { + height: 56px; + } +} diff --git a/src/app/features/backoffice/media/components/asset-tile/asset-tile.component.ts b/src/app/features/backoffice/media/components/asset-tile/asset-tile.component.ts new file mode 100644 index 0000000..262316a --- /dev/null +++ b/src/app/features/backoffice/media/components/asset-tile/asset-tile.component.ts @@ -0,0 +1,48 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Output, computed, input } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { TranslatePipe } from '../../../../../i18n/translate.pipe'; +import { ButtonComponent } from '../../../../../shared/ui/button/button.component'; +import { BadgeComponent } from '../../../../../shared/ui/badge/badge.component'; +import { CardComponent } from '../../../../../shared/ui/card/card.component'; +import { ToggleComponent } from '../../../../../shared/ui/toggle/toggle.component'; +import { MediaAsset } from '../../../../../core/media/models/media-asset.model'; + +export type AssetTileLayout = 'grid' | 'list'; + +@Component({ + selector: 'app-asset-tile', + standalone: true, + imports: [FormsModule, TranslatePipe, ButtonComponent, BadgeComponent, CardComponent, ToggleComponent], + templateUrl: './asset-tile.component.html', + styleUrl: './asset-tile.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AssetTileComponent { + readonly asset = input.required(); + readonly layout = input('grid'); + readonly selected = input(false); + readonly usageCount = input(null); + readonly isUnused = input(false); + readonly missingAlt = input(false); + + @Output() open = new EventEmitter(); + @Output() toggleSelected = new EventEmitter(); + @Output() remove = new EventEmitter(); + + readonly isImage = computed(() => this.asset().mimeType.startsWith('image/')); + + readonly dimensionsLabel = computed(() => { + const { width, height } = this.asset(); + return width && height ? `${width}×${height}` : null; + }); + + formatSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } +} diff --git a/src/app/features/backoffice/media/facade/media-library.facade.ts b/src/app/features/backoffice/media/facade/media-library.facade.ts index 1cad1a4..91f857c 100644 --- a/src/app/features/backoffice/media/facade/media-library.facade.ts +++ b/src/app/features/backoffice/media/facade/media-library.facade.ts @@ -1,9 +1,11 @@ import { Injectable, inject, signal } from '@angular/core'; import { MediaRepository } from '../../../../core/media/media-repository'; -import { MediaAsset, MediaUploadOptions } from '../../../../core/media/models/media-asset.model'; +import { MediaAsset, MediaAssetKind, MediaSort, MediaUploadOptions } from '../../../../core/media/models/media-asset.model'; const PAGE_SIZE = 24; +export type MediaViewMode = 'grid' | 'list'; + @Injectable({ providedIn: 'root' }) export class MediaLibraryFacade { private readonly repository = inject(MediaRepository); @@ -17,6 +19,14 @@ export class MediaLibraryFacade { readonly page = signal(1); readonly folder = signal(null); readonly folders = signal([]); + readonly kind = signal(null); + readonly sort = signal('recent'); + readonly viewMode = signal('grid'); + readonly selectedIds = signal>(new Set()); + + /** Kept so a failed upload can be retried without asking the user to re-pick the file. */ + private lastFailedFile: File | null = null; + private uploadToken = 0; async load(): Promise { this.loading.set(true); @@ -27,6 +37,8 @@ export class MediaLibraryFacade { pageSize: PAGE_SIZE, search: this.search(), folder: this.folder() ?? undefined, + kind: this.kind() ?? undefined, + sort: this.sort(), }); this.items.set(result.items); this.total.set(result.total); @@ -50,22 +62,65 @@ export class MediaLibraryFacade { void this.load(); } + setKind(value: MediaAssetKind | null): void { + this.kind.set(value); + this.page.set(1); + void this.load(); + } + + setSort(value: MediaSort): void { + this.sort.set(value); + void this.load(); + } + + setViewMode(mode: MediaViewMode): void { + this.viewMode.set(mode); + } + setPage(page: number): void { this.page.set(page); void this.load(); } async upload(file: File, options: MediaUploadOptions = {}): Promise { + const token = ++this.uploadToken; this.uploading.set(true); this.error.set(null); try { await this.repository.upload(file, { folder: this.folder() ?? undefined, ...options }); + // A cancelled upload can't stop the underlying store write mid-flight (best-effort + // cancel), but it can at least avoid refreshing the list with the cancelled result. + if (token !== this.uploadToken) { + return; + } + this.lastFailedFile = null; this.page.set(1); await this.load(); } catch (error: unknown) { + if (token !== this.uploadToken) { + return; + } + this.lastFailedFile = file; this.error.set(error instanceof Error ? error.message : 'Failed to upload file.'); } finally { - this.uploading.set(false); + if (token === this.uploadToken) { + this.uploading.set(false); + } + } + } + + cancelUpload(): void { + this.uploadToken++; + this.uploading.set(false); + } + + canRetry(): boolean { + return !!this.lastFailedFile; + } + + async retryUpload(): Promise { + if (this.lastFailedFile) { + await this.upload(this.lastFailedFile); } } @@ -79,13 +134,103 @@ export class MediaLibraryFacade { } } + async updateDetails(id: string, patch: { altText?: Record; caption?: string; description?: string; decorative?: boolean }): Promise { + this.error.set(null); + try { + await this.repository.update(id, patch); + await this.load(); + } catch { + this.error.set('Failed to update asset details.'); + } + } + async remove(id: string): Promise { this.error.set(null); try { await this.repository.remove(id); + this.deselect(id); await this.load(); } catch { this.error.set('Failed to delete file.'); } } + + isSelected(id: string): boolean { + return this.selectedIds().has(id); + } + + toggleSelect(id: string, checked: boolean): void { + const next = new Set(this.selectedIds()); + checked ? next.add(id) : next.delete(id); + this.selectedIds.set(next); + } + + clearSelection(): void { + this.selectedIds.set(new Set()); + } + + private deselect(id: string): void { + const next = new Set(this.selectedIds()); + next.delete(id); + this.selectedIds.set(next); + } + + async bulkDelete(ids: string[]): Promise { + this.error.set(null); + try { + for (const id of ids) { + await this.repository.remove(id); + } + this.clearSelection(); + await this.load(); + } catch { + this.error.set('Failed to delete selected files.'); + } + } + + async bulkMoveToFolder(ids: string[], folder: string | null): Promise { + this.error.set(null); + try { + for (const id of ids) { + await this.repository.update(id, { folder: folder ?? undefined }); + } + this.clearSelection(); + await this.load(); + } catch { + this.error.set('Failed to move selected files.'); + } + } + + downloadAssets(assets: MediaAsset[]): void { + for (const asset of assets) { + const link = document.createElement('a'); + link.href = asset.url; + link.download = asset.filename; + link.click(); + } + } + + exportMetadata(assets: MediaAsset[]): void { + const payload = assets.map(asset => ({ + id: asset.id, + filename: asset.filename, + mimeType: asset.mimeType, + size: asset.size, + width: asset.width, + height: asset.height, + altText: asset.altText, + caption: asset.caption, + description: asset.description, + tags: asset.tags, + folder: asset.folder, + createdAt: asset.createdAt, + })); + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'media-metadata.json'; + link.click(); + URL.revokeObjectURL(url); + } } diff --git a/src/app/features/backoffice/media/media-library-page.component.html b/src/app/features/backoffice/media/media-library-page.component.html index bd3a4b2..95b3004 100644 --- a/src/app/features/backoffice/media/media-library-page.component.html +++ b/src/app/features/backoffice/media/media-library-page.component.html @@ -1,4 +1,14 @@
+
+ + + + + + + +
+

{{ 'mediaLibrary.title' | translate }}

@@ -7,28 +17,56 @@ (ngModelChange)="facade.setSearch($event)" [placeholder]="'mediaLibrary.searchPlaceholder' | translate" /> - - {{ 'mediaLibrary.newFolder' | translate }} + + +
+ {{ 'mediaLibrary.viewGrid' | translate }} + {{ 'mediaLibrary.viewList' | translate }} +
{{ (facade.uploading() ? 'mediaLibrary.uploading' : 'mediaLibrary.upload') | translate }} + @if (facade.uploading()) { + {{ 'mediaLibrary.cancelUpload' | translate }} + }
+
+

{{ 'mediaLibrary.dropHint' | translate }}

+
+ @if (facade.error()) { -

{{ facade.error() }}

+

+ {{ friendlyUploadError(facade.error()) }} + @if (facade.canRetry()) { + {{ 'mediaLibrary.retry' | translate }} + } +

+ } + + @if (facade.selectedIds().size > 0) { +
+ {{ facade.selectedIds().size }} {{ 'mediaLibrary.selectedCount' | translate }} + {{ 'mediaLibrary.download' | translate }} + {{ 'mediaLibrary.exportMetadata' | translate }} + {{ 'mediaLibrary.bulkDelete' | translate }} + {{ 'mediaLibrary.clearSelection' | translate }} +
} @if (facade.loading()) { @@ -41,32 +79,26 @@ + > + + {{ 'mediaLibrary.upload' | translate }} + + +

{{ 'mediaLibrary.guideCopy' | translate }}

} @else { -
+
@for (asset of facade.items(); track asset.id) { - -
- @if (asset.mimeType.startsWith('image/')) { - - } @else { -
{{ asset.mimeType }}
- } -
- {{ asset.filename }} - {{ formatSize(asset.size) }} - @if (asset.tags?.length) { - {{ asset.tags!.join(', ') }} - } -
-
- {{ 'mediaLibrary.editTags' | translate }} - - {{ 'mediaLibrary.delete' | translate }} - -
-
-
+ }
@@ -75,6 +107,14 @@ } } + + ; protected readonly pendingDelete = signal(null); + protected readonly detailsAsset = signal(null); + protected readonly dragActive = signal(false); protected readonly totalPages = () => Math.max(1, Math.ceil(this.facade.total() / PAGE_SIZE)); + protected readonly kindOptions = computed(() => [ + { value: 'all', label: this.translate.t('mediaLibrary.allTypes') }, + { value: 'image', label: this.translate.t('mediaLibrary.typeImage') }, + { value: 'svg', label: this.translate.t('mediaLibrary.typeSvg') }, + { value: 'pdf', label: this.translate.t('mediaLibrary.typePdf') }, + ]); + + protected readonly sortOptions = computed(() => [ + { value: 'recent', label: this.translate.t('mediaLibrary.sortRecent') }, + { value: 'name', label: this.translate.t('mediaLibrary.sortName') }, + { value: 'size', label: this.translate.t('mediaLibrary.sortSize') }, + ]); + + private readonly bootstrap = this.projectEditor.bootstrap; + + /** Usage lookups are computed per visible page, not the whole library, to avoid walking the bootstrap config thousands of times for large libraries. */ + private readonly usageByAssetId = computed>(() => { + const bootstrap = this.bootstrap(); + const map = new Map(); + for (const asset of this.facade.items()) { + map.set(asset.id, this.usageService.findUsages(bootstrap, asset.url).length); + } + return map; + }); + + protected readonly totalAssets = computed(() => this.facade.total()); + protected readonly imagesCount = computed(() => this.facade.items().filter(a => a.mimeType.startsWith('image/')).length); + protected readonly svgCount = computed(() => this.facade.items().filter(a => a.mimeType === 'image/svg+xml').length); + protected readonly logosCount = computed(() => { + const bootstrap = this.bootstrap(); + return this.facade.items().filter(a => this.usageService.findUsages(bootstrap, a.url).some(u => u.labelKey === 'mediaLibrary.usage.brand')).length; + }); + protected readonly unusedCount = computed(() => { + const usage = this.usageByAssetId(); + return this.facade.items().filter(a => (usage.get(a.id) ?? 0) === 0).length; + }); + protected readonly storageBytes = computed(() => this.facade.items().reduce((sum, a) => sum + a.size, 0)); + protected readonly altCoveragePercent = computed(() => { + const images = this.facade.items().filter(a => a.mimeType.startsWith('image/')); + if (images.length === 0) return 100; + const withAlt = images.filter(a => a.decorative || !!a.altText?.['en']).length; + return Math.round((withAlt / images.length) * 100); + }); + ngOnInit(): void { void this.facade.load(); } + protected usageCountFor(asset: MediaAsset): number { + return this.usageByAssetId().get(asset.id) ?? 0; + } + + protected isUnused(asset: MediaAsset): boolean { + return this.usageCountFor(asset) === 0; + } + + protected missingAlt(asset: MediaAsset): boolean { + return !asset.decorative && !asset.altText?.['en']; + } + + protected formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + + protected setViewMode(mode: MediaViewMode): void { + this.facade.setViewMode(mode); + } + + protected setKind(value: string): void { + this.facade.setKind(value === 'all' ? null : (value as MediaAssetKind)); + } + + protected setSort(value: string): void { + this.facade.setSort(value as MediaSort); + } + protected triggerFileInput(): void { this.fileInput?.nativeElement.click(); } protected async onFileSelected(event: Event): Promise { const input = event.target as HTMLInputElement; - const file = input.files?.[0]; + const files = input.files ? Array.from(input.files) : []; input.value = ''; - if (!file) { - return; + for (const file of files) { + await this.facade.upload(file); } - await this.facade.upload(file); } - protected async editTags(asset: MediaAsset): Promise { - const current = (asset.tags ?? []).join(', '); - const next = window.prompt('Tags (comma-separated):', current); - if (next === null) { - return; + protected onDragOver(event: DragEvent): void { + event.preventDefault(); + this.dragActive.set(true); + } + + protected onDragLeave(): void { + this.dragActive.set(false); + } + + protected async onDrop(event: DragEvent): Promise { + event.preventDefault(); + this.dragActive.set(false); + const files = event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []; + for (const file of files) { + await this.facade.upload(file); } - const tags = next.split(',').map(tag => tag.trim()).filter(Boolean); - await this.facade.updateTags(asset.id, tags); + } + + protected friendlyUploadError(message: string | null): string | null { + if (!message) return null; + if (message.includes('exceeds')) return this.translate.t('mediaLibrary.errorTooLarge'); + if (message.includes('Unsupported file type')) return this.translate.t('mediaLibrary.errorUnsupportedType'); + return this.translate.t('mediaLibrary.errorGeneric'); + } + + protected openDetails(asset: MediaAsset): void { + this.detailsAsset.set(asset); + } + + protected closeDetails(): void { + this.detailsAsset.set(null); + } + + protected detailsUsages() { + const asset = this.detailsAsset(); + return asset ? this.usageService.findUsages(this.bootstrap(), asset.url) : []; + } + + protected async saveDetails(patch: AssetDetailsPatch): Promise { + const asset = this.detailsAsset(); + if (!asset) return; + await this.facade.updateDetails(asset.id, patch); + const refreshed = this.facade.items().find(a => a.id === asset.id) ?? null; + this.detailsAsset.set(refreshed); } protected confirmDelete(asset: MediaAsset): void { @@ -77,28 +200,30 @@ export class MediaLibraryPageComponent implements OnInit { protected async proceedDelete(): Promise { const asset = this.pendingDelete(); - if (!asset) { - return; - } + if (!asset) return; await this.facade.remove(asset.id); this.pendingDelete.set(null); + if (this.detailsAsset()?.id === asset.id) { + this.closeDetails(); + } } - protected createFolder(): void { - const name = window.prompt('New folder name:'); - if (!name?.trim()) { - return; - } - this.facade.setFolder(name.trim()); + protected selectedAssets(): MediaAsset[] { + return this.facade.items().filter(a => this.facade.isSelected(a.id)); } - protected formatSize(bytes: number): string { - if (bytes < 1024) { - return `${bytes} B`; - } - if (bytes < 1024 * 1024) { - return `${(bytes / 1024).toFixed(1)} KB`; - } - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + protected async bulkDelete(): Promise { + const ids = this.selectedAssets().map(a => a.id); + if (ids.length === 0) return; + if (!confirm(this.translate.t('mediaLibrary.bulkDeleteConfirm'))) return; + await this.facade.bulkDelete(ids); + } + + protected bulkDownload(): void { + this.facade.downloadAssets(this.selectedAssets()); + } + + protected bulkExportMetadata(): void { + this.facade.exportMetadata(this.selectedAssets()); } } diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index c5ceb77..4cdf90d 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -1152,6 +1152,71 @@ export const en: Translations = { confirm: 'Delete', emptyTitle: 'No files yet', emptyDescription: 'Upload an image or document to get started.', + allFolders: 'All folders', + newFolder: 'New folder', + editTags: 'Edit tags', + totalAssets: 'Total assets', + logos: 'Logos', + unused: 'Unused', + storageUsed: 'Storage used', + altCoverage: 'Alt text coverage', + allTypes: 'All types', + typeImage: 'Images', + typeSvg: 'SVG', + typePdf: 'PDF', + sortRecent: 'Recent', + sortName: 'Name', + sortSize: 'Size', + viewMode: 'View', + viewGrid: 'Grid', + viewList: 'List', + dropHint: 'Drag and drop images here, or use Upload above.', + cancelUpload: 'Cancel', + retry: 'Retry', + errorTooLarge: 'That file is too large. Try a smaller file.', + errorUnsupportedType: 'That file type is not supported. Use JPG, PNG, WebP, GIF, SVG, or PDF.', + errorGeneric: 'Upload failed. Please try again.', + selectedCount: 'selected', + download: 'Download', + exportMetadata: 'Export metadata', + bulkDelete: 'Delete selected', + bulkDeleteConfirm: 'Delete the selected files? This cannot be undone.', + clearSelection: 'Clear selection', + guideCopy: 'Reusing an existing asset avoids duplicate uploads and keeps your storage tidy. For best results upload images around 1200×1200px for hero/banner use and compress large photos before uploading.', + missingAlt: 'Missing alt text', + usageCount: 'uses', + detailsTitle: 'Asset details', + filename: 'Filename', + format: 'Format', + fileSize: 'File size', + dimensions: 'Dimensions', + uploadDate: 'Uploaded', + lastUsed: 'Last used', + usageCountLabel: 'Usage count', + usedIn: 'Used in', + notUsed: 'Not currently used anywhere.', + unknownValue: 'Unknown', + altText: 'Alt text', + altTextHelp: 'Describes the image for screen readers and search engines.', + decorative: 'Decorative (no alt text needed)', + missingAltWarning: 'This image has no alt text and is not marked decorative — screen reader users won’t know what it shows.', + caption: 'Caption', + description: 'Description', + usage: { + brand: 'Logo', + theme: 'Theme', + header: 'Header', + footer: 'Footer', + staticPage: 'Static page', + homepage: 'Homepage', + catalog: 'Catalog', + productPage: 'Product page', + widget: 'Widget', + company: 'Company info', + seo: 'SEO', + navigation: 'Navigation', + other: 'Other', + }, }, adminCategories: { chooseImage: 'Choose image', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 0859e46..5a48b1b 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -1147,6 +1147,71 @@ export const hy: Translations = { confirm: 'Ջնջել', emptyTitle: 'Ֆայլեր դեռ չկան', emptyDescription: 'Վերբեռնեք պատկեր կամ փաստաթուղթ սկսելու համար։', + allFolders: 'Բոլոր պանակները', + newFolder: 'Նոր պանակ', + editTags: 'Խմբագրել պիտակները', + totalAssets: 'Ընդհանուր ֆայլեր', + logos: 'Լոգոներ', + unused: 'Չօգտագործվող', + storageUsed: 'Օգտագործված տարածք', + altCoverage: 'Alt տեքստի ծածկույթ', + allTypes: 'Բոլոր տեսակները', + typeImage: 'Պատկերներ', + typeSvg: 'SVG', + typePdf: 'PDF', + sortRecent: 'Վերջին', + sortName: 'Անուն', + sortSize: 'Չափ', + viewMode: 'Տեսք', + viewGrid: 'Ցանց', + viewList: 'Ցուցակ', + dropHint: 'Քաշեք և գցեք պատկերները այստեղ, կամ օգտագործեք վերևի «Վերբեռնել» կոճակը։', + cancelUpload: 'Չեղարկել', + retry: 'Կրկնել', + errorTooLarge: 'Ֆայլը չափազանց մեծ է։ Փորձեք ավելի փոքր ֆայլ։', + errorUnsupportedType: 'Այս ֆայլի տեսակը չի աջակցվում։ Օգտագործեք JPG, PNG, WebP, GIF, SVG կամ PDF։', + errorGeneric: 'Վերբեռնումը ձախողվեց։ Փորձեք կրկին։', + selectedCount: 'ընտրված է', + download: 'Ներբեռնել', + exportMetadata: 'Արտահանել մետատվյալները', + bulkDelete: 'Ջնջել ընտրվածները', + bulkDeleteConfirm: 'Ջնջե՞լ ընտրված ֆայլերը։ Հնարավոր չէ հետարկել։', + clearSelection: 'Չեղարկել ընտրությունը', + guideCopy: 'Առկա ֆայլի կրկնակի օգտագործումը խուսափում է կրկնօրինակումներից և խնայում է տարածք։ Հերոս/բաններ նկարների համար խորհուրդ ենք տալիս մոտ 1200×1200px չափս և սեղմել մեծ լուսանկարները վերբեռնելուց առաջ։', + missingAlt: 'Alt տեքստը բացակայում է', + usageCount: 'օգտ.', + detailsTitle: 'Ֆայլի մանրամասներ', + filename: 'Ֆայլի անուն', + format: 'Ձևաչափ', + fileSize: 'Ֆայլի չափ', + dimensions: 'Չափսեր', + uploadDate: 'Վերբեռնված է', + lastUsed: 'Վերջին օգտագործում', + usageCountLabel: 'Օգտագործումների քանակ', + usedIn: 'Օգտագործվում է', + notUsed: 'Այս պահին ոչ մի տեղ չի օգտագործվում։', + unknownValue: 'Անհայտ', + altText: 'Alt տեքստ', + altTextHelp: 'Նկարագրում է պատկերը էկրանընթերցողների և որոնողական համակարգերի համար։', + decorative: 'Դեկորատիվ (alt տեքստ պետք չէ)', + missingAltWarning: 'Այս պատկերը չունի alt տեքստ և նշված չէ որպես դեկորատիվ․ էկրանընթերցող օգտագործողները չեն իմանա, թե ինչ է պատկերված։', + caption: 'Ենթագիր', + description: 'Նկարագրություն', + usage: { + brand: 'Լոգո', + theme: 'Թեմա', + header: 'Վերնագիր', + footer: 'Ստորագիր', + staticPage: 'Ստատիկ էջ', + homepage: 'Գլխավոր էջ', + catalog: 'Կատալոգ', + productPage: 'Ապրանքի էջ', + widget: 'Վիջեթ', + company: 'Ընկերության տվյալներ', + seo: 'SEO', + navigation: 'Նավիգացիա', + other: 'Այլ', + }, }, adminCategories: { chooseImage: 'Ընտրել պատկեր', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 77df43a..0a8127c 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -1147,6 +1147,71 @@ export const ru: Translations = { confirm: 'Удалить', emptyTitle: 'Пока нет файлов', emptyDescription: 'Загрузите изображение или документ, чтобы начать.', + allFolders: 'Все папки', + newFolder: 'Новая папка', + editTags: 'Изменить теги', + totalAssets: 'Всего файлов', + logos: 'Логотипы', + unused: 'Неиспользуемые', + storageUsed: 'Использовано места', + altCoverage: 'Покрытие alt-текстом', + allTypes: 'Все типы', + typeImage: 'Изображения', + typeSvg: 'SVG', + typePdf: 'PDF', + sortRecent: 'Недавние', + sortName: 'Имя', + sortSize: 'Размер', + viewMode: 'Вид', + viewGrid: 'Сетка', + viewList: 'Список', + dropHint: 'Перетащите изображения сюда или используйте кнопку «Загрузить» выше.', + cancelUpload: 'Отменить', + retry: 'Повторить', + errorTooLarge: 'Файл слишком большой. Попробуйте файл меньшего размера.', + errorUnsupportedType: 'Этот тип файла не поддерживается. Используйте JPG, PNG, WebP, GIF, SVG или PDF.', + errorGeneric: 'Не удалось загрузить файл. Попробуйте ещё раз.', + selectedCount: 'выбрано', + download: 'Скачать', + exportMetadata: 'Экспортировать метаданные', + bulkDelete: 'Удалить выбранные', + bulkDeleteConfirm: 'Удалить выбранные файлы? Это действие нельзя отменить.', + clearSelection: 'Снять выделение', + guideCopy: 'Повторное использование существующего файла избавляет от дублей и экономит место. Для баннеров и главных изображений рекомендуем размер около 1200×1200px и сжатие крупных фото перед загрузкой.', + missingAlt: 'Нет alt-текста', + usageCount: 'исп.', + detailsTitle: 'Информация о файле', + filename: 'Имя файла', + format: 'Формат', + fileSize: 'Размер файла', + dimensions: 'Размеры', + uploadDate: 'Загружен', + lastUsed: 'Последнее использование', + usageCountLabel: 'Количество использований', + usedIn: 'Используется в', + notUsed: 'Пока нигде не используется.', + unknownValue: 'Неизвестно', + altText: 'Alt-текст', + altTextHelp: 'Описывает изображение для программ чтения с экрана и поисковых систем.', + decorative: 'Декоративное (alt-текст не нужен)', + missingAltWarning: 'У этого изображения нет alt-текста, и оно не отмечено как декоративное — пользователи с программами чтения с экрана не узнают, что на нём изображено.', + caption: 'Подпись', + description: 'Описание', + usage: { + brand: 'Логотип', + theme: 'Тема', + header: 'Шапка', + footer: 'Подвал', + staticPage: 'Статическая страница', + homepage: 'Главная страница', + catalog: 'Каталог', + productPage: 'Страница товара', + widget: 'Виджет', + company: 'Информация о компании', + seo: 'SEO', + navigation: 'Навигация', + other: 'Другое', + }, }, adminCategories: { chooseImage: 'Выбрать изображение', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 526f221..382cc64 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -1150,6 +1150,71 @@ export interface Translations { confirm: string; emptyTitle: string; emptyDescription: string; + allFolders: string; + newFolder: string; + editTags: string; + totalAssets: string; + logos: string; + unused: string; + storageUsed: string; + altCoverage: string; + allTypes: string; + typeImage: string; + typeSvg: string; + typePdf: string; + sortRecent: string; + sortName: string; + sortSize: string; + viewMode: string; + viewGrid: string; + viewList: string; + dropHint: string; + cancelUpload: string; + retry: string; + errorTooLarge: string; + errorUnsupportedType: string; + errorGeneric: string; + selectedCount: string; + download: string; + exportMetadata: string; + bulkDelete: string; + bulkDeleteConfirm: string; + clearSelection: string; + guideCopy: string; + missingAlt: string; + usageCount: string; + detailsTitle: string; + filename: string; + format: string; + fileSize: string; + dimensions: string; + uploadDate: string; + lastUsed: string; + usageCountLabel: string; + usedIn: string; + notUsed: string; + unknownValue: string; + altText: string; + altTextHelp: string; + decorative: string; + missingAltWarning: string; + caption: string; + description: string; + usage: { + brand: string; + theme: string; + header: string; + footer: string; + staticPage: string; + homepage: string; + catalog: string; + productPage: string; + widget: string; + company: string; + seo: string; + navigation: string; + other: string; + }; }; // Sprint 28: only the new empty-state copy this sprint's skeleton/empty- // state consistency fix introduces - NOT the full adminProducts/ diff --git a/src/app/shared/media/media-picker/media-picker.component.html b/src/app/shared/media/media-picker/media-picker.component.html index 3695a86..f31dcd5 100644 --- a/src/app/shared/media/media-picker/media-picker.component.html +++ b/src/app/shared/media/media-picker/media-picker.component.html @@ -11,6 +11,8 @@ (ngModelChange)="facade.setSearch($event)" [placeholder]="'mediaLibrary.searchPlaceholder' | translate" /> + + {{ 'mediaLibrary.sortRecent' | translate }} } @else { -
- @for (asset of facade.items(); track asset.id) { - +
+ @for (asset of facade.items(); track asset.id; let i = $index) { + @if (asset.mimeType.startsWith('image/')) { - + } @else {
{{ asset.mimeType }}
} diff --git a/src/app/shared/media/media-picker/media-picker.component.scss b/src/app/shared/media/media-picker/media-picker.component.scss index dab5cf5..39bfb32 100644 --- a/src/app/shared/media/media-picker/media-picker.component.scss +++ b/src/app/shared/media/media-picker/media-picker.component.scss @@ -40,6 +40,11 @@ margin: 0; } +.media-picker__card--focused { + outline: 2px solid var(--brand-primary, #1e8a6e); + outline-offset: 2px; +} + .media-picker__filename { display: block; font-size: 0.75rem; diff --git a/src/app/shared/media/media-picker/media-picker.component.ts b/src/app/shared/media/media-picker/media-picker.component.ts index 4f41f21..8d4282b 100644 --- a/src/app/shared/media/media-picker/media-picker.component.ts +++ b/src/app/shared/media/media-picker/media-picker.component.ts @@ -1,8 +1,9 @@ -import { ChangeDetectionStrategy, Component, ElementRef, ViewChild, effect, inject, input, output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, ElementRef, ViewChild, computed, effect, inject, input, output, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { TranslatePipe } from '../../../i18n/translate.pipe'; +import { TranslateService } from '../../../i18n/translate.service'; import { MediaLibraryFacade } from '../../../features/backoffice/media/facade/media-library.facade'; -import { MediaAsset } from '../../../core/media/models/media-asset.model'; +import { MediaAsset, MediaAssetKind } from '../../../core/media/models/media-asset.model'; import { ButtonComponent } from '../../ui/button/button.component'; import { InputComponent } from '../../ui/input/input.component'; import { CardComponent } from '../../ui/card/card.component'; @@ -10,6 +11,7 @@ import { EmptyStateComponent } from '../../ui/empty-state/empty-state.component' import { DialogComponent } from '../../ui/dialog/dialog.component'; import { PaginationComponent } from '../../ui/pagination/pagination.component'; import { SkeletonComponent } from '../../ui/skeleton/skeleton.component'; +import { SelectComponent, SelectOption } from '../../ui/select/select.component'; const PAGE_SIZE = 24; @@ -26,6 +28,7 @@ const PAGE_SIZE = 24; DialogComponent, PaginationComponent, SkeletonComponent, + SelectComponent, ], templateUrl: './media-picker.component.html', styleUrl: './media-picker.component.scss', @@ -38,10 +41,55 @@ export class MediaPickerComponent { readonly closed = output(); protected readonly facade = inject(MediaLibraryFacade); + private readonly translate = inject(TranslateService); @ViewChild('fileInput') private fileInput?: ElementRef; protected readonly totalPages = () => Math.max(1, Math.ceil(this.facade.total() / PAGE_SIZE)); + protected readonly focusedIndex = signal(0); + + protected readonly kindOptions = computed(() => [ + { value: 'all', label: this.translate.t('mediaLibrary.allTypes') }, + { value: 'image', label: this.translate.t('mediaLibrary.typeImage') }, + { value: 'svg', label: this.translate.t('mediaLibrary.typeSvg') }, + { value: 'pdf', label: this.translate.t('mediaLibrary.typePdf') }, + ]); + + setKind(value: string): void { + this.facade.setKind(value === 'all' ? null : (value as MediaAssetKind)); + } + + showRecentOnly(): void { + this.facade.setSort('recent'); + this.facade.setFolder(null); + this.facade.setSearch(''); + } + + protected onGridKeydown(event: KeyboardEvent): void { + const count = this.facade.items().length; + if (count === 0) { + return; + } + const columns = Math.max(1, Math.floor((event.currentTarget as HTMLElement).clientWidth / 150)); + if (event.key === 'ArrowRight') { + event.preventDefault(); + this.focusedIndex.set(Math.min(count - 1, this.focusedIndex() + 1)); + } else if (event.key === 'ArrowLeft') { + event.preventDefault(); + this.focusedIndex.set(Math.max(0, this.focusedIndex() - 1)); + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + this.focusedIndex.set(Math.min(count - 1, this.focusedIndex() + columns)); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + this.focusedIndex.set(Math.max(0, this.focusedIndex() - columns)); + } else if (event.key === 'Enter') { + const asset = this.facade.items()[this.focusedIndex()]; + if (asset) { + this.pick(asset); + } + } + } constructor() { // MediaLibraryFacade is a root-provided singleton shared by every @@ -56,7 +104,9 @@ export class MediaPickerComponent { if (this.open()) { this.facade.search.set(''); this.facade.folder.set(null); + this.facade.kind.set(null); this.facade.page.set(1); + this.focusedIndex.set(0); void this.facade.load(); } });