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()) {
{{ chooseLabel() }}
}
@if (allowCamera()) {
Camera
}
@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()) {
Browse
}
@if (allowCamera()) {
}
}
@default {
{{ dropTitle() }}
{{ dropHint() }}
@if (acceptChips().length) {
@for (chip of acceptChips(); track chip) {
{{ chip }}
}
}
@if (allowFileSelect()) {
{{ chooseLabel() }}
}
@if (allowCamera()) {
Camera
}
}
}
@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
·
View
} @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) }}
}
·
View
}
}
}
`,
})
/**
* 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,
};
}