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:
@@ -4,6 +4,6 @@ export abstract class MediaRepository {
|
|||||||
abstract list(params?: MediaListParams): Promise<MediaListResult>;
|
abstract list(params?: MediaListParams): Promise<MediaListResult>;
|
||||||
abstract upload(file: File, options?: MediaUploadOptions): Promise<MediaAsset>;
|
abstract upload(file: File, options?: MediaUploadOptions): Promise<MediaAsset>;
|
||||||
abstract remove(id: string): Promise<void>;
|
abstract remove(id: string): Promise<void>;
|
||||||
abstract update(id: string, patch: Partial<Pick<MediaAsset, 'altText' | 'tags' | 'folder'>>): Promise<MediaAsset>;
|
abstract update(id: string, patch: Partial<Pick<MediaAsset, 'altText' | 'tags' | 'folder' | 'caption' | 'description' | 'decorative'>>): Promise<MediaAsset>;
|
||||||
abstract listFolders(): Promise<string[]>;
|
abstract listFolders(): Promise<string[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
65
src/app/core/media/media-usage.service.ts
Normal file
65
src/app/core/media/media-usage.service.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { BootstrapConfig } from '../../shared/models/config';
|
||||||
|
|
||||||
|
export interface MediaUsageEntry {
|
||||||
|
labelKey: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOP_LEVEL_LABEL_KEYS: Record<string, string> = {
|
||||||
|
branding: 'mediaLibrary.usage.brand',
|
||||||
|
theme: 'mediaLibrary.usage.theme',
|
||||||
|
header: 'mediaLibrary.usage.header',
|
||||||
|
footer: 'mediaLibrary.usage.footer',
|
||||||
|
staticPages: 'mediaLibrary.usage.staticPage',
|
||||||
|
pages: 'mediaLibrary.usage.homepage',
|
||||||
|
catalog: 'mediaLibrary.usage.catalog',
|
||||||
|
productPage: 'mediaLibrary.usage.productPage',
|
||||||
|
widgetRegistry: 'mediaLibrary.usage.widget',
|
||||||
|
company: 'mediaLibrary.usage.company',
|
||||||
|
seo: 'mediaLibrary.usage.seo',
|
||||||
|
navigation: 'mediaLibrary.usage.navigation',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds every place a media asset's URL is actually referenced inside the current
|
||||||
|
* bootstrap config, by walking the object rather than guessing from a fixed schema.
|
||||||
|
* Never fabricates usage - an asset with no matches is reported as unused.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class MediaUsageService {
|
||||||
|
findUsages(bootstrap: BootstrapConfig | null, url: string): MediaUsageEntry[] {
|
||||||
|
if (!bootstrap || !url) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const entries: MediaUsageEntry[] = [];
|
||||||
|
for (const [topKey, value] of Object.entries(bootstrap as unknown as Record<string, unknown>)) {
|
||||||
|
this.walk(value, url, [topKey], entries, 0);
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private walk(value: unknown, url: string, path: string[], entries: MediaUsageEntry[], depth: number): void {
|
||||||
|
if (depth > 8 || value == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (value === url) {
|
||||||
|
entries.push({
|
||||||
|
labelKey: TOP_LEVEL_LABEL_KEYS[path[0]] ?? 'mediaLibrary.usage.other',
|
||||||
|
detail: path.join(' › '),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((item, index) => this.walk(item, url, [...path, String(index)], entries, depth + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
this.walk(nested, url, [...path, key], entries, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { MediaRepository } from './media-repository';
|
import { MediaRepository } from './media-repository';
|
||||||
import { MediaAsset, MediaListParams, MediaListResult, MediaUploadOptions } from './models/media-asset.model';
|
import { MediaAsset, MediaAssetKind, MediaListParams, MediaListResult, MediaUploadOptions } from './models/media-asset.model';
|
||||||
|
|
||||||
const DB_NAME = 'media-manager';
|
const DB_NAME = 'media-manager';
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 1;
|
||||||
@@ -19,12 +19,22 @@ interface StoredAssetRecord {
|
|||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
altText?: Record<string, string>;
|
altText?: Record<string, string>;
|
||||||
|
caption?: string;
|
||||||
|
description?: string;
|
||||||
|
decorative?: boolean;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
folder?: string;
|
folder?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assetKind(mimeType: string): MediaAssetKind {
|
||||||
|
if (mimeType === 'image/svg+xml') return 'svg';
|
||||||
|
if (mimeType === 'application/pdf') return 'pdf';
|
||||||
|
if (mimeType.startsWith('image/')) return 'image';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class MockMediaRepository extends MediaRepository {
|
export class MockMediaRepository extends MediaRepository {
|
||||||
private db: Promise<IDBDatabase> | null = null;
|
private db: Promise<IDBDatabase> | null = null;
|
||||||
@@ -39,9 +49,17 @@ export class MockMediaRepository extends MediaRepository {
|
|||||||
const filtered = records
|
const filtered = records
|
||||||
.filter(record => !search || record.filename.toLowerCase().includes(search))
|
.filter(record => !search || record.filename.toLowerCase().includes(search))
|
||||||
.filter(record => !params.folder || record.folder === params.folder)
|
.filter(record => !params.folder || record.folder === params.folder)
|
||||||
.filter(record => !params.tag || (record.tags ?? []).includes(params.tag!));
|
.filter(record => !params.tag || (record.tags ?? []).includes(params.tag!))
|
||||||
|
.filter(record => !params.kind || assetKind(record.mimeType) === params.kind);
|
||||||
|
|
||||||
|
const sort = params.sort ?? 'recent';
|
||||||
|
if (sort === 'name') {
|
||||||
|
filtered.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||||
|
} else if (sort === 'size') {
|
||||||
|
filtered.sort((a, b) => b.size - a.size);
|
||||||
|
} else {
|
||||||
filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
const start = (page - 1) * pageSize;
|
const start = (page - 1) * pageSize;
|
||||||
const pageItems = filtered.slice(start, start + pageSize);
|
const pageItems = filtered.slice(start, start + pageSize);
|
||||||
@@ -142,7 +160,7 @@ export class MockMediaRepository extends MediaRepository {
|
|||||||
this.revokeObjectUrl(id);
|
this.revokeObjectUrl(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, patch: Partial<Pick<MediaAsset, 'altText' | 'tags'>>): Promise<MediaAsset> {
|
async update(id: string, patch: Partial<Pick<MediaAsset, 'altText' | 'tags' | 'folder' | 'caption' | 'description' | 'decorative'>>): Promise<MediaAsset> {
|
||||||
const db = await this.openDb();
|
const db = await this.openDb();
|
||||||
const existing = await this.getRecord(db, id);
|
const existing = await this.getRecord(db, id);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -167,6 +185,9 @@ export class MockMediaRepository extends MediaRepository {
|
|||||||
width: record.width,
|
width: record.width,
|
||||||
height: record.height,
|
height: record.height,
|
||||||
altText: record.altText,
|
altText: record.altText,
|
||||||
|
caption: record.caption,
|
||||||
|
description: record.description,
|
||||||
|
decorative: record.decorative,
|
||||||
tags: record.tags,
|
tags: record.tags,
|
||||||
folder: record.folder,
|
folder: record.folder,
|
||||||
createdAt: record.createdAt,
|
createdAt: record.createdAt,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export type MediaAssetKind = 'image' | 'svg' | 'pdf' | 'other';
|
||||||
|
|
||||||
export interface MediaAsset {
|
export interface MediaAsset {
|
||||||
id: string;
|
id: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -8,17 +10,26 @@ export interface MediaAsset {
|
|||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
altText?: Record<string, string>;
|
altText?: Record<string, string>;
|
||||||
|
/** Plain (non-localized) caption shown under the asset - matches the existing single-string convention used for page hero captions. */
|
||||||
|
caption?: string;
|
||||||
|
description?: string;
|
||||||
|
/** Marks the asset as intentionally decorative, suppressing the missing-alt-text warning without requiring empty alt text everywhere. */
|
||||||
|
decorative?: boolean;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
folder?: string;
|
folder?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MediaSort = 'recent' | 'name' | 'size';
|
||||||
|
|
||||||
export interface MediaListParams {
|
export interface MediaListParams {
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
folder?: string;
|
folder?: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
|
kind?: MediaAssetKind;
|
||||||
|
sort?: MediaSort;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaUploadOptions {
|
export interface MediaUploadOptions {
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Injectable, inject, signal } from '@angular/core';
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
import { MediaRepository } from '../../../../core/media/media-repository';
|
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;
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
|
export type MediaViewMode = 'grid' | 'list';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class MediaLibraryFacade {
|
export class MediaLibraryFacade {
|
||||||
private readonly repository = inject(MediaRepository);
|
private readonly repository = inject(MediaRepository);
|
||||||
@@ -17,6 +19,14 @@ export class MediaLibraryFacade {
|
|||||||
readonly page = signal(1);
|
readonly page = signal(1);
|
||||||
readonly folder = signal<string | null>(null);
|
readonly folder = signal<string | null>(null);
|
||||||
readonly folders = signal<string[]>([]);
|
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> {
|
async load(): Promise<void> {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
@@ -27,6 +37,8 @@ export class MediaLibraryFacade {
|
|||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
search: this.search(),
|
search: this.search(),
|
||||||
folder: this.folder() ?? undefined,
|
folder: this.folder() ?? undefined,
|
||||||
|
kind: this.kind() ?? undefined,
|
||||||
|
sort: this.sort(),
|
||||||
});
|
});
|
||||||
this.items.set(result.items);
|
this.items.set(result.items);
|
||||||
this.total.set(result.total);
|
this.total.set(result.total);
|
||||||
@@ -50,24 +62,67 @@ export class MediaLibraryFacade {
|
|||||||
void this.load();
|
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 {
|
setPage(page: number): void {
|
||||||
this.page.set(page);
|
this.page.set(page);
|
||||||
void this.load();
|
void this.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
async upload(file: File, options: MediaUploadOptions = {}): Promise<void> {
|
async upload(file: File, options: MediaUploadOptions = {}): Promise<void> {
|
||||||
|
const token = ++this.uploadToken;
|
||||||
this.uploading.set(true);
|
this.uploading.set(true);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
try {
|
try {
|
||||||
await this.repository.upload(file, { folder: this.folder() ?? undefined, ...options });
|
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);
|
this.page.set(1);
|
||||||
await this.load();
|
await this.load();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
if (token !== this.uploadToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastFailedFile = file;
|
||||||
this.error.set(error instanceof Error ? error.message : 'Failed to upload file.');
|
this.error.set(error instanceof Error ? error.message : 'Failed to upload file.');
|
||||||
} finally {
|
} finally {
|
||||||
|
if (token === this.uploadToken) {
|
||||||
this.uploading.set(false);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async updateTags(id: string, tags: string[]): Promise<void> {
|
async updateTags(id: string, tags: string[]): Promise<void> {
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
@@ -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> {
|
async remove(id: string): Promise<void> {
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
try {
|
try {
|
||||||
await this.repository.remove(id);
|
await this.repository.remove(id);
|
||||||
|
this.deselect(id);
|
||||||
await this.load();
|
await this.load();
|
||||||
} catch {
|
} catch {
|
||||||
this.error.set('Failed to delete file.');
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
<section class="media-page">
|
<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">
|
<div class="media-page__toolbar">
|
||||||
<h2>{{ 'mediaLibrary.title' | translate }}</h2>
|
<h2>{{ 'mediaLibrary.title' | translate }}</h2>
|
||||||
<div class="media-page__toolbar-actions">
|
<div class="media-page__toolbar-actions">
|
||||||
@@ -7,28 +17,56 @@
|
|||||||
(ngModelChange)="facade.setSearch($event)"
|
(ngModelChange)="facade.setSearch($event)"
|
||||||
[placeholder]="'mediaLibrary.searchPlaceholder' | translate"
|
[placeholder]="'mediaLibrary.searchPlaceholder' | translate"
|
||||||
/>
|
/>
|
||||||
<select [ngModel]="facade.folder()" (ngModelChange)="facade.setFolder($event || null)">
|
<app-select [options]="kindOptions()" [ngModel]="facade.kind() || 'all'" (ngModelChange)="setKind($event)" [ariaLabel]="'mediaLibrary.allTypes' | translate" />
|
||||||
<option [ngValue]="null">{{ 'mediaLibrary.allFolders' | translate }}</option>
|
<app-select [options]="sortOptions()" [ngModel]="facade.sort()" (ngModelChange)="setSort($event)" [ariaLabel]="'mediaLibrary.sortRecent' | translate" />
|
||||||
@for (folder of facade.folders(); track folder) {
|
<div class="media-page__view-toggle" role="group" [attr.aria-label]="'mediaLibrary.viewMode' | translate">
|
||||||
<option [ngValue]="folder">{{ folder }}</option>
|
<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>
|
||||||
</select>
|
</div>
|
||||||
<app-button variant="secondary" (click)="createFolder()">{{ 'mediaLibrary.newFolder' | translate }}</app-button>
|
|
||||||
<input
|
<input
|
||||||
#fileInput
|
#fileInput
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml,application/pdf"
|
accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml,application/pdf"
|
||||||
|
multiple
|
||||||
hidden
|
hidden
|
||||||
(change)="onFileSelected($event)"
|
(change)="onFileSelected($event)"
|
||||||
/>
|
/>
|
||||||
<app-button variant="primary" [loading]="facade.uploading()" (click)="triggerFileInput()">
|
<app-button variant="primary" [loading]="facade.uploading()" (click)="triggerFileInput()">
|
||||||
{{ (facade.uploading() ? 'mediaLibrary.uploading' : 'mediaLibrary.upload') | translate }}
|
{{ (facade.uploading() ? 'mediaLibrary.uploading' : 'mediaLibrary.upload') | translate }}
|
||||||
</app-button>
|
</app-button>
|
||||||
|
@if (facade.uploading()) {
|
||||||
|
<app-button variant="ghost" size="sm" (click)="facade.cancelUpload()">{{ 'mediaLibrary.cancelUpload' | translate }}</app-button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</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()) {
|
@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()) {
|
@if (facade.loading()) {
|
||||||
@@ -41,32 +79,26 @@
|
|||||||
<app-empty-state
|
<app-empty-state
|
||||||
[title]="'mediaLibrary.emptyTitle' | translate"
|
[title]="'mediaLibrary.emptyTitle' | translate"
|
||||||
[description]="'mediaLibrary.emptyDescription' | 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 {
|
} @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) {
|
@for (asset of facade.items(); track asset.id) {
|
||||||
<app-card padding="sm">
|
<app-asset-tile
|
||||||
<div class="media-tile">
|
[asset]="asset"
|
||||||
@if (asset.mimeType.startsWith('image/')) {
|
[layout]="facade.viewMode()"
|
||||||
<img class="media-tile__preview" [src]="asset.url" [alt]="asset.filename" />
|
[selected]="facade.isSelected(asset.id)"
|
||||||
} @else {
|
[usageCount]="usageCountFor(asset)"
|
||||||
<div class="media-tile__preview media-tile__preview--file">{{ asset.mimeType }}</div>
|
[isUnused]="isUnused(asset)"
|
||||||
}
|
[missingAlt]="missingAlt(asset)"
|
||||||
<div class="media-tile__meta">
|
(open)="openDetails(asset)"
|
||||||
<span class="media-tile__filename">{{ asset.filename }}</span>
|
(toggleSelected)="facade.toggleSelect(asset.id, $event)"
|
||||||
<span class="media-tile__size">{{ formatSize(asset.size) }}</span>
|
(remove)="confirmDelete(asset)"
|
||||||
@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>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -75,6 +107,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<app-asset-details-drawer
|
||||||
|
[asset]="detailsAsset()"
|
||||||
|
[usages]="detailsUsages()"
|
||||||
|
(close)="closeDetails()"
|
||||||
|
(save)="saveDetails($event)"
|
||||||
|
(remove)="detailsAsset() && confirmDelete(detailsAsset()!)"
|
||||||
|
/>
|
||||||
|
|
||||||
<app-dialog
|
<app-dialog
|
||||||
[open]="!!pendingDelete()"
|
[open]="!!pendingDelete()"
|
||||||
[titleText]="'mediaLibrary.deleteConfirmTitle' | translate"
|
[titleText]="'mediaLibrary.deleteConfirmTitle' | translate"
|
||||||
|
|||||||
@@ -4,6 +4,24 @@
|
|||||||
padding: 18px;
|
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 {
|
.media-page__toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -15,56 +33,51 @@
|
|||||||
.media-page__toolbar-actions {
|
.media-page__toolbar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
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 {
|
.media-page__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-tile {
|
.media-page__grid--list {
|
||||||
display: grid;
|
grid-template-columns: 1fr;
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-tile__preview {
|
.media-page__guide {
|
||||||
width: 100%;
|
margin: 0;
|
||||||
height: 110px;
|
font-size: 0.85rem;
|
||||||
object-fit: cover;
|
color: var(--text-secondary, #5f6e6a);
|
||||||
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;
|
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 {
|
.media-page__dialog-actions {
|
||||||
@@ -75,25 +88,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.media-page__error {
|
.media-page__error {
|
||||||
color: var(--color-danger, #d9433c);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--danger, #c0392b);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin: 0;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||||
import { MediaLibraryFacade } from './facade/media-library.facade';
|
import { TranslateService } from '../../../i18n/translate.service';
|
||||||
import { MediaAsset } from '../../../core/media/models/media-asset.model';
|
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 { ButtonComponent } from '../../../shared/ui/button/button.component';
|
||||||
import { InputComponent } from '../../../shared/ui/input/input.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 { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state.component';
|
||||||
import { DialogComponent } from '../../../shared/ui/dialog/dialog.component';
|
import { DialogComponent } from '../../../shared/ui/dialog/dialog.component';
|
||||||
import { PaginationComponent } from '../../../shared/ui/pagination/pagination.component';
|
import { PaginationComponent } from '../../../shared/ui/pagination/pagination.component';
|
||||||
import { SkeletonComponent } from '../../../shared/ui/skeleton/skeleton.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;
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
@@ -21,11 +27,14 @@ const PAGE_SIZE = 24;
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ButtonComponent,
|
ButtonComponent,
|
||||||
InputComponent,
|
InputComponent,
|
||||||
CardComponent,
|
|
||||||
EmptyStateComponent,
|
EmptyStateComponent,
|
||||||
DialogComponent,
|
DialogComponent,
|
||||||
PaginationComponent,
|
PaginationComponent,
|
||||||
SkeletonComponent,
|
SkeletonComponent,
|
||||||
|
SelectComponent,
|
||||||
|
DashboardMetricComponent,
|
||||||
|
AssetTileComponent,
|
||||||
|
AssetDetailsDrawerComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './media-library-page.component.html',
|
templateUrl: './media-library-page.component.html',
|
||||||
styleUrl: './media-library-page.component.scss',
|
styleUrl: './media-library-page.component.scss',
|
||||||
@@ -33,38 +42,152 @@ const PAGE_SIZE = 24;
|
|||||||
})
|
})
|
||||||
export class MediaLibraryPageComponent implements OnInit {
|
export class MediaLibraryPageComponent implements OnInit {
|
||||||
protected readonly facade = inject(MediaLibraryFacade);
|
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>;
|
@ViewChild('fileInput') private fileInput?: ElementRef<HTMLInputElement>;
|
||||||
|
|
||||||
protected readonly pendingDelete = signal<MediaAsset | null>(null);
|
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 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 {
|
ngOnInit(): void {
|
||||||
void this.facade.load();
|
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 {
|
protected triggerFileInput(): void {
|
||||||
this.fileInput?.nativeElement.click();
|
this.fileInput?.nativeElement.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async onFileSelected(event: Event): Promise<void> {
|
protected async onFileSelected(event: Event): Promise<void> {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const files = input.files ? Array.from(input.files) : [];
|
||||||
input.value = '';
|
input.value = '';
|
||||||
if (!file) {
|
for (const file of files) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
const tags = next.split(',').map(tag => tag.trim()).filter(Boolean);
|
|
||||||
await this.facade.updateTags(asset.id, tags);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
protected confirmDelete(asset: MediaAsset): void {
|
||||||
@@ -77,28 +200,30 @@ export class MediaLibraryPageComponent implements OnInit {
|
|||||||
|
|
||||||
protected async proceedDelete(): Promise<void> {
|
protected async proceedDelete(): Promise<void> {
|
||||||
const asset = this.pendingDelete();
|
const asset = this.pendingDelete();
|
||||||
if (!asset) {
|
if (!asset) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.facade.remove(asset.id);
|
await this.facade.remove(asset.id);
|
||||||
this.pendingDelete.set(null);
|
this.pendingDelete.set(null);
|
||||||
|
if (this.detailsAsset()?.id === asset.id) {
|
||||||
|
this.closeDetails();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected createFolder(): void {
|
protected selectedAssets(): MediaAsset[] {
|
||||||
const name = window.prompt('New folder name:');
|
return this.facade.items().filter(a => this.facade.isSelected(a.id));
|
||||||
if (!name?.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.facade.setFolder(name.trim());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected formatSize(bytes: number): string {
|
protected async bulkDelete(): Promise<void> {
|
||||||
if (bytes < 1024) {
|
const ids = this.selectedAssets().map(a => a.id);
|
||||||
return `${bytes} B`;
|
if (ids.length === 0) return;
|
||||||
|
if (!confirm(this.translate.t('mediaLibrary.bulkDeleteConfirm'))) return;
|
||||||
|
await this.facade.bulkDelete(ids);
|
||||||
}
|
}
|
||||||
if (bytes < 1024 * 1024) {
|
|
||||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
protected bulkDownload(): void {
|
||||||
|
this.facade.downloadAssets(this.selectedAssets());
|
||||||
}
|
}
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
|
protected bulkExportMetadata(): void {
|
||||||
|
this.facade.exportMetadata(this.selectedAssets());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1152,6 +1152,71 @@ export const en: Translations = {
|
|||||||
confirm: 'Delete',
|
confirm: 'Delete',
|
||||||
emptyTitle: 'No files yet',
|
emptyTitle: 'No files yet',
|
||||||
emptyDescription: 'Upload an image or document to get started.',
|
emptyDescription: 'Upload an image or document to get started.',
|
||||||
|
allFolders: 'All folders',
|
||||||
|
newFolder: 'New folder',
|
||||||
|
editTags: 'Edit tags',
|
||||||
|
totalAssets: 'Total assets',
|
||||||
|
logos: 'Logos',
|
||||||
|
unused: 'Unused',
|
||||||
|
storageUsed: 'Storage used',
|
||||||
|
altCoverage: 'Alt text coverage',
|
||||||
|
allTypes: 'All types',
|
||||||
|
typeImage: 'Images',
|
||||||
|
typeSvg: 'SVG',
|
||||||
|
typePdf: 'PDF',
|
||||||
|
sortRecent: 'Recent',
|
||||||
|
sortName: 'Name',
|
||||||
|
sortSize: 'Size',
|
||||||
|
viewMode: 'View',
|
||||||
|
viewGrid: 'Grid',
|
||||||
|
viewList: 'List',
|
||||||
|
dropHint: 'Drag and drop images here, or use Upload above.',
|
||||||
|
cancelUpload: 'Cancel',
|
||||||
|
retry: 'Retry',
|
||||||
|
errorTooLarge: 'That file is too large. Try a smaller file.',
|
||||||
|
errorUnsupportedType: 'That file type is not supported. Use JPG, PNG, WebP, GIF, SVG, or PDF.',
|
||||||
|
errorGeneric: 'Upload failed. Please try again.',
|
||||||
|
selectedCount: 'selected',
|
||||||
|
download: 'Download',
|
||||||
|
exportMetadata: 'Export metadata',
|
||||||
|
bulkDelete: 'Delete selected',
|
||||||
|
bulkDeleteConfirm: 'Delete the selected files? This cannot be undone.',
|
||||||
|
clearSelection: 'Clear selection',
|
||||||
|
guideCopy: 'Reusing an existing asset avoids duplicate uploads and keeps your storage tidy. For best results upload images around 1200×1200px for hero/banner use and compress large photos before uploading.',
|
||||||
|
missingAlt: 'Missing alt text',
|
||||||
|
usageCount: 'uses',
|
||||||
|
detailsTitle: 'Asset details',
|
||||||
|
filename: 'Filename',
|
||||||
|
format: 'Format',
|
||||||
|
fileSize: 'File size',
|
||||||
|
dimensions: 'Dimensions',
|
||||||
|
uploadDate: 'Uploaded',
|
||||||
|
lastUsed: 'Last used',
|
||||||
|
usageCountLabel: 'Usage count',
|
||||||
|
usedIn: 'Used in',
|
||||||
|
notUsed: 'Not currently used anywhere.',
|
||||||
|
unknownValue: 'Unknown',
|
||||||
|
altText: 'Alt text',
|
||||||
|
altTextHelp: 'Describes the image for screen readers and search engines.',
|
||||||
|
decorative: 'Decorative (no alt text needed)',
|
||||||
|
missingAltWarning: 'This image has no alt text and is not marked decorative — screen reader users won’t know what it shows.',
|
||||||
|
caption: 'Caption',
|
||||||
|
description: 'Description',
|
||||||
|
usage: {
|
||||||
|
brand: 'Logo',
|
||||||
|
theme: 'Theme',
|
||||||
|
header: 'Header',
|
||||||
|
footer: 'Footer',
|
||||||
|
staticPage: 'Static page',
|
||||||
|
homepage: 'Homepage',
|
||||||
|
catalog: 'Catalog',
|
||||||
|
productPage: 'Product page',
|
||||||
|
widget: 'Widget',
|
||||||
|
company: 'Company info',
|
||||||
|
seo: 'SEO',
|
||||||
|
navigation: 'Navigation',
|
||||||
|
other: 'Other',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
adminCategories: {
|
adminCategories: {
|
||||||
chooseImage: 'Choose image',
|
chooseImage: 'Choose image',
|
||||||
|
|||||||
@@ -1147,6 +1147,71 @@ export const hy: Translations = {
|
|||||||
confirm: 'Ջնջել',
|
confirm: 'Ջնջել',
|
||||||
emptyTitle: 'Ֆայլեր դեռ չկան',
|
emptyTitle: 'Ֆայլեր դեռ չկան',
|
||||||
emptyDescription: 'Վերբեռնեք պատկեր կամ փաստաթուղթ սկսելու համար։',
|
emptyDescription: 'Վերբեռնեք պատկեր կամ փաստաթուղթ սկսելու համար։',
|
||||||
|
allFolders: 'Բոլոր պանակները',
|
||||||
|
newFolder: 'Նոր պանակ',
|
||||||
|
editTags: 'Խմբագրել պիտակները',
|
||||||
|
totalAssets: 'Ընդհանուր ֆայլեր',
|
||||||
|
logos: 'Լոգոներ',
|
||||||
|
unused: 'Չօգտագործվող',
|
||||||
|
storageUsed: 'Օգտագործված տարածք',
|
||||||
|
altCoverage: 'Alt տեքստի ծածկույթ',
|
||||||
|
allTypes: 'Բոլոր տեսակները',
|
||||||
|
typeImage: 'Պատկերներ',
|
||||||
|
typeSvg: 'SVG',
|
||||||
|
typePdf: 'PDF',
|
||||||
|
sortRecent: 'Վերջին',
|
||||||
|
sortName: 'Անուն',
|
||||||
|
sortSize: 'Չափ',
|
||||||
|
viewMode: 'Տեսք',
|
||||||
|
viewGrid: 'Ցանց',
|
||||||
|
viewList: 'Ցուցակ',
|
||||||
|
dropHint: 'Քաշեք և գցեք պատկերները այստեղ, կամ օգտագործեք վերևի «Վերբեռնել» կոճակը։',
|
||||||
|
cancelUpload: 'Չեղարկել',
|
||||||
|
retry: 'Կրկնել',
|
||||||
|
errorTooLarge: 'Ֆայլը չափազանց մեծ է։ Փորձեք ավելի փոքր ֆայլ։',
|
||||||
|
errorUnsupportedType: 'Այս ֆայլի տեսակը չի աջակցվում։ Օգտագործեք JPG, PNG, WebP, GIF, SVG կամ PDF։',
|
||||||
|
errorGeneric: 'Վերբեռնումը ձախողվեց։ Փորձեք կրկին։',
|
||||||
|
selectedCount: 'ընտրված է',
|
||||||
|
download: 'Ներբեռնել',
|
||||||
|
exportMetadata: 'Արտահանել մետատվյալները',
|
||||||
|
bulkDelete: 'Ջնջել ընտրվածները',
|
||||||
|
bulkDeleteConfirm: 'Ջնջե՞լ ընտրված ֆայլերը։ Հնարավոր չէ հետարկել։',
|
||||||
|
clearSelection: 'Չեղարկել ընտրությունը',
|
||||||
|
guideCopy: 'Առկա ֆայլի կրկնակի օգտագործումը խուսափում է կրկնօրինակումներից և խնայում է տարածք։ Հերոս/բաններ նկարների համար խորհուրդ ենք տալիս մոտ 1200×1200px չափս և սեղմել մեծ լուսանկարները վերբեռնելուց առաջ։',
|
||||||
|
missingAlt: 'Alt տեքստը բացակայում է',
|
||||||
|
usageCount: 'օգտ.',
|
||||||
|
detailsTitle: 'Ֆայլի մանրամասներ',
|
||||||
|
filename: 'Ֆայլի անուն',
|
||||||
|
format: 'Ձևաչափ',
|
||||||
|
fileSize: 'Ֆայլի չափ',
|
||||||
|
dimensions: 'Չափսեր',
|
||||||
|
uploadDate: 'Վերբեռնված է',
|
||||||
|
lastUsed: 'Վերջին օգտագործում',
|
||||||
|
usageCountLabel: 'Օգտագործումների քանակ',
|
||||||
|
usedIn: 'Օգտագործվում է',
|
||||||
|
notUsed: 'Այս պահին ոչ մի տեղ չի օգտագործվում։',
|
||||||
|
unknownValue: 'Անհայտ',
|
||||||
|
altText: 'Alt տեքստ',
|
||||||
|
altTextHelp: 'Նկարագրում է պատկերը էկրանընթերցողների և որոնողական համակարգերի համար։',
|
||||||
|
decorative: 'Դեկորատիվ (alt տեքստ պետք չէ)',
|
||||||
|
missingAltWarning: 'Այս պատկերը չունի alt տեքստ և նշված չէ որպես դեկորատիվ․ էկրանընթերցող օգտագործողները չեն իմանա, թե ինչ է պատկերված։',
|
||||||
|
caption: 'Ենթագիր',
|
||||||
|
description: 'Նկարագրություն',
|
||||||
|
usage: {
|
||||||
|
brand: 'Լոգո',
|
||||||
|
theme: 'Թեմա',
|
||||||
|
header: 'Վերնագիր',
|
||||||
|
footer: 'Ստորագիր',
|
||||||
|
staticPage: 'Ստատիկ էջ',
|
||||||
|
homepage: 'Գլխավոր էջ',
|
||||||
|
catalog: 'Կատալոգ',
|
||||||
|
productPage: 'Ապրանքի էջ',
|
||||||
|
widget: 'Վիջեթ',
|
||||||
|
company: 'Ընկերության տվյալներ',
|
||||||
|
seo: 'SEO',
|
||||||
|
navigation: 'Նավիգացիա',
|
||||||
|
other: 'Այլ',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
adminCategories: {
|
adminCategories: {
|
||||||
chooseImage: 'Ընտրել պատկեր',
|
chooseImage: 'Ընտրել պատկեր',
|
||||||
|
|||||||
@@ -1147,6 +1147,71 @@ export const ru: Translations = {
|
|||||||
confirm: 'Удалить',
|
confirm: 'Удалить',
|
||||||
emptyTitle: 'Пока нет файлов',
|
emptyTitle: 'Пока нет файлов',
|
||||||
emptyDescription: 'Загрузите изображение или документ, чтобы начать.',
|
emptyDescription: 'Загрузите изображение или документ, чтобы начать.',
|
||||||
|
allFolders: 'Все папки',
|
||||||
|
newFolder: 'Новая папка',
|
||||||
|
editTags: 'Изменить теги',
|
||||||
|
totalAssets: 'Всего файлов',
|
||||||
|
logos: 'Логотипы',
|
||||||
|
unused: 'Неиспользуемые',
|
||||||
|
storageUsed: 'Использовано места',
|
||||||
|
altCoverage: 'Покрытие alt-текстом',
|
||||||
|
allTypes: 'Все типы',
|
||||||
|
typeImage: 'Изображения',
|
||||||
|
typeSvg: 'SVG',
|
||||||
|
typePdf: 'PDF',
|
||||||
|
sortRecent: 'Недавние',
|
||||||
|
sortName: 'Имя',
|
||||||
|
sortSize: 'Размер',
|
||||||
|
viewMode: 'Вид',
|
||||||
|
viewGrid: 'Сетка',
|
||||||
|
viewList: 'Список',
|
||||||
|
dropHint: 'Перетащите изображения сюда или используйте кнопку «Загрузить» выше.',
|
||||||
|
cancelUpload: 'Отменить',
|
||||||
|
retry: 'Повторить',
|
||||||
|
errorTooLarge: 'Файл слишком большой. Попробуйте файл меньшего размера.',
|
||||||
|
errorUnsupportedType: 'Этот тип файла не поддерживается. Используйте JPG, PNG, WebP, GIF, SVG или PDF.',
|
||||||
|
errorGeneric: 'Не удалось загрузить файл. Попробуйте ещё раз.',
|
||||||
|
selectedCount: 'выбрано',
|
||||||
|
download: 'Скачать',
|
||||||
|
exportMetadata: 'Экспортировать метаданные',
|
||||||
|
bulkDelete: 'Удалить выбранные',
|
||||||
|
bulkDeleteConfirm: 'Удалить выбранные файлы? Это действие нельзя отменить.',
|
||||||
|
clearSelection: 'Снять выделение',
|
||||||
|
guideCopy: 'Повторное использование существующего файла избавляет от дублей и экономит место. Для баннеров и главных изображений рекомендуем размер около 1200×1200px и сжатие крупных фото перед загрузкой.',
|
||||||
|
missingAlt: 'Нет alt-текста',
|
||||||
|
usageCount: 'исп.',
|
||||||
|
detailsTitle: 'Информация о файле',
|
||||||
|
filename: 'Имя файла',
|
||||||
|
format: 'Формат',
|
||||||
|
fileSize: 'Размер файла',
|
||||||
|
dimensions: 'Размеры',
|
||||||
|
uploadDate: 'Загружен',
|
||||||
|
lastUsed: 'Последнее использование',
|
||||||
|
usageCountLabel: 'Количество использований',
|
||||||
|
usedIn: 'Используется в',
|
||||||
|
notUsed: 'Пока нигде не используется.',
|
||||||
|
unknownValue: 'Неизвестно',
|
||||||
|
altText: 'Alt-текст',
|
||||||
|
altTextHelp: 'Описывает изображение для программ чтения с экрана и поисковых систем.',
|
||||||
|
decorative: 'Декоративное (alt-текст не нужен)',
|
||||||
|
missingAltWarning: 'У этого изображения нет alt-текста, и оно не отмечено как декоративное — пользователи с программами чтения с экрана не узнают, что на нём изображено.',
|
||||||
|
caption: 'Подпись',
|
||||||
|
description: 'Описание',
|
||||||
|
usage: {
|
||||||
|
brand: 'Логотип',
|
||||||
|
theme: 'Тема',
|
||||||
|
header: 'Шапка',
|
||||||
|
footer: 'Подвал',
|
||||||
|
staticPage: 'Статическая страница',
|
||||||
|
homepage: 'Главная страница',
|
||||||
|
catalog: 'Каталог',
|
||||||
|
productPage: 'Страница товара',
|
||||||
|
widget: 'Виджет',
|
||||||
|
company: 'Информация о компании',
|
||||||
|
seo: 'SEO',
|
||||||
|
navigation: 'Навигация',
|
||||||
|
other: 'Другое',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
adminCategories: {
|
adminCategories: {
|
||||||
chooseImage: 'Выбрать изображение',
|
chooseImage: 'Выбрать изображение',
|
||||||
|
|||||||
@@ -1150,6 +1150,71 @@ export interface Translations {
|
|||||||
confirm: string;
|
confirm: string;
|
||||||
emptyTitle: string;
|
emptyTitle: string;
|
||||||
emptyDescription: string;
|
emptyDescription: string;
|
||||||
|
allFolders: string;
|
||||||
|
newFolder: string;
|
||||||
|
editTags: string;
|
||||||
|
totalAssets: string;
|
||||||
|
logos: string;
|
||||||
|
unused: string;
|
||||||
|
storageUsed: string;
|
||||||
|
altCoverage: string;
|
||||||
|
allTypes: string;
|
||||||
|
typeImage: string;
|
||||||
|
typeSvg: string;
|
||||||
|
typePdf: string;
|
||||||
|
sortRecent: string;
|
||||||
|
sortName: string;
|
||||||
|
sortSize: string;
|
||||||
|
viewMode: string;
|
||||||
|
viewGrid: string;
|
||||||
|
viewList: string;
|
||||||
|
dropHint: string;
|
||||||
|
cancelUpload: string;
|
||||||
|
retry: string;
|
||||||
|
errorTooLarge: string;
|
||||||
|
errorUnsupportedType: string;
|
||||||
|
errorGeneric: string;
|
||||||
|
selectedCount: string;
|
||||||
|
download: string;
|
||||||
|
exportMetadata: string;
|
||||||
|
bulkDelete: string;
|
||||||
|
bulkDeleteConfirm: string;
|
||||||
|
clearSelection: string;
|
||||||
|
guideCopy: string;
|
||||||
|
missingAlt: string;
|
||||||
|
usageCount: string;
|
||||||
|
detailsTitle: string;
|
||||||
|
filename: string;
|
||||||
|
format: string;
|
||||||
|
fileSize: string;
|
||||||
|
dimensions: string;
|
||||||
|
uploadDate: string;
|
||||||
|
lastUsed: string;
|
||||||
|
usageCountLabel: string;
|
||||||
|
usedIn: string;
|
||||||
|
notUsed: string;
|
||||||
|
unknownValue: string;
|
||||||
|
altText: string;
|
||||||
|
altTextHelp: string;
|
||||||
|
decorative: string;
|
||||||
|
missingAltWarning: string;
|
||||||
|
caption: string;
|
||||||
|
description: string;
|
||||||
|
usage: {
|
||||||
|
brand: string;
|
||||||
|
theme: string;
|
||||||
|
header: string;
|
||||||
|
footer: string;
|
||||||
|
staticPage: string;
|
||||||
|
homepage: string;
|
||||||
|
catalog: string;
|
||||||
|
productPage: string;
|
||||||
|
widget: string;
|
||||||
|
company: string;
|
||||||
|
seo: string;
|
||||||
|
navigation: string;
|
||||||
|
other: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
// Sprint 28: only the new empty-state copy this sprint's skeleton/empty-
|
// Sprint 28: only the new empty-state copy this sprint's skeleton/empty-
|
||||||
// state consistency fix introduces - NOT the full adminProducts/
|
// state consistency fix introduces - NOT the full adminProducts/
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
(ngModelChange)="facade.setSearch($event)"
|
(ngModelChange)="facade.setSearch($event)"
|
||||||
[placeholder]="'mediaLibrary.searchPlaceholder' | translate"
|
[placeholder]="'mediaLibrary.searchPlaceholder' | translate"
|
||||||
/>
|
/>
|
||||||
|
<app-select [options]="kindOptions()" [ngModel]="facade.kind() || 'all'" (ngModelChange)="setKind($event)" [ariaLabel]="'mediaLibrary.allTypes' | translate" [fullWidth]="false" />
|
||||||
|
<app-button variant="ghost" size="sm" (click)="showRecentOnly()">{{ 'mediaLibrary.sortRecent' | translate }}</app-button>
|
||||||
<input
|
<input
|
||||||
#fileInput
|
#fileInput
|
||||||
type="file"
|
type="file"
|
||||||
@@ -39,11 +41,18 @@
|
|||||||
[description]="'mediaLibrary.emptyDescription' | translate"
|
[description]="'mediaLibrary.emptyDescription' | translate"
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="media-picker__grid">
|
<div class="media-picker__grid" role="listbox" [attr.aria-label]="'mediaLibrary.title' | translate" tabindex="0" (keydown)="onGridKeydown($event)">
|
||||||
@for (asset of facade.items(); track asset.id) {
|
@for (asset of facade.items(); track asset.id; let i = $index) {
|
||||||
<app-card padding="sm" [interactive]="true" (click)="pick(asset)">
|
<app-card
|
||||||
|
padding="sm"
|
||||||
|
[interactive]="true"
|
||||||
|
role="option"
|
||||||
|
[attr.aria-selected]="i === focusedIndex()"
|
||||||
|
[class.media-picker__card--focused]="i === focusedIndex()"
|
||||||
|
(click)="pick(asset)"
|
||||||
|
>
|
||||||
@if (asset.mimeType.startsWith('image/')) {
|
@if (asset.mimeType.startsWith('image/')) {
|
||||||
<img class="media-picker__preview" [src]="asset.url" [alt]="asset.filename" />
|
<img class="media-picker__preview" [src]="asset.url" [alt]="asset.filename" loading="lazy" />
|
||||||
} @else {
|
} @else {
|
||||||
<div class="media-picker__preview media-picker__preview--file">{{ asset.mimeType }}</div>
|
<div class="media-picker__preview media-picker__preview--file">{{ asset.mimeType }}</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,11 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-picker__card--focused {
|
||||||
|
outline: 2px solid var(--brand-primary, #1e8a6e);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.media-picker__filename {
|
.media-picker__filename {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { ChangeDetectionStrategy, Component, ElementRef, ViewChild, effect, inject, input, output } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, ElementRef, ViewChild, computed, effect, inject, input, output, signal } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||||
|
import { TranslateService } from '../../../i18n/translate.service';
|
||||||
import { MediaLibraryFacade } from '../../../features/backoffice/media/facade/media-library.facade';
|
import { MediaLibraryFacade } from '../../../features/backoffice/media/facade/media-library.facade';
|
||||||
import { MediaAsset } from '../../../core/media/models/media-asset.model';
|
import { MediaAsset, MediaAssetKind } from '../../../core/media/models/media-asset.model';
|
||||||
import { ButtonComponent } from '../../ui/button/button.component';
|
import { ButtonComponent } from '../../ui/button/button.component';
|
||||||
import { InputComponent } from '../../ui/input/input.component';
|
import { InputComponent } from '../../ui/input/input.component';
|
||||||
import { CardComponent } from '../../ui/card/card.component';
|
import { CardComponent } from '../../ui/card/card.component';
|
||||||
@@ -10,6 +11,7 @@ import { EmptyStateComponent } from '../../ui/empty-state/empty-state.component'
|
|||||||
import { DialogComponent } from '../../ui/dialog/dialog.component';
|
import { DialogComponent } from '../../ui/dialog/dialog.component';
|
||||||
import { PaginationComponent } from '../../ui/pagination/pagination.component';
|
import { PaginationComponent } from '../../ui/pagination/pagination.component';
|
||||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||||
|
import { SelectComponent, SelectOption } from '../../ui/select/select.component';
|
||||||
|
|
||||||
const PAGE_SIZE = 24;
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ const PAGE_SIZE = 24;
|
|||||||
DialogComponent,
|
DialogComponent,
|
||||||
PaginationComponent,
|
PaginationComponent,
|
||||||
SkeletonComponent,
|
SkeletonComponent,
|
||||||
|
SelectComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './media-picker.component.html',
|
templateUrl: './media-picker.component.html',
|
||||||
styleUrl: './media-picker.component.scss',
|
styleUrl: './media-picker.component.scss',
|
||||||
@@ -38,10 +41,55 @@ export class MediaPickerComponent {
|
|||||||
readonly closed = output<void>();
|
readonly closed = output<void>();
|
||||||
|
|
||||||
protected readonly facade = inject(MediaLibraryFacade);
|
protected readonly facade = inject(MediaLibraryFacade);
|
||||||
|
private readonly translate = inject(TranslateService);
|
||||||
|
|
||||||
@ViewChild('fileInput') private fileInput?: ElementRef<HTMLInputElement>;
|
@ViewChild('fileInput') private fileInput?: ElementRef<HTMLInputElement>;
|
||||||
|
|
||||||
protected readonly totalPages = () => Math.max(1, Math.ceil(this.facade.total() / PAGE_SIZE));
|
protected readonly totalPages = () => Math.max(1, Math.ceil(this.facade.total() / PAGE_SIZE));
|
||||||
|
protected readonly focusedIndex = signal(0);
|
||||||
|
|
||||||
|
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') },
|
||||||
|
]);
|
||||||
|
|
||||||
|
setKind(value: string): void {
|
||||||
|
this.facade.setKind(value === 'all' ? null : (value as MediaAssetKind));
|
||||||
|
}
|
||||||
|
|
||||||
|
showRecentOnly(): void {
|
||||||
|
this.facade.setSort('recent');
|
||||||
|
this.facade.setFolder(null);
|
||||||
|
this.facade.setSearch('');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onGridKeydown(event: KeyboardEvent): void {
|
||||||
|
const count = this.facade.items().length;
|
||||||
|
if (count === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const columns = Math.max(1, Math.floor((event.currentTarget as HTMLElement).clientWidth / 150));
|
||||||
|
if (event.key === 'ArrowRight') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.focusedIndex.set(Math.min(count - 1, this.focusedIndex() + 1));
|
||||||
|
} else if (event.key === 'ArrowLeft') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.focusedIndex.set(Math.max(0, this.focusedIndex() - 1));
|
||||||
|
} else if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.focusedIndex.set(Math.min(count - 1, this.focusedIndex() + columns));
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.focusedIndex.set(Math.max(0, this.focusedIndex() - columns));
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
const asset = this.facade.items()[this.focusedIndex()];
|
||||||
|
if (asset) {
|
||||||
|
this.pick(asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// MediaLibraryFacade is a root-provided singleton shared by every
|
// MediaLibraryFacade is a root-provided singleton shared by every
|
||||||
@@ -56,7 +104,9 @@ export class MediaPickerComponent {
|
|||||||
if (this.open()) {
|
if (this.open()) {
|
||||||
this.facade.search.set('');
|
this.facade.search.set('');
|
||||||
this.facade.folder.set(null);
|
this.facade.folder.set(null);
|
||||||
|
this.facade.kind.set(null);
|
||||||
this.facade.page.set(1);
|
this.facade.page.set(1);
|
||||||
|
this.focusedIndex.set(0);
|
||||||
void this.facade.load();
|
void this.facade.load();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user