feat(front-end): multiple file upload
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { Component, input, output, signal, effect } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
@@ -9,6 +9,11 @@ import { AppButtonComponent } from '../../../../shared/components/app-button/app
|
||||
import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component';
|
||||
import { QuoteRequest } from '../../services/quote-estimator.service';
|
||||
|
||||
interface FormItem {
|
||||
file: File;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-upload-form',
|
||||
standalone: true,
|
||||
@@ -20,28 +25,53 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
|
||||
@if (selectedFile()) {
|
||||
<div class="viewer-wrapper">
|
||||
<app-stl-viewer [file]="selectedFile()"></app-stl-viewer>
|
||||
<button type="button" class="btn-clear" (click)="clearFiles()">
|
||||
X
|
||||
<button type="button" class="btn-clear-viewer" (click)="selectedFile.set(null)">
|
||||
Close Viewer
|
||||
</button>
|
||||
</div>
|
||||
<div class="file-list">
|
||||
@for (f of files(); track f.name) {
|
||||
<div class="file-item" [class.active]="f === selectedFile()" (click)="selectFile(f)">
|
||||
{{ f.name }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<app-dropzone
|
||||
}
|
||||
|
||||
<!-- Dropzone always available if we want to add more, or hide if list not empty? -->
|
||||
<!-- User wants to "add more", so dropzone should remain or be available -->
|
||||
<app-dropzone
|
||||
[label]="'CALC.UPLOAD_LABEL' | translate"
|
||||
[subtext]="'CALC.UPLOAD_SUB' | translate"
|
||||
[accept]="acceptedFormats"
|
||||
[multiple]="true"
|
||||
(filesDropped)="onFilesDropped($event)">
|
||||
</app-dropzone>
|
||||
</app-dropzone>
|
||||
|
||||
<!-- New File List with Details -->
|
||||
@if (items().length > 0) {
|
||||
<div class="items-list">
|
||||
@for (item of items(); track item.file.name; let i = $index) {
|
||||
<div class="file-row" [class.active]="item.file === selectedFile()">
|
||||
<div class="file-info" (click)="selectFile(item.file)">
|
||||
<span class="file-name">{{ item.file.name }}</span>
|
||||
<span class="file-size">{{ (item.file.size / 1024 / 1024) | number:'1.1-2' }} MB</span>
|
||||
</div>
|
||||
|
||||
<div class="file-actions">
|
||||
<div class="qty-control">
|
||||
<label>Qtà</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
[value]="item.quantity"
|
||||
(change)="updateItemQuantity(i, $event)"
|
||||
class="qty-input">
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-remove" (click)="removeItem(i)" title="Remove file">
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (form.get('files')?.invalid && form.get('files')?.touched) {
|
||||
@if (items().length === 0 && form.get('itemsTouched')?.value) {
|
||||
<div class="error-msg">{{ 'CALC.ERR_FILE_REQUIRED' | translate }}</div>
|
||||
}
|
||||
</div>
|
||||
@@ -60,11 +90,7 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
|
||||
></app-select>
|
||||
</div>
|
||||
|
||||
<app-input
|
||||
formControlName="quantity"
|
||||
type="number"
|
||||
[label]="'CALC.QUANTITY' | translate"
|
||||
></app-input>
|
||||
<!-- Global quantity removed, now per item -->
|
||||
|
||||
@if (mode() === 'advanced') {
|
||||
<div class="grid">
|
||||
@@ -113,7 +139,7 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
|
||||
|
||||
<app-button
|
||||
type="submit"
|
||||
[disabled]="form.invalid || loading()"
|
||||
[disabled]="form.invalid || items().length === 0 || loading()"
|
||||
[fullWidth]="true">
|
||||
{{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }}
|
||||
</app-button>
|
||||
@@ -127,40 +153,92 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
|
||||
.error-msg { color: var(--color-danger-500); font-size: 0.875rem; margin-top: var(--space-2); text-align: center; }
|
||||
|
||||
.viewer-wrapper { position: relative; margin-bottom: var(--space-4); }
|
||||
.btn-clear {
|
||||
.btn-clear-viewer {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(0,0,0,0.5);
|
||||
color: white;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
&:hover { background: rgba(0,0,0,0.7); }
|
||||
}
|
||||
|
||||
.file-list {
|
||||
.items-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--space-2);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.file-item {
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
.file-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
background: var(--color-neutral-100);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
&:hover { background: var(--color-neutral-200); }
|
||||
transition: all 0.2s;
|
||||
|
||||
&.active {
|
||||
border-color: var(--color-brand);
|
||||
background: rgba(250, 207, 10, 0.1);
|
||||
font-weight: 600;
|
||||
border-color: var(--color-brand);
|
||||
background: rgba(250, 207, 10, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-name { font-weight: 500; font-size: 0.9rem; color: var(--color-text); }
|
||||
.file-size { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.qty-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
label { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||
}
|
||||
|
||||
.qty-input {
|
||||
width: 50px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
&:focus { outline: none; border-color: var(--color-brand); }
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent; // var(--color-neutral-200);
|
||||
color: var(--color-text-muted); // var(--color-danger-500);
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-danger-100);
|
||||
color: var(--color-danger-500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,9 +263,6 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
|
||||
/* Progress Bar */
|
||||
.progress-container {
|
||||
margin-bottom: var(--space-3);
|
||||
/* padding: var(--space-2); */
|
||||
/* background: var(--color-neutral-100); */
|
||||
/* border-radius: var(--radius-md); */
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -206,7 +281,6 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
|
||||
width: 0%;
|
||||
transition: width 0.2s ease-out;
|
||||
}
|
||||
.progress-text { font-size: 0.875rem; color: var(--color-text-muted); }
|
||||
`]
|
||||
})
|
||||
export class UploadFormComponent {
|
||||
@@ -217,7 +291,7 @@ export class UploadFormComponent {
|
||||
|
||||
form: FormGroup;
|
||||
|
||||
files = signal<File[]>([]);
|
||||
items = signal<FormItem[]>([]);
|
||||
selectedFile = signal<File | null>(null);
|
||||
|
||||
materials = [
|
||||
@@ -252,10 +326,9 @@ export class UploadFormComponent {
|
||||
|
||||
constructor(private fb: FormBuilder) {
|
||||
this.form = this.fb.group({
|
||||
files: [[], Validators.required],
|
||||
itemsTouched: [false], // Hack to track touched state for custom items list
|
||||
material: ['PLA', Validators.required],
|
||||
quality: ['Standard', Validators.required],
|
||||
quantity: [1, [Validators.required, Validators.min(1)]],
|
||||
notes: [''],
|
||||
// Advanced fields
|
||||
color: ['Black'],
|
||||
@@ -267,14 +340,14 @@ export class UploadFormComponent {
|
||||
|
||||
onFilesDropped(newFiles: File[]) {
|
||||
const MAX_SIZE = 200 * 1024 * 1024; // 200MB
|
||||
const validFiles: File[] = [];
|
||||
const validItems: FormItem[] = [];
|
||||
let hasError = false;
|
||||
|
||||
for (const file of newFiles) {
|
||||
if (file.size > MAX_SIZE) {
|
||||
hasError = true;
|
||||
} else {
|
||||
validFiles.push(file);
|
||||
validItems.push({ file, quantity: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,32 +355,55 @@ export class UploadFormComponent {
|
||||
alert("Alcuni file superano il limite di 200MB e non sono stati aggiunti.");
|
||||
}
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
this.files.update(current => [...current, ...validFiles]);
|
||||
this.form.patchValue({ files: this.files() });
|
||||
this.form.get('files')?.markAsTouched();
|
||||
this.selectedFile.set(validFiles[validFiles.length - 1]);
|
||||
if (validItems.length > 0) {
|
||||
this.items.update(current => [...current, ...validItems]);
|
||||
this.form.get('itemsTouched')?.setValue(true);
|
||||
// Auto select last added
|
||||
this.selectedFile.set(validItems[validItems.length - 1].file);
|
||||
}
|
||||
}
|
||||
|
||||
selectFile(file: File) {
|
||||
this.selectedFile.set(file);
|
||||
if (this.selectedFile() === file) {
|
||||
// toggle off? no, keep active
|
||||
} else {
|
||||
this.selectedFile.set(file);
|
||||
}
|
||||
}
|
||||
|
||||
clearFiles() {
|
||||
this.files.set([]);
|
||||
this.selectedFile.set(null);
|
||||
this.form.patchValue({ files: [] });
|
||||
updateItemQuantity(index: number, event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
let val = parseInt(input.value, 10);
|
||||
if (isNaN(val) || val < 1) val = 1;
|
||||
|
||||
this.items.update(current => {
|
||||
const updated = [...current];
|
||||
updated[index] = { ...updated[index], quantity: val };
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
removeItem(index: number) {
|
||||
this.items.update(current => {
|
||||
const updated = [...current];
|
||||
const removed = updated.splice(index, 1)[0];
|
||||
if (this.selectedFile() === removed.file) {
|
||||
this.selectedFile.set(null);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (this.form.valid) {
|
||||
if (this.form.valid && this.items().length > 0) {
|
||||
this.submitRequest.emit({
|
||||
items: this.items(), // Pass the items array
|
||||
...this.form.value,
|
||||
mode: this.mode()
|
||||
});
|
||||
} else {
|
||||
this.form.markAllAsTouched();
|
||||
this.form.get('itemsTouched')?.setValue(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user