feat(media): reusable media management

Sprint 22.

- MediaAsset gains folder (flat) and MediaListParams gains folder/tag
  filters; MediaRepository.listFolders() derives the folder list from
  existing records
- upload validation: 10MB size cap, mime allow-list (jpeg/png/webp/gif/
  svg+xml/pdf), real error messages surfaced through MediaLibraryFacade
  instead of a generic swallowed string
- SVG uploads are sanitized (script tags and on*= attributes stripped)
  before storage
- raster images (excl. gif) are downscaled to a 2000px max dimension and
  re-encoded via canvas before storage - compression, not a crop UI
- tag editing (window.prompt, comma-separated) via
  MediaLibraryFacade.updateTags()
- MediaPickerComponent wired into Project Editor branding (logo, compact
  logo, favicon) alongside its existing category/product usage - confirmed
  no image fields exist on Static Pages or as a dedicated hero field to
  wire

docs/ADMIN.md updated with the new Sprint 22 section including the storage
abstraction note (MediaRepository was already the abstraction).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-07-15 10:42:23 +04:00
parent 60c63a0d2a
commit 30afb5d778
14 changed files with 324 additions and 33 deletions

View File

@@ -208,6 +208,89 @@ src/app/features/admin/categories/
- **Preview**: simple read-only line in the editor showing computed discounted price. - **Preview**: simple read-only line in the editor showing computed discounted price.
- **Infinite scroll**: `AdminProductsFacade.infiniteScroll` toggle - when on, `loadMore()` appends the next page to `products()` instead of replacing it; pagination UI swaps for a "Load more" button. Off by default (existing paginated behavior unchanged). - **Infinite scroll**: `AdminProductsFacade.infiniteScroll` toggle - when on, `loadMore()` appends the next page to `products()` instead of replacing it; pagination UI swaps for a "Load more" button. Off by default (existing paginated behavior unchanged).
## Sprint 22 - Media System hardening
`core/media/` (`MediaRepository` abstraction, `MockMediaRepository` IndexedDB
implementation) + `features/backoffice/media/` + the shared
`shared/media/media-picker/`:
- **Folders**: flat `folder?: string` tag on `MediaAsset` (no nesting) -
"New folder" just sets the active filter to a name typed via
`window.prompt` (mirrors the `window.confirm` pattern already used for
destructive actions elsewhere); the folder is created implicitly the next
time something uploads into it. `MediaRepository.listFolders()` derives
the folder list from existing records rather than a separate folder
entity - intentionally light, matches the flat-storage reality of an
IndexedDB mock.
- **Tags**: already existed on `MediaAsset`; added an edit affordance
(`window.prompt`, comma-separated) and `MediaLibraryFacade.updateTags()`.
- **Validation**: `MockMediaRepository.validateFile()` rejects anything over
10MB or outside the allow-list (`jpeg/png/webp/gif/svg+xml/pdf`); errors
now propagate as real messages through `MediaLibraryFacade.error` (both
`media-library-page` and `media-picker` display it - previously upload
failures were swallowed into a generic string).
- **SVG sanitization**: `sanitizeSvg()` strips `<script>` tags and
`on*="..."` attributes from uploaded SVG markup before storing it, since
SVG is the one accepted format that can carry inline script.
- **Compression/resize**: raster images (not SVG/GIF) are downscaled to a
2000px max dimension and re-encoded (JPEG/PNG, quality 0.85) via
`<canvas>` before being stored - client-side only, no crop UI. A full
interactive cropper was out of scope for this ticket; revisit if a real
design need for manual cropping shows up.
- **Reuse confirmed**: `MediaPickerComponent` is now wired into Category
images (Sprint 20), Product gallery (Sprint 21), and Project Editor
branding (logo / compact logo / favicon, this sprint) - one media library
for the whole platform, per the sprint goal. Static Pages editor has no
image fields to wire (confirmed, not a gap). Hero image: no dedicated
hero-image field exists in `BootstrapConfig` today - nothing to wire.
- **Storage abstraction**: already existed via `MediaRepository` (abstract
class + DI token `providedIn: 'root'` on `MockMediaRepository`) - swapping
to a real CDN/backend means implementing `MediaRepository` against a real
API and rebinding the provider; no consumer (`MediaLibraryFacade`,
`MediaPickerComponent`, or any of the pickers above) changes.
## Sprint 22 - Media System hardening
`core/media/` (`MediaRepository` abstraction, `MockMediaRepository` IndexedDB
implementation) + `features/backoffice/media/` + the shared
`shared/media/media-picker/`:
- **Folders**: flat `folder?: string` tag on `MediaAsset` (no nesting) -
"New folder" just sets the active filter to a name typed via
`window.prompt` (mirrors the `window.confirm` pattern already used for
destructive actions elsewhere); the folder is created implicitly the next
time something uploads into it. `MediaRepository.listFolders()` derives
the folder list from existing records rather than a separate folder
entity - intentionally light, matches the flat-storage reality of an
IndexedDB mock.
- **Tags**: already existed on `MediaAsset`; added an edit affordance
(`window.prompt`, comma-separated) and `MediaLibraryFacade.updateTags()`.
- **Validation**: `MockMediaRepository.validateFile()` rejects anything over
10MB or outside the allow-list (`jpeg/png/webp/gif/svg+xml/pdf`); errors
now propagate as real messages through `MediaLibraryFacade.error` (both
`media-library-page` and `media-picker` display it - previously upload
failures were swallowed into a generic string).
- **SVG sanitization**: `sanitizeSvg()` strips `<script>` tags and
`on*="..."` attributes from uploaded SVG markup before storing it, since
SVG is the one accepted format that can carry inline script.
- **Compression/resize**: raster images (not SVG/GIF) are downscaled to a
2000px max dimension and re-encoded (JPEG/PNG, quality 0.85) via
`<canvas>` before being stored - client-side only, no crop UI. A full
interactive cropper was out of scope for this ticket; revisit if a real
design need for manual cropping shows up.
- **Reuse confirmed**: `MediaPickerComponent` is now wired into Category
images (Sprint 20), Product gallery (Sprint 21), and Project Editor
branding (logo / compact logo / favicon, this sprint) - one media library
for the whole platform, per the sprint goal. Static Pages editor has no
image fields to wire (confirmed, not a gap). Hero image: no dedicated
hero-image field exists in `BootstrapConfig` today ('hero' only appears
as a `SectionLayoutStrategy` enum value) - nothing to wire.
- **Storage abstraction**: already existed via `MediaRepository` (abstract
class + DI token `providedIn: 'root'` on `MockMediaRepository`) - swapping
to a real CDN/backend means implementing `MediaRepository` against a real
API and rebinding the provider; no consumer (`MediaLibraryFacade`,
`MediaPickerComponent`, or any of the pickers above) changes.
## Known gaps / backend needs ## Known gaps / backend needs
- **Dashboard metrics endpoint.** Categories/Products counts are computed - **Dashboard metrics endpoint.** Categories/Products counts are computed

