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

473 lines
13 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;
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;
}
`,
];
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: '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>`;
}
return html`
<div class="columns-container">
${this.columns.map((column, index) => this.renderColumnWrapper(column, index))}
</div>
`;
}
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">
${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>
`;
}
}