feat(media): add reusable MediaPickerComponent
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Standalone dialog (app-dialog, size lg) reusing MediaLibraryFacade so it shares the exact same asset list/search/pagination/upload state as the Media Manager page. Emits (selected) with the chosen MediaAsset and (closed) to dismiss - selecting a tile emits both. Completes Sprint 4 (Media Manager): ADR-0002 contract, MediaRepository + mock IndexedDB adapter, Media Manager UI, reusable Media Picker. Not yet wired into Product Editor or Static Pages editor - that's the next natural step when those features gain image fields, not part of this sprint's scope.
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
<app-dialog
|
||||||
|
[open]="open()"
|
||||||
|
[titleText]="'mediaLibrary.title' | translate"
|
||||||
|
size="lg"
|
||||||
|
(closed)="requestClose()"
|
||||||
|
>
|
||||||
|
<div class="media-picker">
|
||||||
|
<div class="media-picker__toolbar">
|
||||||
|
<app-input
|
||||||
|
[ngModel]="facade.search()"
|
||||||
|
(ngModelChange)="facade.setSearch($event)"
|
||||||
|
[placeholder]="'mediaLibrary.searchPlaceholder' | translate"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
#fileInput
|
||||||
|
type="file"
|
||||||
|
accept="image/*,application/pdf"
|
||||||
|
hidden
|
||||||
|
(change)="onFileSelected($event)"
|
||||||
|
/>
|
||||||
|
<app-button variant="secondary" [loading]="facade.uploading()" (click)="triggerFileInput()">
|
||||||
|
{{ (facade.uploading() ? 'mediaLibrary.uploading' : 'mediaLibrary.upload') | translate }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (facade.loading()) {
|
||||||
|
<div class="media-picker__grid">
|
||||||
|
@for (i of [1, 2, 3, 4]; track i) {
|
||||||
|
<app-skeleton shape="rect" height="110px" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else if (facade.items().length === 0) {
|
||||||
|
<app-empty-state
|
||||||
|
[title]="'mediaLibrary.emptyTitle' | translate"
|
||||||
|
[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)">
|
||||||
|
@if (asset.mimeType.startsWith('image/')) {
|
||||||
|
<img class="media-picker__preview" [src]="asset.url" [alt]="asset.filename" />
|
||||||
|
} @else {
|
||||||
|
<div class="media-picker__preview media-picker__preview--file">{{ asset.mimeType }}</div>
|
||||||
|
}
|
||||||
|
<span class="media-picker__filename">{{ asset.filename }}</span>
|
||||||
|
</app-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (totalPages() > 1) {
|
||||||
|
<app-pagination [currentPage]="facade.page()" [totalPages]="totalPages()" (pageChange)="facade.setPage($event)" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</app-dialog>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
.media-picker {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-picker__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-picker__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-picker__preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 90px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-md, 6px);
|
||||||
|
background: var(--bg-secondary, #f4f4f5);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-picker__preview--file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-picker__filename {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-primary, #1f322d);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
72
src/app/shared/media/media-picker/media-picker.component.ts
Normal file
72
src/app/shared/media/media-picker/media-picker.component.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, ElementRef, OnInit, ViewChild, inject, input, output } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||||
|
import { MediaLibraryFacade } from '../../../features/backoffice/media/facade/media-library.facade';
|
||||||
|
import { MediaAsset } 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';
|
||||||
|
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';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-media-picker',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
TranslatePipe,
|
||||||
|
ButtonComponent,
|
||||||
|
InputComponent,
|
||||||
|
CardComponent,
|
||||||
|
EmptyStateComponent,
|
||||||
|
DialogComponent,
|
||||||
|
PaginationComponent,
|
||||||
|
SkeletonComponent,
|
||||||
|
],
|
||||||
|
templateUrl: './media-picker.component.html',
|
||||||
|
styleUrl: './media-picker.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class MediaPickerComponent implements OnInit {
|
||||||
|
readonly open = input(false);
|
||||||
|
|
||||||
|
readonly selected = output<MediaAsset>();
|
||||||
|
readonly closed = output<void>();
|
||||||
|
|
||||||
|
protected readonly facade = inject(MediaLibraryFacade);
|
||||||
|
|
||||||
|
@ViewChild('fileInput') private fileInput?: ElementRef<HTMLInputElement>;
|
||||||
|
|
||||||
|
protected readonly totalPages = () => Math.max(1, Math.ceil(this.facade.total() / PAGE_SIZE));
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
void this.facade.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected triggerFileInput(): void {
|
||||||
|
this.fileInput?.nativeElement.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async onFileSelected(event: Event): Promise<void> {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.facade.upload(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected pick(asset: MediaAsset): void {
|
||||||
|
this.selected.emit(asset);
|
||||||
|
this.closed.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected requestClose(): void {
|
||||||
|
this.closed.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user