393 lines
11 KiB
TypeScript
393 lines
11 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;
|
|
|
|
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';
|
|
}
|
|
|
|
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)}
|
|
>
|
|
<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)}
|
|
>
|
|
<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>
|
|
`;
|
|
}
|
|
}
|