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

@@ -11,6 +11,8 @@
(ngModelChange)="facade.setSearch($event)"
[placeholder]="'mediaLibrary.searchPlaceholder' | translate"
/>
<app-select [options]="kindOptions()" [ngModel]="facade.kind() || 'all'" (ngModelChange)="setKind($event)" [ariaLabel]="'mediaLibrary.allTypes' | translate" [fullWidth]="false" />
<app-button variant="ghost" size="sm" (click)="showRecentOnly()">{{ 'mediaLibrary.sortRecent' | translate }}</app-button>
<input
#fileInput
type="file"
@@ -39,11 +41,18 @@
[description]="'mediaLibrary.emptyDescription' | translate"
/>
} @else {
<div class="media-picker__grid">
@for (asset of facade.items(); track asset.id) {
<app-card padding="sm" [interactive]="true" (click)="pick(asset)">
<div class="media-picker__grid" role="listbox" [attr.aria-label]="'mediaLibrary.title' | translate" tabindex="0" (keydown)="onGridKeydown($event)">
@for (asset of facade.items(); track asset.id; let i = $index) {
<app-card
padding="sm"
[interactive]="true"
role="option"
[attr.aria-selected]="i === focusedIndex()"
[class.media-picker__card--focused]="i === focusedIndex()"
(click)="pick(asset)"
>
@if (asset.mimeType.startsWith('image/')) {
<img class="media-picker__preview" [src]="asset.url" [alt]="asset.filename" />
<img class="media-picker__preview" [src]="asset.url" [alt]="asset.filename" loading="lazy" />
} @else {
<div class="media-picker__preview media-picker__preview--file">{{ asset.mimeType }}</div>
}

View File

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

View File

@@ -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<void>();
protected readonly facade = inject(MediaLibraryFacade);
private readonly translate = inject(TranslateService);
@ViewChild('fileInput') private fileInput?: ElementRef<HTMLInputElement>;
protected readonly totalPages = () => Math.max(1, Math.ceil(this.facade.total() / PAGE_SIZE));
protected readonly focusedIndex = signal(0);
protected readonly kindOptions = computed<SelectOption[]>(() => [
{ 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();
}
});