@makolabs/ripple 2.5.2 → 2.5.8
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/adapters/storage/S3Adapter.d.ts +5 -0
- package/dist/adapters/storage/S3Adapter.js +20 -8
- package/dist/adapters/storage/types.d.ts +8 -0
- package/dist/ai/ai-types.d.ts +23 -1
- package/dist/file-browser/FileBrowser.svelte +265 -47
- package/dist/file-browser/FileBrowser.svelte.d.ts +1 -1
- package/dist/funcs/user-management.remote.d.ts +1 -1
- package/dist/funcs/user-management.remote.js +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/s3.d.ts +53 -0
- package/dist/server/s3.js +100 -0
- package/package.json +7 -1
|
@@ -7,10 +7,15 @@ export declare class S3Adapter extends BaseAdapter {
|
|
|
7
7
|
private basePath;
|
|
8
8
|
private bucket;
|
|
9
9
|
private fileExtensions;
|
|
10
|
+
private displayNameStripPattern;
|
|
11
|
+
/** Default pattern strips common compression extensions (.gz, .bz2, .zip, .zst, .xz, .lz4). */
|
|
12
|
+
private static DEFAULT_STRIP_PATTERN;
|
|
10
13
|
constructor(basePathOrOptions?: string | {
|
|
11
14
|
basePath?: string;
|
|
12
15
|
bucket?: string;
|
|
13
16
|
fileExtensions?: string[];
|
|
17
|
+
/** Regex applied to the filename to produce displayNameKey. @default strips .gz/.bz2/.zip/.zst/.xz/.lz4 */
|
|
18
|
+
displayNameStripPattern?: RegExp;
|
|
14
19
|
}, publicS3BasePath?: string);
|
|
15
20
|
getName(): string;
|
|
16
21
|
isConfigured(): Promise<boolean>;
|
|
@@ -6,15 +6,21 @@ export class S3Adapter extends BaseAdapter {
|
|
|
6
6
|
basePath;
|
|
7
7
|
bucket;
|
|
8
8
|
fileExtensions;
|
|
9
|
+
displayNameStripPattern;
|
|
10
|
+
/** Default pattern strips common compression extensions (.gz, .bz2, .zip, .zst, .xz, .lz4). */
|
|
11
|
+
static DEFAULT_STRIP_PATTERN = /\.(gz|bz2|zip|zst|xz|lz4)$/i;
|
|
9
12
|
constructor(basePathOrOptions, publicS3BasePath) {
|
|
10
13
|
super();
|
|
11
14
|
if (typeof basePathOrOptions === 'object' && basePathOrOptions !== null) {
|
|
12
15
|
this.basePath = basePathOrOptions.basePath || '/';
|
|
13
16
|
this.bucket = basePathOrOptions.bucket;
|
|
14
17
|
this.fileExtensions = basePathOrOptions.fileExtensions;
|
|
18
|
+
this.displayNameStripPattern =
|
|
19
|
+
basePathOrOptions.displayNameStripPattern ?? S3Adapter.DEFAULT_STRIP_PATTERN;
|
|
15
20
|
}
|
|
16
21
|
else {
|
|
17
22
|
this.basePath = basePathOrOptions || publicS3BasePath || '/';
|
|
23
|
+
this.displayNameStripPattern = S3Adapter.DEFAULT_STRIP_PATTERN;
|
|
18
24
|
}
|
|
19
25
|
}
|
|
20
26
|
getName() {
|
|
@@ -49,8 +55,10 @@ export class S3Adapter extends BaseAdapter {
|
|
|
49
55
|
}
|
|
50
56
|
async list(path, searchQuery) {
|
|
51
57
|
try {
|
|
58
|
+
// Use basePath when no path is provided
|
|
59
|
+
const effectivePath = path || this.basePath;
|
|
52
60
|
// Ensure path ends with a forward slash
|
|
53
|
-
const normalizedPath =
|
|
61
|
+
const normalizedPath = effectivePath.endsWith('/') ? effectivePath : effectivePath + '/';
|
|
54
62
|
// Build API URL including search term and bucket if provided
|
|
55
63
|
let apiUrl = `/api/s3/list?prefix=${encodeURIComponent(normalizedPath)}`;
|
|
56
64
|
if (searchQuery) {
|
|
@@ -98,13 +106,17 @@ export class S3Adapter extends BaseAdapter {
|
|
|
98
106
|
}
|
|
99
107
|
return true;
|
|
100
108
|
})
|
|
101
|
-
.map((file) =>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
109
|
+
.map((file) => {
|
|
110
|
+
const filename = file.key.slice(normalizedPath.length);
|
|
111
|
+
return {
|
|
112
|
+
key: file.key,
|
|
113
|
+
name: this.removeFileExtensions(filename),
|
|
114
|
+
displayNameKey: filename.replace(this.displayNameStripPattern, ''),
|
|
115
|
+
lastModified: new Date(file.lastModified),
|
|
116
|
+
size: file.size,
|
|
117
|
+
isFolder: false
|
|
118
|
+
};
|
|
119
|
+
});
|
|
108
120
|
// Combine folders and files
|
|
109
121
|
const files = [...folders, ...fileItems];
|
|
110
122
|
// Sort by lastModified in reverse order (newest first), but keep folders first
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
export interface FileItem {
|
|
5
5
|
key: string;
|
|
6
6
|
name: string;
|
|
7
|
+
/** Name with compression/archive suffixes stripped, for deduplication and display matching. */
|
|
8
|
+
displayNameKey?: string;
|
|
7
9
|
lastModified: Date;
|
|
8
10
|
createdAt?: Date;
|
|
9
11
|
size: number;
|
|
@@ -11,6 +13,12 @@ export interface FileItem {
|
|
|
11
13
|
id?: string;
|
|
12
14
|
mimeType?: string;
|
|
13
15
|
parentId?: string;
|
|
16
|
+
/** Current status of this file in an upload/import flow. */
|
|
17
|
+
status?: 'ready' | 'uploading' | 'success' | 'error';
|
|
18
|
+
/** Upload/import progress (0-100). */
|
|
19
|
+
progress?: number;
|
|
20
|
+
/** Error message when status is 'error'. */
|
|
21
|
+
error?: string;
|
|
14
22
|
}
|
|
15
23
|
export interface FileActionSingle {
|
|
16
24
|
label: (file: FileItem) => string;
|
package/dist/ai/ai-types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Component } from 'svelte';
|
|
2
|
-
import type { FileAction, StorageAdapter } from '../adapters/storage/types.js';
|
|
2
|
+
import type { FileAction, FileItem, StorageAdapter } from '../adapters/storage/types.js';
|
|
3
3
|
export type ChatMessageType = 'chat' | 'action' | 'thinking';
|
|
4
4
|
export type StreamingCallback = (response: ChatResponse) => void;
|
|
5
5
|
export interface ChatAction {
|
|
@@ -35,11 +35,33 @@ export interface FileBrowserProps {
|
|
|
35
35
|
adapter: StorageAdapter;
|
|
36
36
|
startPath?: string;
|
|
37
37
|
actions?: FileAction[];
|
|
38
|
+
/**
|
|
39
|
+
* Bindable list of selected files as full FileItem objects. Consumers
|
|
40
|
+
* update `selectedItems[i].status` / `.progress` / `.error` to drive the
|
|
41
|
+
* per-row status icons and inline status summary.
|
|
42
|
+
*
|
|
43
|
+
* Mirrors FileUpload's `files: StagedFile[]` pattern — one unified array
|
|
44
|
+
* for file identity and upload state.
|
|
45
|
+
*/
|
|
46
|
+
selectedItems?: FileItem[];
|
|
47
|
+
/**
|
|
48
|
+
* Bindable array of selected file keys. Kept for read access / simple
|
|
49
|
+
* use cases; for status tracking use `selectedItems` instead.
|
|
50
|
+
*/
|
|
38
51
|
selectedFiles?: string[];
|
|
39
52
|
/** Controls whether the select-all checkbox selects the current page or all data. @default 'page' */
|
|
40
53
|
selectAllScope?: 'page' | 'all';
|
|
54
|
+
/** Tailwind height class for the browser container. @default 'h-[500px]' */
|
|
55
|
+
height?: string;
|
|
56
|
+
/** Additional CSS classes for the outer container. */
|
|
57
|
+
class?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Custom sidebar snippet. When omitted, the FileBrowser renders a default
|
|
60
|
+
* sidebar with Clear All, batch action buttons, and per-file status icons.
|
|
61
|
+
*/
|
|
41
62
|
infoSection?: (props: {
|
|
42
63
|
selectedFiles: string[];
|
|
64
|
+
files: FileItem[];
|
|
43
65
|
navToFileFolder: (fileKey: string) => void;
|
|
44
66
|
}) => any;
|
|
45
67
|
testId?: string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount } from 'svelte';
|
|
3
|
-
import {
|
|
3
|
+
import { cn } from '../helper/cls.js';
|
|
4
|
+
import { Button, Table, Color, Size, Spinner } from '../index.js';
|
|
4
5
|
import type { TableColumn, FileBrowserProps } from '../index.js';
|
|
5
6
|
import { formatDate } from '../utils/dateUtils.js';
|
|
6
7
|
import type {
|
|
@@ -17,7 +18,10 @@
|
|
|
17
18
|
actions = [],
|
|
18
19
|
infoSection,
|
|
19
20
|
selectAllScope = 'page',
|
|
20
|
-
|
|
21
|
+
height = 'h-[500px]',
|
|
22
|
+
class: className = '',
|
|
23
|
+
selectedItems = $bindable<FileItem[]>([]),
|
|
24
|
+
selectedFiles = $bindable<string[]>([])
|
|
21
25
|
}: FileBrowserProps = $props();
|
|
22
26
|
|
|
23
27
|
let files = $state<FileItem[]>([]);
|
|
@@ -393,6 +397,72 @@
|
|
|
393
397
|
let selected = $state<FileItem[]>([]);
|
|
394
398
|
|
|
395
399
|
// Derived: all files to import (not folders), including recursively fetched
|
|
400
|
+
// Sync selectedItems from selectedFiles + fileQueue. Excludes folders
|
|
401
|
+
// (folders are navigation targets, not files to act on — their contents
|
|
402
|
+
// get expanded into fileQueue by handleSelectFolder).
|
|
403
|
+
// Preserves existing FileItem refs so consumer mutations
|
|
404
|
+
// (e.g., selectedItems[i].status = 'uploading') persist across re-syncs.
|
|
405
|
+
$effect(() => {
|
|
406
|
+
// Candidate file keys: selected non-folder files + files from expanded folders
|
|
407
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
408
|
+
const candidateKeys = new Set<string>();
|
|
409
|
+
for (const key of selectedFiles) {
|
|
410
|
+
const item = displayFiles.find((f) => f.key === key);
|
|
411
|
+
if (item && !item.isFolder) candidateKeys.add(key);
|
|
412
|
+
}
|
|
413
|
+
for (const item of fileQueue) {
|
|
414
|
+
if (!item.isFolder) candidateKeys.add(item.key);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const existingKeys = new Set(selectedItems.map((f) => f.key));
|
|
418
|
+
|
|
419
|
+
const needsSync =
|
|
420
|
+
selectedItems.some((f) => !candidateKeys.has(f.key)) ||
|
|
421
|
+
[...candidateKeys].some((k) => !existingKeys.has(k));
|
|
422
|
+
|
|
423
|
+
if (!needsSync) return;
|
|
424
|
+
|
|
425
|
+
const kept = selectedItems.filter((f) => candidateKeys.has(f.key));
|
|
426
|
+
const added: FileItem[] = [];
|
|
427
|
+
for (const key of candidateKeys) {
|
|
428
|
+
if (!existingKeys.has(key)) {
|
|
429
|
+
const found =
|
|
430
|
+
displayFiles.find((f) => f.key === key) || fileQueue.find((f) => f.key === key);
|
|
431
|
+
if (found && !found.isFolder) {
|
|
432
|
+
added.push({ ...found, status: found.status ?? 'ready' });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
selectedItems = [...kept, ...added];
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// Status tally from selectedItems.
|
|
441
|
+
const statusCounts = $derived.by(() => {
|
|
442
|
+
let uploading = 0;
|
|
443
|
+
let success = 0;
|
|
444
|
+
let error = 0;
|
|
445
|
+
for (const item of selectedItems) {
|
|
446
|
+
if (item.status === 'uploading') uploading++;
|
|
447
|
+
else if (item.status === 'success') success++;
|
|
448
|
+
else if (item.status === 'error') error++;
|
|
449
|
+
}
|
|
450
|
+
return { uploading, success, error };
|
|
451
|
+
});
|
|
452
|
+
const hasUploadActivity = $derived(
|
|
453
|
+
statusCounts.uploading > 0 || statusCounts.success > 0 || statusCounts.error > 0
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
// Auto-clear fileQueue and processedFolders when selectedFiles is emptied
|
|
457
|
+
// from outside (e.g., parent clicks "Clear All" in infoSection).
|
|
458
|
+
$effect(() => {
|
|
459
|
+
if (selectedFiles.length === 0 && fileQueue.length > 0) {
|
|
460
|
+
fileQueue = [];
|
|
461
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
462
|
+
processedFolders = new Set();
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
396
466
|
const allFilesAcquired = $derived.by(() => {
|
|
397
467
|
const selectedFileKeys = new Set(selectedFiles);
|
|
398
468
|
|
|
@@ -687,8 +757,14 @@
|
|
|
687
757
|
<span class="text-default-600">{formatDate(file.lastModified, 'DD.MM.YYYY HH:mm')}</span>
|
|
688
758
|
{/snippet}
|
|
689
759
|
|
|
690
|
-
<div
|
|
691
|
-
|
|
760
|
+
<div
|
|
761
|
+
class={cn(
|
|
762
|
+
'border-default-200 relative flex overflow-hidden rounded-lg border bg-white',
|
|
763
|
+
height,
|
|
764
|
+
className
|
|
765
|
+
)}
|
|
766
|
+
>
|
|
767
|
+
<div class="flex min-w-0 flex-1 flex-col">
|
|
692
768
|
{#if !isAuthenticated && adapter.authenticate}
|
|
693
769
|
<div class="flex h-full flex-col items-center justify-center">
|
|
694
770
|
<div class="mb-4 text-center text-lg">
|
|
@@ -697,10 +773,8 @@
|
|
|
697
773
|
<Button color={Color.PRIMARY} onclick={authenticate}>Authenticate</Button>
|
|
698
774
|
</div>
|
|
699
775
|
{:else}
|
|
700
|
-
<div
|
|
701
|
-
class="
|
|
702
|
-
>
|
|
703
|
-
<div class="flex flex-wrap items-center">
|
|
776
|
+
<div class="border-default-100 flex items-center gap-3 border-b px-4 py-3">
|
|
777
|
+
<div class="flex min-w-0 flex-1 items-center">
|
|
704
778
|
{#if breadcrumbs.length > 1}
|
|
705
779
|
<button
|
|
706
780
|
class="text-default-600 hover:bg-default-100 mr-1 rounded-full px-2 py-1"
|
|
@@ -726,31 +800,55 @@
|
|
|
726
800
|
{/if}
|
|
727
801
|
<span class="text-default-400 mx-1 text-sm">/</span>
|
|
728
802
|
|
|
729
|
-
<div
|
|
803
|
+
<div
|
|
804
|
+
class="flex min-w-0 flex-1 items-center overflow-hidden"
|
|
805
|
+
title={breadcrumbs.map((b) => b.name).join(' / ')}
|
|
806
|
+
>
|
|
730
807
|
{#each breadcrumbs as crumb, i (crumb.path)}
|
|
731
808
|
{#if i > 0}
|
|
732
|
-
<span class="text-default-400 mx-1 text-sm">/</span>
|
|
809
|
+
<span class="text-default-400 mx-1 shrink-0 text-sm">/</span>
|
|
733
810
|
{/if}
|
|
734
811
|
|
|
735
812
|
{#if crumb.clickable && !crumb.current}
|
|
736
813
|
<button
|
|
737
|
-
class="text-default-600 hover:bg-default-100 hover:text-default-900 cursor-pointer border-0 bg-transparent px-1 py-0 text-sm"
|
|
814
|
+
class="text-default-600 hover:bg-default-100 hover:text-default-900 max-w-[200px] cursor-pointer truncate border-0 bg-transparent px-1 py-0 text-sm"
|
|
738
815
|
onclick={() => navigateToFolder(crumb.path)}
|
|
739
816
|
>
|
|
740
817
|
{crumb.name}
|
|
741
818
|
</button>
|
|
742
819
|
{:else}
|
|
743
820
|
<span
|
|
744
|
-
class={`px-1 text-sm ${crumb.current ? 'text-default-800 font-semibold' : 'text-default-600'}`}
|
|
821
|
+
class={`max-w-[240px] truncate px-1 text-sm ${crumb.current ? 'text-default-800 font-semibold' : 'text-default-600'}`}
|
|
745
822
|
>
|
|
746
823
|
{crumb.name}
|
|
747
824
|
</span>
|
|
748
825
|
{/if}
|
|
749
826
|
{/each}
|
|
750
827
|
</div>
|
|
828
|
+
|
|
829
|
+
{#if selectedFiles.length > 0 && hasUploadActivity}
|
|
830
|
+
<div
|
|
831
|
+
class="text-default-500 ml-3 flex shrink-0 items-center gap-1.5 border-l pl-3 text-xs whitespace-nowrap"
|
|
832
|
+
data-filebrowser-summary=""
|
|
833
|
+
>
|
|
834
|
+
{#if statusCounts.success > 0}
|
|
835
|
+
<span class="text-success-600 font-medium">{statusCounts.success} uploaded</span>
|
|
836
|
+
{/if}
|
|
837
|
+
{#if statusCounts.error > 0}
|
|
838
|
+
{#if statusCounts.success > 0}<span class="text-default-300">·</span>{/if}
|
|
839
|
+
<span class="text-danger-600 font-medium">{statusCounts.error} failed</span>
|
|
840
|
+
{/if}
|
|
841
|
+
{#if statusCounts.uploading > 0}
|
|
842
|
+
{#if statusCounts.success > 0 || statusCounts.error > 0}<span
|
|
843
|
+
class="text-default-300">·</span
|
|
844
|
+
>{/if}
|
|
845
|
+
<span class="text-primary-600 font-medium">{statusCounts.uploading} uploading</span>
|
|
846
|
+
{/if}
|
|
847
|
+
</div>
|
|
848
|
+
{/if}
|
|
751
849
|
</div>
|
|
752
850
|
|
|
753
|
-
<div class="flex items-center gap-1">
|
|
851
|
+
<div class="flex shrink-0 items-center gap-1">
|
|
754
852
|
{#if isFetchingRecursively}
|
|
755
853
|
<div
|
|
756
854
|
class="flex items-center gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-1 text-xs"
|
|
@@ -815,47 +913,167 @@
|
|
|
815
913
|
</div>
|
|
816
914
|
</div>
|
|
817
915
|
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
<div class="
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
<div class="
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
<div class="
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
916
|
+
<div class="min-h-0 flex-1 overflow-auto">
|
|
917
|
+
{#if isLoading}
|
|
918
|
+
<div class="flex h-full items-center justify-center py-16">
|
|
919
|
+
<div class="text-default-500">Loading files...</div>
|
|
920
|
+
</div>
|
|
921
|
+
{:else if error}
|
|
922
|
+
<div class="flex h-full flex-col items-center justify-center py-16">
|
|
923
|
+
<div class="text-danger-500 mb-4">{error}</div>
|
|
924
|
+
<Button color={Color.PRIMARY} onclick={() => listFiles(currentPath)}>Retry</Button>
|
|
925
|
+
</div>
|
|
926
|
+
{:else if files.length === 0}
|
|
927
|
+
<div class="flex h-full items-center justify-center py-16">
|
|
928
|
+
<div class="text-default-500">No files found in this directory</div>
|
|
929
|
+
</div>
|
|
930
|
+
{:else}
|
|
931
|
+
<Table
|
|
932
|
+
{columns}
|
|
933
|
+
data={displayFiles}
|
|
934
|
+
loading={isLoading}
|
|
935
|
+
bordered={false}
|
|
936
|
+
onrowclick={handleRowClick}
|
|
937
|
+
rowclass={(row) => {
|
|
938
|
+
let classes = row.isFolder ? 'hover:bg-amber-50 cursor-pointer' : 'hover:bg-blue-50';
|
|
939
|
+
if (isRowSelected(row)) {
|
|
940
|
+
classes += ' bg-primary-50';
|
|
941
|
+
}
|
|
942
|
+
return classes;
|
|
943
|
+
}}
|
|
944
|
+
onsort={handleSort}
|
|
945
|
+
selectable={true}
|
|
946
|
+
{selectAllScope}
|
|
947
|
+
{onselect}
|
|
948
|
+
{selected}
|
|
949
|
+
/>
|
|
950
|
+
{/if}
|
|
951
|
+
</div>
|
|
852
952
|
{/if}
|
|
853
953
|
</div>
|
|
854
954
|
|
|
855
955
|
{#if infoSection}
|
|
856
956
|
{@render infoSection({
|
|
857
957
|
selectedFiles: allFilesAcquired.map((file) => file.key),
|
|
958
|
+
files: selectedItems,
|
|
858
959
|
navToFileFolder
|
|
859
960
|
})}
|
|
961
|
+
{:else if selectedItems.length > 0}
|
|
962
|
+
{@render defaultInfoSection()}
|
|
860
963
|
{/if}
|
|
861
964
|
</div>
|
|
965
|
+
|
|
966
|
+
{#snippet defaultInfoSection()}
|
|
967
|
+
<div class="border-default-200 bg-default-50/50 flex w-72 min-w-72 shrink-0 flex-col border-l">
|
|
968
|
+
<div class="border-default-100 flex flex-col gap-2 border-b px-4 py-3">
|
|
969
|
+
<h3 class="text-default-900 text-sm font-semibold">
|
|
970
|
+
Selected ({selectedItems.length})
|
|
971
|
+
</h3>
|
|
972
|
+
<div class="flex gap-2">
|
|
973
|
+
<Button
|
|
974
|
+
size={Size.XS}
|
|
975
|
+
variant="outline"
|
|
976
|
+
color={Color.DEFAULT}
|
|
977
|
+
onclick={() => {
|
|
978
|
+
selectedFiles = [];
|
|
979
|
+
selectedItems = [];
|
|
980
|
+
}}
|
|
981
|
+
disabled={statusCounts.uploading > 0}
|
|
982
|
+
class="whitespace-nowrap"
|
|
983
|
+
>
|
|
984
|
+
Clear All
|
|
985
|
+
</Button>
|
|
986
|
+
{#each batchActions as action (action.label)}
|
|
987
|
+
<Button
|
|
988
|
+
size={Size.XS}
|
|
989
|
+
color={Color.PRIMARY}
|
|
990
|
+
onclick={() => action.batchAction?.(allFilesAcquired)}
|
|
991
|
+
disabled={!action.isAllowed(allFilesAcquired)}
|
|
992
|
+
class="whitespace-nowrap"
|
|
993
|
+
>
|
|
994
|
+
{action.label(allFilesAcquired)}
|
|
995
|
+
</Button>
|
|
996
|
+
{/each}
|
|
997
|
+
</div>
|
|
998
|
+
</div>
|
|
999
|
+
|
|
1000
|
+
<ul class="divide-default-100 min-h-0 flex-1 divide-y overflow-y-auto">
|
|
1001
|
+
{#each selectedItems as item (item.key)}
|
|
1002
|
+
{@const status = item.status ?? 'ready'}
|
|
1003
|
+
<li
|
|
1004
|
+
class={cn(
|
|
1005
|
+
'flex items-center gap-2 px-3 py-2 text-xs',
|
|
1006
|
+
status === 'success' && 'opacity-60'
|
|
1007
|
+
)}
|
|
1008
|
+
data-filebrowser-selected-row=""
|
|
1009
|
+
data-status={status}
|
|
1010
|
+
>
|
|
1011
|
+
{#if status === 'uploading'}
|
|
1012
|
+
<div class="flex size-5 shrink-0 items-center justify-center">
|
|
1013
|
+
<Spinner size={Size.XS} color={Color.PRIMARY} />
|
|
1014
|
+
</div>
|
|
1015
|
+
{:else if status === 'success'}
|
|
1016
|
+
<span
|
|
1017
|
+
class="bg-success-100 text-success-600 flex size-5 shrink-0 items-center justify-center rounded-full"
|
|
1018
|
+
aria-hidden="true"
|
|
1019
|
+
>
|
|
1020
|
+
<svg class="size-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
1021
|
+
<path
|
|
1022
|
+
stroke-linecap="round"
|
|
1023
|
+
stroke-linejoin="round"
|
|
1024
|
+
stroke-width="3"
|
|
1025
|
+
d="M5 13l4 4L19 7"
|
|
1026
|
+
/>
|
|
1027
|
+
</svg>
|
|
1028
|
+
</span>
|
|
1029
|
+
{:else if status === 'error'}
|
|
1030
|
+
<span
|
|
1031
|
+
class="bg-danger-100 text-danger-600 flex size-5 shrink-0 items-center justify-center rounded-full"
|
|
1032
|
+
aria-hidden="true"
|
|
1033
|
+
>
|
|
1034
|
+
<svg class="size-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
1035
|
+
<path
|
|
1036
|
+
stroke-linecap="round"
|
|
1037
|
+
stroke-linejoin="round"
|
|
1038
|
+
stroke-width="3"
|
|
1039
|
+
d="M6 18L18 6M6 6l12 12"
|
|
1040
|
+
/>
|
|
1041
|
+
</svg>
|
|
1042
|
+
</span>
|
|
1043
|
+
{:else}
|
|
1044
|
+
<span class="border-default-300 size-5 shrink-0 rounded-full border" aria-hidden="true"
|
|
1045
|
+
></span>
|
|
1046
|
+
{/if}
|
|
1047
|
+
<div class="flex min-w-0 flex-1 flex-col">
|
|
1048
|
+
<span class="text-default-900 truncate font-medium" title={item.key}>
|
|
1049
|
+
{item.name}
|
|
1050
|
+
</span>
|
|
1051
|
+
{#if item.error}
|
|
1052
|
+
<span class="text-danger-600 truncate text-[11px]">{item.error}</span>
|
|
1053
|
+
{/if}
|
|
1054
|
+
</div>
|
|
1055
|
+
{#if status !== 'uploading' && status !== 'success'}
|
|
1056
|
+
<button
|
|
1057
|
+
type="button"
|
|
1058
|
+
onclick={() => {
|
|
1059
|
+
selectedFiles = selectedFiles.filter((k) => k !== item.key);
|
|
1060
|
+
}}
|
|
1061
|
+
class="text-default-400 hover:bg-default-100 hover:text-danger-500 shrink-0 cursor-pointer rounded p-1"
|
|
1062
|
+
aria-label="Remove {item.name}"
|
|
1063
|
+
title="Remove"
|
|
1064
|
+
>
|
|
1065
|
+
<svg class="size-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
1066
|
+
<path
|
|
1067
|
+
stroke-linecap="round"
|
|
1068
|
+
stroke-linejoin="round"
|
|
1069
|
+
stroke-width="2"
|
|
1070
|
+
d="M6 18L18 6M6 6l12 12"
|
|
1071
|
+
/>
|
|
1072
|
+
</svg>
|
|
1073
|
+
</button>
|
|
1074
|
+
{/if}
|
|
1075
|
+
</li>
|
|
1076
|
+
{/each}
|
|
1077
|
+
</ul>
|
|
1078
|
+
</div>
|
|
1079
|
+
{/snippet}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { FileBrowserProps } from '../index.js';
|
|
2
|
-
declare const FileBrowser: import("svelte").Component<FileBrowserProps, {}, "selectedFiles">;
|
|
2
|
+
declare const FileBrowser: import("svelte").Component<FileBrowserProps, {}, "selectedItems" | "selectedFiles">;
|
|
3
3
|
type FileBrowser = ReturnType<typeof FileBrowser>;
|
|
4
4
|
export default FileBrowser;
|
|
@@ -7,7 +7,7 @@ export declare const updateUser: import("@sveltejs/kit").RemoteCommand<{
|
|
|
7
7
|
}, Promise<User>>;
|
|
8
8
|
export declare const deleteUser: import("@sveltejs/kit").RemoteCommand<string, Promise<void>>;
|
|
9
9
|
export declare const deleteUsers: import("@sveltejs/kit").RemoteCommand<string[], Promise<void>>;
|
|
10
|
-
export declare const getUserPermissions: import("@sveltejs/kit").
|
|
10
|
+
export declare const getUserPermissions: import("@sveltejs/kit").RemoteCommand<string, Promise<string[]>>;
|
|
11
11
|
export declare const updateUserPermissions: import("@sveltejs/kit").RemoteCommand<{
|
|
12
12
|
userId: string;
|
|
13
13
|
permissions: string[];
|
|
@@ -491,7 +491,7 @@ async function fetchUserPermissions(email) {
|
|
|
491
491
|
}
|
|
492
492
|
}
|
|
493
493
|
}
|
|
494
|
-
export const getUserPermissions =
|
|
494
|
+
export const getUserPermissions = command('unchecked', async (userId) => {
|
|
495
495
|
log.trace('getUserPermissions', 'Called for userId:', userId);
|
|
496
496
|
try {
|
|
497
497
|
// Fetch user from Clerk to get email
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './s3.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './s3.js';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S3 server handler factory. Returns Request handlers that the client-side
|
|
3
|
+
* S3Adapter expects at /api/s3/list and /api/s3/download.
|
|
4
|
+
*
|
|
5
|
+
* Keeps AWS credentials server-side — consumers just wire these handlers into
|
|
6
|
+
* their route files.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* // src/routes/api/s3/list/+server.ts
|
|
11
|
+
* import { createS3Handlers } from '@makolabs/ripple/server';
|
|
12
|
+
* import { env } from '$env/dynamic/private';
|
|
13
|
+
*
|
|
14
|
+
* const s3 = createS3Handlers({
|
|
15
|
+
* bucket: env.S3_BUCKET,
|
|
16
|
+
* region: env.S3_REGION,
|
|
17
|
+
* accessKeyId: env.S3_ACCESS_KEY_ID,
|
|
18
|
+
* secretAccessKey: env.S3_SECRET_ACCESS_KEY,
|
|
19
|
+
* endpoint: env.S3_ENDPOINT, // optional, e.g. for DO Spaces
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* export const GET = ({ request }) => s3.list(request);
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export interface S3HandlerConfig {
|
|
26
|
+
/** S3 bucket name (default used when request doesn't specify one) */
|
|
27
|
+
bucket: string;
|
|
28
|
+
/** AWS region or DO Spaces region (e.g. 'us-east-1', 'fra1') */
|
|
29
|
+
region: string;
|
|
30
|
+
/** AWS access key ID */
|
|
31
|
+
accessKeyId: string;
|
|
32
|
+
/** AWS secret access key */
|
|
33
|
+
secretAccessKey: string;
|
|
34
|
+
/**
|
|
35
|
+
* Custom endpoint URL for S3-compatible services like DigitalOcean Spaces.
|
|
36
|
+
* e.g. 'https://fra1.digitaloceanspaces.com'
|
|
37
|
+
*/
|
|
38
|
+
endpoint?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Whether a specific bucket override passed via the `bucket` query param
|
|
41
|
+
* should be allowed. Defaults to `false` for safety — only the configured
|
|
42
|
+
* bucket is accessible. Set to `true` if you need multi-bucket support.
|
|
43
|
+
*/
|
|
44
|
+
allowBucketOverride?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Download URL expiry in seconds. @default 3600 (1 hour)
|
|
47
|
+
*/
|
|
48
|
+
downloadUrlExpirySeconds?: number;
|
|
49
|
+
}
|
|
50
|
+
export declare function createS3Handlers(config: S3HandlerConfig): {
|
|
51
|
+
list: (request: Request) => Promise<Response>;
|
|
52
|
+
download: (request: Request) => Promise<Response>;
|
|
53
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { S3Client, ListObjectsV2Command, GetObjectCommand } from '@aws-sdk/client-s3';
|
|
2
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
3
|
+
export function createS3Handlers(config) {
|
|
4
|
+
const clientConfig = {
|
|
5
|
+
region: config.region,
|
|
6
|
+
credentials: {
|
|
7
|
+
accessKeyId: config.accessKeyId,
|
|
8
|
+
secretAccessKey: config.secretAccessKey
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
if (config.endpoint) {
|
|
12
|
+
clientConfig.endpoint = config.endpoint;
|
|
13
|
+
clientConfig.forcePathStyle = false; // DO Spaces uses virtual-hosted style
|
|
14
|
+
}
|
|
15
|
+
const client = new S3Client(clientConfig);
|
|
16
|
+
const expirySeconds = config.downloadUrlExpirySeconds ?? 3600;
|
|
17
|
+
/**
|
|
18
|
+
* GET /api/s3/list?prefix=<path>&search=<term>&bucket=<name>
|
|
19
|
+
*
|
|
20
|
+
* Returns folders and files at the given prefix.
|
|
21
|
+
*/
|
|
22
|
+
async function list(request) {
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(request.url);
|
|
25
|
+
const prefix = url.searchParams.get('prefix') ?? '';
|
|
26
|
+
const search = url.searchParams.get('search') ?? '';
|
|
27
|
+
const bucketParam = url.searchParams.get('bucket');
|
|
28
|
+
const bucket = config.allowBucketOverride && bucketParam ? bucketParam : config.bucket;
|
|
29
|
+
const command = new ListObjectsV2Command({
|
|
30
|
+
Bucket: bucket,
|
|
31
|
+
Prefix: prefix,
|
|
32
|
+
Delimiter: '/'
|
|
33
|
+
});
|
|
34
|
+
const response = await client.send(command);
|
|
35
|
+
// Folders come back as CommonPrefixes
|
|
36
|
+
let folders = (response.CommonPrefixes ?? []).map((cp) => ({
|
|
37
|
+
prefix: cp.Prefix ?? '',
|
|
38
|
+
folderName: cp.Prefix ? cp.Prefix.slice(prefix.length).replace(/\/$/, '') : ''
|
|
39
|
+
}));
|
|
40
|
+
// Files come back as Contents
|
|
41
|
+
let files = (response.Contents ?? [])
|
|
42
|
+
.filter((obj) => obj.Key && obj.Key !== prefix)
|
|
43
|
+
.map((obj) => ({
|
|
44
|
+
key: obj.Key,
|
|
45
|
+
lastModified: obj.LastModified?.toISOString() ?? new Date().toISOString(),
|
|
46
|
+
size: obj.Size ?? 0
|
|
47
|
+
}));
|
|
48
|
+
// Apply search filter if provided (case-insensitive substring match on name)
|
|
49
|
+
if (search) {
|
|
50
|
+
const needle = search.toLowerCase();
|
|
51
|
+
folders = folders.filter((f) => f.folderName.toLowerCase().includes(needle));
|
|
52
|
+
files = files.filter((f) => {
|
|
53
|
+
const name = f.key.slice(prefix.length).toLowerCase();
|
|
54
|
+
return name.includes(needle);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return jsonResponse({ folders, files });
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
return errorResponse(err);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* GET /api/s3/download?key=<object-key>&bucket=<name>
|
|
65
|
+
*
|
|
66
|
+
* Returns a presigned URL (as plain text) that the browser can GET to
|
|
67
|
+
* download the file.
|
|
68
|
+
*/
|
|
69
|
+
async function download(request) {
|
|
70
|
+
try {
|
|
71
|
+
const url = new URL(request.url);
|
|
72
|
+
const key = url.searchParams.get('key');
|
|
73
|
+
const bucketParam = url.searchParams.get('bucket');
|
|
74
|
+
if (!key) {
|
|
75
|
+
return jsonResponse({ error: 'Missing required "key" query param' }, 400);
|
|
76
|
+
}
|
|
77
|
+
const bucket = config.allowBucketOverride && bucketParam ? bucketParam : config.bucket;
|
|
78
|
+
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
|
|
79
|
+
const signedUrl = await getSignedUrl(client, command, { expiresIn: expirySeconds });
|
|
80
|
+
return new Response(signedUrl, {
|
|
81
|
+
status: 200,
|
|
82
|
+
headers: { 'Content-Type': 'text/plain' }
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
return errorResponse(err);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { list, download };
|
|
90
|
+
}
|
|
91
|
+
function jsonResponse(body, status = 200) {
|
|
92
|
+
return new Response(JSON.stringify(body), {
|
|
93
|
+
status,
|
|
94
|
+
headers: { 'Content-Type': 'application/json' }
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function errorResponse(err) {
|
|
98
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
99
|
+
return jsonResponse({ error: message }, 500);
|
|
100
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@makolabs/ripple",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.8",
|
|
4
4
|
"description": "Simple Svelte 5 powered component library ✨",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"repository": {
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
"types": "./dist/index.d.ts",
|
|
43
43
|
"svelte": "./dist/index.js"
|
|
44
44
|
},
|
|
45
|
+
"./server": {
|
|
46
|
+
"types": "./dist/server/index.d.ts",
|
|
47
|
+
"default": "./dist/server/index.js"
|
|
48
|
+
},
|
|
45
49
|
"./funcs/*": {
|
|
46
50
|
"types": "./dist/funcs/*.d.ts",
|
|
47
51
|
"default": "./dist/funcs/*.js"
|
|
@@ -121,6 +125,8 @@
|
|
|
121
125
|
"*.{js,ts,svelte,css,md,json}": "prettier --write"
|
|
122
126
|
},
|
|
123
127
|
"dependencies": {
|
|
128
|
+
"@aws-sdk/client-s3": "^3.1029.0",
|
|
129
|
+
"@aws-sdk/s3-request-presigner": "^3.1029.0",
|
|
124
130
|
"@friendofsvelte/mermaid": "^0.0.4",
|
|
125
131
|
"dayjs": "^1.11.19",
|
|
126
132
|
"echarts": "^6.0.0",
|