@hyvor/design 1.1.18 → 1.1.19-beta-file-uploader.1

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.
@@ -4,6 +4,7 @@
4
4
  import DarkProvider from './../Dark/DarkProvider.svelte';
5
5
  import '../../index.js';
6
6
  import ToastProvider from '../Toast/ToastProvider.svelte';
7
+ import FileUploaderProvider from '../FileUploader/FileUploaderProvider.svelte';
7
8
 
8
9
  interface Props {
9
10
  dark?: boolean;
@@ -23,3 +24,4 @@
23
24
 
24
25
  <ToastProvider />
25
26
  <ConfirmModalProvider />
27
+ <FileUploaderProvider />
@@ -160,8 +160,6 @@
160
160
  max-height: 370px;
161
161
  overflow-y: auto;
162
162
  }
163
- .wrap {
164
- }
165
163
  .emojis {
166
164
  display: flex;
167
165
  flex-wrap: wrap;
@@ -0,0 +1,137 @@
1
+ <script lang="ts">
2
+ import IconCardImage from '@hyvor/icons/IconCardImage';
3
+ import IconCaretLeft from '@hyvor/icons/IconCaretLeft';
4
+ import IconCloudUpload from '@hyvor/icons/IconCloudUpload';
5
+ import Button from '../Button/Button.svelte';
6
+ import Modal from '../Modal/Modal.svelte';
7
+ import TabNav from '../TabNav/TabNav.svelte';
8
+ import TabNavItem from '../TabNav/TabNavItem.svelte';
9
+ import {
10
+ clearSelectedFile,
11
+ closeFileUploader,
12
+ getFileUploaderConfig,
13
+ selectedFile
14
+ } from './file-uploader.js';
15
+ import TabUpload from './TabUpload/TabUpload.svelte';
16
+ import Preview from './Preview/Preview.svelte';
17
+
18
+ const config = getFileUploaderConfig();
19
+
20
+ let tab = $state('upload');
21
+
22
+ function onClose() {
23
+ closeFileUploader();
24
+ }
25
+
26
+ function handleBack() {
27
+ clearSelectedFile();
28
+ }
29
+ </script>
30
+
31
+ <div class="image-uploader">
32
+ <Modal
33
+ show={true}
34
+ size="large"
35
+ closeOnEscape={false}
36
+ closeOnOutsideClick={false}
37
+ on:cancel={onClose}
38
+ >
39
+ {#snippet title()}
40
+ <div>
41
+ {#if $selectedFile}
42
+ <Button color="input" onclick={handleBack}>
43
+ {#snippet start()}
44
+ <IconCaretLeft va />
45
+ {/snippet}
46
+ Back
47
+ </Button>
48
+ {:else}
49
+ <TabNav bind:active={tab}>
50
+ <TabNavItem name="upload">
51
+ {#snippet start()}
52
+ <IconCloudUpload />
53
+ {/snippet}
54
+ Upload
55
+ </TabNavItem>
56
+
57
+ <!-- <TabNavItem name="media">
58
+ {#snippet start()}
59
+ <IconCardImage />
60
+ {/snippet}
61
+ Media Library
62
+ </TabNavItem> -->
63
+
64
+ {#if config.type === 'image'}
65
+ <!-- <TabNavItem name="unsplash">
66
+ {#snippet start()}
67
+ <svg
68
+ role="img"
69
+ width="1em"
70
+ height="1em"
71
+ fill="currentColor"
72
+ viewBox="0 0 24 24"
73
+ xmlns="http://www.w3.org/2000/svg"
74
+ ><path
75
+ d="M7.5 6.75V0h9v6.75h-9zm9 3.75H24V24H0V10.5h7.5v6.75h9V10.5z"
76
+ /></svg
77
+ >
78
+ {/snippet}
79
+ Unsplash
80
+ </TabNavItem>
81
+ <TabNavItem name="excalidraw">
82
+ {#snippet start()}
83
+ <ExcalidrawIcon />
84
+ {/snippet}
85
+ Excalidraw
86
+ </TabNavItem> -->
87
+ {/if}
88
+ </TabNav>
89
+ {/if}
90
+ </div>
91
+ {/snippet}
92
+ <div class="body" style:position={selectedFile ? 'relative' : undefined}>
93
+ {#if tab === 'upload'}
94
+ <TabUpload />
95
+ {/if}
96
+
97
+ {#if $selectedFile}
98
+ <Preview />
99
+ {/if}
100
+
101
+ <!-- {#if tab === 'upload'}
102
+ <TabUpload {type} on:select={handleSelect} />
103
+ {:else if tab === 'media' && type !== 'any'}
104
+ <Media {type} on:select={handleSelect} />
105
+ {:else if tab === 'unsplash'}
106
+ <Unsplash on:select={handleSelect} />
107
+ {:else if tab === 'excalidraw'}
108
+ <Excalidraw on:select={handleSelect} />
109
+ {/if}
110
+
111
+ -->
112
+ </div>
113
+ </Modal>
114
+ </div>
115
+
116
+ <style>.image-uploader :global(.wrap) {
117
+ z-index: 1000 !important;
118
+ }
119
+
120
+ .image-uploader :global(.inner) {
121
+ height: 100%;
122
+ width: 1100px !important;
123
+ display: flex;
124
+ flex-direction: column;
125
+ }
126
+ .image-uploader :global(.inner) :global(> .content) {
127
+ flex: 1;
128
+ padding-top: 0;
129
+ min-height: 0;
130
+ display: flex;
131
+ flex-direction: column;
132
+ }
133
+
134
+ .body {
135
+ flex: 1;
136
+ min-height: 0;
137
+ }</style>
@@ -0,0 +1,3 @@
1
+ declare const FileUploader: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type FileUploader = ReturnType<typeof FileUploader>;
3
+ export default FileUploader;
@@ -0,0 +1,8 @@
1
+ <script lang="ts">
2
+ import { fileUploaderConfig } from './file-uploader.js';
3
+ import FileUploader from './FileUploader.svelte';
4
+ </script>
5
+
6
+ {#if $fileUploaderConfig}
7
+ <FileUploader />
8
+ {/if}
@@ -0,0 +1,18 @@
1
+ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
2
+ new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
3
+ $$bindings?: Bindings;
4
+ } & Exports;
5
+ (internal: unknown, props: {
6
+ $$events?: Events;
7
+ $$slots?: Slots;
8
+ }): Exports & {
9
+ $set?: any;
10
+ $on?: any;
11
+ };
12
+ z_$$bindings?: Bindings;
13
+ }
14
+ declare const FileUploaderProvider: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
15
+ [evt: string]: CustomEvent<any>;
16
+ }, {}, {}, string>;
17
+ type FileUploaderProvider = InstanceType<typeof FileUploaderProvider>;
18
+ export default FileUploaderProvider;
@@ -0,0 +1,30 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ name: string;
4
+ children?: import('svelte').Snippet;
5
+ }
6
+
7
+ let { name, children }: Props = $props();
8
+ </script>
9
+
10
+ <div class="meta">
11
+ <div class="name">{name}</div>
12
+ <div class="value">{@render children?.()}</div>
13
+ </div>
14
+
15
+ <style>
16
+ .meta {
17
+ display: flex;
18
+ flex-direction: column;
19
+ padding: 0 15px;
20
+ }
21
+
22
+ .name {
23
+ font-size: 13px;
24
+ color: var(--text-light);
25
+ margin-bottom: 10px;
26
+ }
27
+ .value {
28
+ font-weight: 600;
29
+ }
30
+ </style>
@@ -0,0 +1,7 @@
1
+ interface Props {
2
+ name: string;
3
+ children?: import('svelte').Snippet;
4
+ }
5
+ declare const Meta: import("svelte").Component<Props, {}, "">;
6
+ type Meta = ReturnType<typeof Meta>;
7
+ export default Meta;
@@ -0,0 +1,308 @@
1
+ <script lang="ts">
2
+ import Meta from './Meta.svelte';
3
+ import IconCheckAll from '@hyvor/icons/IconCheckAll';
4
+ import IconCloudUpload from '@hyvor/icons/IconCloudUpload';
5
+
6
+ import { onDestroy, onMount } from 'svelte';
7
+ import { completeFileUpload, getFileUploaderConfig, selectedFile } from '../file-uploader.js';
8
+ import TextInput from '../../TextInput/TextInput.svelte';
9
+ import Switch from '../../Switch/Switch.svelte';
10
+ import Button from '../../Button/Button.svelte';
11
+ import Loader from '../../Loader/Loader.svelte';
12
+ import toast from '../../Toast/toast.js';
13
+ import { byteFormatter, toKebabCase } from '../helpers.js';
14
+
15
+ const config = getFileUploaderConfig();
16
+ let file = $selectedFile!;
17
+
18
+ const fileUrl = getFileUrl();
19
+
20
+ function getFileUrl(): string {
21
+ if (file.upload) {
22
+ return URL.createObjectURL(file.upload.blob);
23
+ }
24
+ return ''; // TODO
25
+ }
26
+
27
+ let imageSize = $state(getInitialImageSize());
28
+
29
+ function getInitialImageSize() {
30
+ if (file.upload) {
31
+ return file.upload.blob.size;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ let imageName = $state(toKebabCase(getInitialImageName()));
37
+
38
+ function getInitialImageName() {
39
+ if (file.upload && file.upload.blob instanceof File) {
40
+ return file.upload.blob.name;
41
+ }
42
+ // TODO: add other
43
+ return null;
44
+ }
45
+
46
+ let nameError = $state('');
47
+
48
+ let imgEl: HTMLImageElement | undefined = $state();
49
+
50
+ let width = $state(0);
51
+ let height = $state(0);
52
+
53
+ function getShouldUpload() {
54
+ if (file.from === 'excalidraw' || file.from == 'upload') return true;
55
+ return false;
56
+ }
57
+
58
+ function getCanChangeUpload() {
59
+ return file.from === 'upload' && file.upload?.type === 'url' && file.upload?.fetchedUrl;
60
+ }
61
+
62
+ function getHosting() {
63
+ if (file.from === 'upload') {
64
+ if (file.upload?.type === 'url' && file.upload?.fetchedUrl) {
65
+ const fetchedUrl = file.upload.fetchedUrl!;
66
+ const domain = new URL(fetchedUrl).hostname;
67
+ return `External (${domain})`;
68
+ }
69
+ }
70
+
71
+ if (file.from === 'unsplash') {
72
+ return 'Unsplash';
73
+ }
74
+
75
+ if (file.from === 'media') {
76
+ return 'Media Library';
77
+ }
78
+
79
+ return null;
80
+ }
81
+
82
+ let shouldUpload = $state(getShouldUpload());
83
+ const canChangeUpload = getCanChangeUpload();
84
+ const hosting = getHosting();
85
+
86
+ function handleImageLoad() {
87
+ if (!imgEl) return;
88
+ width = imgEl.naturalWidth;
89
+ height = imgEl.naturalHeight;
90
+ }
91
+
92
+ function tryGetSize() {
93
+ fetch(fileUrl)
94
+ .then((res) => res.blob())
95
+ .then((blob) => {
96
+ imageSize = blob.size;
97
+ })
98
+ .catch((err) => {
99
+ console.error(err);
100
+ });
101
+ }
102
+
103
+ let isUploading = $state(false);
104
+
105
+ function handleUpload() {
106
+ if (shouldUpload && file.upload) {
107
+ // TODO: other blobs should be handled
108
+ isUploading = true;
109
+
110
+ if (imageName.length > 255) {
111
+ toast.error('Image name is too long');
112
+ isUploading = false;
113
+ return;
114
+ }
115
+
116
+ config
117
+ .uploader(file.upload.blob, imageName)
118
+ .then((res) => {
119
+ completeFileUpload({
120
+ url: res.url,
121
+ selectedFile: file
122
+ });
123
+ })
124
+ .catch((err) => {
125
+ toast.error(err.message || 'Failed to upload image');
126
+ })
127
+ .finally(() => {
128
+ isUploading = false;
129
+ });
130
+ } else {
131
+ completeFileUpload({
132
+ url: file.upload!.fetchedUrl!,
133
+ selectedFile: file
134
+ });
135
+ }
136
+ }
137
+
138
+ function handleNameChange() {
139
+ nameError = '';
140
+ if (imageName.length > 255) {
141
+ nameError = 'Too long';
142
+ } else if (imageName.includes('/')) {
143
+ nameError = '/ is not allowed';
144
+ }
145
+ console.log(imageName);
146
+ }
147
+
148
+ onMount(() => {
149
+ if (imageSize === null) {
150
+ tryGetSize();
151
+ }
152
+ });
153
+
154
+ onDestroy(() => {
155
+ URL.revokeObjectURL(fileUrl);
156
+ });
157
+ </script>
158
+
159
+ <div class="selected-image">
160
+ {#if isUploading}
161
+ <Loader full>Uploading...</Loader>
162
+ {:else}
163
+ <div class="img-wrap">
164
+ {#if file.type === 'audio'}
165
+ <audio src={fileUrl} controls></audio>
166
+ {:else if file.type === 'image'}
167
+ <img src={fileUrl} alt="Editing" bind:this={imgEl} onload={handleImageLoad} />
168
+ {:else}
169
+ No preview available
170
+ {/if}
171
+ </div>
172
+
173
+ <div class="top-bar">
174
+ <div class="meta">
175
+ {#if file.type === 'image'}
176
+ <Meta name="Dimensions (px)">
177
+ {width} x {height}
178
+ </Meta>
179
+ {/if}
180
+ <Meta name="File Size">
181
+ {#if imageSize !== null}
182
+ {byteFormatter(imageSize)}
183
+ {:else}
184
+ Unknown
185
+ {/if}
186
+ </Meta>
187
+ <div class="name-editor">
188
+ <div class="name">
189
+ Name
190
+ {#if nameError}
191
+ <span class="name-error">Error: {nameError}</span>
192
+ {/if}
193
+ </div>
194
+ <TextInput
195
+ bind:value={imageName}
196
+ on:input={handleNameChange}
197
+ placeholder="Image Name"
198
+ state={nameError ? 'error' : 'default'}
199
+ disabled={!shouldUpload}
200
+ />
201
+ </div>
202
+ {#if hosting}
203
+ <Meta name="Hosting">
204
+ {hosting}
205
+ </Meta>
206
+ {/if}
207
+ </div>
208
+ {#if file.from !== 'media'}
209
+ <div class="upload-switch">
210
+ Upload to Media Library
211
+ <Switch bind:checked={shouldUpload} disabled={!canChangeUpload} />
212
+ </div>
213
+ {/if}
214
+ </div>
215
+
216
+ <div class="footer">
217
+ <Button on:click={handleUpload}>
218
+ {shouldUpload ? 'Upload' : 'Select'}
219
+ {#snippet end()}
220
+ {#if shouldUpload}
221
+ <IconCloudUpload />
222
+ {:else}
223
+ <IconCheckAll />
224
+ {/if}
225
+ {/snippet}
226
+ </Button>
227
+ </div>
228
+ {/if}
229
+ </div>
230
+
231
+ <style>
232
+ .selected-image {
233
+ position: absolute;
234
+ z-index: 1000000;
235
+ top: 0;
236
+ left: 0;
237
+ width: 100%;
238
+ height: 100%;
239
+ background-color: var(--box-background);
240
+ display: flex;
241
+ flex-direction: column;
242
+ }
243
+
244
+ .top-bar {
245
+ padding: 10px 25px;
246
+ border-top: 1px solid var(--border);
247
+ border-bottom: 1px solid var(--border);
248
+ margin: 0 -25px;
249
+ display: flex;
250
+ align-items: center;
251
+ }
252
+
253
+ .meta {
254
+ display: flex;
255
+ flex: 1;
256
+ }
257
+
258
+ .img-wrap {
259
+ display: flex;
260
+ align-items: center;
261
+ justify-content: center;
262
+ height: 100%;
263
+ padding: 25px;
264
+ flex: 1;
265
+ min-height: 0;
266
+ min-width: 0;
267
+ margin-bottom: 20px;
268
+ }
269
+ .img-wrap :global(img) {
270
+ display: block;
271
+ max-width: 100%;
272
+ }
273
+ img {
274
+ max-width: 100%;
275
+ max-height: 100%;
276
+ }
277
+
278
+ .upload-switch {
279
+ display: inline-flex;
280
+ align-items: center;
281
+ gap: 10px;
282
+ font-size: 14px;
283
+ }
284
+
285
+ .footer {
286
+ padding: 5px 25px;
287
+ padding-top: 15px;
288
+ text-align: center;
289
+ }
290
+
291
+ .name-editor {
292
+ display: flex;
293
+ flex-direction: column;
294
+ padding: 0 15px;
295
+ }
296
+
297
+ .name {
298
+ font-size: 13px;
299
+ color: var(--text-light);
300
+ margin-bottom: 5px;
301
+ display: inline-flex;
302
+ gap: 4px;
303
+ }
304
+ .name .name-error {
305
+ color: var(--red-dark);
306
+ font-size: 12px;
307
+ }
308
+ </style>
@@ -0,0 +1,3 @@
1
+ declare const Preview: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type Preview = ReturnType<typeof Preview>;
3
+ export default Preview;
@@ -0,0 +1,284 @@
1
+ <script lang="ts">
2
+ import IconArrowReturnLeft from '@hyvor/icons/IconArrowReturnLeft';
3
+ import { onMount } from 'svelte';
4
+ import {
5
+ getFileUploaderConfig,
6
+ getMimeNamesJoined,
7
+ setSelectedFile,
8
+ validateMimeType,
9
+ type UploadType
10
+ } from '../file-uploader.js';
11
+ import { byteFormatter, isValidUrl } from '../helpers.js';
12
+ import toast from '../../Toast/toast.js';
13
+ import Loader from '../../Loader/Loader.svelte';
14
+ import TextInput from '../../TextInput/TextInput.svelte';
15
+ import Button from '../../Button/Button.svelte';
16
+
17
+ let isUploading = $state(false);
18
+
19
+ const config = getFileUploaderConfig();
20
+
21
+ let inputEl: HTMLInputElement;
22
+ let byUrlInputEl: HTMLInputElement | undefined = $state();
23
+
24
+ let byUrl = $state('');
25
+ let isDragging = $state(false);
26
+
27
+ function getCtrl() {
28
+ const platform =
29
+ (navigator as any)?.userAgentData?.platform || navigator?.platform || 'unknown';
30
+ return platform.match(/mac/i) ? '⌘' : 'Ctrl';
31
+ }
32
+
33
+ function getSelectedType(blob: Blob | null = null): UploadType {
34
+ if (config.type === 'file' && blob) {
35
+ if (blob.type.indexOf('image') === 0) return 'image';
36
+ if (blob.type.indexOf('audio') === 0) return 'audio';
37
+ }
38
+ return config.type;
39
+ }
40
+
41
+ function handleFetch() {
42
+ isUploading = true;
43
+
44
+ fetch(byUrl)
45
+ .then((res) => res.blob())
46
+ .then((blob) => {
47
+ if (!validateMimeType(blob.type)) {
48
+ const names = getMimeNamesJoined();
49
+ toast.error(
50
+ `Only ${names} files are allowed. Current file type is ${blob.type}`
51
+ );
52
+ return;
53
+ }
54
+
55
+ setSelectedFile({
56
+ type: getSelectedType(blob),
57
+ from: 'upload',
58
+ upload: {
59
+ type: 'url',
60
+ fetchedUrl: byUrl,
61
+ blob
62
+ }
63
+ });
64
+ })
65
+ .catch((err) => {
66
+ toast.error(`Failed to fetch file from the URL`);
67
+ })
68
+ .finally(() => {
69
+ isUploading = false;
70
+ });
71
+ }
72
+
73
+ function handlePaste(e: ClipboardEvent) {
74
+ // first check if there's any text (url)
75
+ const text = e.clipboardData?.getData('text/plain') || '';
76
+ if (isValidUrl(text) && (e.target as HTMLElement).tagName !== 'INPUT') {
77
+ byUrl = text;
78
+ handleFetch();
79
+ return;
80
+ }
81
+
82
+ // only looking for images
83
+ if (config.type === 'audio') return;
84
+
85
+ const items = e.clipboardData?.items;
86
+ if (!items) return;
87
+
88
+ for (let i = 0; i < items.length; i++) {
89
+ const item = items[i]!;
90
+ if (item.type.indexOf('image') === 0) {
91
+ const blob = item.getAsFile();
92
+ if (!blob) continue;
93
+
94
+ setSelectedFile({
95
+ type: getSelectedType(blob),
96
+ from: 'upload',
97
+ upload: { type: 'paste', blob }
98
+ });
99
+ break;
100
+ }
101
+ }
102
+ }
103
+
104
+ function handleDragEnter(e: DragEvent) {
105
+ e.preventDefault();
106
+ e.stopPropagation();
107
+ isDragging = true;
108
+ }
109
+
110
+ function handleDragLeave(e: DragEvent) {
111
+ e.preventDefault();
112
+ e.stopPropagation();
113
+ isDragging = false;
114
+ }
115
+
116
+ function handleDragDrop(e: DragEvent) {
117
+ e.preventDefault();
118
+ e.stopPropagation();
119
+
120
+ isDragging = false;
121
+
122
+ if (!e.dataTransfer) return;
123
+ const files = e.dataTransfer.files;
124
+ const file = getFileFromFiles(files);
125
+ if (!file) return;
126
+
127
+ setSelectedFile({
128
+ type: getSelectedType(file),
129
+ from: 'upload',
130
+ upload: { type: 'dnd', blob: file }
131
+ });
132
+ }
133
+
134
+ function handleUploadClick() {
135
+ inputEl?.click();
136
+ }
137
+
138
+ function handleInputChange(e: any) {
139
+ const file = getFileFromFiles(e.target.files);
140
+ if (!file) return;
141
+
142
+ setSelectedFile({
143
+ type: getSelectedType(file),
144
+ from: 'upload',
145
+ upload: { type: 'browse', blob: file }
146
+ });
147
+ }
148
+
149
+ function getFileFromFiles(files: FileList | null): File | null {
150
+ if (!files || files.length === 0) {
151
+ toast.error('No files selected');
152
+ return null;
153
+ }
154
+ const file = files[0];
155
+ if (!file) {
156
+ toast.error('No files selected');
157
+ return null;
158
+ }
159
+
160
+ const maxBytes = config.maxFileSizeInMB * 1024 * 1024;
161
+ if (file.size > maxBytes) {
162
+ toast.error('File size exceeds the limit of ' + byteFormatter(maxBytes));
163
+ return null;
164
+ }
165
+
166
+ if (validateMimeType(file.type) === false) {
167
+ const names = getMimeNamesJoined();
168
+ toast.error(`Only ${names} files are allowed. Current file type is ${file.type}`);
169
+ return null;
170
+ }
171
+
172
+ return file;
173
+ }
174
+
175
+ onMount(() => {
176
+ byUrl && byUrlInputEl && byUrlInputEl.focus();
177
+ });
178
+ </script>
179
+
180
+ <svelte:window
181
+ onpaste={handlePaste}
182
+ ondragenter={handleDragEnter}
183
+ ondragover={handleDragEnter}
184
+ ondragleave={handleDragLeave}
185
+ ondragexit={handleDragLeave}
186
+ />
187
+
188
+ <div class="tab">
189
+ <input
190
+ type="file"
191
+ accept={config.type === 'audio' ? 'audio/*' : config.type === 'image' ? 'image/*' : '*'}
192
+ style="display:none"
193
+ bind:this={inputEl}
194
+ onchange={handleInputChange}
195
+ />
196
+
197
+ {#if isUploading}
198
+ <Loader full />
199
+ {:else}
200
+ <div class="upload-wrap">
201
+ <div
202
+ class="upload-area"
203
+ onclick={handleUploadClick}
204
+ ondrop={handleDragDrop}
205
+ role="button"
206
+ tabindex="0"
207
+ onkeyup={(e) => e.key === 'Enter' && handleUploadClick()}
208
+ >
209
+ {#if isDragging}
210
+ Drop here!
211
+ {:else}
212
+ Drag and drop, paste ({getCtrl()} + v), or click to upload
213
+ {/if}
214
+ </div>
215
+ </div>
216
+
217
+ {#if config.type === 'image'}
218
+ <div class="by-url-wrap">
219
+ <div class="title">or, Upload by URL</div>
220
+
221
+ <div class="input-button">
222
+ <TextInput
223
+ block
224
+ placeholder="Enter image URL"
225
+ bind:value={byUrl}
226
+ on:keyup={(e) => e.key === 'Enter' && handleFetch()}
227
+ bind:input={byUrlInputEl}
228
+ />
229
+ <Button disabled={byUrl.trim() === ''} on:click={handleFetch}>
230
+ Fetch {#snippet end()}
231
+ <IconArrowReturnLeft />
232
+ {/snippet}
233
+ </Button>
234
+ </div>
235
+ </div>
236
+ {/if}
237
+ {/if}
238
+ </div>
239
+
240
+ <style>.tab {
241
+ height: 100%;
242
+ display: flex;
243
+ flex-direction: column;
244
+ padding-bottom: 15px;
245
+ }
246
+
247
+ .upload-wrap {
248
+ flex: 1;
249
+ width: 100%;
250
+ height: 100%;
251
+ }
252
+ .upload-wrap .upload-area {
253
+ background-color: var(--input);
254
+ width: 100%;
255
+ height: 100%;
256
+ border-radius: 20px;
257
+ display: flex;
258
+ align-items: center;
259
+ justify-content: center;
260
+ font-size: 14px;
261
+ color: var(--text-light);
262
+ transition: 0.2s box-shadow;
263
+ cursor: pointer;
264
+ }
265
+ .upload-wrap .upload-area:hover {
266
+ box-shadow: 0 0 0 2px var(--accent-light);
267
+ }
268
+
269
+ .by-url-wrap {
270
+ margin-top: 15px;
271
+ }
272
+ .by-url-wrap .title {
273
+ font-size: 0.9rem;
274
+ font-weight: 500;
275
+ color: var(--text-light);
276
+ margin-bottom: 10px;
277
+ padding-left: 5px;
278
+ text-align: center;
279
+ }
280
+ .by-url-wrap .input-button {
281
+ display: flex;
282
+ align-items: center;
283
+ gap: 10px;
284
+ }</style>
@@ -0,0 +1,3 @@
1
+ declare const TabUpload: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type TabUpload = ReturnType<typeof TabUpload>;
3
+ export default TabUpload;
@@ -0,0 +1,56 @@
1
+ export declare let fileUploaderConfig: import("svelte/store").Writable<FileUploaderConfigInternal | null>;
2
+ export declare let selectedFile: import("svelte/store").Writable<SelectedFile | null>;
3
+ export type UploadType = 'image' | 'audio' | 'file';
4
+ export interface FileUploaderConfig {
5
+ /**
6
+ * image:
7
+ * - shows unsplash and excalidraw tabs
8
+ * audio:
9
+ * - shows audio preview
10
+ * file
11
+ * - allows any file type
12
+ * - preview tries to detect file type (image/audio/other)
13
+ */
14
+ type: UploadType;
15
+ uploader: (file: Blob, name: string | null) => Promise<{
16
+ url: string;
17
+ }>;
18
+ allowedMimeTypes?: string[];
19
+ maxFileSizeInMB?: number;
20
+ }
21
+ export type SelectedFileFrom = 'upload' | 'media' | 'unsplash' | 'excalidraw';
22
+ export type SelectedFileUploadType = 'paste' | 'dnd' | 'browse' | 'url';
23
+ export interface UnsplashImage {
24
+ url: string;
25
+ author: string;
26
+ author_url: string;
27
+ title: string | null;
28
+ alt: string | null;
29
+ }
30
+ export interface SelectedFile {
31
+ type: UploadType;
32
+ from: SelectedFileFrom;
33
+ upload?: {
34
+ type: SelectedFileUploadType;
35
+ blob: Blob;
36
+ fetchedUrl?: string;
37
+ };
38
+ unsplash?: UnsplashImage;
39
+ }
40
+ export interface UploadedFile {
41
+ url: string;
42
+ selectedFile: SelectedFile;
43
+ }
44
+ export type FileUploaderConfigInternal = Required<FileUploaderConfig> & {
45
+ onCancel: () => void;
46
+ onUpload: (file: UploadedFile) => void;
47
+ };
48
+ export declare function uploadFile(config: FileUploaderConfig): Promise<UploadedFile | null>;
49
+ export declare function getFileUploaderConfig(): FileUploaderConfigInternal;
50
+ export declare function closeFileUploader(): void;
51
+ export declare function completeFileUpload(file: UploadedFile): void;
52
+ export declare function setSelectedFile(file: SelectedFile): void;
53
+ export declare function clearSelectedFile(): void;
54
+ export declare function validateMimeType(mimeType: string): boolean;
55
+ export declare function getMimeNames(): string[];
56
+ export declare function getMimeNamesJoined(): string;
@@ -0,0 +1,92 @@
1
+ import { get, writable } from "svelte/store";
2
+ export let fileUploaderConfig = writable(null);
3
+ export let selectedFile = writable(null);
4
+ const defaults = {
5
+ type: 'image',
6
+ uploader: null,
7
+ allowedMimeTypes: [],
8
+ maxFileSizeInMB: 10,
9
+ };
10
+ // UploadedFile is uploaded
11
+ // null means cancelled
12
+ export function uploadFile(config) {
13
+ return new Promise((resolve, reject) => {
14
+ const finalConfig = {
15
+ ...defaults,
16
+ ...config,
17
+ onCancel: () => {
18
+ resolve(null);
19
+ },
20
+ onUpload: (file) => {
21
+ resolve(file);
22
+ },
23
+ };
24
+ if (config.allowedMimeTypes === undefined) {
25
+ // set based on type
26
+ if (finalConfig.type === 'image') {
27
+ finalConfig.allowedMimeTypes = ALLOWED_MIME_TYPES_IMAGE;
28
+ }
29
+ else if (finalConfig.type === 'audio') {
30
+ finalConfig.allowedMimeTypes = ALLOWED_MIME_TYPES_AUDIO;
31
+ }
32
+ }
33
+ fileUploaderConfig.set(finalConfig);
34
+ });
35
+ }
36
+ // to be used internally when the modal is opened
37
+ export function getFileUploaderConfig() {
38
+ return get(fileUploaderConfig);
39
+ }
40
+ export function closeFileUploader() {
41
+ const config = getFileUploaderConfig();
42
+ config.onCancel();
43
+ fileUploaderConfig.set(null);
44
+ clearSelectedFile();
45
+ }
46
+ export function completeFileUpload(file) {
47
+ const config = getFileUploaderConfig();
48
+ config.onUpload(file);
49
+ closeFileUploader();
50
+ }
51
+ export function setSelectedFile(file) {
52
+ selectedFile.set(file);
53
+ }
54
+ export function clearSelectedFile() {
55
+ selectedFile.set(null);
56
+ }
57
+ const ALLOWED_MIME_TYPES_IMAGE = [
58
+ 'image/gif',
59
+ 'image/jpeg',
60
+ 'image/png',
61
+ 'image/svg+xml',
62
+ 'image/webp',
63
+ 'image/apng',
64
+ 'image/avif'
65
+ ];
66
+ const ALLOWED_MIME_TYPES_AUDIO = [
67
+ 'audio/mpeg',
68
+ 'audio/ogg',
69
+ 'audio/wav',
70
+ 'audio/webm'
71
+ ];
72
+ export function validateMimeType(mimeType) {
73
+ const config = getFileUploaderConfig();
74
+ if (config.allowedMimeTypes.length === 0) {
75
+ return true; // all mime types allowed
76
+ }
77
+ return config.allowedMimeTypes.includes(mimeType);
78
+ }
79
+ export function getMimeNames() {
80
+ const config = getFileUploaderConfig();
81
+ if (config.allowedMimeTypes.length === 0) {
82
+ return []; // all mime types allowed
83
+ }
84
+ return config.allowedMimeTypes.map(m => m.split('/')[1]?.split('+')[0]);
85
+ }
86
+ export function getMimeNamesJoined() {
87
+ const names = getMimeNames();
88
+ if (names.length === 0) {
89
+ return 'any';
90
+ }
91
+ return names.join(', ').toUpperCase();
92
+ }
@@ -0,0 +1,6 @@
1
+ export declare function isValidUrl(url: string): boolean;
2
+ /**
3
+ * https://stackoverflow.com/a/18650828
4
+ */
5
+ export declare function byteFormatter(bytes: number): string;
6
+ export declare function toKebabCase(str: string | null): string;
@@ -0,0 +1,31 @@
1
+ export function isValidUrl(url) {
2
+ try {
3
+ new URL(url);
4
+ return true;
5
+ }
6
+ catch (e) {
7
+ return false;
8
+ }
9
+ }
10
+ /**
11
+ * https://stackoverflow.com/a/18650828
12
+ */
13
+ export function byteFormatter(bytes) {
14
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
15
+ if (bytes == 0)
16
+ return '0 Bytes';
17
+ const i = parseInt(String(Math.floor(Math.log(bytes) / Math.log(1000))));
18
+ return Math.round(bytes / Math.pow(1000, i)) + ' ' + sizes[i];
19
+ }
20
+ export function toKebabCase(str) {
21
+ if (!str) {
22
+ return '';
23
+ }
24
+ return str
25
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
26
+ .replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`) // Add hyphen before capital letters and convert them to lowercase
27
+ .replace(/_+/g, '-') // Replace underscores with hyphens
28
+ .replace(/--+/g, '-') // Replace multiple hyphens with a single one
29
+ .replace(/^-|-$|^-+|-+$/g, '') // Remove leading and trailing hyphens
30
+ .toLowerCase(); // Ensure everything is in lowercase
31
+ }
@@ -31,11 +31,6 @@
31
31
  flex-shrink: 0;
32
32
  margin-right: 8px;
33
33
  }
34
- img {
35
- width: 40px;
36
- height: 40px;
37
- border-radius: 50%;
38
- }
39
34
  .right {
40
35
  flex: 1;
41
36
  line-height: 18px;
@@ -25,6 +25,7 @@ export { default as FormControl } from './FormControl/FormControl.svelte';
25
25
  export { default as InputGroup } from './FormControl/InputGroup.svelte';
26
26
  export { default as Label } from './FormControl/Label.svelte';
27
27
  export { default as Validation } from './FormControl/Validation.svelte';
28
+ export { uploadFile, type FileUploaderConfig, type UploadedFile as FileUploaderUploadedFile, type SelectedFile as FileUploaderSelectedFile, } from './FileUploader/file-uploader.js';
28
29
  export { default as HyvorBar } from './HyvorBar/HyvorBar.svelte';
29
30
  export { bar as hyvorBar } from './HyvorBar/bar.js';
30
31
  export { default as IconButton } from './IconButton/IconButton.svelte';
@@ -25,6 +25,7 @@ export { default as FormControl } from './FormControl/FormControl.svelte';
25
25
  export { default as InputGroup } from './FormControl/InputGroup.svelte';
26
26
  export { default as Label } from './FormControl/Label.svelte';
27
27
  export { default as Validation } from './FormControl/Validation.svelte';
28
+ export { uploadFile, } from './FileUploader/file-uploader.js';
28
29
  export { default as HyvorBar } from './HyvorBar/HyvorBar.svelte';
29
30
  export { bar as hyvorBar } from './HyvorBar/bar.js';
30
31
  export { default as IconButton } from './IconButton/IconButton.svelte';
package/package.json CHANGED
@@ -60,5 +60,5 @@
60
60
  "publishConfig": {
61
61
  "access": "public"
62
62
  },
63
- "version": "1.1.18"
63
+ "version": "1.1.19-beta-file-uploader.1"
64
64
  }