View File

@@ -32,12 +32,13 @@ Notify user: **from Sprint 20 (Categories) once product↔category link + admin
- Commit: `feat(admin): complete product management` - Commit: `feat(admin): complete product management`
- Known trade-off: related-products picker sources from whatever page is currently loaded in `AdminProductsFacade.products()`, not a full catalog search — documented in ADMIN.md, fine at current mock scale. - Known trade-off: related-products picker sources from whatever page is currently loaded in `AdminProductsFacade.products()`, not a full catalog search — documented in ADMIN.md, fine at current mock scale.
## Sprint 22 — Media System hardening ## Sprint 22 — Media System hardening ✅ done
- [ ] Folder support, tags, search in Media Manager - [x] Folder support (flat, filter-based), tags (edit + filter), search (already existed)
- [ ] Crop/resize/compression on upload, SVG + file-type validation - [x] Resize/compression on upload (canvas downscale + re-encode, raster only) + SVG script-sanitization + file-type/size validation with real surfaced errors
- [ ] Confirm reuse across category/product/logo/hero/static-page selectors - [x] Confirmed reuse: category images (20), product gallery (21), branding logo/compact-logo/favicon (22, newly wired). Static pages: no image fields (not a gap). Hero: no dedicated field exists.
- [ ] Storage abstraction doc note (swap IndexedDB mock -> CDN later) - [x] Storage abstraction documented (`MediaRepository` abstract class was already the abstraction)
- Commit: `feat(media): reusable media management` - Commit: `feat(media): reusable media management`
- Scoped down from ticket: no interactive crop UI (compression/resize only); folders are a flat tag, not a real folder entity/hierarchy.
## Sprint 23 — Orders (mock/local, flag backend gap) ## Sprint 23 — Orders (mock/local, flag backend gap)
- [ ] Orders model + local gateway (seed mock data) - [ ] Orders model + local gateway (seed mock data)

