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:
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user