feat(s3,web-ui): add S3 deletePrefix and getObjectUrl endpoints and add context menus in UI for S3 and Mongo views
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-01-25 - 1.2.0 - feat(s3,web-ui)
|
||||
add S3 deletePrefix and getObjectUrl endpoints and add context menus in UI for S3 and Mongo views
|
||||
|
||||
- Add server-side TypedHandlers: deletePrefix and getObjectUrl (ts/api/handlers.s3.ts)
|
||||
- Add request/response interfaces IReq_DeletePrefix and IReq_GetObjectUrl (ts/interfaces/index.ts)
|
||||
- Add client API methods deletePrefix and getObjectUrl (ts_web/services/api.service.ts)
|
||||
- Introduce context menu actions (DeesContextmenu) across UI: bucket/database/collection/document/folder/file actions including open, copy path, delete, download and duplicate (ts_web/elements/tsview-app.ts, tsview-mongo-collections.ts, tsview-mongo-documents.ts, tsview-s3-columns.ts, tsview-s3-keys.ts)
|
||||
- Switch from inline delete buttons to contextual menus for safer UX; implement downloads via data URLs returned by getObjectUrl and deletion of S3 prefixes (folders)
|
||||
|
||||
## 2026-01-25 - 1.1.3 - fix(package)
|
||||
update package metadata
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@git.zone/tsview',
|
||||
version: '1.1.3',
|
||||
version: '1.2.0',
|
||||
description: 'A CLI tool for viewing S3 and MongoDB data with a web UI'
|
||||
}
|
||||
|
||||
@@ -364,4 +364,103 @@ export async function registerS3Handlers(
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Delete prefix (folder and all contents)
|
||||
typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.IReq_DeletePrefix>(
|
||||
'deletePrefix',
|
||||
async (reqData) => {
|
||||
const smartbucket = await tsview.getSmartBucket();
|
||||
if (!smartbucket) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const bucket = await smartbucket.getBucketByName(reqData.bucketName);
|
||||
if (!bucket) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const baseDir = await bucket.getBaseDirectory();
|
||||
let targetDir = baseDir;
|
||||
|
||||
// Navigate to the prefix directory
|
||||
const prefix = reqData.prefix.replace(/\/$/, '');
|
||||
const prefixParts = prefix.split('/').filter(Boolean);
|
||||
for (const part of prefixParts) {
|
||||
const subDir = await targetDir.getSubDirectoryByName(part, { getEmptyDirectory: true });
|
||||
if (subDir) {
|
||||
targetDir = subDir;
|
||||
} else {
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the directory and all its contents
|
||||
await targetDir.delete({ mode: 'permanent' });
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Error deleting prefix:', err);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get object URL (for downloads)
|
||||
typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.IReq_GetObjectUrl>(
|
||||
'getObjectUrl',
|
||||
async (reqData) => {
|
||||
const smartbucket = await tsview.getSmartBucket();
|
||||
if (!smartbucket) {
|
||||
throw new Error('S3 not configured');
|
||||
}
|
||||
|
||||
try {
|
||||
const bucket = await smartbucket.getBucketByName(reqData.bucketName);
|
||||
if (!bucket) {
|
||||
throw new Error(`Bucket ${reqData.bucketName} not found`);
|
||||
}
|
||||
|
||||
// Get the content and create a data URL
|
||||
const content = await bucket.fastGet({ path: reqData.key });
|
||||
const ext = reqData.key.split('.').pop()?.toLowerCase() || '';
|
||||
const contentTypeMap: Record<string, string> = {
|
||||
'json': 'application/json',
|
||||
'txt': 'text/plain',
|
||||
'html': 'text/html',
|
||||
'css': 'text/css',
|
||||
'js': 'application/javascript',
|
||||
'ts': 'text/plain',
|
||||
'tsx': 'text/plain',
|
||||
'jsx': 'text/plain',
|
||||
'md': 'text/markdown',
|
||||
'csv': 'text/csv',
|
||||
'yaml': 'text/yaml',
|
||||
'yml': 'text/yaml',
|
||||
'log': 'text/plain',
|
||||
'sh': 'text/plain',
|
||||
'env': 'text/plain',
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'svg': 'image/svg+xml',
|
||||
'pdf': 'application/pdf',
|
||||
'xml': 'application/xml',
|
||||
};
|
||||
const contentType = contentTypeMap[ext] || 'application/octet-stream';
|
||||
const base64 = content.toString('base64');
|
||||
const url = `data:${contentType};base64,${base64}`;
|
||||
|
||||
return { url };
|
||||
} catch (err) {
|
||||
console.error('Error getting object URL:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -199,6 +199,34 @@ export interface IReq_CopyObject extends plugins.typedrequestInterfaces.implemen
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_DeletePrefix extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeletePrefix
|
||||
> {
|
||||
method: 'deletePrefix';
|
||||
request: {
|
||||
bucketName: string;
|
||||
prefix: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetObjectUrl extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetObjectUrl
|
||||
> {
|
||||
method: 'getObjectUrl';
|
||||
request: {
|
||||
bucketName: string;
|
||||
key: string;
|
||||
};
|
||||
response: {
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// TypedRequest interfaces for MongoDB API
|
||||
// ===========================================
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@git.zone/tsview',
|
||||
version: '1.1.3',
|
||||
version: '1.2.0',
|
||||
description: 'A CLI tool for viewing S3 and MongoDB data with a web UI'
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { apiService } from '../services/index.js';
|
||||
import { themeStyles } from '../styles/index.js';
|
||||
|
||||
const { html, css, cssManager, customElement, state, DeesElement } = plugins;
|
||||
const { DeesContextmenu } = plugins.deesCatalog;
|
||||
|
||||
type TViewMode = 's3' | 'mongo' | 'settings';
|
||||
|
||||
@@ -359,30 +360,6 @@ export class TsviewApp extends DeesElement {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
opacity: 0;
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.sidebar-item:hover .delete-btn,
|
||||
.db-group-header:hover .delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -495,6 +472,66 @@ export class TsviewApp extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
private handleBucketContextMenu(event: MouseEvent, bucket: string) {
|
||||
event.preventDefault();
|
||||
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||
{
|
||||
name: 'View Contents',
|
||||
iconName: 'lucide:folderOpen',
|
||||
action: async () => {
|
||||
this.selectBucket(bucket);
|
||||
},
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Delete Bucket',
|
||||
iconName: 'lucide:trash2',
|
||||
action: async () => {
|
||||
if (confirm(`Delete bucket "${bucket}"? This will delete all objects in the bucket.`)) {
|
||||
const success = await apiService.deleteBucket(bucket);
|
||||
if (success) {
|
||||
this.buckets = this.buckets.filter(b => b !== bucket);
|
||||
if (this.selectedBucket === bucket) {
|
||||
this.selectedBucket = this.buckets[0] || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
private handleDatabaseContextMenu(event: MouseEvent, dbName: string) {
|
||||
event.preventDefault();
|
||||
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||
{
|
||||
name: 'New Collection',
|
||||
iconName: 'lucide:folderPlus',
|
||||
action: async () => {
|
||||
this.selectedDatabase = dbName;
|
||||
this.showCreateCollectionDialog = true;
|
||||
},
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Delete Database',
|
||||
iconName: 'lucide:trash2',
|
||||
action: async () => {
|
||||
if (confirm(`Delete database "${dbName}"? This will delete all collections and documents.`)) {
|
||||
const success = await apiService.dropDatabase(dbName);
|
||||
if (success) {
|
||||
this.databases = this.databases.filter(d => d.name !== dbName);
|
||||
if (this.selectedDatabase === dbName) {
|
||||
this.selectedDatabase = this.databases[0]?.name || '';
|
||||
this.selectedCollection = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="app-container">
|
||||
@@ -653,14 +690,9 @@ export class TsviewApp extends DeesElement {
|
||||
<div
|
||||
class="sidebar-item ${bucket === this.selectedBucket ? 'selected' : ''}"
|
||||
@click=${() => this.selectBucket(bucket)}
|
||||
@contextmenu=${(e: MouseEvent) => this.handleBucketContextMenu(e, bucket)}
|
||||
>
|
||||
<span class="sidebar-item-name">${bucket}</span>
|
||||
<button class="delete-btn" @click=${(e: Event) => this.deleteBucket(bucket, e)} title="Delete bucket">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
@@ -680,15 +712,6 @@ export class TsviewApp extends DeesElement {
|
||||
</svg>
|
||||
New Database
|
||||
</button>
|
||||
${this.selectedDatabase ? html`
|
||||
<button class="create-btn" @click=${() => this.showCreateCollectionDialog = true}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
New Collection
|
||||
</button>
|
||||
` : ''}
|
||||
<div class="sidebar-list">
|
||||
${this.databases.length === 0
|
||||
? html`<div class="sidebar-item" style="color: #666; cursor: default;">No databases found</div>`
|
||||
@@ -715,6 +738,7 @@ export class TsviewApp extends DeesElement {
|
||||
<div
|
||||
class="db-group-header ${this.selectedDatabase === db.name ? 'selected' : ''}"
|
||||
@click=${() => this.selectDatabase(db.name)}
|
||||
@contextmenu=${(e: MouseEvent) => this.handleDatabaseContextMenu(e, db.name)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
|
||||
@@ -722,12 +746,6 @@ export class TsviewApp extends DeesElement {
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
|
||||
</svg>
|
||||
<span style="flex: 1;">${db.name}</span>
|
||||
<button class="delete-btn" @click=${(e: Event) => this.deleteDatabase(db.name, e)} title="Delete database">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
${this.selectedDatabase === db.name ? this.renderCollectionsList(db.name) : ''}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { formatCount } from '../utilities/index.js';
|
||||
import { themeStyles } from '../styles/index.js';
|
||||
|
||||
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||
const { DeesContextmenu } = plugins.deesCatalog;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementEventMap {
|
||||
@@ -89,29 +90,6 @@ export class TsviewMongoCollections extends DeesElement {
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
opacity: 0;
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.collection-item:hover .delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -149,8 +127,7 @@ export class TsviewMongoCollections extends DeesElement {
|
||||
);
|
||||
}
|
||||
|
||||
private async deleteCollection(name: string, e: Event) {
|
||||
e.stopPropagation();
|
||||
private async deleteCollection(name: string) {
|
||||
if (!confirm(`Delete collection "${name}"? This will delete all documents.`)) return;
|
||||
|
||||
const success = await apiService.dropCollection(this.databaseName, name);
|
||||
@@ -166,6 +143,27 @@ export class TsviewMongoCollections extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
private handleCollectionContextMenu(event: MouseEvent, collection: IMongoCollection) {
|
||||
event.preventDefault();
|
||||
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||
{
|
||||
name: 'View Documents',
|
||||
iconName: 'lucide:fileText',
|
||||
action: async () => {
|
||||
this.selectCollection(collection.name);
|
||||
},
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Delete Collection',
|
||||
iconName: 'lucide:trash2',
|
||||
action: async () => {
|
||||
await this.deleteCollection(collection.name);
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
public async refresh() {
|
||||
await this.loadCollections();
|
||||
}
|
||||
@@ -186,6 +184,7 @@ export class TsviewMongoCollections extends DeesElement {
|
||||
<div
|
||||
class="collection-item ${this.selectedCollection === coll.name ? 'selected' : ''}"
|
||||
@click=${() => this.selectCollection(coll.name)}
|
||||
@contextmenu=${(e: MouseEvent) => this.handleCollectionContextMenu(e, coll)}
|
||||
>
|
||||
<span class="collection-name">
|
||||
<svg class="collection-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -193,17 +192,9 @@ export class TsviewMongoCollections extends DeesElement {
|
||||
</svg>
|
||||
${coll.name}
|
||||
</span>
|
||||
<span style="display: flex; align-items: center; gap: 4px;">
|
||||
${coll.count !== undefined
|
||||
? html`<span class="collection-count">${formatCount(coll.count)}</span>`
|
||||
: ''}
|
||||
<button class="delete-btn" @click=${(e: Event) => this.deleteCollection(coll.name, e)} title="Delete collection">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
${coll.count !== undefined
|
||||
? html`<span class="collection-count">${formatCount(coll.count)}</span>`
|
||||
: ''}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { apiService } from '../services/index.js';
|
||||
import { themeStyles } from '../styles/index.js';
|
||||
|
||||
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||
const { DeesContextmenu } = plugins.deesCatalog;
|
||||
|
||||
@customElement('tsview-mongo-documents')
|
||||
export class TsviewMongoDocuments extends DeesElement {
|
||||
@@ -330,6 +331,74 @@ export class TsviewMongoDocuments extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
private handleDocumentContextMenu(event: MouseEvent, doc: Record<string, unknown>) {
|
||||
event.preventDefault();
|
||||
const docId = doc._id as string;
|
||||
|
||||
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||
{
|
||||
name: 'View/Edit',
|
||||
iconName: 'lucide:edit',
|
||||
action: async () => {
|
||||
this.selectDocument(doc);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Copy as JSON',
|
||||
iconName: 'lucide:copy',
|
||||
action: async () => {
|
||||
await navigator.clipboard.writeText(JSON.stringify(doc, null, 2));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Duplicate',
|
||||
iconName: 'lucide:copyPlus',
|
||||
action: async () => {
|
||||
const { _id, ...docWithoutId } = doc;
|
||||
const newDoc = { ...docWithoutId, createdAt: new Date().toISOString() };
|
||||
try {
|
||||
const insertedId = await apiService.insertDocument(
|
||||
this.databaseName,
|
||||
this.collectionName,
|
||||
newDoc
|
||||
);
|
||||
await this.loadDocuments();
|
||||
this.selectedId = insertedId;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('document-selected', {
|
||||
detail: { documentId: insertedId },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error duplicating document:', err);
|
||||
}
|
||||
},
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash2',
|
||||
action: async () => {
|
||||
if (confirm(`Delete document "${docId}"?`)) {
|
||||
const result = await apiService.deleteDocument(
|
||||
this.databaseName,
|
||||
this.collectionName,
|
||||
docId
|
||||
);
|
||||
if (result.success) {
|
||||
await this.loadDocuments();
|
||||
if (this.selectedId === docId) {
|
||||
this.selectedId = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
render() {
|
||||
const startRecord = (this.page - 1) * this.pageSize + 1;
|
||||
const endRecord = Math.min(this.page * this.pageSize, this.total);
|
||||
@@ -362,6 +431,7 @@ export class TsviewMongoDocuments extends DeesElement {
|
||||
<div
|
||||
class="document-row ${this.selectedId === doc._id ? 'selected' : ''}"
|
||||
@click=${() => this.selectDocument(doc)}
|
||||
@contextmenu=${(e: MouseEvent) => this.handleDocumentContextMenu(e, doc)}
|
||||
>
|
||||
<div class="document-id">_id: ${doc._id}</div>
|
||||
<div class="document-preview">${this.getDocumentPreview(doc)}</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getFileName } from '../utilities/index.js';
|
||||
import { themeStyles } from '../styles/index.js';
|
||||
|
||||
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||
const { DeesContextmenu } = plugins.deesCatalog;
|
||||
|
||||
interface IColumn {
|
||||
prefix: string;
|
||||
@@ -318,6 +319,83 @@ export class TsviewS3Columns extends DeesElement {
|
||||
return iconMap[ext] || 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z';
|
||||
}
|
||||
|
||||
private handleFolderContextMenu(event: MouseEvent, columnIndex: number, prefix: string) {
|
||||
event.preventDefault();
|
||||
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||
{
|
||||
name: 'Open',
|
||||
iconName: 'lucide:folderOpen',
|
||||
action: async () => {
|
||||
this.selectFolder(columnIndex, prefix);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Copy Path',
|
||||
iconName: 'lucide:copy',
|
||||
action: async () => {
|
||||
await navigator.clipboard.writeText(prefix);
|
||||
},
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Delete Folder',
|
||||
iconName: 'lucide:trash2',
|
||||
action: async () => {
|
||||
if (confirm(`Delete folder "${getFileName(prefix)}" and all its contents?`)) {
|
||||
const success = await apiService.deletePrefix(this.bucketName, prefix);
|
||||
if (success) {
|
||||
await this.loadInitialColumn();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
private handleFileContextMenu(event: MouseEvent, columnIndex: number, key: string) {
|
||||
event.preventDefault();
|
||||
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||
{
|
||||
name: 'Preview',
|
||||
iconName: 'lucide:eye',
|
||||
action: async () => {
|
||||
this.selectFile(columnIndex, key);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Download',
|
||||
iconName: 'lucide:download',
|
||||
action: async () => {
|
||||
const url = await apiService.getObjectUrl(this.bucketName, key);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = getFileName(key);
|
||||
link.click();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Copy Path',
|
||||
iconName: 'lucide:copy',
|
||||
action: async () => {
|
||||
await navigator.clipboard.writeText(key);
|
||||
},
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash2',
|
||||
action: async () => {
|
||||
if (confirm(`Delete file "${getFileName(key)}"?`)) {
|
||||
const success = await apiService.deleteObject(this.bucketName, key);
|
||||
if (success) {
|
||||
await this.loadInitialColumn();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.loading && this.columns.length === 0) {
|
||||
return html`<div class="loading">Loading...</div>`;
|
||||
@@ -361,6 +439,7 @@ export class TsviewS3Columns extends DeesElement {
|
||||
<div
|
||||
class="column-item folder ${column.selectedItem === prefix ? 'selected' : ''}"
|
||||
@click=${() => this.selectFolder(index, prefix)}
|
||||
@contextmenu=${(e: MouseEvent) => this.handleFolderContextMenu(e, index, prefix)}
|
||||
>
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
@@ -377,6 +456,7 @@ export class TsviewS3Columns extends DeesElement {
|
||||
<div
|
||||
class="column-item ${column.selectedItem === obj.key ? 'selected' : ''}"
|
||||
@click=${() => this.selectFile(index, obj.key)}
|
||||
@contextmenu=${(e: MouseEvent) => this.handleFileContextMenu(e, index, obj.key)}
|
||||
>
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="${this.getFileIcon(obj.key)}" />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { formatSize, getFileName } from '../utilities/index.js';
|
||||
import { themeStyles } from '../styles/index.js';
|
||||
|
||||
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||
const { DeesContextmenu } = plugins.deesCatalog;
|
||||
|
||||
@customElement('tsview-s3-keys')
|
||||
export class TsviewS3Keys extends DeesElement {
|
||||
@@ -210,6 +211,83 @@ export class TsviewS3Keys extends DeesElement {
|
||||
return [...folders, ...files];
|
||||
}
|
||||
|
||||
private handleItemContextMenu(event: MouseEvent, key: string, isFolder: boolean) {
|
||||
event.preventDefault();
|
||||
|
||||
if (isFolder) {
|
||||
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||
{
|
||||
name: 'Open',
|
||||
iconName: 'lucide:folderOpen',
|
||||
action: async () => {
|
||||
this.selectKey(key, true);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Copy Path',
|
||||
iconName: 'lucide:copy',
|
||||
action: async () => {
|
||||
await navigator.clipboard.writeText(key);
|
||||
},
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Delete Folder',
|
||||
iconName: 'lucide:trash2',
|
||||
action: async () => {
|
||||
if (confirm(`Delete folder "${getFileName(key)}" and all its contents?`)) {
|
||||
const success = await apiService.deletePrefix(this.bucketName, key);
|
||||
if (success) {
|
||||
await this.loadObjects();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||
{
|
||||
name: 'Preview',
|
||||
iconName: 'lucide:eye',
|
||||
action: async () => {
|
||||
this.selectKey(key, false);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Download',
|
||||
iconName: 'lucide:download',
|
||||
action: async () => {
|
||||
const url = await apiService.getObjectUrl(this.bucketName, key);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = getFileName(key);
|
||||
link.click();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Copy Path',
|
||||
iconName: 'lucide:copy',
|
||||
action: async () => {
|
||||
await navigator.clipboard.writeText(key);
|
||||
},
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash2',
|
||||
action: async () => {
|
||||
if (confirm(`Delete file "${getFileName(key)}"?`)) {
|
||||
const success = await apiService.deleteObject(this.bucketName, key);
|
||||
if (success) {
|
||||
await this.loadObjects();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="keys-container">
|
||||
@@ -242,6 +320,7 @@ export class TsviewS3Keys extends DeesElement {
|
||||
<tr
|
||||
class="${this.selectedKey === item.key ? 'selected' : ''}"
|
||||
@click=${() => this.selectKey(item.key, item.isFolder)}
|
||||
@contextmenu=${(e: MouseEvent) => this.handleItemContextMenu(e, item.key, item.isFolder)}
|
||||
>
|
||||
<td>
|
||||
<div class="key-cell">
|
||||
|
||||
@@ -128,6 +128,22 @@ export class ApiService {
|
||||
return result.success;
|
||||
}
|
||||
|
||||
async deletePrefix(bucketName: string, prefix: string): Promise<boolean> {
|
||||
const result = await this.request<
|
||||
{ bucketName: string; prefix: string },
|
||||
{ success: boolean }
|
||||
>('deletePrefix', { bucketName, prefix });
|
||||
return result.success;
|
||||
}
|
||||
|
||||
async getObjectUrl(bucketName: string, key: string): Promise<string> {
|
||||
const result = await this.request<
|
||||
{ bucketName: string; key: string },
|
||||
{ url: string }
|
||||
>('getObjectUrl', { bucketName, key });
|
||||
return result.url;
|
||||
}
|
||||
|
||||
async copyObject(
|
||||
sourceBucket: string,
|
||||
sourceKey: string,
|
||||
|
||||
Reference in New Issue
Block a user