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.
Files changed (41) hide show
  1. package/README.md +14 -0
  2. package/bunfig.toml +2 -0
  3. package/cli.js +98 -0
  4. package/dist/chunk-fa0pf3pw.js +11 -0
  5. package/dist/chunk-fa0pf3pw.js.map +24 -0
  6. package/dist/chunk-veptbhs8.css +1 -0
  7. package/dist/index.html +1 -1
  8. package/package.json +16 -2
  9. package/src/App.tsx +390 -0
  10. package/src/features/buckets/BucketPanel.tsx +93 -0
  11. package/src/features/objects/ObjectExplorer.tsx +129 -0
  12. package/src/features/objects/PathBreadcrumb.tsx +50 -0
  13. package/src/features/profiles/ProfileSidebar.tsx +167 -0
  14. package/src/frontend.tsx +26 -0
  15. package/{dist/chunk-js4y3bna.css → src/index.css} +134 -111
  16. package/src/index.html +13 -0
  17. package/src/index.ts +22 -0
  18. package/src/logo.svg +1 -0
  19. package/src/server/http/response.ts +12 -0
  20. package/src/server/routes/s3Routes.ts +17 -0
  21. package/src/server/s3/client.ts +14 -0
  22. package/src/server/s3/handlers.ts +96 -0
  23. package/src/server/s3/mappers.ts +73 -0
  24. package/src/server/s3/types.ts +15 -0
  25. package/src/server/s3/validate.ts +103 -0
  26. package/src/shared/api/s3Api.ts +56 -0
  27. package/src/shared/hooks/useProfilesStorage.ts +175 -0
  28. package/src/shared/types/s3.ts +42 -0
  29. package/dist/chunk-vtsn1g38.js +0 -1022
  30. package/dist/index-3xfxtfws.js +0 -238
  31. package/dist/index-3xfxtfws.js.map +0 -24
  32. package/dist/index-67w6q0ny.css +0 -1
  33. package/dist/index-9t8tyk25.js +0 -238
  34. package/dist/index-9t8tyk25.js.map +0 -24
  35. package/dist/index-b7b12360.css +0 -1
  36. package/dist/index-bz8f0q85.js +0 -238
  37. package/dist/index-bz8f0q85.js.map +0 -18
  38. package/dist/index-vw9287sb.js +0 -238
  39. package/dist/index-vw9287sb.js.map +0 -18
  40. package/dist/index-xde44bqw.css +0 -1
  41. 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-js4y3bna.css"><script type="module" crossorigin src="./chunk-vtsn1g38.js"></script></head>
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": "0.1.0",
3
+ "version": "1.0.0",
4
4
  "private": false,
5
- "bin": "dist/index.js",
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
+ }