import * as plugins from '../plugins.js'; import { apiService, type IS3Object } from '../services/index.js'; 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 { @property({ type: String }) public accessor bucketName: string = ''; @property({ type: String }) public accessor currentPrefix: string = ''; @property({ type: Number }) public accessor refreshKey: number = 0; @state() private accessor allKeys: IS3Object[] = []; @state() private accessor prefixes: string[] = []; @state() private accessor loading: boolean = false; @state() private accessor selectedKey: string = ''; @state() private accessor filterText: string = ''; @state() private accessor showCreateDialog: boolean = false; @state() private accessor createDialogType: 'folder' | 'file' = 'folder'; @state() private accessor createDialogPrefix: string = ''; @state() private accessor createDialogName: string = ''; public static styles = [ cssManager.defaultStyles, themeStyles, css` :host { display: block; height: 100%; overflow: hidden; } .keys-container { display: flex; flex-direction: column; height: 100%; } .filter-bar { padding: 12px; border-bottom: 1px solid #333; } .filter-input { width: 100%; padding: 8px 12px; background: rgba(0, 0, 0, 0.3); border: 1px solid #444; border-radius: 6px; color: #fff; font-size: 14px; } .filter-input:focus { outline: none; border-color: #404040; } .filter-input::placeholder { color: #666; } .keys-list { flex: 1; overflow-y: auto; } table { width: 100%; border-collapse: collapse; } thead { position: sticky; top: 0; background: #1a1a1a; z-index: 1; } th { text-align: left; padding: 10px 12px; font-size: 12px; font-weight: 500; color: #666; text-transform: uppercase; border-bottom: 1px solid #333; } td { padding: 8px 12px; font-size: 13px; border-bottom: 1px solid #2a2a3e; } tr:hover td { background: rgba(255, 255, 255, 0.03); } tr.selected td { background: rgba(255, 255, 255, 0.08); } .key-cell { display: flex; align-items: center; gap: 8px; cursor: pointer; } .key-icon { width: 16px; height: 16px; flex-shrink: 0; } .folder-icon { color: #fbbf24; } .key-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .size-cell { color: #888; font-variant-numeric: tabular-nums; } .empty-state { padding: 32px; text-align: center; color: #666; } .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-location { font-size: 12px; color: #888; margin-bottom: 12px; font-family: monospace; } .dialog-input { width: 100%; padding: 10px 12px; background: #141414; border: 1px solid #333; border-radius: 6px; color: #fff; font-size: 14px; margin-bottom: 8px; box-sizing: border-box; } .dialog-input:focus { outline: none; border-color: #e0e0e0; } .dialog-hint { font-size: 11px; color: #666; margin-bottom: 16px; } .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; } `, ]; async connectedCallback() { super.connectedCallback(); await this.loadObjects(); } updated(changedProperties: Map) { if (changedProperties.has('bucketName') || changedProperties.has('currentPrefix') || changedProperties.has('refreshKey')) { this.loadObjects(); } } private async loadObjects() { this.loading = true; try { const result = await apiService.listObjects(this.bucketName, this.currentPrefix, '/'); this.allKeys = result.objects; this.prefixes = result.prefixes; } catch (err) { console.error('Error loading objects:', err); this.allKeys = []; this.prefixes = []; } this.loading = false; } private handleFilterInput(e: Event) { this.filterText = (e.target as HTMLInputElement).value; } private selectKey(key: string, isFolder: boolean) { this.selectedKey = key; if (isFolder) { this.dispatchEvent( new CustomEvent('navigate', { detail: { prefix: key }, bubbles: true, composed: true, }) ); } else { this.dispatchEvent( new CustomEvent('key-selected', { detail: { key }, bubbles: true, composed: true, }) ); } } private get filteredItems() { const filter = this.filterText.toLowerCase(); const folders = this.prefixes .filter((p) => !filter || getFileName(p).toLowerCase().includes(filter)) .map((p) => ({ key: p, isFolder: true, size: undefined })); const files = this.allKeys .filter((o) => !filter || getFileName(o.key).toLowerCase().includes(filter)) .map((o) => ({ key: o.key, isFolder: false, size: o.size })); 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: 'New Folder Inside', iconName: 'lucide:folderPlus', action: async () => this.openCreateDialog('folder', key), }, { name: 'New File Inside', iconName: 'lucide:filePlus', action: async () => this.openCreateDialog('file', 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(); } } }, }, ]); } } private handleEmptySpaceContextMenu(event: MouseEvent) { // Only trigger if clicking on the container itself, not on items if ((event.target as HTMLElement).closest('tr')) return; event.preventDefault(); DeesContextmenu.openContextMenuWithOptions(event, [ { name: 'New Folder', iconName: 'lucide:folderPlus', action: async () => this.openCreateDialog('folder', this.currentPrefix), }, { name: 'New File', iconName: 'lucide:filePlus', action: async () => this.openCreateDialog('file', this.currentPrefix), }, ]); } private openCreateDialog(type: 'folder' | 'file', prefix: string) { this.createDialogType = type; this.createDialogPrefix = prefix; this.createDialogName = ''; this.showCreateDialog = 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 handleCreate() { if (!this.createDialogName.trim()) return; const name = this.createDialogName.trim(); let path: string; if (this.createDialogType === 'folder') { path = this.createDialogPrefix + name + '/.keep'; } else { path = this.createDialogPrefix + name; } const ext = name.split('.').pop()?.toLowerCase() || ''; const contentType = this.createDialogType === 'file' ? this.getContentType(ext) : 'application/octet-stream'; const content = this.createDialogType === 'file' ? this.getDefaultContent(ext) : ''; const success = await apiService.putObject( this.bucketName, path, btoa(content), contentType ); if (success) { this.showCreateDialog = false; await this.loadObjects(); } } private renderCreateDialog() { if (!this.showCreateDialog) return ''; const isFolder = this.createDialogType === '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.showCreateDialog = false}>
e.stopPropagation()}>
${title}
Location: ${this.bucketName}/${this.createDialogPrefix}
this.createDialogName = (e.target as HTMLInputElement).value} @keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleCreate()} />
Use "/" to create nested ${isFolder ? 'folders' : 'path'} (e.g., ${isFolder ? 'parent/child' : 'folder/file.txt'})
`; } render() { return html`
this.handleEmptySpaceContextMenu(e)}> ${this.loading ? html`
Loading...
` : this.filteredItems.length === 0 ? html`
No objects found
` : html` ${this.filteredItems.map( (item) => html` this.selectKey(item.key, item.isFolder)} @contextmenu=${(e: MouseEvent) => this.handleItemContextMenu(e, item.key, item.isFolder)} > ` )}
Name Size
${item.isFolder ? html` ` : html` `} ${getFileName(item.key)}
${item.isFolder ? '-' : formatSize(item.size)}
`}
${this.renderCreateDialog()} `; } }