@noxlovette/material 0.1.16 → 0.1.18
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/dist/components/buttons/ButtonIcon.svelte +1 -1
- package/dist/components/containers/dialogue/Dialogue.svelte +2 -0
- package/dist/components/containers/dialogue/types.d.ts +2 -0
- package/dist/components/forms/index.d.ts +0 -1
- package/dist/components/forms/index.js +0 -1
- package/package.json +1 -1
- package/dist/components/forms/multipart/Multipart.svelte +0 -531
- package/dist/components/forms/multipart/Multipart.svelte.d.ts +0 -51
- package/dist/components/forms/multipart/index.d.ts +0 -1
- package/dist/components/forms/multipart/index.js +0 -3
|
@@ -49,7 +49,7 @@ Icon buttons help people take minor actions and are often used in app bars.
|
|
|
49
49
|
</script>
|
|
50
50
|
|
|
51
51
|
<Tooltip {triggerClass} variant="snack" supportingText={tooltipContent}>
|
|
52
|
-
{#snippet trigger({ class: triggerClass_, ...triggerAttrs })}
|
|
52
|
+
{#snippet trigger({ class: triggerClass_, type: _tooltipType, ...triggerAttrs })}
|
|
53
53
|
{#if variation === 'toggle'}
|
|
54
54
|
<Toggle.Root
|
|
55
55
|
bind:pressed
|
|
@@ -34,6 +34,7 @@ on screen until confirmed, dismissed, or a required action has been taken.
|
|
|
34
34
|
delegateClose = false,
|
|
35
35
|
class: className,
|
|
36
36
|
contentProps,
|
|
37
|
+
formProps,
|
|
37
38
|
...rootRest
|
|
38
39
|
}: DialogueProps = $props();
|
|
39
40
|
|
|
@@ -65,6 +66,7 @@ on screen until confirmed, dismissed, or a required action has been taken.
|
|
|
65
66
|
action={confirmAction}
|
|
66
67
|
use:enhance
|
|
67
68
|
{...props}
|
|
69
|
+
{...formProps}
|
|
68
70
|
class={inner({ class: className })}
|
|
69
71
|
style:min-width={minWidth}
|
|
70
72
|
style:max-width={maxWidth}
|
|
@@ -37,6 +37,8 @@ export type DialogueProps = DialogueVariants & Dialog.RootProps & {
|
|
|
37
37
|
maxWidth?: string;
|
|
38
38
|
/** Additional props */
|
|
39
39
|
contentProps?: Dialog.ContentProps;
|
|
40
|
+
/** Additional props */
|
|
41
|
+
formProps?: Record<string, unknown>;
|
|
40
42
|
/** Class for the inner content */
|
|
41
43
|
class?: string;
|
|
42
44
|
/** Optional callback when the confirm button is clicked. */
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
// folder re-exports (export everything from these folders)
|
|
3
3
|
export * from './checkbox/index.js';
|
|
4
4
|
export * from './command/index.js';
|
|
5
|
-
export * from './multipart/index.js';
|
|
6
5
|
export * from './pin/index.js';
|
|
7
6
|
export * from './radio-group/index.js';
|
|
8
7
|
export * from './search/index.js';
|
package/package.json
CHANGED
|
@@ -1,531 +0,0 @@
|
|
|
1
|
-
<!--
|
|
2
|
-
@component
|
|
3
|
-
@component
|
|
4
|
-
Multipart file upload component that supports chunked uploads for large files.
|
|
5
|
-
Follows Material 3 design principles for file uploaders.
|
|
6
|
-
-->
|
|
7
|
-
<script lang="ts">
|
|
8
|
-
import { onDestroy } from 'svelte';
|
|
9
|
-
import { formatPercentage, Layer } from '../../../utils/index.js';
|
|
10
|
-
import Icon from '../../../utils/icon/Icon.svelte';
|
|
11
|
-
import ButtonIcon from '../../buttons/ButtonIcon.svelte';
|
|
12
|
-
import CircularProgress from '../../progress/CircularProgress.svelte';
|
|
13
|
-
import Label from '../../typography/label/Label.svelte';
|
|
14
|
-
import Title from '../../typography/title/Title.svelte';
|
|
15
|
-
import HStack from '../../containers/stack/HStack.svelte';
|
|
16
|
-
import Dialogue from '../../containers/dialogue/Dialogue.svelte';
|
|
17
|
-
import { nanoid } from 'nanoid';
|
|
18
|
-
|
|
19
|
-
type UploadStatus = 'waiting' | 'uploading' | 'complete' | 'error';
|
|
20
|
-
|
|
21
|
-
interface PartUploadUrl {
|
|
22
|
-
partNumber: number;
|
|
23
|
-
url: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
interface InitResponse {
|
|
27
|
-
uploadId: string;
|
|
28
|
-
fileId: string;
|
|
29
|
-
s3Key: string;
|
|
30
|
-
parts: PartUploadUrl[];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
interface CompletedPart {
|
|
34
|
-
partNumber: number;
|
|
35
|
-
etag: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Internal state for a single file upload.
|
|
40
|
-
*/
|
|
41
|
-
interface FileUploadState {
|
|
42
|
-
/** Unique identifier for the upload session */
|
|
43
|
-
id: string;
|
|
44
|
-
/** The file being uploaded */
|
|
45
|
-
file: File;
|
|
46
|
-
/** Upload progress tracking */
|
|
47
|
-
progress: {
|
|
48
|
-
/** Number of chunks uploaded */
|
|
49
|
-
uploaded: number;
|
|
50
|
-
/** Total number of chunks */
|
|
51
|
-
total: number;
|
|
52
|
-
/** Total bytes uploaded */
|
|
53
|
-
bytes: number;
|
|
54
|
-
/** Total size of the file in bytes */
|
|
55
|
-
totalBytes: number;
|
|
56
|
-
/** Percentage of upload completed (0-100) */
|
|
57
|
-
percentComplete: number;
|
|
58
|
-
};
|
|
59
|
-
/** Current status of the upload */
|
|
60
|
-
status: UploadStatus;
|
|
61
|
-
/** Optional error message if status is 'error' */
|
|
62
|
-
errorMessage?: string;
|
|
63
|
-
/** AbortController for cancelling the upload */
|
|
64
|
-
abortController?: AbortController;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
let {
|
|
68
|
-
taskId = null,
|
|
69
|
-
folderId = null,
|
|
70
|
-
readonly = false,
|
|
71
|
-
initialUploads = [],
|
|
72
|
-
title = 'Загрузка файлов',
|
|
73
|
-
label = 'Область загрузки файлов'
|
|
74
|
-
}: {
|
|
75
|
-
/** Unique ID of the task associated with these uploads */
|
|
76
|
-
taskId?: string | null;
|
|
77
|
-
/** ID of the folder where files will be stored */
|
|
78
|
-
folderId?: string | null;
|
|
79
|
-
/** Whether the upload area is interactive */
|
|
80
|
-
readonly?: boolean;
|
|
81
|
-
/** Initial list of files to display */
|
|
82
|
-
initialUploads?: FileUploadState[];
|
|
83
|
-
/** Title displayed above the upload area */
|
|
84
|
-
title?: string;
|
|
85
|
-
/** Accessibility label for the upload zone */
|
|
86
|
-
label?: string;
|
|
87
|
-
} = $props();
|
|
88
|
-
|
|
89
|
-
let fileUploads: FileUploadState[] = $state([...initialUploads]);
|
|
90
|
-
let fileToRemove = $state<FileUploadState | null>(null);
|
|
91
|
-
let fileInputRef = $state<HTMLInputElement>();
|
|
92
|
-
|
|
93
|
-
const CHUNK_SIZE = 5 * 1024 * 1024;
|
|
94
|
-
const MAX_FILE_SIZE = 99 * 1024 * 1024; // 99MB
|
|
95
|
-
|
|
96
|
-
function calculateChunks(file: File): number {
|
|
97
|
-
return Math.ceil(file.size / CHUNK_SIZE);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async function handleFileSelect(event: Event) {
|
|
101
|
-
if (readonly) return;
|
|
102
|
-
|
|
103
|
-
const input = event.target as HTMLInputElement;
|
|
104
|
-
if (!input.files?.length) return;
|
|
105
|
-
|
|
106
|
-
const validFiles: File[] = [];
|
|
107
|
-
const rejectedFiles: string[] = [];
|
|
108
|
-
|
|
109
|
-
Array.from(input.files).forEach((file) => {
|
|
110
|
-
if (file.size > MAX_FILE_SIZE) {
|
|
111
|
-
rejectedFiles.push(file.name);
|
|
112
|
-
} else {
|
|
113
|
-
validFiles.push(file);
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
const newFiles = validFiles.map((file) => ({
|
|
118
|
-
id: nanoid(),
|
|
119
|
-
file,
|
|
120
|
-
progress: {
|
|
121
|
-
uploaded: 0,
|
|
122
|
-
total: calculateChunks(file),
|
|
123
|
-
bytes: 0,
|
|
124
|
-
totalBytes: file.size,
|
|
125
|
-
percentComplete: 0
|
|
126
|
-
},
|
|
127
|
-
status: 'waiting' as UploadStatus
|
|
128
|
-
}));
|
|
129
|
-
|
|
130
|
-
fileUploads = [...fileUploads, ...newFiles];
|
|
131
|
-
|
|
132
|
-
input.value = '';
|
|
133
|
-
startUploads();
|
|
134
|
-
}
|
|
135
|
-
function startUploads() {
|
|
136
|
-
if (readonly) return;
|
|
137
|
-
fileUploads.filter((upload) => upload.status === 'waiting').forEach(uploadFile);
|
|
138
|
-
}
|
|
139
|
-
async function uploadFile(fileState: FileUploadState) {
|
|
140
|
-
if (readonly) return;
|
|
141
|
-
const { id, file } = fileState;
|
|
142
|
-
let uploadIdLocal = '';
|
|
143
|
-
let fileIdLocal = '';
|
|
144
|
-
let s3Key = '';
|
|
145
|
-
|
|
146
|
-
try {
|
|
147
|
-
fileState.status = 'uploading';
|
|
148
|
-
fileState.abortController = new AbortController();
|
|
149
|
-
|
|
150
|
-
const initResponse = await fetch('/api/multipart/init', {
|
|
151
|
-
method: 'POST',
|
|
152
|
-
headers: { 'Content-Type': 'application/json' },
|
|
153
|
-
body: JSON.stringify({
|
|
154
|
-
fileName: file.name,
|
|
155
|
-
contentType: file.type || 'application/octet-stream',
|
|
156
|
-
fileSize: file.size,
|
|
157
|
-
totalParts: calculateChunks(file),
|
|
158
|
-
parentId: folderId,
|
|
159
|
-
taskId: taskId
|
|
160
|
-
})
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
if (!initResponse.ok) {
|
|
164
|
-
throw new Error(`Failed to initialize upload: ${await initResponse.text()}`);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const {
|
|
168
|
-
uploadId,
|
|
169
|
-
fileId,
|
|
170
|
-
s3Key: fileKey,
|
|
171
|
-
parts
|
|
172
|
-
} = (await initResponse.json()) as InitResponse;
|
|
173
|
-
uploadIdLocal = uploadId;
|
|
174
|
-
fileIdLocal = fileId;
|
|
175
|
-
s3Key = fileKey;
|
|
176
|
-
|
|
177
|
-
const signal = fileState.abortController.signal;
|
|
178
|
-
const completedParts: CompletedPart[] = [];
|
|
179
|
-
|
|
180
|
-
for (let i = 0; i < parts.length; i++) {
|
|
181
|
-
if (signal.aborted) {
|
|
182
|
-
throw new Error('Upload aborted by user');
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const { partNumber, url } = parts[i];
|
|
186
|
-
const start = (partNumber - 1) * CHUNK_SIZE;
|
|
187
|
-
const end = Math.min(file.size, start + CHUNK_SIZE);
|
|
188
|
-
const chunk = file.slice(start, end);
|
|
189
|
-
|
|
190
|
-
try {
|
|
191
|
-
const uploadResult = await uploadWithProgress(url, chunk, signal, (loaded) => {
|
|
192
|
-
// LE FIX: calcul propre du progress
|
|
193
|
-
const previousBytes = (partNumber - 1) * CHUNK_SIZE;
|
|
194
|
-
fileState.progress.bytes = previousBytes + loaded;
|
|
195
|
-
fileState.progress.percentComplete = (fileState.progress.bytes / file.size) * 100;
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
if (!uploadResult.ok) {
|
|
199
|
-
throw new Error(`Failed to upload part ${partNumber}: ${uploadResult.statusText}`);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const etag = uploadResult.headers.get('ETag')?.replace(/['"]/g, '') || '';
|
|
203
|
-
completedParts.push({ partNumber, etag });
|
|
204
|
-
|
|
205
|
-
// Pas besoin de double update ici, c'est déjà fait dans le callback
|
|
206
|
-
fileState.progress.uploaded = i + 1;
|
|
207
|
-
} catch (error) {
|
|
208
|
-
throw new Error('Failed to upload');
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// 3. Complete the multipart upload
|
|
213
|
-
const completeResponse = await fetch(`/api/multipart/complete?taskId=${taskId}`, {
|
|
214
|
-
method: 'POST',
|
|
215
|
-
headers: { 'Content-Type': 'application/json' },
|
|
216
|
-
body: JSON.stringify({
|
|
217
|
-
uploadId,
|
|
218
|
-
fileId: fileIdLocal,
|
|
219
|
-
s3Key,
|
|
220
|
-
parts: completedParts
|
|
221
|
-
})
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
if (!completeResponse.ok) {
|
|
225
|
-
throw new Error(`Failed to complete upload: ${await completeResponse.text()}`);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
fileState.status = 'complete';
|
|
229
|
-
} catch (error: any) {
|
|
230
|
-
fileState.status = 'error';
|
|
231
|
-
fileState.errorMessage = 'Ошибка загрузки';
|
|
232
|
-
|
|
233
|
-
// Try to abort the upload on S3 if it was initialized
|
|
234
|
-
if (uploadIdLocal && s3Key && fileIdLocal) {
|
|
235
|
-
try {
|
|
236
|
-
await fetch('/api/multipart/abort', {
|
|
237
|
-
method: 'POST',
|
|
238
|
-
headers: { 'Content-Type': 'application/json' },
|
|
239
|
-
body: JSON.stringify({
|
|
240
|
-
uploadId: uploadIdLocal,
|
|
241
|
-
fileId: fileIdLocal,
|
|
242
|
-
s3Key
|
|
243
|
-
})
|
|
244
|
-
});
|
|
245
|
-
} catch (abortError: any) {
|
|
246
|
-
console.error(abortError);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// Cancel an upload
|
|
253
|
-
function cancelUpload(fileState: FileUploadState) {
|
|
254
|
-
fileState.abortController?.abort();
|
|
255
|
-
fileState.status = 'error';
|
|
256
|
-
fileState.errorMessage = 'Загрузка отменена пользователем';
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Remove a file from the list
|
|
260
|
-
function removeFile(fileState: FileUploadState) {
|
|
261
|
-
// Cancel upload if in progress
|
|
262
|
-
if (fileState.status === 'uploading') {
|
|
263
|
-
cancelUpload(fileState);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// Remove from list
|
|
267
|
-
fileUploads = fileUploads.filter((upload) => upload.id !== fileState.id);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Clean up abort controllers on component destroy
|
|
271
|
-
onDestroy(() => {
|
|
272
|
-
fileUploads.forEach((upload) => {
|
|
273
|
-
upload.abortController?.abort();
|
|
274
|
-
});
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
// Upload with progress tracking
|
|
278
|
-
async function uploadWithProgress(
|
|
279
|
-
url: string,
|
|
280
|
-
data: Blob,
|
|
281
|
-
signal: AbortSignal,
|
|
282
|
-
onProgress: (loaded: number) => void
|
|
283
|
-
): Promise<Response> {
|
|
284
|
-
return new Promise((resolve, reject) => {
|
|
285
|
-
const xhr = new XMLHttpRequest();
|
|
286
|
-
|
|
287
|
-
xhr.upload.addEventListener('progress', (event) => {
|
|
288
|
-
if (event.lengthComputable) {
|
|
289
|
-
onProgress(event.loaded);
|
|
290
|
-
}
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
xhr.addEventListener('loadend', () => {
|
|
294
|
-
if (xhr.readyState === 4) {
|
|
295
|
-
if (xhr.status >= 200 && xhr.status < 300) {
|
|
296
|
-
// Convert XHR response to fetch Response for consistency
|
|
297
|
-
const response = new Response(xhr.response, {
|
|
298
|
-
status: xhr.status,
|
|
299
|
-
statusText: xhr.statusText,
|
|
300
|
-
headers: new Headers({
|
|
301
|
-
ETag: xhr.getResponseHeader('ETag') || ''
|
|
302
|
-
})
|
|
303
|
-
});
|
|
304
|
-
resolve(response);
|
|
305
|
-
} else {
|
|
306
|
-
reject(new Error(`HTTP error ${xhr.status}: ${xhr.statusText}`));
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
xhr.addEventListener('error', () => {
|
|
312
|
-
reject(new Error('Network error occurred'));
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
xhr.addEventListener('abort', () => {
|
|
316
|
-
reject(new Error('Upload aborted'));
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
// Initialize the request
|
|
320
|
-
xhr.open('PUT', url);
|
|
321
|
-
xhr.send(data);
|
|
322
|
-
|
|
323
|
-
// Handle abort signal
|
|
324
|
-
if (signal) {
|
|
325
|
-
signal.addEventListener('abort', () => {
|
|
326
|
-
xhr.abort();
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
});
|
|
330
|
-
}
|
|
331
|
-
let isDragging = $state(false);
|
|
332
|
-
|
|
333
|
-
function handleDragOver(e: DragEvent) {
|
|
334
|
-
if (readonly) return;
|
|
335
|
-
e.preventDefault();
|
|
336
|
-
isDragging = true;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
function handleDragLeave() {
|
|
340
|
-
isDragging = false;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function handleDrop(e: DragEvent) {
|
|
344
|
-
if (readonly) return;
|
|
345
|
-
e.preventDefault();
|
|
346
|
-
isDragging = false;
|
|
347
|
-
|
|
348
|
-
if (!e.dataTransfer?.files.length) return;
|
|
349
|
-
|
|
350
|
-
// Create a synthetic event that mimics an input change event
|
|
351
|
-
const mockEvent = {
|
|
352
|
-
target: {
|
|
353
|
-
files: e.dataTransfer.files
|
|
354
|
-
}
|
|
355
|
-
} as unknown as Event;
|
|
356
|
-
|
|
357
|
-
// Use your existing file handling function
|
|
358
|
-
handleFileSelect(mockEvent);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function triggerBrowse() {
|
|
362
|
-
if (readonly) return;
|
|
363
|
-
fileInputRef?.click();
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
function handleKeydown(e: KeyboardEvent) {
|
|
367
|
-
if (readonly) return;
|
|
368
|
-
if (e.key === 'Enter' || e.key === ' ') {
|
|
369
|
-
e.preventDefault();
|
|
370
|
-
triggerBrowse();
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const statusTone = (status: UploadStatus) => {
|
|
375
|
-
switch (status) {
|
|
376
|
-
case 'complete':
|
|
377
|
-
return 'bg-md-sys-color-primary-container text-md-sys-color-on-primary-container';
|
|
378
|
-
case 'error':
|
|
379
|
-
return 'bg-md-sys-color-error/12 text-md-sys-color-error';
|
|
380
|
-
case 'uploading':
|
|
381
|
-
return 'bg-md-sys-color-primary/10 text-md-sys-color-primary';
|
|
382
|
-
default:
|
|
383
|
-
return 'bg-md-sys-color-surface-variant text-md-sys-color-on-surface-variant';
|
|
384
|
-
}
|
|
385
|
-
};
|
|
386
|
-
|
|
387
|
-
const statusIcon = (status: UploadStatus) => {
|
|
388
|
-
switch (status) {
|
|
389
|
-
case 'complete':
|
|
390
|
-
return 'check_circle';
|
|
391
|
-
case 'error':
|
|
392
|
-
return 'error';
|
|
393
|
-
case 'uploading':
|
|
394
|
-
return 'cloud_upload';
|
|
395
|
-
default:
|
|
396
|
-
return 'schedule';
|
|
397
|
-
}
|
|
398
|
-
};
|
|
399
|
-
|
|
400
|
-
const statusLabel = (fileState: FileUploadState) => {
|
|
401
|
-
switch (fileState.status) {
|
|
402
|
-
case 'complete':
|
|
403
|
-
return 'Готово';
|
|
404
|
-
case 'error':
|
|
405
|
-
return 'Ошибка';
|
|
406
|
-
case 'uploading':
|
|
407
|
-
return `Загрузка — ${formatPercentage(fileState.progress.percentComplete)}%`;
|
|
408
|
-
default:
|
|
409
|
-
return 'В очереди';
|
|
410
|
-
}
|
|
411
|
-
};
|
|
412
|
-
|
|
413
|
-
const progressWidth = (fileState: FileUploadState) =>
|
|
414
|
-
fileState.status === 'complete' ? 100 : formatPercentage(fileState.progress.percentComplete);
|
|
415
|
-
</script>
|
|
416
|
-
|
|
417
|
-
<HStack>
|
|
418
|
-
<Title>{title}</Title>
|
|
419
|
-
<Label>Максимальный размер файла — 100 Мб.</Label>
|
|
420
|
-
|
|
421
|
-
<label
|
|
422
|
-
for="fileInput"
|
|
423
|
-
ondragover={handleDragOver}
|
|
424
|
-
ondragleave={handleDragLeave}
|
|
425
|
-
ondrop={handleDrop}
|
|
426
|
-
aria-label="Область загрузки файлов"
|
|
427
|
-
aria-disabled={readonly}
|
|
428
|
-
class={`group state-layer relative block ${readonly ? 'cursor-not-allowed opacity-80' : 'cursor-pointer'} focus-within:outline-md-sys-color-primary rounded-lg p-4 focus-within:outline-2 ${
|
|
429
|
-
isDragging
|
|
430
|
-
? ' bg-md-sys-color-primary/6 shadow-elevation-1'
|
|
431
|
-
: ' bg-md-sys-color-surface-container-high '
|
|
432
|
-
}`}
|
|
433
|
-
>
|
|
434
|
-
<input
|
|
435
|
-
id="fileInput"
|
|
436
|
-
type="file"
|
|
437
|
-
name="file"
|
|
438
|
-
onchange={handleFileSelect}
|
|
439
|
-
multiple
|
|
440
|
-
class="sr-only"
|
|
441
|
-
disabled={readonly}
|
|
442
|
-
/>
|
|
443
|
-
|
|
444
|
-
<div class="flex flex-col items-center gap-3 text-center">
|
|
445
|
-
<div
|
|
446
|
-
class={`flex size-12 items-center justify-center rounded-full ${
|
|
447
|
-
isDragging
|
|
448
|
-
? 'bg-md-sys-color-primary text-md-sys-color-on-primary'
|
|
449
|
-
: 'bg-md-sys-color-primary-container text-md-sys-color-on-primary-container group-hover:bg-md-sys-color-primary group-hover:text-md-sys-color-on-primary'
|
|
450
|
-
}`}
|
|
451
|
-
>
|
|
452
|
-
<Icon name="file_upload" />
|
|
453
|
-
</div>
|
|
454
|
-
|
|
455
|
-
<div class="space-y-1">
|
|
456
|
-
<p class="md-sys-typescale-title-medium text-md-sys-color-on-surface">
|
|
457
|
-
Перетащите файлы сюда
|
|
458
|
-
</p>
|
|
459
|
-
<p class="md-sys-typescale-body-small text-md-sys-color-on-surface-variant">
|
|
460
|
-
Или нажмите, чтобы выбрать их на устройстве
|
|
461
|
-
</p>
|
|
462
|
-
</div>
|
|
463
|
-
</div>
|
|
464
|
-
|
|
465
|
-
<Layer />
|
|
466
|
-
</label>
|
|
467
|
-
|
|
468
|
-
{#if fileUploads.length > 0}
|
|
469
|
-
<div class="space-y-3">
|
|
470
|
-
{#each fileUploads as fileState (fileState.id)}
|
|
471
|
-
<div class=" bg-md-sys-color-surface-container-high flex items-center gap-3 rounded-lg p-3">
|
|
472
|
-
{#if fileState.status === 'uploading'}
|
|
473
|
-
<CircularProgress percent={progressWidth(fileState)} />
|
|
474
|
-
{:else}
|
|
475
|
-
<div
|
|
476
|
-
class={`flex size-10 items-center justify-center rounded-full ${statusTone(fileState.status)}`}
|
|
477
|
-
>
|
|
478
|
-
<Icon name={statusIcon(fileState.status)} />
|
|
479
|
-
</div>
|
|
480
|
-
{/if}
|
|
481
|
-
|
|
482
|
-
<div class="flex-1 space-y-1 overflow-hidden">
|
|
483
|
-
<p class="md-sys-typescale-body-large text-md-sys-color-on-surface truncate">
|
|
484
|
-
{fileState.file.name}
|
|
485
|
-
</p>
|
|
486
|
-
|
|
487
|
-
{#if fileState.errorMessage}
|
|
488
|
-
<p class="md-sys-typescale-body-small text-md-sys-color-error">
|
|
489
|
-
{fileState.errorMessage}
|
|
490
|
-
</p>
|
|
491
|
-
{/if}
|
|
492
|
-
</div>
|
|
493
|
-
|
|
494
|
-
<div class="flex items-center gap-1">
|
|
495
|
-
{#if fileState.status === 'uploading'}
|
|
496
|
-
<ButtonIcon
|
|
497
|
-
variant="text"
|
|
498
|
-
iconProps={{ name: 'close' }}
|
|
499
|
-
type="button"
|
|
500
|
-
onclick={() => cancelUpload(fileState)}
|
|
501
|
-
></ButtonIcon>
|
|
502
|
-
{:else}
|
|
503
|
-
<ButtonIcon
|
|
504
|
-
variant="text"
|
|
505
|
-
iconProps={{ name: 'delete' }}
|
|
506
|
-
type="button"
|
|
507
|
-
onclick={() => (fileToRemove = fileState)}
|
|
508
|
-
></ButtonIcon>
|
|
509
|
-
{/if}
|
|
510
|
-
</div>
|
|
511
|
-
</div>
|
|
512
|
-
{/each}
|
|
513
|
-
</div>
|
|
514
|
-
{/if}
|
|
515
|
-
</HStack>
|
|
516
|
-
|
|
517
|
-
<Dialogue
|
|
518
|
-
open={!!fileToRemove}
|
|
519
|
-
onconfirm={() => {
|
|
520
|
-
if (fileToRemove) {
|
|
521
|
-
removeFile(fileToRemove);
|
|
522
|
-
fileToRemove = null;
|
|
523
|
-
}
|
|
524
|
-
}}
|
|
525
|
-
confirmText="Удалить"
|
|
526
|
-
headline="Удаление файла"
|
|
527
|
-
supportingText={fileToRemove
|
|
528
|
-
? `Вы действительно хотите удалить файл "${fileToRemove.file.name}"?`
|
|
529
|
-
: ''}
|
|
530
|
-
enhance={null}
|
|
531
|
-
/>
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
type UploadStatus = 'waiting' | 'uploading' | 'complete' | 'error';
|
|
2
|
-
/**
|
|
3
|
-
* Internal state for a single file upload.
|
|
4
|
-
*/
|
|
5
|
-
interface FileUploadState {
|
|
6
|
-
/** Unique identifier for the upload session */
|
|
7
|
-
id: string;
|
|
8
|
-
/** The file being uploaded */
|
|
9
|
-
file: File;
|
|
10
|
-
/** Upload progress tracking */
|
|
11
|
-
progress: {
|
|
12
|
-
/** Number of chunks uploaded */
|
|
13
|
-
uploaded: number;
|
|
14
|
-
/** Total number of chunks */
|
|
15
|
-
total: number;
|
|
16
|
-
/** Total bytes uploaded */
|
|
17
|
-
bytes: number;
|
|
18
|
-
/** Total size of the file in bytes */
|
|
19
|
-
totalBytes: number;
|
|
20
|
-
/** Percentage of upload completed (0-100) */
|
|
21
|
-
percentComplete: number;
|
|
22
|
-
};
|
|
23
|
-
/** Current status of the upload */
|
|
24
|
-
status: UploadStatus;
|
|
25
|
-
/** Optional error message if status is 'error' */
|
|
26
|
-
errorMessage?: string;
|
|
27
|
-
/** AbortController for cancelling the upload */
|
|
28
|
-
abortController?: AbortController;
|
|
29
|
-
}
|
|
30
|
-
type $$ComponentProps = {
|
|
31
|
-
/** Unique ID of the task associated with these uploads */
|
|
32
|
-
taskId?: string | null;
|
|
33
|
-
/** ID of the folder where files will be stored */
|
|
34
|
-
folderId?: string | null;
|
|
35
|
-
/** Whether the upload area is interactive */
|
|
36
|
-
readonly?: boolean;
|
|
37
|
-
/** Initial list of files to display */
|
|
38
|
-
initialUploads?: FileUploadState[];
|
|
39
|
-
/** Title displayed above the upload area */
|
|
40
|
-
title?: string;
|
|
41
|
-
/** Accessibility label for the upload zone */
|
|
42
|
-
label?: string;
|
|
43
|
-
};
|
|
44
|
-
/**
|
|
45
|
-
* @component
|
|
46
|
-
* Multipart file upload component that supports chunked uploads for large files.
|
|
47
|
-
* Follows Material 3 design principles for file uploaders.
|
|
48
|
-
*/
|
|
49
|
-
declare const Multipart: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
50
|
-
type Multipart = ReturnType<typeof Multipart>;
|
|
51
|
-
export default Multipart;
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { default as Multipart } from './Multipart.svelte';
|