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