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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user