mirror of
https://github.com/vee1e/bulk-questionnaire-upload.git
synced 2026-09-01 09:50:06 +00:00
Refactor backend and frontend services, add unit tests, configure Ruff and Vitest, and delegate frontend state
This commit is contained in:
parent
a7cda4faf0
commit
dbaba1c315
26 changed files with 4406 additions and 1954 deletions
2155
frontend/bun.lock
Normal file
2155
frontend/bun.lock
Normal file
File diff suppressed because it is too large
Load diff
177
frontend/src/app/components/form-list/form-list.component.ts
Normal file
177
frontend/src/app/components/form-list/form-list.component.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { FormData } from '../../services/form.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-form-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
DatePipe,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
template: `
|
||||
<div *ngIf="forms.length > 0" class="form-list">
|
||||
<div class="form-list-header center-header">
|
||||
<h3>Parsed Forms</h3>
|
||||
</div>
|
||||
<div class="delete-all-wrapper">
|
||||
<button mat-raised-button color="warn" id="delete-all-forms-btn"
|
||||
(click)="deleteAllForms.emit()"
|
||||
[disabled]="isDeletingAll || forms.length === 0">
|
||||
<mat-icon>delete_sweep</mat-icon>
|
||||
{{isDeletingAll ? 'Deleting...' : 'Delete All Forms'}}
|
||||
</button>
|
||||
</div>
|
||||
<div *ngFor="let form of forms" class="form-item"
|
||||
[class.loading]="loadingFormId === form.id"
|
||||
[class.previewed]="currentPreviewedFormId === form.id"
|
||||
(click)="formClicked.emit(form)">
|
||||
<div class="form-info">
|
||||
<mat-icon *ngIf="loadingFormId !== form.id">description</mat-icon>
|
||||
<mat-spinner *ngIf="loadingFormId === form.id" diameter="24" class="loading-spinner"></mat-spinner>
|
||||
<div class="form-details">
|
||||
<h4 class="form-title" [title]="form.title">{{form.title}}</h4>
|
||||
<p>{{form.language || 'en'}} • {{form.version || '1.0.0'}} • {{(form.created_at ? (form.created_at | date:'short') : 'Unknown')}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button mat-icon-button (click)="exportForm.emit(form); $event.stopPropagation()"
|
||||
[disabled]="loadingFormId === form.id"
|
||||
style="color: white;" matTooltip="Download JSON">
|
||||
<mat-icon style="color: white;">download</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button color="accent" (click)="updateForm.emit(form); $event.stopPropagation()"
|
||||
[disabled]="loadingFormId === form.id" matTooltip="Update Form">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button color="warn" (click)="deleteForm.emit(form); $event.stopPropagation()"
|
||||
[disabled]="loadingFormId === form.id" matTooltip="Delete Form">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.form-list {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.form-list-header {
|
||||
padding: 0 1rem;
|
||||
|
||||
&.center-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: white;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-all-wrapper {
|
||||
padding: 0.5rem 1rem 0.5rem 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
&.loading {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&.previewed {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 2px solid #ffffff;
|
||||
box-shadow: 0 0 12px rgba(255, 255, 255, 0.3);
|
||||
transform: scale(1.009);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.form-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
mat-icon {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-details {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
|
||||
.form-title {
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class FormListComponent {
|
||||
@Input() forms: FormData[] = [];
|
||||
@Input() loadingFormId: string | null = null;
|
||||
@Input() isDeletingAll = false;
|
||||
@Input() currentPreviewedFormId: string | null = null;
|
||||
@Output() formClicked = new EventEmitter<FormData>();
|
||||
@Output() deleteForm = new EventEmitter<FormData>();
|
||||
@Output() exportForm = new EventEmitter<FormData>();
|
||||
@Output() updateForm = new EventEmitter<FormData>();
|
||||
@Output() deleteAllForms = new EventEmitter<void>();
|
||||
}
|
||||
|
|
@ -0,0 +1,420 @@
|
|||
import { Component, Input, Output, EventEmitter, HostListener } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-schema-modal',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatButtonModule, MatIconModule],
|
||||
template: `
|
||||
<div *ngIf="visible" class="schema-modal-overlay" (click)="closed.emit()">
|
||||
<div class="schema-modal-content" (click)="$event.stopPropagation()">
|
||||
<div class="schema-modal-header">
|
||||
<h3>
|
||||
<mat-icon class="schema-modal-icon">description</mat-icon>
|
||||
Parsed Schema: {{fileName}}
|
||||
</h3>
|
||||
<button mat-icon-button (click)="closed.emit()" class="close-modal-btn">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="schema-modal-body">
|
||||
<div class="schema-section">
|
||||
<h4>
|
||||
<mat-icon class="section-icon">info</mat-icon>
|
||||
Form Information
|
||||
</h4>
|
||||
<div class="schema-info-grid">
|
||||
<div class="info-item">
|
||||
<strong>Title:</strong>
|
||||
<span>{{getFormTitle()}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>Language:</strong>
|
||||
<span>{{getFormLanguage()}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>Version:</strong>
|
||||
<span>{{getFormVersion()}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>Questions:</strong>
|
||||
<span>{{getFormQuestionsCount()}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>Options:</strong>
|
||||
<span>{{getFormOptionsCount()}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="schema-section" *ngIf="getFormQuestions().length > 0">
|
||||
<h4>
|
||||
<mat-icon class="section-icon">quiz</mat-icon>
|
||||
Questions Structure
|
||||
</h4>
|
||||
<div class="questions-container">
|
||||
<div class="question-group">
|
||||
<h5 class="group-title">Form Questions</h5>
|
||||
<div *ngFor="let question of getFormQuestions()" class="question-item">
|
||||
<div class="question-header">
|
||||
<span class="question-name">#{{question.order}}</span>
|
||||
<span class="question-type">Type {{question.input_type}}</span>
|
||||
</div>
|
||||
<div class="question-label">{{question.title}}</div>
|
||||
<div *ngIf="question.answer_option && question.answer_option.length > 0" class="question-choices">
|
||||
<div class="choice-header">Options:</div>
|
||||
<div *ngFor="let option of question.answer_option" class="choice-item">
|
||||
<span class="choice-name">{{option._id}}</span>
|
||||
<span class="choice-label">{{option.name}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="schema-section">
|
||||
<h4>
|
||||
<mat-icon class="section-icon">code</mat-icon>
|
||||
Raw JSON Schema
|
||||
</h4>
|
||||
<div class="json-container">
|
||||
<pre class="json-content">{{formatJsonSchema()}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="schema-modal-actions">
|
||||
<button mat-raised-button color="accent" (click)="copied.emit()">
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
Copy JSON
|
||||
</button>
|
||||
<button mat-raised-button color="primary" (click)="downloaded.emit()">
|
||||
<mat-icon>download</mat-icon>
|
||||
Download JSON
|
||||
</button>
|
||||
<button mat-raised-button (click)="closed.emit()">
|
||||
<mat-icon>close</mat-icon>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.schema-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.schema-modal-content {
|
||||
background: rgba(20, 20, 35, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
width: 80vw;
|
||||
max-width: 900px;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.schema-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #ffffff;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.schema-modal-icon {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.close-modal-btn {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.schema-modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.schema-section {
|
||||
h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
color: #ffffff !important;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.schema-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.9rem;
|
||||
|
||||
strong {
|
||||
color: #ffffff;
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.questions-container {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.question-group {
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
.group-title {
|
||||
margin: 0 0 0.75rem 0;
|
||||
color: #ffffff;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.question-item {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
|
||||
.question-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.question-name {
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.question-type {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.question-label {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.question-choices {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.choice-header {
|
||||
color: #ffffff;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.choice-item {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
|
||||
.choice-name {
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
.choice-label {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.json-container {
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
background: #0d1117;
|
||||
}
|
||||
|
||||
.json-content {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
color: #ffffff;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.schema-modal-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.schema-modal-content {
|
||||
width: 95vw;
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
.schema-info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.schema-modal-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SchemaModalComponent {
|
||||
@Input() schema: any = null;
|
||||
@Input() fileName = '';
|
||||
@Input() visible = false;
|
||||
@Output() closed = new EventEmitter<void>();
|
||||
@Output() copied = new EventEmitter<void>();
|
||||
@Output() downloaded = new EventEmitter<void>();
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscapeKey(): void {
|
||||
if (this.visible) {
|
||||
this.closed.emit();
|
||||
}
|
||||
}
|
||||
|
||||
getFormTitle(): string {
|
||||
if (!this.schema) return 'N/A';
|
||||
if (Array.isArray(this.schema) && this.schema.length > 0) {
|
||||
const formDef = this.schema[0];
|
||||
if (formDef.language && Array.isArray(formDef.language) && formDef.language.length > 0) {
|
||||
return formDef.language[0].title || 'N/A';
|
||||
}
|
||||
}
|
||||
if (this.schema.title?.default) return this.schema.title.default;
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
getFormLanguage(): string {
|
||||
if (!this.schema) return 'N/A';
|
||||
if (Array.isArray(this.schema) && this.schema.length > 0) {
|
||||
const formDef = this.schema[0];
|
||||
if (typeof formDef.language === 'string') return formDef.language;
|
||||
if (Array.isArray(formDef.language) && formDef.language.length > 0) {
|
||||
return formDef.language[0].lng || 'N/A';
|
||||
}
|
||||
}
|
||||
return this.schema.language || 'N/A';
|
||||
}
|
||||
|
||||
getFormVersion(): string {
|
||||
if (!this.schema) return 'N/A';
|
||||
if (Array.isArray(this.schema) && this.schema.length > 0) {
|
||||
return this.schema[0].version || 'N/A';
|
||||
}
|
||||
return this.schema.version || 'N/A';
|
||||
}
|
||||
|
||||
getFormOptionsCount(): number {
|
||||
if (!this.schema) return 0;
|
||||
if (Array.isArray(this.schema) && this.schema.length > 0) {
|
||||
const formDef = this.schema[0];
|
||||
if (formDef.language && Array.isArray(formDef.language) && formDef.language.length > 0) {
|
||||
const langConfig = formDef.language[0];
|
||||
if (langConfig.question && Array.isArray(langConfig.question)) {
|
||||
return langConfig.question.reduce((acc: number, q: any) => acc + (q.answer_option?.length ?? 0), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.schema.metadata?.options_count ?? 0;
|
||||
}
|
||||
|
||||
getFormQuestionsCount(): number {
|
||||
if (!this.schema) return 0;
|
||||
if (Array.isArray(this.schema) && this.schema.length > 0) {
|
||||
const formDef = this.schema[0];
|
||||
if (formDef.question && Array.isArray(formDef.question)) {
|
||||
return formDef.question.length;
|
||||
}
|
||||
}
|
||||
return this.schema.metadata?.questions_count ?? 0;
|
||||
}
|
||||
|
||||
getFormQuestions(): any[] {
|
||||
if (!this.schema) return [];
|
||||
if (Array.isArray(this.schema) && this.schema.length > 0) {
|
||||
const formDef = this.schema[0];
|
||||
if (formDef.language && Array.isArray(formDef.language) && formDef.language.length > 0) {
|
||||
return formDef.language[0].question ?? [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
formatJsonSchema(): string {
|
||||
if (!this.schema) return '';
|
||||
return JSON.stringify(this.schema, null, 2);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,9 +7,14 @@ import { MatProgressBarModule } from '@angular/material/progress-bar';
|
|||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { FormService, FormData, FormDetails, OptionData, ParsedSchema } from '../../services/form.service';
|
||||
import { FormValidation, ValidationError, ValidationWarning } from '../../models/form.model';
|
||||
import { FormPreviewService } from '../../services/form-preview.service';
|
||||
import { FormListComponent } from '../form-list/form-list.component';
|
||||
import { SchemaModalComponent } from '../schema-modal/schema-modal.component';
|
||||
import { UploadStateService } from '../../services/upload-state.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-upload',
|
||||
|
|
@ -22,7 +27,9 @@ import { FormPreviewService } from '../../services/form-preview.service';
|
|||
MatProgressBarModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
MatSnackBarModule
|
||||
MatSnackBarModule,
|
||||
FormListComponent,
|
||||
SchemaModalComponent
|
||||
],
|
||||
template: `
|
||||
<div *ngIf="isValidating || isUploading || isDeletingAll || isParsing" class="global-upload-progress"
|
||||
|
|
@ -285,146 +292,30 @@ import { FormPreviewService } from '../../services/form-preview.service';
|
|||
</div>
|
||||
|
||||
<!-- Form List -->
|
||||
<div *ngIf="filteredForms.length > 0" class="form-list">
|
||||
<div class="form-list-header center-header">
|
||||
<h3>Parsed Forms</h3>
|
||||
</div>
|
||||
<div class="delete-all-wrapper">
|
||||
<button mat-raised-button color="warn" (click)="confirmDeleteAllForms()" [disabled]="isDeletingAll || filteredForms.length === 0">
|
||||
<mat-icon>delete_sweep</mat-icon>
|
||||
{{isDeletingAll ? 'Deleting...' : 'Delete All Forms'}}
|
||||
</button>
|
||||
</div>
|
||||
<div *ngFor="let form of filteredForms" class="form-item"
|
||||
[class.loading]="loadingFormId === form.id"
|
||||
[class.previewed]="currentPreviewedFormId === form.id"
|
||||
(click)="showFormDetails(form)">
|
||||
<div class="form-info">
|
||||
<mat-icon *ngIf="loadingFormId !== form.id">description</mat-icon>
|
||||
<mat-spinner *ngIf="loadingFormId === form.id" diameter="24" class="loading-spinner"></mat-spinner>
|
||||
<div class="form-details">
|
||||
<h4 class="form-title" [title]="form.title">{{form.title}}</h4>
|
||||
<p>{{form.language || 'en'}} • {{form.version || '1.0.0'}} • {{(form.created_at ? (form.created_at | date:'short') : 'Unknown')}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button mat-icon-button (click)="exportFormAsJson(form, $event)" [disabled]="loadingFormId === form.id" style="color: white;" matTooltip="Download JSON">
|
||||
<mat-icon style="color: white;">download</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button color="accent" (click)="onUpdateButtonClick(form, $event)" [disabled]="loadingFormId === form.id" matTooltip="Update Form">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button color="warn" (click)="deleteForm(form, $event)" [disabled]="loadingFormId === form.id" matTooltip="Delete Form">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<app-form-list
|
||||
[forms]="filteredForms"
|
||||
[loadingFormId]="loadingFormId"
|
||||
[isDeletingAll]="isDeletingAll"
|
||||
[currentPreviewedFormId]="currentPreviewedFormId"
|
||||
(formClicked)="showFormDetails($event)"
|
||||
(deleteForm)="deleteForm($event)"
|
||||
(exportForm)="exportFormAsJson($event)"
|
||||
(updateForm)="onUpdateButtonClick($event)"
|
||||
(deleteAllForms)="confirmDeleteAllForms()">
|
||||
</app-form-list>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<input type="file" accept=".xls,.xlsx" #updateFileInput id="global-update-file-input" style="display: none" (change)="onUpdateFileSelected($event)">
|
||||
|
||||
<!-- Schema Modal -->
|
||||
<div *ngIf="showSchemaModal" class="schema-modal-overlay" (click)="closeSchemaModal()">
|
||||
<div class="schema-modal-content" (click)="$event.stopPropagation()">
|
||||
<div class="schema-modal-header">
|
||||
<h3>
|
||||
<mat-icon class="schema-modal-icon">description</mat-icon>
|
||||
Parsed Schema: {{selectedSchemaFileName}}
|
||||
</h3>
|
||||
<button mat-icon-button (click)="closeSchemaModal()" class="close-modal-btn">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="schema-modal-body">
|
||||
<div class="schema-section">
|
||||
<h4>
|
||||
<mat-icon class="section-icon">info</mat-icon>
|
||||
Form Information
|
||||
</h4>
|
||||
<div class="schema-info-grid">
|
||||
<div class="info-item">
|
||||
<strong>Title:</strong>
|
||||
<span>{{getFormTitle(selectedSchema)}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>Language:</strong>
|
||||
<span>{{getFormLanguage(selectedSchema)}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>Version:</strong>
|
||||
<span>{{getFormVersion(selectedSchema)}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>Questions:</strong>
|
||||
<span>{{getFormQuestionsCount(selectedSchema)}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>Options:</strong>
|
||||
<span>{{getFormOptionsCount(selectedSchema)}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>Form ID:</strong>
|
||||
<span>{{getFormId(selectedSchema)}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="schema-section" *ngIf="getFormQuestions(selectedSchema).length > 0">
|
||||
<h4>
|
||||
<mat-icon class="section-icon">quiz</mat-icon>
|
||||
Questions Structure
|
||||
</h4>
|
||||
<div class="questions-container">
|
||||
<div class="question-group">
|
||||
<h5 class="group-title">Form Questions</h5>
|
||||
<div *ngFor="let question of getFormQuestions(selectedSchema)" class="question-item">
|
||||
<div class="question-header">
|
||||
<span class="question-name">#{{question.order}}</span>
|
||||
<span class="question-type">Type {{question.input_type}}</span>
|
||||
</div>
|
||||
<div class="question-label">{{question.title}}</div>
|
||||
<div *ngIf="question.answer_option && question.answer_option.length > 0" class="question-choices">
|
||||
<div class="choice-header">Options:</div>
|
||||
<div *ngFor="let option of question.answer_option" class="choice-item">
|
||||
<span class="choice-name">{{option._id}}</span>
|
||||
<span class="choice-label">{{option.name}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="schema-section">
|
||||
<h4>
|
||||
<mat-icon class="section-icon">code</mat-icon>
|
||||
Raw JSON Schema
|
||||
</h4>
|
||||
<div class="json-container">
|
||||
<pre class="json-content">{{formatJsonSchema(selectedSchema)}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="schema-modal-actions">
|
||||
<button mat-raised-button color="accent" (click)="copySchemaToClipboard()">
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
Copy JSON
|
||||
</button>
|
||||
<button mat-raised-button color="primary" (click)="downloadSchemaFromModal()">
|
||||
<mat-icon>download</mat-icon>
|
||||
Download JSON
|
||||
</button>
|
||||
<button mat-raised-button (click)="closeSchemaModal()">
|
||||
<mat-icon>close</mat-icon>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<app-schema-modal
|
||||
[schema]="selectedSchema"
|
||||
[fileName]="selectedSchemaFileName"
|
||||
[visible]="showSchemaModal"
|
||||
(closed)="closeSchemaModal()"
|
||||
(copied)="copySchemaToClipboard()"
|
||||
(downloaded)="downloadSchemaFromModal()">
|
||||
</app-schema-modal>
|
||||
`,
|
||||
styles: [`
|
||||
.upload-card {
|
||||
|
|
@ -1580,7 +1471,7 @@ export class UploadComponent implements OnInit, OnChanges {
|
|||
currentUploadIndex = 0;
|
||||
|
||||
// Async upload control
|
||||
private uploadController: AbortController | null = null;
|
||||
private cancelSubject$ = new Subject<void>();
|
||||
private activeUploads = new Set<Promise<any>>();
|
||||
private maxConcurrentUploads = 3;
|
||||
|
||||
|
|
@ -1594,6 +1485,7 @@ export class UploadComponent implements OnInit, OnChanges {
|
|||
updateTargetForm: FormData | null = null;
|
||||
|
||||
private platformId = inject(PLATFORM_ID);
|
||||
private uploadStateService = inject(UploadStateService);
|
||||
|
||||
constructor(private formService: FormService, private formPreviewService: FormPreviewService, private snackBar: MatSnackBar, private cdr: ChangeDetectorRef) {}
|
||||
|
||||
|
|
@ -1736,54 +1628,14 @@ export class UploadComponent implements OnInit, OnChanges {
|
|||
this.currentUploadIndex = 0;
|
||||
this.uploadProgress = { current: 0, total: this.selectedFiles.length };
|
||||
|
||||
// Create new abort controller for this upload session
|
||||
this.uploadController = new AbortController();
|
||||
// Reset cancellation subject for this upload session
|
||||
this.cancelSubject$ = new Subject<void>();
|
||||
this.activeUploads.clear();
|
||||
|
||||
this.saveUploadState();
|
||||
await this.processQueueAsync();
|
||||
}
|
||||
|
||||
private processNextFile() {
|
||||
if (this.uploadPaused || this.currentUploadIndex >= this.uploadQueue.length) {
|
||||
if (this.currentUploadIndex >= this.uploadQueue.length) {
|
||||
this.completeUpload();
|
||||
}
|
||||
this.saveUploadState();
|
||||
return;
|
||||
}
|
||||
|
||||
const file = this.uploadQueue[this.currentUploadIndex];
|
||||
this.formService.uploadSingleFile(file).subscribe({
|
||||
next: (result: any) => {
|
||||
this.processedFiles.push(file.name);
|
||||
this.currentUploadIndex++;
|
||||
this.uploadProgress.current = this.currentUploadIndex;
|
||||
this.saveUploadState();
|
||||
|
||||
if (!result.error) {
|
||||
this.snackBar.open(`Successfully processed ${file.name}`, 'Close', {
|
||||
duration: 2000,
|
||||
verticalPosition: 'top'
|
||||
});
|
||||
} else {
|
||||
this.snackBar.open(`Failed to process ${file.name}`, 'Close', { duration: 3000 });
|
||||
}
|
||||
|
||||
setTimeout(() => this.processNextFile(), 500);
|
||||
},
|
||||
error: (error: any) => {
|
||||
console.error(`Upload failed for ${file.name}:`, error);
|
||||
this.snackBar.open(`Upload failed for ${file.name}`, 'Close', { duration: 3000 });
|
||||
this.currentUploadIndex++;
|
||||
this.uploadProgress.current = this.currentUploadIndex;
|
||||
this.saveUploadState();
|
||||
|
||||
setTimeout(() => this.processNextFile(), 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async processQueueAsync() {
|
||||
try {
|
||||
const remainingFiles = this.uploadQueue.slice(this.currentUploadIndex);
|
||||
|
|
@ -1842,41 +1694,38 @@ export class UploadComponent implements OnInit, OnChanges {
|
|||
}
|
||||
|
||||
private async uploadSingleFileAsync(file: File, index: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.uploadController?.signal.aborted) {
|
||||
reject(new Error('Upload cancelled'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.formService.uploadSingleFile(file).subscribe({
|
||||
return new Promise<void>((resolve) => {
|
||||
let handled = false;
|
||||
this.formService.uploadSingleFile(file).pipe(takeUntil(this.cancelSubject$)).subscribe({
|
||||
next: (result: any) => {
|
||||
handled = true;
|
||||
if (!this.uploadStopped) {
|
||||
this.processedFiles.push(file.name);
|
||||
this.uploadProgress.current = this.processedFiles.length;
|
||||
|
||||
|
||||
if (!result.error) {
|
||||
this.snackBar.open(`Successfully processed ${file.name}`, 'Close', {
|
||||
duration: 2000,
|
||||
verticalPosition: 'top'
|
||||
this.snackBar.open(`Successfully processed ${file.name}`, 'Close', {
|
||||
duration: 2000,
|
||||
verticalPosition: 'top'
|
||||
});
|
||||
} else {
|
||||
this.snackBar.open(`Failed to process ${file.name}`, 'Close', { duration: 3000 });
|
||||
}
|
||||
|
||||
|
||||
this.saveUploadState();
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
error: (error: any) => {
|
||||
console.error(`Failed to upload ${file.name}:`, error);
|
||||
if (!this.uploadStopped) {
|
||||
this.snackBar.open(`Failed to upload ${file.name}`, 'Close', {
|
||||
this.snackBar.open(`Failed to upload ${file.name}`, 'Close', {
|
||||
duration: 3000,
|
||||
panelClass: ['error-snackbar']
|
||||
});
|
||||
}
|
||||
resolve(); // Resolve even on error to continue with other files
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
complete: () => resolve() // handles both normal completion and takeUntil cancellation
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1927,17 +1776,15 @@ export class UploadComponent implements OnInit, OnChanges {
|
|||
async cancelUpload() {
|
||||
if (confirm('Are you sure you want to cancel the upload? Progress will be lost.')) {
|
||||
this.uploadStopped = true;
|
||||
|
||||
// Abort any ongoing HTTP requests
|
||||
if (this.uploadController) {
|
||||
this.uploadController.abort();
|
||||
}
|
||||
|
||||
|
||||
// Cancel all in-flight HTTP requests via RxJS takeUntil
|
||||
this.cancelSubject$.next();
|
||||
|
||||
// Wait for active uploads to complete/cancel
|
||||
if (this.activeUploads.size > 0) {
|
||||
await Promise.allSettled([...this.activeUploads]);
|
||||
}
|
||||
|
||||
|
||||
this.resetUploadState();
|
||||
this.cdr.detectChanges();
|
||||
this.snackBar.open('Upload cancelled', 'Close', { duration: 3000 });
|
||||
|
|
@ -1956,84 +1803,42 @@ export class UploadComponent implements OnInit, OnChanges {
|
|||
this.allValid = false;
|
||||
this.uploadProgress = { current: 0, total: 0 };
|
||||
|
||||
// Clean up async resources
|
||||
if (this.uploadController) {
|
||||
this.uploadController.abort();
|
||||
this.uploadController = null;
|
||||
}
|
||||
// Cancel any in-flight requests and reset the subject for the next session
|
||||
this.cancelSubject$.next();
|
||||
this.cancelSubject$ = new Subject<void>();
|
||||
this.activeUploads.clear();
|
||||
|
||||
this.clearUploadSession();
|
||||
}
|
||||
|
||||
private saveUploadState() {
|
||||
if (typeof window === 'undefined' || !window.sessionStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isUploading || this.uploadPaused) {
|
||||
const state = {
|
||||
isUploading: this.isUploading,
|
||||
uploadPaused: this.uploadPaused,
|
||||
uploadStopped: this.uploadStopped,
|
||||
uploadQueue: this.uploadQueue.map(file => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
})),
|
||||
processedFiles: this.processedFiles,
|
||||
currentUploadIndex: this.currentUploadIndex,
|
||||
uploadProgress: this.uploadProgress,
|
||||
validationResults: this.validationResults,
|
||||
allValid: this.allValid,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sessionStorage.setItem('uploadState', JSON.stringify(state));
|
||||
}
|
||||
this.uploadStateService.saveUploadState({
|
||||
isUploading: this.isUploading,
|
||||
uploadPaused: this.uploadPaused,
|
||||
uploadStopped: this.uploadStopped,
|
||||
uploadQueue: this.uploadQueue.map(file => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
})),
|
||||
processedFiles: this.processedFiles,
|
||||
currentUploadIndex: this.currentUploadIndex,
|
||||
uploadProgress: this.uploadProgress,
|
||||
validationResults: this.validationResults,
|
||||
allValid: this.allValid
|
||||
});
|
||||
}
|
||||
|
||||
private restoreUploadState() {
|
||||
if (typeof window === 'undefined' || !window.sessionStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedState = sessionStorage.getItem('uploadState');
|
||||
if (savedState) {
|
||||
try {
|
||||
const state = JSON.parse(savedState);
|
||||
const timeDiff = Date.now() - state.timestamp;
|
||||
|
||||
if (timeDiff < 30 * 60 * 1000) {
|
||||
this.isUploading = state.isUploading;
|
||||
this.uploadPaused = state.uploadPaused;
|
||||
this.processedFiles = state.processedFiles || [];
|
||||
this.currentUploadIndex = state.currentUploadIndex || 0;
|
||||
this.uploadProgress = state.uploadProgress || { current: 0, total: 0 };
|
||||
this.validationResults = state.validationResults || {};
|
||||
this.allValid = state.allValid || false;
|
||||
|
||||
if (this.isUploading || this.uploadPaused) {
|
||||
this.snackBar.open('📋 Previous upload session restored. You can resume or cancel.', 'Close', {
|
||||
duration: 5000,
|
||||
panelClass: ['custom-snackbar']
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.clearUploadSession();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restore upload state:', error);
|
||||
this.clearUploadSession();
|
||||
}
|
||||
const message = this.uploadStateService.checkInterruptedUpload();
|
||||
if (message) {
|
||||
this.snackBar.open(message, 'Close', { duration: 6000 });
|
||||
}
|
||||
}
|
||||
|
||||
private clearUploadSession() {
|
||||
if (typeof window === 'undefined' || !window.sessionStorage) {
|
||||
return;
|
||||
}
|
||||
sessionStorage.removeItem('uploadState');
|
||||
this.uploadStateService.clearUploadSession();
|
||||
}
|
||||
|
||||
getFileUploadStatus(fileName: string, index: number): string {
|
||||
|
|
@ -2076,8 +1881,8 @@ export class UploadComponent implements OnInit, OnChanges {
|
|||
}
|
||||
}
|
||||
|
||||
deleteForm(form: FormData, event: Event): void {
|
||||
event.stopPropagation();
|
||||
deleteForm(form: FormData, event?: Event): void {
|
||||
if (event) event.stopPropagation();
|
||||
if (confirm(`Are you sure you want to delete "${form.title}"?`)) {
|
||||
this.formService.deleteForm(form.id).subscribe({
|
||||
next: () => {
|
||||
|
|
@ -2090,8 +1895,8 @@ export class UploadComponent implements OnInit, OnChanges {
|
|||
}
|
||||
}
|
||||
|
||||
exportFormAsJson(form: FormData, event: Event): void {
|
||||
event.stopPropagation();
|
||||
exportFormAsJson(form: FormData, event?: Event): void {
|
||||
if (event) event.stopPropagation();
|
||||
this.formService.getFormById(form.id).subscribe({
|
||||
next: (details) => {
|
||||
const json = JSON.stringify(details, null, 2);
|
||||
|
|
@ -2129,31 +1934,21 @@ export class UploadComponent implements OnInit, OnChanges {
|
|||
});
|
||||
}
|
||||
|
||||
async deleteAllForms() {
|
||||
deleteAllForms() {
|
||||
if (this.isDeletingAll || this.parsedForms.length === 0) return;
|
||||
this.isDeletingAll = true;
|
||||
this.deleteProgress = { current: 0, total: this.parsedForms.length };
|
||||
let deleted = 0;
|
||||
const deleteNext = (index: number) => {
|
||||
if (index >= this.parsedForms.length) {
|
||||
this.formService.deleteAllForms().subscribe({
|
||||
next: () => {
|
||||
this.deleteProgress.current = this.parsedForms.length;
|
||||
this.isDeletingAll = false;
|
||||
this.loadForms();
|
||||
return;
|
||||
},
|
||||
error: () => {
|
||||
this.isDeletingAll = false;
|
||||
this.snackBar.open('Failed to delete all forms', 'Close', { duration: 3000 });
|
||||
}
|
||||
this.formService.deleteForm(this.parsedForms[index].id).subscribe({
|
||||
next: () => {
|
||||
deleted++;
|
||||
this.deleteProgress.current = deleted;
|
||||
deleteNext(index + 1);
|
||||
},
|
||||
error: () => {
|
||||
deleted++;
|
||||
this.deleteProgress.current = deleted;
|
||||
deleteNext(index + 1);
|
||||
}
|
||||
});
|
||||
};
|
||||
deleteNext(0);
|
||||
});
|
||||
}
|
||||
|
||||
confirmDeleteAllForms() {
|
||||
|
|
@ -2195,8 +1990,8 @@ export class UploadComponent implements OnInit, OnChanges {
|
|||
return labels[type] || type.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
|
||||
onUpdateButtonClick(form: FormData, event: Event): void {
|
||||
event.stopPropagation();
|
||||
onUpdateButtonClick(form: FormData, event?: Event): void {
|
||||
if (event) event.stopPropagation();
|
||||
this.updateTargetForm = form;
|
||||
const input = document.getElementById('global-update-file-input') as HTMLInputElement;
|
||||
if (input) {
|
||||
|
|
|
|||
|
|
@ -81,8 +81,10 @@ export class FormService {
|
|||
|
||||
uploadFile(file: File): Observable<FormDetails> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return this.http.post<FormDetails>(`${this.apiUrl}/upload`, formData);
|
||||
formData.append('files', file);
|
||||
return this.http.post<any[]>(`${this.apiUrl}/upload`, formData).pipe(
|
||||
map((results: any[]) => results[0])
|
||||
);
|
||||
}
|
||||
|
||||
uploadSingleFile(file: File): Observable<any> {
|
||||
|
|
|
|||
65
frontend/src/app/services/upload-state.service.ts
Normal file
65
frontend/src/app/services/upload-state.service.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
const SESSION_KEY = 'uploadState';
|
||||
const INTERRUPTED_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UploadStateService {
|
||||
/**
|
||||
* Persist the current upload state to sessionStorage.
|
||||
* Only writes when an upload is in-progress or paused.
|
||||
*/
|
||||
saveUploadState(state: {
|
||||
isUploading: boolean;
|
||||
uploadPaused: boolean;
|
||||
uploadStopped: boolean;
|
||||
uploadQueue: { name: string; size: number; type: string; lastModified: number }[];
|
||||
processedFiles: string[];
|
||||
currentUploadIndex: number;
|
||||
uploadProgress: { current: number; total: number };
|
||||
validationResults: Record<string, any>;
|
||||
allValid: boolean;
|
||||
}): void {
|
||||
if (typeof window === 'undefined' || !window.sessionStorage) {
|
||||
return;
|
||||
}
|
||||
if (state.isUploading || state.uploadPaused) {
|
||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify({ ...state, timestamp: Date.now() }));
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the upload session key. */
|
||||
clearUploadSession(): void {
|
||||
if (typeof window === 'undefined' || !window.sessionStorage) {
|
||||
return;
|
||||
}
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a recent interrupted upload exists.
|
||||
* Always clears the session so the user starts fresh.
|
||||
* @returns A user-facing message string if interrupted, or null.
|
||||
*/
|
||||
checkInterruptedUpload(): string | null {
|
||||
if (typeof window === 'undefined' || !window.sessionStorage) {
|
||||
return null;
|
||||
}
|
||||
const savedState = sessionStorage.getItem(SESSION_KEY);
|
||||
if (!savedState) {
|
||||
return null;
|
||||
}
|
||||
// Always clear — File objects cannot survive a page reload.
|
||||
this.clearUploadSession();
|
||||
try {
|
||||
const state = JSON.parse(savedState);
|
||||
const timeDiff = Date.now() - state.timestamp;
|
||||
if (timeDiff < INTERRUPTED_THRESHOLD_MS && (state.isUploading || state.uploadPaused)) {
|
||||
return 'Previous upload was interrupted. Please re-select your files to try again.';
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed session data
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
1
frontend/src/assets/config.json
Normal file
1
frontend/src/assets/config.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"API_URL":"http://localhost:8000/api"}
|
||||
|
|
@ -7,6 +7,14 @@ const __dirname = dirname(__filename)
|
|||
const repoRoot = resolve(__dirname, '..')
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@angular/core': resolve(__dirname, 'node_modules/@angular/core'),
|
||||
'@angular/common': resolve(__dirname, 'node_modules/@angular/common'),
|
||||
'@angular/compiler': resolve(__dirname, 'node_modules/@angular/compiler'),
|
||||
'rxjs': resolve(__dirname, 'node_modules/rxjs'),
|
||||
}
|
||||
},
|
||||
server: {
|
||||
fs: {
|
||||
allow: ['..']
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue