Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,4 @@ src/assets/components/themes

.playwright-mcp/*


.claude/*
293 changes: 293 additions & 0 deletions src/lib/components/fileupload/fileupload.component.ts
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Надо добавить стори с формой, где компонент используется как formControl

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MoskvaVoronezh реализован ControlValueAccessor, добавлена стори с реактивной формой 4458850

Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectorRef, inject, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { FileUpload } from 'primeng/fileupload';
import { ProgressBar } from 'primeng/progressbar';
import { Message } from 'primeng/message';
import { PrimeTemplate } from 'primeng/api';
import { ExtraButtonComponent } from '../button/button.component';

@Component({
selector: 'fileupload',
standalone: true,
imports: [FileUpload, ProgressBar, Message, PrimeTemplate, ExtraButtonComponent],
host: { style: 'display: contents' },
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => FileUploadComponent), multi: true }],
template: `
<p-fileupload
#fuRef
[name]="name"
[url]="url"
[multiple]="multiple"
[accept]="accept"
[maxFileSize]="maxFileSize"
[fileLimit]="fileLimit"
[disabled]="disabled"
[customUpload]="true"
[auto]="false"
[invalidFileSizeMessageSummary]="invalidFileSizeMessageSummary"
[invalidFileSizeMessageDetail]="invalidFileSizeMessageDetail"
[invalidFileTypeMessageSummary]="invalidFileTypeMessageSummary"
[invalidFileTypeMessageDetail]="invalidFileTypeMessageDetail"
[invalidFileLimitMessageSummary]="invalidFileLimitMessageSummary"
[invalidFileLimitMessageDetail]="invalidFileLimitMessageDetail"
(onSelect)="onSelectedFiles($event)"
(uploadHandler)="onUploader($event)"
(onRemove)="onRemoveEvent.emit($event)"
(onClear)="onClearEvent.emit()"
(onError)="onError.emit($event)"
>
<ng-template pTemplate="header" let-uploadCallback="uploadCallback" let-clearCallback="clearCallback">
<div class="fu-header" [attr.data-ref]="storeCallbacks(uploadCallback, clearCallback)">
<div class="fu-dropzone"
[class.fu-dropzone--disabled]="disabled"
(click)="onChooseClick()"
(dragover)="$event.preventDefault()"
(drop)="onDrop($event)">
<i class="ti ti-upload fu-dropzone__icon"></i>
<div class="fu-dropzone__info">
<span class="fu-dropzone__title">{{ dropzoneTitle }}</span>
<span class="fu-dropzone__caption">
<i class="ti ti-info-circle"></i>
{{ dropzoneCaption }}
</span>
</div>
</div>
</div>
</ng-template>

<ng-template pTemplate="content"
let-removeFileCallback="removeFileCallback" let-removeUploadedFileCallback="removeUploadedFileCallback">
<div class="fu-content">
@if (isUploading) {
<p-progressBar [value]="totalSizePercent" [showValue]="false"></p-progressBar>
}
@if (uploadSuccess) {
<p-message severity="success" icon="ti ti-circle-check" [closable]="true" (onClose)="uploadSuccess = false">
Файлы успешно загружены
</p-message>
}
@if (selectedFiles.length > 0) {
<div class="fu-file-list">
@for (file of selectedFiles; track file.name + file.size; let i = $index) {
<div class="fu-file-card">
<div class="fu-file-card__wrap">
@if (isImage(file)) {
<img [src]="file.objectURL" [alt]="file.name" class="fu-file-card__thumbnail" />
} @else {
<i class="ti ti-file fu-file-card__icon"></i>
}
<div class="fu-file-card__info">
<span class="fu-file-card__name">{{ file.name }}</span>
<span class="fu-file-card__size">
<i class="ti ti-info-circle"></i>
{{ formatSize(file.size) }}
</span>
</div>
</div>
<extra-button icon="ti ti-trash" variant="text" [rounded]="true" size="small" [iconOnly]="true"
(click)="onRemoveFile(file, removeFileCallback, i)"></extra-button>
</div>
}
</div>
}
@if (uploadedFiles.length > 0) {
<div class="fu-file-list">
@for (file of uploadedFiles; track file.name + file.size; let i = $index) {
<div class="fu-file-card fu-file-card--uploaded">
<div class="fu-file-card__wrap">
<i class="ti ti-file-check fu-file-card__icon"></i>
<div class="fu-file-card__info">
<span class="fu-file-card__name">{{ file.name }}</span>
<span class="fu-file-card__size">Загружено</span>
</div>
</div>
<extra-button icon="ti ti-trash" variant="text" [rounded]="true" size="small" [iconOnly]="true"
(click)="removeUploadedFileCallback(i)"></extra-button>
</div>
}
</div>
}
@if (selectedFiles.length > 0 || uploadedFiles.length > 0) {
<div class="fu-footer">
<extra-button label="Отправить" [disabled]="!selectedFiles.length" (click)="uploadCb?.()"></extra-button>
<extra-button label="Очистить" severity="danger" variant="text"
[disabled]="!selectedFiles.length && !uploadedFiles.length" (click)="onClearUpload()"></extra-button>
</div>
}
</div>
</ng-template>
</p-fileupload>
`,
})
export class FileUploadComponent implements ControlValueAccessor {
private el = inject(ElementRef);
private cdr = inject(ChangeDetectorRef);
@ViewChild('fuRef') fuRef!: FileUpload;

@Input() name = 'files[]';
@Input() url = '/api/upload';
@Input() multiple = true;
@Input() accept = 'image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document';
@Input() maxFileSize = 1000000;
@Input() fileLimit: number | undefined = undefined;
@Input() disabled = false;
@Input() dropzoneTitle = 'Чтобы загрузить файлы кликните или перетащите их в эту область';
@Input() dropzoneCaption = 'Можно загрузить не более 10 файлов размером 1 MB';

@Input() invalidFileSizeMessageSummary = '{0}: Некорректный размер файла';
@Input() invalidFileSizeMessageDetail = 'Максимальный размер — {0}';
@Input() invalidFileTypeMessageSummary = '{0}: Некорректный тип файла';
@Input() invalidFileTypeMessageDetail = 'Допустимые типы: {0}';
@Input() invalidFileLimitMessageSummary = 'Превышен лимит файлов';
@Input() invalidFileLimitMessageDetail = 'Максимум: {0}';

@Output() onSelectEvent = new EventEmitter<any>();
@Output() onRemoveEvent = new EventEmitter<any>();
@Output() onClearEvent = new EventEmitter<void>();
@Output() onError = new EventEmitter<any>();
@Output() onUpload = new EventEmitter<any>();

selectedFiles: any[] = [];
uploadedFiles: any[] = [];
totalSize = 0;
totalSizePercent = 0;
uploadSuccess = false;
isUploading = false;

private uploadCbRef: (() => void) | null = null;
private clearCbRef: (() => void) | null = null;
private onChange: (files: File[]) => void = () => {};
private onTouched: () => void = () => {};

writeValue(files: File[]): void {
this.selectedFiles = files ?? [];
this.cdr.markForCheck();
}

registerOnChange(fn: (files: File[]) => void): void {
this.onChange = fn;
}

registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}

setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
this.cdr.markForCheck();
}

get uploadCb(): (() => void) | null {
return this.uploadCbRef;
}

storeCallbacks(upload: () => void, clear: () => void): string {
this.uploadCbRef = upload;
this.clearCbRef = clear;
return '';
}

onDrop(event: DragEvent): void {
event.preventDefault();
const files = event.dataTransfer?.files;
if (!files?.length || this.disabled) return;
const accepted = this.filterFilesByAccept(Array.from(files));
if (!accepted.length) return;
const dt = new DataTransfer();
accepted.forEach(f => dt.items.add(f));
this.fuRef.onFileSelect({ target: { files: dt.files } } as unknown as Event);
}

private filterFilesByAccept(files: File[]): File[] {
if (!this.accept) return files;
const types = this.accept.split(',').map(t => t.trim());
return files.filter(file =>
types.some(type => {
if (type.includes('*')) {
return file.type.startsWith(type.replace('*', ''));
}
if (type.startsWith('.')) {
return file.name.toLowerCase().endsWith(type.toLowerCase());
}
return file.type === type;
}),
);
}

onChooseClick(): void {
const input = this.el.nativeElement.querySelector('input[type="file"]') as HTMLInputElement;
input?.click();
}

isImage(file: File): boolean {
return file.type.startsWith('image/');
}

formatSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(3)) + ' ' + sizes[i];
}

onSelectedFiles(event: any): void {
this.selectedFiles = [...(this.fuRef?.files || [])];
this.totalSize = this.selectedFiles.reduce((acc, f) => acc + f.size, 0);
this.uploadSuccess = false;
this.isUploading = this.selectedFiles.length > 0;
this.totalSizePercent = 0;

let progress = 0;
const interval = setInterval(() => {
progress += 10;
this.totalSizePercent = Math.min(progress, 100);
if (progress >= 100) clearInterval(interval);
this.cdr.markForCheck();
}, 40);

this.onChange(this.selectedFiles);
this.onTouched();
this.cdr.detectChanges();
this.onSelectEvent.emit(event);
}

onUploader(event: any): void {
setTimeout(() => {
this.clearCbRef?.();
this.selectedFiles = [];
this.uploadedFiles = [...(event.files || [])];
this.totalSize = 0;
this.totalSizePercent = 0;
this.uploadSuccess = true;
this.isUploading = false;
this.onChange([]);
this.cdr.detectChanges();
}, 1500);
this.onUpload.emit(event);
}

onRemoveFile(file: File, removeFileCallback: (index: number) => void, index: number): void {
removeFileCallback(index);
this.selectedFiles = [...(this.fuRef?.files || [])];
this.totalSize -= file.size;
this.totalSizePercent = Math.min((this.totalSize / (this.maxFileSize || 1000000)) * 100, 100);
if (this.totalSize <= 0) {
this.isUploading = false;
}
this.onChange(this.selectedFiles);
this.cdr.detectChanges();
}

onClearUpload(): void {
this.clearCbRef?.();
this.selectedFiles = [];
this.uploadedFiles = [];
this.totalSize = 0;
this.totalSizePercent = 0;
this.uploadSuccess = false;
this.isUploading = false;
this.onChange([]);
this.cdr.detectChanges();
}
}
5 changes: 5 additions & 0 deletions src/lib/providers/prime-preset/map-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { carouselCss } from './tokens/components/carousel';
import { galleriaCss } from './tokens/components/galleria';
import { confirmDialogCss } from './tokens/components/confirm-dialog';
import { drawerCss } from './tokens/components/drawer';
import { fileuploadCss } from './tokens/components/fileupload';

const presetTokens: Preset<AuraBaseDesignTokens> = {
primitive: tokens.primitive as unknown as AuraBaseDesignTokens['primitive'],
Expand Down Expand Up @@ -108,6 +109,10 @@ const presetTokens: Preset<AuraBaseDesignTokens> = {
drawer: {
...(tokens.components.drawer as unknown as ComponentsDesignTokens['drawer']),
css: drawerCss
},
fileupload: {
...(tokens.components.fileupload as unknown as ComponentsDesignTokens['fileupload']),
css: fileuploadCss
}
} as ComponentsDesignTokens
};
Expand Down
Loading