diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index dd94511..2dd2a0c 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -93,7 +93,7 @@ const coreRoutes: Routes = [ }, { 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' } }, { path: '**', redirectTo: 'dashboard' } diff --git a/src/app/features/backoffice/media/facade/media-library.facade.ts b/src/app/features/backoffice/media/facade/media-library.facade.ts new file mode 100644 index 0000000..6eb3866 --- /dev/null +++ b/src/app/features/backoffice/media/facade/media-library.facade.ts @@ -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([]); + readonly total = signal(0); + readonly loading = signal(false); + readonly uploading = signal(false); + readonly error = signal(null); + readonly search = signal(''); + readonly page = signal(1); + + async load(): Promise { + 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 { + 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 { + this.error.set(null); + try { + await this.repository.remove(id); + await this.load(); + } catch { + this.error.set('Failed to delete file.'); + } + } +} diff --git a/src/app/features/backoffice/media/media-library-page.component.html b/src/app/features/backoffice/media/media-library-page.component.html new file mode 100644 index 0000000..f2e7a27 --- /dev/null +++ b/src/app/features/backoffice/media/media-library-page.component.html @@ -0,0 +1,73 @@ +
+
+

{{ 'mediaLibrary.title' | translate }}

+
+ + + + {{ (facade.uploading() ? 'mediaLibrary.uploading' : 'mediaLibrary.upload') | translate }} + +
+
+ + @if (facade.loading()) { +
+ @for (i of [1, 2, 3, 4, 5, 6]; track i) { + + } +
+ } @else if (facade.items().length === 0) { + + } @else { +
+ @for (asset of facade.items(); track asset.id) { + +
+ @if (asset.mimeType.startsWith('image/')) { + + } @else { +
{{ asset.mimeType }}
+ } +
+ {{ asset.filename }} + {{ formatSize(asset.size) }} +
+ + {{ 'mediaLibrary.delete' | translate }} + +
+
+ } +
+ + @if (totalPages() > 1) { + + } + } + + +

{{ 'mediaLibrary.deleteConfirmBody' | translate }}

+
+ {{ 'mediaLibrary.cancel' | translate }} + {{ 'mediaLibrary.confirm' | translate }} +
+
+
diff --git a/src/app/features/backoffice/media/media-library-page.component.scss b/src/app/features/backoffice/media/media-library-page.component.scss new file mode 100644 index 0000000..9e73f39 --- /dev/null +++ b/src/app/features/backoffice/media/media-library-page.component.scss @@ -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; +} diff --git a/src/app/features/backoffice/media/media-library-page.component.ts b/src/app/features/backoffice/media/media-library-page.component.ts new file mode 100644 index 0000000..9a3f392 --- /dev/null +++ b/src/app/features/backoffice/media/media-library-page.component.ts @@ -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; + + protected readonly pendingDelete = signal(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 { + 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 { + 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`; + } +} diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 0d8e3fe..b0f3977 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -846,4 +846,17 @@ export const en: Translations = { optionalDataMissingDescription: 'Optional configuration data is absent.', 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.', + }, }; diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 124acc4..0946630 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -841,4 +841,17 @@ export const hy: Translations = { optionalDataMissingDescription: 'Optional config արժեքը բացակայում է։', optionalDataMissingResolution: 'Ավելացրեք արժեքը, եթե UX-ը կախված է դրանից։', }, + mediaLibrary: { + title: 'Մեդիագրադարան', + upload: 'Վերբեռնել', + uploading: 'Վերբեռնվում է...', + searchPlaceholder: 'Փնտրել ֆայլեր...', + delete: 'Ջնջել', + deleteConfirmTitle: 'Ջնջե՞լ ֆայլը', + deleteConfirmBody: 'Այս գործողությունը հնարավոր չէ հետարկել։', + cancel: 'Չեղարկել', + confirm: 'Ջնջել', + emptyTitle: 'Ֆայլեր դեռ չկան', + emptyDescription: 'Վերբեռնեք պատկեր կամ փաստաթուղթ սկսելու համար։', + }, }; diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index d9e4c11..79ca537 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -841,4 +841,17 @@ export const ru: Translations = { optionalDataMissingDescription: 'Необязательное конфигурационное значение отсутствует.', optionalDataMissingResolution: 'Добавьте значение, если оно нужно UX.', }, + mediaLibrary: { + title: 'Медиатека', + upload: 'Загрузить', + uploading: 'Загрузка...', + searchPlaceholder: 'Поиск файлов...', + delete: 'Удалить', + deleteConfirmTitle: 'Удалить файл?', + deleteConfirmBody: 'Это действие необратимо.', + cancel: 'Отмена', + confirm: 'Удалить', + emptyTitle: 'Пока нет файлов', + emptyDescription: 'Загрузите изображение или документ, чтобы начать.', + }, }; diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 95d72f0..6f88316 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -844,4 +844,17 @@ export interface Translations { optionalDataMissingDescription: 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; + }; }