Only reset columns when bucket changes, not when prefix changes. Internal folder navigation is handled by selectFolder() which properly appends new columns while preserving previous ones.
296 lines
7.9 KiB
TypeScript
296 lines
7.9 KiB
TypeScript
import * as plugins from '../plugins.js';
|
|
import { apiService, type IS3Object } from '../services/index.js';
|
|
|
|
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
|
|
|
interface IColumn {
|
|
prefix: string;
|
|
objects: IS3Object[];
|
|
prefixes: string[];
|
|
selectedItem: string | null;
|
|
}
|
|
|
|
@customElement('tsview-s3-columns')
|
|
export class TsviewS3Columns extends DeesElement {
|
|
@property({ type: String })
|
|
public accessor bucketName: string = '';
|
|
|
|
@property({ type: String })
|
|
public accessor currentPrefix: string = '';
|
|
|
|
@state()
|
|
private accessor columns: IColumn[] = [];
|
|
|
|
@state()
|
|
private accessor loading: boolean = false;
|
|
|
|
public static styles = [
|
|
cssManager.defaultStyles,
|
|
css`
|
|
:host {
|
|
display: block;
|
|
height: 100%;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.columns-container {
|
|
display: flex;
|
|
height: 100%;
|
|
min-width: 100%;
|
|
}
|
|
|
|
.column {
|
|
min-width: 220px;
|
|
max-width: 280px;
|
|
border-right: 1px solid #333;
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
}
|
|
|
|
.column:last-child {
|
|
border-right: 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(99, 102, 241, 0.2);
|
|
color: #818cf8;
|
|
}
|
|
|
|
.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
|
|
// Internal folder navigation is handled by selectFolder() which appends columns
|
|
if (changedProperties.has('bucketName')) {
|
|
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,
|
|
},
|
|
];
|
|
} 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,
|
|
},
|
|
];
|
|
} 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 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 getFileName(path: string): string {
|
|
const parts = path.replace(/\/$/, '').split('/');
|
|
return parts[parts.length - 1] || path;
|
|
}
|
|
|
|
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.renderColumn(column, index))}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderColumn(column: IColumn, index: number) {
|
|
const headerName = column.prefix
|
|
? this.getFileName(column.prefix)
|
|
: this.bucketName;
|
|
|
|
return html`
|
|
<div class="column">
|
|
<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">${this.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">${this.getFileName(obj.key)}</span>
|
|
</div>
|
|
`
|
|
)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|