mirror of
https://github.com/versity/versitygw.git
synced 2026-04-22 21:50:29 +00:00
The object listing now fetches pages from S3 directly using ListObjectsV2 continuation tokens rather than loading all objects at once. Users can navigate forward and back with first/prev/next buttons and choose how many rows to show per page (10/20/50/100/1000, defaulting to 10), which keeps the listing fast and responsive even in buckets with thousands of objects. Pagination resets automatically when navigating into a folder, running a search, or changing page size. While a search is active, forward navigation is disabled since search filters within the current page only, and the item count shows "(filtered)" to make that clear. When versioning is enabled, delete-marker rows are scoped to the current page's key range so they don't bleed in from adjacent pages. Fixes #2055
4597 lines
209 KiB
HTML
4597 lines
209 KiB
HTML
<!--
|
||
Copyright 2026 Versity Software
|
||
This file is licensed under the Apache License, Version 2.0
|
||
(the "License"); you may not use this file except in compliance
|
||
with the License. You may obtain a copy of the License at
|
||
|
||
http://www.apache.org/licenses/LICENSE-2.0
|
||
|
||
Unless required by applicable law or agreed to in writing,
|
||
software distributed under the License is distributed on an
|
||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||
KIND, either express or implied. See the License for the
|
||
specific language governing permissions and limitations
|
||
under the License.
|
||
-->
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>VersityGW - Explorer</title>
|
||
<script src="assets/js/crypto-js.min.js"></script>
|
||
<script src="assets/js/tailwind.js"></script>
|
||
<script src="assets/css/tailwind-config.js"></script>
|
||
<link rel="stylesheet" href="assets/css/fonts.css">
|
||
<link rel="stylesheet" href="assets/css/theme.css">
|
||
<link rel="icon" type="image/png" href="assets/images/favicon.png">
|
||
</head>
|
||
<body class="min-h-screen bg-surface">
|
||
<script src="js/api.js"></script>
|
||
<script src="js/app.js"></script>
|
||
|
||
<div class="relative flex h-screen overflow-hidden">
|
||
<input id="sidebar-toggle" type="checkbox" class="peer hidden"/>
|
||
<label for="sidebar-toggle" aria-label="Toggle navigation" class="
|
||
sm:hidden rotate-180 peer-checked:rotate-0 absolute z-20 top-[14px] left-6
|
||
flex justify-center items-center p-2 rounded-lg transition-all
|
||
text-charcoal-300 hover:text-charcoal hover:bg-gray-100
|
||
peer-checked:text-white/70 peer-checked:hover:text-white peer-checked:hover:bg-white/10
|
||
">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="-0.5 0 25 25">
|
||
<path stroke-width="3" stroke-linecap="round" stroke-linejoin="round" d="M7.6728 22L16.1434 13.0294C16.4081 12.75 16.4081 12.3088 16.1434 12.0147L7.65808 3" />
|
||
</svg>
|
||
</label>
|
||
<!-- Sidebar -->
|
||
<aside class="absolute z-10 sm:static -translate-x-60 peer-checked:translate-x-0 sm:!translate-x-0 w-60 h-screen bg-charcoal flex flex-col overflow-auto transition-all">
|
||
<div class="ml-12 sm:ml-0 h-16 flex-shrink-0 flex items-center px-6 border-b border-white/10">
|
||
<a href="https://www.versity.com" target="_blank" rel="noopener noreferrer">
|
||
<img src="assets/images/Versity-logo-white-horizontal.png" alt="Versity" class="h-10 hover:opacity-80 transition-opacity">
|
||
</a>
|
||
</div>
|
||
<nav class="flex-1 py-4">
|
||
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-admin-only>
|
||
Admin
|
||
</div>
|
||
<a href="dashboard.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||
</svg>
|
||
<span class="font-medium">Dashboard</span>
|
||
</a>
|
||
<a href="users.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
|
||
</svg>
|
||
<span class="font-medium">Users</span>
|
||
</a>
|
||
<a href="buckets.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
|
||
</svg>
|
||
<span class="font-medium">Buckets</span>
|
||
</a>
|
||
<div class="mx-6 my-2 border-t border-white/10" data-admin-only></div>
|
||
<a href="explorer.html" class="nav-item active flex items-center gap-3 px-6 py-3 text-white">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||
</svg>
|
||
<span class="font-medium">Explorer</span>
|
||
</a>
|
||
<div class="mx-6 my-2 border-t border-white/10"></div>
|
||
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase">
|
||
Resources
|
||
</div>
|
||
<a href="https://github.com/versity/versitygw/wiki" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||
</svg>
|
||
<span class="font-medium">Documentation</span>
|
||
</a>
|
||
<a href="https://github.com/versity/versitygw/issues" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||
</svg>
|
||
<span class="font-medium">Bug Reports</span>
|
||
</a>
|
||
<a href="https://github.com/versity/versitygw/releases" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
|
||
</svg>
|
||
<span class="font-medium">Releases</span>
|
||
</a>
|
||
<a href="https://github.com/versity/versitygw" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
|
||
</svg>
|
||
<span class="font-medium">GitHub</span>
|
||
</a>
|
||
</nav>
|
||
<div class="p-4 border-t border-white/10">
|
||
<div id="user-info" class="flex items-center gap-3 mb-3">
|
||
<!-- Populated by JS -->
|
||
</div>
|
||
<button onclick="logout()" class="w-full flex items-center gap-2 px-3 py-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors text-sm">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||
</svg>
|
||
Sign Out
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- Main Content -->
|
||
<div class="flex-1 flex flex-col overflow-hidden">
|
||
<header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 flex-shrink-0">
|
||
<h1 class="ml-12 sm:ml-0 text-xl font-semibold text-charcoal">VersityGW Explorer</h1>
|
||
<div id="header-actions" class="flex items-center gap-2 hidden">
|
||
<button onclick="refresh()" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors" title="Refresh">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||
</svg>
|
||
</button>
|
||
<button id="versioning-config-btn" onclick="openVersioningModal()" class="inline-flex items-center gap-2 px-4 py-2 border border-gray-200 hover:bg-gray-50 text-charcoal font-medium rounded-lg transition-colors" title="Configure bucket versioning">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
Versioning
|
||
</button>
|
||
<button id="versions-toggle-btn" onclick="toggleVersionsView()" class="hidden inline-flex items-center gap-2 px-4 py-2 border border-gray-200 hover:bg-gray-50 text-charcoal font-medium rounded-lg transition-colors">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
<span id="versions-toggle-text">Show Versions</span>
|
||
</button>
|
||
<button onclick="openCreateFolderDialog()" class="inline-flex items-center gap-2 px-4 py-2 border border-gray-200 hover:bg-gray-50 text-charcoal font-medium rounded-lg transition-colors">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
|
||
</svg>
|
||
New Folder
|
||
</button>
|
||
<button onclick="openUploadDialog()" id="upload-btn" class="inline-flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
|
||
</svg>
|
||
Upload
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Breadcrumb & Search -->
|
||
<div class="bg-white border-b border-gray-100 px-8 py-3 flex items-center justify-between">
|
||
<nav id="breadcrumb" class="flex items-center gap-2 text-sm">
|
||
<span class="text-charcoal-300">Select a bucket to browse</span>
|
||
</nav>
|
||
<div id="search-container" class="hidden">
|
||
<div class="relative">
|
||
<svg class="w-4 h-4 text-charcoal-300 absolute left-3 top-1/2 transform -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||
</svg>
|
||
<input type="text" id="search-input" class="pl-9 pr-8 py-1.5 w-64 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-accent" placeholder="Filter by name...">
|
||
<button id="clear-search" class="hidden absolute right-2 top-1/2 transform -translate-y-1/2 text-charcoal-300 hover:text-charcoal" onclick="clearSearch()">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<main class="flex-1 overflow-auto p-6" id="drop-zone">
|
||
<div class="max-w-7xl mx-auto">
|
||
<!-- Drop Zone Overlay (hidden by default) -->
|
||
<div id="drop-overlay" class="hidden fixed inset-0 z-40 bg-accent/10 border-4 border-dashed border-accent flex items-center justify-center">
|
||
<div class="bg-white rounded-xl p-8 shadow-lg text-center">
|
||
<svg class="w-16 h-16 text-accent mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
|
||
</svg>
|
||
<p class="text-xl font-semibold text-charcoal">Drop files to upload</p>
|
||
<p class="text-charcoal-300 mt-2">Files will be uploaded to current folder</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Buckets Table (shown when no bucket selected) -->
|
||
<div id="buckets-view">
|
||
<!-- Action bar: go-to-bucket input + favorites + create bucket -->
|
||
<div class="flex items-center justify-between mb-4 gap-4 flex-wrap">
|
||
<div class="flex items-center gap-2">
|
||
<input type="text" id="goto-bucket-input"
|
||
class="px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-accent w-56"
|
||
placeholder="Enter bucket name..."
|
||
onkeydown="if(event.key==='Enter') goToBucketByName()">
|
||
<button onclick="goToBucketByName()" class="inline-flex items-center gap-1.5 px-3 py-2 border border-gray-200 hover:bg-gray-50 text-charcoal text-sm font-medium rounded-lg transition-colors" title="Open bucket">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
|
||
</svg>
|
||
Open
|
||
</button>
|
||
<button onclick="addInputBucketToFavorites()" class="inline-flex items-center gap-1.5 px-3 py-2 border border-gray-200 hover:bg-yellow-50 hover:border-yellow-300 text-charcoal-300 hover:text-yellow-600 text-sm font-medium rounded-lg transition-colors" title="Save bucket name to favorites">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>
|
||
</svg>
|
||
Favorite
|
||
</button>
|
||
</div>
|
||
<!-- Create Bucket Button (shown for admin/userplus) -->
|
||
<div id="bucket-actions">
|
||
<button onclick="openCreateBucketDialog()" class="inline-flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent-600 text-white font-medium rounded-lg transition-colors">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||
</svg>
|
||
Create Bucket
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Favorites Section -->
|
||
<div id="favorites-section" class="mb-4 hidden">
|
||
<div class="bg-white rounded-xl shadow-sm border border-yellow-100 p-4">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<svg class="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>
|
||
</svg>
|
||
<h3 class="text-sm font-semibold text-charcoal">Favorites</h3>
|
||
</div>
|
||
<div id="favorites-list" class="flex flex-wrap gap-2">
|
||
<!-- Populated by JS -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="bg-white rounded-xl shadow-sm border border-gray-100">
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full">
|
||
<colgroup>
|
||
<col style="width: 30%;">
|
||
<col style="width: 18%;">
|
||
<col style="width: 18%;">
|
||
<col id="buckets-created-col" style="width: 16%;">
|
||
<col style="width: 18%;">
|
||
</colgroup>
|
||
<thead>
|
||
<tr class="border-b border-gray-100 bg-gray-50">
|
||
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-400">Name</th>
|
||
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-400">Versioning</th>
|
||
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-400">Object Lock</th>
|
||
<th id="buckets-created-header" class="text-left py-3 px-4 text-sm font-medium text-charcoal-400">Created</th>
|
||
<th class="text-center py-3 px-4 text-sm font-medium text-charcoal-400">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="buckets-table">
|
||
<!-- Bucket rows rendered by JS -->
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Objects Table (shown when bucket is selected) -->
|
||
<div id="objects-view" class="hidden">
|
||
<div class="bg-white rounded-xl shadow-sm border border-gray-100">
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full">
|
||
<thead>
|
||
<tr class="border-b border-gray-100 bg-gray-50">
|
||
<th class="w-10 py-3 px-4">
|
||
<input type="checkbox" id="select-all" class="w-4 h-4 rounded border-gray-300" onchange="toggleSelectAll()">
|
||
</th>
|
||
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-400">Name</th>
|
||
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-400 w-28">Size</th>
|
||
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-400 w-48">Modified</th>
|
||
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-400 w-28">Class</th>
|
||
<th class="text-right py-3 px-4 text-sm font-medium text-charcoal-400 w-28">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="objects-table">
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<!-- Pagination Bar -->
|
||
<div id="objects-pagination" class="hidden flex items-center justify-between px-4 py-3 border-t border-gray-100">
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-sm text-charcoal-300" id="pagination-info"></span>
|
||
</div>
|
||
<div class="flex items-center gap-4">
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-sm text-charcoal-300">Rows per page:</span>
|
||
<select id="page-size-select" onchange="changePageSize(parseInt(this.value, 10))" class="text-sm border border-gray-200 rounded-lg px-2 py-1.5 text-charcoal focus:outline-none focus:border-accent bg-white">
|
||
<option value="10" selected>10</option>
|
||
<option value="20">20</option>
|
||
<option value="50">50</option>
|
||
<option value="100">100</option>
|
||
<option value="1000">1000</option>
|
||
</select>
|
||
</div>
|
||
<div class="flex items-center gap-1">
|
||
<button id="pagination-first" onclick="changePage(1)" class="p-1.5 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed" title="First page">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7"/>
|
||
</svg>
|
||
</button>
|
||
<button id="pagination-prev" onclick="changePage(currentPage - 1)" class="p-1.5 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed" title="Previous page">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||
</svg>
|
||
</button>
|
||
<span id="pagination-pages" class="text-sm text-charcoal px-2"></span>
|
||
<button id="pagination-next" onclick="changePage(currentPage + 1)" class="p-1.5 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed" title="Next page">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Selection Toolbar (hidden by default) -->
|
||
<div id="selection-toolbar" class="hidden fixed bottom-8 left-1/2 transform -translate-x-1/2 bg-charcoal text-white px-6 py-3 rounded-xl shadow-lg flex items-center gap-4">
|
||
<span id="selection-count" class="text-sm font-medium">0 items selected</span>
|
||
<div class="w-px h-6 bg-white/20"></div>
|
||
<button onclick="deleteSelected()" class="inline-flex items-center gap-2 px-3 py-1.5 text-red-400 hover:text-red-300 hover:bg-white/10 rounded-lg transition-colors">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
Delete
|
||
</button>
|
||
<button onclick="clearSelection()" class="px-3 py-1.5 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors">
|
||
Clear
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Object Info Modal -->
|
||
<div id="info-modal" class="hidden fixed inset-0 z-50">
|
||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('info-modal')"></div>
|
||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-3xl relative max-h-[90vh] flex flex-col">
|
||
<div class="flex items-center justify-between p-6 border-b border-gray-100 flex-shrink-0">
|
||
<h2 class="text-xl font-semibold text-charcoal">Object Info</h2>
|
||
<button onclick="closeModal('info-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="flex-1 overflow-auto p-6 space-y-6">
|
||
<!-- Basic Info Section -->
|
||
<div>
|
||
<h3 class="text-sm font-semibold text-charcoal-400 uppercase tracking-wider mb-3">Basic Info</h3>
|
||
<div class="space-y-3">
|
||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||
<span class="text-charcoal-300">Key</span>
|
||
<span id="info-key" class="text-charcoal font-mono text-sm max-w-xs truncate">-</span>
|
||
</div>
|
||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||
<span class="text-charcoal-300">Size</span>
|
||
<span id="info-size" class="text-charcoal">-</span>
|
||
</div>
|
||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||
<span class="text-charcoal-300">Last Modified</span>
|
||
<span id="info-modified" class="text-charcoal">-</span>
|
||
</div>
|
||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||
<span class="text-charcoal-300">Content Type</span>
|
||
<span id="info-type" class="text-charcoal">-</span>
|
||
</div>
|
||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||
<span class="text-charcoal-300">Storage Class</span>
|
||
<span id="info-class" class="text-charcoal">-</span>
|
||
</div>
|
||
<div class="flex items-center justify-between py-2">
|
||
<span class="text-charcoal-300">ETag</span>
|
||
<span id="info-etag" class="text-charcoal font-mono text-sm max-w-xs truncate">-</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tags Section -->
|
||
<div>
|
||
<div class="flex items-center justify-between mb-3">
|
||
<h3 class="text-sm font-semibold text-charcoal-400 uppercase tracking-wider">Tags</h3>
|
||
<button onclick="addObjectTag()" class="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-accent hover:text-accent-600 hover:bg-accent-50 rounded transition-colors">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||
</svg>
|
||
Add Tag
|
||
</button>
|
||
</div>
|
||
<div id="object-tags-list" class="space-y-2 min-h-[60px]">
|
||
<p class="text-charcoal-300 text-sm py-4 text-center">No tags</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Metadata Section -->
|
||
<div>
|
||
<div class="flex items-center justify-between mb-3">
|
||
<h3 class="text-sm font-semibold text-charcoal-400 uppercase tracking-wider">Metadata</h3>
|
||
<button onclick="addObjectMetadata()" class="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-accent hover:text-accent-600 hover:bg-accent-50 rounded transition-colors">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||
</svg>
|
||
Add Metadata
|
||
</button>
|
||
</div>
|
||
<div id="object-metadata-list" class="space-y-2 min-h-[60px]">
|
||
<p class="text-charcoal-300 text-sm py-4 text-center">No metadata</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 p-6 border-t border-gray-100 flex-shrink-0 bg-white">
|
||
<button onclick="closeModal('info-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">
|
||
Close
|
||
</button>
|
||
<div class="flex items-center gap-3">
|
||
<button id="save-object-tags-btn" onclick="saveObjectTags()" class="px-4 py-2.5 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
|
||
Save Tags
|
||
</button>
|
||
<button id="save-object-metadata-btn" onclick="saveObjectMetadata()" class="px-4 py-2.5 bg-accent hover:bg-accent-600 text-white font-medium rounded-lg transition-colors">
|
||
Save Metadata
|
||
</button>
|
||
<button id="info-download-btn" onclick="downloadCurrentObject()" class="px-4 py-2.5 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">
|
||
Download
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Hidden file input for uploads -->
|
||
<input type="file" id="file-input" class="hidden" multiple onchange="handleFileSelect(event)">
|
||
|
||
<!-- Object Versions Modal -->
|
||
<div id="versions-modal" class="hidden fixed inset-0 z-50">
|
||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('versions-modal')"></div>
|
||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-4xl relative max-h-[90vh] flex flex-col">
|
||
<div class="flex items-center justify-between p-6 border-b border-gray-100 flex-shrink-0">
|
||
<div>
|
||
<h2 class="text-xl font-semibold text-charcoal">Object Versions</h2>
|
||
<p id="versions-object-key" class="text-sm text-charcoal-300 font-mono mt-1"></p>
|
||
</div>
|
||
<button onclick="closeModal('versions-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="flex-1 overflow-auto p-6">
|
||
<div id="versions-loading" class="hidden text-center py-8">
|
||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-4 border-accent border-t-transparent"></div>
|
||
<p class="text-charcoal-300 mt-3">Loading versions...</p>
|
||
</div>
|
||
<div id="versions-content" class="space-y-3">
|
||
<!-- Versions will be rendered here -->
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100 flex-shrink-0">
|
||
<button onclick="closeModal('versions-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Delete Confirmation Modal -->
|
||
<div id="delete-modal" class="hidden fixed inset-0 z-50">
|
||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('delete-modal')"></div>
|
||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
|
||
<div class="p-6">
|
||
<div class="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
</div>
|
||
<h3 class="text-lg font-semibold text-charcoal text-center mb-2">Delete Objects</h3>
|
||
<p id="delete-message" class="text-charcoal-300 text-center mb-6">Are you sure you want to delete these items? This action cannot be undone.</p>
|
||
<div class="flex items-center justify-center gap-3">
|
||
<button onclick="closeModal('delete-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">
|
||
Cancel
|
||
</button>
|
||
<button id="confirm-delete-btn" onclick="confirmDelete()" class="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Create Folder Modal -->
|
||
<div id="create-folder-modal" class="hidden fixed inset-0 z-50">
|
||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('create-folder-modal')"></div>
|
||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
|
||
<div class="flex items-center justify-between p-6 border-b border-gray-100">
|
||
<h2 class="text-xl font-semibold text-charcoal">Create Folder</h2>
|
||
<button onclick="closeModal('create-folder-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="p-6">
|
||
<label class="block text-sm font-medium text-charcoal mb-2">Folder Name</label>
|
||
<input type="text" id="new-folder-name" class="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal focus:outline-none focus:border-accent" placeholder="Enter folder name">
|
||
<p class="text-xs text-charcoal-300 mt-2">Folder will be created in: <span id="folder-location" class="font-mono">/</span></p>
|
||
</div>
|
||
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100">
|
||
<button onclick="closeModal('create-folder-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">
|
||
Cancel
|
||
</button>
|
||
<button id="create-folder-btn" onclick="createFolder()" class="px-4 py-2.5 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">
|
||
Create
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Create Bucket Modal -->
|
||
<div id="create-bucket-modal" class="hidden fixed inset-0 z-50">
|
||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('create-bucket-modal')"></div>
|
||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
|
||
<div class="flex items-center justify-between p-6 border-b border-gray-100">
|
||
<h2 class="text-xl font-semibold text-charcoal">Create Bucket</h2>
|
||
<button onclick="closeModal('create-bucket-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="p-6">
|
||
<label class="block text-sm font-medium text-charcoal mb-2">Bucket Name</label>
|
||
<input type="text" id="new-bucket-name" class="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal focus:outline-none focus:border-accent" placeholder="my-bucket-name">
|
||
<p class="text-xs text-charcoal-300 mt-2">Bucket names must be lowercase, 3-63 characters, and can contain letters, numbers, and hyphens.</p>
|
||
</div>
|
||
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100">
|
||
<button onclick="closeModal('create-bucket-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">
|
||
Cancel
|
||
</button>
|
||
<button id="create-bucket-btn" onclick="createBucket()" class="px-4 py-2.5 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">
|
||
Create
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Delete Bucket Modal -->
|
||
<div id="delete-bucket-modal" class="hidden fixed inset-0 z-50">
|
||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('delete-bucket-modal')"></div>
|
||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
|
||
<div class="p-6">
|
||
<div class="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
</div>
|
||
<h3 class="text-lg font-semibold text-charcoal text-center mb-2">Delete Bucket</h3>
|
||
<p id="delete-bucket-message" class="text-charcoal-300 text-center mb-6"></p>
|
||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||
<div class="flex items-start gap-3">
|
||
<svg class="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||
</svg>
|
||
<p class="text-sm text-yellow-800">This action cannot be undone. The bucket must be empty before it can be deleted.</p>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-center gap-3">
|
||
<button onclick="closeModal('delete-bucket-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">
|
||
Cancel
|
||
</button>
|
||
<button id="confirm-delete-bucket-btn" onclick="confirmDeleteBucketAction()" class="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">
|
||
Delete Bucket
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bucket Info Modal -->
|
||
<div id="bucket-info-modal" class="hidden fixed inset-0 z-50">
|
||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('bucket-info-modal')"></div>
|
||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-2xl relative max-h-[90vh] overflow-y-auto">
|
||
<div class="flex items-center justify-between p-6 border-b border-gray-100 sticky top-0 bg-white z-10">
|
||
<h2 class="text-xl font-semibold text-charcoal">Bucket Information</h2>
|
||
<button onclick="closeModal('bucket-info-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="p-6 space-y-6">
|
||
<!-- Basic Info Section -->
|
||
<div>
|
||
<h3 class="text-sm font-semibold text-charcoal-400 uppercase tracking-wider mb-3">Basic Information</h3>
|
||
<div class="space-y-3">
|
||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||
<span class="text-charcoal-300">Bucket Name</span>
|
||
<span id="bucket-info-name" class="text-charcoal font-mono text-sm">-</span>
|
||
</div>
|
||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||
<span class="text-charcoal-300">Versioning</span>
|
||
<span id="bucket-info-versioning" class="text-charcoal">-</span>
|
||
</div>
|
||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||
<span class="text-charcoal-300">Object Lock</span>
|
||
<span id="bucket-info-object-lock" class="text-charcoal">-</span>
|
||
</div>
|
||
<div id="bucket-info-object-lock-details" class="hidden space-y-2 pl-4 py-2 border-l-2 border-accent-200">
|
||
<div class="flex items-center justify-between text-sm">
|
||
<span class="text-charcoal-300">Mode</span>
|
||
<span id="bucket-info-lock-mode" class="text-charcoal font-mono">-</span>
|
||
</div>
|
||
<div class="flex items-center justify-between text-sm">
|
||
<span class="text-charcoal-300">Retention Period</span>
|
||
<span id="bucket-info-lock-retention" class="text-charcoal">-</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tags Section -->
|
||
<div>
|
||
<div class="flex items-center justify-between mb-3">
|
||
<h3 class="text-sm font-semibold text-charcoal-400 uppercase tracking-wider">Tags</h3>
|
||
<button onclick="addBucketTag()" class="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-accent hover:text-accent-600 hover:bg-accent-50 rounded transition-colors">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||
</svg>
|
||
Add Tag
|
||
</button>
|
||
</div>
|
||
<div id="bucket-tags-list" class="space-y-2 min-h-[60px]">
|
||
<p class="text-charcoal-300 text-sm py-4 text-center">No tags</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 p-6 border-t border-gray-100 sticky bottom-0 bg-white">
|
||
<div></div>
|
||
<div class="flex gap-3">
|
||
<button onclick="closeModal('bucket-info-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">
|
||
Close
|
||
</button>
|
||
<button id="save-bucket-tags-btn" onclick="saveBucketTags()" class="px-4 py-2.5 bg-accent hover:bg-accent-600 text-white font-medium rounded-lg transition-colors">
|
||
Save Tags
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Presigned URL modal -->
|
||
<div id="presigned-url-modal" class="hidden fixed inset-0 z-50">
|
||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('presigned-url-modal')"></div>
|
||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-2xl relative max-h-[90vh] overflow-y-auto">
|
||
<div class="flex items-center justify-between p-6 border-b border-gray-100 sticky top-0 bg-white z-10">
|
||
<h2 class="text-xl font-semibold text-charcoal">Generate presigned URL</h2>
|
||
<button onclick="closeModal('presigned-url-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="p-6 space-y-6">
|
||
<div>
|
||
<div class="space-y-3">
|
||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||
<span class="text-charcoal-300">Bucket</span>
|
||
<span id="presigned-url-bucket" class="text-charcoal font-mono text-sm">-</span>
|
||
</div>
|
||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||
<span class="text-charcoal-300">Key</span>
|
||
<span id="presigned-url-key" class="text-charcoal font-mono text-sm">-</span>
|
||
</div>
|
||
<div class="flex flex-col justify-between py-2 border-b border-gray-100">
|
||
<span class="text-charcoal-300 mb-1">Expiration</span>
|
||
<div class="flex gap-1 justify-center">
|
||
<input id="presigned-url-exp-value" class="text-charcoal w-full px-3 py-1.5 border border-gray-200 rounded text-sm focus:outline-none focus:border-accent" value="1" />
|
||
<select id="presigned-url-exp-unit" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg bg-white text-charcoal focus:outline-none focus:border-accent">
|
||
<option value="minutes" selected>minutes</option>
|
||
<option value="hours">hours</option>
|
||
<option value="days">days</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="text-right py-2 border-b border-gray-100">
|
||
<button onclick="generatePresignedUrl()" id="presigned-url-btn" class="inline-flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-600 disabled:bg-gray-500 text-white font-medium rounded-lg transition-colors">
|
||
Generate presigned URL
|
||
</button>
|
||
</div>
|
||
<div id="presigned-url-result" class="hidden flex flex-col items-start justify-between py-2 border-b border-gray-100">
|
||
<span class="flex col gap-2 items-center text-charcoal-300">
|
||
URL
|
||
<svg class="w-5 h-5 cursor-pointer" fill="none" viewBox="0 0 24 24" stroke="currentColor" onclick="copyPresignedUrl();" title="Copy to clipboard">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 7.5V6.108c0-1.135.845-2.098 1.976-2.192.373-.03.748-.057 1.123-.08M15.75 18H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08M15.75 18.75v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5A3.375 3.375 0 0 0 6.375 7.5H5.25m11.9-3.664A2.251 2.251 0 0 0 15 2.25h-1.5a2.251 2.251 0 0 0-2.15 1.586m5.8 0c.065.21.1.433.1.664v.75h-6V4.5c0-.231.035-.454.1-.664M6.75 7.5H4.875c-.621 0-1.125.504-1.125 1.125v12c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V16.5a9 9 0 0 0-9-9Z" />
|
||
</svg>
|
||
</span>
|
||
<input readonly id="presigned-url-url" class="text-charcoal w-full px-3 py-1.5 border border-gray-200 rounded text-sm focus:outline-none focus:border-accent" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 p-6 sticky bottom-0 bg-white">
|
||
<div></div>
|
||
<div class="flex gap-3">
|
||
<button id="presigned-close-btn" onclick="closeModal('presigned-url-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Versioning Modal -->
|
||
<div id="versioning-modal" class="hidden fixed inset-0 z-50">
|
||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('versioning-modal')"></div>
|
||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
|
||
<div class="flex items-center justify-between p-6 border-b border-gray-100">
|
||
<h2 class="text-xl font-semibold text-charcoal">Bucket Versioning</h2>
|
||
<button onclick="closeModal('versioning-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="p-6 space-y-5">
|
||
<div>
|
||
<label class="block text-sm font-medium text-charcoal mb-2">Bucket</label>
|
||
<input type="text" id="versioning-bucket-name" readonly class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal bg-gray-50 font-mono">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-charcoal mb-2">Current Status</label>
|
||
<div id="versioning-current-status" class="flex items-center gap-2"></div>
|
||
</div>
|
||
<div class="bg-accent-50 border border-accent/20 rounded-lg p-4">
|
||
<div class="flex items-start gap-3">
|
||
<svg class="w-5 h-5 text-accent flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
<p class="text-sm text-charcoal">
|
||
<span class="font-medium">Note:</span> Once versioning is enabled, it can only be suspended, not disabled. All object uploads will create new versions.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100">
|
||
<button onclick="closeModal('versioning-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Cancel</button>
|
||
<button id="suspend-versioning-btn" onclick="suspendVersioning()" class="hidden px-4 py-2.5 bg-yellow-500 hover:bg-yellow-600 text-white font-medium rounded-lg transition-colors">Suspend Versioning</button>
|
||
<button id="enable-versioning-btn" onclick="enableVersioning()" class="px-4 py-2.5 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">Enable Versioning</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Object Lock Modal -->
|
||
<div id="object-lock-modal" class="hidden fixed inset-0 z-50">
|
||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('object-lock-modal')"></div>
|
||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
|
||
<div class="flex items-center justify-between p-6 border-b border-gray-100">
|
||
<h2 class="text-xl font-semibold text-charcoal">Object Lock Configuration</h2>
|
||
<button onclick="closeModal('object-lock-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="p-6 space-y-5">
|
||
<div>
|
||
<label class="block text-sm font-medium text-charcoal mb-2">Bucket</label>
|
||
<input type="text" id="object-lock-bucket-name" readonly class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal bg-gray-50 font-mono">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-charcoal mb-2">Current Status</label>
|
||
<div id="object-lock-current-status" class="flex items-center gap-2"></div>
|
||
</div>
|
||
<div id="object-lock-config-section" class="hidden space-y-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-charcoal mb-2">Retention Mode</label>
|
||
<select id="object-lock-mode" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal focus:outline-none focus:border-accent">
|
||
<option value="">None</option>
|
||
<option value="GOVERNANCE">GOVERNANCE</option>
|
||
<option value="COMPLIANCE">COMPLIANCE</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-charcoal mb-2">Retention Period</label>
|
||
<div class="flex gap-3">
|
||
<div class="flex-1">
|
||
<input type="number" id="object-lock-days" min="0" placeholder="Days" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal focus:outline-none focus:border-accent">
|
||
</div>
|
||
<div class="flex-1">
|
||
<input type="number" id="object-lock-years" min="0" placeholder="Years" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal focus:outline-none focus:border-accent">
|
||
</div>
|
||
</div>
|
||
<p class="text-xs text-charcoal-300 mt-1">Specify either days or years, not both</p>
|
||
</div>
|
||
</div>
|
||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||
<div class="flex items-start gap-3">
|
||
<svg class="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
<p class="text-sm text-blue-800">
|
||
<span class="font-medium">Note:</span> Versioning must be enabled before you can enable Object Lock. Once Object Lock is enabled, it cannot be disabled. You can update retention settings at any time.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100">
|
||
<button onclick="closeModal('object-lock-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Close</button>
|
||
<button id="enable-object-lock-btn" onclick="enableObjectLock()" class="hidden px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">Enable Object Lock</button>
|
||
<button id="update-object-lock-btn" onclick="updateObjectLockConfig()" class="hidden px-4 py-2.5 bg-accent hover:bg-accent-600 text-white font-medium rounded-lg transition-colors">Update Configuration</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bucket Policy Modal -->
|
||
<div id="bucket-policy-modal" class="hidden fixed inset-0 z-50">
|
||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('bucket-policy-modal')"></div>
|
||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-5xl relative max-h-[90vh] flex flex-col">
|
||
<div class="flex items-center justify-between p-6 border-b border-gray-100 flex-shrink-0">
|
||
<div>
|
||
<h2 class="text-xl font-semibold text-charcoal">Bucket Policy Manager</h2>
|
||
<p class="text-sm text-charcoal-300 mt-1" id="policy-bucket-name-display"></p>
|
||
</div>
|
||
<button onclick="closeModal('bucket-policy-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="flex-1 overflow-auto">
|
||
<div class="p-6 space-y-6">
|
||
<!-- Info Banner -->
|
||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||
<div class="flex items-start gap-3">
|
||
<svg class="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
<div class="text-sm text-blue-800">
|
||
<p class="font-medium">About Bucket Policies</p>
|
||
<p class="mt-1">Bucket policies define who can access your bucket and what actions they can perform. The <strong>Principal</strong> is the access key of the user account, and the <strong>Resource</strong> uses standard bucket ARN format.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Quick Reference Card -->
|
||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||
<h3 class="text-sm font-semibold text-charcoal mb-3 flex items-center gap-2">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||
</svg>
|
||
Quick Reference
|
||
</h3>
|
||
<div class="grid grid-cols-2 gap-4 text-xs">
|
||
<div>
|
||
<p class="font-medium text-charcoal mb-1">Common Actions:</p>
|
||
<ul class="space-y-0.5 text-charcoal-400">
|
||
<li>• <code class="bg-white px-1 py-0.5 rounded">s3:GetObject</code> - Download objects</li>
|
||
<li>• <code class="bg-white px-1 py-0.5 rounded">s3:PutObject</code> - Upload objects</li>
|
||
<li>• <code class="bg-white px-1 py-0.5 rounded">s3:DeleteObject</code> - Delete objects</li>
|
||
<li>• <code class="bg-white px-1 py-0.5 rounded">s3:ListBucket</code> - List bucket contents</li>
|
||
</ul>
|
||
</div>
|
||
<div>
|
||
<p class="font-medium text-charcoal mb-1">Resource Format:</p>
|
||
<ul class="space-y-0.5 text-charcoal-400">
|
||
<li>• Bucket: <code class="bg-white px-1 py-0.5 rounded text-[10px]">arn:aws:s3:::bucketname</code></li>
|
||
<li>• All objects: <code class="bg-white px-1 py-0.5 rounded text-[10px]">arn:aws:s3:::bucketname/*</code></li>
|
||
<li>• Folder: <code class="bg-white px-1 py-0.5 rounded text-[10px]">arn:aws:s3:::bucketname/folder/*</code></li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Current Policy Status -->
|
||
<div>
|
||
<div class="flex items-center justify-between mb-3">
|
||
<label class="text-sm font-medium text-charcoal">Current Policy</label>
|
||
<div class="flex gap-2">
|
||
<button onclick="showPolicyDocumentation()" class="inline-flex items-center gap-1 px-3 py-1.5 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal rounded-lg transition-colors">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
Help
|
||
</button>
|
||
<button id="load-example-policy-btn" onclick="loadExamplePolicy()" class="inline-flex items-center gap-1 px-3 py-1.5 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal rounded-lg transition-colors">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||
</svg>
|
||
Load Example
|
||
</button>
|
||
<button onclick="validatePolicyJson()" class="inline-flex items-center gap-1 px-3 py-1.5 text-xs border border-accent text-accent hover:bg-accent-50 rounded-lg transition-colors">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
Validate
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div id="policy-status-message" class="mb-3 hidden"></div>
|
||
<div class="relative">
|
||
<textarea
|
||
id="policy-json-editor"
|
||
class="w-full px-4 py-3 border-2 border-gray-200 rounded-lg text-sm font-mono focus:outline-none focus:border-accent resize-none"
|
||
rows="16"
|
||
placeholder="No policy defined. Click 'Load Example' to get started."
|
||
></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Policy Documentation (collapsible) -->
|
||
<div id="policy-documentation" class="hidden bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||
<div class="flex items-start justify-between mb-3">
|
||
<h3 class="text-sm font-semibold text-charcoal flex items-center gap-2">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||
</svg>
|
||
Policy Structure Guide
|
||
</h3>
|
||
<button onclick="hidePolicyDocumentation()" class="text-charcoal-300 hover:text-charcoal">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="space-y-3 text-sm text-charcoal">
|
||
<div>
|
||
<p class="font-medium mb-1">Required Fields:</p>
|
||
<ul class="list-disc list-inside space-y-1 text-charcoal-400 ml-2">
|
||
<li><strong>Version:</strong> Always "2012-10-17"</li>
|
||
<li><strong>Statement:</strong> Array of policy statements</li>
|
||
</ul>
|
||
</div>
|
||
<div>
|
||
<p class="font-medium mb-1">Statement Fields:</p>
|
||
<ul class="list-disc list-inside space-y-1 text-charcoal-400 ml-2">
|
||
<li><strong>Sid:</strong> Statement identifier (optional, but recommended)</li>
|
||
<li><strong>Effect:</strong> "Allow" or "Deny"</li>
|
||
<li><strong>Principal:</strong> Array of access keys (e.g., ["user001", "user002"])</li>
|
||
<li><strong>Action:</strong> Array of S3 actions (e.g., ["s3:GetObject", "s3:PutObject"])</li>
|
||
<li><strong>Resource:</strong> Array of ARNs (e.g., ["arn:aws:s3:::bucket/*"])</li>
|
||
</ul>
|
||
</div>
|
||
<div>
|
||
<p class="font-medium mb-1">Common S3 Actions:</p>
|
||
<div class="grid grid-cols-2 gap-2 text-xs text-charcoal-400 ml-2">
|
||
<div>
|
||
<p class="font-medium text-charcoal mb-1">Bucket Operations:</p>
|
||
<ul class="space-y-0.5">
|
||
<li>• s3:ListBucket</li>
|
||
<li>• s3:GetBucketLocation</li>
|
||
<li>• s3:GetBucketPolicy</li>
|
||
<li>• s3:PutBucketTagging</li>
|
||
</ul>
|
||
</div>
|
||
<div>
|
||
<p class="font-medium text-charcoal mb-1">Object Operations:</p>
|
||
<ul class="space-y-0.5">
|
||
<li>• s3:GetObject</li>
|
||
<li>• s3:PutObject</li>
|
||
<li>• s3:DeleteObject</li>
|
||
<li>• s3:GetObjectTagging</li>
|
||
<li>• s3:PutObjectTagging</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center justify-between p-6 border-t border-gray-100 flex-shrink-0">
|
||
<button id="delete-policy-btn" onclick="deleteBucketPolicy()" class="px-4 py-2.5 border border-red-200 text-red-600 hover:bg-red-50 font-medium rounded-lg transition-colors">
|
||
Delete Policy
|
||
</button>
|
||
<div class="flex gap-3">
|
||
<button onclick="closeModal('bucket-policy-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Cancel</button>
|
||
<button id="save-policy-btn" onclick="saveBucketPolicy()" class="px-4 py-2.5 bg-accent hover:bg-accent-600 text-white font-medium rounded-lg transition-colors">Save Policy</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Multipart Uploads Modal -->
|
||
<div id="multipart-uploads-modal" class="hidden fixed inset-0 z-50">
|
||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('multipart-uploads-modal')"></div>
|
||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-4xl relative max-h-[90vh] flex flex-col">
|
||
<div class="flex items-center justify-between p-6 border-b border-gray-100 flex-shrink-0">
|
||
<div>
|
||
<h2 class="text-xl font-semibold text-charcoal">Multipart Uploads</h2>
|
||
<p class="text-sm text-charcoal-300 mt-1" id="multipart-bucket-name-display"></p>
|
||
</div>
|
||
<button onclick="closeModal('multipart-uploads-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="flex-1 overflow-auto">
|
||
<div class="p-6 space-y-4">
|
||
<!-- Info Banner -->
|
||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||
<div class="flex items-start gap-3">
|
||
<svg class="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
<div class="text-sm text-blue-800">
|
||
<p class="font-medium">About Multipart Uploads</p>
|
||
<p class="mt-1">These are incomplete multipart uploads in this bucket. You can abort uploads that are no longer needed to free up storage.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Loading State -->
|
||
<div id="multipart-loading" class="py-12 text-center">
|
||
<svg class="animate-spin w-8 h-8 text-accent mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||
</svg>
|
||
<p class="text-charcoal-300">Loading multipart uploads...</p>
|
||
</div>
|
||
|
||
<!-- Uploads List -->
|
||
<div id="multipart-list" class="hidden">
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full">
|
||
<thead>
|
||
<tr class="border-b-2 border-gray-200">
|
||
<th class="py-3 px-4 text-left text-xs font-semibold text-charcoal-400 uppercase tracking-wider">Object Key</th>
|
||
<th class="py-3 px-4 text-left text-xs font-semibold text-charcoal-400 uppercase tracking-wider">Upload ID</th>
|
||
<th class="py-3 px-4 text-left text-xs font-semibold text-charcoal-400 uppercase tracking-wider">Initiated</th>
|
||
<th class="py-3 px-4 text-center text-xs font-semibold text-charcoal-400 uppercase tracking-wider">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="multipart-uploads-tbody">
|
||
<!-- Populated by JavaScript -->
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Empty State -->
|
||
<div id="multipart-empty" class="hidden py-12 text-center">
|
||
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
<p class="text-lg font-medium text-charcoal">No Multipart Uploads</p>
|
||
<p class="text-sm text-charcoal-300 mt-1">There are no outstanding multipart uploads in this bucket</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center justify-between gap-3 p-6 border-t border-gray-100 flex-shrink-0">
|
||
<div>
|
||
<button id="abort-all-btn" onclick="abortAllMultipartUploads()" class="hidden px-4 py-2.5 border border-red-200 text-red-600 hover:bg-red-50 font-medium rounded-lg transition-colors">
|
||
<span class="flex items-center gap-2">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
Abort All
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<div class="flex gap-3">
|
||
<button onclick="refreshMultipartUploads()" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">
|
||
<span class="flex items-center gap-2">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||
</svg>
|
||
Refresh
|
||
</span>
|
||
</button>
|
||
<button onclick="closeModal('multipart-uploads-modal')" class="px-4 py-2.5 bg-accent hover:bg-accent-600 text-white font-medium rounded-lg transition-colors">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// State
|
||
let currentBucket = null;
|
||
let currentPrefix = '';
|
||
let objects = [];
|
||
let folders = [];
|
||
let bucketsList = [];
|
||
let selectedObjects = new Set();
|
||
let currentObjectKey = null; // For info modal
|
||
let searchTerm = ''; // Search filter
|
||
|
||
// Pagination state
|
||
let currentPage = 1;
|
||
let pageSize = 10;
|
||
let continuationTokens = [null]; // continuationTokens[i] = S3 token needed to fetch page i+1
|
||
let isTruncated = false;
|
||
let isReloadingFirstPageForSearch = false;
|
||
|
||
// Versioning state
|
||
let showVersions = false;
|
||
let currentBucketVersioningStatus = null;
|
||
let objectVersions = [];
|
||
let objectVersionCounts = {}; // Maps object key to version count
|
||
let deletedObjectsWithVersions = []; // Objects with delete marker but older versions exist
|
||
let bucketsVersioningCache = {};
|
||
let bucketsObjectLockCache = {};
|
||
|
||
// Auth guard
|
||
if (!requireAuth()) {
|
||
// Will redirect to login
|
||
} else {
|
||
initSidebarWithRole();
|
||
updateUserInfo();
|
||
init();
|
||
setupDragDrop();
|
||
}
|
||
|
||
// ============================================
|
||
// Initialization
|
||
// ============================================
|
||
|
||
async function init() {
|
||
// Check URL hash for bucket/prefix
|
||
const hash = window.location.hash.slice(1);
|
||
if (hash) {
|
||
const parts = hash.split('/');
|
||
const bucketName = parts[0];
|
||
if (bucketName) {
|
||
currentBucket = bucketName;
|
||
currentPrefix = parts.slice(1).join('/');
|
||
if (currentPrefix && !currentPrefix.endsWith('/')) {
|
||
currentPrefix += '/';
|
||
}
|
||
}
|
||
}
|
||
|
||
await loadBuckets();
|
||
}
|
||
|
||
// ============================================
|
||
// Logout
|
||
// ============================================
|
||
|
||
function logout() {
|
||
confirm('Are you sure you want to sign out?', () => {
|
||
api.logout();
|
||
window.location.href = 'index.html';
|
||
});
|
||
}
|
||
|
||
// ============================================
|
||
// Bucket Loading
|
||
// ============================================
|
||
|
||
async function loadBuckets() {
|
||
const tbody = document.getElementById('buckets-table');
|
||
tbody.innerHTML = `
|
||
<tr>
|
||
<td colspan="2" class="py-12 text-center">
|
||
<svg class="animate-spin h-8 w-8 text-accent mx-auto" fill="none" viewBox="0 0 24 24">
|
||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
</svg>
|
||
<p class="text-charcoal-300 mt-4">Loading buckets...</p>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
|
||
try {
|
||
if (api.isAdmin()) {
|
||
// Admin: use admin API to get all buckets with owner info
|
||
bucketsList = await api.listBuckets();
|
||
} else {
|
||
// Regular user: use S3 API to get accessible buckets
|
||
bucketsList = await api.listBucketsS3();
|
||
}
|
||
|
||
// Load versioning status for all buckets
|
||
await loadAllBucketsVersioning();
|
||
|
||
// Load object lock configuration for all buckets
|
||
await loadAllBucketsObjectLock();
|
||
|
||
// If we have a bucket from URL, show it
|
||
if (currentBucket) {
|
||
showObjectsView();
|
||
await loadObjects();
|
||
} else {
|
||
showBucketsView();
|
||
renderBuckets();
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading buckets:', error);
|
||
showToast('Error loading buckets: ' + error.message, 'error');
|
||
tbody.innerHTML = `
|
||
<tr>
|
||
<td colspan="2" class="py-12 text-center">
|
||
<svg class="w-16 h-16 text-red-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||
</svg>
|
||
<p class="text-lg font-medium text-charcoal">Error loading buckets</p>
|
||
<p class="text-sm text-charcoal-300 mt-1">${escapeHtml(error.message)}</p>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
}
|
||
|
||
async function loadAllBucketsVersioning() {
|
||
const promises = bucketsList.map(async (bucket) => {
|
||
const name = bucket.name || bucket.Name;
|
||
try {
|
||
const result = await api.getBucketVersioning(name);
|
||
bucketsVersioningCache[name] = result.status || '';
|
||
} catch (error) {
|
||
console.error(`Error loading versioning for ${name}:`, error);
|
||
bucketsVersioningCache[name] = 'Unknown';
|
||
}
|
||
});
|
||
await Promise.all(promises);
|
||
}
|
||
|
||
async function loadAllBucketsObjectLock() {
|
||
const promises = bucketsList.map(async (bucket) => {
|
||
const name = bucket.name || bucket.Name;
|
||
try {
|
||
const result = await api.getBucketObjectLockConfiguration(name);
|
||
bucketsObjectLockCache[name] = result;
|
||
} catch (error) {
|
||
console.error(`Error loading object lock for ${name}:`, error);
|
||
bucketsObjectLockCache[name] = { enabled: false, mode: '', days: null, years: null };
|
||
}
|
||
});
|
||
await Promise.all(promises);
|
||
}
|
||
|
||
function renderBuckets() {
|
||
const tbody = document.getElementById('buckets-table');
|
||
const createdHeader = document.getElementById('buckets-created-header');
|
||
const createdCol = document.getElementById('buckets-created-col');
|
||
tbody.innerHTML = '';
|
||
|
||
if (bucketsList.length === 0) {
|
||
createdHeader.style.display = 'none';
|
||
if (createdCol) createdCol.style.display = 'none';
|
||
tbody.innerHTML = `
|
||
<tr>
|
||
<td colspan="5" class="py-12 text-center">
|
||
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
|
||
</svg>
|
||
<p class="text-lg font-medium text-charcoal">No buckets available</p>
|
||
<p class="text-sm text-charcoal-300 mt-1">You don't have access to any buckets</p>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
// Check if any bucket has a creation date
|
||
const hasAnyCreationDate = bucketsList.some(bucket => {
|
||
const creationDate = bucket.creationdate || bucket.CreationDate;
|
||
return creationDate != null;
|
||
});
|
||
|
||
// Show/hide the Created column header and column
|
||
if (hasAnyCreationDate) {
|
||
createdHeader.style.display = '';
|
||
if (createdCol) createdCol.style.display = '';
|
||
} else {
|
||
createdHeader.style.display = 'none';
|
||
if (createdCol) createdCol.style.display = 'none';
|
||
}
|
||
|
||
const favoritesSet = new Set(loadFavorites());
|
||
|
||
bucketsList.forEach(bucket => {
|
||
const name = bucket.name || bucket.Name;
|
||
const creationDate = bucket.creationdate || bucket.CreationDate;
|
||
const versioningStatus = bucketsVersioningCache[name] || '';
|
||
const objectLockConfig = bucketsObjectLockCache[name] || { enabled: false };
|
||
const starred = favoritesSet.has(name);
|
||
const row = document.createElement('tr');
|
||
row.className = 'file-row border-b border-gray-50';
|
||
row.innerHTML = `
|
||
<td class="py-3 px-4 cursor-pointer" onclick="selectBucket('${escapeHtml(name)}')">
|
||
<div class="flex items-center gap-3">
|
||
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
|
||
</svg>
|
||
<span class="font-mono text-sm text-charcoal">${escapeHtml(name)}</span>
|
||
</div>
|
||
</td>
|
||
<td class="py-3 px-4">
|
||
${getVersioningBadge(versioningStatus)}
|
||
</td>
|
||
<td class="py-3 px-4">
|
||
${getObjectLockBadge(objectLockConfig)}
|
||
</td>
|
||
${hasAnyCreationDate ? `<td class="py-3 px-4 text-charcoal-300 text-sm">${creationDate ? new Date(creationDate).toLocaleDateString() : '-'}</td>` : ''}
|
||
<td class="py-3 px-4 text-center">
|
||
<div class="flex items-center justify-center gap-1">
|
||
<button onclick="event.stopPropagation(); toggleFavorite('${escapeHtml(name)}')" class="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs ${starred ? 'text-yellow-500 hover:text-yellow-600' : 'text-charcoal-300 hover:text-yellow-500'} hover:bg-yellow-50 rounded transition-colors" title="${starred ? 'Remove from favorites' : 'Add to favorites'}">
|
||
<svg class="w-3.5 h-3.5" fill="${starred ? 'currentColor' : 'none'}" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>
|
||
</svg>
|
||
</button>
|
||
<button onclick="event.stopPropagation(); openBucketInfoModal('${escapeHtml(name)}')" class="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs text-charcoal-400 hover:text-charcoal hover:bg-gray-100 rounded transition-colors" title="Bucket info">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
</button>
|
||
<button onclick="event.stopPropagation(); openBucketPolicyModal('${escapeHtml(name)}')" class="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs text-charcoal-400 hover:text-charcoal hover:bg-gray-100 rounded transition-colors" title="Manage policy">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||
</svg>
|
||
</button>
|
||
<button onclick="event.stopPropagation(); openVersioningModalForBucket('${escapeHtml(name)}')" class="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs text-charcoal-400 hover:text-charcoal hover:bg-gray-100 rounded transition-colors" title="Configure versioning">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
</button>
|
||
<button onclick="event.stopPropagation(); openObjectLockModalForBucket('${escapeHtml(name)}')" class="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs text-charcoal-400 hover:text-charcoal hover:bg-gray-100 rounded transition-colors" title="Configure object lock">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||
</svg>
|
||
</button>
|
||
<button onclick="event.stopPropagation(); openMultipartUploadsModalForBucket('${escapeHtml(name)}')" class="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs text-charcoal-400 hover:text-charcoal hover:bg-gray-100 rounded transition-colors" title="Multipart uploads">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17h6m-3-3v6m-4 1h10a2 2 0 002-2V7a2 2 0 00-2-2h-4l-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 13h6"/>
|
||
</svg>
|
||
</button>
|
||
<button onclick="event.stopPropagation(); confirmDeleteBucket('${escapeHtml(name)}')" class="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs text-red-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors" title="Delete bucket">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
`;
|
||
tbody.appendChild(row);
|
||
});
|
||
}
|
||
|
||
async function selectBucket(bucketName) {
|
||
currentBucket = bucketName;
|
||
currentPrefix = '';
|
||
showVersions = false;
|
||
showObjectsView();
|
||
await checkBucketVersioning();
|
||
loadObjects();
|
||
updateUrl();
|
||
}
|
||
|
||
async function checkBucketVersioning() {
|
||
const btn = document.getElementById('versions-toggle-btn');
|
||
const text = document.getElementById('versions-toggle-text');
|
||
|
||
try {
|
||
const result = await api.getBucketVersioning(currentBucket);
|
||
currentBucketVersioningStatus = result.status || '';
|
||
|
||
// Show/hide versions toggle based on versioning status
|
||
if (currentBucketVersioningStatus === 'Enabled' || currentBucketVersioningStatus === 'Suspended') {
|
||
btn.classList.remove('hidden');
|
||
} else {
|
||
btn.classList.add('hidden');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error checking bucket versioning:', error);
|
||
currentBucketVersioningStatus = null;
|
||
btn.classList.add('hidden');
|
||
}
|
||
|
||
// Reset toggle state
|
||
showVersions = false;
|
||
text.textContent = 'Show Versions';
|
||
btn.classList.remove('bg-accent', 'text-white', 'border-accent');
|
||
btn.classList.add('border-gray-200', 'text-charcoal');
|
||
}
|
||
|
||
function showBucketsView() {
|
||
document.getElementById('buckets-view').classList.remove('hidden');
|
||
document.getElementById('objects-view').classList.add('hidden');
|
||
document.getElementById('header-actions').classList.add('hidden');
|
||
document.getElementById('search-container').classList.add('hidden');
|
||
clearSearch();
|
||
updateBreadcrumb();
|
||
renderFavoritesSection();
|
||
}
|
||
|
||
function showObjectsView() {
|
||
document.getElementById('buckets-view').classList.add('hidden');
|
||
document.getElementById('objects-view').classList.remove('hidden');
|
||
document.getElementById('header-actions').classList.remove('hidden');
|
||
document.getElementById('search-container').classList.remove('hidden');
|
||
}
|
||
|
||
function goToBuckets() {
|
||
currentBucket = null;
|
||
currentPrefix = '';
|
||
showBucketsView();
|
||
renderBuckets();
|
||
updateUrl();
|
||
}
|
||
|
||
async function selectBucketChecked(name) {
|
||
try {
|
||
await api.listObjectsV2(name, '', '/', 1);
|
||
} catch (e) {
|
||
showToast(`Cannot access bucket "${name}": ${e.message}`, 'error');
|
||
return false;
|
||
}
|
||
selectBucket(name);
|
||
return true;
|
||
}
|
||
|
||
async function goToBucketByName() {
|
||
const input = document.getElementById('goto-bucket-input');
|
||
const name = input.value.trim();
|
||
if (!name) {
|
||
showToast('Enter a bucket name first', 'warning');
|
||
return;
|
||
}
|
||
const ok = await selectBucketChecked(name);
|
||
if (ok) input.value = '';
|
||
}
|
||
|
||
function addInputBucketToFavorites() {
|
||
const input = document.getElementById('goto-bucket-input');
|
||
const name = input.value.trim();
|
||
if (!name) {
|
||
showToast('Enter a bucket name first', 'warning');
|
||
return;
|
||
}
|
||
addToFavorites(name);
|
||
}
|
||
|
||
// ============================================
|
||
// Favorites Management
|
||
// ============================================
|
||
|
||
function getFavoritesStorageKey() {
|
||
const info = api.getCredentialsInfo();
|
||
const key = (info && info.accessKey) ? info.accessKey : 'default';
|
||
return `explorer_favorites_${key}`;
|
||
}
|
||
|
||
function loadFavorites() {
|
||
try {
|
||
const raw = localStorage.getItem(getFavoritesStorageKey());
|
||
if (raw) return JSON.parse(raw);
|
||
} catch (e) {
|
||
console.error('Error loading favorites:', e);
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function saveFavorites(favorites) {
|
||
try {
|
||
localStorage.setItem(getFavoritesStorageKey(), JSON.stringify(favorites));
|
||
} catch (e) {
|
||
console.error('Error saving favorites:', e);
|
||
}
|
||
}
|
||
|
||
function isFavorite(name) {
|
||
return loadFavorites().includes(name);
|
||
}
|
||
|
||
function addToFavorites(name) {
|
||
const favs = loadFavorites();
|
||
if (favs.includes(name)) {
|
||
showToast(`"${name}" is already in favorites`, 'info');
|
||
return;
|
||
}
|
||
favs.push(name);
|
||
saveFavorites(favs);
|
||
renderFavoritesSection();
|
||
// Re-render bucket rows to update star icons
|
||
if (bucketsList.length > 0) renderBuckets();
|
||
showToast(`Added "${name}" to favorites`, 'success');
|
||
}
|
||
|
||
function removeFromFavorites(name) {
|
||
const favs = loadFavorites().filter(f => f !== name);
|
||
saveFavorites(favs);
|
||
renderFavoritesSection();
|
||
// Re-render bucket rows to update star icons
|
||
if (bucketsList.length > 0) renderBuckets();
|
||
showToast(`Removed "${name}" from favorites`, 'info');
|
||
}
|
||
|
||
function toggleFavorite(name) {
|
||
if (isFavorite(name)) {
|
||
removeFromFavorites(name);
|
||
} else {
|
||
addToFavorites(name);
|
||
}
|
||
}
|
||
|
||
function renderFavoritesSection() {
|
||
const section = document.getElementById('favorites-section');
|
||
const list = document.getElementById('favorites-list');
|
||
if (!section || !list) return;
|
||
|
||
const favs = loadFavorites();
|
||
|
||
if (favs.length === 0) {
|
||
section.classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
section.classList.remove('hidden');
|
||
list.innerHTML = favs.map(name => `
|
||
<div class="group inline-flex items-center gap-1 pl-3 pr-1.5 py-1.5 bg-yellow-50 border border-yellow-200 hover:border-yellow-300 rounded-lg transition-colors">
|
||
<button onclick="selectBucketChecked('${escapeHtml(name)}')" class="text-sm text-charcoal font-mono hover:text-accent transition-colors">
|
||
${escapeHtml(name)}
|
||
</button>
|
||
<button onclick="removeFromFavorites('${escapeHtml(name)}')" class="ml-1 p-0.5 text-charcoal-300 hover:text-red-500 hover:bg-red-50 rounded transition-colors opacity-0 group-hover:opacity-100" title="Remove from favorites">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function refresh() {
|
||
if (currentBucket) {
|
||
loadObjects();
|
||
} else {
|
||
loadBuckets();
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// Object Loading
|
||
// ============================================
|
||
|
||
async function loadObjects(continuationToken = null, isPageChange = false) {
|
||
if (!currentBucket) return;
|
||
|
||
showTableLoading('objects-table', 6);
|
||
clearSelection();
|
||
if (!isPageChange) {
|
||
currentPage = 1;
|
||
continuationTokens = [null];
|
||
}
|
||
updateBreadcrumb();
|
||
|
||
try {
|
||
const result = await api.listObjectsV2(currentBucket, currentPrefix, '/', pageSize, continuationToken);
|
||
isTruncated = result.isTruncated;
|
||
if (isTruncated && result.continuationToken) {
|
||
continuationTokens[currentPage] = result.continuationToken;
|
||
}
|
||
|
||
folders = result.commonPrefixes || [];
|
||
objects = result.contents || [];
|
||
|
||
// Filter out the current prefix itself (if it exists as an object)
|
||
objects = objects.filter(obj => obj.key !== currentPrefix);
|
||
|
||
// If versioning is enabled, fetch version counts for all objects
|
||
objectVersionCounts = {};
|
||
deletedObjectsWithVersions = [];
|
||
if (currentBucketVersioningStatus === 'Enabled' || currentBucketVersioningStatus === 'Suspended') {
|
||
try {
|
||
const versionsResult = await api.listObjectVersions(currentBucket, currentPrefix);
|
||
|
||
// Explicitly mark delete markers
|
||
const versions = versionsResult.versions.map(v => ({ ...v, isDeleteMarker: false }));
|
||
const deleteMarkers = versionsResult.deleteMarkers.map(dm => ({ ...dm, isDeleteMarker: true }));
|
||
const allVersions = [...versions, ...deleteMarkers];
|
||
|
||
// Group versions by key
|
||
const versionsByKey = {};
|
||
allVersions.forEach(v => {
|
||
// Only include objects in the current prefix level (not subdirectories)
|
||
if (!versionsByKey[v.key]) {
|
||
versionsByKey[v.key] = [];
|
||
}
|
||
versionsByKey[v.key].push(v);
|
||
});
|
||
|
||
// Count versions per key and identify deleted objects with older versions
|
||
Object.entries(versionsByKey).forEach(([key, versions]) => {
|
||
objectVersionCounts[key] = versions.length;
|
||
|
||
// Find the latest version (marked by isLatest flag from API)
|
||
const latestVersion = versions.find(v => v.isLatest === true);
|
||
const hasOlderVersions = versions.length > 1;
|
||
|
||
if (latestVersion && latestVersion.isDeleteMarker && hasOlderVersions) {
|
||
// Sort to find the most recent non-delete-marker version for display info
|
||
const nonDeleteMarkers = versions.filter(v => !v.isDeleteMarker);
|
||
nonDeleteMarkers.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
|
||
const latestRealVersion = nonDeleteMarkers[0];
|
||
|
||
deletedObjectsWithVersions.push({
|
||
key: key,
|
||
size: latestRealVersion?.size || 0,
|
||
lastModified: latestVersion.lastModified,
|
||
storageClass: latestRealVersion?.storageClass || 'STANDARD',
|
||
deleteMarkerVersionId: latestVersion.versionId
|
||
});
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.error('Error loading version counts:', error);
|
||
}
|
||
}
|
||
|
||
// When paginating, restrict deleted-with-versions objects to the current page's
|
||
// key range, since listObjectVersions returns all versions (not page-bounded).
|
||
if (isTruncated || currentPage > 1) {
|
||
const allPageKeys = [
|
||
...folders.map(f => f.prefix),
|
||
...objects.map(o => o.key)
|
||
].sort();
|
||
const pageFirstKey = allPageKeys[0];
|
||
const pageLastKey = allPageKeys[allPageKeys.length - 1];
|
||
deletedObjectsWithVersions = deletedObjectsWithVersions.filter(obj => {
|
||
if (currentPage > 1 && pageFirstKey && obj.key < pageFirstKey) return false;
|
||
if (isTruncated && pageLastKey && obj.key > pageLastKey) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
renderObjects();
|
||
return true;
|
||
} catch (error) {
|
||
console.error('Error loading objects:', error);
|
||
showToast('Error loading objects: ' + error.message, 'error');
|
||
showEmptyState('objects-table', 6, 'Error loading objects');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function refreshObjects() {
|
||
loadObjects();
|
||
}
|
||
|
||
// ============================================
|
||
// Rendering
|
||
// ============================================
|
||
|
||
function renderObjects() {
|
||
const tbody = document.getElementById('objects-table');
|
||
tbody.innerHTML = '';
|
||
|
||
// No bucket selected - shouldn't happen but safety check
|
||
if (!currentBucket) {
|
||
goToBuckets();
|
||
return;
|
||
}
|
||
|
||
// Apply search filter
|
||
let filteredFolders = folders;
|
||
let filteredObjects = objects;
|
||
let filteredDeletedObjects = deletedObjectsWithVersions;
|
||
|
||
if (searchTerm) {
|
||
filteredFolders = folders.filter(f => {
|
||
const name = getFolderName(f.prefix);
|
||
return name.toLowerCase().includes(searchTerm);
|
||
});
|
||
filteredObjects = objects.filter(o => {
|
||
const name = getFileName(o.key);
|
||
return name.toLowerCase().includes(searchTerm);
|
||
});
|
||
filteredDeletedObjects = deletedObjectsWithVersions.filter(o => {
|
||
const name = getFileName(o.key);
|
||
return name.toLowerCase().includes(searchTerm);
|
||
});
|
||
}
|
||
|
||
// Empty folder or no results
|
||
if (filteredFolders.length === 0 && filteredObjects.length === 0 && filteredDeletedObjects.length === 0) {
|
||
document.getElementById('objects-pagination').classList.add('hidden');
|
||
if (searchTerm) {
|
||
tbody.innerHTML = `
|
||
<tr>
|
||
<td colspan="6" class="py-12 text-center">
|
||
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||
</svg>
|
||
<p class="text-lg font-medium text-charcoal">No results found</p>
|
||
<p class="text-sm text-charcoal-300 mt-1">No items match "${escapeHtml(searchTerm)}"</p>
|
||
<button onclick="clearSearch()" class="mt-4 px-4 py-2 border border-gray-200 hover:bg-gray-50 text-charcoal font-medium rounded-lg transition-colors">
|
||
Clear Filter
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
} else {
|
||
// Empty folder (no search active)
|
||
tbody.innerHTML = `
|
||
<tr>
|
||
<td colspan="6" class="py-12 text-center">
|
||
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||
</svg>
|
||
<p class="text-lg font-medium text-charcoal">This folder is empty</p>
|
||
<p class="text-sm text-charcoal-300 mt-1">Upload files to get started</p>
|
||
<button onclick="openUploadDialog()" class="mt-4 px-4 py-2 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">
|
||
Upload Files
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Count after filtering (objects.filter above may have removed the prefix key)
|
||
const totalVisible = filteredFolders.length + filteredObjects.length + filteredDeletedObjects.length;
|
||
|
||
// Render folders first
|
||
filteredFolders.forEach(folder => {
|
||
const name = getFolderName(folder.prefix);
|
||
const row = document.createElement('tr');
|
||
row.className = 'file-row border-b border-gray-50 cursor-pointer';
|
||
row.onclick = (e) => {
|
||
if (!e.target.closest('input[type="checkbox"]') && !e.target.closest('button')) {
|
||
navigateToFolder(folder.prefix);
|
||
}
|
||
};
|
||
row.innerHTML = `
|
||
<td class="py-3 px-4">
|
||
<input type="checkbox" class="w-4 h-4 rounded border-gray-300" data-key="${escapeHtml(folder.prefix)}" data-type="folder" onchange="toggleSelect(this)">
|
||
</td>
|
||
<td class="py-3 px-4">
|
||
<div class="flex items-center gap-3">
|
||
<svg class="w-5 h-5 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>
|
||
</svg>
|
||
<span class="font-mono text-sm text-charcoal">${escapeHtml(name)}</span>
|
||
</div>
|
||
</td>
|
||
<td class="py-3 px-4 text-charcoal-300">-</td>
|
||
<td class="py-3 px-4 text-charcoal-300">-</td>
|
||
<td class="py-3 px-4 text-charcoal-300">-</td>
|
||
<td class="py-3 px-4 text-right">
|
||
<button onclick="event.stopPropagation(); deleteObject('${escapeHtml(folder.prefix)}', true)" class="p-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors" title="Delete folder">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
</button>
|
||
</td>
|
||
`;
|
||
tbody.appendChild(row);
|
||
});
|
||
|
||
// Render objects
|
||
filteredObjects.forEach(obj => {
|
||
const name = getFileName(obj.key);
|
||
const versionCount = objectVersionCounts[obj.key] || 0;
|
||
const hasMultipleVersions = versionCount > 1;
|
||
|
||
const row = document.createElement('tr');
|
||
row.className = 'file-row border-b border-gray-50';
|
||
row.innerHTML = `
|
||
<td class="py-3 px-4">
|
||
<input type="checkbox" class="w-4 h-4 rounded border-gray-300" data-key="${escapeHtml(obj.key)}" data-type="file" onchange="toggleSelect(this)">
|
||
</td>
|
||
<td class="py-3 px-4">
|
||
<div class="flex items-center gap-2">
|
||
${getFileIcon(name)}
|
||
<span class="font-mono text-sm text-charcoal">${escapeHtml(name)}</span>
|
||
${hasMultipleVersions ? `
|
||
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-50 text-blue-600 text-xs font-medium rounded" title="${versionCount} versions">
|
||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
${versionCount}
|
||
</span>
|
||
` : ''}
|
||
</div>
|
||
</td>
|
||
<td class="py-3 px-4 text-charcoal-300 font-mono text-sm">${formatSize(obj.size)}</td>
|
||
<td class="py-3 px-4 text-charcoal-300 text-sm">${formatDate(obj.lastModified)}</td>
|
||
<td class="py-3 px-4">
|
||
<span class="px-2 py-1 ${getStorageClassColor(obj.storageClass)} text-xs font-medium rounded">
|
||
${obj.storageClass || 'STANDARD'}
|
||
</span>
|
||
</td>
|
||
<td class="py-3 px-4 text-right">
|
||
<div class="flex items-center justify-end gap-1">
|
||
${hasMultipleVersions ? `
|
||
<button onclick="showObjectVersions('${escapeHtml(obj.key)}')" class="p-1.5 text-blue-500 hover:bg-blue-50 rounded-lg transition-colors" title="View versions">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
</button>
|
||
` : ''}
|
||
<button onclick="showObjectInfo('${escapeHtml(obj.key)}')" class="p-1.5 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors" title="Info">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
</button>
|
||
<button onclick="downloadObject('${escapeHtml(obj.key)}')" class="p-1.5 text-accent hover:bg-accent-50 rounded-lg transition-colors" title="Download">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||
</svg>
|
||
</button>
|
||
<button onclick="showPresignedUrl('${escapeHtml(obj.key)}')" class="p-1.5 text-accent hover:bg-blue-50 rounded-lg transition-colors" title="Generate presigned URL">
|
||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z" />
|
||
</svg>
|
||
</button>
|
||
<button onclick="deleteObject('${escapeHtml(obj.key)}')" class="p-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors" title="Delete">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
`;
|
||
tbody.appendChild(row);
|
||
});
|
||
|
||
// Render deleted objects that still have versions
|
||
filteredDeletedObjects.forEach(obj => {
|
||
const name = getFileName(obj.key);
|
||
const versionCount = objectVersionCounts[obj.key] || 0;
|
||
|
||
const row = document.createElement('tr');
|
||
row.className = 'file-row border-b border-gray-50 opacity-60';
|
||
row.innerHTML = `
|
||
<td class="py-3 px-4">
|
||
<input type="checkbox" class="w-4 h-4 rounded border-gray-300" data-key="${escapeHtml(obj.key)}" data-type="file" onchange="toggleSelect(this)">
|
||
</td>
|
||
<td class="py-3 px-4">
|
||
<div class="flex items-center gap-2">
|
||
${getFileIcon(name)}
|
||
<span class="font-mono text-sm text-charcoal line-through">${escapeHtml(name)}</span>
|
||
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-red-50 text-red-600 text-xs font-medium rounded" title="Deleted but has ${versionCount} older versions">
|
||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
Deleted
|
||
</span>
|
||
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-50 text-blue-600 text-xs font-medium rounded" title="${versionCount} versions available">
|
||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
${versionCount}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td class="py-3 px-4 text-charcoal-300 font-mono text-sm">${formatSize(obj.size)}</td>
|
||
<td class="py-3 px-4 text-charcoal-300 text-sm">${formatDate(obj.lastModified)}</td>
|
||
<td class="py-3 px-4">
|
||
<span class="px-2 py-1 ${getStorageClassColor(obj.storageClass)} text-xs font-medium rounded">
|
||
${obj.storageClass || 'STANDARD'}
|
||
</span>
|
||
</td>
|
||
<td class="py-3 px-4 text-right">
|
||
<div class="flex items-center justify-end gap-1">
|
||
<button onclick="showObjectVersions('${escapeHtml(obj.key)}')" class="p-1.5 text-blue-500 hover:bg-blue-50 rounded-lg transition-colors" title="View ${versionCount} older versions">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
</button>
|
||
<button onclick="restoreDeletedObject('${escapeHtml(obj.key)}')" class="p-1.5 text-green-500 hover:bg-green-50 rounded-lg transition-colors" title="Restore latest version">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||
</svg>
|
||
</button>
|
||
<button onclick="permanentlyDeleteObject('${escapeHtml(obj.key)}', '${escapeHtml(obj.deleteMarkerVersionId)}')" class="p-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors" title="Permanently delete (remove delete marker)">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
`;
|
||
tbody.appendChild(row);
|
||
});
|
||
|
||
// Show and update pagination bar
|
||
updatePagination(totalVisible);
|
||
}
|
||
|
||
// ============================================
|
||
// Pagination
|
||
// ============================================
|
||
|
||
function updatePagination(itemCount) {
|
||
const bar = document.getElementById('objects-pagination');
|
||
bar.classList.remove('hidden');
|
||
|
||
const startItem = (currentPage - 1) * pageSize + 1;
|
||
const endItem = (currentPage - 1) * pageSize + itemCount;
|
||
const searchNote = searchTerm ? ' (filtered)' : '';
|
||
document.getElementById('pagination-info').textContent =
|
||
itemCount > 0 ? `${startItem}–${endItem}${isTruncated ? '+' : ''} items${searchNote}` : '';
|
||
document.getElementById('pagination-pages').textContent =
|
||
`Page ${currentPage}${isTruncated ? '' : ' (last)'}`;
|
||
|
||
const firstBtn = document.getElementById('pagination-first');
|
||
const prevBtn = document.getElementById('pagination-prev');
|
||
const nextBtn = document.getElementById('pagination-next');
|
||
|
||
const isFirst = currentPage <= 1;
|
||
const isLast = !isTruncated || !!searchTerm; // disable forward nav while search is active
|
||
firstBtn.disabled = isFirst;
|
||
prevBtn.disabled = isFirst;
|
||
nextBtn.disabled = isLast;
|
||
|
||
// Sync the select to the current pageSize
|
||
const select = document.getElementById('page-size-select');
|
||
if (select) select.value = String(pageSize);
|
||
}
|
||
|
||
async function changePage(page) {
|
||
if (page < 1) return false;
|
||
if (page === currentPage) return false;
|
||
if (page > currentPage && !isTruncated) return false;
|
||
const token = continuationTokens[page - 1];
|
||
if (token === undefined) return false; // token not yet known; can't jump
|
||
|
||
const previousPage = currentPage;
|
||
currentPage = page;
|
||
const loaded = await loadObjects(token, true);
|
||
if (!loaded) {
|
||
currentPage = previousPage;
|
||
renderObjects();
|
||
}
|
||
|
||
return loaded;
|
||
}
|
||
|
||
async function changePageSize(size) {
|
||
if (size === pageSize) return false;
|
||
|
||
const previousPageSize = pageSize;
|
||
const previousPage = currentPage;
|
||
const previousTokens = [...continuationTokens];
|
||
|
||
pageSize = size;
|
||
currentPage = 1;
|
||
continuationTokens = [null];
|
||
const loaded = await loadObjects();
|
||
if (!loaded) {
|
||
pageSize = previousPageSize;
|
||
currentPage = previousPage;
|
||
continuationTokens = previousTokens;
|
||
renderObjects();
|
||
}
|
||
|
||
return loaded;
|
||
}
|
||
|
||
async function reloadFirstPageForSearch() {
|
||
if (isReloadingFirstPageForSearch) return;
|
||
|
||
const previousTokens = [...continuationTokens];
|
||
|
||
isReloadingFirstPageForSearch = true;
|
||
continuationTokens = [null];
|
||
try {
|
||
const loaded = await changePage(1);
|
||
if (!loaded) {
|
||
continuationTokens = previousTokens;
|
||
}
|
||
} finally {
|
||
isReloadingFirstPageForSearch = false;
|
||
}
|
||
}
|
||
|
||
|
||
// ============================================
|
||
// Breadcrumb Navigation
|
||
// ===========================-=================
|
||
|
||
function updateBreadcrumb() {
|
||
const nav = document.getElementById('breadcrumb');
|
||
|
||
if (!currentBucket) {
|
||
nav.innerHTML = '<span class="text-charcoal font-medium">Buckets</span>';
|
||
return;
|
||
}
|
||
|
||
let html = `
|
||
<button onclick="goToBuckets()" class="text-accent hover:text-accent-600 font-medium">
|
||
Buckets
|
||
</button>
|
||
<svg class="w-4 h-4 text-charcoal-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||
</svg>
|
||
`;
|
||
|
||
if (!currentPrefix) {
|
||
html += `
|
||
<span class="text-charcoal font-medium">${escapeHtml(currentBucket)}</span>
|
||
<button onclick="openMultipartUploadsModal()" class="ml-2 p-1.5 text-charcoal-300 hover:text-accent hover:bg-gray-100 rounded-lg transition-colors" title="View multipart uploads">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 13h6m-3-3v6m5 1H7a2 2 0 01-2-2V7a2 2 0 012-2h3l2 2h5a2 2 0 012 2v1M7 13h10"/>
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17h6"/>
|
||
</svg>
|
||
</button>
|
||
`;
|
||
} else {
|
||
html += `
|
||
<button onclick="navigateToRoot()" class="text-accent hover:text-accent-600">
|
||
${escapeHtml(currentBucket)}
|
||
</button>
|
||
<button onclick="openMultipartUploadsModal()" class="ml-2 p-1.5 text-charcoal-300 hover:text-accent hover:bg-gray-100 rounded-lg transition-colors" title="View multipart uploads">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 13h6m-3-3v6m5 1H7a2 2 0 01-2-2V7a2 2 0 012-2h3l2 2h5a2 2 0 012 2v1M7 13h10"/>
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17h6"/>
|
||
</svg>
|
||
</button>
|
||
`;
|
||
const parts = currentPrefix.split('/').filter(p => p);
|
||
let path = '';
|
||
parts.forEach((part, index) => {
|
||
path += part + '/';
|
||
const isLast = index === parts.length - 1;
|
||
html += `
|
||
<svg class="w-4 h-4 text-charcoal-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||
</svg>
|
||
`;
|
||
if (isLast) {
|
||
html += `<span class="text-charcoal font-medium">${escapeHtml(part)}</span>`;
|
||
} else {
|
||
html += `<button onclick="navigateToPrefix('${escapeHtml(path)}')" class="text-accent hover:text-accent-600">${escapeHtml(part)}</button>`;
|
||
}
|
||
});
|
||
}
|
||
|
||
nav.innerHTML = html;
|
||
}
|
||
|
||
function navigateToRoot() {
|
||
currentPrefix = '';
|
||
loadObjects();
|
||
updateUrl();
|
||
}
|
||
|
||
function navigateToPrefix(prefix) {
|
||
currentPrefix = prefix;
|
||
loadObjects();
|
||
updateUrl();
|
||
}
|
||
|
||
function navigateToFolder(prefix) {
|
||
currentPrefix = prefix;
|
||
currentPage = 1;
|
||
loadObjects();
|
||
updateUrl();
|
||
}
|
||
|
||
function navigateUp() {
|
||
if (!currentPrefix) return;
|
||
const parts = currentPrefix.split('/').filter(p => p);
|
||
parts.pop();
|
||
currentPrefix = parts.length > 0 ? parts.join('/') + '/' : '';
|
||
loadObjects();
|
||
updateUrl();
|
||
}
|
||
|
||
function updateUrl() {
|
||
if (currentBucket) {
|
||
window.location.hash = currentBucket + '/' + currentPrefix;
|
||
} else {
|
||
window.location.hash = '';
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// Selection
|
||
// ============================================
|
||
|
||
function toggleSelect(checkbox) {
|
||
const key = checkbox.dataset.key;
|
||
if (checkbox.checked) {
|
||
selectedObjects.add(key);
|
||
} else {
|
||
selectedObjects.delete(key);
|
||
}
|
||
updateSelectionToolbar();
|
||
}
|
||
|
||
function toggleSelectAll() {
|
||
const checkbox = document.getElementById('select-all');
|
||
const checkboxes = document.querySelectorAll('#objects-table input[type="checkbox"]');
|
||
|
||
checkboxes.forEach(cb => {
|
||
cb.checked = checkbox.checked;
|
||
const key = cb.dataset.key;
|
||
if (checkbox.checked) {
|
||
selectedObjects.add(key);
|
||
} else {
|
||
selectedObjects.delete(key);
|
||
}
|
||
});
|
||
|
||
updateSelectionToolbar();
|
||
}
|
||
|
||
function clearSelection() {
|
||
selectedObjects.clear();
|
||
document.querySelectorAll('#objects-table input[type="checkbox"]').forEach(cb => cb.checked = false);
|
||
document.getElementById('select-all').checked = false;
|
||
updateSelectionToolbar();
|
||
}
|
||
|
||
function updateSelectionToolbar() {
|
||
const toolbar = document.getElementById('selection-toolbar');
|
||
const count = document.getElementById('selection-count');
|
||
|
||
if (selectedObjects.size > 0) {
|
||
toolbar.classList.remove('hidden');
|
||
count.textContent = `${selectedObjects.size} item${selectedObjects.size > 1 ? 's' : ''} selected`;
|
||
} else {
|
||
toolbar.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// Download
|
||
// ============================================
|
||
|
||
async function downloadObject(key) {
|
||
try {
|
||
showToast('Starting download...', 'info');
|
||
const result = await api.getObject(currentBucket, key);
|
||
const fileName = getFileName(key);
|
||
|
||
// Check if File System Access API is supported
|
||
if ('showSaveFilePicker' in window) {
|
||
try {
|
||
// Show save dialog with location picker
|
||
const fileHandle = await window.showSaveFilePicker({
|
||
suggestedName: fileName,
|
||
types: [{
|
||
description: 'Files',
|
||
accept: { '*/*': [getFileExtension(fileName)] }
|
||
}]
|
||
});
|
||
|
||
const writable = await fileHandle.createWritable();
|
||
await writable.write(result.blob);
|
||
await writable.close();
|
||
|
||
showToast('Download complete', 'success');
|
||
} catch (err) {
|
||
// User cancelled the dialog
|
||
if (err.name !== 'AbortError') {
|
||
console.error('Save dialog error:', err);
|
||
// Fall back to default download
|
||
fallbackDownload(result.blob, fileName);
|
||
}
|
||
}
|
||
} else {
|
||
// Fallback for browsers without File System Access API
|
||
fallbackDownload(result.blob, fileName);
|
||
}
|
||
} catch (error) {
|
||
console.error('Download error:', error);
|
||
showToast('Download failed: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
function fallbackDownload(blob, fileName) {
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = fileName;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
showToast('Download complete', 'success');
|
||
}
|
||
|
||
function getFileExtension(fileName) {
|
||
const lastDot = fileName.lastIndexOf('.');
|
||
return lastDot > 0 ? fileName.substring(lastDot) : '';
|
||
}
|
||
|
||
function downloadCurrentObject() {
|
||
if (currentObjectKey) {
|
||
downloadObject(currentObjectKey);
|
||
closeModal('info-modal');
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// Object Info
|
||
// ============================================
|
||
|
||
let objectTagsModified = [];
|
||
let objectMetadataModified = [];
|
||
let currentObjectContentType = null;
|
||
|
||
async function showObjectInfo(key) {
|
||
currentObjectKey = key;
|
||
document.getElementById('info-key').textContent = key;
|
||
document.getElementById('info-size').textContent = 'Loading...';
|
||
document.getElementById('info-modified').textContent = 'Loading...';
|
||
document.getElementById('info-type').textContent = 'Loading...';
|
||
document.getElementById('info-class').textContent = 'Loading...';
|
||
document.getElementById('info-etag').textContent = 'Loading...';
|
||
|
||
// Reset tags and metadata
|
||
objectTagsModified = [];
|
||
objectMetadataModified = [];
|
||
|
||
openModal('info-modal');
|
||
|
||
try {
|
||
// Get basic object info
|
||
const info = await api.headObject(currentBucket, key);
|
||
|
||
document.getElementById('info-size').textContent = formatSize(info.contentLength);
|
||
document.getElementById('info-modified').textContent = info.lastModified || '-';
|
||
document.getElementById('info-type').textContent = info.contentType || '-';
|
||
document.getElementById('info-class').textContent = info.storageClass || 'STANDARD';
|
||
document.getElementById('info-etag').textContent = info.etag || '-';
|
||
|
||
currentObjectContentType = info.contentType;
|
||
|
||
// Get object tags
|
||
let tags = [];
|
||
try {
|
||
tags = await api.getObjectTagging(currentBucket, key);
|
||
} catch (error) {
|
||
console.log('No tags or error fetching tags:', error.message);
|
||
}
|
||
|
||
// Store metadata as array of {key, value} objects
|
||
objectMetadataModified = Object.entries(info.metadata).map(([key, value]) => ({ key, value }));
|
||
|
||
// Store tags as working copy
|
||
objectTagsModified = [...tags];
|
||
|
||
// Render tags and metadata
|
||
renderObjectTags();
|
||
renderObjectMetadata();
|
||
} catch (error) {
|
||
console.error('Error fetching object info:', error);
|
||
showToast('Error fetching object info: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
function showPresignedUrl(key) {
|
||
currentObjectKey = key;
|
||
|
||
document.getElementById('presigned-url-result').classList.add('hidden');
|
||
document.getElementById('presigned-url-bucket').textContent = currentBucket;
|
||
document.getElementById('presigned-url-key').textContent = key;
|
||
document.getElementById('presigned-url-exp-value').value = '1';
|
||
document.getElementById('presigned-url-exp-unit').value = 'minutes';
|
||
|
||
openModal('presigned-url-modal');
|
||
}
|
||
|
||
async function generatePresignedUrl() {
|
||
if (!currentBucket || !currentObjectKey) {
|
||
return;
|
||
}
|
||
|
||
document.getElementById('presigned-url-btn').setAttribute('disabled', '')
|
||
|
||
let expirationSecs = 0;
|
||
|
||
const expirationValue = parseInt(document.getElementById('presigned-url-exp-value').value);
|
||
|
||
if (isNaN(expirationValue) || expirationValue <= 0) {
|
||
document.getElementById('presigned-url-url').value = '-';
|
||
document.getElementById('presigned-url-btn').removeAttribute('disabled')
|
||
return;
|
||
}
|
||
|
||
switch (document.getElementById('presigned-url-exp-unit').value) {
|
||
case 'minutes':
|
||
expirationSecs = expirationValue * 60;
|
||
break;
|
||
case 'hours':
|
||
expirationSecs = expirationValue * 3600;
|
||
break;
|
||
case 'days':
|
||
expirationSecs = expirationValue * 3600 * 24;
|
||
break;
|
||
default:
|
||
expirationSecs = expirationValue;
|
||
}
|
||
|
||
try {
|
||
const url = await api.presignUrl('GET', `/${currentBucket}/${currentObjectKey}`, {}, expirationSecs);
|
||
|
||
document.getElementById('presigned-url-result').classList.remove('hidden');
|
||
document.getElementById('presigned-url-url').value = url;
|
||
|
||
showToast('Presigned URL generated')
|
||
} catch (e) {
|
||
console.error('Error generating presigned URL:', e);
|
||
showToast('An error occurred generating presigned URL', 'error');
|
||
}
|
||
|
||
document.getElementById('presigned-url-btn').removeAttribute('disabled')
|
||
}
|
||
|
||
async function copyPresignedUrl() {
|
||
const url = document.getElementById('presigned-url-url').value;
|
||
|
||
if (url === '-') {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(url);
|
||
showToast('Presigned URL copied to your clipboard');
|
||
} catch (e) {
|
||
console.error('Error copying presigned URL to clipboard:', e);
|
||
showToast('An error occurred while copying the presigned URL to your clipboard', 'error');
|
||
}
|
||
}
|
||
|
||
function renderObjectTags() {
|
||
const tagsList = document.getElementById('object-tags-list');
|
||
|
||
if (objectTagsModified.length === 0) {
|
||
tagsList.innerHTML = '<p class="text-charcoal-300 text-sm py-4 text-center">No tags</p>';
|
||
return;
|
||
}
|
||
|
||
tagsList.innerHTML = objectTagsModified.map((tag, index) => `
|
||
<div class="flex items-center gap-2 p-3 border border-gray-200 rounded-lg bg-gray-50">
|
||
<div class="flex-1 grid grid-cols-2 gap-2">
|
||
<input
|
||
type="text"
|
||
value="${escapeHtml(tag.key)}"
|
||
placeholder="Key"
|
||
class="px-3 py-1.5 border border-gray-200 rounded text-sm focus:outline-none focus:border-accent"
|
||
onchange="updateObjectTagKey(${index}, this.value)"
|
||
>
|
||
<input
|
||
type="text"
|
||
value="${escapeHtml(tag.value)}"
|
||
placeholder="Value"
|
||
class="px-3 py-1.5 border border-gray-200 rounded text-sm focus:outline-none focus:border-accent"
|
||
onchange="updateObjectTagValue(${index}, this.value)"
|
||
>
|
||
</div>
|
||
<button
|
||
onclick="removeObjectTag(${index})"
|
||
class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||
title="Remove tag"
|
||
>
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function renderObjectMetadata() {
|
||
const metadataList = document.getElementById('object-metadata-list');
|
||
|
||
if (objectMetadataModified.length === 0) {
|
||
metadataList.innerHTML = '<p class="text-charcoal-300 text-sm py-4 text-center">No metadata</p>';
|
||
return;
|
||
}
|
||
|
||
metadataList.innerHTML = objectMetadataModified.map((meta, index) => `
|
||
<div class="flex items-center gap-2 p-3 border border-gray-200 rounded-lg bg-gray-50">
|
||
<div class="flex-1 grid grid-cols-2 gap-2">
|
||
<input
|
||
type="text"
|
||
value="${escapeHtml(meta.key)}"
|
||
placeholder="Key"
|
||
class="px-3 py-1.5 border border-gray-200 rounded text-sm focus:outline-none focus:border-accent"
|
||
onchange="updateObjectMetadataKey(${index}, this.value)"
|
||
>
|
||
<input
|
||
type="text"
|
||
value="${escapeHtml(meta.value)}"
|
||
placeholder="Value"
|
||
class="px-3 py-1.5 border border-gray-200 rounded text-sm focus:outline-none focus:border-accent"
|
||
onchange="updateObjectMetadataValue(${index}, this.value)"
|
||
>
|
||
</div>
|
||
<button
|
||
onclick="removeObjectMetadata(${index})"
|
||
class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||
title="Remove metadata"
|
||
>
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function addObjectTag() {
|
||
objectTagsModified.push({ key: '', value: '' });
|
||
renderObjectTags();
|
||
}
|
||
|
||
function removeObjectTag(index) {
|
||
objectTagsModified.splice(index, 1);
|
||
renderObjectTags();
|
||
}
|
||
|
||
function updateObjectTagKey(index, key) {
|
||
objectTagsModified[index].key = key;
|
||
}
|
||
|
||
function updateObjectTagValue(index, value) {
|
||
objectTagsModified[index].value = value;
|
||
}
|
||
|
||
function addObjectMetadata() {
|
||
objectMetadataModified.push({ key: '', value: '' });
|
||
renderObjectMetadata();
|
||
}
|
||
|
||
function removeObjectMetadata(index) {
|
||
objectMetadataModified.splice(index, 1);
|
||
renderObjectMetadata();
|
||
}
|
||
|
||
function updateObjectMetadataKey(index, key) {
|
||
objectMetadataModified[index].key = key;
|
||
}
|
||
|
||
function updateObjectMetadataValue(index, value) {
|
||
objectMetadataModified[index].value = value;
|
||
}
|
||
|
||
async function saveObjectTags() {
|
||
if (!currentObjectKey) return;
|
||
|
||
const btn = document.getElementById('save-object-tags-btn');
|
||
setLoading(btn, true);
|
||
|
||
try {
|
||
// Save tags
|
||
const validTags = objectTagsModified.filter(tag => tag.key && tag.value);
|
||
await api.putObjectTagging(currentBucket, currentObjectKey, validTags);
|
||
|
||
showToast('Object tags saved successfully', 'success');
|
||
} catch (error) {
|
||
console.error('Error saving object tags:', error);
|
||
showToast('Error saving tags: ' + error.message, 'error');
|
||
} finally {
|
||
setLoading(btn, false);
|
||
}
|
||
}
|
||
|
||
async function saveObjectMetadata() {
|
||
if (!currentObjectKey) return;
|
||
|
||
const btn = document.getElementById('save-object-metadata-btn');
|
||
setLoading(btn, true);
|
||
|
||
try {
|
||
// Save metadata - convert array back to object and filter out empty keys
|
||
const metadataObj = {};
|
||
objectMetadataModified.forEach(meta => {
|
||
if (meta.key) {
|
||
metadataObj[meta.key] = meta.value;
|
||
}
|
||
});
|
||
await api.putObjectMetadata(currentBucket, currentObjectKey, metadataObj, currentObjectContentType);
|
||
|
||
showToast('Object metadata saved successfully', 'success');
|
||
|
||
// Refresh the object list to show updated info
|
||
await refresh();
|
||
} catch (error) {
|
||
console.error('Error saving object metadata:', error);
|
||
showToast('Error saving metadata: ' + error.message, 'error');
|
||
} finally {
|
||
setLoading(btn, false);
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// Object Versions
|
||
// ============================================
|
||
|
||
async function showObjectVersions(key) {
|
||
document.getElementById('versions-object-key').textContent = key;
|
||
document.getElementById('versions-loading').classList.remove('hidden');
|
||
document.getElementById('versions-content').innerHTML = '';
|
||
openModal('versions-modal');
|
||
|
||
try {
|
||
// Fetch all versions for this specific key
|
||
const result = await api.listObjectVersions(currentBucket, key, '');
|
||
|
||
// Filter to only include versions for this exact key (not prefixes)
|
||
const versions = [
|
||
...result.versions.filter(v => v.key === key).map(v => ({ ...v, isDeleteMarker: false })),
|
||
...result.deleteMarkers.filter(dm => dm.key === key).map(dm => ({ ...dm, isDeleteMarker: true, size: 0 }))
|
||
];
|
||
|
||
// Sort by lastModified (newest first)
|
||
versions.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
|
||
|
||
document.getElementById('versions-loading').classList.add('hidden');
|
||
renderVersionsList(versions);
|
||
} catch (error) {
|
||
console.error('Error loading versions:', error);
|
||
document.getElementById('versions-loading').classList.add('hidden');
|
||
document.getElementById('versions-content').innerHTML = `
|
||
<div class="text-center py-8">
|
||
<svg class="w-12 h-12 text-red-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
<p class="text-charcoal-300">Error loading versions: ${escapeHtml(error.message)}</p>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
function renderVersionsList(versions) {
|
||
const container = document.getElementById('versions-content');
|
||
|
||
if (versions.length === 0) {
|
||
container.innerHTML = `
|
||
<div class="text-center py-8">
|
||
<svg class="w-12 h-12 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
<p class="text-charcoal-300">No versions found</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = versions.map((version, index) => {
|
||
const isLatestBadge = version.isLatest ? `
|
||
<span class="inline-flex items-center px-2 py-0.5 bg-green-50 text-green-700 text-xs font-medium rounded">
|
||
Latest
|
||
</span>
|
||
` : '';
|
||
|
||
const deleteMarkerBadge = version.isDeleteMarker ? `
|
||
<span class="inline-flex items-center px-2 py-0.5 bg-red-50 text-red-700 text-xs font-medium rounded">
|
||
Delete Marker
|
||
</span>
|
||
` : '';
|
||
|
||
return `
|
||
<div class="border border-gray-200 rounded-lg p-4 hover:border-accent transition-colors">
|
||
<div class="flex items-start justify-between gap-4">
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<span class="text-xs font-mono text-charcoal-400 truncate" title="${escapeHtml(version.versionId)}">
|
||
${escapeHtml(version.versionId.substring(0, 24))}...
|
||
</span>
|
||
${isLatestBadge}
|
||
${deleteMarkerBadge}
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||
<div>
|
||
<span class="text-charcoal-300">Modified:</span>
|
||
<span class="text-charcoal ml-1">${formatDate(version.lastModified)}</span>
|
||
</div>
|
||
${!version.isDeleteMarker ? `
|
||
<div>
|
||
<span class="text-charcoal-300">Size:</span>
|
||
<span class="text-charcoal ml-1">${formatSize(version.size)}</span>
|
||
</div>
|
||
<div>
|
||
<span class="text-charcoal-300">Storage Class:</span>
|
||
<span class="text-charcoal ml-1">${version.storageClass || 'STANDARD'}</span>
|
||
</div>
|
||
<div>
|
||
<span class="text-charcoal-300">ETag:</span>
|
||
<span class="text-charcoal ml-1 font-mono text-xs truncate" title="${escapeHtml(version.etag || '')}">${escapeHtml((version.etag || '').substring(0, 16))}...</span>
|
||
</div>
|
||
` : '<div class="col-span-2 text-charcoal-300 text-sm">This version was deleted</div>'}
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-1 flex-shrink-0">
|
||
${!version.isDeleteMarker ? `
|
||
<button onclick="downloadObjectVersion('${escapeHtml(version.key)}', '${escapeHtml(version.versionId)}')" class="p-2 text-accent hover:bg-accent-50 rounded-lg transition-colors" title="Download this version">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||
</svg>
|
||
</button>
|
||
` : ''}
|
||
<button onclick="deleteSpecificVersion('${escapeHtml(version.key)}', '${escapeHtml(version.versionId)}', ${version.isLatest})" class="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors" title="Delete this version">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
async function downloadObjectVersion(key, versionId) {
|
||
try {
|
||
showToast('Downloading version...', 'info');
|
||
const result = await api.getObjectVersion(currentBucket, key, versionId);
|
||
const fileName = getFileName(key);
|
||
|
||
// Check if File System Access API is supported
|
||
if ('showSaveFilePicker' in window) {
|
||
try {
|
||
// Show save dialog with location picker
|
||
const fileHandle = await window.showSaveFilePicker({
|
||
suggestedName: fileName,
|
||
types: [{
|
||
description: 'Files',
|
||
accept: { '*/*': [getFileExtension(fileName)] }
|
||
}]
|
||
});
|
||
|
||
const writable = await fileHandle.createWritable();
|
||
await writable.write(result.blob);
|
||
await writable.close();
|
||
|
||
showToast('Download complete', 'success');
|
||
} catch (err) {
|
||
// User cancelled the dialog
|
||
if (err.name !== 'AbortError') {
|
||
console.error('Save dialog error:', err);
|
||
// Fall back to default download
|
||
fallbackDownload(result.blob, fileName);
|
||
}
|
||
}
|
||
} else {
|
||
// Fallback for browsers without File System Access API
|
||
fallbackDownload(result.blob, fileName);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error downloading version:', error);
|
||
showToast('Error downloading version: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function deleteSpecificVersion(key, versionId, isLatest) {
|
||
const warningMessage = isLatest
|
||
? 'Are you sure you want to delete the latest version? This will make an older version the current one.'
|
||
: 'Are you sure you want to delete this version? This action cannot be undone.';
|
||
|
||
confirm(warningMessage, async () => {
|
||
try {
|
||
await api.deleteObjectVersion(currentBucket, key, versionId);
|
||
showToast('Version deleted successfully', 'success');
|
||
// Refresh the versions list
|
||
await showObjectVersions(key);
|
||
// Refresh the main object list to update version counts
|
||
loadObjects();
|
||
} catch (error) {
|
||
console.error('Error deleting version:', error);
|
||
showToast('Error deleting version: ' + error.message, 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
async function restoreDeletedObject(key) {
|
||
confirm('Restore this object? This will remove the delete marker and make the latest version visible again.', async () => {
|
||
try {
|
||
// Get all versions to find the delete marker
|
||
const result = await api.listObjectVersions(currentBucket, key, '');
|
||
const versions = [
|
||
...result.versions.filter(v => v.key === key),
|
||
...result.deleteMarkers.filter(dm => dm.key === key)
|
||
];
|
||
|
||
// Find the latest delete marker
|
||
versions.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
|
||
const deleteMarker = versions.find(v => v.isDeleteMarker && v.isLatest);
|
||
|
||
if (deleteMarker) {
|
||
// Delete the delete marker to restore the object
|
||
await api.deleteObjectVersion(currentBucket, key, deleteMarker.versionId);
|
||
showToast('Object restored successfully', 'success');
|
||
loadObjects();
|
||
} else {
|
||
showToast('No delete marker found to remove', 'warning');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error restoring object:', error);
|
||
showToast('Error restoring object: ' + error.message, 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
async function permanentlyDeleteObject(key, deleteMarkerVersionId) {
|
||
confirm('Permanently remove the delete marker? This will make the object visible again.', async () => {
|
||
try {
|
||
await api.deleteObjectVersion(currentBucket, key, deleteMarkerVersionId);
|
||
showToast('Delete marker removed', 'success');
|
||
loadObjects();
|
||
} catch (error) {
|
||
console.error('Error removing delete marker:', error);
|
||
showToast('Error removing delete marker: ' + error.message, 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ============================================
|
||
// Delete
|
||
// ============================================
|
||
|
||
let deleteKeys = [];
|
||
|
||
function deleteObject(key, isFolder = false) {
|
||
deleteKeys = [key];
|
||
const name = isFolder ? getFolderName(key) : getFileName(key);
|
||
document.getElementById('delete-message').textContent =
|
||
`Are you sure you want to delete "${name}"? This action cannot be undone.`;
|
||
openModal('delete-modal');
|
||
}
|
||
|
||
function deleteSelected() {
|
||
if (selectedObjects.size === 0) return;
|
||
deleteKeys = Array.from(selectedObjects);
|
||
document.getElementById('delete-message').textContent =
|
||
`Are you sure you want to delete ${deleteKeys.length} item${deleteKeys.length > 1 ? 's' : ''}? This action cannot be undone.`;
|
||
openModal('delete-modal');
|
||
}
|
||
|
||
function isFolderKey(key) {
|
||
return typeof key === 'string' && key.endsWith('/');
|
||
}
|
||
|
||
function getRootFolderSelections(keys) {
|
||
const folderKeys = Array.from(new Set((keys || []).filter(isFolderKey))).sort((a, b) => a.length - b.length);
|
||
const roots = [];
|
||
for (const folder of folderKeys) {
|
||
if (!roots.some(r => folder.startsWith(r))) {
|
||
roots.push(folder);
|
||
}
|
||
}
|
||
return roots;
|
||
}
|
||
|
||
function isUnderAnyFolder(key, folderPrefixes) {
|
||
return (folderPrefixes || []).some(prefix => typeof key === 'string' && key.startsWith(prefix));
|
||
}
|
||
|
||
function parseMultiDeleteResult(xmlText) {
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(xmlText || '', 'text/xml');
|
||
|
||
// If parsing failed, DOMParser returns a document with <parsererror>
|
||
if (doc.querySelector('parsererror')) {
|
||
return { deleted: 0, errors: [] };
|
||
}
|
||
|
||
// Namespace-agnostic element lookup for S3 XML responses (often include a default xmlns)
|
||
function elementsByTag(parent, tagName) {
|
||
if (!parent) return [];
|
||
const byNS = parent.getElementsByTagNameNS ? parent.getElementsByTagNameNS('*', tagName) : [];
|
||
if (byNS && byNS.length) return Array.from(byNS);
|
||
return Array.from(parent.getElementsByTagName(tagName) || []);
|
||
}
|
||
|
||
function firstText(parent, tagName) {
|
||
const els = elementsByTag(parent, tagName);
|
||
return els[0]?.textContent || '';
|
||
}
|
||
|
||
const deleted = elementsByTag(doc, 'Deleted').length;
|
||
const errors = elementsByTag(doc, 'Error').map(err => ({
|
||
key: firstText(err, 'Key'),
|
||
code: firstText(err, 'Code'),
|
||
message: firstText(err, 'Message')
|
||
}));
|
||
|
||
return { deleted, errors };
|
||
}
|
||
|
||
async function deleteInBatches(bucket, keys, batchSize = 1000) {
|
||
if (!keys || keys.length === 0) return 0;
|
||
let deletedCount = 0;
|
||
|
||
for (let i = 0; i < keys.length; i += batchSize) {
|
||
const batch = keys.slice(i, i + batchSize);
|
||
const resultXml = await api.deleteObjects(bucket, batch);
|
||
const result = parseMultiDeleteResult(resultXml);
|
||
if (result.errors.length > 0) {
|
||
const first = result.errors[0];
|
||
const msg = first.code
|
||
? `${first.code}${first.message ? `: ${first.message}` : ''}${first.key ? ` (${first.key})` : ''}`
|
||
: (first.message || 'Unknown delete error');
|
||
throw new Error(msg);
|
||
}
|
||
deletedCount += result.deleted;
|
||
}
|
||
|
||
return deletedCount;
|
||
}
|
||
|
||
async function deleteFolderRecursively(bucket, folderPrefix) {
|
||
// Post-order traversal: delete files in a folder, recurse into subfolders,
|
||
// then delete the folder marker object as we return.
|
||
const visited = new Set();
|
||
const stack = [{ prefix: folderPrefix, phase: 'enter' }];
|
||
let deletedCount = 0;
|
||
|
||
while (stack.length > 0) {
|
||
const frame = stack.pop();
|
||
const prefix = frame.prefix;
|
||
|
||
if (frame.phase === 'enter') {
|
||
if (!prefix || visited.has(prefix)) continue;
|
||
visited.add(prefix);
|
||
|
||
let markerSeen = false;
|
||
const childPrefixes = new Set();
|
||
const fileKeys = [];
|
||
|
||
let continuationToken = null;
|
||
while (true) {
|
||
// Reuse the same listing behavior as Explorer navigation (delimiter='/')
|
||
const result = await api.listObjectsV2(bucket, prefix, '/', 1000, continuationToken);
|
||
|
||
if (result?.contents?.length) {
|
||
for (const obj of result.contents) {
|
||
if (!obj?.key) continue;
|
||
if (obj.key === prefix && isFolderKey(obj.key)) {
|
||
markerSeen = true;
|
||
continue;
|
||
}
|
||
if (isFolderKey(obj.key)) {
|
||
// Folder marker objects can appear in Contents; treat them as child folders.
|
||
childPrefixes.add(obj.key);
|
||
continue;
|
||
}
|
||
fileKeys.push(obj.key);
|
||
}
|
||
}
|
||
|
||
if (result?.commonPrefixes?.length) {
|
||
for (const p of result.commonPrefixes) {
|
||
const childPrefix = typeof p === 'string' ? p : p?.prefix;
|
||
if (childPrefix) childPrefixes.add(childPrefix);
|
||
}
|
||
}
|
||
|
||
if (!result?.isTruncated || !result?.continuationToken) break;
|
||
continuationToken = result.continuationToken;
|
||
}
|
||
|
||
// Delete non-folder objects at this level first
|
||
deletedCount += await deleteInBatches(bucket, fileKeys);
|
||
|
||
// Ensure we delete marker after all children are deleted
|
||
stack.push({ prefix, phase: 'exit', markerSeen });
|
||
|
||
// Traverse children
|
||
const children = Array.from(childPrefixes).filter(p => p && p !== prefix);
|
||
// Reverse to keep a stable-ish traversal order with a stack
|
||
children.sort().reverse().forEach(child => stack.push({ prefix: child, phase: 'enter' }));
|
||
} else {
|
||
// Delete folder marker last. Only count it if it was observed during listing.
|
||
if (isFolderKey(prefix)) {
|
||
if (frame.markerSeen) {
|
||
deletedCount += await deleteInBatches(bucket, [prefix]);
|
||
} else {
|
||
// Marker might not exist; best-effort delete without inflating deleted count.
|
||
await api.deleteObject(bucket, prefix);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return deletedCount;
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
const btn = document.getElementById('confirm-delete-btn');
|
||
setLoading(btn, true);
|
||
|
||
try {
|
||
const rootFolderKeys = getRootFolderSelections(deleteKeys);
|
||
const fileKeys = (deleteKeys || []).filter(k => !isFolderKey(k) && !isUnderAnyFolder(k, rootFolderKeys));
|
||
|
||
let deletedCount = 0;
|
||
deletedCount += await deleteInBatches(currentBucket, fileKeys);
|
||
for (const folderPrefix of rootFolderKeys) {
|
||
deletedCount += await deleteFolderRecursively(currentBucket, folderPrefix);
|
||
}
|
||
|
||
showToast(
|
||
`Deleted ${deletedCount} object${deletedCount === 1 ? '' : 's'}${deleteKeys.length !== deletedCount ? ` (from ${deleteKeys.length} selection${deleteKeys.length === 1 ? '' : 's'})` : ''}`,
|
||
'success'
|
||
);
|
||
closeModal('delete-modal');
|
||
clearSelection();
|
||
loadObjects();
|
||
} catch (error) {
|
||
console.error('Delete error:', error);
|
||
showToast('Delete failed: ' + error.message, 'error');
|
||
} finally {
|
||
setLoading(btn, false);
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// Upload
|
||
// ============================================
|
||
|
||
function openUploadDialog() {
|
||
if (!currentBucket) {
|
||
showToast('Please select a bucket first', 'warning');
|
||
return;
|
||
}
|
||
document.getElementById('file-input').click();
|
||
}
|
||
|
||
async function handleFileSelect(event) {
|
||
const files = event.target.files;
|
||
if (files.length === 0) return;
|
||
|
||
await uploadFiles(Array.from(files));
|
||
event.target.value = ''; // Reset input
|
||
}
|
||
|
||
// Multipart upload threshold: 5MB (S3 minimum part size)
|
||
const MULTIPART_THRESHOLD = 5 * 1024 * 1024;
|
||
// Maximum parts allowed by VersityGW
|
||
const MAX_PARTS = 10000;
|
||
// Minimum part size per S3 spec (except last part)
|
||
const MIN_PART_SIZE = 5 * 1024 * 1024;
|
||
|
||
/**
|
||
* Calculate optimal part size based on file size
|
||
* - Minimum 5MB per S3 spec (except last part)
|
||
* - Increase if needed to stay within MAX_PARTS
|
||
*/
|
||
function calculatePartSize(fileSize) {
|
||
// Minimum part size needed to fit within MAX_PARTS
|
||
const minPartSizeForParts = Math.ceil(fileSize / MAX_PARTS);
|
||
// Use the larger of S3 minimum or what's needed for part count
|
||
return Math.max(MIN_PART_SIZE, minPartSizeForParts);
|
||
}
|
||
|
||
async function uploadFiles(files) {
|
||
let successCount = 0;
|
||
let failCount = 0;
|
||
|
||
// Show uploading toast for multiple files
|
||
if (files.length > 1) {
|
||
showToast(`Uploading ${files.length} files...`, 'info');
|
||
}
|
||
|
||
for (const file of files) {
|
||
const key = currentPrefix + file.name;
|
||
|
||
try {
|
||
if (file.size >= MULTIPART_THRESHOLD) {
|
||
// Use multipart upload for large files
|
||
await uploadMultipart(file, key);
|
||
} else {
|
||
// Use simple PUT for small files
|
||
await api.putObject(currentBucket, key, file);
|
||
}
|
||
successCount++;
|
||
} catch (error) {
|
||
console.error('Upload error:', error);
|
||
showToast(`Failed to upload ${file.name}: ${error.message}`, 'error');
|
||
failCount++;
|
||
}
|
||
}
|
||
|
||
if (successCount > 0) {
|
||
showToast(`Uploaded ${successCount} file${successCount > 1 ? 's' : ''}${failCount > 0 ? ` (${failCount} failed)` : ''}`, 'success');
|
||
loadObjects();
|
||
}
|
||
}
|
||
|
||
async function uploadMultipart(file, key) {
|
||
const contentType = file.type || 'application/octet-stream';
|
||
|
||
// Calculate optimal part size for this file
|
||
const partSize = calculatePartSize(file.size);
|
||
console.log(`Uploading ${file.name}: ${formatSize(file.size)}, part size: ${formatSize(partSize)}`);
|
||
|
||
// Initiate multipart upload
|
||
const uploadId = await api.createMultipartUpload(currentBucket, key, contentType);
|
||
|
||
const parts = [];
|
||
const totalParts = Math.ceil(file.size / partSize);
|
||
|
||
try {
|
||
for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
|
||
const start = (partNumber - 1) * partSize;
|
||
const end = Math.min(start + partSize, file.size);
|
||
const chunk = file.slice(start, end);
|
||
const chunkBuffer = await chunk.arrayBuffer();
|
||
|
||
const etag = await api.uploadPart(currentBucket, key, uploadId, partNumber, chunkBuffer);
|
||
parts.push({ partNumber, etag });
|
||
}
|
||
|
||
// Complete the multipart upload
|
||
await api.completeMultipartUpload(currentBucket, key, uploadId, parts);
|
||
} catch (error) {
|
||
// Abort the multipart upload on failure
|
||
try {
|
||
await api.abortMultipartUpload(currentBucket, key, uploadId);
|
||
} catch (abortError) {
|
||
console.error('Failed to abort multipart upload:', abortError);
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// Create Folder
|
||
// ============================================
|
||
|
||
function openCreateFolderDialog() {
|
||
if (!currentBucket) {
|
||
showToast('Please select a bucket first', 'warning');
|
||
return;
|
||
}
|
||
document.getElementById('new-folder-name').value = '';
|
||
document.getElementById('folder-location').textContent = currentBucket + '/' + currentPrefix;
|
||
openModal('create-folder-modal');
|
||
}
|
||
|
||
async function createFolder() {
|
||
const folderName = document.getElementById('new-folder-name').value.trim();
|
||
|
||
if (!folderName) {
|
||
showToast('Please enter a folder name', 'warning');
|
||
return;
|
||
}
|
||
|
||
// Validate folder name
|
||
if (folderName.includes('/')) {
|
||
showToast('Folder name cannot contain "/"', 'warning');
|
||
return;
|
||
}
|
||
|
||
const btn = document.getElementById('create-folder-btn');
|
||
setLoading(btn, true);
|
||
|
||
try {
|
||
const folderKey = currentPrefix + folderName;
|
||
await api.createFolder(currentBucket, folderKey);
|
||
showToast(`Folder "${folderName}" created`, 'success');
|
||
closeModal('create-folder-modal');
|
||
loadObjects();
|
||
} catch (error) {
|
||
console.error('Create folder error:', error);
|
||
showToast('Failed to create folder: ' + error.message, 'error');
|
||
} finally {
|
||
setLoading(btn, false);
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// Create Bucket
|
||
// ============================================
|
||
|
||
function openCreateBucketDialog() {
|
||
document.getElementById('new-bucket-name').value = '';
|
||
openModal('create-bucket-modal');
|
||
}
|
||
|
||
async function createBucket() {
|
||
const bucketName = document.getElementById('new-bucket-name').value.trim().toLowerCase();
|
||
|
||
if (!bucketName) {
|
||
showToast('Please enter a bucket name', 'warning');
|
||
return;
|
||
}
|
||
|
||
// Basic bucket name validation
|
||
if (bucketName.length < 3 || bucketName.length > 63) {
|
||
showToast('Bucket name must be between 3 and 63 characters', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (!/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(bucketName) && bucketName.length > 2) {
|
||
showToast('Bucket name must start and end with a letter or number', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (/[^a-z0-9.-]/.test(bucketName)) {
|
||
showToast('Bucket name can only contain lowercase letters, numbers, hyphens, and periods', 'warning');
|
||
return;
|
||
}
|
||
|
||
const btn = document.getElementById('create-bucket-btn');
|
||
setLoading(btn, true);
|
||
|
||
try {
|
||
await api.createBucket(bucketName);
|
||
showToast(`Bucket "${bucketName}" created successfully`, 'success');
|
||
closeModal('create-bucket-modal');
|
||
// Reload buckets list
|
||
await loadBuckets();
|
||
renderBuckets();
|
||
} catch (error) {
|
||
console.error('Create bucket error:', error);
|
||
showToast(error.message || 'Failed to create bucket', 'error');
|
||
} finally {
|
||
setLoading(btn, false);
|
||
}
|
||
}
|
||
|
||
// Delete Bucket Functions
|
||
let bucketToDelete = null;
|
||
|
||
function confirmDeleteBucket(bucketName) {
|
||
bucketToDelete = bucketName;
|
||
document.getElementById('delete-bucket-message').textContent =
|
||
`Are you sure you want to delete the bucket "${bucketName}"?`;
|
||
openModal('delete-bucket-modal');
|
||
}
|
||
|
||
async function confirmDeleteBucketAction() {
|
||
if (!bucketToDelete) return;
|
||
|
||
const btn = document.getElementById('confirm-delete-bucket-btn');
|
||
setLoading(btn, true);
|
||
|
||
try {
|
||
await api.deleteBucket(bucketToDelete);
|
||
showToast(`Bucket "${bucketToDelete}" deleted successfully`, 'success');
|
||
closeModal('delete-bucket-modal');
|
||
bucketToDelete = null;
|
||
|
||
// If we're currently viewing the deleted bucket, go back to buckets view
|
||
if (currentBucket === bucketToDelete) {
|
||
currentBucket = '';
|
||
currentPrefix = '';
|
||
showBucketsView();
|
||
}
|
||
|
||
// Reload buckets list
|
||
await loadBuckets();
|
||
renderBuckets();
|
||
} catch (error) {
|
||
console.error('Delete bucket error:', error);
|
||
showToast(error.message || 'Failed to delete bucket', 'error');
|
||
} finally {
|
||
setLoading(btn, false);
|
||
}
|
||
}
|
||
|
||
// Handle Enter key in create folder/bucket inputs
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const folderInput = document.getElementById('new-folder-name');
|
||
if (folderInput) {
|
||
folderInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
createFolder();
|
||
}
|
||
});
|
||
}
|
||
|
||
const bucketInput = document.getElementById('new-bucket-name');
|
||
if (bucketInput) {
|
||
bucketInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
createBucket();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Setup search input handler
|
||
const searchInput = document.getElementById('search-input');
|
||
if (searchInput) {
|
||
searchInput.addEventListener('input', debounce((e) => {
|
||
searchTerm = e.target.value.toLowerCase();
|
||
const clearBtn = document.getElementById('clear-search');
|
||
if (searchTerm) {
|
||
clearBtn.classList.remove('hidden');
|
||
} else {
|
||
clearBtn.classList.add('hidden');
|
||
}
|
||
|
||
if (currentBucket && (currentPage > 1 || isReloadingFirstPageForSearch)) {
|
||
reloadFirstPageForSearch();
|
||
return;
|
||
}
|
||
|
||
renderObjects();
|
||
}, 200));
|
||
}
|
||
});
|
||
|
||
// ============================================
|
||
// Search/Filter
|
||
// ============================================
|
||
|
||
function clearSearch() {
|
||
searchTerm = '';
|
||
const searchInput = document.getElementById('search-input');
|
||
const clearBtn = document.getElementById('clear-search');
|
||
if (searchInput) searchInput.value = '';
|
||
if (clearBtn) clearBtn.classList.add('hidden');
|
||
|
||
if (!currentBucket) return;
|
||
|
||
if (currentPage > 1 || isReloadingFirstPageForSearch) {
|
||
reloadFirstPageForSearch();
|
||
return;
|
||
}
|
||
|
||
renderObjects();
|
||
}
|
||
|
||
// ============================================
|
||
// Drag and Drop
|
||
// ============================================
|
||
|
||
function setupDragDrop() {
|
||
const overlay = document.getElementById('drop-overlay');
|
||
|
||
if (!overlay) {
|
||
console.error('Drop overlay element not found');
|
||
return;
|
||
}
|
||
|
||
// Counter to track drag enter/leave across the entire window
|
||
let dragCounter = 0;
|
||
|
||
function showOverlay() {
|
||
if (currentBucket && !overlay.classList.contains('hidden') === false) {
|
||
overlay.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
function hideOverlay() {
|
||
overlay.classList.add('hidden');
|
||
}
|
||
|
||
async function handleDrop(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
dragCounter = 0;
|
||
hideOverlay();
|
||
|
||
if (!currentBucket) {
|
||
showToast('Please select a bucket first', 'warning');
|
||
return;
|
||
}
|
||
|
||
const files = e.dataTransfer?.files;
|
||
if (files && files.length > 0) {
|
||
await uploadFiles(Array.from(files));
|
||
}
|
||
}
|
||
|
||
// Use document-level events for reliable drag detection
|
||
document.addEventListener('dragenter', (e) => {
|
||
e.preventDefault();
|
||
dragCounter++;
|
||
if (currentBucket) {
|
||
overlay.classList.remove('hidden');
|
||
}
|
||
}, false);
|
||
|
||
document.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
// Keep overlay visible during dragover
|
||
if (currentBucket && overlay.classList.contains('hidden')) {
|
||
overlay.classList.remove('hidden');
|
||
}
|
||
}, false);
|
||
|
||
document.addEventListener('dragleave', (e) => {
|
||
e.preventDefault();
|
||
dragCounter--;
|
||
if (dragCounter <= 0) {
|
||
dragCounter = 0;
|
||
hideOverlay();
|
||
}
|
||
}, false);
|
||
|
||
document.addEventListener('drop', handleDrop, false);
|
||
}
|
||
|
||
// ============================================
|
||
// Utility Functions
|
||
// ============================================
|
||
|
||
function getFolderName(prefix) {
|
||
const parts = prefix.split('/').filter(p => p);
|
||
return parts[parts.length - 1] || prefix;
|
||
}
|
||
|
||
function getFileName(key) {
|
||
const parts = key.split('/');
|
||
return parts[parts.length - 1] || key;
|
||
}
|
||
|
||
function formatSize(bytes) {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||
}
|
||
|
||
function formatDate(dateString) {
|
||
if (!dateString) return '-';
|
||
const date = new Date(dateString);
|
||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
|
||
function getFileIcon(name) {
|
||
const ext = name.split('.').pop().toLowerCase();
|
||
const icons = {
|
||
// Images
|
||
'jpg': 'text-pink-500',
|
||
'jpeg': 'text-pink-500',
|
||
'png': 'text-pink-500',
|
||
'gif': 'text-pink-500',
|
||
'svg': 'text-pink-500',
|
||
'webp': 'text-pink-500',
|
||
// Documents
|
||
'pdf': 'text-red-500',
|
||
'doc': 'text-blue-500',
|
||
'docx': 'text-blue-500',
|
||
'xls': 'text-green-500',
|
||
'xlsx': 'text-green-500',
|
||
// Code
|
||
'js': 'text-yellow-500',
|
||
'ts': 'text-blue-500',
|
||
'py': 'text-blue-500',
|
||
'go': 'text-cyan-500',
|
||
'html': 'text-orange-500',
|
||
'css': 'text-purple-500',
|
||
'json': 'text-yellow-600',
|
||
// Archive
|
||
'zip': 'text-yellow-600',
|
||
'tar': 'text-yellow-600',
|
||
'gz': 'text-yellow-600',
|
||
'rar': 'text-yellow-600',
|
||
// Video
|
||
'mp4': 'text-purple-500',
|
||
'webm': 'text-purple-500',
|
||
'mov': 'text-purple-500',
|
||
// Audio
|
||
'mp3': 'text-green-500',
|
||
'wav': 'text-green-500',
|
||
};
|
||
|
||
const color = icons[ext] || 'text-gray-400';
|
||
|
||
return `
|
||
<svg class="w-5 h-5 ${color}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||
</svg>
|
||
`;
|
||
}
|
||
|
||
function getStorageClassColor(storageClass) {
|
||
const colors = {
|
||
'STANDARD': 'bg-green-50 text-green-700',
|
||
'STANDARD_IA': 'bg-blue-50 text-blue-700',
|
||
'ONEZONE_IA': 'bg-blue-50 text-blue-700',
|
||
'INTELLIGENT_TIERING': 'bg-purple-50 text-purple-700',
|
||
'GLACIER': 'bg-cyan-50 text-cyan-700',
|
||
'GLACIER_IR': 'bg-cyan-50 text-cyan-700',
|
||
'DEEP_ARCHIVE': 'bg-indigo-50 text-indigo-700',
|
||
};
|
||
return colors[storageClass] || 'bg-gray-100 text-gray-700';
|
||
}
|
||
|
||
// ============================================
|
||
// Versioning Functions
|
||
// ============================================
|
||
|
||
function toggleVersionsView() {
|
||
showVersions = !showVersions;
|
||
const btn = document.getElementById('versions-toggle-btn');
|
||
const text = document.getElementById('versions-toggle-text');
|
||
|
||
if (showVersions) {
|
||
text.textContent = 'Hide Versions';
|
||
btn.classList.remove('border-gray-200', 'text-charcoal');
|
||
btn.classList.add('bg-accent', 'text-white', 'border-accent');
|
||
loadObjectVersions();
|
||
} else {
|
||
text.textContent = 'Show Versions';
|
||
btn.classList.remove('bg-accent', 'text-white', 'border-accent');
|
||
btn.classList.add('border-gray-200', 'text-charcoal');
|
||
loadObjects();
|
||
}
|
||
}
|
||
|
||
async function loadObjectVersions() {
|
||
if (!currentBucket) return;
|
||
|
||
showTableLoading('objects-table', 6);
|
||
clearSelection();
|
||
|
||
try {
|
||
const result = await api.listObjectVersions(currentBucket, currentPrefix);
|
||
|
||
// Merge versions and delete markers
|
||
objectVersions = [
|
||
...result.versions.map(v => ({ ...v, isDeleteMarker: false })),
|
||
...result.deleteMarkers.map(dm => ({ ...dm, isDeleteMarker: true, size: 0 }))
|
||
];
|
||
|
||
// Sort by key, then by lastModified (newest first)
|
||
objectVersions.sort((a, b) => {
|
||
const keyCompare = a.key.localeCompare(b.key);
|
||
if (keyCompare !== 0) return keyCompare;
|
||
return new Date(b.lastModified) - new Date(a.lastModified);
|
||
});
|
||
|
||
// Also get common prefixes (folders)
|
||
folders = result.commonPrefixes || [];
|
||
|
||
renderVersions();
|
||
} catch (error) {
|
||
console.error('Error loading object versions:', error);
|
||
showToast('Error loading versions: ' + error.message, 'error');
|
||
showEmptyState('objects-table', 6, 'Error loading versions');
|
||
}
|
||
}
|
||
|
||
function renderVersions() {
|
||
const tbody = document.getElementById('objects-table');
|
||
tbody.innerHTML = '';
|
||
|
||
// Apply search filter
|
||
let filteredFolders = folders;
|
||
let filteredVersions = objectVersions;
|
||
|
||
if (searchTerm) {
|
||
filteredFolders = folders.filter(f => {
|
||
const name = getFolderName(f.prefix);
|
||
return name.toLowerCase().includes(searchTerm);
|
||
});
|
||
filteredVersions = objectVersions.filter(v => {
|
||
const name = getFileName(v.key);
|
||
return name.toLowerCase().includes(searchTerm);
|
||
});
|
||
}
|
||
|
||
// Empty state
|
||
if (filteredFolders.length === 0 && filteredVersions.length === 0) {
|
||
tbody.innerHTML = `
|
||
<tr>
|
||
<td colspan="6" class="py-12 text-center">
|
||
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
<p class="text-lg font-medium text-charcoal">No versions found</p>
|
||
<p class="text-sm text-charcoal-300 mt-1">Upload files to create versions</p>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
// Render folders first (same as normal view)
|
||
filteredFolders.forEach(folder => {
|
||
const name = getFolderName(folder.prefix);
|
||
const row = document.createElement('tr');
|
||
row.className = 'file-row border-b border-gray-50 cursor-pointer';
|
||
row.onclick = (e) => {
|
||
if (!e.target.closest('button')) {
|
||
navigateToFolder(folder.prefix);
|
||
}
|
||
};
|
||
row.innerHTML = `
|
||
<td class="py-3 px-4"></td>
|
||
<td class="py-3 px-4">
|
||
<div class="flex items-center gap-3">
|
||
<svg class="w-5 h-5 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>
|
||
</svg>
|
||
<span class="font-mono text-sm text-charcoal">${escapeHtml(name)}</span>
|
||
</div>
|
||
</td>
|
||
<td class="py-3 px-4 text-charcoal-300">-</td>
|
||
<td class="py-3 px-4 text-charcoal-300">-</td>
|
||
<td class="py-3 px-4 text-charcoal-300">-</td>
|
||
<td class="py-3 px-4 text-right"></td>
|
||
`;
|
||
tbody.appendChild(row);
|
||
});
|
||
|
||
// Render versions
|
||
filteredVersions.forEach(version => {
|
||
const name = getFileName(version.key);
|
||
const row = document.createElement('tr');
|
||
const isDeleteMarker = version.isDeleteMarker;
|
||
|
||
row.className = `file-row border-b border-gray-50 ${isDeleteMarker ? 'bg-red-50' : ''}`;
|
||
|
||
// Version status badge
|
||
let statusBadge;
|
||
if (isDeleteMarker) {
|
||
statusBadge = '<span class="px-2 py-1 bg-red-100 text-red-700 text-xs font-medium rounded">Deleted</span>';
|
||
} else if (version.isLatest) {
|
||
statusBadge = '<span class="px-2 py-1 bg-green-50 text-green-700 text-xs font-medium rounded">Latest</span>';
|
||
} else {
|
||
statusBadge = '<span class="px-2 py-1 bg-gray-100 text-charcoal-400 text-xs font-medium rounded">Old</span>';
|
||
}
|
||
|
||
// Truncate version ID
|
||
const shortVersionId = version.versionId ? (version.versionId.length > 12 ? version.versionId.substring(0, 12) + '...' : version.versionId) : '-';
|
||
|
||
// Actions
|
||
let actions = '';
|
||
if (isDeleteMarker) {
|
||
// Delete markers can only be permanently deleted (which undeletes the object)
|
||
actions = `
|
||
<button onclick="permanentlyDeleteVersion('${escapeHtml(version.key)}', '${escapeHtml(version.versionId)}', true)" class="p-1.5 text-red-500 hover:bg-red-100 rounded-lg transition-colors" title="Remove delete marker (undelete)">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
</button>
|
||
`;
|
||
} else {
|
||
actions = `
|
||
<div class="flex items-center justify-end gap-1">
|
||
${!version.isLatest ? `
|
||
<button onclick="restoreVersion('${escapeHtml(version.key)}', '${escapeHtml(version.versionId)}')" class="p-1.5 text-green-600 hover:bg-green-50 rounded-lg transition-colors" title="Restore this version">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||
</svg>
|
||
</button>
|
||
` : ''}
|
||
<button onclick="downloadVersion('${escapeHtml(version.key)}', '${escapeHtml(version.versionId)}')" class="p-1.5 text-accent hover:bg-accent-50 rounded-lg transition-colors" title="Download this version">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||
</svg>
|
||
</button>
|
||
<button onclick="permanentlyDeleteVersion('${escapeHtml(version.key)}', '${escapeHtml(version.versionId)}', false)" class="p-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors" title="Permanently delete this version">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
row.innerHTML = `
|
||
<td class="py-3 px-4"></td>
|
||
<td class="py-3 px-4">
|
||
<div class="flex items-center gap-3">
|
||
${isDeleteMarker ?
|
||
`<svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>` :
|
||
getFileIcon(name)
|
||
}
|
||
<div class="flex flex-col">
|
||
<span class="font-mono text-sm ${isDeleteMarker ? 'line-through text-charcoal-400' : 'text-charcoal'}">${escapeHtml(name)}</span>
|
||
<span class="text-xs text-charcoal-300 font-mono" title="${escapeHtml(version.versionId)}">${escapeHtml(shortVersionId)}</span>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td class="py-3 px-4 text-charcoal-300 font-mono text-sm">${isDeleteMarker ? '-' : formatSize(version.size)}</td>
|
||
<td class="py-3 px-4 text-charcoal-300 text-sm">${formatDate(version.lastModified)}</td>
|
||
<td class="py-3 px-4">${statusBadge}</td>
|
||
<td class="py-3 px-4 text-right">${actions}</td>
|
||
`;
|
||
tbody.appendChild(row);
|
||
});
|
||
}
|
||
|
||
async function downloadVersion(key, versionId) {
|
||
try {
|
||
showToast('Starting download...', 'info');
|
||
const result = await api.getObjectVersion(currentBucket, key, versionId);
|
||
|
||
const url = URL.createObjectURL(result.blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = getFileName(key);
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
|
||
showToast('Download complete', 'success');
|
||
} catch (error) {
|
||
console.error('Download version error:', error);
|
||
showToast('Download failed: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function restoreVersion(key, versionId) {
|
||
const name = getFileName(key);
|
||
confirm(`Restore this version of "${name}"? This will create a new current version.`, async () => {
|
||
try {
|
||
showToast('Restoring version...', 'info');
|
||
await api.restoreObjectVersion(currentBucket, key, versionId);
|
||
showToast('Version restored successfully', 'success');
|
||
loadObjectVersions();
|
||
} catch (error) {
|
||
console.error('Restore version error:', error);
|
||
showToast('Restore failed: ' + error.message, 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
async function permanentlyDeleteVersion(key, versionId, isDeleteMarker) {
|
||
const name = getFileName(key);
|
||
const msg = isDeleteMarker
|
||
? `Remove delete marker for "${name}"? This will make the object accessible again.`
|
||
: `Permanently delete this version of "${name}"? This action cannot be undone.`;
|
||
|
||
confirm(msg, async () => {
|
||
try {
|
||
await api.deleteObjectVersion(currentBucket, key, versionId);
|
||
showToast(isDeleteMarker ? 'Delete marker removed' : 'Version permanently deleted', 'success');
|
||
loadObjectVersions();
|
||
} catch (error) {
|
||
console.error('Delete version error:', error);
|
||
showToast('Delete failed: ' + error.message, 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ============================================
|
||
// Versioning Configuration Functions
|
||
// ============================================
|
||
|
||
function getVersioningBadge(status) {
|
||
if (status === 'Enabled') {
|
||
return `<span class="inline-flex items-center gap-1 px-2 py-1 bg-green-50 text-green-700 text-xs font-medium rounded">
|
||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
Enabled
|
||
</span>`;
|
||
} else if (status === 'Suspended') {
|
||
return `<span class="inline-flex items-center gap-1 px-2 py-1 bg-yellow-50 text-yellow-700 text-xs font-medium rounded">
|
||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
Suspended
|
||
</span>`;
|
||
} else if (status === 'Unknown') {
|
||
return `<span class="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 text-charcoal-400 text-xs font-medium rounded">
|
||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
Unknown
|
||
</span>`;
|
||
}
|
||
return `<span class="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 text-charcoal-400 text-xs font-medium rounded">
|
||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
|
||
</svg>
|
||
Disabled
|
||
</span>`;
|
||
}
|
||
|
||
function getObjectLockBadge(config) {
|
||
if (!config || !config.enabled) {
|
||
return `<span class="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 text-charcoal-400 text-xs font-medium rounded">
|
||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"/>
|
||
</svg>
|
||
Disabled
|
||
</span>`;
|
||
}
|
||
|
||
let retentionText = '';
|
||
if (config.mode) {
|
||
retentionText = config.mode;
|
||
if (config.days) {
|
||
retentionText += ` (${config.days} day${config.days > 1 ? 's' : ''})`;
|
||
} else if (config.years) {
|
||
retentionText += ` (${config.years} year${config.years > 1 ? 's' : ''})`;
|
||
}
|
||
}
|
||
|
||
return `<span class="inline-flex items-center gap-1 px-2 py-1 bg-blue-50 text-blue-700 text-xs font-medium rounded" title="${escapeHtml(retentionText)}">
|
||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||
</svg>
|
||
Enabled
|
||
</span>`;
|
||
}
|
||
|
||
async function openVersioningModalForBucket(bucketName) {
|
||
// Open versioning modal for a bucket from the buckets list
|
||
document.getElementById('versioning-bucket-name').value = bucketName;
|
||
|
||
// Get versioning status from cache
|
||
const status = bucketsVersioningCache[bucketName] || '';
|
||
const statusContainer = document.getElementById('versioning-current-status');
|
||
statusContainer.innerHTML = getVersioningBadge(status);
|
||
|
||
// Show/hide appropriate buttons
|
||
const enableBtn = document.getElementById('enable-versioning-btn');
|
||
const suspendBtn = document.getElementById('suspend-versioning-btn');
|
||
|
||
if (status === 'Enabled') {
|
||
enableBtn.classList.add('hidden');
|
||
suspendBtn.classList.remove('hidden');
|
||
} else if (status === 'Suspended') {
|
||
enableBtn.classList.remove('hidden');
|
||
suspendBtn.classList.remove('hidden');
|
||
enableBtn.textContent = 'Re-enable Versioning';
|
||
} else {
|
||
enableBtn.classList.remove('hidden');
|
||
suspendBtn.classList.add('hidden');
|
||
enableBtn.textContent = 'Enable Versioning';
|
||
}
|
||
|
||
openModal('versioning-modal');
|
||
}
|
||
|
||
async function openVersioningModal() {
|
||
if (!currentBucket) return;
|
||
|
||
document.getElementById('versioning-bucket-name').value = currentBucket;
|
||
|
||
// Get current versioning status
|
||
const statusContainer = document.getElementById('versioning-current-status');
|
||
statusContainer.innerHTML = getVersioningBadge(currentBucketVersioningStatus || '');
|
||
|
||
// Show/hide appropriate buttons based on current status
|
||
const enableBtn = document.getElementById('enable-versioning-btn');
|
||
const suspendBtn = document.getElementById('suspend-versioning-btn');
|
||
|
||
if (currentBucketVersioningStatus === 'Enabled') {
|
||
enableBtn.classList.add('hidden');
|
||
suspendBtn.classList.remove('hidden');
|
||
} else if (currentBucketVersioningStatus === 'Suspended') {
|
||
enableBtn.classList.remove('hidden');
|
||
suspendBtn.classList.remove('hidden');
|
||
enableBtn.textContent = 'Re-enable Versioning';
|
||
} else {
|
||
enableBtn.classList.remove('hidden');
|
||
suspendBtn.classList.add('hidden');
|
||
enableBtn.textContent = 'Enable Versioning';
|
||
}
|
||
|
||
openModal('versioning-modal');
|
||
}
|
||
|
||
async function enableVersioning() {
|
||
const bucketName = document.getElementById('versioning-bucket-name').value;
|
||
if (!bucketName) return;
|
||
|
||
const btn = document.getElementById('enable-versioning-btn');
|
||
setLoading(btn, true);
|
||
|
||
try {
|
||
await api.putBucketVersioning(bucketName, 'Enabled');
|
||
bucketsVersioningCache[bucketName] = 'Enabled';
|
||
if (currentBucket === bucketName) {
|
||
currentBucketVersioningStatus = 'Enabled';
|
||
}
|
||
showToast('Versioning enabled successfully', 'success');
|
||
closeModal('versioning-modal');
|
||
|
||
// Refresh to update UI
|
||
if (currentBucket === bucketName) {
|
||
await checkBucketVersioning();
|
||
} else {
|
||
renderBuckets();
|
||
}
|
||
} catch (error) {
|
||
console.error('Error enabling versioning:', error);
|
||
showToast('Error: ' + error.message, 'error');
|
||
} finally {
|
||
setLoading(btn, false);
|
||
}
|
||
}
|
||
|
||
async function suspendVersioning() {
|
||
const bucketName = document.getElementById('versioning-bucket-name').value;
|
||
if (!bucketName) return;
|
||
|
||
const btn = document.getElementById('suspend-versioning-btn');
|
||
setLoading(btn, true);
|
||
|
||
try {
|
||
await api.putBucketVersioning(bucketName, 'Suspended');
|
||
bucketsVersioningCache[bucketName] = 'Suspended';
|
||
if (currentBucket === bucketName) {
|
||
currentBucketVersioningStatus = 'Suspended';
|
||
}
|
||
showToast('Versioning suspended successfully', 'success');
|
||
closeModal('versioning-modal');
|
||
|
||
// Refresh to update UI
|
||
if (currentBucket === bucketName) {
|
||
await checkBucketVersioning();
|
||
} else {
|
||
renderBuckets();
|
||
}
|
||
} catch (error) {
|
||
console.error('Error suspending versioning:', error);
|
||
showToast('Error: ' + error.message, 'error');
|
||
} finally {
|
||
setLoading(btn, false);
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// Object Lock Configuration Functions
|
||
// ============================================
|
||
|
||
async function openObjectLockModalForBucket(bucketName) {
|
||
// Open object lock modal for a bucket from the buckets list
|
||
document.getElementById('object-lock-bucket-name').value = bucketName;
|
||
|
||
// Get object lock status from cache
|
||
const config = bucketsObjectLockCache[bucketName] || { enabled: false };
|
||
const statusContainer = document.getElementById('object-lock-current-status');
|
||
statusContainer.innerHTML = getObjectLockBadge(config);
|
||
|
||
// Always show configuration section
|
||
const configSection = document.getElementById('object-lock-config-section');
|
||
const enableBtn = document.getElementById('enable-object-lock-btn');
|
||
const updateBtn = document.getElementById('update-object-lock-btn');
|
||
|
||
configSection.classList.remove('hidden');
|
||
|
||
if (config.enabled) {
|
||
// Object lock is enabled - show update button
|
||
enableBtn.classList.add('hidden');
|
||
updateBtn.classList.remove('hidden');
|
||
|
||
// Populate current configuration
|
||
document.getElementById('object-lock-mode').value = config.mode || '';
|
||
document.getElementById('object-lock-days').value = config.days || '';
|
||
document.getElementById('object-lock-years').value = config.years || '';
|
||
} else {
|
||
// Object lock is not enabled - show enable button
|
||
enableBtn.classList.remove('hidden');
|
||
updateBtn.classList.add('hidden');
|
||
|
||
// Clear inputs
|
||
document.getElementById('object-lock-mode').value = '';
|
||
document.getElementById('object-lock-days').value = '';
|
||
document.getElementById('object-lock-years').value = '';
|
||
}
|
||
|
||
openModal('object-lock-modal');
|
||
}
|
||
|
||
async function enableObjectLock() {
|
||
const bucketName = document.getElementById('object-lock-bucket-name').value;
|
||
if (!bucketName) return;
|
||
|
||
const mode = document.getElementById('object-lock-mode').value;
|
||
const days = document.getElementById('object-lock-days').value;
|
||
const years = document.getElementById('object-lock-years').value;
|
||
|
||
if (!mode || (!days && !years)) {
|
||
showToast('Please specify retention mode and period', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (days && years) {
|
||
showToast('Specify either days or years, not both', 'warning');
|
||
return;
|
||
}
|
||
|
||
const btn = document.getElementById('enable-object-lock-btn');
|
||
setLoading(btn, true);
|
||
|
||
try {
|
||
const config = {
|
||
mode: mode,
|
||
days: days ? parseInt(days) : null,
|
||
years: years ? parseInt(years) : null
|
||
};
|
||
|
||
await api.putBucketObjectLockConfiguration(bucketName, config);
|
||
|
||
// Update cache
|
||
bucketsObjectLockCache[bucketName] = { enabled: true, ...config };
|
||
|
||
showToast('Object Lock enabled successfully', 'success');
|
||
closeModal('object-lock-modal');
|
||
|
||
// Reload object lock status
|
||
await loadAllBucketsObjectLock();
|
||
renderBuckets();
|
||
} catch (error) {
|
||
console.error('Error enabling object lock:', error);
|
||
showToast('Error: ' + error.message, 'error');
|
||
} finally {
|
||
setLoading(btn, false);
|
||
}
|
||
}
|
||
|
||
async function updateObjectLockConfig() {
|
||
const bucketName = document.getElementById('object-lock-bucket-name').value;
|
||
if (!bucketName) return;
|
||
|
||
const mode = document.getElementById('object-lock-mode').value;
|
||
const days = document.getElementById('object-lock-days').value;
|
||
const years = document.getElementById('object-lock-years').value;
|
||
|
||
if (days && years) {
|
||
showToast('Specify either days or years, not both', 'warning');
|
||
return;
|
||
}
|
||
|
||
const btn = document.getElementById('update-object-lock-btn');
|
||
setLoading(btn, true);
|
||
|
||
try {
|
||
const config = {
|
||
mode: mode || null,
|
||
days: days ? parseInt(days) : null,
|
||
years: years ? parseInt(years) : null
|
||
};
|
||
|
||
await api.putBucketObjectLockConfiguration(bucketName, config);
|
||
|
||
// Update cache
|
||
bucketsObjectLockCache[bucketName] = { enabled: true, ...config };
|
||
|
||
showToast('Object Lock configuration updated', 'success');
|
||
closeModal('object-lock-modal');
|
||
|
||
// Reload object lock status
|
||
await loadAllBucketsObjectLock();
|
||
renderBuckets();
|
||
} catch (error) {
|
||
console.error('Error updating object lock:', error);
|
||
showToast('Error: ' + error.message, 'error');
|
||
} finally {
|
||
setLoading(btn, false);
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// Bucket Info Modal
|
||
// ============================================
|
||
|
||
let currentBucketInfo = null;
|
||
let bucketTagsModified = [];
|
||
|
||
async function openBucketInfoModal(bucketName) {
|
||
try {
|
||
// Get versioning status
|
||
let versioningStatus = bucketsVersioningCache[bucketName];
|
||
if (!versioningStatus) {
|
||
try {
|
||
const result = await api.getBucketVersioning(bucketName);
|
||
versioningStatus = result.status || 'Disabled';
|
||
bucketsVersioningCache[bucketName] = versioningStatus;
|
||
} catch (error) {
|
||
versioningStatus = 'Unknown';
|
||
}
|
||
}
|
||
|
||
// Get object lock configuration
|
||
let objectLockConfig = null;
|
||
try {
|
||
objectLockConfig = await api.getBucketObjectLockConfiguration(bucketName);
|
||
} catch (error) {
|
||
console.log('Error fetching object lock configuration:', error.message);
|
||
objectLockConfig = { enabled: false };
|
||
}
|
||
|
||
// Get bucket tags
|
||
let tags = [];
|
||
try {
|
||
tags = await api.getBucketTagging(bucketName);
|
||
} catch (error) {
|
||
console.log('No tags or error fetching tags:', error.message);
|
||
}
|
||
|
||
// Store current bucket info
|
||
currentBucketInfo = {
|
||
name: bucketName,
|
||
versioning: versioningStatus,
|
||
objectLock: objectLockConfig
|
||
};
|
||
bucketTagsModified = [...tags];
|
||
|
||
// Populate modal
|
||
document.getElementById('bucket-info-name').textContent = bucketName;
|
||
document.getElementById('bucket-info-versioning').textContent = versioningStatus || 'Disabled';
|
||
|
||
// Populate object lock info
|
||
const objectLockEl = document.getElementById('bucket-info-object-lock');
|
||
const objectLockDetailsEl = document.getElementById('bucket-info-object-lock-details');
|
||
|
||
if (objectLockConfig && objectLockConfig.enabled) {
|
||
objectLockEl.textContent = 'Enabled';
|
||
objectLockEl.className = 'text-green-600 font-medium';
|
||
|
||
// Show details if retention is configured
|
||
if (objectLockConfig.mode) {
|
||
objectLockDetailsEl.classList.remove('hidden');
|
||
document.getElementById('bucket-info-lock-mode').textContent = objectLockConfig.mode;
|
||
|
||
let retention = '-';
|
||
if (objectLockConfig.days) {
|
||
retention = `${objectLockConfig.days} day${objectLockConfig.days !== 1 ? 's' : ''}`;
|
||
} else if (objectLockConfig.years) {
|
||
retention = `${objectLockConfig.years} year${objectLockConfig.years !== 1 ? 's' : ''}`;
|
||
}
|
||
document.getElementById('bucket-info-lock-retention').textContent = retention;
|
||
} else {
|
||
objectLockDetailsEl.classList.add('hidden');
|
||
}
|
||
} else {
|
||
objectLockEl.textContent = 'Disabled';
|
||
objectLockEl.className = 'text-charcoal';
|
||
objectLockDetailsEl.classList.add('hidden');
|
||
}
|
||
|
||
// Render tags
|
||
renderBucketTags();
|
||
|
||
openModal('bucket-info-modal');
|
||
} catch (error) {
|
||
console.error('Error opening bucket info modal:', error);
|
||
showToast('Error loading bucket info: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
function renderBucketTags() {
|
||
const tagsList = document.getElementById('bucket-tags-list');
|
||
|
||
if (bucketTagsModified.length === 0) {
|
||
tagsList.innerHTML = '<p class="text-charcoal-300 text-sm py-4 text-center">No tags</p>';
|
||
return;
|
||
}
|
||
|
||
tagsList.innerHTML = bucketTagsModified.map((tag, index) => `
|
||
<div class="flex items-center gap-2 p-3 border border-gray-200 rounded-lg bg-gray-50">
|
||
<div class="flex-1 grid grid-cols-2 gap-2">
|
||
<input
|
||
type="text"
|
||
value="${escapeHtml(tag.key)}"
|
||
placeholder="Key"
|
||
class="px-3 py-1.5 border border-gray-200 rounded text-sm focus:outline-none focus:border-accent"
|
||
onchange="updateBucketTagKey(${index}, this.value)"
|
||
>
|
||
<input
|
||
type="text"
|
||
value="${escapeHtml(tag.value)}"
|
||
placeholder="Value"
|
||
class="px-3 py-1.5 border border-gray-200 rounded text-sm focus:outline-none focus:border-accent"
|
||
onchange="updateBucketTagValue(${index}, this.value)"
|
||
>
|
||
</div>
|
||
<button
|
||
onclick="removeBucketTag(${index})"
|
||
class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||
title="Remove tag"
|
||
>
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function addBucketTag() {
|
||
bucketTagsModified.push({ key: '', value: '' });
|
||
renderBucketTags();
|
||
}
|
||
|
||
function removeBucketTag(index) {
|
||
bucketTagsModified.splice(index, 1);
|
||
renderBucketTags();
|
||
}
|
||
|
||
function updateBucketTagKey(index, key) {
|
||
bucketTagsModified[index].key = key;
|
||
}
|
||
|
||
function updateBucketTagValue(index, value) {
|
||
bucketTagsModified[index].value = value;
|
||
}
|
||
|
||
async function saveBucketTags() {
|
||
if (!currentBucketInfo) return;
|
||
|
||
const btn = document.getElementById('save-bucket-tags-btn');
|
||
setLoading(btn, true);
|
||
|
||
try {
|
||
// Filter out empty tags
|
||
const validTags = bucketTagsModified.filter(tag => tag.key && tag.value);
|
||
|
||
await api.putBucketTagging(currentBucketInfo.name, validTags);
|
||
showToast('Bucket tags saved successfully', 'success');
|
||
closeModal('bucket-info-modal');
|
||
} catch (error) {
|
||
console.error('Error saving bucket tags:', error);
|
||
showToast('Error saving tags: ' + error.message, 'error');
|
||
} finally {
|
||
setLoading(btn, false);
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// Bucket Policy Modal
|
||
// ============================================
|
||
|
||
let currentPolicyBucket = null;
|
||
let currentPolicy = null;
|
||
|
||
async function openBucketPolicyModal(bucketName) {
|
||
currentPolicyBucket = bucketName;
|
||
document.getElementById('policy-bucket-name-display').textContent = `Bucket: ${bucketName}`;
|
||
|
||
// Reset UI
|
||
document.getElementById('policy-status-message').classList.add('hidden');
|
||
document.getElementById('policy-documentation').classList.add('hidden');
|
||
|
||
try {
|
||
// Try to load existing policy
|
||
const policy = await api.getBucketPolicy(bucketName);
|
||
currentPolicy = policy;
|
||
document.getElementById('policy-json-editor').value = JSON.stringify(policy, null, 2);
|
||
showPolicyStatusMessage('Policy loaded successfully', 'success');
|
||
document.getElementById('delete-policy-btn').disabled = false;
|
||
} catch (error) {
|
||
// No policy exists or error loading
|
||
currentPolicy = null;
|
||
document.getElementById('policy-json-editor').value = '';
|
||
if (error.message && error.message.includes('NoSuchBucketPolicy')) {
|
||
showPolicyStatusMessage('No policy defined for this bucket', 'info');
|
||
} else {
|
||
console.error('Error loading policy:', error);
|
||
showPolicyStatusMessage('Error loading policy: ' + error.message, 'error');
|
||
}
|
||
document.getElementById('delete-policy-btn').disabled = true;
|
||
}
|
||
|
||
openModal('bucket-policy-modal');
|
||
}
|
||
|
||
function showPolicyStatusMessage(message, type) {
|
||
const statusDiv = document.getElementById('policy-status-message');
|
||
const colors = {
|
||
success: 'bg-green-50 border-green-200 text-green-800',
|
||
error: 'bg-red-50 border-red-200 text-red-800',
|
||
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
|
||
info: 'bg-blue-50 border-blue-200 text-blue-800'
|
||
};
|
||
|
||
const icons = {
|
||
success: '<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
|
||
error: '<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
|
||
warning: '<svg class="w-5 h-5 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>',
|
||
info: '<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
|
||
};
|
||
|
||
statusDiv.className = `flex items-center gap-3 p-3 rounded-lg border ${colors[type]}`;
|
||
statusDiv.innerHTML = `
|
||
${icons[type]}
|
||
<p class="text-sm">${escapeHtml(message)}</p>
|
||
`;
|
||
statusDiv.classList.remove('hidden');
|
||
}
|
||
|
||
function loadExamplePolicy() {
|
||
if (!currentPolicyBucket) return;
|
||
|
||
const examplePolicy = {
|
||
"Version": "2012-10-17",
|
||
"Statement": [
|
||
{
|
||
"Sid": "FullS3Access",
|
||
"Effect": "Allow",
|
||
"Principal": [
|
||
"user001",
|
||
"user002"
|
||
],
|
||
"Action": [
|
||
"s3:AbortMultipartUpload",
|
||
"s3:DeleteObject",
|
||
"s3:DeleteObjectTagging",
|
||
"s3:GetBucketAcl",
|
||
"s3:GetBucketPolicy",
|
||
"s3:GetBucketTagging",
|
||
"s3:GetBucketLocation",
|
||
"s3:GetBucketCORS",
|
||
"s3:GetObject",
|
||
"s3:GetObjectAcl",
|
||
"s3:GetObjectAttributes",
|
||
"s3:GetObjectRetention",
|
||
"s3:GetObjectTagging",
|
||
"s3:ListBucket",
|
||
"s3:ListBucketMultipartUploads",
|
||
"s3:ListMultipartUploadParts",
|
||
"s3:PutBucketTagging",
|
||
"s3:PutObject",
|
||
"s3:PutObjectRetention",
|
||
"s3:PutObjectTagging",
|
||
"s3:RestoreObject"
|
||
],
|
||
"Resource": [
|
||
`arn:aws:s3:::${currentPolicyBucket}`,
|
||
`arn:aws:s3:::${currentPolicyBucket}/*`
|
||
]
|
||
}
|
||
]
|
||
};
|
||
|
||
document.getElementById('policy-json-editor').value = JSON.stringify(examplePolicy, null, 2);
|
||
showPolicyStatusMessage('Example policy loaded. Modify as needed and click Save.', 'info');
|
||
}
|
||
|
||
function validatePolicyJson() {
|
||
const policyText = document.getElementById('policy-json-editor').value.trim();
|
||
|
||
if (!policyText) {
|
||
showPolicyStatusMessage('Policy is empty', 'warning');
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
const policy = JSON.parse(policyText);
|
||
|
||
// Validate required fields
|
||
if (!policy.Version) {
|
||
showPolicyStatusMessage('Missing required field: Version', 'error');
|
||
return false;
|
||
}
|
||
|
||
if (policy.Version !== "2012-10-17") {
|
||
showPolicyStatusMessage('Version must be "2012-10-17"', 'error');
|
||
return false;
|
||
}
|
||
|
||
if (!policy.Statement || !Array.isArray(policy.Statement)) {
|
||
showPolicyStatusMessage('Missing or invalid Statement array', 'error');
|
||
return false;
|
||
}
|
||
|
||
if (policy.Statement.length === 0) {
|
||
showPolicyStatusMessage('Statement array cannot be empty', 'error');
|
||
return false;
|
||
}
|
||
|
||
// Validate each statement
|
||
for (let i = 0; i < policy.Statement.length; i++) {
|
||
const stmt = policy.Statement[i];
|
||
|
||
if (!stmt.Effect || !['Allow', 'Deny'].includes(stmt.Effect)) {
|
||
showPolicyStatusMessage(`Statement ${i}: Effect must be "Allow" or "Deny"`, 'error');
|
||
return false;
|
||
}
|
||
|
||
if (!stmt.Principal) {
|
||
showPolicyStatusMessage(`Statement ${i}: Missing Principal field`, 'error');
|
||
return false;
|
||
}
|
||
|
||
if (!stmt.Action) {
|
||
showPolicyStatusMessage(`Statement ${i}: Missing Action field`, 'error');
|
||
return false;
|
||
}
|
||
|
||
if (!stmt.Resource) {
|
||
showPolicyStatusMessage(`Statement ${i}: Missing Resource field`, 'error');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
showPolicyStatusMessage('Policy is valid ✓', 'success');
|
||
return true;
|
||
} catch (error) {
|
||
showPolicyStatusMessage('Invalid JSON: ' + error.message, 'error');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function saveBucketPolicy() {
|
||
if (!currentPolicyBucket) return;
|
||
|
||
if (!validatePolicyJson()) {
|
||
return;
|
||
}
|
||
|
||
const policyText = document.getElementById('policy-json-editor').value.trim();
|
||
const policy = JSON.parse(policyText);
|
||
|
||
const btn = document.getElementById('save-policy-btn');
|
||
setLoading(btn, true);
|
||
|
||
try {
|
||
await api.putBucketPolicy(currentPolicyBucket, policy);
|
||
showToast('Bucket policy saved successfully', 'success');
|
||
currentPolicy = policy;
|
||
document.getElementById('delete-policy-btn').disabled = false;
|
||
showPolicyStatusMessage('Policy saved successfully', 'success');
|
||
} catch (error) {
|
||
console.error('Error saving policy:', error);
|
||
showToast('Error saving policy: ' + error.message, 'error');
|
||
showPolicyStatusMessage('Error saving policy: ' + error.message, 'error');
|
||
} finally {
|
||
setLoading(btn, false);
|
||
}
|
||
}
|
||
|
||
async function deleteBucketPolicy() {
|
||
if (!currentPolicyBucket) return;
|
||
|
||
confirm('Are you sure you want to delete this bucket policy? This action cannot be undone.', async () => {
|
||
const btn = document.getElementById('delete-policy-btn');
|
||
setLoading(btn, true);
|
||
|
||
try {
|
||
await api.deleteBucketPolicy(currentPolicyBucket);
|
||
showToast('Bucket policy deleted successfully', 'success');
|
||
currentPolicy = null;
|
||
document.getElementById('policy-json-editor').value = '';
|
||
document.getElementById('delete-policy-btn').disabled = true;
|
||
showPolicyStatusMessage('Policy deleted successfully', 'success');
|
||
} catch (error) {
|
||
console.error('Error deleting policy:', error);
|
||
showToast('Error deleting policy: ' + error.message, 'error');
|
||
showPolicyStatusMessage('Error deleting policy: ' + error.message, 'error');
|
||
} finally {
|
||
setLoading(btn, false);
|
||
}
|
||
});
|
||
}
|
||
|
||
function showPolicyDocumentation() {
|
||
document.getElementById('policy-documentation').classList.remove('hidden');
|
||
}
|
||
|
||
function hidePolicyDocumentation() {
|
||
document.getElementById('policy-documentation').classList.add('hidden');
|
||
}
|
||
|
||
// ============================================
|
||
// Multipart Uploads Modal
|
||
// ============================================
|
||
|
||
let currentMultipartUploads = [];
|
||
let currentMultipartBucket = null;
|
||
|
||
async function openMultipartUploadsModalForBucket(bucketName) {
|
||
currentMultipartBucket = bucketName;
|
||
|
||
document.getElementById('multipart-bucket-name-display').textContent = `Bucket: ${bucketName}`;
|
||
|
||
// Show modal and loading state
|
||
openModal('multipart-uploads-modal');
|
||
document.getElementById('multipart-loading').classList.remove('hidden');
|
||
document.getElementById('multipart-list').classList.add('hidden');
|
||
document.getElementById('multipart-empty').classList.add('hidden');
|
||
|
||
await loadMultipartUploads();
|
||
}
|
||
|
||
async function openMultipartUploadsModal() {
|
||
if (!currentBucket) {
|
||
showToast('No bucket selected', 'warning');
|
||
return;
|
||
}
|
||
|
||
currentMultipartBucket = currentBucket;
|
||
document.getElementById('multipart-bucket-name-display').textContent = `Bucket: ${currentBucket}`;
|
||
|
||
// Show modal and loading state
|
||
openModal('multipart-uploads-modal');
|
||
document.getElementById('multipart-loading').classList.remove('hidden');
|
||
document.getElementById('multipart-list').classList.add('hidden');
|
||
document.getElementById('multipart-empty').classList.add('hidden');
|
||
|
||
await loadMultipartUploads();
|
||
}
|
||
|
||
async function loadMultipartUploads() {
|
||
if (!currentMultipartBucket) return;
|
||
|
||
try {
|
||
currentMultipartUploads = await api.listMultipartUploads(currentMultipartBucket);
|
||
renderMultipartUploads();
|
||
} catch (error) {
|
||
console.error('Error loading multipart uploads:', error);
|
||
showToast('Error loading multipart uploads: ' + error.message, 'error');
|
||
document.getElementById('multipart-loading').classList.add('hidden');
|
||
document.getElementById('multipart-empty').classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
function renderMultipartUploads() {
|
||
document.getElementById('multipart-loading').classList.add('hidden');
|
||
|
||
const abortAllBtn = document.getElementById('abort-all-btn');
|
||
|
||
if (currentMultipartUploads.length === 0) {
|
||
document.getElementById('multipart-list').classList.add('hidden');
|
||
document.getElementById('multipart-empty').classList.remove('hidden');
|
||
abortAllBtn.classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
document.getElementById('multipart-empty').classList.add('hidden');
|
||
document.getElementById('multipart-list').classList.remove('hidden');
|
||
abortAllBtn.classList.remove('hidden');
|
||
|
||
const tbody = document.getElementById('multipart-uploads-tbody');
|
||
tbody.innerHTML = '';
|
||
|
||
currentMultipartUploads.forEach(upload => {
|
||
const row = document.createElement('tr');
|
||
row.className = 'border-b border-gray-50 hover:bg-gray-50';
|
||
|
||
// Format the initiated date
|
||
let initiatedDisplay = '-';
|
||
if (upload.initiated) {
|
||
try {
|
||
const date = new Date(upload.initiated);
|
||
initiatedDisplay = date.toLocaleString();
|
||
} catch (e) {
|
||
initiatedDisplay = upload.initiated;
|
||
}
|
||
}
|
||
|
||
row.innerHTML = `
|
||
<td class="py-3 px-4">
|
||
<div class="flex items-center gap-2">
|
||
<svg class="w-4 h-4 text-charcoal-300 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||
</svg>
|
||
<span class="text-sm text-charcoal font-mono break-all">${escapeHtml(upload.key)}</span>
|
||
</div>
|
||
</td>
|
||
<td class="py-3 px-4">
|
||
<span class="text-xs text-charcoal-300 font-mono break-all">${escapeHtml(upload.uploadId.substring(0, 24))}...</span>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm text-charcoal-300">
|
||
${escapeHtml(initiatedDisplay)}
|
||
</td>
|
||
<td class="py-3 px-4 text-center">
|
||
<button
|
||
onclick="abortMultipartUpload('${escapeHtml(upload.key)}', '${escapeHtml(upload.uploadId)}')"
|
||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs text-red-600 hover:bg-red-50 border border-red-200 rounded-lg transition-colors"
|
||
title="Abort this multipart upload">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
Abort
|
||
</button>
|
||
</td>
|
||
`;
|
||
|
||
tbody.appendChild(row);
|
||
});
|
||
}
|
||
|
||
async function abortMultipartUpload(key, uploadId) {
|
||
confirm(`Are you sure you want to abort this multipart upload for "${key}"? This action cannot be undone.`, async () => {
|
||
try {
|
||
await api.abortMultipartUpload(currentMultipartBucket, key, uploadId);
|
||
showToast('Multipart upload aborted successfully', 'success');
|
||
|
||
// Remove from current list
|
||
currentMultipartUploads = currentMultipartUploads.filter(
|
||
upload => !(upload.key === key && upload.uploadId === uploadId)
|
||
);
|
||
|
||
renderMultipartUploads();
|
||
} catch (error) {
|
||
console.error('Error aborting multipart upload:', error);
|
||
showToast('Error aborting multipart upload: ' + error.message, 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
async function abortAllMultipartUploads() {
|
||
if (!currentMultipartUploads || currentMultipartUploads.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const count = currentMultipartUploads.length;
|
||
confirm(`Are you sure you want to abort all ${count} multipart upload${count > 1 ? 's' : ''}? This action cannot be undone.`, async () => {
|
||
const btn = document.getElementById('abort-all-btn');
|
||
setLoading(btn, true);
|
||
|
||
let successCount = 0;
|
||
let failureCount = 0;
|
||
const failures = [];
|
||
|
||
// Abort all uploads in parallel
|
||
const promises = currentMultipartUploads.map(async (upload) => {
|
||
try {
|
||
await api.abortMultipartUpload(currentMultipartBucket, upload.key, upload.uploadId);
|
||
successCount++;
|
||
} catch (error) {
|
||
failureCount++;
|
||
failures.push({ key: upload.key, error: error.message });
|
||
console.error(`Error aborting ${upload.key}:`, error);
|
||
}
|
||
});
|
||
|
||
await Promise.all(promises);
|
||
|
||
// Show results
|
||
if (failureCount === 0) {
|
||
showToast(`Successfully aborted all ${successCount} multipart upload${successCount > 1 ? 's' : ''}`, 'success');
|
||
} else if (successCount === 0) {
|
||
showToast(`Failed to abort all ${failureCount} multipart upload${failureCount > 1 ? 's' : ''}`, 'error');
|
||
} else {
|
||
showToast(`Aborted ${successCount} upload${successCount > 1 ? 's' : ''}, ${failureCount} failed`, 'warning');
|
||
}
|
||
|
||
setLoading(btn, false);
|
||
|
||
// Refresh the list
|
||
await refreshMultipartUploads();
|
||
});
|
||
}
|
||
|
||
async function refreshMultipartUploads() {
|
||
document.getElementById('multipart-loading').classList.remove('hidden');
|
||
document.getElementById('multipart-list').classList.add('hidden');
|
||
document.getElementById('multipart-empty').classList.add('hidden');
|
||
await loadMultipartUploads();
|
||
}
|
||
|
||
// ============================================
|
||
// Keyboard Shortcuts
|
||
// ============================================
|
||
|
||
// Keyboard shortcuts
|
||
document.addEventListener('keydown', (e) => {
|
||
// Delete key
|
||
if (e.key === 'Delete' && selectedObjects.size > 0) {
|
||
deleteSelected();
|
||
}
|
||
// Ctrl+A to select all
|
||
if (e.ctrlKey && e.key === 'a' && currentBucket) {
|
||
e.preventDefault();
|
||
document.getElementById('select-all').checked = true;
|
||
toggleSelectAll();
|
||
}
|
||
// Backspace to go up
|
||
if (e.key === 'Backspace' && currentPrefix && !e.target.matches('input, textarea')) {
|
||
e.preventDefault();
|
||
navigateUp();
|
||
}
|
||
// Escape to clear selection
|
||
if (e.key === 'Escape') {
|
||
clearSelection();
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|