feat(media): add Media Manager UI (grid, upload, delete)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
MediaLibraryPageComponent replaces the coming-soon placeholder at /backoffice/media. Built entirely on Sprint 6 Design System primitives (app-card, app-button, app-input, app-empty-state, app-dialog, app-pagination, app-skeleton) and Sprint 4 Task 2's MediaRepository/MockMediaRepository. - Grid view with per-tile filename/size and delete action - Hidden native file input triggered by an app-button, uploads via MediaLibraryFacade -> MediaRepository.upload() - Delete requires confirmation through app-dialog (destructive action) - Search + pagination wired to MockMediaRepository's list() params - Loading state shows app-skeleton tiles; empty state shows app-empty-state - New mediaLibrary.* translation namespace across en/ru/hy Verified in browser (via a temporary unguarded route, reverted before commit - /backoffice/media itself requires Telegram QR admin auth not available in this session): empty state renders correctly with translated copy, search input and upload button present, no console errors.
This commit is contained in:
@@ -93,7 +93,7 @@ const coreRoutes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'media',
|
path: 'media',
|
||||||
loadComponent: () => import('./features/backoffice/shared/backoffice-coming-soon-page.component').then(m => m.BackofficeComingSoonPageComponent),
|
loadComponent: () => import('./features/backoffice/media/media-library-page.component').then(m => m.MediaLibraryPageComponent),
|
||||||
data: { titleKey: 'dashboard.actionMediaLibrary' }
|
data: { titleKey: 'dashboard.actionMediaLibrary' }
|
||||||
},
|
},
|
||||||
{ path: '**', redirectTo: 'dashboard' }
|
{ path: '**', redirectTo: 'dashboard' }
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { MediaRepository } from '../../../../core/media/media-repository';
|
||||||
|
import { MediaAsset } from '../../../../core/media/models/media-asset.model';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class MediaLibraryFacade {
|
||||||
|
private readonly repository = inject(MediaRepository);
|
||||||
|
|
||||||
|
readonly items = signal<MediaAsset[]>([]);
|
||||||
|
readonly total = signal(0);
|
||||||
|
readonly loading = signal(false);
|
||||||
|
readonly uploading = signal(false);
|
||||||
|
readonly error = signal<string | null>(null);
|
||||||
|
readonly search = signal('');
|
||||||
|
readonly page = signal(1);
|
||||||
|
|
||||||
|
async load(): Promise<void> {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
|
try {
|
||||||
|
const result = await this.repository.list({
|
||||||
|
page: this.page(),
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
search: this.search(),
|
||||||
|
});
|
||||||
|
this.items.set(result.items);
|
||||||
|
this.total.set(result.total);
|
||||||
|
} catch {
|
||||||
|
this.error.set('Failed to load media assets.');
|
||||||
|
} finally {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearch(value: string): void {
|
||||||
|
this.search.set(value);
|
||||||
|
this.page.set(1);
|
||||||
|
void this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPage(page: number): void {
|
||||||
|
this.page.set(page);
|
||||||
|
void this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload(file: File): Promise<void> {
|
||||||
|
this.uploading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
|
try {
|
||||||
|
await this.repository.upload(file);
|
||||||
|
this.page.set(1);
|
||||||
|
await this.load();
|
||||||
|
} catch {
|
||||||
|
this.error.set('Failed to upload file.');
|
||||||
|
} finally {
|
||||||
|
this.uploading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
this.error.set(null);
|
||||||
|
try {
|
||||||
|
await this.repository.remove(id);
|
||||||
|
await this.load();
|
||||||
|
} catch {
|
||||||
|
this.error.set('Failed to delete file.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<section class="media-page">
|
||||||
|
<div class="media-page__toolbar">
|
||||||
|
<h2>{{ 'mediaLibrary.title' | translate }}</h2>
|
||||||
|
<div class="media-page__toolbar-actions">
|
||||||
|
<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="primary" [loading]="facade.uploading()" (click)="triggerFileInput()">
|
||||||
|
{{ (facade.uploading() ? 'mediaLibrary.uploading' : 'mediaLibrary.upload') | translate }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (facade.loading()) {
|
||||||
|
<div class="media-page__grid">
|
||||||
|
@for (i of [1, 2, 3, 4, 5, 6]; track i) {
|
||||||
|
<app-skeleton shape="rect" height="140px" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else if (facade.items().length === 0) {
|
||||||
|
<app-empty-state
|
||||||
|
[title]="'mediaLibrary.emptyTitle' | translate"
|
||||||
|
[description]="'mediaLibrary.emptyDescription' | translate"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<div class="media-page__grid">
|
||||||
|
@for (asset of facade.items(); track asset.id) {
|
||||||
|
<app-card padding="sm">
|
||||||
|
<div class="media-tile">
|
||||||
|
@if (asset.mimeType.startsWith('image/')) {
|
||||||
|
<img class="media-tile__preview" [src]="asset.url" [alt]="asset.filename" />
|
||||||
|
} @else {
|
||||||
|
<div class="media-tile__preview media-tile__preview--file">{{ asset.mimeType }}</div>
|
||||||
|
}
|
||||||
|
<div class="media-tile__meta">
|
||||||
|
<span class="media-tile__filename">{{ asset.filename }}</span>
|
||||||
|
<span class="media-tile__size">{{ formatSize(asset.size) }}</span>
|
||||||
|
</div>
|
||||||
|
<app-button variant="danger" size="sm" [fullWidth]="true" (click)="confirmDelete(asset)">
|
||||||
|
{{ 'mediaLibrary.delete' | translate }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</app-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (totalPages() > 1) {
|
||||||
|
<app-pagination [currentPage]="facade.page()" [totalPages]="totalPages()" (pageChange)="facade.setPage($event)" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<app-dialog
|
||||||
|
[open]="!!pendingDelete()"
|
||||||
|
[titleText]="'mediaLibrary.deleteConfirmTitle' | translate"
|
||||||
|
size="sm"
|
||||||
|
(closed)="cancelDelete()"
|
||||||
|
>
|
||||||
|
<p>{{ 'mediaLibrary.deleteConfirmBody' | translate }}</p>
|
||||||
|
<div class="media-page__dialog-actions">
|
||||||
|
<app-button variant="secondary" (click)="cancelDelete()">{{ 'mediaLibrary.cancel' | translate }}</app-button>
|
||||||
|
<app-button variant="danger" (click)="proceedDelete()">{{ 'mediaLibrary.confirm' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
</app-dialog>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
.media-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-page__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-page__toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-page__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-tile {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-tile__preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 110px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-md, 6px);
|
||||||
|
background: var(--bg-secondary, #f4f4f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-tile__preview--file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-tile__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-tile__filename {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1f322d);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-tile__size {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-page__dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, ElementRef, OnInit, ViewChild, inject, signal } from '@angular/core';
|
||||||
|
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||||
|
import { MediaLibraryFacade } from './facade/media-library.facade';
|
||||||
|
import { MediaAsset } from '../../../core/media/models/media-asset.model';
|
||||||
|
import { ButtonComponent } from '../../../shared/ui/button/button.component';
|
||||||
|
import { InputComponent } from '../../../shared/ui/input/input.component';
|
||||||
|
import { CardComponent } from '../../../shared/ui/card/card.component';
|
||||||
|
import { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state.component';
|
||||||
|
import { DialogComponent } from '../../../shared/ui/dialog/dialog.component';
|
||||||
|
import { PaginationComponent } from '../../../shared/ui/pagination/pagination.component';
|
||||||
|
import { SkeletonComponent } from '../../../shared/ui/skeleton/skeleton.component';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-media-library-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
TranslatePipe,
|
||||||
|
FormsModule,
|
||||||
|
ButtonComponent,
|
||||||
|
InputComponent,
|
||||||
|
CardComponent,
|
||||||
|
EmptyStateComponent,
|
||||||
|
DialogComponent,
|
||||||
|
PaginationComponent,
|
||||||
|
SkeletonComponent,
|
||||||
|
],
|
||||||
|
templateUrl: './media-library-page.component.html',
|
||||||
|
styleUrl: './media-library-page.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class MediaLibraryPageComponent implements OnInit {
|
||||||
|
protected readonly facade = inject(MediaLibraryFacade);
|
||||||
|
|
||||||
|
@ViewChild('fileInput') private fileInput?: ElementRef<HTMLInputElement>;
|
||||||
|
|
||||||
|
protected readonly pendingDelete = signal<MediaAsset | null>(null);
|
||||||
|
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 confirmDelete(asset: MediaAsset): void {
|
||||||
|
this.pendingDelete.set(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected cancelDelete(): void {
|
||||||
|
this.pendingDelete.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async proceedDelete(): Promise<void> {
|
||||||
|
const asset = this.pendingDelete();
|
||||||
|
if (!asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.facade.remove(asset.id);
|
||||||
|
this.pendingDelete.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -846,4 +846,17 @@ export const en: Translations = {
|
|||||||
optionalDataMissingDescription: 'Optional configuration data is absent.',
|
optionalDataMissingDescription: 'Optional configuration data is absent.',
|
||||||
optionalDataMissingResolution: 'Add optional value if UX depends on it.',
|
optionalDataMissingResolution: 'Add optional value if UX depends on it.',
|
||||||
},
|
},
|
||||||
|
mediaLibrary: {
|
||||||
|
title: 'Media Library',
|
||||||
|
upload: 'Upload',
|
||||||
|
uploading: 'Uploading...',
|
||||||
|
searchPlaceholder: 'Search files...',
|
||||||
|
delete: 'Delete',
|
||||||
|
deleteConfirmTitle: 'Delete file?',
|
||||||
|
deleteConfirmBody: 'This cannot be undone.',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
confirm: 'Delete',
|
||||||
|
emptyTitle: 'No files yet',
|
||||||
|
emptyDescription: 'Upload an image or document to get started.',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -841,4 +841,17 @@ export const hy: Translations = {
|
|||||||
optionalDataMissingDescription: 'Optional config արժեքը բացակայում է։',
|
optionalDataMissingDescription: 'Optional config արժեքը բացակայում է։',
|
||||||
optionalDataMissingResolution: 'Ավելացրեք արժեքը, եթե UX-ը կախված է դրանից։',
|
optionalDataMissingResolution: 'Ավելացրեք արժեքը, եթե UX-ը կախված է դրանից։',
|
||||||
},
|
},
|
||||||
|
mediaLibrary: {
|
||||||
|
title: 'Մեդիագրադարան',
|
||||||
|
upload: 'Վերբեռնել',
|
||||||
|
uploading: 'Վերբեռնվում է...',
|
||||||
|
searchPlaceholder: 'Փնտրել ֆայլեր...',
|
||||||
|
delete: 'Ջնջել',
|
||||||
|
deleteConfirmTitle: 'Ջնջե՞լ ֆայլը',
|
||||||
|
deleteConfirmBody: 'Այս գործողությունը հնարավոր չէ հետարկել։',
|
||||||
|
cancel: 'Չեղարկել',
|
||||||
|
confirm: 'Ջնջել',
|
||||||
|
emptyTitle: 'Ֆայլեր դեռ չկան',
|
||||||
|
emptyDescription: 'Վերբեռնեք պատկեր կամ փաստաթուղթ սկսելու համար։',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -841,4 +841,17 @@ export const ru: Translations = {
|
|||||||
optionalDataMissingDescription: 'Необязательное конфигурационное значение отсутствует.',
|
optionalDataMissingDescription: 'Необязательное конфигурационное значение отсутствует.',
|
||||||
optionalDataMissingResolution: 'Добавьте значение, если оно нужно UX.',
|
optionalDataMissingResolution: 'Добавьте значение, если оно нужно UX.',
|
||||||
},
|
},
|
||||||
|
mediaLibrary: {
|
||||||
|
title: 'Медиатека',
|
||||||
|
upload: 'Загрузить',
|
||||||
|
uploading: 'Загрузка...',
|
||||||
|
searchPlaceholder: 'Поиск файлов...',
|
||||||
|
delete: 'Удалить',
|
||||||
|
deleteConfirmTitle: 'Удалить файл?',
|
||||||
|
deleteConfirmBody: 'Это действие необратимо.',
|
||||||
|
cancel: 'Отмена',
|
||||||
|
confirm: 'Удалить',
|
||||||
|
emptyTitle: 'Пока нет файлов',
|
||||||
|
emptyDescription: 'Загрузите изображение или документ, чтобы начать.',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -844,4 +844,17 @@ export interface Translations {
|
|||||||
optionalDataMissingDescription: string;
|
optionalDataMissingDescription: string;
|
||||||
optionalDataMissingResolution: string;
|
optionalDataMissingResolution: string;
|
||||||
};
|
};
|
||||||
|
mediaLibrary: {
|
||||||
|
title: string;
|
||||||
|
upload: string;
|
||||||
|
uploading: string;
|
||||||
|
searchPlaceholder: string;
|
||||||
|
delete: string;
|
||||||
|
deleteConfirmTitle: string;
|
||||||
|
deleteConfirmBody: string;
|
||||||
|
cancel: string;
|
||||||
|
confirm: string;
|
||||||
|
emptyTitle: string;
|
||||||
|
emptyDescription: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user