import { NgTemplateOutlet } from '@angular/common'; import { booleanAttribute, ChangeDetectionStrategy, Component, computed, DestroyRef, ElementRef, forwardRef, inject, input, linkedSignal, OnDestroy, output, signal, viewChild, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { type ControlValueAccessor, NG_VALUE_ACCESSOR, } from '@angular/forms'; import { AfricaniesIconComponent, type IconName } from '@africanies/africanies-icons'; import { ButtonComponent } from '../../button/button.component'; import { ModalService } from '../../overlay/modal.service'; import { FORM_ERROR_CLASS, FORM_HINT_CLASS, FORM_LABEL_CLASS, } from '../form-field.classes'; import { CameraCaptureDialogComponent } from './camera-capture.dialog'; import { acceptLabels, fileExtensionLabel, fileMatchesAccept, } from './file-accept'; import { FilePreviewDialogComponent } from './file-preview.dialog'; /** * Visual layout for {@link FileUploadComponent}. * * - `dropzone` — large drag target with icon, copy, and accept chips (default) * - `button` — compact trigger buttons inside a bordered panel * - `compact` — single-row inline control for dense forms */ export type FileUploadVariant = 'dropzone' | 'button' | 'compact'; /** * One selected file with optional image preview metadata. */ export interface FileUploadResult { /** Original {@link File} from the picker / camera / drop. */ file: File; /** * Object URL for image previews only (`URL.createObjectURL`). * Non-images use `null` — revoke on destroy via the component. */ previewUrl: string | null; /** True when `file.type` starts with `image/`. */ isImage: boolean; } /** * Server / previously uploaded file shown in the list before the user picks * replacements. `previewUrl` is a signed URL or data URI — the component does * **not** revoke it (the host owns its lifetime). */ export interface FileUploadExistingFile { /** Display name in the file row. */ name: string; /** Image / document preview URL (signed URL or data URI). */ previewUrl: string; /** Defaults to whether {@link previewUrl} looks like an image. */ isImage?: boolean; /** Optional host id (e.g. `photo_ref`) echoed in {@link FileUploadComponent.existingChange}. */ id?: string; } type FileUploadListRow = | { kind: 'existing'; item: FileUploadExistingFile; index: number } | { kind: 'local'; item: FileUploadResult; index: number }; let nextFileUploadId = 0; /** * File / camera / drag-and-drop picker following the shared AFRICANIES form field pattern. * * **Prefix/suffix slots are N/A** — drop zone / trigger set, not a text field. * * Allowed types come from the native `accept` attribute (e.g. `image/*,.pdf`). * Invalid picks are skipped and a short reject message is shown. Object URLs * are revoked in `ngOnDestroy` and when items are removed. Each added file * has an underlined **View** action that opens a larger preview. * * Prefill previously uploaded files with {@link existing} so edit forms reuse * the same list UI as fresh picks: * * ```html * * ``` * * **Camera** opens a live `getUserMedia` modal when overlays are registered; * otherwise falls back to ``. * * @example * ```html * * ``` */ @Component({ selector: 'africanies-file-upload', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [AfricaniesIconComponent, ButtonComponent, NgTemplateOutlet], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => FileUploadComponent), multi: true, }, ], host: { class: 'block', }, template: `
@if (label()) { {{ label() }} } @switch (variant()) { @case ('button') {
@if (allowFileSelect()) { } @if (allowCamera()) { } @if (acceptChips().length) {
@for (chip of acceptChips(); track chip) { {{ chip }} }
}

{{ dropHint() }}

} @case ('compact') {

{{ dropTitle() }}

@if (acceptChips().length) {
@for (chip of acceptChips(); track chip) { {{ chip }} }
}
@if (allowFileSelect()) { } @if (allowCamera()) { }
} @default {

{{ dropTitle() }}

{{ dropHint() }}

@if (acceptChips().length) {
@for (chip of acceptChips(); track chip) { {{ chip }} }
}
@if (allowFileSelect()) { } @if (allowCamera()) { }
} } @if (rejectMessage(); as reject) {

{{ reject }}

} @if (error(); as err) {

{{ err }}

} @else if (hint(); as h) {

{{ h }}

}
@if (listRows().length) {
    @for (row of listRows(); track trackRow(row)) {
  • @if (row.kind === 'existing') { @if (row.item.isImage && row.item.previewUrl) { } @else {
    }

    {{ row.item.name }}

    On file ·

    } @else { @if (row.item.isImage && row.item.previewUrl) { } @else {
    @if (extensionOf(row.item.file); as ext) { {{ ext }} }
    }

    {{ row.item.file.name }}

    {{ formatSize(row.item.file.size) }} @if (!row.item.isImage) { · {{ kindLabel(row.item.file) }} } ·

    }
  • }
}
`, }) /** * File / camera / drag-and-drop picker following the shared AFRICANIES form field pattern. */ export class FileUploadComponent implements ControlValueAccessor, OnDestroy { private readonly destroyRef = inject(DestroyRef); /** Optional so the control still works when overlays are not registered. */ private readonly modal = inject(ModalService, { optional: true }); private readonly fileInput = viewChild>('fileInput'); private readonly cameraInput = viewChild>('cameraInput'); protected readonly controlId = `africanies-file-upload-${++nextFileUploadId}`; protected readonly hintId = `${this.controlId}-hint`; protected readonly errorId = `${this.controlId}-error`; protected readonly labelClass = FORM_LABEL_CLASS; protected readonly hintClass = FORM_HINT_CLASS; protected readonly errorClass = FORM_ERROR_CLASS; protected readonly chipClass = 'inline-flex items-center rounded-full border border-neutral-300 bg-background-welcome px-2 py-0.5 text-caption font-medium text-neutral-600 dark:border-white/20 dark:bg-white/10 dark:text-neutral-300'; /** Visible field label. */ readonly label = input(''); /** Helper copy. Hidden while {@link error} is set. */ readonly hint = input(undefined); /** Field-level validation message. Distinct from `ErrorStateComponent`. */ readonly error = input(null); /** * Layout variant. Defaults to `dropzone`. */ readonly variant = input('dropzone'); /** * Passed to the native file input `accept` (e.g. `image/*,.pdf`). * Also drives accept chips and drop/picker filtering. */ readonly accept = input(''); /** Show the file-picker trigger. Defaults to true. */ readonly allowFileSelect = input(true, { transform: booleanAttribute }); /** * Show a camera trigger. Opens a live `getUserMedia` modal when * {@link ModalService} is available; otherwise falls back to a hidden * ``. Defaults to true. */ readonly allowCamera = input(true, { transform: booleanAttribute }); /** Allow selecting more than one file. Defaults to false. */ readonly multiple = input(false, { transform: booleanAttribute }); /** * Emitted whenever the selection changes (after object URLs are created for * images). */ readonly filesSelected = output(); /** * Previously uploaded files to show in the list (signed URL / data URI). * Resets the visible existing rows when the bound array identity or contents * change (e.g. switching tabs on an edit form). */ readonly existing = input([]); /** * Emitted when the user removes an existing row (or a new single-file pick * clears them). Does not fire for local {@link filesSelected} picks alone. */ readonly existingChange = output(); /** Host disable flag (in addition to CVA). */ readonly disabledInput = input(false, { alias: 'disabled', transform: booleanAttribute, }); protected readonly results = signal([]); /** Writable mirror of {@link existing} so removals can update before parent rebinds. */ protected readonly existingShown = linkedSignal(() => this.existing().map((row) => normalizeExistingFile(row)), ); protected readonly listRows = computed((): FileUploadListRow[] => [ ...this.existingShown().map((item, index) => ({ kind: 'existing' as const, item, index, })), ...this.results().map((item, index) => ({ kind: 'local' as const, item, index, })), ]); protected readonly cvaDisabled = signal(false); protected readonly dragDepth = signal(0); protected readonly rejectMessage = signal(null); protected readonly disabled = computed( () => this.disabledInput() || this.cvaDisabled(), ); protected readonly dragging = computed(() => this.dragDepth() > 0); protected readonly acceptChips = computed(() => acceptLabels(this.accept())); protected readonly chooseLabel = computed(() => this.multiple() ? 'Choose files' : 'Choose file', ); protected readonly dropTitle = computed(() => this.multiple() ? 'Drag & drop files here' : 'Drag & drop a file here', ); protected readonly dropHint = computed(() => this.multiple() ? 'or click to browse — multiple files allowed' : 'or click to browse — one file only', ); protected readonly describedBy = computed(() => { if (this.error()) { return this.errorId; } if (this.hint()) { return this.hintId; } return null; }); protected readonly panelClass = computed(() => { let classes = 'rounded-md border border-dashed border-neutral-400 bg-background-welcome p-4 transition-colors dark:border-white/20 dark:bg-white/[0.03]'; if (this.dragging()) { classes += ' border-ink bg-white dark:border-white dark:bg-white/10 ring-2 ring-ink/10 dark:ring-white/20'; } if (this.error()) { classes += ' !border-danger dark:!border-danger'; } if (this.disabled()) { classes += ' cursor-not-allowed opacity-50'; } return classes; }); protected readonly dropzoneClass = computed(() => { let classes = 'rounded-lg border-2 border-dashed border-neutral-400 bg-background-welcome px-4 py-8 transition-colors dark:border-white/20 dark:bg-white/[0.03]'; if (!this.disabled()) { classes += ' cursor-pointer hover:border-neutral-600 dark:hover:border-white/40'; } if (this.dragging()) { classes += ' border-ink bg-white dark:border-white dark:bg-white/10 ring-2 ring-ink/10 dark:ring-white/20'; } if (this.error()) { classes += ' !border-danger dark:!border-danger'; } if (this.disabled()) { classes += ' cursor-not-allowed opacity-50'; } return classes; }); protected readonly compactClass = computed(() => { let classes = 'flex items-center gap-3 rounded-md border border-dashed border-neutral-400 bg-background-welcome px-3 py-2.5 transition-colors dark:border-white/20 dark:bg-white/[0.03]'; if (this.dragging()) { classes += ' border-ink bg-white dark:border-white dark:bg-white/10 ring-2 ring-ink/10 dark:ring-white/20'; } if (this.error()) { classes += ' !border-danger dark:!border-danger'; } if (this.disabled()) { classes += ' cursor-not-allowed opacity-50'; } return classes; }); protected readonly fileRowClass = computed( () => 'flex items-center gap-3 rounded-md border border-border bg-white p-2 dark:border-white/15 dark:bg-white/[0.04]', ); private onChange: (value: FileUploadResult[]) => void = () => undefined; private onTouched: () => void = () => undefined; private rejectClearTimer: ReturnType | null = null; constructor() { this.destroyRef.onDestroy(() => this.clearRejectTimer()); } /** * Revokes any outstanding image object URLs to avoid leaks when this control * handles many files over its lifetime. */ ngOnDestroy(): void { this.revokeAll(this.results()); this.clearRejectTimer(); } /** @param value - Prior results from the form model (URLs should already exist). */ writeValue(value: FileUploadResult[] | null): void { this.revokeAll(this.results()); this.results.set(value ?? []); } /** @param fn - Files change callback. */ registerOnChange(fn: (value: FileUploadResult[]) => void): void { this.onChange = fn; } /** @param fn - Touched callback. */ registerOnTouched(fn: () => void): void { this.onTouched = fn; } /** @param isDisabled - Blocks picking when true. */ setDisabledState(isDisabled: boolean): void { this.cvaDisabled.set(isDisabled); } protected openFilePicker(): void { if (this.disabled()) { return; } this.fileInput()?.nativeElement.click(); } protected openCamera(): void { if (this.disabled()) { return; } const canUseLiveCamera = !!this.modal && typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia; if (!canUseLiveCamera || !this.modal) { this.cameraInput()?.nativeElement.click(); return; } this.modal .open(CameraCaptureDialogComponent, { dismissible: true }) .afterClosed() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((file) => { if (file) { this.commitFiles([file]); } }); } protected onDropzoneActivate(event: Event): void { if (this.disabled() || !this.allowFileSelect()) { return; } event.preventDefault(); this.openFilePicker(); } protected onDragEnter(event: DragEvent): void { if (this.disabled() || !this.hasFilePayload(event)) { return; } event.preventDefault(); this.dragDepth.update((n) => n + 1); } protected onDragOver(event: DragEvent): void { if (this.disabled() || !this.hasFilePayload(event)) { return; } event.preventDefault(); if (event.dataTransfer) { event.dataTransfer.dropEffect = 'copy'; } } protected onDragLeave(event: DragEvent): void { if (this.disabled()) { return; } event.preventDefault(); this.dragDepth.update((n) => Math.max(0, n - 1)); } protected onDrop(event: DragEvent): void { event.preventDefault(); this.dragDepth.set(0); if (this.disabled()) { return; } const files = Array.from(event.dataTransfer?.files ?? []); if (!files.length) { return; } this.commitFiles(files); } protected onNativeChange(event: Event): void { const inputEl = event.target as HTMLInputElement; const files = Array.from(inputEl.files ?? []); inputEl.value = ''; if (!files.length) { return; } this.commitFiles(files); } protected openPreview(item: FileUploadResult): void { if (this.modal) { this.modal.open(FilePreviewDialogComponent, { dismissible: true, data: { file: item.file, name: item.file.name, previewUrl: item.previewUrl, isImage: item.isImage, size: item.file.size, }, size: 'xl', }); return; } // Without a modal host, only images can be shown (object URL in a new tab). if (!item.isImage && !item.file.type.startsWith('image/')) { return; } const url = item.previewUrl ?? URL.createObjectURL(item.file); window.open(url, '_blank', 'noopener'); if (!item.previewUrl) { window.setTimeout(() => URL.revokeObjectURL(url), 60_000); } } protected openExistingPreview(item: FileUploadExistingFile): void { if (this.modal) { this.modal.open(FilePreviewDialogComponent, { dismissible: true, data: { file: null, name: item.name, previewUrl: item.previewUrl, isImage: item.isImage === true, size: null, }, size: 'xl', }); return; } if (item.isImage) { window.open(item.previewUrl, '_blank', 'noopener'); } } protected removeAt(index: number): void { if (this.disabled()) { return; } const current = this.results(); const target = current[index]; if (!target) { return; } this.revokePreviewUrl(target.previewUrl); const next = current.filter((_, i) => i !== index); this.results.set(next); this.filesSelected.emit(next); this.onChange(next); this.onTouched(); } protected removeExistingAt(index: number): void { if (this.disabled()) { return; } const next = this.existingShown().filter((_, i) => i !== index); this.existingShown.set(next); this.existingChange.emit(next); this.onTouched(); } protected trackRow(row: FileUploadListRow): string { if (row.kind === 'existing') { return `existing:${row.item.id ?? row.item.previewUrl}:${row.index}`; } return `local:${this.trackResult(row.item)}`; } protected trackResult(item: FileUploadResult): string { return `${item.file.name}:${item.file.size}:${item.file.lastModified}`; } 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 extensionOf(file: File): string { return fileExtensionLabel(file.name); } protected nonImageIcon(file: File): IconName { const type = (file.type || '').toLowerCase(); const name = file.name.toLowerCase(); if (type === 'application/pdf' || name.endsWith('.pdf')) { return 'file-text'; } if ( type.includes('zip') || name.endsWith('.zip') || name.endsWith('.rar') ) { return 'file-zip'; } if (type.startsWith('video/') || name.match(/\.(mp4|mov|webm)$/)) { return 'file-movie'; } if (type.startsWith('audio/') || name.match(/\.(mp3|wav|ogg)$/)) { return 'file-sound'; } if ( type.includes('sheet') || type.includes('excel') || name.match(/\.(xlsx|xls|csv)$/) ) { return 'file-table'; } if (type.startsWith('text/') || name.match(/\.(txt|md|csv)$/)) { return 'file-text-o'; } if (name.match(/\.(js|ts|json|html|css|xml)$/)) { return 'file-code'; } return 'file'; } protected kindLabel(file: File): string { const ext = fileExtensionLabel(file.name); if (ext) { return `${ext} file`; } if (file.type) { return file.type; } return 'File'; } private commitFiles(files: File[]): void { const accept = this.accept(); const accepted = files.filter((f) => fileMatchesAccept(f, accept)); const rejectedCount = files.length - accepted.length; if (rejectedCount > 0) { const chips = acceptLabels(accept); const allowed = chips.length ? chips.join(', ') : 'the allowed types'; this.showReject( rejectedCount === 1 ? `1 file was skipped. Only ${allowed} allowed.` : `${rejectedCount} files were skipped. Only ${allowed} allowed.`, ); } else { this.rejectMessage.set(null); } if (!accepted.length) { this.onTouched(); return; } let incoming = accepted; if (!this.multiple()) { incoming = accepted.slice(0, 1); } const mapped = incoming.map((file) => this.toResult(file)); const next = this.multiple() ? [...this.results(), ...mapped] : mapped; if (!this.multiple()) { this.revokeAll(this.results()); } // Fresh picks replace server-backed rows (edit forms prefill via `existing`). if (this.existingShown().length) { this.existingShown.set([]); this.existingChange.emit([]); } this.results.set(next); this.filesSelected.emit(next); this.onChange(next); this.onTouched(); } private showReject(message: string): void { this.rejectMessage.set(message); this.clearRejectTimer(); this.rejectClearTimer = setTimeout(() => { this.rejectMessage.set(null); this.rejectClearTimer = null; }, 6000); } private clearRejectTimer(): void { if (this.rejectClearTimer) { clearTimeout(this.rejectClearTimer); this.rejectClearTimer = null; } } private hasFilePayload(event: DragEvent): boolean { const types = event.dataTransfer?.types; if (!types) { return false; } return Array.from(types).includes('Files'); } private toResult(file: File): FileUploadResult { const isImage = file.type.startsWith('image/'); return { file, isImage, previewUrl: isImage ? URL.createObjectURL(file) : null, }; } private revokeAll(items: FileUploadResult[]): void { for (const item of items) { this.revokePreviewUrl(item.previewUrl); } } /** Only revoke blob: object URLs — never http(s) / data URIs from the host. */ private revokePreviewUrl(url: string | null): void { if (url?.startsWith('blob:')) { URL.revokeObjectURL(url); } } } function normalizeExistingFile( row: FileUploadExistingFile, ): FileUploadExistingFile { const previewUrl = row.previewUrl.trim(); const name = row.name.trim() || 'Existing file'; const isImage = row.isImage ?? (previewUrl.startsWith('data:image/') || /\.(jpe?g|png|gif|webp|bmp|svg)(\?|#|$)/i.test(previewUrl)); return { ...row, name, previewUrl, isImage, }; }