View File

@@ -1,8 +1,9 @@
import { MediaAsset, MediaListParams, MediaListResult } from './models/media-asset.model'; import { MediaAsset, MediaListParams, MediaListResult, MediaUploadOptions } from './models/media-asset.model';
export abstract class MediaRepository { export abstract class MediaRepository {
abstract list(params?: MediaListParams): Promise<MediaListResult>; abstract list(params?: MediaListParams): Promise<MediaListResult>;
abstract upload(file: File): 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'>>): Promise<MediaAsset>; abstract update(id: string, patch: Partial<Pick<MediaAsset, 'altText' | 'tags' | 'folder'>>): Promise<MediaAsset>;
abstract listFolders(): Promise<string[]>;
} }

View File

@@ -1,11 +1,15 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { MediaRepository } from './media-repository'; import { MediaRepository } from './media-repository';
import { MediaAsset, MediaListParams, MediaListResult } from './models/media-asset.model'; import { MediaAsset, 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;
const STORE_NAME = 'assets'; const STORE_NAME = 'assets';
const DEFAULT_PAGE_SIZE = 24; const DEFAULT_PAGE_SIZE = 24;
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
const MAX_IMAGE_DIMENSION = 2000;
const COMPRESS_QUALITY = 0.85;
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/svg+xml', 'application/pdf'];
interface StoredAssetRecord { interface StoredAssetRecord {
id: string; id: string;
@@ -16,6 +20,7 @@ interface StoredAssetRecord {
height?: number; height?: number;
altText?: Record<string, string>; altText?: Record<string, string>;
tags?: string[]; tags?: string[];
folder?: string;
createdAt: string; createdAt: string;
blob: Blob; blob: Blob;
} }
@@ -31,9 +36,10 @@ export class MockMediaRepository extends MediaRepository {
const search = (params.search ?? '').trim().toLowerCase(); const search = (params.search ?? '').trim().toLowerCase();
const records = await this.getAllRecords(); const records = await this.getAllRecords();
const filtered = search const filtered = records
? records.filter(record => record.filename.toLowerCase().includes(search)) .filter(record => !search || record.filename.toLowerCase().includes(search))
: records; .filter(record => !params.folder || record.folder === params.folder)
.filter(record => !params.tag || (record.tags ?? []).includes(params.tag!));
filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
@@ -46,17 +52,21 @@ export class MockMediaRepository extends MediaRepository {
}; };
} }
async upload(file: File): Promise<MediaAsset> { async upload(file: File, options: MediaUploadOptions = {}): Promise<MediaAsset> {
const dimensions = await this.readImageDimensions(file); this.validateFile(file);
const blob = await this.prepareBlob(file);
const dimensions = await this.readImageDimensions(blob, file.type);
const record: StoredAssetRecord = { const record: StoredAssetRecord = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
filename: file.name, filename: file.name,
mimeType: file.type, mimeType: file.type,
size: file.size, size: blob.size,
width: dimensions?.width, width: dimensions?.width,
height: dimensions?.height, height: dimensions?.height,
folder: options.folder || undefined,
tags: options.tags,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
blob: file, blob,
}; };
const db = await this.openDb(); const db = await this.openDb();
@@ -65,6 +75,67 @@ export class MockMediaRepository extends MediaRepository {
return this.toAsset(record); return this.toAsset(record);
} }
async listFolders(): Promise<string[]> {
const records = await this.getAllRecords();
return [...new Set(records.map(record => record.folder).filter((folder): folder is string => !!folder))].sort();
}
private validateFile(file: File): void {
if (file.size > MAX_FILE_SIZE_BYTES) {
throw new Error(`File exceeds the ${MAX_FILE_SIZE_BYTES / (1024 * 1024)}MB limit.`);
}
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
throw new Error(`Unsupported file type: ${file.type || 'unknown'}.`);
}
}
private async prepareBlob(file: File): Promise<Blob> {
if (file.type === 'image/svg+xml') {
return this.sanitizeSvg(file);
}
if (file.type === 'image/gif') {
return file;
}
if (file.type.startsWith('image/')) {
return this.compressImage(file);
}
return file;
}
/** Strips <script> elements and on*="..." event-handler attributes before storing untrusted SVG markup. */
private async sanitizeSvg(file: File): Promise<Blob> {
const text = await file.text();
const sanitized = text
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/\son\w+\s*=\s*"[^"]*"/gi, '')
.replace(/\son\w+\s*=\s*'[^']*'/gi, '');
return new Blob([sanitized], { type: 'image/svg+xml' });
}
private async compressImage(file: File): Promise<Blob> {
try {
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, MAX_IMAGE_DIMENSION / Math.max(bitmap.width, bitmap.height));
const width = Math.round(bitmap.width * scale);
const height = Math.round(bitmap.height * scale);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
bitmap.close();
return file;
}
ctx.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
const outputType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve, outputType, COMPRESS_QUALITY));
return blob ?? file;
} catch {
return file;
}
}
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
const db = await this.openDb(); const db = await this.openDb();
await this.runTransaction(db, 'readwrite', store => store.delete(id)); await this.runTransaction(db, 'readwrite', store => store.delete(id));
@@ -97,6 +168,7 @@ export class MockMediaRepository extends MediaRepository {
height: record.height, height: record.height,
altText: record.altText, altText: record.altText,
tags: record.tags, tags: record.tags,
folder: record.folder,
createdAt: record.createdAt, createdAt: record.createdAt,
}; };
} }
@@ -119,12 +191,12 @@ export class MockMediaRepository extends MediaRepository {
} }
} }
private async readImageDimensions(file: File): Promise<{ width: number; height: number } | undefined> { private async readImageDimensions(blob: Blob, mimeType: string): Promise<{ width: number; height: number } | undefined> {
if (!file.type.startsWith('image/')) { if (!mimeType.startsWith('image/') || mimeType === 'image/svg+xml') {
return undefined; return undefined;
} }
try { try {
const bitmap = await createImageBitmap(file); const bitmap = await createImageBitmap(blob);
const dimensions = { width: bitmap.width, height: bitmap.height }; const dimensions = { width: bitmap.width, height: bitmap.height };
bitmap.close(); bitmap.close();
return dimensions; return dimensions;

View File

@@ -9,6 +9,7 @@ export interface MediaAsset {
height?: number; height?: number;
altText?: Record<string, string>; altText?: Record<string, string>;
tags?: string[]; tags?: string[];
folder?: string;
createdAt: string; createdAt: string;
} }
@@ -16,6 +17,13 @@ export interface MediaListParams {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
search?: string; search?: string;
folder?: string;
tag?: string;
}
export interface MediaUploadOptions {
folder?: string;
tags?: string[];
} }
export interface MediaListResult { export interface MediaListResult {

View File

@@ -1,6 +1,6 @@
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 } from '../../../../core/media/models/media-asset.model'; import { MediaAsset, MediaUploadOptions } from '../../../../core/media/models/media-asset.model';
const PAGE_SIZE = 24; const PAGE_SIZE = 24;
@@ -15,6 +15,8 @@ export class MediaLibraryFacade {
readonly error = signal<string | null>(null); readonly error = signal<string | null>(null);
readonly search = signal(''); readonly search = signal('');
readonly page = signal(1); readonly page = signal(1);
readonly folder = signal<string | null>(null);
readonly folders = signal<string[]>([]);
async load(): Promise<void> { async load(): Promise<void> {
this.loading.set(true); this.loading.set(true);
@@ -24,9 +26,11 @@ export class MediaLibraryFacade {
page: this.page(), page: this.page(),
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
search: this.search(), search: this.search(),
folder: this.folder() ?? undefined,
}); });
this.items.set(result.items); this.items.set(result.items);
this.total.set(result.total); this.total.set(result.total);
this.folders.set(await this.repository.listFolders());
} catch { } catch {
this.error.set('Failed to load media assets.'); this.error.set('Failed to load media assets.');
} finally { } finally {
@@ -40,25 +44,41 @@ export class MediaLibraryFacade {
void this.load(); void this.load();
} }
setFolder(value: string | null): void {
this.folder.set(value);
this.page.set(1);
void this.load();
}
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): Promise<void> { async upload(file: File, options: MediaUploadOptions = {}): Promise<void> {
this.uploading.set(true); this.uploading.set(true);
this.error.set(null); this.error.set(null);
try { try {
await this.repository.upload(file); await this.repository.upload(file, { folder: this.folder() ?? undefined, ...options });
this.page.set(1); this.page.set(1);
await this.load(); await this.load();
} catch { } catch (error: unknown) {
this.error.set('Failed to upload file.'); this.error.set(error instanceof Error ? error.message : 'Failed to upload file.');
} finally { } finally {
this.uploading.set(false); this.uploading.set(false);
} }
} }
async updateTags(id: string, tags: string[]): Promise<void> {
this.error.set(null);
try {
await this.repository.update(id, { tags });
await this.load();
} catch {
this.error.set('Failed to update tags.');
}
}
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
this.error.set(null); this.error.set(null);
try { try {

View File

@@ -7,10 +7,17 @@
(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)">
<option [ngValue]="null">{{ 'mediaLibrary.allFolders' | translate }}</option>
@for (folder of facade.folders(); track folder) {
<option [ngValue]="folder">{{ folder }}</option>
}
</select>
<app-button variant="secondary" (click)="createFolder()">{{ 'mediaLibrary.newFolder' | translate }}</app-button>
<input <input
#fileInput #fileInput
type="file" type="file"
accept="image/*,application/pdf" accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml,application/pdf"
hidden hidden
(change)="onFileSelected($event)" (change)="onFileSelected($event)"
/> />
@@ -20,6 +27,10 @@
</div> </div>
</div> </div>
@if (facade.error()) {
<p class="media-page__error">{{ facade.error() }}</p>
}
@if (facade.loading()) { @if (facade.loading()) {
<div class="media-page__grid"> <div class="media-page__grid">
@for (i of [1, 2, 3, 4, 5, 6]; track i) { @for (i of [1, 2, 3, 4, 5, 6]; track i) {
@@ -44,11 +55,17 @@
<div class="media-tile__meta"> <div class="media-tile__meta">
<span class="media-tile__filename">{{ asset.filename }}</span> <span class="media-tile__filename">{{ asset.filename }}</span>
<span class="media-tile__size">{{ formatSize(asset.size) }}</span> <span class="media-tile__size">{{ formatSize(asset.size) }}</span>
@if (asset.tags?.length) {
<span class="media-tile__tags">{{ asset.tags!.join(', ') }}</span>
}
</div> </div>
<app-button variant="danger" size="sm" [fullWidth]="true" (click)="confirmDelete(asset)"> <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 }} {{ 'mediaLibrary.delete' | translate }}
</app-button> </app-button>
</div> </div>
</div>
</app-card> </app-card>
} }
</div> </div>

View File

@@ -73,3 +73,27 @@
gap: 8px; gap: 8px;
margin-top: 16px; margin-top: 16px;
} }
.media-page__error {
color: var(--color-danger, #d9433c);
font-size: 0.85rem;
margin: 0;
}
.media-tile__tags {
font-size: 0.7rem;
color: var(--text-secondary, #6b7280);
font-style: italic;
}
.media-tile__row {
display: flex;
gap: 8px;
}
.media-page__toolbar-actions select {
min-height: 40px;
padding: 0 10px;
border: 1px solid var(--border-color, #d3dad9);
border-radius: 10px;
}

View File

@@ -57,6 +57,16 @@ export class MediaLibraryPageComponent implements OnInit {
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 confirmDelete(asset: MediaAsset): void { protected confirmDelete(asset: MediaAsset): void {
this.pendingDelete.set(asset); this.pendingDelete.set(asset);
} }
@@ -74,6 +84,14 @@ export class MediaLibraryPageComponent implements OnInit {
this.pendingDelete.set(null); this.pendingDelete.set(null);
} }
protected createFolder(): void {
const name = window.prompt('New folder name:');
if (!name?.trim()) {
return;
}
this.facade.setFolder(name.trim());
}
protected formatSize(bytes: number): string { protected formatSize(bytes: number): string {
if (bytes < 1024) { if (bytes < 1024) {
return `${bytes} B`; return `${bytes} B`;

View File

@@ -3,17 +3,27 @@
<h2>{{ 'builder.branding' | translate }}</h2> <h2>{{ 'builder.branding' | translate }}</h2>
<div class="editor-grid two"> <div class="editor-grid two">
<app-form-field [label]="'builder.logo' | translate" [hint]="'builder.logoDesc' | translate"> <app-form-field [label]="'builder.logo' | translate" [hint]="'builder.logoDesc' | translate">
<div class="media-field-row">
<app-input [ngModel]="bootstrap.branding.logoUrl" (ngModelChange)="updateField('logoUrl', $event)" /> <app-input [ngModel]="bootstrap.branding.logoUrl" (ngModelChange)="updateField('logoUrl', $event)" />
<app-button variant="secondary" size="sm" (click)="openMediaPicker('logoUrl')">{{ 'adminCategories.chooseImage' | translate }}</app-button>
</div>
</app-form-field> </app-form-field>
<app-form-field [label]="'builder.smallLogo' | translate" [hint]="'builder.smallLogoDesc' | translate"> <app-form-field [label]="'builder.smallLogo' | translate" [hint]="'builder.smallLogoDesc' | translate">
<div class="media-field-row">
<app-input [ngModel]="bootstrap.branding.logoCompactUrl || ''" (ngModelChange)="updateField('logoCompactUrl', $event)" /> <app-input [ngModel]="bootstrap.branding.logoCompactUrl || ''" (ngModelChange)="updateField('logoCompactUrl', $event)" />
<app-button variant="secondary" size="sm" (click)="openMediaPicker('logoCompactUrl')">{{ 'adminCategories.chooseImage' | translate }}</app-button>
</div>
</app-form-field> </app-form-field>
<app-form-field [label]="'builder.favicon' | translate" [hint]="'builder.faviconDesc' | translate"> <app-form-field [label]="'builder.favicon' | translate" [hint]="'builder.faviconDesc' | translate">
<div class="media-field-row">
<app-input [ngModel]="bootstrap.branding.faviconUrl" (ngModelChange)="updateField('faviconUrl', $event)" /> <app-input [ngModel]="bootstrap.branding.faviconUrl" (ngModelChange)="updateField('faviconUrl', $event)" />
<app-button variant="secondary" size="sm" (click)="openMediaPicker('faviconUrl')">{{ 'adminCategories.chooseImage' | translate }}</app-button>
</div>
</app-form-field> </app-form-field>
<app-form-field [label]="'builder.marketplaceTitle' | translate" [hint]="'builder.marketplaceTitleDesc' | translate"> <app-form-field [label]="'builder.marketplaceTitle' | translate" [hint]="'builder.marketplaceTitleDesc' | translate">
<app-input [ngModel]="bootstrap.seo.default.title" (ngModelChange)="updateMarketplaceTitle($event)" /> <app-input [ngModel]="bootstrap.seo.default.title" (ngModelChange)="updateMarketplaceTitle($event)" />
</app-form-field> </app-form-field>
</div> </div>
<app-media-picker [open]="mediaPickerOpen" (selected)="onImagePicked($event)" (closed)="mediaPickerOpen = false" />
</section> </section>
} }

View File

@@ -4,11 +4,16 @@ import { ProjectEditorFacade } from '../facade/project-editor.facade';
import { TranslatePipe } from '../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../i18n/translate.pipe';
import { InputComponent } from '../../../shared/ui/input/input.component'; import { InputComponent } from '../../../shared/ui/input/input.component';
import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component';
import { ButtonComponent } from '../../../shared/ui/button/button.component';
import { MediaPickerComponent } from '../../../shared/media/media-picker/media-picker.component';
import { MediaAsset } from '../../../core/media/models/media-asset.model';
type BrandingImageField = 'logoUrl' | 'logoCompactUrl' | 'faviconUrl';
@Component({ @Component({
selector: 'app-project-editor-branding-section', selector: 'app-project-editor-branding-section',
standalone: true, standalone: true,
imports: [FormsModule, TranslatePipe, InputComponent, FormFieldComponent], imports: [FormsModule, TranslatePipe, InputComponent, FormFieldComponent, ButtonComponent, MediaPickerComponent],
templateUrl: './branding-section.component.html', templateUrl: './branding-section.component.html',
styleUrls: ['./section.shared.scss'], styleUrls: ['./section.shared.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -17,6 +22,9 @@ export class ProjectEditorBrandingSectionComponent {
private readonly facade = inject(ProjectEditorFacade); private readonly facade = inject(ProjectEditorFacade);
readonly bootstrap = this.facade.bootstrap; readonly bootstrap = this.facade.bootstrap;
protected mediaPickerOpen = false;
private mediaPickerTarget: BrandingImageField | null = null;
updateField<K extends 'logoUrl' | 'logoCompactUrl' | 'faviconUrl' | 'brandName'>(key: K, value: string): void { updateField<K extends 'logoUrl' | 'logoCompactUrl' | 'faviconUrl' | 'brandName'>(key: K, value: string): void {
this.facade.updateBootstrap(current => ({ this.facade.updateBootstrap(current => ({
...current, ...current,
@@ -24,6 +32,18 @@ export class ProjectEditorBrandingSectionComponent {
})); }));
} }
openMediaPicker(target: BrandingImageField): void {
this.mediaPickerTarget = target;
this.mediaPickerOpen = true;
}
onImagePicked(asset: MediaAsset): void {
if (this.mediaPickerTarget) {
this.updateField(this.mediaPickerTarget, asset.url);
}
this.mediaPickerOpen = false;
}
updateMarketplaceTitle(value: string): void { updateMarketplaceTitle(value: string): void {
this.facade.updateBootstrap(current => ({ this.facade.updateBootstrap(current => ({
...current, ...current,

View File

@@ -11,6 +11,13 @@
gap: 14px; gap: 14px;
} }
.media-field-row {
display: flex;
gap: 8px;
align-items: center;
}
.media-field-row app-input { flex: 1; }
.editor-grid { .editor-grid {
display: grid; display: grid;
gap: 12px; gap: 12px;

View File

@@ -14,7 +14,7 @@
<input <input
#fileInput #fileInput
type="file" type="file"
accept="image/*,application/pdf" accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml,application/pdf"
hidden hidden
(change)="onFileSelected($event)" (change)="onFileSelected($event)"
/> />
@@ -23,6 +23,10 @@
</app-button> </app-button>
</div> </div>
@if (facade.error()) {
<p class="media-picker__error">{{ facade.error() }}</p>
}
@if (facade.loading()) { @if (facade.loading()) {
<div class="media-picker__grid"> <div class="media-picker__grid">
@for (i of [1, 2, 3, 4]; track i) { @for (i of [1, 2, 3, 4]; track i) {

View File

@@ -34,6 +34,12 @@
padding: 6px; padding: 6px;
} }
.media-picker__error {
color: var(--color-danger, #d9433c);
font-size: 0.85rem;
margin: 0;
}
.media-picker__filename { .media-picker__filename {
display: block; display: block;
font-size: 0.75rem; font-size: 0.75rem;