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

722 lines
20 KiB
TypeScript

import * as plugins from '../plugins.js';
import { apiService, type IS3Object } from '../services/index.js';
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;
objects: IS3Object[];
prefixes: string[];
selectedItem: string | null;
width: number;
}
@customElement('tsview-s3-columns')
export class TsviewS3Columns 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 columns: IColumn[] = [];
@state()
private accessor loading: boolean = false;
@state()
private accessor showCreateDialog: boolean = false;
@state()
private accessor createDialogType: 'folder' | 'file' = 'folder';
@state()
private accessor createDialogPrefix: string = '';
@state()
private accessor createDialogName: string = '';
private resizing: { columnIndex: number; startX: number; startWidth: number } | null = null;
private readonly DEFAULT_COLUMN_WIDTH = 250;
private readonly MIN_COLUMN_WIDTH = 150;
private readonly MAX_COLUMN_WIDTH = 500;
public static styles = [
cssManager.defaultStyles,
themeStyles,
css`
:host {
display: block;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.columns-container {
display: flex;
height: 100%;
min-width: max-content;
}
.column-wrapper {
display: flex;
height: 100%;
flex-shrink: 0;
}
.column {
display: flex;
flex-direction: column;
height: 100%;
flex-shrink: 0;
overflow: hidden;
}
.resize-handle {
width: 5px;
height: 100%;
background: transparent;
cursor: col-resize;
position: relative;
flex-shrink: 0;
}
.resize-handle::after {
content: '';
position: absolute;
top: 0;
left: 2px;
width: 1px;
height: 100%;
background: #333;
}
.resize-handle:hover::after,
.resize-handle.active::after {
background: #404040;
width: 2px;
left: 1px;
}
.column-wrapper:last-child .resize-handle {
display: none;
}
.column-header {
padding: 8px 12px;
font-size: 12px;
font-weight: 500;
color: #666;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.column-items {
flex: 1;
overflow-y: auto;
padding: 4px;
}
.column-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: background 0.1s;
}
.column-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.column-item.selected {
background: rgba(255, 255, 255, 0.1);
color: #e0e0e0;
}
.column-item.folder {
color: #fbbf24;
}
.column-item .icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.column-item .name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.column-item .chevron {
width: 14px;
height: 14px;
color: #555;
}
.empty-state {
padding: 16px;
text-align: center;
color: #666;
font-size: 13px;
}
.loading {
padding: 16px;
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.loadInitialColumn();
}
updated(changedProperties: Map<string, unknown>) {
// Only reset columns when bucket changes or refresh is triggered
// Internal folder navigation is handled by selectFolder() which appends columns
if (changedProperties.has('bucketName') || changedProperties.has('refreshKey')) {
this.loadInitialColumn();
}
}
private async loadInitialColumn() {
this.loading = true;
try {
const result = await apiService.listObjects(this.bucketName, this.currentPrefix, '/');
this.columns = [
{
prefix: this.currentPrefix,
objects: result.objects,
prefixes: result.prefixes,
selectedItem: null,
width: this.DEFAULT_COLUMN_WIDTH,
},
];
} catch (err) {
console.error('Error loading objects:', err);
this.columns = [];
}
this.loading = false;
}
private async selectFolder(columnIndex: number, prefix: string) {
// Update selection in current column
this.columns = this.columns.map((col, i) => {
if (i === columnIndex) {
return { ...col, selectedItem: prefix };
}
return col;
});
// Remove columns after current
this.columns = this.columns.slice(0, columnIndex + 1);
// Load new column
try {
const result = await apiService.listObjects(this.bucketName, prefix, '/');
this.columns = [
...this.columns,
{
prefix,
objects: result.objects,
prefixes: result.prefixes,
selectedItem: null,
width: this.DEFAULT_COLUMN_WIDTH,
},
];
// Auto-scroll to show the new column
this.updateComplete.then(() => this.scrollToEnd());
} catch (err) {
console.error('Error loading folder:', err);
}
// Note: Don't dispatch navigate event here - columns view expands horizontally
// The navigate event is only for breadcrumb sync, not for column navigation
}
private scrollToEnd() {
this.scrollLeft = this.scrollWidth - this.clientWidth;
}
private startResize(e: MouseEvent, columnIndex: number) {
e.preventDefault();
this.resizing = {
columnIndex,
startX: e.clientX,
startWidth: this.columns[columnIndex].width,
};
document.addEventListener('mousemove', this.handleResize);
document.addEventListener('mouseup', this.stopResize);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}
private handleResize = (e: MouseEvent) => {
if (!this.resizing) return;
const delta = e.clientX - this.resizing.startX;
const newWidth = Math.min(
this.MAX_COLUMN_WIDTH,
Math.max(this.MIN_COLUMN_WIDTH, this.resizing.startWidth + delta)
);
this.columns = this.columns.map((col, i) => {
if (i === this.resizing!.columnIndex) {
return { ...col, width: newWidth };
}
return col;
});
};
private stopResize = () => {
this.resizing = null;
document.removeEventListener('mousemove', this.handleResize);
document.removeEventListener('mouseup', this.stopResize);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
private selectFile(columnIndex: number, key: string) {
// Update selection
this.columns = this.columns.map((col, i) => {
if (i === columnIndex) {
return { ...col, selectedItem: key };
}
return col;
});
// Remove columns after current
this.columns = this.columns.slice(0, columnIndex + 1);
// Dispatch key-selected event
this.dispatchEvent(
new CustomEvent('key-selected', {
detail: { key },
bubbles: true,
composed: true,
})
);
}
private getFileIcon(key: string): string {
const ext = key.split('.').pop()?.toLowerCase() || '';
const iconMap: Record<string, string> = {
json: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z',
txt: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z',
png: 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z',
jpg: 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z',
jpeg: 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z',
gif: 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z',
pdf: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z',
};
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: 'New Folder Inside',
iconName: 'lucide:folderPlus',
action: async () => this.openCreateDialog('folder', prefix),
},
{
name: 'New File Inside',
iconName: 'lucide:filePlus',
action: async () => this.openCreateDialog('file', 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();
}
}
},
},
]);
}
private handleEmptySpaceContextMenu(event: MouseEvent, columnIndex: number) {
// Only trigger if clicking on the container itself, not on items
if (event.target !== event.currentTarget) return;
event.preventDefault();
const prefix = this.columns[columnIndex].prefix;
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'New Folder',
iconName: 'lucide:folderPlus',
action: async () => this.openCreateDialog('folder', prefix),
},
{
name: 'New File',
iconName: 'lucide:filePlus',
action: async () => this.openCreateDialog('file', prefix),
},
]);
}
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') {
// Support deep paths: "a/b/c" creates nested folders
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.loadInitialColumn();
}
}
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() {
if (this.loading && this.columns.length === 0) {
return html`<div class="loading">Loading...</div>`;
}
return html`
<div class="columns-container">
${this.columns.map((column, index) => this.renderColumnWrapper(column, index))}
</div>
${this.renderCreateDialog()}
`;
}
private renderColumnWrapper(column: IColumn, index: number) {
return html`
<div class="column-wrapper">
${this.renderColumn(column, index)}
<div
class="resize-handle ${this.resizing?.columnIndex === index ? 'active' : ''}"
@mousedown=${(e: MouseEvent) => this.startResize(e, index)}
></div>
</div>
`;
}
private renderColumn(column: IColumn, index: number) {
const headerName = column.prefix
? getFileName(column.prefix)
: this.bucketName;
return html`
<div class="column" style="width: ${column.width}px">
<div class="column-header" title=${column.prefix || this.bucketName}>
${headerName}
</div>
<div class="column-items" @contextmenu=${(e: MouseEvent) => this.handleEmptySpaceContextMenu(e, index)}>
${column.prefixes.length === 0 && column.objects.length === 0
? html`<div class="empty-state">Empty folder</div>`
: ''}
${column.prefixes.map(
(prefix) => html`
<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" />
</svg>
<span class="name">${getFileName(prefix)}</span>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</div>
`
)}
${column.objects.map(
(obj) => html`
<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)}" />
</svg>
<span class="name">${getFileName(obj.key)}</span>
</div>
`
)}
</div>
</div>
`;
}
}