@sqlrooms/s3-browser 0.0.0

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.
@@ -0,0 +1,4 @@
1
+ import {config} from '@sqlrooms/eslint-config/react-internal';
2
+
3
+ /** @type {import("eslint").Linter.Config} */
4
+ export default config;
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@sqlrooms/s3-browser",
3
+ "version": "0.0.0",
4
+ "main": "dist/index.js",
5
+ "types": "src/index.ts",
6
+ "module": "dist/index.js",
7
+ "type": "module",
8
+ "private": false,
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "dependencies": {
13
+ "@aws-sdk/client-s3": "^3.726.1",
14
+ "@sqlrooms/ui": "0.0.0",
15
+ "@sqlrooms/utils": "0.0.0",
16
+ "lucide-react": "^0.473.0",
17
+ "zod": "^3.24.1"
18
+ },
19
+ "peerDependencies": {
20
+ "react": "*"
21
+ },
22
+ "scripts": {
23
+ "dev": "tsc -w",
24
+ "build": "tsc",
25
+ "lint": "eslint ."
26
+ },
27
+ "gitHead": "4b0c709542475e4f95db0b2a8405ecadcf2ec186"
28
+ }
@@ -0,0 +1,220 @@
1
+ import {
2
+ Breadcrumb,
3
+ BreadcrumbItem,
4
+ BreadcrumbLink,
5
+ BreadcrumbList,
6
+ BreadcrumbSeparator,
7
+ Button,
8
+ Checkbox,
9
+ Table,
10
+ TableBody,
11
+ TableCell,
12
+ TableHead,
13
+ TableHeader,
14
+ TableRow,
15
+ cn,
16
+ } from '@sqlrooms/ui';
17
+ import {Undo2Icon, FolderIcon} from 'lucide-react';
18
+ import {formatBytes, formatTimeRelative} from '@sqlrooms/utils';
19
+ import {FC, useCallback, useEffect, useMemo} from 'react';
20
+ import {S3FileOrDirectory} from './S3FileOrDirectory';
21
+
22
+ type Props = {
23
+ files?: S3FileOrDirectory[];
24
+ selectedFiles: string[];
25
+ selectedDirectory: string;
26
+ onCanConfirmChange: (canConfirm: boolean) => void;
27
+ onChangeSelectedDirectory: (directory: string) => void;
28
+ onChangeSelectedFiles: (files: string[]) => void;
29
+ };
30
+
31
+ const S3FileBrowser: FC<Props> = (props) => {
32
+ const {
33
+ files,
34
+ selectedDirectory,
35
+ selectedFiles,
36
+ onCanConfirmChange,
37
+ onChangeSelectedFiles,
38
+ onChangeSelectedDirectory,
39
+ } = props;
40
+
41
+ useEffect(() => {
42
+ onCanConfirmChange(Boolean(selectedFiles?.length));
43
+ }, [selectedFiles, onCanConfirmChange]);
44
+
45
+ const handleSelectFile = useCallback(
46
+ (key: string) => {
47
+ if (selectedFiles.includes(key)) {
48
+ onChangeSelectedFiles(selectedFiles.filter((id) => id !== key));
49
+ } else {
50
+ onChangeSelectedFiles([...selectedFiles, key]);
51
+ }
52
+ },
53
+ [onChangeSelectedFiles, selectedFiles],
54
+ );
55
+
56
+ const handleSelectDirectory = useCallback(
57
+ (key: string) => {
58
+ onChangeSelectedDirectory(`${selectedDirectory}${key}/`);
59
+ },
60
+ [selectedDirectory, onChangeSelectedDirectory],
61
+ );
62
+
63
+ const filesInDirectory = useMemo(
64
+ () => files?.filter(({isDirectory}) => !isDirectory) ?? [],
65
+ [files],
66
+ );
67
+
68
+ const handleSelectAll = useCallback(() => {
69
+ if (selectedFiles.length === filesInDirectory.length) {
70
+ onChangeSelectedFiles([]);
71
+ } else {
72
+ onChangeSelectedFiles(filesInDirectory.map(({key}) => key) ?? []);
73
+ }
74
+ }, [filesInDirectory, onChangeSelectedFiles, selectedFiles.length]);
75
+
76
+ const parentDirectory = useMemo(() => {
77
+ const dir = selectedDirectory.split('/').slice(0, -2).join('/');
78
+ return dir ? `${dir}/` : '';
79
+ }, [selectedDirectory]);
80
+
81
+ return (
82
+ <div className="relative w-full h-full overflow-hidden">
83
+ <div className="absolute w-full h-full overflow-x-auto overflow-y-auto flex flex-col py-0 items-start">
84
+ <div className="w-full rounded-lg border border-gray-600 overflow-y-auto">
85
+ <Table disableWrapper>
86
+ <TableHeader>
87
+ {selectedDirectory ? (
88
+ <TableRow>
89
+ <TableCell
90
+ colSpan={5}
91
+ className="py-3 text-gray-100 bg-gray-800"
92
+ >
93
+ <div className="flex gap-2 items-center">
94
+ <Button
95
+ size="sm"
96
+ variant="outline"
97
+ onClick={() =>
98
+ onChangeSelectedDirectory(parentDirectory)
99
+ }
100
+ >
101
+ <Undo2Icon className="w-3 h-3 mr-1" />
102
+ ..
103
+ </Button>
104
+ <Breadcrumb>
105
+ <BreadcrumbList>
106
+ <BreadcrumbItem>
107
+ <BreadcrumbLink
108
+ onClick={() => onChangeSelectedDirectory('')}
109
+ className="text-xs text-blue-400"
110
+ >
111
+ Home
112
+ </BreadcrumbLink>
113
+ </BreadcrumbItem>
114
+
115
+ {selectedDirectory.split('/').map((directory, i) => {
116
+ if (!directory) return null;
117
+ const path = selectedDirectory
118
+ .split('/')
119
+ .slice(0, i + 1)
120
+ .join('/')
121
+ .concat('/');
122
+ const isCurrent = path === selectedDirectory;
123
+ return (
124
+ <BreadcrumbItem key={i}>
125
+ <BreadcrumbSeparator />
126
+ <BreadcrumbLink
127
+ className={cn(
128
+ 'text-xs text-blue-400',
129
+ isCurrent &&
130
+ 'cursor-default hover:no-underline',
131
+ )}
132
+ onClick={() => {
133
+ if (!isCurrent) {
134
+ onChangeSelectedDirectory(path);
135
+ }
136
+ }}
137
+ >
138
+ {directory}
139
+ </BreadcrumbLink>
140
+ </BreadcrumbItem>
141
+ );
142
+ })}
143
+ </BreadcrumbList>
144
+ </Breadcrumb>
145
+ </div>
146
+ </TableCell>
147
+ </TableRow>
148
+ ) : null}
149
+ <TableRow className="sticky top-0 z-[2] bg-gray-600">
150
+ <TableHead className="w-[1%]">
151
+ <Checkbox
152
+ checked={selectedFiles.length === filesInDirectory.length}
153
+ onCheckedChange={handleSelectAll}
154
+ />
155
+ </TableHead>
156
+ <TableHead className="py-2 text-white">Name</TableHead>
157
+ <TableHead className="py-2 text-white">Type</TableHead>
158
+ <TableHead className="text-white text-right">Size</TableHead>
159
+ <TableHead className="text-white text-right">
160
+ Modified
161
+ </TableHead>
162
+ </TableRow>
163
+ </TableHeader>
164
+ <TableBody>
165
+ {files?.map((object) => {
166
+ const {key, isDirectory} = object;
167
+ return (
168
+ <TableRow
169
+ key={key}
170
+ className="cursor-pointer text-blue-300 hover:bg-blue-700 hover:text-white"
171
+ onClick={(evt) => {
172
+ if (isDirectory) {
173
+ handleSelectDirectory(key);
174
+ } else {
175
+ handleSelectFile(key);
176
+ evt.preventDefault(); // prevent double change when clicking checkbox
177
+ }
178
+ }}
179
+ >
180
+ <TableCell>
181
+ <Checkbox
182
+ disabled={isDirectory}
183
+ checked={selectedFiles.includes(key)}
184
+ />
185
+ </TableCell>
186
+ <TableCell className="text-xs">
187
+ {isDirectory ? (
188
+ <div className="flex items-center gap-2">
189
+ <FolderIcon className="w-4 h-4" />
190
+ <span>{`${key}/`}</span>
191
+ </div>
192
+ ) : (
193
+ key
194
+ )}
195
+ </TableCell>
196
+ <TableCell className="text-xs">
197
+ {isDirectory ? 'Directory' : object.contentType}
198
+ </TableCell>
199
+ <TableCell className="text-xs text-right">
200
+ {!isDirectory && object.size !== undefined
201
+ ? formatBytes(object.size)
202
+ : ''}
203
+ </TableCell>
204
+ <TableCell className="text-xs text-right">
205
+ {!isDirectory && object.lastModified
206
+ ? formatTimeRelative(object.lastModified)
207
+ : ''}
208
+ </TableCell>
209
+ </TableRow>
210
+ );
211
+ })}
212
+ </TableBody>
213
+ </Table>
214
+ </div>
215
+ </div>
216
+ </div>
217
+ );
218
+ };
219
+
220
+ export default S3FileBrowser;
@@ -0,0 +1,16 @@
1
+ import {z} from 'zod';
2
+
3
+ export const S3FileOrDirectory = z.union([
4
+ z.object({
5
+ key: z.string(),
6
+ isDirectory: z.literal(true),
7
+ }),
8
+ z.object({
9
+ key: z.string(),
10
+ isDirectory: z.literal(false),
11
+ lastModified: z.date().optional(),
12
+ size: z.number().optional(),
13
+ contentType: z.string().optional(),
14
+ }),
15
+ ]);
16
+ export type S3FileOrDirectory = z.infer<typeof S3FileOrDirectory>;
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export {default as S3FileBrowser} from './S3FileBrowser';
2
+ export * from './s3';
3
+ export * from './S3FileOrDirectory';
package/src/s3.ts ADDED
@@ -0,0 +1,103 @@
1
+ import {
2
+ DeleteObjectCommand,
3
+ HeadObjectCommand,
4
+ ListObjectsCommand,
5
+ ListObjectsV2Command,
6
+ S3Client,
7
+ } from '@aws-sdk/client-s3';
8
+ import {S3FileOrDirectory} from './S3FileOrDirectory';
9
+
10
+ export async function listFilesAndDirectoriesWithPrefix(
11
+ S3: S3Client,
12
+ bucket: string,
13
+ prefix?: string,
14
+ ): Promise<S3FileOrDirectory[]> {
15
+ const command = new ListObjectsV2Command({
16
+ Bucket: bucket,
17
+ Prefix: prefix ? `${prefix}${prefix.endsWith('/') ? '' : '/'}` : '',
18
+ Delimiter: '/',
19
+ });
20
+
21
+ const response = await S3.send(command);
22
+
23
+ const objects: S3FileOrDirectory[] = [];
24
+
25
+ const removePrefix = (key: string) => {
26
+ if (!prefix) {
27
+ return key;
28
+ }
29
+ return key.replace(prefix ?? '', '');
30
+ };
31
+
32
+ if (response.CommonPrefixes) {
33
+ for (const commonPrefix of response.CommonPrefixes) {
34
+ if (commonPrefix.Prefix) {
35
+ // Extract the directory name from the CommonPrefixes
36
+ const directoryName = removePrefix(commonPrefix.Prefix).slice(0, -1);
37
+ objects.push({key: directoryName, isDirectory: true});
38
+ }
39
+ }
40
+ }
41
+
42
+ if (response.Contents) {
43
+ for (const content of response.Contents) {
44
+ const key = content.Key;
45
+ if (key) {
46
+ // Exclude subdirectories by checking if the Key ends with '/'
47
+ if (!key.endsWith('/')) {
48
+ const fileName = removePrefix(key);
49
+
50
+ const headCommand = new HeadObjectCommand({
51
+ Bucket: bucket,
52
+ Key: key,
53
+ });
54
+
55
+ const headResponse = await S3.send(headCommand);
56
+
57
+ objects.push({
58
+ key: fileName,
59
+ isDirectory: false,
60
+ lastModified: content.LastModified,
61
+ size: content.Size,
62
+ contentType: headResponse.ContentType,
63
+ });
64
+ }
65
+ }
66
+ }
67
+ }
68
+
69
+ return objects;
70
+ }
71
+
72
+ // async function listBucketContents(
73
+ // prefix: string,
74
+ // ): Promise<ListObjectsCommandOutput> {
75
+ // if (!prefix.length) {
76
+ // throw new Error('Prefix cannot be empty');
77
+ // }
78
+ // const listObjectsCommand = new ListObjectsCommand({
79
+ // Bucket: S3_BUCKET_NAME,
80
+ // Prefix: `${prefix}/`,
81
+ // });
82
+ // const response = await S3.send(listObjectsCommand);
83
+ // return response;
84
+ // }
85
+
86
+ /**
87
+ * Delete all files with the given prefix
88
+ * @param prefix
89
+ */
90
+ export async function deleteS3Files(
91
+ S3: S3Client,
92
+ bucket: string,
93
+ prefix: string,
94
+ ) {
95
+ const data = await S3.send(
96
+ new ListObjectsCommand({Bucket: bucket, Prefix: `${prefix}/`}),
97
+ );
98
+ if (data.Contents?.length) {
99
+ for (const obj of data.Contents) {
100
+ await S3.send(new DeleteObjectCommand({Bucket: bucket, Key: obj.Key}));
101
+ }
102
+ }
103
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "@sqlrooms/typescript-config/react-library.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist"
5
+ },
6
+ "include": ["src", "turbo"],
7
+ "exclude": ["node_modules", "dist"]
8
+ }