@noxlovette/material 0.1.17 → 0.1.19
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/FAB.svelte +21 -19
- package/dist/components/buttons/FABMenu.svelte +11 -18
- package/dist/components/buttons/theme.d.ts +17 -11
- package/dist/components/buttons/theme.js +1 -4
- package/dist/components/buttons/types.d.ts +2 -6
- package/dist/components/containers/context-menu/ContextMenu.svelte +0 -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
|
@@ -14,7 +14,6 @@ Floating action buttons (FABs) help people take primary actions.
|
|
|
14
14
|
import { fab } from './theme.js';
|
|
15
15
|
import type { FABProps } from './types.js';
|
|
16
16
|
import FABMenu from './FABMenu.svelte';
|
|
17
|
-
import ButtonIcon from './ButtonIcon.svelte';
|
|
18
17
|
import { Layer, Icon } from '../../utils/index.js';
|
|
19
18
|
import { clickOutside } from '../../actions/index.js';
|
|
20
19
|
import { Button, type ButtonRootProps } from 'bits-ui';
|
|
@@ -34,10 +33,19 @@ Floating action buttons (FABs) help people take primary actions.
|
|
|
34
33
|
}: FABProps = $props();
|
|
35
34
|
|
|
36
35
|
const { base, icon, label: labelClass } = $derived(fab({ size, config, expanded }));
|
|
37
|
-
const btnCls = $derived(base({ class: clsx(className) }));
|
|
38
36
|
|
|
39
37
|
let showMenu = $state(false);
|
|
40
38
|
|
|
39
|
+
const btnCls = $derived(
|
|
40
|
+
base({
|
|
41
|
+
class: clsx(
|
|
42
|
+
className,
|
|
43
|
+
'transition-[border-radius] duration-300',
|
|
44
|
+
withMenu && showMenu && '!rounded-full'
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
);
|
|
48
|
+
|
|
41
49
|
function handleClick(e: MouseEvent) {
|
|
42
50
|
if (disabled) {
|
|
43
51
|
e.preventDefault();
|
|
@@ -45,24 +53,12 @@ Floating action buttons (FABs) help people take primary actions.
|
|
|
45
53
|
}
|
|
46
54
|
if (withMenu) {
|
|
47
55
|
e.preventDefault();
|
|
48
|
-
showMenu =
|
|
56
|
+
showMenu = !showMenu;
|
|
49
57
|
}
|
|
50
58
|
}
|
|
51
59
|
</script>
|
|
52
60
|
|
|
53
|
-
{
|
|
54
|
-
<div class="relative z-50" use:clickOutside={() => (showMenu = false)}>
|
|
55
|
-
<FABMenu>
|
|
56
|
-
{@render children?.()}
|
|
57
|
-
</FABMenu>
|
|
58
|
-
<ButtonIcon
|
|
59
|
-
variant="filled"
|
|
60
|
-
class="shadow-elevation-3"
|
|
61
|
-
iconProps={{ name: 'close' }}
|
|
62
|
-
onclick={() => (showMenu = !showMenu)}
|
|
63
|
-
/>
|
|
64
|
-
</div>
|
|
65
|
-
{:else}
|
|
61
|
+
<div class="contents" use:clickOutside={() => (showMenu = false)}>
|
|
66
62
|
<Button.Root
|
|
67
63
|
{disabled}
|
|
68
64
|
{formaction}
|
|
@@ -71,12 +67,18 @@ Floating action buttons (FABs) help people take primary actions.
|
|
|
71
67
|
data-cy="m3-fab"
|
|
72
68
|
{...restProps as ButtonRootProps}
|
|
73
69
|
>
|
|
74
|
-
<Icon {...iconProps} class={icon()} />
|
|
70
|
+
<Icon {...withMenu && showMenu ? { name: 'close' } : iconProps} class={icon()} />
|
|
75
71
|
<Layer />
|
|
76
|
-
{#if expanded}
|
|
72
|
+
{#if expanded && !(withMenu && showMenu)}
|
|
77
73
|
<p class={labelClass()}>
|
|
78
74
|
{label}
|
|
79
75
|
</p>
|
|
80
76
|
{/if}
|
|
81
77
|
</Button.Root>
|
|
82
|
-
|
|
78
|
+
|
|
79
|
+
{#if withMenu && showMenu}
|
|
80
|
+
<FABMenu>
|
|
81
|
+
{@render children?.()}
|
|
82
|
+
</FABMenu>
|
|
83
|
+
{/if}
|
|
84
|
+
</div>
|
|
@@ -10,21 +10,19 @@ Should be used with `FABMenuItem` as children.
|
|
|
10
10
|
import { enterExit } from '../../animation/enterExit.js';
|
|
11
11
|
import { easeEmphasizedDecel } from '../../animation/easing.js';
|
|
12
12
|
|
|
13
|
-
let { children, class: className,
|
|
13
|
+
let { children, class: className, ...restProps }: FABMenuProps = $props();
|
|
14
14
|
|
|
15
|
-
const { base } =
|
|
15
|
+
const { base } = fabMenu();
|
|
16
16
|
|
|
17
17
|
const margin = 8;
|
|
18
18
|
const gap = 8;
|
|
19
19
|
let coords = $state({
|
|
20
20
|
x: 0,
|
|
21
21
|
y: 0,
|
|
22
|
-
originX: '
|
|
23
|
-
originY: '
|
|
22
|
+
originX: 'right',
|
|
23
|
+
originY: 'bottom',
|
|
24
24
|
maxHeight: 0
|
|
25
25
|
});
|
|
26
|
-
|
|
27
|
-
// TODO: out:enterExit={{ duration: 200, easing: easeEmphasizedAccel, mode: 'scale' }} – if you can solve how to eliminate another button from appearing here, use this too
|
|
28
26
|
</script>
|
|
29
27
|
|
|
30
28
|
<ul
|
|
@@ -38,9 +36,11 @@ Should be used with `FABMenuItem` as children.
|
|
|
38
36
|
style:max-height={coords.maxHeight ? `${coords.maxHeight}px` : undefined}
|
|
39
37
|
style:--enter-exit-origin={`${coords.originY} ${coords.originX}`}
|
|
40
38
|
{@attach (menu) => {
|
|
39
|
+
const anchorEl = menu.previousElementSibling as HTMLElement;
|
|
40
|
+
if (!anchorEl) return;
|
|
41
|
+
|
|
42
|
+
const anchorRect = anchorEl.getBoundingClientRect();
|
|
41
43
|
const rect = menu.getBoundingClientRect();
|
|
42
|
-
const anchorRect = menu.parentElement?.getBoundingClientRect();
|
|
43
|
-
if (!anchorRect) return;
|
|
44
44
|
|
|
45
45
|
const spaceAbove = anchorRect.top - gap - margin;
|
|
46
46
|
const spaceBelow = window.innerHeight - margin - (anchorRect.bottom + gap);
|
|
@@ -52,29 +52,22 @@ Should be used with `FABMenuItem` as children.
|
|
|
52
52
|
const targetTop = placeAbove ? anchorRect.top - gap - maxHeight : anchorRect.bottom + gap;
|
|
53
53
|
|
|
54
54
|
let targetLeft = anchorRect.right - rect.width;
|
|
55
|
-
|
|
56
55
|
if (targetLeft + rect.width > window.innerWidth - margin) {
|
|
57
56
|
targetLeft = window.innerWidth - margin - rect.width;
|
|
58
57
|
}
|
|
59
|
-
if (targetLeft < margin)
|
|
60
|
-
targetLeft = margin;
|
|
61
|
-
}
|
|
58
|
+
if (targetLeft < margin) targetLeft = margin;
|
|
62
59
|
|
|
63
60
|
let clampedTop = targetTop;
|
|
64
|
-
if (clampedTop < margin)
|
|
65
|
-
clampedTop = margin;
|
|
66
|
-
}
|
|
61
|
+
if (clampedTop < margin) clampedTop = margin;
|
|
67
62
|
if (clampedTop + maxHeight > window.innerHeight - margin) {
|
|
68
63
|
clampedTop = window.innerHeight - margin - maxHeight;
|
|
69
64
|
}
|
|
70
65
|
|
|
71
|
-
const x = targetLeft - rect.left;
|
|
72
|
-
const y = clampedTop - rect.top;
|
|
73
66
|
const originY = placeAbove ? 'bottom' : 'top';
|
|
74
67
|
const originX =
|
|
75
68
|
anchorRect.left + anchorRect.width / 2 < targetLeft + rect.width / 2 ? 'left' : 'right';
|
|
76
69
|
|
|
77
|
-
coords = { x, y, originX, originY, maxHeight };
|
|
70
|
+
coords = { x: targetLeft, y: clampedTop, originX, originY, maxHeight };
|
|
78
71
|
}}
|
|
79
72
|
>
|
|
80
73
|
{@render children()}
|
|
@@ -491,22 +491,28 @@ export declare const fab: import("tailwind-variants").TVReturnType<{
|
|
|
491
491
|
label: string;
|
|
492
492
|
}, undefined, unknown, unknown, undefined>>;
|
|
493
493
|
export declare const fabMenu: import("tailwind-variants").TVReturnType<{
|
|
494
|
-
|
|
495
|
-
|
|
494
|
+
[key: string]: {
|
|
495
|
+
[key: string]: import("tailwind-merge").ClassNameValue | {
|
|
496
|
+
base?: import("tailwind-merge").ClassNameValue;
|
|
497
|
+
};
|
|
496
498
|
};
|
|
497
|
-
}
|
|
499
|
+
} | {
|
|
500
|
+
[x: string]: {
|
|
501
|
+
[x: string]: import("tailwind-merge").ClassNameValue | {
|
|
502
|
+
base?: import("tailwind-merge").ClassNameValue;
|
|
503
|
+
};
|
|
504
|
+
};
|
|
505
|
+
} | {}, {
|
|
498
506
|
base: string;
|
|
499
507
|
}, undefined, {
|
|
500
|
-
|
|
501
|
-
|
|
508
|
+
[key: string]: {
|
|
509
|
+
[key: string]: import("tailwind-merge").ClassNameValue | {
|
|
510
|
+
base?: import("tailwind-merge").ClassNameValue;
|
|
511
|
+
};
|
|
502
512
|
};
|
|
503
|
-
}, {
|
|
513
|
+
} | {}, {
|
|
504
514
|
base: string;
|
|
505
|
-
}, import("tailwind-variants").TVReturnType<{
|
|
506
|
-
position: {
|
|
507
|
-
top: string;
|
|
508
|
-
};
|
|
509
|
-
}, {
|
|
515
|
+
}, import("tailwind-variants").TVReturnType<unknown, {
|
|
510
516
|
base: string;
|
|
511
517
|
}, undefined, unknown, unknown, undefined>>;
|
|
512
518
|
export declare const fabMenuItem: import("tailwind-variants").TVReturnType<{
|
|
@@ -474,10 +474,7 @@ export const fab = tv({
|
|
|
474
474
|
});
|
|
475
475
|
export const fabMenu = tv({
|
|
476
476
|
slots: {
|
|
477
|
-
base: 'gap-2 pb-2 flex flex-col
|
|
478
|
-
},
|
|
479
|
-
variants: {
|
|
480
|
-
position: { top: 'top-16' }
|
|
477
|
+
base: 'gap-2 pb-2 flex flex-col fixed left-0 top-0 z-[9999] overflow-visible p-1'
|
|
481
478
|
}
|
|
482
479
|
});
|
|
483
480
|
export const fabMenuItem = tv({
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AnchorButtonAttributes, IconProps } from '../../utils/index.js';
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import type { HTMLAnchorAttributes, HTMLAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
|
4
|
-
import type { ButtonGroupVariants, ButtonIconVariants, ButtonMDVariants, FABMenuItemVariants,
|
|
4
|
+
import type { ButtonGroupVariants, ButtonIconVariants, ButtonMDVariants, FABMenuItemVariants, FABVariants } from './theme.js';
|
|
5
5
|
export interface ButtonGroupProps extends ButtonGroupVariants, HTMLAttributes<HTMLDivElement> {
|
|
6
6
|
/**
|
|
7
7
|
* The content of the button group.
|
|
@@ -157,11 +157,7 @@ export type FABProps = FABVariants & AnchorButtonAttributes & {
|
|
|
157
157
|
*/
|
|
158
158
|
children?: Snippet;
|
|
159
159
|
};
|
|
160
|
-
export type FABMenuProps =
|
|
161
|
-
/**
|
|
162
|
-
* The vertical position of the menu relative to the anchor.
|
|
163
|
-
*/
|
|
164
|
-
position?: FABMenuVariants['position'];
|
|
160
|
+
export type FABMenuProps = HTMLAttributes<HTMLUListElement> & {
|
|
165
161
|
/**
|
|
166
162
|
* The menu items. Use `FABMenuItem` components.
|
|
167
163
|
*/
|
|
@@ -26,7 +26,6 @@ Context menus appear on right-click and provide actions related to the clicked e
|
|
|
26
26
|
import Divider from '../divider/Divider.svelte';
|
|
27
27
|
import { enterExit } from '../../../animation/enterExit.js';
|
|
28
28
|
import { easeEmphasizedDecel } from '../../../animation/easing.js';
|
|
29
|
-
import clsx from 'clsx';
|
|
30
29
|
|
|
31
30
|
let {
|
|
32
31
|
items = [],
|
|
@@ -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';
|