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

@@ -0,0 +1,71 @@
<app-dialog [open]="!!asset()" [titleText]="'mediaLibrary.detailsTitle' | translate" size="lg" (closed)="close.emit()">
@if (asset(); as item) {
<div class="asset-details">
<div class="asset-details__preview">
@if (isImage()) {
<img [src]="item.url" [alt]="item.altText?.['en'] || ''" />
} @else {
<div class="asset-details__file">{{ item.mimeType }}</div>
}
</div>
<div class="asset-details__facts">
<dl>
<dt>{{ 'mediaLibrary.filename' | translate }}</dt>
<dd>{{ item.filename }}</dd>
<dt>{{ 'mediaLibrary.format' | translate }}</dt>
<dd>{{ item.mimeType }}</dd>
<dt>{{ 'mediaLibrary.fileSize' | translate }}</dt>
<dd>{{ formatSize(item.size) }}</dd>
<dt>{{ 'mediaLibrary.dimensions' | translate }}</dt>
<dd>{{ dimensionsLabel() || ('mediaLibrary.unknownValue' | translate) }}</dd>
<dt>{{ 'mediaLibrary.uploadDate' | translate }}</dt>
<dd>{{ formatDate(item.createdAt) }}</dd>
<dt>{{ 'mediaLibrary.lastUsed' | translate }}</dt>
<dd>{{ 'mediaLibrary.unknownValue' | translate }}</dd>
<dt>{{ 'mediaLibrary.usageCountLabel' | translate }}</dt>
<dd>{{ usages().length }}</dd>
</dl>
</div>
<div class="asset-details__usage">
<h4>{{ 'mediaLibrary.usedIn' | translate }}</h4>
@if (usages().length === 0) {
<p class="asset-details__hint">{{ 'mediaLibrary.notUsed' | translate }}</p>
} @else {
<ul>
@for (usage of usages(); track usage.detail) {
<li><app-badge variant="info">{{ usage.labelKey | translate }}</app-badge> <span>{{ usage.detail }}</span></li>
}
</ul>
}
</div>
<div class="asset-details__edit">
@if (isImage()) {
<app-form-field [label]="'mediaLibrary.altText' | translate" [hint]="'mediaLibrary.altTextHelp' | translate">
<app-input [ngModel]="item.altText?.['en'] || ''" (ngModelChange)="onAltChange($event)" [disabled]="!!item.decorative" />
</app-form-field>
<label class="toggle-row">
<app-toggle [ngModel]="!!item.decorative" (ngModelChange)="onDecorativeChange($event)" [ariaLabel]="'mediaLibrary.decorative' | translate" />
<span>{{ 'mediaLibrary.decorative' | translate }}</span>
</label>
@if (!item.decorative && !item.altText?.['en']) {
<p class="asset-details__warning">{{ 'mediaLibrary.missingAltWarning' | translate }}</p>
}
}
<app-form-field [label]="'mediaLibrary.caption' | translate">
<app-input [ngModel]="item.caption || ''" (ngModelChange)="onCaptionChange($event)" />
</app-form-field>
<app-form-field [label]="'mediaLibrary.description' | translate">
<app-input [ngModel]="item.description || ''" (ngModelChange)="onDescriptionChange($event)" />
</app-form-field>
</div>
<div class="asset-details__actions">
<app-button variant="danger" size="sm" (click)="remove.emit()">{{ 'mediaLibrary.delete' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="close.emit()">{{ 'mediaLibrary.cancel' | translate }}</app-button>
</div>
</div>
}
</app-dialog>

View File

@@ -0,0 +1,75 @@
.asset-details {
display: grid;
gap: 16px;
}
.asset-details__preview img {
max-width: 100%;
max-height: 220px;
border-radius: 8px;
object-fit: contain;
}
.asset-details__file {
padding: 20px;
border-radius: 8px;
background: var(--surface-muted, #eef2f0);
text-align: center;
}
.asset-details__facts dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
margin: 0;
}
.asset-details__facts dt {
color: var(--text-tertiary, #9aa6a2);
font-size: 0.8rem;
}
.asset-details__facts dd {
margin: 0;
font-size: 0.85rem;
color: var(--text-primary, #1e3c38);
}
.asset-details__usage h4 {
margin: 0 0 6px;
font-size: 0.9rem;
}
.asset-details__usage ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 6px;
font-size: 0.82rem;
}
.asset-details__hint {
margin: 0;
color: var(--text-secondary, #5f6e6a);
font-size: 0.85rem;
}
.asset-details__edit {
display: grid;
gap: 12px;
border-top: 1px solid var(--border-subtle, #e7ece9);
padding-top: 12px;
}
.asset-details__warning {
margin: 0;
color: var(--danger, #c0392b);
font-size: 0.8rem;
}
.asset-details__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -0,0 +1,69 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Output, computed, input, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
import { ButtonComponent } from '../../../../../shared/ui/button/button.component';
import { InputComponent } from '../../../../../shared/ui/input/input.component';
import { FormFieldComponent } from '../../../../../shared/ui/form-field/form-field.component';
import { ToggleComponent } from '../../../../../shared/ui/toggle/toggle.component';
import { DialogComponent } from '../../../../../shared/ui/dialog/dialog.component';
import { BadgeComponent } from '../../../../../shared/ui/badge/badge.component';
import { MediaAsset } from '../../../../../core/media/models/media-asset.model';
import { MediaUsageEntry } from '../../../../../core/media/media-usage.service';
export interface AssetDetailsPatch {
altText?: Record<string, string>;
caption?: string;
description?: string;
decorative?: boolean;
}
@Component({
selector: 'app-asset-details-drawer',
standalone: true,
imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, FormFieldComponent, ToggleComponent, DialogComponent, BadgeComponent],
templateUrl: './asset-details-drawer.component.html',
styleUrl: './asset-details-drawer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AssetDetailsDrawerComponent {
readonly asset = input<MediaAsset | null>(null);
readonly usages = input<MediaUsageEntry[]>([]);
@Output() close = new EventEmitter<void>();
@Output() save = new EventEmitter<AssetDetailsPatch>();
@Output() remove = new EventEmitter<void>();
readonly isImage = computed(() => this.asset()?.mimeType.startsWith('image/') ?? false);
readonly dimensionsLabel = computed(() => {
const asset = this.asset();
return asset?.width && asset?.height ? `${asset.width}×${asset.height}` : null;
});
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`;
}
formatDate(iso: string): string {
return new Date(iso).toLocaleString();
}
onAltChange(value: string): void {
const asset = this.asset();
if (!asset) return;
this.save.emit({ altText: { ...(asset.altText ?? {}), en: value } });
}
onCaptionChange(value: string): void {
this.save.emit({ caption: value });
}
onDescriptionChange(value: string): void {
this.save.emit({ description: value });
}
onDecorativeChange(value: boolean): void {
this.save.emit({ decorative: value });
}
}

View File

@@ -0,0 +1,36 @@
<app-card padding="sm" [interactive]="true" class="asset-tile" [class.asset-tile--list]="layout() === 'list'" [class.asset-tile--selected]="selected()">
<label class="asset-tile__select" (click)="$event.stopPropagation()">
<app-toggle size="sm" [ngModel]="selected()" (ngModelChange)="toggleSelected.emit($event)" [ariaLabel]="asset().filename" />
</label>
<button type="button" class="asset-tile__surface" (click)="open.emit()" [attr.aria-label]="asset().filename">
@if (isImage()) {
<img class="asset-tile__preview" [src]="asset().thumbnailUrl || asset().url" [alt]="asset().altText?.['en'] || ''" loading="lazy" />
} @else {
<div class="asset-tile__preview asset-tile__preview--file">{{ asset().mimeType }}</div>
}
<div class="asset-tile__meta">
<span class="asset-tile__filename">{{ asset().filename }}</span>
<span class="asset-tile__sub">
{{ formatSize(asset().size) }}
@if (dimensionsLabel()) { · {{ dimensionsLabel() }} }
</span>
<div class="asset-tile__badges">
@if (isUnused()) {
<app-badge variant="neutral">{{ 'mediaLibrary.unused' | translate }}</app-badge>
}
@if (missingAlt() && isImage()) {
<app-badge variant="warning">{{ 'mediaLibrary.missingAlt' | translate }}</app-badge>
}
@if (usageCount(); as count) {
<app-badge variant="info">{{ count }} {{ 'mediaLibrary.usageCount' | translate }}</app-badge>
}
</div>
</div>
</button>
<div class="asset-tile__actions">
<app-button variant="ghost" size="sm" (click)="remove.emit()">{{ 'mediaLibrary.delete' | translate }}</app-button>
</div>
</app-card>

View File

@@ -0,0 +1,91 @@
.asset-tile {
position: relative;
display: block;
}
.asset-tile--selected {
outline: 2px solid var(--brand-primary, #1e8a6e);
outline-offset: 2px;
}
.asset-tile__select {
position: absolute;
top: 8px;
left: 8px;
z-index: 1;
}
.asset-tile__surface {
all: unset;
display: grid;
gap: 8px;
width: 100%;
cursor: pointer;
box-sizing: border-box;
&:focus-visible {
outline: 2px solid var(--brand-primary, #1e8a6e);
outline-offset: 3px;
border-radius: 8px;
}
}
.asset-tile__preview {
width: 100%;
height: 120px;
object-fit: cover;
border-radius: 8px;
background: var(--surface-muted, #eef2f0);
}
.asset-tile__preview--file {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: var(--text-secondary, #5f6e6a);
text-align: center;
padding: 8px;
}
.asset-tile__meta {
display: grid;
gap: 4px;
}
.asset-tile__filename {
font-size: 0.8rem;
font-weight: 700;
color: var(--text-primary, #1e3c38);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-tile__sub {
font-size: 0.72rem;
color: var(--text-tertiary, #9aa6a2);
}
.asset-tile__badges {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.asset-tile__actions {
display: flex;
justify-content: flex-end;
margin-top: 6px;
}
.asset-tile--list {
.asset-tile__surface {
grid-template-columns: 72px 1fr;
align-items: center;
}
.asset-tile__preview {
height: 56px;
}
}

View File

@@ -0,0 +1,48 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Output, computed, input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
import { ButtonComponent } from '../../../../../shared/ui/button/button.component';
import { BadgeComponent } from '../../../../../shared/ui/badge/badge.component';
import { CardComponent } from '../../../../../shared/ui/card/card.component';
import { ToggleComponent } from '../../../../../shared/ui/toggle/toggle.component';
import { MediaAsset } from '../../../../../core/media/models/media-asset.model';
export type AssetTileLayout = 'grid' | 'list';
@Component({
selector: 'app-asset-tile',
standalone: true,
imports: [FormsModule, TranslatePipe, ButtonComponent, BadgeComponent, CardComponent, ToggleComponent],
templateUrl: './asset-tile.component.html',
styleUrl: './asset-tile.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AssetTileComponent {
readonly asset = input.required<MediaAsset>();
readonly layout = input<AssetTileLayout>('grid');
readonly selected = input(false);
readonly usageCount = input<number | null>(null);
readonly isUnused = input(false);
readonly missingAlt = input(false);
@Output() open = new EventEmitter<void>();
@Output() toggleSelected = new EventEmitter<boolean>();
@Output() remove = new EventEmitter<void>();
readonly isImage = computed(() => this.asset().mimeType.startsWith('image/'));
readonly dimensionsLabel = computed(() => {
const { width, height } = this.asset();
return width && height ? `${width}×${height}` : null;
});
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`;
}
}

View File

@@ -1,9 +1,11 @@
import { Injectable, inject, signal } from '@angular/core';
import { MediaRepository } from '../../../../core/media/media-repository';
import { MediaAsset, MediaUploadOptions } from '../../../../core/media/models/media-asset.model';
import { MediaAsset, MediaAssetKind, MediaSort, MediaUploadOptions } from '../../../../core/media/models/media-asset.model';
const PAGE_SIZE = 24;
export type MediaViewMode = 'grid' | 'list';
@Injectable({ providedIn: 'root' })
export class MediaLibraryFacade {
private readonly repository = inject(MediaRepository);
@@ -17,6 +19,14 @@ export class MediaLibraryFacade {
readonly page = signal(1);
readonly folder = signal<string | null>(null);
readonly folders = signal<string[]>([]);
readonly kind = signal<MediaAssetKind | null>(null);
readonly sort = signal<MediaSort>('recent');
readonly viewMode = signal<MediaViewMode>('grid');
readonly selectedIds = signal<Set<string>>(new Set());
/** Kept so a failed upload can be retried without asking the user to re-pick the file. */
private lastFailedFile: File | null = null;
private uploadToken = 0;
async load(): Promise<void> {
this.loading.set(true);
@@ -27,6 +37,8 @@ export class MediaLibraryFacade {
pageSize: PAGE_SIZE,
search: this.search(),
folder: this.folder() ?? undefined,
kind: this.kind() ?? undefined,
sort: this.sort(),
});
this.items.set(result.items);
this.total.set(result.total);
@@ -50,22 +62,65 @@ export class MediaLibraryFacade {
void this.load();
}
setKind(value: MediaAssetKind | null): void {
this.kind.set(value);
this.page.set(1);
void this.load();
}
setSort(value: MediaSort): void {
this.sort.set(value);
void this.load();
}
setViewMode(mode: MediaViewMode): void {
this.viewMode.set(mode);
}
setPage(page: number): void {
this.page.set(page);
void this.load();
}
async upload(file: File, options: MediaUploadOptions = {}): Promise<void> {
const token = ++this.uploadToken;
this.uploading.set(true);
this.error.set(null);
try {
await this.repository.upload(file, { folder: this.folder() ?? undefined, ...options });
// A cancelled upload can't stop the underlying store write mid-flight (best-effort
// cancel), but it can at least avoid refreshing the list with the cancelled result.
if (token !== this.uploadToken) {
return;
}
this.lastFailedFile = null;
this.page.set(1);
await this.load();
} catch (error: unknown) {
if (token !== this.uploadToken) {
return;
}
this.lastFailedFile = file;
this.error.set(error instanceof Error ? error.message : 'Failed to upload file.');
} finally {
this.uploading.set(false);
if (token === this.uploadToken) {
this.uploading.set(false);
}
}
}
cancelUpload(): void {
this.uploadToken++;
this.uploading.set(false);
}
canRetry(): boolean {
return !!this.lastFailedFile;
}
async retryUpload(): Promise<void> {
if (this.lastFailedFile) {
await this.upload(this.lastFailedFile);
}
}
@@ -79,13 +134,103 @@ export class MediaLibraryFacade {
}
}
async updateDetails(id: string, patch: { altText?: Record<string, string>; caption?: string; description?: string; decorative?: boolean }): Promise<void> {
this.error.set(null);
try {
await this.repository.update(id, patch);
await this.load();
} catch {
this.error.set('Failed to update asset details.');
}
}
async remove(id: string): Promise<void> {
this.error.set(null);
try {
await this.repository.remove(id);
this.deselect(id);
await this.load();
} catch {
this.error.set('Failed to delete file.');
}
}
isSelected(id: string): boolean {
return this.selectedIds().has(id);
}
toggleSelect(id: string, checked: boolean): void {
const next = new Set(this.selectedIds());
checked ? next.add(id) : next.delete(id);
this.selectedIds.set(next);
}
clearSelection(): void {
this.selectedIds.set(new Set());
}
private deselect(id: string): void {
const next = new Set(this.selectedIds());
next.delete(id);
this.selectedIds.set(next);
}
async bulkDelete(ids: string[]): Promise<void> {
this.error.set(null);
try {
for (const id of ids) {
await this.repository.remove(id);
}
this.clearSelection();
await this.load();
} catch {
this.error.set('Failed to delete selected files.');
}
}
async bulkMoveToFolder(ids: string[], folder: string | null): Promise<void> {
this.error.set(null);
try {
for (const id of ids) {
await this.repository.update(id, { folder: folder ?? undefined });
}
this.clearSelection();
await this.load();
} catch {
this.error.set('Failed to move selected files.');
}
}
downloadAssets(assets: MediaAsset[]): void {
for (const asset of assets) {
const link = document.createElement('a');
link.href = asset.url;
link.download = asset.filename;
link.click();
}
}
exportMetadata(assets: MediaAsset[]): void {
const payload = assets.map(asset => ({
id: asset.id,
filename: asset.filename,
mimeType: asset.mimeType,
size: asset.size,
width: asset.width,
height: asset.height,
altText: asset.altText,
caption: asset.caption,
description: asset.description,
tags: asset.tags,
folder: asset.folder,
createdAt: asset.createdAt,
}));
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'media-metadata.json';
link.click();
URL.revokeObjectURL(url);
}
}

View File

@@ -1,4 +1,14 @@
<section class="media-page">
<div class="media-page__dashboard">
<app-dashboard-metric labelKey="mediaLibrary.totalAssets" [value]="totalAssets().toString()" />
<app-dashboard-metric labelKey="mediaLibrary.typeImage" [value]="imagesCount().toString()" />
<app-dashboard-metric labelKey="mediaLibrary.typeSvg" [value]="svgCount().toString()" />
<app-dashboard-metric labelKey="mediaLibrary.logos" [value]="logosCount().toString()" />
<app-dashboard-metric labelKey="mediaLibrary.unused" [value]="unusedCount().toString()" />
<app-dashboard-metric labelKey="mediaLibrary.storageUsed" [value]="formatSize(storageBytes())" />
<app-dashboard-metric labelKey="mediaLibrary.altCoverage" [value]="altCoveragePercent() + '%'" />
</div>
<div class="media-page__toolbar">
<h2>{{ 'mediaLibrary.title' | translate }}</h2>
<div class="media-page__toolbar-actions">
@@ -7,28 +17,56 @@
(ngModelChange)="facade.setSearch($event)"
[placeholder]="'mediaLibrary.searchPlaceholder' | translate"
/>
<select [ngModel]="facade.folder()" (ngModelChange)="facade.setFolder($event || null)">
<option [ngValue]="null">{{ 'mediaLibrary.allFolders' | translate }}</option>
@for (folder of facade.folders(); track folder) {
<option [ngValue]="folder">{{ folder }}</option>
}
</select>
<app-button variant="secondary" (click)="createFolder()">{{ 'mediaLibrary.newFolder' | translate }}</app-button>
<app-select [options]="kindOptions()" [ngModel]="facade.kind() || 'all'" (ngModelChange)="setKind($event)" [ariaLabel]="'mediaLibrary.allTypes' | translate" />
<app-select [options]="sortOptions()" [ngModel]="facade.sort()" (ngModelChange)="setSort($event)" [ariaLabel]="'mediaLibrary.sortRecent' | translate" />
<div class="media-page__view-toggle" role="group" [attr.aria-label]="'mediaLibrary.viewMode' | translate">
<app-button variant="ghost" size="sm" [attr.aria-pressed]="facade.viewMode() === 'grid'" (click)="setViewMode('grid')">{{ 'mediaLibrary.viewGrid' | translate }}</app-button>
<app-button variant="ghost" size="sm" [attr.aria-pressed]="facade.viewMode() === 'list'" (click)="setViewMode('list')">{{ 'mediaLibrary.viewList' | translate }}</app-button>
</div>
<input
#fileInput
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml,application/pdf"
multiple
hidden
(change)="onFileSelected($event)"
/>
<app-button variant="primary" [loading]="facade.uploading()" (click)="triggerFileInput()">
{{ (facade.uploading() ? 'mediaLibrary.uploading' : 'mediaLibrary.upload') | translate }}
</app-button>
@if (facade.uploading()) {
<app-button variant="ghost" size="sm" (click)="facade.cancelUpload()">{{ 'mediaLibrary.cancelUpload' | translate }}</app-button>
}
</div>
</div>
<div
class="media-page__dropzone"
[class.media-page__dropzone--active]="dragActive()"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave()"
(drop)="onDrop($event)"
>
<p>{{ 'mediaLibrary.dropHint' | translate }}</p>
</div>
@if (facade.error()) {
<p class="media-page__error">{{ facade.error() }}</p>
<p class="media-page__error">
{{ friendlyUploadError(facade.error()) }}
@if (facade.canRetry()) {
<app-button variant="secondary" size="sm" (click)="facade.retryUpload()">{{ 'mediaLibrary.retry' | translate }}</app-button>
}
</p>
}
@if (facade.selectedIds().size > 0) {
<div class="media-page__bulk-bar">
<span>{{ facade.selectedIds().size }} {{ 'mediaLibrary.selectedCount' | translate }}</span>
<app-button variant="secondary" size="sm" (click)="bulkDownload()">{{ 'mediaLibrary.download' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="bulkExportMetadata()">{{ 'mediaLibrary.exportMetadata' | translate }}</app-button>
<app-button variant="danger" size="sm" (click)="bulkDelete()">{{ 'mediaLibrary.bulkDelete' | translate }}</app-button>
<app-button variant="ghost" size="sm" (click)="facade.clearSelection()">{{ 'mediaLibrary.clearSelection' | translate }}</app-button>
</div>
}
@if (facade.loading()) {
@@ -41,32 +79,26 @@
<app-empty-state
[title]="'mediaLibrary.emptyTitle' | translate"
[description]="'mediaLibrary.emptyDescription' | translate"
/>
>
<span slot="actions">
<app-button variant="primary" (click)="triggerFileInput()">{{ 'mediaLibrary.upload' | translate }}</app-button>
</span>
</app-empty-state>
<p class="media-page__guide">{{ 'mediaLibrary.guideCopy' | translate }}</p>
} @else {
<div class="media-page__grid">
<div class="media-page__grid" [class.media-page__grid--list]="facade.viewMode() === 'list'">
@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>
@if (asset.tags?.length) {
<span class="media-tile__tags">{{ asset.tags!.join(', ') }}</span>
}
</div>
<div class="media-tile__row">
<app-button variant="secondary" size="sm" (click)="editTags(asset)">{{ 'mediaLibrary.editTags' | translate }}</app-button>
<app-button variant="danger" size="sm" (click)="confirmDelete(asset)">
{{ 'mediaLibrary.delete' | translate }}
</app-button>
</div>
</div>
</app-card>
<app-asset-tile
[asset]="asset"
[layout]="facade.viewMode()"
[selected]="facade.isSelected(asset.id)"
[usageCount]="usageCountFor(asset)"
[isUnused]="isUnused(asset)"
[missingAlt]="missingAlt(asset)"
(open)="openDetails(asset)"
(toggleSelected)="facade.toggleSelect(asset.id, $event)"
(remove)="confirmDelete(asset)"
/>
}
</div>
@@ -75,6 +107,14 @@
}
}
<app-asset-details-drawer
[asset]="detailsAsset()"
[usages]="detailsUsages()"
(close)="closeDetails()"
(save)="saveDetails($event)"
(remove)="detailsAsset() && confirmDelete(detailsAsset()!)"
/>
<app-dialog
[open]="!!pendingDelete()"
[titleText]="'mediaLibrary.deleteConfirmTitle' | translate"

View File

@@ -4,6 +4,24 @@
padding: 18px;
}
.media-page__dashboard {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 12px;
}
@media (max-width: 1100px) {
.media-page__dashboard {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 600px) {
.media-page__dashboard {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.media-page__toolbar {
display: flex;
align-items: center;
@@ -15,56 +33,51 @@
.media-page__toolbar-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.media-page__view-toggle {
display: flex;
gap: 2px;
}
.media-page__dropzone {
border: 2px dashed var(--border-subtle, #e7ece9);
border-radius: 10px;
padding: 14px;
text-align: center;
color: var(--text-tertiary, #9aa6a2);
font-size: 0.85rem;
}
.media-page__dropzone--active {
border-color: var(--brand-primary, #1e8a6e);
background: var(--surface-muted, #eef2f0);
}
.media-page__bulk-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.media-page__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
.media-tile {
display: grid;
gap: 8px;
.media-page__grid--list {
grid-template-columns: 1fr;
}
.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);
.media-page__guide {
margin: 0;
font-size: 0.85rem;
color: var(--text-secondary, #5f6e6a);
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 {
@@ -75,25 +88,10 @@
}
.media-page__error {
color: var(--color-danger, #d9433c);
display: flex;
align-items: center;
gap: 10px;
color: var(--danger, #c0392b);
font-size: 0.85rem;
margin: 0;
}
.media-tile__tags {
font-size: 0.7rem;
color: var(--text-secondary, #6b7280);
font-style: italic;
}
.media-tile__row {
display: flex;
gap: 8px;
}
.media-page__toolbar-actions select {
min-height: 40px;
padding: 0 10px;
border: 1px solid var(--border-color, #d3dad9);
border-radius: 10px;
}

View File

@@ -1,15 +1,21 @@
import { ChangeDetectionStrategy, Component, ElementRef, OnInit, ViewChild, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, ElementRef, OnInit, ViewChild, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TranslatePipe } from '../../../i18n/translate.pipe';
import { MediaLibraryFacade } from './facade/media-library.facade';
import { MediaAsset } from '../../../core/media/models/media-asset.model';
import { TranslateService } from '../../../i18n/translate.service';
import { MediaLibraryFacade, MediaViewMode } from './facade/media-library.facade';
import { MediaAsset, MediaAssetKind, MediaSort } from '../../../core/media/models/media-asset.model';
import { MediaUsageService } from '../../../core/media/media-usage.service';
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';
import { SelectComponent, SelectOption } from '../../../shared/ui/select/select.component';
import { DashboardMetricComponent } from '../../admin/dashboard/components/dashboard-metric.component';
import { ProjectEditorFacade } from '../../project-editor/facade/project-editor.facade';
import { AssetTileComponent } from './components/asset-tile/asset-tile.component';
import { AssetDetailsDrawerComponent, AssetDetailsPatch } from './components/asset-details-drawer/asset-details-drawer.component';
const PAGE_SIZE = 24;
@@ -21,11 +27,14 @@ const PAGE_SIZE = 24;
FormsModule,
ButtonComponent,
InputComponent,
CardComponent,
EmptyStateComponent,
DialogComponent,
PaginationComponent,
SkeletonComponent,
SelectComponent,
DashboardMetricComponent,
AssetTileComponent,
AssetDetailsDrawerComponent,
],
templateUrl: './media-library-page.component.html',
styleUrl: './media-library-page.component.scss',
@@ -33,38 +42,152 @@ const PAGE_SIZE = 24;
})
export class MediaLibraryPageComponent implements OnInit {
protected readonly facade = inject(MediaLibraryFacade);
private readonly usageService = inject(MediaUsageService);
private readonly projectEditor = inject(ProjectEditorFacade);
private readonly translate = inject(TranslateService);
@ViewChild('fileInput') private fileInput?: ElementRef<HTMLInputElement>;
protected readonly pendingDelete = signal<MediaAsset | null>(null);
protected readonly detailsAsset = signal<MediaAsset | null>(null);
protected readonly dragActive = signal(false);
protected readonly totalPages = () => Math.max(1, Math.ceil(this.facade.total() / PAGE_SIZE));
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') },
]);
protected readonly sortOptions = computed<SelectOption[]>(() => [
{ value: 'recent', label: this.translate.t('mediaLibrary.sortRecent') },
{ value: 'name', label: this.translate.t('mediaLibrary.sortName') },
{ value: 'size', label: this.translate.t('mediaLibrary.sortSize') },
]);
private readonly bootstrap = this.projectEditor.bootstrap;
/** Usage lookups are computed per visible page, not the whole library, to avoid walking the bootstrap config thousands of times for large libraries. */
private readonly usageByAssetId = computed<Map<string, number>>(() => {
const bootstrap = this.bootstrap();
const map = new Map<string, number>();
for (const asset of this.facade.items()) {
map.set(asset.id, this.usageService.findUsages(bootstrap, asset.url).length);
}
return map;
});
protected readonly totalAssets = computed(() => this.facade.total());
protected readonly imagesCount = computed(() => this.facade.items().filter(a => a.mimeType.startsWith('image/')).length);
protected readonly svgCount = computed(() => this.facade.items().filter(a => a.mimeType === 'image/svg+xml').length);
protected readonly logosCount = computed(() => {
const bootstrap = this.bootstrap();
return this.facade.items().filter(a => this.usageService.findUsages(bootstrap, a.url).some(u => u.labelKey === 'mediaLibrary.usage.brand')).length;
});
protected readonly unusedCount = computed(() => {
const usage = this.usageByAssetId();
return this.facade.items().filter(a => (usage.get(a.id) ?? 0) === 0).length;
});
protected readonly storageBytes = computed(() => this.facade.items().reduce((sum, a) => sum + a.size, 0));
protected readonly altCoveragePercent = computed(() => {
const images = this.facade.items().filter(a => a.mimeType.startsWith('image/'));
if (images.length === 0) return 100;
const withAlt = images.filter(a => a.decorative || !!a.altText?.['en']).length;
return Math.round((withAlt / images.length) * 100);
});
ngOnInit(): void {
void this.facade.load();
}
protected usageCountFor(asset: MediaAsset): number {
return this.usageByAssetId().get(asset.id) ?? 0;
}
protected isUnused(asset: MediaAsset): boolean {
return this.usageCountFor(asset) === 0;
}
protected missingAlt(asset: MediaAsset): boolean {
return !asset.decorative && !asset.altText?.['en'];
}
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`;
}
protected setViewMode(mode: MediaViewMode): void {
this.facade.setViewMode(mode);
}
protected setKind(value: string): void {
this.facade.setKind(value === 'all' ? null : (value as MediaAssetKind));
}
protected setSort(value: string): void {
this.facade.setSort(value as MediaSort);
}
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];
const files = input.files ? Array.from(input.files) : [];
input.value = '';
if (!file) {
return;
for (const file of files) {
await this.facade.upload(file);
}
await this.facade.upload(file);
}
protected async editTags(asset: MediaAsset): Promise<void> {
const current = (asset.tags ?? []).join(', ');
const next = window.prompt('Tags (comma-separated):', current);
if (next === null) {
return;
protected onDragOver(event: DragEvent): void {
event.preventDefault();
this.dragActive.set(true);
}
protected onDragLeave(): void {
this.dragActive.set(false);
}
protected async onDrop(event: DragEvent): Promise<void> {
event.preventDefault();
this.dragActive.set(false);
const files = event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : [];
for (const file of files) {
await this.facade.upload(file);
}
const tags = next.split(',').map(tag => tag.trim()).filter(Boolean);
await this.facade.updateTags(asset.id, tags);
}
protected friendlyUploadError(message: string | null): string | null {
if (!message) return null;
if (message.includes('exceeds')) return this.translate.t('mediaLibrary.errorTooLarge');
if (message.includes('Unsupported file type')) return this.translate.t('mediaLibrary.errorUnsupportedType');
return this.translate.t('mediaLibrary.errorGeneric');
}
protected openDetails(asset: MediaAsset): void {
this.detailsAsset.set(asset);
}
protected closeDetails(): void {
this.detailsAsset.set(null);
}
protected detailsUsages() {
const asset = this.detailsAsset();
return asset ? this.usageService.findUsages(this.bootstrap(), asset.url) : [];
}
protected async saveDetails(patch: AssetDetailsPatch): Promise<void> {
const asset = this.detailsAsset();
if (!asset) return;
await this.facade.updateDetails(asset.id, patch);
const refreshed = this.facade.items().find(a => a.id === asset.id) ?? null;
this.detailsAsset.set(refreshed);
}
protected confirmDelete(asset: MediaAsset): void {
@@ -77,28 +200,30 @@ export class MediaLibraryPageComponent implements OnInit {
protected async proceedDelete(): Promise<void> {
const asset = this.pendingDelete();
if (!asset) {
return;
}
if (!asset) return;
await this.facade.remove(asset.id);
this.pendingDelete.set(null);
if (this.detailsAsset()?.id === asset.id) {
this.closeDetails();
}
}
protected createFolder(): void {
const name = window.prompt('New folder name:');
if (!name?.trim()) {
return;
}
this.facade.setFolder(name.trim());
protected selectedAssets(): MediaAsset[] {
return this.facade.items().filter(a => this.facade.isSelected(a.id));
}
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`;
protected async bulkDelete(): Promise<void> {
const ids = this.selectedAssets().map(a => a.id);
if (ids.length === 0) return;
if (!confirm(this.translate.t('mediaLibrary.bulkDeleteConfirm'))) return;
await this.facade.bulkDelete(ids);
}
protected bulkDownload(): void {
this.facade.downloadAssets(this.selectedAssets());
}
protected bulkExportMetadata(): void {
this.facade.exportMetadata(this.selectedAssets());
}
}