Files
tsview/ts_web/elements/tsview-s3-keys.ts

601 lines
16 KiB
TypeScript

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<string, unknown>) {
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<string, string> = {
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<string, string> = {
json: '{\n \n}',
html: '<!DOCTYPE html>\n<html>\n<head>\n <title></title>\n</head>\n<body>\n \n</body>\n</html>',
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`
<div class="dialog-overlay" @click=${() => this.showCreateDialog = false}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog-title">${title}</div>
<div class="dialog-location">
Location: ${this.bucketName}/${this.createDialogPrefix}
</div>
<input
type="text"
class="dialog-input"
placeholder=${placeholder}
.value=${this.createDialogName}
@input=${(e: InputEvent) => this.createDialogName = (e.target as HTMLInputElement).value}
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleCreate()}
/>
<div class="dialog-hint">
Use "/" to create nested ${isFolder ? 'folders' : 'path'} (e.g., ${isFolder ? 'parent/child' : 'folder/file.txt'})
</div>
<div class="dialog-actions">
<button class="dialog-btn dialog-btn-cancel" @click=${() => this.showCreateDialog = false}>
Cancel
</button>
<button
class="dialog-btn dialog-btn-create"
?disabled=${!this.createDialogName.trim()}
@click=${() => this.handleCreate()}
>
Create
</button>
</div>
</div>
</div>
`;
}
render() {
return html`
<div class="keys-container">
<div class="filter-bar">
<input
type="text"
class="filter-input"
placeholder="Filter files..."
.value=${this.filterText}
@input=${this.handleFilterInput}
/>
</div>
<div class="keys-list" @contextmenu=${(e: MouseEvent) => this.handleEmptySpaceContextMenu(e)}>
${this.loading
? html`<div class="empty-state">Loading...</div>`
: this.filteredItems.length === 0
? html`<div class="empty-state">No objects found</div>`
: html`
<table>
<thead>
<tr>
<th>Name</th>
<th style="width: 100px;">Size</th>
</tr>
</thead>
<tbody>
${this.filteredItems.map(
(item) => html`
<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">
${item.isFolder
? html`
<svg class="key-icon folder-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" />
</svg>
`
: html`
<svg class="key-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
</svg>
`}
<span class="key-name">${getFileName(item.key)}</span>
</div>
</td>
<td class="size-cell">
${item.isFolder ? '-' : formatSize(item.size)}
</td>
</tr>
`
)}
</tbody>
</table>
`}
</div>
</div>
${this.renderCreateDialog()}
`;
}
}