@leogps/file-uploader 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.js +178 -0
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/dist/client/1551f4f60c37af51121f.woff2 +0 -0
- package/dist/client/2285773e6b4b172f07d9.woff +0 -0
- package/dist/client/23f19bb08961f37aaf69.eot +0 -0
- package/dist/client/2f517e09eb2ca6650ff5.svg +3717 -0
- package/dist/client/4689f52cc96215721344.svg +801 -0
- package/dist/client/491974d108fe4002b2aa.ttf +0 -0
- package/dist/client/527940b104eb2ea366c8.ttf +0 -0
- package/dist/client/77206a6bb316fa0aded5.eot +0 -0
- package/dist/client/7a3337626410ca2f4071.woff2 +0 -0
- package/dist/client/7a8b4f130182d19a2d7c.svg +5034 -0
- package/dist/client/9bbb245e67a133f6e486.eot +0 -0
- package/dist/client/bb58e57c48a3e911f15f.woff +0 -0
- package/dist/client/be9ee23c0c6390141475.ttf +0 -0
- package/dist/client/d878b0a6a1144760244f.woff2 +0 -0
- package/dist/client/eeccf4f66002c6f2ba24.woff +0 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/index.html +1 -0
- package/dist/client/main.66a16cbe5e2ce036e9a7.bundle.js +39507 -0
- package/dist/client/main.6db272040eaab1c51019.css +14 -0
- package/dist/index.js +3 -0
- package/dist/index.js.LICENSE.txt +273 -0
- package/package-gzip.js +30 -0
- package/package.json +107 -0
- package/src/globals.ts +23 -0
- package/src/index.ts +87 -0
- package/src/model/progress.ts +175 -0
- package/src/model/progress_utils.ts +17 -0
- package/src/routes/uploadChunk.ts +125 -0
- package/src/routes/uploadComplete.ts +53 -0
- package/src/routes/uploadInit.ts +83 -0
- package/src/routes/uploadStatus.ts +137 -0
- package/src/service/progress_writer.ts +52 -0
- package/src-client/entrypoint.ts +273 -0
- package/src-client/progress-handler.ts +233 -0
- package/src-client/public/favicon.ico +0 -0
- package/src-client/public/index.html +67 -0
- package/src-client/sha1.ts +19 -0
- package/src-client/style.scss +87 -0
- package/tsconfig.json +107 -0
- package/webpack-client.common.js +29 -0
- package/webpack-client.dev.js +51 -0
- package/webpack-client.prod.js +65 -0
- package/webpack.config.js +41 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { ProgressHandler } from "./progress-handler";
|
|
2
|
+
import "jquery-blockui/jquery.blockUI.js";
|
|
3
|
+
import "./style.scss";
|
|
4
|
+
import Toastify from "toastify-js";
|
|
5
|
+
import {computeSHA1} from "./sha1";
|
|
6
|
+
|
|
7
|
+
const MAX_COMPLETE_CHECK_RETRIES = 20;
|
|
8
|
+
const COMPLETE_CHECK_RETRY_DELAY_MS = 1000;
|
|
9
|
+
|
|
10
|
+
jQuery(() => {
|
|
11
|
+
const progressHandler = new ProgressHandler();
|
|
12
|
+
progressHandler.registerHandler();
|
|
13
|
+
|
|
14
|
+
const pageEventRegistrar = new PageEventRegistrar();
|
|
15
|
+
pageEventRegistrar.registerEvents();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
class PageEventRegistrar {
|
|
19
|
+
public registerEvents(): void {
|
|
20
|
+
this.registerThemeSelectionEventHandler();
|
|
21
|
+
this.registerFileInputEventHandler();
|
|
22
|
+
this.registerFormSubmissionEventHandler();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private registerThemeSelectionEventHandler() {
|
|
26
|
+
const $themeToggle = $('#themeToggle');
|
|
27
|
+
const $themeIcon = $('#themeIcon');
|
|
28
|
+
const $htmlEl = $('html');
|
|
29
|
+
|
|
30
|
+
const applyTheme = (theme: string) => {
|
|
31
|
+
$htmlEl.attr('data-theme', theme);
|
|
32
|
+
localStorage.setItem('theme', theme);
|
|
33
|
+
$themeIcon.removeClass('fa-sun fa-moon');
|
|
34
|
+
$themeIcon.addClass(theme === 'dark' ? 'fas fa-sun' : 'fas fa-moon');
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Detect saved theme or system preference
|
|
38
|
+
const savedTheme = localStorage.getItem('theme');
|
|
39
|
+
if (savedTheme) {
|
|
40
|
+
applyTheme(savedTheme);
|
|
41
|
+
} else {
|
|
42
|
+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
43
|
+
applyTheme(prefersDark ? 'dark' : 'light');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Toggle on click
|
|
47
|
+
$themeToggle.on('click', () => {
|
|
48
|
+
const currentTheme = $htmlEl.attr('data-theme');
|
|
49
|
+
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
|
50
|
+
applyTheme(newTheme);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private registerFileInputEventHandler() {
|
|
55
|
+
const $fileDiv = jQuery("#file-div");
|
|
56
|
+
const $fileNameDiv = $fileDiv.find("#file-name");
|
|
57
|
+
const $fileInput = jQuery("form#uploadForm input[name='multipleFiles']");
|
|
58
|
+
$fileInput.on("change", () => {
|
|
59
|
+
this.onFilesChange($fileNameDiv, $fileInput);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private onFilesChange($fileNameDiv: JQuery<HTMLElement>, $fileInput: JQuery<HTMLElement>) {
|
|
64
|
+
$fileNameDiv.html("");
|
|
65
|
+
const files: FileList = $fileInput.prop("files");
|
|
66
|
+
if (files && files.length > 0) {
|
|
67
|
+
const ul = document.createElement("ul");
|
|
68
|
+
for (const file of Array.from(files)) {
|
|
69
|
+
const li = document.createElement("li");
|
|
70
|
+
li.textContent = file.name;
|
|
71
|
+
ul.appendChild(li);
|
|
72
|
+
}
|
|
73
|
+
$fileNameDiv.append(ul);
|
|
74
|
+
} else {
|
|
75
|
+
$fileNameDiv.html("No files selected.");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private registerFormSubmissionEventHandler() {
|
|
80
|
+
const $uploadForm = jQuery("form#uploadForm");
|
|
81
|
+
$uploadForm.on("submit", (event) => {
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
|
|
84
|
+
// wrap async logic in an IIFE
|
|
85
|
+
(async () => {
|
|
86
|
+
const formElement: any = $('input[name="multipleFiles"]')[0];
|
|
87
|
+
const files: FileList = formElement.files;
|
|
88
|
+
|
|
89
|
+
if (!files || files.length === 0) {
|
|
90
|
+
Toastify({
|
|
91
|
+
text: "Please select files",
|
|
92
|
+
duration: 3000,
|
|
93
|
+
close: true
|
|
94
|
+
}).showToast();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Block form before uploading
|
|
99
|
+
$uploadForm.block({
|
|
100
|
+
message: '<h1 class="upload-block-modal p-2 m-0">Uploading...</h1>'
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
// Upload all files sequentially
|
|
105
|
+
for (const file of Array.from(files)) {
|
|
106
|
+
await this.uploadFile(file);
|
|
107
|
+
}
|
|
108
|
+
} finally {
|
|
109
|
+
// Unblock and reset form after all files finish
|
|
110
|
+
$uploadForm.trigger("reset");
|
|
111
|
+
|
|
112
|
+
const $fileDiv = jQuery("#file-div");
|
|
113
|
+
const $fileNameDiv = $fileDiv.find("#file-name");
|
|
114
|
+
const $fileInput = jQuery("form#uploadForm input[name='multipleFiles']");
|
|
115
|
+
this.onFilesChange($fileNameDiv, $fileInput);
|
|
116
|
+
|
|
117
|
+
$uploadForm.unblock();
|
|
118
|
+
}
|
|
119
|
+
})().catch(err => {
|
|
120
|
+
console.error("Error during upload:", err);
|
|
121
|
+
Toastify({
|
|
122
|
+
text: `Upload error: ${err}`,
|
|
123
|
+
duration: -1,
|
|
124
|
+
close: true,
|
|
125
|
+
style: { background: "linear-gradient(to right, #F39454, #FF6600)" }
|
|
126
|
+
}).showToast();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async uploadFile(file: File): Promise<void> {
|
|
132
|
+
try {
|
|
133
|
+
// Initialize upload
|
|
134
|
+
const initResp = await fetch("/upload/init", {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: { "Content-Type": "application/json" },
|
|
137
|
+
body: JSON.stringify({ fileName: file.name, fileSize: file.size })
|
|
138
|
+
});
|
|
139
|
+
const initData = await initResp.json();
|
|
140
|
+
const fileId: string = initData.fileId;
|
|
141
|
+
const chunkSize: number = initData.chunkSize;
|
|
142
|
+
const maxParallel: number = initData.maxParallel || 3;
|
|
143
|
+
const totalChunks: number = Math.ceil(file.size / chunkSize);
|
|
144
|
+
|
|
145
|
+
// Active upload pool
|
|
146
|
+
const pool: Promise<void>[] = [];
|
|
147
|
+
|
|
148
|
+
// Function to handle one chunk
|
|
149
|
+
const uploadChunkTask = async (chunkIndex: number) => {
|
|
150
|
+
const start = chunkIndex * chunkSize;
|
|
151
|
+
const end = Math.min(start + chunkSize, file.size);
|
|
152
|
+
const chunk = file.slice(start, end);
|
|
153
|
+
|
|
154
|
+
// Compute SHA-1 for just this chunk
|
|
155
|
+
const chunkHash = await computeSHA1(chunk);
|
|
156
|
+
|
|
157
|
+
// Check if chunk already exists on server
|
|
158
|
+
const statusResp = await fetch(
|
|
159
|
+
`/upload/status?fileId=${fileId}&chunkIndex=${chunkIndex}&chunkSize=${chunkSize}&hash=${chunkHash}`
|
|
160
|
+
);
|
|
161
|
+
const statusData = await statusResp.json();
|
|
162
|
+
console.log(`chunk-status for index ${chunkIndex}: hash-matches? ${statusData.hashMatches}`);
|
|
163
|
+
|
|
164
|
+
if (statusData.hashMatches) {
|
|
165
|
+
// already uploaded
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Upload chunk
|
|
170
|
+
const formData = new FormData();
|
|
171
|
+
formData.append("chunk", chunk, file.name);
|
|
172
|
+
await this.uploadChunk(fileId, chunkIndex, chunkHash, formData);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// Loop through chunks and dynamically manage pool
|
|
176
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
177
|
+
const taskPromise: Promise<void> = uploadChunkTask(i)
|
|
178
|
+
.finally(() => {
|
|
179
|
+
const index = pool.indexOf(taskPromise);
|
|
180
|
+
if (index > -1) {
|
|
181
|
+
// remove finished task
|
|
182
|
+
pool.splice(index, 1);
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
.catch(err => {
|
|
186
|
+
console.error(`Chunk ${i} failed:`, err);
|
|
187
|
+
throw err;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
pool.push(taskPromise);
|
|
191
|
+
|
|
192
|
+
// If pool is full, wait for at least one to finish
|
|
193
|
+
if (pool.length >= maxParallel) {
|
|
194
|
+
await Promise.race(pool).catch((err) => {
|
|
195
|
+
console.warn(`Pool full, but one task failed, err: ${err}`);
|
|
196
|
+
}); // don't block other tasks
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Wait for remaining chunks
|
|
201
|
+
await Promise.all(pool);
|
|
202
|
+
|
|
203
|
+
// Complete upload
|
|
204
|
+
let completeData: any = null;
|
|
205
|
+
let markUploadFailed = false;
|
|
206
|
+
for (let attempt = 1; attempt <= MAX_COMPLETE_CHECK_RETRIES; attempt++) {
|
|
207
|
+
markUploadFailed = attempt === MAX_COMPLETE_CHECK_RETRIES;
|
|
208
|
+
if (markUploadFailed) {
|
|
209
|
+
console.warn(`Marking upload as failed after ${attempt} retries...`);
|
|
210
|
+
}
|
|
211
|
+
const completeResp = await fetch(`/upload/complete?fileId=${fileId}&markUploadFailed=${markUploadFailed}`,
|
|
212
|
+
{
|
|
213
|
+
method: "POST"
|
|
214
|
+
});
|
|
215
|
+
completeData = await completeResp.json();
|
|
216
|
+
|
|
217
|
+
if (completeResp.ok && completeData.msg !== 'File incomplete') {
|
|
218
|
+
// Upload is confirmed complete
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
console.log(`Attempt ${attempt}: file still incomplete, retrying in ${COMPLETE_CHECK_RETRY_DELAY_MS}ms...`);
|
|
223
|
+
await new Promise(res => setTimeout(res, COMPLETE_CHECK_RETRY_DELAY_MS));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!completeData || completeData.msg === 'File incomplete') {
|
|
227
|
+
Toastify({
|
|
228
|
+
text: `Upload failed: file incomplete after ${MAX_COMPLETE_CHECK_RETRIES} retries`,
|
|
229
|
+
duration: -1,
|
|
230
|
+
close: true,
|
|
231
|
+
style: { background: "linear-gradient(to right, #F39454, #FF6600)" }
|
|
232
|
+
}).showToast();
|
|
233
|
+
} else {
|
|
234
|
+
Toastify({
|
|
235
|
+
text: `Upload complete: ${file.name}, saved to: ${completeData.savedLocation}`,
|
|
236
|
+
duration: -1,
|
|
237
|
+
close: true,
|
|
238
|
+
style: { background: "linear-gradient(to right, #00b09b, #96c93d)" }
|
|
239
|
+
}).showToast();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
} catch (err: any) {
|
|
243
|
+
console.error(`Error uploading file ${file.name}:`, err);
|
|
244
|
+
Toastify({
|
|
245
|
+
text: `Error uploading file ${file.name}, ${err}`,
|
|
246
|
+
duration: -1,
|
|
247
|
+
close: true,
|
|
248
|
+
style: { background: "linear-gradient(to right, #F39454, #FF6600)" }
|
|
249
|
+
}).showToast();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private async uploadChunk(fileId: string, chunkIndex: number, hash: string, formData: FormData) {
|
|
254
|
+
let uploaded = false;
|
|
255
|
+
while (!uploaded) {
|
|
256
|
+
try {
|
|
257
|
+
const chunkResp = await fetch(`/upload/chunk?fileId=${fileId}&chunkIndex=${chunkIndex}&hash=${hash}`, {
|
|
258
|
+
method: "POST",
|
|
259
|
+
body: formData
|
|
260
|
+
});
|
|
261
|
+
if (chunkResp.ok) {
|
|
262
|
+
uploaded = true;
|
|
263
|
+
} else {
|
|
264
|
+
console.warn(`Retrying chunk ${chunkIndex}...`);
|
|
265
|
+
await new Promise(res => setTimeout(res, 500));
|
|
266
|
+
}
|
|
267
|
+
} catch (err) {
|
|
268
|
+
console.error(`Chunk ${chunkIndex} upload error:`, err);
|
|
269
|
+
await new Promise(res => setTimeout(res, 500));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { io } from "socket.io-client";
|
|
2
|
+
import {FileTransferProgress} from "../src/model/progress";
|
|
3
|
+
import { ProgressUtils } from "../src/model/progress_utils";
|
|
4
|
+
import moment from "moment";
|
|
5
|
+
import prettyBytes from "pretty-bytes";
|
|
6
|
+
|
|
7
|
+
const _progressDivCache: Map<string, JQuery<HTMLElement>> = new Map();
|
|
8
|
+
const stateColorMap: Record<"COMPLETE" | "FAILED", string> = {
|
|
9
|
+
COMPLETE: "is-success",
|
|
10
|
+
FAILED: "is-danger",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export class ProgressHandler {
|
|
14
|
+
|
|
15
|
+
private addToDomCache(progressId: string, $panel: JQuery):void {
|
|
16
|
+
_progressDivCache.set(progressId, $panel);
|
|
17
|
+
this.updateCollapseAllVisibility();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private getFromDomCache(progressId: string): JQuery | undefined {
|
|
21
|
+
return _progressDivCache.get(progressId);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private domCacheSize(): number {
|
|
25
|
+
return _progressDivCache.size;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public registerHandler(): void {
|
|
29
|
+
console.log("Registering Progress Handler...");
|
|
30
|
+
const socket = io();
|
|
31
|
+
socket.emit("message", "Connected.");
|
|
32
|
+
|
|
33
|
+
socket.on("progresses", (progresses: FileTransferProgress[]) => {
|
|
34
|
+
this.handleProgresses(progresses);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
this.registerAllDetailToggleEventHandler();
|
|
38
|
+
console.log("Progress handler registration complete.");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private updateCollapseAllVisibility(): void {
|
|
42
|
+
const $control = $(".all-progress-detail-control");
|
|
43
|
+
if (this.domCacheSize() > 0) {
|
|
44
|
+
$control.removeClass("is-hidden");
|
|
45
|
+
} else {
|
|
46
|
+
$control.addClass("is-hidden");
|
|
47
|
+
}
|
|
48
|
+
const $parentElem = jQuery(".single-progress-container");
|
|
49
|
+
const $btn = jQuery(".all-progress-detail-control button");
|
|
50
|
+
const $progressDetailElems = $parentElem
|
|
51
|
+
.find("[id^='progressTableContainer-']");
|
|
52
|
+
const $hiddenProgressDetailElems = $parentElem
|
|
53
|
+
.find("[id^='progressTableContainer-'].is-hidden");
|
|
54
|
+
const allHidden = $hiddenProgressDetailElems.length === $progressDetailElems.length;
|
|
55
|
+
const buttonLabel = allHidden ? "Expand All" : "Collapse All";
|
|
56
|
+
$btn.text(buttonLabel);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private registerAllDetailToggleEventHandler(): void {
|
|
60
|
+
const $btn = jQuery(".all-progress-detail-control button");
|
|
61
|
+
|
|
62
|
+
$btn.on("click", () => {
|
|
63
|
+
const collapseAll = $btn.text().trim() === "Collapse All";
|
|
64
|
+
const newLabel = collapseAll ? "Expand All" : "Collapse All";
|
|
65
|
+
$btn.text(newLabel);
|
|
66
|
+
|
|
67
|
+
// Loop through every progress panel
|
|
68
|
+
jQuery(".single-progress-container").each((_, panel) => {
|
|
69
|
+
const $panel = jQuery(panel);
|
|
70
|
+
const $toggle = $panel.find(".progress-detail-control");
|
|
71
|
+
const $icon = $toggle.find("i");
|
|
72
|
+
|
|
73
|
+
// table container selector
|
|
74
|
+
const panelId = $panel.attr("id")!;
|
|
75
|
+
const tableContainerId = `progressTableContainer-${panelId}`;
|
|
76
|
+
const $tableContainer = jQuery(`#${tableContainerId}`);
|
|
77
|
+
|
|
78
|
+
if (collapseAll) {
|
|
79
|
+
// collapse everything
|
|
80
|
+
$toggle.removeClass("is-active");
|
|
81
|
+
$tableContainer.addClass("is-hidden");
|
|
82
|
+
$icon.removeClass("fa-minus-circle").addClass("fa-plus-circle")
|
|
83
|
+
.attr("title", "expand");
|
|
84
|
+
} else {
|
|
85
|
+
// expand everything
|
|
86
|
+
$toggle.addClass("is-active");
|
|
87
|
+
$tableContainer.removeClass("is-hidden");
|
|
88
|
+
$icon.removeClass("fa-plus-circle").addClass("fa-minus-circle")
|
|
89
|
+
.attr("title", "collapse");
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private registerDetailToggleEventHandler($panel: JQuery, tableContainerId: string) {
|
|
96
|
+
$panel.on("click", ".progress-detail-control", (event) => {
|
|
97
|
+
const $btn = jQuery(event.currentTarget);
|
|
98
|
+
|
|
99
|
+
const $tableContainer = jQuery(`#${tableContainerId}`);
|
|
100
|
+
|
|
101
|
+
// toggle active class
|
|
102
|
+
const isActive: boolean = $btn.toggleClass("is-active").hasClass("is-active");
|
|
103
|
+
|
|
104
|
+
// toggle content visibility
|
|
105
|
+
if (isActive) {
|
|
106
|
+
$tableContainer.removeClass("is-hidden");
|
|
107
|
+
} else {
|
|
108
|
+
$tableContainer.addClass("is-hidden");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// toggle icon
|
|
112
|
+
const $icon = $btn.find("i");
|
|
113
|
+
|
|
114
|
+
if (isActive) {
|
|
115
|
+
// expanded → show minus
|
|
116
|
+
$icon.removeClass("fa-plus-circle").addClass("fa-minus-circle");
|
|
117
|
+
$icon.attr("title", "collapse");
|
|
118
|
+
} else {
|
|
119
|
+
// collapsed → show plus
|
|
120
|
+
$icon.removeClass("fa-minus-circle").addClass("fa-plus-circle");
|
|
121
|
+
$icon.attr("title", "expand");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// no need for _this alias
|
|
125
|
+
this.updateCollapseAllVisibility();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private handleProgresses(progresses: FileTransferProgress[]): void {
|
|
130
|
+
const $progressContainer = jQuery("div#progress-container");
|
|
131
|
+
|
|
132
|
+
progresses.forEach(progress => {
|
|
133
|
+
const progressId = `progress-${progress.uuid}`;
|
|
134
|
+
const uploaded = (progress.uploadedChunks as any)?.length ?? 0;
|
|
135
|
+
const uploading = (progress.uploadingChunks as any)?.length ?? 0;
|
|
136
|
+
const totalChunks = progress.totalChunks ?? 1;
|
|
137
|
+
|
|
138
|
+
const colorKey = progress.lastState as keyof typeof stateColorMap;
|
|
139
|
+
const stateColor = stateColorMap[colorKey] || "";
|
|
140
|
+
|
|
141
|
+
// Container box
|
|
142
|
+
let $panel = this.getFromDomCache(progressId);
|
|
143
|
+
const tableContainerId = `progressTableContainer-${progressId}`;
|
|
144
|
+
if (!$panel) {
|
|
145
|
+
$panel = jQuery(`<article id="${progressId}" class="progress-panel panel is-bordered single-progress-container mb-4"></article>`);
|
|
146
|
+
$progressContainer.prepend($panel);
|
|
147
|
+
this.addToDomCache(progressId, $panel);
|
|
148
|
+
this.registerDetailToggleEventHandler($panel, tableContainerId);
|
|
149
|
+
}
|
|
150
|
+
// Remove existing progress colors
|
|
151
|
+
$panel.removeClass("is-success is-danger is-info is-warning");
|
|
152
|
+
$panel.addClass(stateColor || "is-info");
|
|
153
|
+
|
|
154
|
+
// Add file name as a heading above the table
|
|
155
|
+
const panelHeadingId = `panelHeading-${progressId}`;
|
|
156
|
+
let $panelHeading = $panel.find(`#${panelHeadingId}`);
|
|
157
|
+
if (!$panelHeading.length) {
|
|
158
|
+
$panelHeading = jQuery(`<div id="${panelHeadingId}" class="panel-heading wrap-text is-flex-wrap-wrap p-2 has-text-weight-normal is-size-6 mb-1">
|
|
159
|
+
<a class="progress-detail-control is-active ml-1 mr-1">
|
|
160
|
+
<span class="panel-icon m-0 p-0">
|
|
161
|
+
<i class="fas fa-minus-circle m-0 p-0" aria-hidden="true" title="collapse"></i>
|
|
162
|
+
</span>
|
|
163
|
+
</a>
|
|
164
|
+
<span class="ml-0 pl-0">
|
|
165
|
+
${progress.fileName}
|
|
166
|
+
</span>
|
|
167
|
+
</div>`);
|
|
168
|
+
$panel.append($panelHeading);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Main progress bar (bytes)
|
|
172
|
+
let $progressElem = $panel.find(`progress#${progressId}`);
|
|
173
|
+
if (!$progressElem.length) {
|
|
174
|
+
$progressElem = jQuery(`<progress id="${progressId}" class="progress is-info is-small">`);
|
|
175
|
+
$panel.append($progressElem);
|
|
176
|
+
}
|
|
177
|
+
$progressElem.attr("max", (progress.bytesExpected || 100));
|
|
178
|
+
$progressElem.attr("value", (progress.bytesReceived || 0));
|
|
179
|
+
// Remove existing progress colors
|
|
180
|
+
$progressElem.removeClass("is-success is-danger is-info is-warning");
|
|
181
|
+
$progressElem.addClass(stateColor || "is-info");
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
let $tableContainer = $panel.find(`#${tableContainerId}`);
|
|
185
|
+
if (!$tableContainer.length) {
|
|
186
|
+
$tableContainer = jQuery(`<div id="${tableContainerId}" class="panel-block table-container m-0 p-0"></div>`);
|
|
187
|
+
$panel.append($tableContainer);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const tableId = `progressTable-${progressId}`;
|
|
191
|
+
let $table = $tableContainer.find(`#${tableId}`);
|
|
192
|
+
if (!$table.length) {
|
|
193
|
+
$table = jQuery(`<table id="${tableId}" class="table is-fullwidth is-bordered is-striped"></table>`);
|
|
194
|
+
$tableContainer.append($table);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Clear previous rows
|
|
198
|
+
$table.empty();
|
|
199
|
+
|
|
200
|
+
// Define table rows
|
|
201
|
+
const rows: [string, string][] = [
|
|
202
|
+
// ["File Name", progress.fileName || "-"],
|
|
203
|
+
// ["Location", progress.savedLocation || "-"],
|
|
204
|
+
["Started", moment(progress.timestamp).format("MMMM Do YYYY, h:mm:ss a")],
|
|
205
|
+
["Transferred", `${prettyBytes(progress.bytesReceived || 0)} / ${prettyBytes(progress.bytesExpected || 0)}`],
|
|
206
|
+
["Chunks", `Verified ${progress.chunkVerificationCount || 0}
|
|
207
|
+
<b>|</b> Uploading: ${uploading}
|
|
208
|
+
<b>|</b> Uploaded: ${uploaded}/${totalChunks}`],
|
|
209
|
+
["Speed", `${prettyBytes(ProgressUtils.calculateTransferRate(progress))}/s`],
|
|
210
|
+
["Status", `${progress.lastState || "-"}`],
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
if (progress.completed) {
|
|
214
|
+
const timeTaken = ((progress.completed - (progress.timestamp || 0)) / 1000).toFixed(2);
|
|
215
|
+
rows.push(["Completed in", `${timeTaken} sec`]);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Append rows
|
|
219
|
+
for (const [key, value] of rows) {
|
|
220
|
+
$table.append(`<tr><th class="is-narrow">${key}</th><td>${value}</td></tr>`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const horizontalRulerId = `horizontalRuler-${progressId}`;
|
|
224
|
+
let $horizontalRuler = $panel.find(`#${horizontalRulerId}`);
|
|
225
|
+
if (!$horizontalRuler.length) {
|
|
226
|
+
$horizontalRuler = jQuery(`<hr id="${horizontalRulerId}" class="is-one-third mt-3 mb-1">`);
|
|
227
|
+
$panel.append($horizontalRuler);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
this.updateCollapseAllVisibility();
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>File Uploader!</title>
|
|
7
|
+
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="content m-2">
|
|
11
|
+
<div class="fixed-grid container has-4-columns mt-4">
|
|
12
|
+
<div class="grid">
|
|
13
|
+
<div class="cell is-col-span-2">
|
|
14
|
+
<section>
|
|
15
|
+
<div>
|
|
16
|
+
Hello there from file-uploader server.
|
|
17
|
+
</div>
|
|
18
|
+
<div>
|
|
19
|
+
<code class="">File Uploader,</code> you know for file uploads.
|
|
20
|
+
</div>
|
|
21
|
+
</section>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="cell is-col-from-end-1">
|
|
24
|
+
<section class="mt-4 m-2 is-pulled-right mr-6">
|
|
25
|
+
<div class="is-position-absolute bulma-is-fixed-top is-clickable" id="themeToggle">
|
|
26
|
+
<i class="fas fa-moon" id="themeIcon"></i>
|
|
27
|
+
</div>
|
|
28
|
+
</section>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="container is-one-third">
|
|
33
|
+
<form id="uploadForm" action="/upload" enctype="multipart/form-data" method="post">
|
|
34
|
+
<div id="file-div" class="field file has-name is-boxed column is-flex-grow-1">
|
|
35
|
+
<label class="file-label">
|
|
36
|
+
<input class="file-input" type="file" name="multipleFiles" multiple="multiple">
|
|
37
|
+
<span class="file-cta">
|
|
38
|
+
<span class="file-icon">
|
|
39
|
+
<i class="fas fa-upload"></i>
|
|
40
|
+
</span>
|
|
41
|
+
<span class="file-label">
|
|
42
|
+
Choose file(s)…
|
|
43
|
+
</span>
|
|
44
|
+
</span>
|
|
45
|
+
</label>
|
|
46
|
+
<div id="file-name" class="mt-1 wrap-text is-multiline"></div>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div class="control field is-flex-grow-1">
|
|
50
|
+
<button type="submit" class="button is-link">Submit</button>
|
|
51
|
+
</div>
|
|
52
|
+
</form>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<hr class="is-one-third"/>
|
|
56
|
+
<div class="container">
|
|
57
|
+
<section class="m-2 is-one-third">
|
|
58
|
+
<h4>Progress:</h4>
|
|
59
|
+
<div class="all-progress-detail-control control field is-flex-grow-1 is-active p-1 is-hidden">
|
|
60
|
+
<button type="button" class="button is-link">Collapse All</button>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="container" id="progress-container"></div>
|
|
63
|
+
</section>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</body>
|
|
67
|
+
</html>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { sha1 } from "js-sha1";
|
|
2
|
+
|
|
3
|
+
export const computeSHA1 = async (blob: Blob): Promise<string> => {
|
|
4
|
+
if (window.crypto && crypto.subtle && crypto.subtle.digest) {
|
|
5
|
+
try {
|
|
6
|
+
const buffer = await blob.arrayBuffer();
|
|
7
|
+
const hashBuffer = await crypto.subtle.digest("SHA-1", buffer);
|
|
8
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
9
|
+
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
|
|
10
|
+
} catch (err) {
|
|
11
|
+
console.warn("WebCrypto SHA-1 failed, falling back to js-sha1:", err);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Fallback to js-sha1
|
|
16
|
+
const arrayBuffer = await blob.arrayBuffer();
|
|
17
|
+
const bytes = new Uint8Array(arrayBuffer);
|
|
18
|
+
return sha1(bytes);
|
|
19
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
@import "bulma/css/bulma.min.css";
|
|
2
|
+
@import "bulma/sass/themes/light";
|
|
3
|
+
@import "bulma/sass/themes/dark";
|
|
4
|
+
@import "toastify-js/src/toastify.css";
|
|
5
|
+
@import "@fortawesome/fontawesome-free/css/all.min.css";
|
|
6
|
+
|
|
7
|
+
:root {
|
|
8
|
+
--file-item-hover-bg: rgba(0, 0, 0, 0.05); // light theme default
|
|
9
|
+
--file-item-border-color: #9e9e9e;
|
|
10
|
+
--file-item-icon-color: #9e9e9e;
|
|
11
|
+
|
|
12
|
+
--upload-block-modal-bg: #ffffff;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
[data-theme="dark"] {
|
|
16
|
+
--file-item-hover-bg: rgba(255, 255, 255, 0.1); // dark theme
|
|
17
|
+
--file-item-border-color: #6e6e6e;
|
|
18
|
+
--file-item-icon-color: #b0b0b0;
|
|
19
|
+
|
|
20
|
+
--upload-block-modal-bg: #1e1e1e;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
h1.upload-block-modal {
|
|
24
|
+
background-color: var(--upload-block-modal-bg);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#file-name.wrap-text {
|
|
28
|
+
white-space: normal;
|
|
29
|
+
word-break: break-word;
|
|
30
|
+
overflow-wrap: anywhere;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#file-name ul {
|
|
34
|
+
list-style-type: none;
|
|
35
|
+
padding: 0;
|
|
36
|
+
margin: 0 1rem;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#file-name li {
|
|
40
|
+
position: relative;
|
|
41
|
+
padding-left: 1.5em;
|
|
42
|
+
margin-bottom: 0.25em;
|
|
43
|
+
border-bottom: 1px solid var(--file-item-border-color);
|
|
44
|
+
transition: background-color 0.2s ease;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#file-name li:first-child {
|
|
48
|
+
border-top: 1px solid var(--file-item-border-color);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#file-name li:hover {
|
|
52
|
+
background-color: var(--file-item-hover-bg);
|
|
53
|
+
cursor: pointer;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#file-name li::before {
|
|
57
|
+
content: "\f15b";
|
|
58
|
+
font-family: "Font Awesome 5 Free";
|
|
59
|
+
font-weight: 900;
|
|
60
|
+
position: absolute;
|
|
61
|
+
left: 0;
|
|
62
|
+
color: var(--file-item-icon-color);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.progress-panel {
|
|
66
|
+
box-shadow: none;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.panel-heading.wrap-text {
|
|
70
|
+
border-radius: 5px 5px 0 0;
|
|
71
|
+
white-space: normal;
|
|
72
|
+
word-break: break-word;
|
|
73
|
+
overflow-wrap: anywhere;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.progress.is-small {
|
|
77
|
+
height: 0.25rem;
|
|
78
|
+
margin-bottom: 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.panel-heading a.progress-detail-control .panel-icon {
|
|
82
|
+
vertical-align: middle;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.panel-heading a.progress-detail-control .fas {
|
|
86
|
+
color: blue;
|
|
87
|
+
}
|