storage-explorer 0.1.0 → 1.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/README.md +14 -0
- package/bunfig.toml +2 -0
- package/cli.js +98 -0
- package/dist/chunk-fa0pf3pw.js +11 -0
- package/dist/chunk-fa0pf3pw.js.map +24 -0
- package/dist/chunk-veptbhs8.css +1 -0
- package/dist/index.html +1 -1
- package/package.json +16 -2
- package/src/App.tsx +390 -0
- package/src/features/buckets/BucketPanel.tsx +93 -0
- package/src/features/objects/ObjectExplorer.tsx +129 -0
- package/src/features/objects/PathBreadcrumb.tsx +50 -0
- package/src/features/profiles/ProfileSidebar.tsx +167 -0
- package/src/frontend.tsx +26 -0
- package/{dist/chunk-js4y3bna.css → src/index.css} +134 -111
- package/src/index.html +13 -0
- package/src/index.ts +22 -0
- package/src/logo.svg +1 -0
- package/src/server/http/response.ts +12 -0
- package/src/server/routes/s3Routes.ts +17 -0
- package/src/server/s3/client.ts +14 -0
- package/src/server/s3/handlers.ts +96 -0
- package/src/server/s3/mappers.ts +73 -0
- package/src/server/s3/types.ts +15 -0
- package/src/server/s3/validate.ts +103 -0
- package/src/shared/api/s3Api.ts +56 -0
- package/src/shared/hooks/useProfilesStorage.ts +175 -0
- package/src/shared/types/s3.ts +42 -0
- package/dist/chunk-vtsn1g38.js +0 -1022
- package/dist/index-3xfxtfws.js +0 -238
- package/dist/index-3xfxtfws.js.map +0 -24
- package/dist/index-67w6q0ny.css +0 -1
- package/dist/index-9t8tyk25.js +0 -238
- package/dist/index-9t8tyk25.js.map +0 -24
- package/dist/index-b7b12360.css +0 -1
- package/dist/index-bz8f0q85.js +0 -238
- package/dist/index-bz8f0q85.js.map +0 -18
- package/dist/index-vw9287sb.js +0 -238
- package/dist/index-vw9287sb.js.map +0 -18
- package/dist/index-xde44bqw.css +0 -1
- package/dist/index.js +0 -29485
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{color:#152234;--bg-top:#f9f3ea;--bg-bottom:#e8eff8;--panel:#ffffffd8;--panel-border:#cdd9e8;--text:#152234;--muted:#5d6f84;--accent:#146c94;--accent-soft:#e1f0f8;--danger:#b9414f;--ok:#1f8b4c;--radius:14px;background:#f6f8fb;font-family:Segoe UI,Avenir Next,Helvetica Neue,sans-serif;font-weight:400;line-height:1.4}*{box-sizing:border-box}body{color:var(--text);background:linear-gradient(168deg,var(--bg-top),var(--bg-bottom));min-width:320px;min-height:100vh;margin:0}body:before{content:"";position:fixed;z-index:-1;pointer-events:none;background:radial-gradient(circle at 20% 18%,#fffa 0,#0000 36%),radial-gradient(circle at 85% 12%,#d9e9fa88 0,#0000 28%),radial-gradient(circle at 70% 80%,#f8e8d6aa 0,#0000 35%);inset:0}#root{width:100%;min-height:100vh}.app-shell{display:grid;grid-template-columns:360px minmax(0,1fr);gap:1rem;width:min(1320px,100% - 2rem);margin:1rem auto}.main-column{display:grid;align-content: start;gap:1rem}.panel{background:var(--panel);backdrop-filter:blur(10px);border:1px solid var(--panel-border);border-radius:var(--radius);box-shadow:0 10px 24px #12233712}.panel-step{display:inline-block;color:var(--accent);background:var(--accent-soft);border-radius:999px;margin-bottom:.6rem;padding:.2rem .55rem;font-size:.73rem;font-weight:600}.profile-panel{position:sticky;overflow:auto;max-height:calc(100vh - 2rem);padding:1rem;top:1rem}.explorer-panel,.object-panel{padding:1rem}.panel-header h1,.explorer-header h2,.objects-header h3{letter-spacing:.01em;margin:0}.panel-header p,.explorer-header p,.objects-header p{color:var(--muted);margin:.35rem 0 0;font-size:.92rem}.profiles-toolbar{display:flex;justify-content:space-between;align-items: center;margin-top:1rem}.profiles-list{display:grid;gap:.5rem;margin-top:.65rem}.profile-card{border:1px solid var(--panel-border);overflow:hidden;background:#fff;border-radius:10px}.profile-card.active{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}.profile-select{text-align:left;display:grid;cursor:pointer;background:0 0;border:0;gap:.25rem;width:100%;padding:.65rem .75rem}.profile-name{font-weight:600}.profile-endpoint{color:var(--muted);overflow-wrap:anywhere;font-size:.84rem}.delete-button{border:0;border-top:1px solid var(--panel-border);color:var(--danger);cursor:pointer;background:#fff;width:100%;padding:.45rem .75rem}.profile-form{display:grid;gap:.7rem;margin-top:1rem}.profile-form label{display:grid;gap:.35rem;font-size:.9rem}input,button{font:inherit}.profile-form input,.manual-bucket-row input{border:1px solid var(--panel-border);color:var(--text);background:#fff;border-radius:9px;flex:1;padding:.55rem .65rem}.profile-form input:focus,.manual-bucket-row input:focus{outline:2px solid #0000;border-color:var(--accent);box-shadow:0 0 0 2px #146c9430}.secret-row{display:flex;gap:.5rem}.secret-row input{flex:1}.checkbox-row{grid-template-columns:auto 1fr;align-items: center}.checkbox-row input{width:1rem;height:1rem}.form-actions{display:flex;flex-wrap:wrap;gap:.55rem}.primary-button,.secondary-button,.ghost-button{cursor:pointer;border-radius:9px;padding:.55rem .85rem;transition:all .15s}.primary-button{border:1px solid var(--accent);background:var(--accent);color:#fff}.primary-button:hover{filter:brightness(1.05)}.secondary-button{border:1px solid var(--panel-border);color:var(--text);background:#fff}.ghost-button{border:1px solid var(--panel-border);color:var(--muted);background:#fff}.secondary-button:hover,.ghost-button:hover,.delete-button:hover,.bucket-item:hover,.object-row.folder:hover,.breadcrumb-link:hover{background:var(--accent-soft)}button:disabled{opacity:.58;cursor:not-allowed}.status-ok,.status-error,.empty-copy{margin:.75rem 0 0;font-size:.88rem}.status-ok{color:var(--ok)}.status-error{color:var(--danger)}.empty-copy,.muted,.helper-copy{color:var(--muted)}.explorer-header,.objects-header{display:flex;justify-content:space-between;align-items: flex-start;gap:1rem}.manual-bucket-form{display:grid;border:1px solid var(--panel-border);background:#fff;border-radius:10px;gap:.45rem;margin-top:.9rem;padding:.7rem}.manual-bucket-form label{color:var(--muted);font-size:.88rem}.manual-bucket-row{display:flex;align-items: center;gap:.5rem}.helper-copy{margin:0;font-size:.82rem}.buckets-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:.5rem;margin-top:.8rem}.bucket-item{border:1px solid var(--panel-border);text-align:left;display:grid;cursor:pointer;background:#fff;border-radius:10px;gap:.2rem;padding:.7rem}.bucket-item.active{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}.bucket-item small{color:var(--muted)}.objects-actions{display:flex;flex-wrap:wrap;gap:.5rem}.path-breadcrumb{border:1px solid var(--panel-border);display:flex;background:#fff;border-radius:10px;flex-wrap:wrap;align-items: center;gap:.1rem;margin-top:.8rem;padding:.55rem .7rem;font-size:.88rem}.path-breadcrumb.muted{color:var(--muted)}.breadcrumb-link{cursor:pointer;color:var(--text);background:0 0;border:0;border-radius:6px;padding:.2rem .4rem}.breadcrumb-segment{display:inline-flex;align-items: center}.breadcrumb-separator{color:var(--muted);margin:0 .05rem}.objects-list{border:1px solid var(--panel-border);overflow:auto;background:#fff;border-radius:10px;min-height:320px;margin-top:.8rem;padding:.5rem}.object-row{color:var(--text);display:grid;grid-template-columns:minmax(0,1fr)140px 210px;text-align:left;background:#fff;border:0;border-bottom:1px solid #e7edf6;align-items: center;gap:.8rem;width:100%;padding:.65rem}.object-row:last-child{border-bottom:0}.object-row.folder{cursor:pointer}.object-name{overflow-wrap:anywhere}.object-meta{color:var(--muted);text-align:right;white-space:nowrap;font-size:.82rem}@media (max-width:1020px){.app-shell{grid-template-columns:1fr}.profile-panel{position:static;max-height:none}.explorer-header,.objects-header{flex-direction:column}.manual-bucket-row{flex-direction:column;align-items:stretch}.object-row{grid-template-columns:1fr}.object-meta{text-align:left;white-space:normal}}
|
package/dist/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="./logo-kygw735p.svg" />
|
|
7
7
|
<title>S3 Explorer</title>
|
|
8
|
-
<link rel="stylesheet" crossorigin href="./chunk-
|
|
8
|
+
<link rel="stylesheet" crossorigin href="./chunk-veptbhs8.css"><script type="module" crossorigin src="./chunk-fa0pf3pw.js"></script></head>
|
|
9
9
|
<body>
|
|
10
10
|
<div id="root"></div>
|
|
11
11
|
|
package/package.json
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "storage-explorer",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"bin":
|
|
5
|
+
"bin": {
|
|
6
|
+
"storage-explorer": "./cli.js"
|
|
7
|
+
},
|
|
6
8
|
"type": "module",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": {
|
|
11
|
+
"name": "Ion Korol",
|
|
12
|
+
"email": "ion@korol.dev"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/KorSoftwareSolutions/storage-explorer.git"
|
|
17
|
+
},
|
|
7
18
|
"scripts": {
|
|
8
19
|
"dev": "bun --hot src/index.ts",
|
|
9
20
|
"build": "bun run build.ts",
|
|
@@ -20,7 +31,10 @@
|
|
|
20
31
|
"@types/bun": "latest"
|
|
21
32
|
},
|
|
22
33
|
"files": [
|
|
34
|
+
"src",
|
|
23
35
|
"dist",
|
|
36
|
+
"bin",
|
|
37
|
+
"bunfig.toml",
|
|
24
38
|
"package.json"
|
|
25
39
|
]
|
|
26
40
|
}
|
package/src/App.tsx
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import "./index.css";
|
|
2
|
+
import { useEffect, useState, type FormEvent } from "react";
|
|
3
|
+
import { BucketPanel } from "./features/buckets/BucketPanel";
|
|
4
|
+
import { ObjectExplorer } from "./features/objects/ObjectExplorer";
|
|
5
|
+
import { ProfileSidebar } from "./features/profiles/ProfileSidebar";
|
|
6
|
+
import { listBuckets, listObjects, testConnection } from "./shared/api/s3Api";
|
|
7
|
+
import { useProfilesStorage } from "./shared/hooks/useProfilesStorage";
|
|
8
|
+
import type {
|
|
9
|
+
BucketItem,
|
|
10
|
+
EditableProfile,
|
|
11
|
+
ObjectsPayload,
|
|
12
|
+
S3ConnectionProfile,
|
|
13
|
+
SavedProfile,
|
|
14
|
+
} from "./shared/types/s3";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_MAX_KEYS = 200;
|
|
17
|
+
|
|
18
|
+
const emptyProfile: EditableProfile = {
|
|
19
|
+
name: "",
|
|
20
|
+
endpoint: "",
|
|
21
|
+
region: "us-east-1",
|
|
22
|
+
accessKeyId: "",
|
|
23
|
+
secretAccessKey: "",
|
|
24
|
+
forcePathStyle: true,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function toEditable(profile: SavedProfile): EditableProfile {
|
|
28
|
+
return {
|
|
29
|
+
name: profile.name,
|
|
30
|
+
endpoint: profile.endpoint,
|
|
31
|
+
region: profile.region,
|
|
32
|
+
accessKeyId: profile.accessKeyId,
|
|
33
|
+
secretAccessKey: profile.secretAccessKey,
|
|
34
|
+
forcePathStyle: profile.forcePathStyle,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function deriveName(endpoint: string): string {
|
|
39
|
+
try {
|
|
40
|
+
const normalized = endpoint.startsWith("http") ? endpoint : `https://${endpoint}`;
|
|
41
|
+
const host = new URL(normalized).host;
|
|
42
|
+
return host || endpoint;
|
|
43
|
+
} catch {
|
|
44
|
+
return endpoint;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function App() {
|
|
49
|
+
const {
|
|
50
|
+
profiles,
|
|
51
|
+
selectedProfileId,
|
|
52
|
+
selectedProfile,
|
|
53
|
+
setSelectedProfileId,
|
|
54
|
+
saveProfile,
|
|
55
|
+
deleteProfile,
|
|
56
|
+
updateProfileView,
|
|
57
|
+
getProfileView,
|
|
58
|
+
} = useProfilesStorage();
|
|
59
|
+
|
|
60
|
+
const [form, setForm] = useState<EditableProfile>(emptyProfile);
|
|
61
|
+
const [showSecret, setShowSecret] = useState(false);
|
|
62
|
+
|
|
63
|
+
const [statusMessage, setStatusMessage] = useState("");
|
|
64
|
+
const [statusError, setStatusError] = useState("");
|
|
65
|
+
|
|
66
|
+
const [testingConnection, setTestingConnection] = useState(false);
|
|
67
|
+
const [loadingBuckets, setLoadingBuckets] = useState(false);
|
|
68
|
+
const [loadingObjects, setLoadingObjects] = useState(false);
|
|
69
|
+
|
|
70
|
+
const [buckets, setBuckets] = useState<BucketItem[]>([]);
|
|
71
|
+
const [manualBucketName, setManualBucketName] = useState("");
|
|
72
|
+
const [selectedBucket, setSelectedBucket] = useState("");
|
|
73
|
+
const [prefix, setPrefix] = useState("");
|
|
74
|
+
const [objects, setObjects] = useState<ObjectsPayload | null>(null);
|
|
75
|
+
const [nextToken, setNextToken] = useState<string | null>(null);
|
|
76
|
+
const [isNextPage, setIsNextPage] = useState(false);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!selectedProfile) {
|
|
80
|
+
setForm(emptyProfile);
|
|
81
|
+
setManualBucketName("");
|
|
82
|
+
setSelectedBucket("");
|
|
83
|
+
setPrefix("");
|
|
84
|
+
setObjects(null);
|
|
85
|
+
setNextToken(null);
|
|
86
|
+
setIsNextPage(false);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const profileView = getProfileView(selectedProfile.id);
|
|
91
|
+
setForm(toEditable(selectedProfile));
|
|
92
|
+
setManualBucketName(profileView.manualBucketName || profileView.bucket);
|
|
93
|
+
setSelectedBucket(profileView.bucket);
|
|
94
|
+
setPrefix(profileView.prefix);
|
|
95
|
+
setObjects(null);
|
|
96
|
+
setNextToken(null);
|
|
97
|
+
setIsNextPage(false);
|
|
98
|
+
}, [selectedProfileId, selectedProfile]);
|
|
99
|
+
|
|
100
|
+
const profileValid =
|
|
101
|
+
Boolean(form.endpoint.trim()) &&
|
|
102
|
+
Boolean(form.accessKeyId.trim()) &&
|
|
103
|
+
Boolean(form.secretAccessKey.trim());
|
|
104
|
+
|
|
105
|
+
const profilePayload = (value: EditableProfile = form): S3ConnectionProfile => ({
|
|
106
|
+
endpoint: value.endpoint.trim(),
|
|
107
|
+
region: value.region.trim() || "us-east-1",
|
|
108
|
+
accessKeyId: value.accessKeyId.trim(),
|
|
109
|
+
secretAccessKey: value.secretAccessKey.trim(),
|
|
110
|
+
forcePathStyle: value.forcePathStyle,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const onFormChange = <K extends keyof EditableProfile>(field: K, value: EditableProfile[K]) => {
|
|
114
|
+
setForm(prev => ({ ...prev, [field]: value }));
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const onCreateNewProfile = () => {
|
|
118
|
+
setSelectedProfileId(null);
|
|
119
|
+
setForm(emptyProfile);
|
|
120
|
+
setShowSecret(false);
|
|
121
|
+
setStatusMessage("");
|
|
122
|
+
setStatusError("");
|
|
123
|
+
setBuckets([]);
|
|
124
|
+
setManualBucketName("");
|
|
125
|
+
setSelectedBucket("");
|
|
126
|
+
setPrefix("");
|
|
127
|
+
setObjects(null);
|
|
128
|
+
setNextToken(null);
|
|
129
|
+
setIsNextPage(false);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const onSelectProfile = (profile: SavedProfile) => {
|
|
133
|
+
setSelectedProfileId(profile.id);
|
|
134
|
+
setStatusMessage("");
|
|
135
|
+
setStatusError("");
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const onSaveProfile = (event: FormEvent<HTMLFormElement>) => {
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
|
|
141
|
+
if (!profileValid) {
|
|
142
|
+
setStatusError("Endpoint, access key ID, and secret key are required.");
|
|
143
|
+
setStatusMessage("");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const normalized: SavedProfile = {
|
|
148
|
+
id: selectedProfile?.id ?? crypto.randomUUID(),
|
|
149
|
+
name: form.name.trim() || deriveName(form.endpoint.trim()),
|
|
150
|
+
...profilePayload(form),
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
saveProfile(normalized);
|
|
154
|
+
setSelectedProfileId(normalized.id);
|
|
155
|
+
setStatusError("");
|
|
156
|
+
setStatusMessage(selectedProfile ? "Profile updated." : "Profile saved.");
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const onDeleteProfile = (profileId: string) => {
|
|
160
|
+
deleteProfile(profileId);
|
|
161
|
+
|
|
162
|
+
if (selectedProfileId === profileId) {
|
|
163
|
+
setForm(emptyProfile);
|
|
164
|
+
setManualBucketName("");
|
|
165
|
+
setSelectedBucket("");
|
|
166
|
+
setPrefix("");
|
|
167
|
+
setObjects(null);
|
|
168
|
+
setNextToken(null);
|
|
169
|
+
setIsNextPage(false);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
setStatusMessage("Profile deleted.");
|
|
173
|
+
setStatusError("");
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const onTestConnection = async () => {
|
|
177
|
+
if (!profileValid) {
|
|
178
|
+
setStatusError("Fill in endpoint, access key ID, and secret key first.");
|
|
179
|
+
setStatusMessage("");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
setTestingConnection(true);
|
|
184
|
+
setStatusError("");
|
|
185
|
+
setStatusMessage("");
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const result = await testConnection(profilePayload());
|
|
189
|
+
setStatusMessage(result.message || "Connection successful.");
|
|
190
|
+
} catch (err) {
|
|
191
|
+
setStatusError(err instanceof Error ? err.message : "Connection failed.");
|
|
192
|
+
} finally {
|
|
193
|
+
setTestingConnection(false);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const onLoadBuckets = async () => {
|
|
198
|
+
if (!profileValid) {
|
|
199
|
+
setStatusError("Fill in endpoint, access key ID, and secret key first.");
|
|
200
|
+
setStatusMessage("");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
setLoadingBuckets(true);
|
|
205
|
+
setStatusError("");
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const result = await listBuckets(profilePayload());
|
|
209
|
+
setBuckets(result.buckets.filter(bucket => Boolean(bucket.name)));
|
|
210
|
+
setStatusMessage(`Loaded ${result.buckets.length} bucket(s).`);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
setStatusError(err instanceof Error ? err.message : "Failed to load buckets.");
|
|
213
|
+
setStatusMessage("");
|
|
214
|
+
} finally {
|
|
215
|
+
setLoadingBuckets(false);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const loadObjects = async (options: {
|
|
220
|
+
bucket: string;
|
|
221
|
+
targetPrefix?: string;
|
|
222
|
+
continuationToken?: string | null;
|
|
223
|
+
profileOverride?: EditableProfile;
|
|
224
|
+
}) => {
|
|
225
|
+
const { bucket, targetPrefix = "", continuationToken = null, profileOverride } = options;
|
|
226
|
+
|
|
227
|
+
setLoadingObjects(true);
|
|
228
|
+
setStatusError("");
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const data = await listObjects({
|
|
232
|
+
profile: profilePayload(profileOverride ?? form),
|
|
233
|
+
bucket,
|
|
234
|
+
prefix: targetPrefix,
|
|
235
|
+
continuationToken,
|
|
236
|
+
maxKeys: DEFAULT_MAX_KEYS,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
setSelectedBucket(bucket);
|
|
240
|
+
setPrefix(targetPrefix);
|
|
241
|
+
setObjects(data);
|
|
242
|
+
setNextToken(data.nextContinuationToken);
|
|
243
|
+
setIsNextPage(Boolean(continuationToken));
|
|
244
|
+
setStatusMessage(`Loaded ${data.folders.length + data.files.length} item(s) from ${bucket}.`);
|
|
245
|
+
|
|
246
|
+
if (selectedProfileId) {
|
|
247
|
+
updateProfileView(selectedProfileId, {
|
|
248
|
+
bucket,
|
|
249
|
+
prefix: targetPrefix,
|
|
250
|
+
manualBucketName: manualBucketName.trim() || bucket,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
} catch (err) {
|
|
254
|
+
setStatusError(err instanceof Error ? err.message : "Failed to load objects.");
|
|
255
|
+
setStatusMessage("");
|
|
256
|
+
} finally {
|
|
257
|
+
setLoadingObjects(false);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const onOpenBucket = async (bucket: string) => {
|
|
262
|
+
setManualBucketName(bucket);
|
|
263
|
+
await loadObjects({ bucket, targetPrefix: "", continuationToken: null });
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const onOpenManualBucket = async (event: FormEvent<HTMLFormElement>) => {
|
|
267
|
+
event.preventDefault();
|
|
268
|
+
|
|
269
|
+
if (!profileValid) {
|
|
270
|
+
setStatusError("Fill in endpoint, access key ID, and secret key first.");
|
|
271
|
+
setStatusMessage("");
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const bucket = manualBucketName.trim();
|
|
276
|
+
if (!bucket) {
|
|
277
|
+
setStatusError("Enter a bucket name to open.");
|
|
278
|
+
setStatusMessage("");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
await onOpenBucket(bucket);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const onOpenFolder = async (folderPrefix: string) => {
|
|
286
|
+
if (!selectedBucket) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
await loadObjects({ bucket: selectedBucket, targetPrefix: folderPrefix, continuationToken: null });
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const onUpOneLevel = async () => {
|
|
294
|
+
if (!selectedBucket) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!prefix) {
|
|
299
|
+
await loadObjects({ bucket: selectedBucket, targetPrefix: "", continuationToken: null });
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const trimmed = prefix.replace(/\/$/, "");
|
|
304
|
+
const segments = trimmed.split("/").filter(Boolean);
|
|
305
|
+
const parentPrefix = segments.length > 1 ? `${segments.slice(0, -1).join("/")}/` : "";
|
|
306
|
+
|
|
307
|
+
await loadObjects({ bucket: selectedBucket, targetPrefix: parentPrefix, continuationToken: null });
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const onNavigatePrefix = async (targetPrefix: string) => {
|
|
311
|
+
if (!selectedBucket) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
await loadObjects({ bucket: selectedBucket, targetPrefix, continuationToken: null });
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const onLoadNextPage = async () => {
|
|
319
|
+
if (!selectedBucket || !nextToken) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
await loadObjects({
|
|
324
|
+
bucket: selectedBucket,
|
|
325
|
+
targetPrefix: prefix,
|
|
326
|
+
continuationToken: nextToken,
|
|
327
|
+
});
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const onLoadFirstPage = async () => {
|
|
331
|
+
if (!selectedBucket) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
await loadObjects({ bucket: selectedBucket, targetPrefix: prefix, continuationToken: null });
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
return (
|
|
339
|
+
<div className="app-shell">
|
|
340
|
+
<ProfileSidebar
|
|
341
|
+
profiles={profiles}
|
|
342
|
+
selectedProfileId={selectedProfileId}
|
|
343
|
+
isEditingExisting={Boolean(selectedProfile)}
|
|
344
|
+
form={form}
|
|
345
|
+
showSecret={showSecret}
|
|
346
|
+
statusMessage={statusMessage}
|
|
347
|
+
statusError={statusError}
|
|
348
|
+
testingConnection={testingConnection}
|
|
349
|
+
onCreateNewProfile={onCreateNewProfile}
|
|
350
|
+
onSelectProfile={onSelectProfile}
|
|
351
|
+
onDeleteProfile={onDeleteProfile}
|
|
352
|
+
onSaveProfile={onSaveProfile}
|
|
353
|
+
onTestConnection={onTestConnection}
|
|
354
|
+
onToggleSecret={() => setShowSecret(prev => !prev)}
|
|
355
|
+
onFormChange={onFormChange}
|
|
356
|
+
/>
|
|
357
|
+
|
|
358
|
+
<main className="main-column">
|
|
359
|
+
<BucketPanel
|
|
360
|
+
profileValid={profileValid}
|
|
361
|
+
loadingBuckets={loadingBuckets}
|
|
362
|
+
loadingObjects={loadingObjects}
|
|
363
|
+
buckets={buckets}
|
|
364
|
+
selectedBucket={selectedBucket}
|
|
365
|
+
manualBucketName={manualBucketName}
|
|
366
|
+
onManualBucketNameChange={setManualBucketName}
|
|
367
|
+
onLoadBuckets={onLoadBuckets}
|
|
368
|
+
onOpenBucket={onOpenBucket}
|
|
369
|
+
onOpenManualBucket={onOpenManualBucket}
|
|
370
|
+
/>
|
|
371
|
+
|
|
372
|
+
<ObjectExplorer
|
|
373
|
+
selectedBucket={selectedBucket}
|
|
374
|
+
prefix={prefix}
|
|
375
|
+
objects={objects}
|
|
376
|
+
loadingObjects={loadingObjects}
|
|
377
|
+
isNextPage={isNextPage}
|
|
378
|
+
nextToken={nextToken}
|
|
379
|
+
onUpOneLevel={onUpOneLevel}
|
|
380
|
+
onLoadFirstPage={onLoadFirstPage}
|
|
381
|
+
onLoadNextPage={onLoadNextPage}
|
|
382
|
+
onOpenFolder={onOpenFolder}
|
|
383
|
+
onNavigatePrefix={onNavigatePrefix}
|
|
384
|
+
/>
|
|
385
|
+
</main>
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export default App;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { FormEvent } from "react";
|
|
2
|
+
import type { BucketItem } from "../../shared/types/s3";
|
|
3
|
+
|
|
4
|
+
type BucketPanelProps = {
|
|
5
|
+
profileValid: boolean;
|
|
6
|
+
loadingBuckets: boolean;
|
|
7
|
+
loadingObjects: boolean;
|
|
8
|
+
buckets: BucketItem[];
|
|
9
|
+
selectedBucket: string;
|
|
10
|
+
manualBucketName: string;
|
|
11
|
+
onManualBucketNameChange: (value: string) => void;
|
|
12
|
+
onLoadBuckets: () => void;
|
|
13
|
+
onOpenBucket: (bucket: string) => void;
|
|
14
|
+
onOpenManualBucket: (event: FormEvent<HTMLFormElement>) => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function BucketPanel(props: BucketPanelProps) {
|
|
18
|
+
const {
|
|
19
|
+
profileValid,
|
|
20
|
+
loadingBuckets,
|
|
21
|
+
loadingObjects,
|
|
22
|
+
buckets,
|
|
23
|
+
selectedBucket,
|
|
24
|
+
manualBucketName,
|
|
25
|
+
onManualBucketNameChange,
|
|
26
|
+
onLoadBuckets,
|
|
27
|
+
onOpenBucket,
|
|
28
|
+
onOpenManualBucket,
|
|
29
|
+
} = props;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<section className="panel explorer-panel">
|
|
33
|
+
<div className="panel-step">Step 2</div>
|
|
34
|
+
<div className="explorer-header">
|
|
35
|
+
<div>
|
|
36
|
+
<h2>Buckets</h2>
|
|
37
|
+
<p>Load visible buckets or open one directly by name.</p>
|
|
38
|
+
</div>
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
className="primary-button"
|
|
42
|
+
onClick={onLoadBuckets}
|
|
43
|
+
disabled={loadingBuckets || !profileValid}
|
|
44
|
+
>
|
|
45
|
+
{loadingBuckets ? "Loading..." : "Load Buckets"}
|
|
46
|
+
</button>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<form className="manual-bucket-form" onSubmit={onOpenManualBucket}>
|
|
50
|
+
<label htmlFor="manual-bucket-input">Known bucket name</label>
|
|
51
|
+
<div className="manual-bucket-row">
|
|
52
|
+
<input
|
|
53
|
+
id="manual-bucket-input"
|
|
54
|
+
type="text"
|
|
55
|
+
value={manualBucketName}
|
|
56
|
+
onChange={event => onManualBucketNameChange(event.target.value)}
|
|
57
|
+
placeholder="my-private-bucket"
|
|
58
|
+
autoComplete="off"
|
|
59
|
+
/>
|
|
60
|
+
<button
|
|
61
|
+
type="submit"
|
|
62
|
+
className="secondary-button"
|
|
63
|
+
disabled={loadingObjects || !profileValid}
|
|
64
|
+
>
|
|
65
|
+
Open Bucket
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
<p className="helper-copy">
|
|
69
|
+
Useful when your key can access a bucket but cannot list all buckets.
|
|
70
|
+
</p>
|
|
71
|
+
</form>
|
|
72
|
+
|
|
73
|
+
<div className="buckets-grid">
|
|
74
|
+
{buckets.length === 0 ? (
|
|
75
|
+
<p className="empty-copy">No buckets loaded yet.</p>
|
|
76
|
+
) : (
|
|
77
|
+
buckets.map(bucket => (
|
|
78
|
+
<button
|
|
79
|
+
key={bucket.name}
|
|
80
|
+
type="button"
|
|
81
|
+
className={`bucket-item ${bucket.name === selectedBucket ? "active" : ""}`}
|
|
82
|
+
onClick={() => onOpenBucket(bucket.name)}
|
|
83
|
+
disabled={loadingObjects}
|
|
84
|
+
>
|
|
85
|
+
<span>{bucket.name}</span>
|
|
86
|
+
{bucket.creationDate && <small>{new Date(bucket.creationDate).toLocaleString()}</small>}
|
|
87
|
+
</button>
|
|
88
|
+
))
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
</section>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { ObjectsPayload } from "../../shared/types/s3";
|
|
2
|
+
import { PathBreadcrumb } from "./PathBreadcrumb";
|
|
3
|
+
|
|
4
|
+
type ObjectExplorerProps = {
|
|
5
|
+
selectedBucket: string;
|
|
6
|
+
prefix: string;
|
|
7
|
+
objects: ObjectsPayload | null;
|
|
8
|
+
loadingObjects: boolean;
|
|
9
|
+
isNextPage: boolean;
|
|
10
|
+
nextToken: string | null;
|
|
11
|
+
onUpOneLevel: () => void;
|
|
12
|
+
onLoadFirstPage: () => void;
|
|
13
|
+
onLoadNextPage: () => void;
|
|
14
|
+
onOpenFolder: (folderPrefix: string) => void;
|
|
15
|
+
onNavigatePrefix: (prefix: string) => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function formatObjectName(key: string, prefix: string): string {
|
|
19
|
+
if (!prefix) {
|
|
20
|
+
return key;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return key.startsWith(prefix) ? key.slice(prefix.length) : key;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatFolderName(prefixValue: string, currentPrefix: string): string {
|
|
27
|
+
const clean = prefixValue.replace(/\/$/, "");
|
|
28
|
+
const withoutCurrent =
|
|
29
|
+
currentPrefix && clean.startsWith(currentPrefix) ? clean.slice(currentPrefix.length) : clean;
|
|
30
|
+
const parts = withoutCurrent.split("/").filter(Boolean);
|
|
31
|
+
return parts.at(-1) ?? clean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function ObjectExplorer(props: ObjectExplorerProps) {
|
|
35
|
+
const {
|
|
36
|
+
selectedBucket,
|
|
37
|
+
prefix,
|
|
38
|
+
objects,
|
|
39
|
+
loadingObjects,
|
|
40
|
+
isNextPage,
|
|
41
|
+
nextToken,
|
|
42
|
+
onUpOneLevel,
|
|
43
|
+
onLoadFirstPage,
|
|
44
|
+
onLoadNextPage,
|
|
45
|
+
onOpenFolder,
|
|
46
|
+
onNavigatePrefix,
|
|
47
|
+
} = props;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<section className="panel object-panel">
|
|
51
|
+
<div className="panel-step">Step 3</div>
|
|
52
|
+
<div className="objects-header">
|
|
53
|
+
<div>
|
|
54
|
+
<h3>Objects</h3>
|
|
55
|
+
<p>Read-only view of folders and files.</p>
|
|
56
|
+
</div>
|
|
57
|
+
<div className="objects-actions">
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
className="secondary-button"
|
|
61
|
+
onClick={onUpOneLevel}
|
|
62
|
+
disabled={!selectedBucket || loadingObjects}
|
|
63
|
+
>
|
|
64
|
+
Up One Level
|
|
65
|
+
</button>
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
className="secondary-button"
|
|
69
|
+
onClick={onLoadFirstPage}
|
|
70
|
+
disabled={!selectedBucket || loadingObjects || !isNextPage}
|
|
71
|
+
>
|
|
72
|
+
First Page
|
|
73
|
+
</button>
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
className="secondary-button"
|
|
77
|
+
onClick={onLoadNextPage}
|
|
78
|
+
disabled={!nextToken || loadingObjects}
|
|
79
|
+
>
|
|
80
|
+
{loadingObjects ? "Loading..." : "Next Page"}
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<PathBreadcrumb
|
|
86
|
+
bucket={selectedBucket}
|
|
87
|
+
prefix={prefix}
|
|
88
|
+
loading={loadingObjects}
|
|
89
|
+
onNavigatePrefix={onNavigatePrefix}
|
|
90
|
+
/>
|
|
91
|
+
|
|
92
|
+
<div className="objects-list">
|
|
93
|
+
{!objects && <p className="empty-copy">Open a bucket to browse objects.</p>}
|
|
94
|
+
|
|
95
|
+
{objects && (
|
|
96
|
+
<>
|
|
97
|
+
{objects.folders.length === 0 && objects.files.length === 0 && (
|
|
98
|
+
<p className="empty-copy">This path is empty.</p>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
{objects.folders.map(folder => (
|
|
102
|
+
<button
|
|
103
|
+
key={folder}
|
|
104
|
+
type="button"
|
|
105
|
+
className="object-row folder"
|
|
106
|
+
onClick={() => onOpenFolder(folder)}
|
|
107
|
+
disabled={loadingObjects}
|
|
108
|
+
>
|
|
109
|
+
<span className="object-name">{formatFolderName(folder, prefix)}/</span>
|
|
110
|
+
<span className="object-meta">folder</span>
|
|
111
|
+
<span className="object-meta">-</span>
|
|
112
|
+
</button>
|
|
113
|
+
))}
|
|
114
|
+
|
|
115
|
+
{objects.files.map(file => (
|
|
116
|
+
<div key={file.key} className="object-row">
|
|
117
|
+
<span className="object-name">{formatObjectName(file.key, prefix)}</span>
|
|
118
|
+
<span className="object-meta">{file.size.toLocaleString()} bytes</span>
|
|
119
|
+
<span className="object-meta">
|
|
120
|
+
{file.lastModified ? new Date(file.lastModified).toLocaleString() : "-"}
|
|
121
|
+
</span>
|
|
122
|
+
</div>
|
|
123
|
+
))}
|
|
124
|
+
</>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
</section>
|
|
128
|
+
);
|
|
129
|
+
}
|