1128 lines
33 KiB
TypeScript
1128 lines
33 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;
|
|
|
|
// FileSystem API types for drag-and-drop folder support
|
|
interface FileSystemEntry {
|
|
isFile: boolean;
|
|
isDirectory: boolean;
|
|
name: string;
|
|
}
|
|
interface FileSystemFileEntry extends FileSystemEntry {
|
|
file(successCallback: (file: File) => void, errorCallback?: (error: Error) => void): void;
|
|
}
|
|
interface FileSystemDirectoryEntry extends FileSystemEntry {
|
|
createReader(): FileSystemDirectoryReader;
|
|
}
|
|
interface FileSystemDirectoryReader {
|
|
readEntries(
|
|
successCallback: (entries: FileSystemEntry[]) => void,
|
|
errorCallback?: (error: Error) => void
|
|
): void;
|
|
}
|
|
|
|
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 = '';
|
|
|
|
@state()
|
|
private accessor dragOverColumnIndex: number = -1;
|
|
|
|
@state()
|
|
private accessor dragOverFolderPrefix: string | null = null;
|
|
|
|
@state()
|
|
private accessor uploading: boolean = false;
|
|
|
|
@state()
|
|
private accessor uploadProgress: { current: number; total: number } | null = null;
|
|
|
|
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;
|
|
private dragCounters: Map<number, number> = new Map();
|
|
private folderHoverTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private fileInputElement: HTMLInputElement | null = null;
|
|
private folderInputElement: HTMLInputElement | null = null;
|
|
|
|
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;
|
|
position: relative;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.column.drag-over {
|
|
background: rgba(59, 130, 246, 0.08);
|
|
outline: 2px dashed rgba(59, 130, 246, 0.4);
|
|
outline-offset: -2px;
|
|
}
|
|
|
|
.column-item.folder.drag-target {
|
|
background: rgba(59, 130, 246, 0.2) !important;
|
|
outline: 1px solid rgba(59, 130, 246, 0.5);
|
|
}
|
|
|
|
.upload-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
z-index: 10;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.upload-overlay .upload-text {
|
|
color: #e0e0e0;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.upload-overlay .upload-progress {
|
|
color: #888;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.drag-hint {
|
|
position: absolute;
|
|
bottom: 8px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: rgba(59, 130, 246, 0.9);
|
|
color: white;
|
|
padding: 4px 12px;
|
|
border-radius: 4px;
|
|
font-size: 11px;
|
|
pointer-events: none;
|
|
white-space: nowrap;
|
|
z-index: 5;
|
|
}
|
|
|
|
.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();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
this.clearFolderHover();
|
|
this.dragCounters.clear();
|
|
if (this.fileInputElement) { this.fileInputElement.remove(); this.fileInputElement = null; }
|
|
if (this.folderInputElement) { this.folderInputElement.remove(); this.folderInputElement = null; }
|
|
}
|
|
|
|
updated(changedProperties: Map<string, unknown>) {
|
|
if (changedProperties.has('bucketName') || changedProperties.has('currentPrefix')) {
|
|
this.loadInitialColumn();
|
|
} else if (changedProperties.has('refreshKey')) {
|
|
this.refreshAllColumns();
|
|
}
|
|
}
|
|
|
|
private async loadInitialColumn() {
|
|
this.loading = true;
|
|
try {
|
|
// Parse the path segments from currentPrefix
|
|
// e.g., "folder1/folder2/folder3/" → ["folder1/", "folder1/folder2/", "folder1/folder2/folder3/"]
|
|
const pathSegments = this.getPathSegments(this.currentPrefix);
|
|
|
|
// Build all prefixes we need to load (including root)
|
|
const prefixesToLoad = ['', ...pathSegments];
|
|
|
|
// Load all columns in parallel
|
|
const columnResults = await Promise.all(
|
|
prefixesToLoad.map(prefix =>
|
|
apiService.listObjects(this.bucketName, prefix, '/')
|
|
)
|
|
);
|
|
|
|
// Build columns array with proper selections
|
|
this.columns = columnResults.map((result, index) => {
|
|
const prefix = prefixesToLoad[index];
|
|
// The selected item is the next prefix in the path (if any)
|
|
const selectedItem = index < pathSegments.length ? pathSegments[index] : null;
|
|
|
|
return {
|
|
prefix,
|
|
objects: result.objects,
|
|
prefixes: result.prefixes,
|
|
selectedItem,
|
|
width: this.DEFAULT_COLUMN_WIDTH,
|
|
};
|
|
});
|
|
|
|
// Auto-scroll to show the rightmost column
|
|
this.updateComplete.then(() => this.scrollToEnd());
|
|
} catch (err) {
|
|
console.error('Error loading objects:', err);
|
|
this.columns = [];
|
|
}
|
|
this.loading = false;
|
|
}
|
|
|
|
// Helper to parse prefix into cumulative path segments
|
|
private getPathSegments(prefix: string): string[] {
|
|
if (!prefix) return [];
|
|
|
|
const parts = prefix.split('/').filter(p => p); // Remove empty strings
|
|
const segments: string[] = [];
|
|
let cumulative = '';
|
|
|
|
for (const part of parts) {
|
|
cumulative += part + '/';
|
|
segments.push(cumulative);
|
|
}
|
|
|
|
return segments;
|
|
}
|
|
|
|
private async refreshAllColumns() {
|
|
const updatedColumns = await Promise.all(
|
|
this.columns.map(async (col) => {
|
|
try {
|
|
const result = await apiService.listObjects(this.bucketName, col.prefix, '/');
|
|
return { ...col, objects: result.objects, prefixes: result.prefixes };
|
|
} catch {
|
|
return col;
|
|
}
|
|
})
|
|
);
|
|
this.columns = updatedColumns;
|
|
}
|
|
|
|
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),
|
|
},
|
|
{
|
|
name: 'Upload Files...',
|
|
iconName: 'lucide:file',
|
|
action: async () => this.triggerFileUpload(prefix, 'files'),
|
|
},
|
|
{
|
|
name: 'Upload Folder...',
|
|
iconName: 'lucide:folderUp',
|
|
action: async () => this.triggerFileUpload(prefix, 'folder'),
|
|
},
|
|
{ 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) {
|
|
// Remove columns that were inside the deleted folder
|
|
this.columns = this.columns.slice(0, columnIndex + 1);
|
|
this.columns = this.columns.map((col, i) =>
|
|
i === columnIndex ? { ...col, selectedItem: null } : col
|
|
);
|
|
await this.refreshColumnByPrefix(this.columns[columnIndex].prefix);
|
|
}
|
|
}
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
|
|
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.refreshColumnByPrefix(this.columns[columnIndex].prefix);
|
|
}
|
|
}
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
|
|
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),
|
|
},
|
|
{
|
|
name: 'Upload Files...',
|
|
iconName: 'lucide:file',
|
|
action: async () => this.triggerFileUpload(prefix, 'files'),
|
|
},
|
|
{
|
|
name: 'Upload Folder...',
|
|
iconName: 'lucide:folderUp',
|
|
action: async () => this.triggerFileUpload(prefix, 'folder'),
|
|
},
|
|
]);
|
|
}
|
|
|
|
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] || '';
|
|
}
|
|
|
|
// --- File upload helpers ---
|
|
|
|
private ensureFileInputs(): void {
|
|
if (!this.fileInputElement) {
|
|
this.fileInputElement = document.createElement('input');
|
|
this.fileInputElement.type = 'file';
|
|
this.fileInputElement.multiple = true;
|
|
this.fileInputElement.style.display = 'none';
|
|
this.shadowRoot!.appendChild(this.fileInputElement);
|
|
}
|
|
if (!this.folderInputElement) {
|
|
this.folderInputElement = document.createElement('input');
|
|
this.folderInputElement.type = 'file';
|
|
this.folderInputElement.multiple = true;
|
|
(this.folderInputElement as any).webkitdirectory = true;
|
|
this.folderInputElement.style.display = 'none';
|
|
this.shadowRoot!.appendChild(this.folderInputElement);
|
|
}
|
|
}
|
|
|
|
private triggerFileUpload(targetPrefix: string, type: 'files' | 'folder') {
|
|
this.ensureFileInputs();
|
|
const input = type === 'folder' ? this.folderInputElement! : this.fileInputElement!;
|
|
input.value = '';
|
|
const handler = async () => {
|
|
input.removeEventListener('change', handler);
|
|
if (input.files && input.files.length > 0) {
|
|
await this.uploadFiles(Array.from(input.files), targetPrefix);
|
|
}
|
|
};
|
|
input.addEventListener('change', handler);
|
|
input.click();
|
|
}
|
|
|
|
private async uploadFiles(files: File[], targetPrefix: string) {
|
|
this.uploading = true;
|
|
this.uploadProgress = { current: 0, total: files.length };
|
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
this.uploadProgress = { current: i + 1, total: files.length };
|
|
try {
|
|
const base64 = await this.readFileAsBase64(file);
|
|
// Use webkitRelativePath if available (folder upload), otherwise just filename
|
|
const relativePath = (file as any).webkitRelativePath || file.name;
|
|
const key = targetPrefix + relativePath;
|
|
const contentType = file.type || this.getContentType(file.name.split('.').pop()?.toLowerCase() || '');
|
|
await apiService.putObject(this.bucketName, key, base64, contentType);
|
|
} catch (err) {
|
|
console.error(`Failed to upload ${file.name}:`, err);
|
|
}
|
|
}
|
|
|
|
this.uploading = false;
|
|
this.uploadProgress = null;
|
|
await this.refreshColumnByPrefix(targetPrefix);
|
|
}
|
|
|
|
private async uploadFilesWithPaths(
|
|
files: { file: File; relativePath: string }[],
|
|
targetPrefix: string
|
|
) {
|
|
this.uploading = true;
|
|
this.uploadProgress = { current: 0, total: files.length };
|
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
const { file, relativePath } = files[i];
|
|
this.uploadProgress = { current: i + 1, total: files.length };
|
|
try {
|
|
const base64 = await this.readFileAsBase64(file);
|
|
const key = targetPrefix + relativePath;
|
|
const contentType = file.type || this.getContentType(file.name.split('.').pop()?.toLowerCase() || '');
|
|
await apiService.putObject(this.bucketName, key, base64, contentType);
|
|
} catch (err) {
|
|
console.error(`Failed to upload ${relativePath}:`, err);
|
|
}
|
|
}
|
|
|
|
this.uploading = false;
|
|
this.uploadProgress = null;
|
|
await this.refreshColumnByPrefix(targetPrefix);
|
|
}
|
|
|
|
private readFileAsBase64(file: File): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const result = reader.result as string;
|
|
resolve(result.split(',')[1] || '');
|
|
};
|
|
reader.onerror = () => reject(reader.error);
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
private async refreshColumnByPrefix(prefix: string) {
|
|
const columnIndex = this.columns.findIndex(col => col.prefix === prefix);
|
|
if (columnIndex === -1) {
|
|
await this.refreshAllColumns();
|
|
return;
|
|
}
|
|
try {
|
|
const result = await apiService.listObjects(this.bucketName, prefix, '/');
|
|
this.columns = this.columns.map((col, i) =>
|
|
i === columnIndex ? { ...col, objects: result.objects, prefixes: result.prefixes } : col
|
|
);
|
|
} catch {
|
|
await this.refreshAllColumns();
|
|
}
|
|
}
|
|
|
|
// --- Drag-and-drop handlers ---
|
|
|
|
private handleColumnDragEnter(e: DragEvent, columnIndex: number) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const count = (this.dragCounters.get(columnIndex) || 0) + 1;
|
|
this.dragCounters.set(columnIndex, count);
|
|
this.dragOverColumnIndex = columnIndex;
|
|
}
|
|
|
|
private handleColumnDragOver(e: DragEvent) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
|
}
|
|
|
|
private handleColumnDragLeave(e: DragEvent, columnIndex: number) {
|
|
e.stopPropagation();
|
|
const count = (this.dragCounters.get(columnIndex) || 1) - 1;
|
|
this.dragCounters.set(columnIndex, count);
|
|
if (count <= 0) {
|
|
this.dragCounters.delete(columnIndex);
|
|
if (this.dragOverColumnIndex === columnIndex) this.dragOverColumnIndex = -1;
|
|
this.clearFolderHover();
|
|
}
|
|
}
|
|
|
|
private async handleColumnDrop(e: DragEvent, columnIndex: number) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.dragCounters.clear();
|
|
this.dragOverColumnIndex = -1;
|
|
|
|
const items = e.dataTransfer?.items;
|
|
if (!items || items.length === 0) return;
|
|
|
|
const targetPrefix = this.dragOverFolderPrefix ?? this.columns[columnIndex].prefix;
|
|
this.clearFolderHover();
|
|
|
|
// Collect all files (including from nested folders)
|
|
const allFiles: { file: File; relativePath: string }[] = [];
|
|
|
|
const processEntry = async (entry: FileSystemEntry, path: string): Promise<void> => {
|
|
if (entry.isFile) {
|
|
const fileEntry = entry as FileSystemFileEntry;
|
|
const file = await new Promise<File>((resolve, reject) => {
|
|
fileEntry.file(resolve, reject);
|
|
});
|
|
allFiles.push({ file, relativePath: path + file.name });
|
|
} else if (entry.isDirectory) {
|
|
const dirEntry = entry as FileSystemDirectoryEntry;
|
|
const reader = dirEntry.createReader();
|
|
const entries = await new Promise<FileSystemEntry[]>((resolve, reject) => {
|
|
reader.readEntries(resolve, reject);
|
|
});
|
|
for (const childEntry of entries) {
|
|
await processEntry(childEntry, path + entry.name + '/');
|
|
}
|
|
}
|
|
};
|
|
|
|
// Process all dropped items
|
|
for (let i = 0; i < items.length; i++) {
|
|
const item = items[i];
|
|
if (item.kind === 'file') {
|
|
const entry = (item as any).webkitGetAsEntry();
|
|
if (entry) {
|
|
await processEntry(entry, '');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (allFiles.length > 0) {
|
|
await this.uploadFilesWithPaths(allFiles, targetPrefix);
|
|
}
|
|
}
|
|
|
|
private handleFolderDragEnter(e: DragEvent, folderPrefix: string) {
|
|
e.stopPropagation();
|
|
this.clearFolderHover();
|
|
this.folderHoverTimer = setTimeout(() => {
|
|
this.dragOverFolderPrefix = folderPrefix;
|
|
}, 500);
|
|
}
|
|
|
|
private handleFolderDragLeave(e: DragEvent, folderPrefix: string) {
|
|
e.stopPropagation();
|
|
if (this.dragOverFolderPrefix === folderPrefix) this.dragOverFolderPrefix = null;
|
|
if (this.folderHoverTimer) {
|
|
clearTimeout(this.folderHoverTimer);
|
|
this.folderHoverTimer = null;
|
|
}
|
|
}
|
|
|
|
private clearFolderHover() {
|
|
if (this.folderHoverTimer) { clearTimeout(this.folderHoverTimer); this.folderHoverTimer = null; }
|
|
this.dragOverFolderPrefix = null;
|
|
}
|
|
|
|
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.refreshColumnByPrefix(this.createDialogPrefix);
|
|
}
|
|
}
|
|
|
|
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 ${this.dragOverColumnIndex === index ? 'drag-over' : ''}"
|
|
style="width: ${column.width}px"
|
|
@dragenter=${(e: DragEvent) => this.handleColumnDragEnter(e, index)}
|
|
@dragover=${(e: DragEvent) => this.handleColumnDragOver(e)}
|
|
@dragleave=${(e: DragEvent) => this.handleColumnDragLeave(e, index)}
|
|
@drop=${(e: DragEvent) => this.handleColumnDrop(e, index)}
|
|
>
|
|
<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' : ''} ${this.dragOverFolderPrefix === prefix ? 'drag-target' : ''}"
|
|
@click=${() => this.selectFolder(index, prefix)}
|
|
@contextmenu=${(e: MouseEvent) => this.handleFolderContextMenu(e, index, prefix)}
|
|
@dragenter=${(e: DragEvent) => this.handleFolderDragEnter(e, prefix)}
|
|
@dragleave=${(e: DragEvent) => this.handleFolderDragLeave(e, 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>
|
|
${this.dragOverColumnIndex === index ? html`
|
|
<div class="drag-hint">
|
|
${this.dragOverFolderPrefix
|
|
? `Drop to upload into ${getFileName(this.dragOverFolderPrefix)}`
|
|
: 'Drop to upload here'}
|
|
</div>
|
|
` : ''}
|
|
${this.uploading ? html`
|
|
<div class="upload-overlay">
|
|
<div class="upload-text">Uploading...</div>
|
|
${this.uploadProgress ? html`
|
|
<div class="upload-progress">${this.uploadProgress.current} / ${this.uploadProgress.total} files</div>
|
|
` : ''}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|