import * as plugins from '../plugins.js'; 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'; @customElement('tsview-app') export class TsviewApp extends DeesElement { @state() private accessor viewMode: TViewMode = 's3'; @state() private accessor selectedBucket: string = ''; @state() private accessor selectedDatabase: string = ''; @state() private accessor selectedCollection: string = ''; @state() private accessor buckets: string[] = []; @state() private accessor databases: Array<{ name: string; sizeOnDisk?: number }> = []; @state() private accessor showCreateBucketDialog: boolean = false; @state() private accessor newBucketName: string = ''; @state() private accessor showCreateCollectionDialog: boolean = false; @state() private accessor newCollectionName: string = ''; @state() private accessor showCreateDatabaseDialog: boolean = false; @state() private accessor newDatabaseName: string = ''; @state() private accessor showS3CreateDialog: boolean = false; @state() private accessor s3CreateDialogType: 'folder' | 'file' = 'folder'; @state() private accessor s3CreateDialogBucket: string = ''; @state() private accessor s3CreateDialogName: string = ''; public static styles = [ cssManager.defaultStyles, themeStyles, css` :host { display: block; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: var(--tsview-bg-primary, #1a1a1a); color: #eee; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .app-container { display: grid; grid-template-rows: 48px 1fr; height: 100%; } .app-header { background: #141414; border-bottom: 1px solid #333; display: flex; align-items: center; padding: 0 16px; gap: 24px; } .app-title { font-size: 18px; font-weight: 600; color: #fff; display: flex; align-items: center; gap: 8px; } .app-title svg { width: 24px; height: 24px; } .nav-tabs { display: flex; gap: 4px; } .nav-tab { padding: 8px 16px; background: transparent; border: none; color: #888; cursor: pointer; font-size: 14px; border-radius: 6px; transition: all 0.2s; } .nav-tab:hover { background: rgba(255, 255, 255, 0.05); color: #aaa; } .nav-tab.active { background: rgba(255, 255, 255, 0.1); color: #e0e0e0; } .app-main { display: grid; grid-template-columns: 240px 1fr; overflow: hidden; } .sidebar { background: #1e1e1e; border-right: 1px solid #333; overflow-y: auto; } .sidebar-header { padding: 12px 16px; font-size: 12px; font-weight: 600; text-transform: uppercase; color: #666; border-bottom: 1px solid #333; } .sidebar-list { padding: 8px; } .sidebar-item { padding: 8px 12px; border-radius: 6px; cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: space-between; transition: background 0.15s; } .sidebar-item:hover { background: rgba(255, 255, 255, 0.05); } .sidebar-item.selected { background: rgba(255, 255, 255, 0.1); color: #e0e0e0; } .sidebar-item .count { font-size: 12px; color: #666; background: rgba(255, 255, 255, 0.1); padding: 2px 6px; border-radius: 10px; } .content-area { overflow: auto; padding: 16px; } .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #666; } .empty-state svg { width: 64px; height: 64px; margin-bottom: 16px; opacity: 0.5; } .db-group { margin-bottom: 8px; } .db-group-header { padding: 8px 12px; font-size: 13px; font-weight: 500; color: #999; cursor: pointer; display: flex; align-items: center; gap: 8px; } .db-group-header:hover { color: #ccc; } .db-group-collections { padding-left: 12px; } .collection-item { padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 13px; display: flex; align-items: center; justify-content: space-between; } .collection-item:hover { background: rgba(255, 255, 255, 0.05); } .collection-item.selected { background: rgba(255, 255, 255, 0.08); color: #e0e0e0; } .create-btn { display: flex; align-items: center; gap: 6px; padding: 8px 12px; margin: 8px; background: rgba(255, 255, 255, 0.1); border: 1px dashed rgba(255, 255, 255, 0.2); border-radius: 6px; color: #e0e0e0; cursor: pointer; font-size: 13px; transition: all 0.2s; } .create-btn:hover { background: rgba(255, 255, 255, 0.15); border-color: rgba(255, 255, 255, 0.3); } .dialog-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.7); display: flex; align-items: center; justify-content: center; z-index: 1000; } .dialog { background: #1e1e1e; border-radius: 12px; padding: 24px; min-width: 400px; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5); } .dialog-title { font-size: 18px; font-weight: 600; margin-bottom: 16px; color: #fff; } .dialog-input { width: 100%; padding: 10px 12px; background: #141414; border: 1px solid #333; border-radius: 6px; color: #fff; font-size: 14px; margin-bottom: 16px; box-sizing: border-box; } .dialog-input:focus { outline: none; border-color: #e0e0e0; } .dialog-location { font-size: 12px; color: #888; margin-bottom: 12px; font-family: monospace; } .dialog-hint { font-size: 11px; color: #666; margin-bottom: 16px; margin-top: -8px; } .dialog-actions { display: flex; gap: 12px; justify-content: flex-end; } .dialog-btn { padding: 8px 16px; border-radius: 6px; font-size: 14px; cursor: pointer; transition: all 0.2s; } .dialog-btn-cancel { background: transparent; border: 1px solid #444; color: #aaa; } .dialog-btn-cancel:hover { background: rgba(255, 255, 255, 0.05); color: #fff; } .dialog-btn-create { background: #404040; border: none; color: #fff; } .dialog-btn-create:hover { background: #505050; } .dialog-btn-create:disabled { opacity: 0.5; cursor: not-allowed; } .dialog-btn-delete { background: rgba(239, 68, 68, 0.2); border: 1px solid #ef4444; color: #f87171; } .dialog-btn-delete:hover { background: rgba(239, 68, 68, 0.3); } .sidebar-item-content { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; } .sidebar-item-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } `, ]; async connectedCallback() { super.connectedCallback(); await this.loadData(); } private async loadData() { try { // Load buckets for S3 this.buckets = await apiService.listBuckets(); // Load databases for MongoDB this.databases = await apiService.listDatabases(); // Select first item if available if (this.viewMode === 's3' && this.buckets.length > 0 && !this.selectedBucket) { this.selectedBucket = this.buckets[0]; } if (this.viewMode === 'mongo' && this.databases.length > 0 && !this.selectedDatabase) { this.selectedDatabase = this.databases[0].name; } } catch (err) { console.error('Error loading data:', err); } } private setViewMode(mode: TViewMode) { this.viewMode = mode; } private selectBucket(bucket: string) { this.selectedBucket = bucket; } private selectDatabase(db: string) { this.selectedDatabase = db; this.selectedCollection = ''; } private selectCollection(collection: string) { this.selectedCollection = collection; } private async createBucket() { if (!this.newBucketName.trim()) return; const success = await apiService.createBucket(this.newBucketName.trim()); if (success) { this.buckets = [...this.buckets, this.newBucketName.trim()]; this.newBucketName = ''; this.showCreateBucketDialog = false; } } private async createCollection() { if (!this.newCollectionName.trim() || !this.selectedDatabase) return; const success = await apiService.createCollection(this.selectedDatabase, this.newCollectionName.trim()); if (success) { this.newCollectionName = ''; this.showCreateCollectionDialog = false; // Refresh will happen through the collections component } } private async createDatabase() { if (!this.newDatabaseName.trim()) return; const success = await apiService.createDatabase(this.newDatabaseName.trim()); if (success) { this.databases = [...this.databases, { name: this.newDatabaseName.trim() }]; this.newDatabaseName = ''; this.showCreateDatabaseDialog = false; } } private async deleteBucket(bucket: string, e: Event) { e.stopPropagation(); if (!confirm(`Delete bucket "${bucket}"? This will delete all objects in the bucket.`)) return; 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 async deleteDatabase(dbName: string, e: Event) { e.stopPropagation(); if (!confirm(`Delete database "${dbName}"? This will delete all collections and documents.`)) return; 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 = ''; } } } private async deleteCollection(dbName: string, collectionName: string) { if (!confirm(`Delete collection "${collectionName}"? This will delete all documents.`)) return; const success = await apiService.dropCollection(dbName, collectionName); if (success) { if (this.selectedCollection === collectionName) { this.selectedCollection = ''; } // Force refresh of the collections list this.requestUpdate(); } } 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: 'New Folder', iconName: 'lucide:folderPlus', action: async () => this.openS3CreateDialog(bucket, 'folder'), }, { name: 'New File', iconName: 'lucide:filePlus', action: async () => this.openS3CreateDialog(bucket, 'file'), }, { 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 openS3CreateDialog(bucket: string, type: 'folder' | 'file') { this.s3CreateDialogBucket = bucket; this.s3CreateDialogType = type; this.s3CreateDialogName = ''; this.showS3CreateDialog = true; } private getContentType(ext: string): string { const contentTypes: Record = { json: 'application/json', txt: 'text/plain', html: 'text/html', css: 'text/css', js: 'application/javascript', ts: 'text/typescript', md: 'text/markdown', xml: 'application/xml', yaml: 'text/yaml', yml: 'text/yaml', csv: 'text/csv', }; return contentTypes[ext] || 'application/octet-stream'; } private getDefaultContent(ext: string): string { const defaults: Record = { json: '{\n \n}', html: '\n\n\n \n\n\n \n\n', md: '# Title\n\n', txt: '', }; return defaults[ext] || ''; } private async handleS3Create() { if (!this.s3CreateDialogName.trim()) return; const name = this.s3CreateDialogName.trim(); let path: string; if (this.s3CreateDialogType === 'folder') { path = name + '/.keep'; } else { path = name; } const ext = name.split('.').pop()?.toLowerCase() || ''; const contentType = this.s3CreateDialogType === 'file' ? this.getContentType(ext) : 'application/octet-stream'; const content = this.s3CreateDialogType === 'file' ? this.getDefaultContent(ext) : ''; const success = await apiService.putObject( this.s3CreateDialogBucket, path, btoa(content), contentType ); if (success) { this.showS3CreateDialog = false; // Select the bucket to show the new content this.selectedBucket = this.s3CreateDialogBucket; // Trigger a refresh by dispatching an event this.requestUpdate(); } } 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`
TsView
${this.renderSidebar()} ${this.renderContent()}
${this.renderCreateBucketDialog()} ${this.renderCreateCollectionDialog()} ${this.renderCreateDatabaseDialog()} ${this.renderS3CreateDialog()} `; } private renderCreateBucketDialog() { if (!this.showCreateBucketDialog) return ''; return html`
this.showCreateBucketDialog = false}>
e.stopPropagation()}>
Create New Bucket
this.newBucketName = (e.target as HTMLInputElement).value} @keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.createBucket()} />
`; } private renderCreateCollectionDialog() { if (!this.showCreateCollectionDialog) return ''; return html`
this.showCreateCollectionDialog = false}>
e.stopPropagation()}>
Create Collection in ${this.selectedDatabase}
this.newCollectionName = (e.target as HTMLInputElement).value} @keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.createCollection()} />
`; } private renderCreateDatabaseDialog() { if (!this.showCreateDatabaseDialog) return ''; return html`
this.showCreateDatabaseDialog = false}>
e.stopPropagation()}>
Create New Database
this.newDatabaseName = (e.target as HTMLInputElement).value} @keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.createDatabase()} />
`; } private renderS3CreateDialog() { if (!this.showS3CreateDialog) return ''; const isFolder = this.s3CreateDialogType === 'folder'; const title = isFolder ? 'Create New Folder' : 'Create New File'; const placeholder = isFolder ? 'folder-name or path/to/folder' : 'filename.txt or path/to/file.txt'; return html`
this.showS3CreateDialog = false}>
e.stopPropagation()}>
${title}
Location: ${this.s3CreateDialogBucket}/
this.s3CreateDialogName = (e.target as HTMLInputElement).value} @keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleS3Create()} />
Use "/" to create nested ${isFolder ? 'folders' : 'path'} (e.g., ${isFolder ? 'parent/child' : 'folder/file.txt'})
`; } private renderSidebar() { if (this.viewMode === 's3') { return html` `; } if (this.viewMode === 'mongo') { return html` `; } return html` `; } private renderDatabaseGroup(db: { name: string }) { return html`
this.selectDatabase(db.name)} @contextmenu=${(e: MouseEvent) => this.handleDatabaseContextMenu(e, db.name)} > ${db.name}
${this.selectedDatabase === db.name ? this.renderCollectionsList(db.name) : ''}
`; } private renderCollectionsList(dbName: string) { return html` this.selectCollection(e.detail)} @collection-deleted=${(e: CustomEvent) => this.handleCollectionDeleted(e)} > `; } private handleCollectionDeleted(e: CustomEvent) { const { collectionName } = e.detail; if (this.selectedCollection === collectionName) { this.selectedCollection = ''; } } private renderContent() { if (this.viewMode === 's3') { if (!this.selectedBucket) { return html`

Select a bucket to browse

`; } return html`
`; } if (this.viewMode === 'mongo') { if (!this.selectedCollection) { return html`

Select a collection to view documents

`; } return html`
`; } return html`

Settings

Configuration options coming soon.

`; } }