@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.
- package/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-dev.log +50 -0
- package/.turbo/turbo-lint.log +4 -0
- package/CHANGELOG.md +8 -0
- package/LICENSE.md +9 -0
- package/dist/S3FileBrowser.d.ts +13 -0
- package/dist/S3FileBrowser.d.ts.map +1 -0
- package/dist/S3FileBrowser.js +67 -0
- package/dist/S3FileOrDirectory.d.ts +31 -0
- package/dist/S3FileOrDirectory.d.ts.map +1 -0
- package/dist/S3FileOrDirectory.js +14 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/s3.d.ts +9 -0
- package/dist/s3.d.ts.map +1 -0
- package/dist/s3.js +74 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/eslint.config.js +4 -0
- package/package.json +28 -0
- package/src/S3FileBrowser.tsx +220 -0
- package/src/S3FileOrDirectory.ts +16 -0
- package/src/index.ts +3 -0
- package/src/s3.ts +103 -0
- package/tsconfig.json +8 -0
package/eslint.config.js
ADDED
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
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
|
+
}
|