@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.
@@ -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 = path.endsWith('/') ? path : path + '/';
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
- key: file.key,
103
- name: this.removeFileExtensions(file.key.slice(normalizedPath.length)),
104
- lastModified: new Date(file.lastModified),
105
- size: file.size,
106
- isFolder: false
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;
@@ -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 { Button, Table, Color, Size } from '../index.js';
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
- selectedFiles = $bindable([])
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 class="relative flex h-[calc(100vh-100px)] px-0">
691
- <div class="min-w-0 flex-1">
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="border-default-100 mb-2 flex flex-wrap items-center justify-between border-b pb-3"
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 class="flex flex-wrap items-center">
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
- {#if isLoading}
819
- <div class="flex h-full items-center justify-center">
820
- <div class="text-default-500">Loading files...</div>
821
- </div>
822
- {:else if error}
823
- <div class="flex h-full flex-col items-center justify-center">
824
- <div class="text-danger-500 mb-4">{error}</div>
825
- <Button color={Color.PRIMARY} onclick={() => listFiles(currentPath)}>Retry</Button>
826
- </div>
827
- {:else if files.length === 0}
828
- <div class="flex h-full items-center justify-center">
829
- <div class="text-default-500">No files found in this directory</div>
830
- </div>
831
- {:else}
832
- <Table
833
- {columns}
834
- data={displayFiles}
835
- loading={isLoading}
836
- bordered={false}
837
- onrowclick={handleRowClick}
838
- rowclass={(row) => {
839
- let classes = row.isFolder ? 'hover:bg-amber-50 cursor-pointer' : 'hover:bg-blue-50';
840
- if (isRowSelected(row)) {
841
- classes += ' bg-primary-50';
842
- }
843
- return classes;
844
- }}
845
- onsort={handleSort}
846
- selectable={true}
847
- {selectAllScope}
848
- {onselect}
849
- {selected}
850
- />
851
- {/if}
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").RemoteQueryFunction<string, string[]>;
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 = query('unchecked', async (userId) => {
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.2",
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",