diff --git a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java index d06fc11..15d1c40 100644 --- a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java +++ b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java @@ -4,10 +4,12 @@ import com.printcalculator.entity.CustomQuoteRequest; import com.printcalculator.entity.CustomQuoteRequestAttachment; import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; import com.printcalculator.repository.CustomQuoteRequestRepository; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; @@ -15,8 +17,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.OffsetDateTime; -import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.UUID; @RestController @@ -29,6 +31,23 @@ public class CustomQuoteRequestController { // TODO: Inject Storage Service private static final String STORAGE_ROOT = "storage_requests"; + private static final Set FORBIDDEN_COMPRESSED_EXTENSIONS = Set.of( + "zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "tbz2", "xz", "txz", "zst" + ); + private static final Set FORBIDDEN_COMPRESSED_MIME_TYPES = Set.of( + "application/zip", + "application/x-zip-compressed", + "application/x-rar-compressed", + "application/vnd.rar", + "application/x-7z-compressed", + "application/gzip", + "application/x-gzip", + "application/x-tar", + "application/x-bzip2", + "application/x-xz", + "application/zstd", + "application/x-zstd" + ); public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo, CustomQuoteRequestAttachmentRepository attachmentRepo, @@ -70,6 +89,13 @@ public class CustomQuoteRequestController { for (MultipartFile file : files) { if (file.isEmpty()) continue; + + if (isCompressedFile(file)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Compressed files are not allowed." + ); + } // Scan for virus clamAVService.scan(file.getInputStream()); @@ -120,8 +146,17 @@ public class CustomQuoteRequestController { if (filename == null) return "dat"; int i = filename.lastIndexOf('.'); if (i > 0) { - return filename.substring(i + 1); + return filename.substring(i + 1).toLowerCase(); } return "dat"; } + + private boolean isCompressedFile(MultipartFile file) { + String ext = getExtension(file.getOriginalFilename()); + if (FORBIDDEN_COMPRESSED_EXTENSIONS.contains(ext)) { + return true; + } + String mime = file.getContentType(); + return mime != null && FORBIDDEN_COMPRESSED_MIME_TYPES.contains(mime.toLowerCase()); + } } diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index e17af19..91f1b58 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,5 +1,5 @@ import { ApplicationConfig, provideZoneChangeDetection, importProvidersFrom } from '@angular/core'; -import { provideRouter, withComponentInputBinding, withViewTransitions } from '@angular/router'; +import { provideRouter, withComponentInputBinding, withInMemoryScrolling, withViewTransitions } from '@angular/router'; import { routes } from './app.routes'; import { provideHttpClient } from '@angular/common/http'; import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; @@ -8,7 +8,14 @@ import { provideTranslateHttpLoader, TranslateHttpLoader } from '@ngx-translate/ export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), - provideRouter(routes, withComponentInputBinding(), withViewTransitions()), + provideRouter( + routes, + withComponentInputBinding(), + withViewTransitions(), + withInMemoryScrolling({ + scrollPositionRestoration: 'top' + }) + ), provideHttpClient(), provideTranslateHttpLoader({ prefix: './assets/i18n/', @@ -24,4 +31,4 @@ export const appConfig: ApplicationConfig = { }) ) ] -}; \ No newline at end of file +}; diff --git a/frontend/src/app/features/about/about-page.component.html b/frontend/src/app/features/about/about-page.component.html index 9dd3f12..41adea1 100644 --- a/frontend/src/app/features/about/about-page.component.html +++ b/frontend/src/app/features/about/about-page.component.html @@ -1,12 +1,12 @@
- +

{{ 'ABOUT.EYEBROW' | translate }}

{{ 'ABOUT.TITLE' | translate }}

{{ 'ABOUT.SUBTITLE' | translate }}

- +

{{ 'ABOUT.HOW_TEXT' | translate }}

@@ -25,15 +25,15 @@
- Member 1 - Founder + Joe Küng + Studente Ingegneria Informatica
- Member 2 - Co-Founder + Matteo Caletti + Studente Ingegneria Elettronica
diff --git a/frontend/src/app/features/contact/components/contact-form/contact-form.component.html b/frontend/src/app/features/contact/components/contact-form/contact-form.component.html index 9793ba8..a43718a 100644 --- a/frontend/src/app/features/contact/components/contact-form/contact-form.component.html +++ b/frontend/src/app/features/contact/components/contact-form/contact-form.component.html @@ -51,7 +51,7 @@
+ [accept]="acceptedFormats">

{{ 'CONTACT.DROP_FILES' | translate }}

@@ -59,9 +59,12 @@
-
+ +
{{ 'CONTACT.FILE_TYPE_PDF' | translate }} {{ 'CONTACT.FILE_TYPE_3D' | translate }} + {{ 'CONTACT.FILE_TYPE_DOC' | translate }} + {{ 'CONTACT.FILE_TYPE_FILE' | translate }}
{{ file.file.name }}
diff --git a/frontend/src/app/features/contact/components/contact-form/contact-form.component.scss b/frontend/src/app/features/contact/components/contact-form/contact-form.component.scss index 76186ad..0df67df 100644 --- a/frontend/src/app/features/contact/components/contact-form/contact-form.component.scss +++ b/frontend/src/app/features/contact/components/contact-form/contact-form.component.scss @@ -114,6 +114,16 @@ app-input.col { width: 100%; } border-radius: var(--radius-sm); } +.preview-video { + width: 100%; + height: 100%; + object-fit: cover; + position: absolute; + top: 0; + left: 0; + border-radius: var(--radius-sm); +} + .file-icon { font-weight: 700; color: var(--color-text-muted); font-size: 0.8rem; } diff --git a/frontend/src/app/features/contact/components/contact-form/contact-form.component.ts b/frontend/src/app/features/contact/components/contact-form/contact-form.component.ts index 651208a..8aa19e6 100644 --- a/frontend/src/app/features/contact/components/contact-form/contact-form.component.ts +++ b/frontend/src/app/features/contact/components/contact-form/contact-form.component.ts @@ -1,4 +1,4 @@ -import { Component, signal, effect, inject } from '@angular/core'; +import { Component, signal, effect, inject, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; @@ -10,7 +10,7 @@ import { QuoteRequestService } from '../../../../core/services/quote-request.ser interface FilePreview { file: File; url?: string; - type: 'image' | 'pdf' | '3d' | 'other'; + type: 'image' | 'video' | 'pdf' | '3d' | 'document' | 'other'; } import { SuccessStateComponent } from '../../../../shared/components/success-state/success-state.component'; @@ -22,10 +22,11 @@ import { SuccessStateComponent } from '../../../../shared/components/success-sta templateUrl: './contact-form.component.html', styleUrl: './contact-form.component.scss' }) -export class ContactFormComponent { +export class ContactFormComponent implements OnDestroy { form: FormGroup; sent = signal(false); files = signal([]); + readonly acceptedFormats = '.jpg,.jpeg,.png,.webp,.gif,.bmp,.svg,.heic,.heif,.pdf,.stl,.step,.stp,.3mf,.obj,.iges,.igs,.dwg,.dxf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.rtf,.csv,.mp4,.mov,.avi,.mkv,.webm,.m4v,.wmv'; get isCompany(): boolean { return this.form.get('isCompany')?.value; @@ -96,14 +97,22 @@ export class ContactFormComponent { }); // Process files - const filePreviews: FilePreview[] = []; - pending.files.forEach(f => { - filePreviews.push({ file: f, type: this.getFileType(f) }); + const filePreviews: FilePreview[] = pending.files.map(f => { + const type = this.getFileType(f); + return { + file: f, + type, + url: this.shouldCreatePreview(type) ? URL.createObjectURL(f) : undefined + }; }); this.files.set(filePreviews); } } + ngOnDestroy(): void { + this.revokeAllPreviewUrls(); + } + setCompanyMode(isCompany: boolean) { this.form.patchValue({ isCompany }); } @@ -124,36 +133,52 @@ export class ContactFormComponent { handleFiles(newFiles: File[]) { const currentFiles = this.files(); - if (currentFiles.length + newFiles.length > 15) { + const blockedCompressed = newFiles.filter(file => this.isCompressedFile(file)); + if (blockedCompressed.length > 0) { + alert(this.translate.instant('CONTACT.ERR_COMPRESSED_FILES')); + } + + const allowedFiles = newFiles.filter(file => !this.isCompressedFile(file)); + if (allowedFiles.length === 0) return; + + if (currentFiles.length + allowedFiles.length > 15) { alert(this.translate.instant('CONTACT.ERR_MAX_FILES')); return; } - newFiles.forEach(file => { + allowedFiles.forEach(file => { const type = this.getFileType(file); - const preview: FilePreview = { file, type }; - - if (type === 'image') { - const reader = new FileReader(); - reader.onload = (e) => { - preview.url = e.target?.result as string; - this.files.update(files => [...files]); - }; - reader.readAsDataURL(file); - } + const preview: FilePreview = { + file, + type, + url: this.shouldCreatePreview(type) ? URL.createObjectURL(file) : undefined + }; this.files.update(files => [...files, preview]); }); } removeFile(index: number) { - this.files.update(files => files.filter((_, i) => i !== index)); + this.files.update(files => { + const fileToRemove = files[index]; + if (fileToRemove) this.revokePreviewUrl(fileToRemove); + return files.filter((_, i) => i !== index); + }); } - getFileType(file: File): 'image' | 'pdf' | '3d' | 'other' { - if (file.type.startsWith('image/')) return 'image'; - if (file.type === 'application/pdf') return 'pdf'; - const ext = file.name.split('.').pop()?.toLowerCase(); - if (['stl', 'step', 'stp', '3mf', 'obj'].includes(ext || '')) return '3d'; + getFileType(file: File): 'image' | 'video' | 'pdf' | '3d' | 'document' | 'other' { + const ext = this.getExtension(file.name); + + if (file.type.startsWith('image/') || ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'svg', 'heic', 'heif'].includes(ext)) { + return 'image'; + } + if (file.type.startsWith('video/') || ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'wmv'].includes(ext)) { + return 'video'; + } + if (file.type === 'application/pdf' || ext === 'pdf') return 'pdf'; + if (['stl', 'step', 'stp', '3mf', 'obj', 'iges', 'igs', 'dwg', 'dxf'].includes(ext)) return '3d'; + if ([ + 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf', 'csv', + ].includes(ext)) return 'document'; return 'other'; } @@ -195,6 +220,48 @@ export class ContactFormComponent { resetForm() { this.sent.set(false); this.form.reset({ requestType: 'custom', isCompany: false }); + this.revokeAllPreviewUrls(); this.files.set([]); } + + private getExtension(fileName: string): string { + const index = fileName.lastIndexOf('.'); + return index > -1 ? fileName.substring(index + 1).toLowerCase() : ''; + } + + private shouldCreatePreview(type: FilePreview['type']): boolean { + return type === 'image' || type === 'video'; + } + + private isCompressedFile(file: File): boolean { + const ext = this.getExtension(file.name); + const compressedExtensions = [ + 'zip', 'rar', '7z', 'tar', 'gz', 'tgz', 'bz2', 'tbz2', 'xz', 'txz', 'zst' + ]; + const compressedMimeTypes = [ + 'application/zip', + 'application/x-zip-compressed', + 'application/x-rar-compressed', + 'application/vnd.rar', + 'application/x-7z-compressed', + 'application/gzip', + 'application/x-gzip', + 'application/x-tar', + 'application/x-bzip2', + 'application/x-xz', + 'application/zstd', + 'application/x-zstd' + ]; + return compressedExtensions.includes(ext) || compressedMimeTypes.includes((file.type || '').toLowerCase()); + } + + private revokePreviewUrl(file: FilePreview): void { + if (file.url?.startsWith('blob:')) { + URL.revokeObjectURL(file.url); + } + } + + private revokeAllPreviewUrls(): void { + this.files().forEach(file => this.revokePreviewUrl(file)); + } } diff --git a/frontend/src/app/features/home/home.component.scss b/frontend/src/app/features/home/home.component.scss index fe2049c..c5fe5fa 100644 --- a/frontend/src/app/features/home/home.component.scss +++ b/frontend/src/app/features/home/home.component.scss @@ -231,6 +231,8 @@ background: var(--color-neutral-100); margin: -1.5rem -1.5rem 1.5rem -1.5rem; /* Negative margins to bleed to edge */ width: calc(100% + 3rem); + border-top-left-radius: var(--radius-lg); + border-top-right-radius: var(--radius-lg); border-bottom: 1px solid var(--color-border); display: flex; align-items: center; @@ -417,8 +419,9 @@ .hero-actions { flex-direction: column; align-items: stretch; } .quote-meta { grid-template-columns: 1fr; } .shop-gallery { - width: min(100%, 320px); - justify-self: start; + width: 100%; + max-width: none; + justify-self: stretch; } .shop-gallery-item { aspect-ratio: 16 / 11; diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index 94f4eb5..fc76bd3 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -142,7 +142,7 @@ "COMPANY_NAME": "Company Name", "REF_PERSON": "Reference Person", "UPLOAD_LABEL": "Attachments", - "UPLOAD_HINT": "Max 15 files. Supported: Images, PDF, STL, STEP, 3MF", + "UPLOAD_HINT": "Max 15 files. Supported: images, videos, PDF, Office documents, STL/STEP/3MF/OBJ/IGES, DWG/DXF. Compressed files are not allowed.", "DROP_FILES": "Drop files here or click to upload", "PLACEHOLDER_NAME": "Your Name", "PLACEHOLDER_EMAIL": "your@email.com", @@ -154,9 +154,15 @@ "LABEL_NAME": "Name *", "MSG_SENT": "Sent!", "ERR_MAX_FILES": "Max 15 files limit reached.", + "ERR_COMPRESSED_FILES": "Compressed files are not allowed (ZIP/RAR/7z/TAR/GZ).", "SUCCESS_TITLE": "Message Sent Successfully", "SUCCESS_DESC": "Thank you for contacting us. We have received your message and will send you a recap email shortly.", - "SEND_ANOTHER": "Send Another Message" + "SEND_ANOTHER": "Send Another Message", + "FILE_TYPE_PDF": "PDF", + "FILE_TYPE_3D": "3D", + "FILE_TYPE_VIDEO": "Video", + "FILE_TYPE_DOC": "DOC", + "FILE_TYPE_FILE": "FILE" }, "CHECKOUT": { "TITLE": "Checkout", diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index 1e86599..79f2b31 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -36,11 +36,11 @@ "CAP_2_TITLE": "Pezzi personalizzati", "CAP_2_TEXT": "Componenti unici impossibili da trovare in commercio. Riproduciamo parti rotte o creiamo adattamenti su misura.", "CAP_3_TITLE": "Piccole serie", - "CAP_3_TEXT": "Produzione ponte o serie limitate (10-500 pezzi) con qualità ripetibile. Ideale per validazione di mercato.", + "CAP_3_TEXT": "Produzione ponte o serie limitate (10-500 pezzi) con qualità ripetibile.", "CAP_4_TITLE": "Consulenza e CAD", - "CAP_4_TEXT": "Non hai il file 3D? Ti aiutiamo a progettarlo, ottimizzarlo per la stampa e scegliere il materiale giusto.", - "SEC_SHOP_TITLE": "Shop di soluzioni tecniche pronte", - "SEC_SHOP_TEXT": "Prodotti selezionati, testati in laboratorio e pronti all'uso. Risolvono problemi reali con funzionalità concrete.", + "CAP_4_TEXT": "Non hai il file 3D? Ti aiutiamo a progettarlo, ottimizzarlo per la stampa e scegliere il materiale giusto insieme.", + "SEC_SHOP_TITLE": "Shop", + "SEC_SHOP_TEXT": "Prodotti selezionati, e pronti all'uso.", "SEC_SHOP_LIST_1": "Accessori funzionali per officine e laboratori", "SEC_SHOP_LIST_2": "Ricambi e componenti difficili da reperire", "SEC_SHOP_LIST_3": "Supporti e organizzatori per migliorare i flussi di lavoro", @@ -206,7 +206,7 @@ "COMPANY_NAME": "Ragione Sociale", "REF_PERSON": "Persona di Riferimento", "UPLOAD_LABEL": "Allegati", - "UPLOAD_HINT": "Max 15 file. Supportati: Immagini, PDF, STL, STEP, 3MF", + "UPLOAD_HINT": "Max 15 file. Supportati: immagini, video, PDF, documenti Office, STL/STEP/3MF/OBJ/IGES, DWG/DXF. File compressi non consentiti.", "DROP_FILES": "Trascina qui i file o clicca per caricare", "PLACEHOLDER_NAME": "Il tuo nome", "PLACEHOLDER_EMAIL": "tuo@email.com", @@ -218,12 +218,16 @@ "LABEL_NAME": "Nome *", "MSG_SENT": "Inviato!", "ERR_MAX_FILES": "Limite massimo di 15 file raggiunto.", + "ERR_COMPRESSED_FILES": "I file compressi non sono consentiti (ZIP/RAR/7z/TAR/GZ).", "SUCCESS_TITLE": "Messaggio Inviato con Successo", "SUCCESS_DESC": "Grazie per averci contattato. Abbiamo ricevuto il tuo messaggio e ti invieremo una email di riepilogo a breve.", "SEND_ANOTHER": "Invia un altro messaggio", "HERO_SUBTITLE": "Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.", "FILE_TYPE_PDF": "PDF", - "FILE_TYPE_3D": "3D" + "FILE_TYPE_3D": "3D", + "FILE_TYPE_VIDEO": "Video", + "FILE_TYPE_DOC": "DOC", + "FILE_TYPE_FILE": "FILE" }, "CHECKOUT": { "TITLE": "Checkout", diff --git a/frontend/src/assets/images/Fav-icon.png b/frontend/src/assets/images/Fav-icon.png new file mode 100644 index 0000000..ff6550e Binary files /dev/null and b/frontend/src/assets/images/Fav-icon.png differ diff --git a/frontend/src/index.html b/frontend/src/index.html index 61d676c..633451d 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -6,7 +6,7 @@ 3D fab - +