feat(s3): add drag-and-drop and context-menu file uploads, inline text editing in preview, and increase preview width
This commit is contained in:
@@ -24,7 +24,7 @@ export class TsviewS3Browser extends DeesElement {
|
||||
private accessor refreshKey: number = 0;
|
||||
|
||||
@state()
|
||||
private accessor previewWidth: number = 350;
|
||||
private accessor previewWidth: number = 700;
|
||||
|
||||
@state()
|
||||
private accessor isResizingPreview: boolean = false;
|
||||
@@ -123,7 +123,7 @@ export class TsviewS3Browser extends DeesElement {
|
||||
}
|
||||
|
||||
.content.has-preview {
|
||||
grid-template-columns: 1fr 4px var(--preview-width, 350px);
|
||||
grid-template-columns: 1fr 4px var(--preview-width, 700px);
|
||||
}
|
||||
|
||||
.resize-divider {
|
||||
@@ -305,7 +305,7 @@ export class TsviewS3Browser extends DeesElement {
|
||||
const contentEl = this.shadowRoot?.querySelector('.content');
|
||||
if (!contentEl) return;
|
||||
const containerRect = contentEl.getBoundingClientRect();
|
||||
const newWidth = Math.min(Math.max(containerRect.right - e.clientX, 250), 600);
|
||||
const newWidth = Math.min(Math.max(containerRect.right - e.clientX, 250), 1000);
|
||||
this.previewWidth = newWidth;
|
||||
};
|
||||
|
||||
|
||||
@@ -43,10 +43,25 @@ export class TsviewS3Columns extends DeesElement {
|
||||
@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;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
@@ -77,6 +92,7 @@ export class TsviewS3Columns extends DeesElement {
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
@@ -183,6 +199,58 @@ export class TsviewS3Columns extends DeesElement {
|
||||
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;
|
||||
@@ -288,6 +356,13 @@ export class TsviewS3Columns extends DeesElement {
|
||||
await this.loadInitialColumn();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.clearFolderHover();
|
||||
this.dragCounters.clear();
|
||||
if (this.fileInputElement) { this.fileInputElement.remove(); this.fileInputElement = null; }
|
||||
}
|
||||
|
||||
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
|
||||
@@ -457,6 +532,11 @@ export class TsviewS3Columns extends DeesElement {
|
||||
iconName: 'lucide:filePlus',
|
||||
action: async () => this.openCreateDialog('file', prefix),
|
||||
},
|
||||
{
|
||||
name: 'Upload Files',
|
||||
iconName: 'lucide:upload',
|
||||
action: async () => this.triggerFileUpload(prefix),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Delete Folder',
|
||||
@@ -535,6 +615,11 @@ export class TsviewS3Columns extends DeesElement {
|
||||
iconName: 'lucide:filePlus',
|
||||
action: async () => this.openCreateDialog('file', prefix),
|
||||
},
|
||||
{
|
||||
name: 'Upload Files',
|
||||
iconName: 'lucide:upload',
|
||||
action: async () => this.triggerFileUpload(prefix),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -572,6 +657,145 @@ export class TsviewS3Columns extends DeesElement {
|
||||
return defaults[ext] || '';
|
||||
}
|
||||
|
||||
// --- File upload helpers ---
|
||||
|
||||
private ensureFileInput(): HTMLInputElement {
|
||||
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);
|
||||
}
|
||||
return this.fileInputElement;
|
||||
}
|
||||
|
||||
private triggerFileUpload(targetPrefix: string) {
|
||||
const input = this.ensureFileInput();
|
||||
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);
|
||||
const key = targetPrefix + file.name;
|
||||
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 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.loadInitialColumn();
|
||||
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.loadInitialColumn();
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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 handleColumnDrop(e: DragEvent, columnIndex: number) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.dragCounters.clear();
|
||||
this.dragOverColumnIndex = -1;
|
||||
|
||||
const files = e.dataTransfer?.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const targetPrefix = this.dragOverFolderPrefix ?? this.columns[columnIndex].prefix;
|
||||
this.clearFolderHover();
|
||||
this.uploadFiles(Array.from(files), 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;
|
||||
|
||||
@@ -675,7 +899,14 @@ export class TsviewS3Columns extends DeesElement {
|
||||
: this.bucketName;
|
||||
|
||||
return html`
|
||||
<div class="column" style="width: ${column.width}px">
|
||||
<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>
|
||||
@@ -686,9 +917,11 @@ export class TsviewS3Columns extends DeesElement {
|
||||
${column.prefixes.map(
|
||||
(prefix) => html`
|
||||
<div
|
||||
class="column-item folder ${column.selectedItem === prefix ? 'selected' : ''}"
|
||||
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" />
|
||||
@@ -715,6 +948,21 @@ export class TsviewS3Columns extends DeesElement {
|
||||
`
|
||||
)}
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ export class TsviewS3Preview extends DeesElement {
|
||||
@state()
|
||||
private accessor hasChanges: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor editing: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor contentType: string = '';
|
||||
|
||||
@@ -83,8 +86,12 @@ export class TsviewS3Preview extends DeesElement {
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-content dees-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview-content.code-editor {
|
||||
@@ -96,25 +103,6 @@ export class TsviewS3Preview extends DeesElement {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: #ccc;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
padding: 12px;
|
||||
border-top: 1px solid #333;
|
||||
@@ -225,11 +213,6 @@ export class TsviewS3Preview extends DeesElement {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.binary-preview {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
padding: 24px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -243,6 +226,7 @@ export class TsviewS3Preview extends DeesElement {
|
||||
this.error = '';
|
||||
this.originalTextContent = '';
|
||||
this.hasChanges = false;
|
||||
this.editing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,6 +237,7 @@ export class TsviewS3Preview extends DeesElement {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.hasChanges = false;
|
||||
this.editing = false;
|
||||
|
||||
try {
|
||||
const result = await apiService.getObject(this.bucketName, this.objectKey);
|
||||
@@ -395,12 +380,17 @@ export class TsviewS3Preview extends DeesElement {
|
||||
this.hasChanges = newValue !== this.originalTextContent;
|
||||
}
|
||||
|
||||
private handleEdit() {
|
||||
this.editing = true;
|
||||
}
|
||||
|
||||
private handleDiscard() {
|
||||
const codeEditor = this.shadowRoot?.querySelector('dees-input-code') as any;
|
||||
if (codeEditor) {
|
||||
codeEditor.value = this.originalTextContent;
|
||||
}
|
||||
this.hasChanges = false;
|
||||
this.editing = false;
|
||||
}
|
||||
|
||||
private async handleSave() {
|
||||
@@ -428,6 +418,7 @@ export class TsviewS3Preview extends DeesElement {
|
||||
if (success) {
|
||||
this.originalTextContent = currentContent;
|
||||
this.hasChanges = false;
|
||||
this.editing = false;
|
||||
// Update the stored content as well
|
||||
this.content = base64Content;
|
||||
}
|
||||
@@ -486,40 +477,60 @@ export class TsviewS3Preview extends DeesElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-content ${this.isText() ? 'code-editor' : ''}">
|
||||
${this.isImage()
|
||||
? html`<img class="preview-image" src="data:${this.contentType};base64,${this.content}" />`
|
||||
<div class="preview-content ${this.editing ? 'code-editor' : ''}">
|
||||
${this.editing
|
||||
? html`
|
||||
<dees-input-code
|
||||
.value=${this.originalTextContent}
|
||||
.language=${this.getLanguage()}
|
||||
height="100%"
|
||||
@content-change=${(e: CustomEvent) => this.handleContentChange(e)}
|
||||
></dees-input-code>
|
||||
`
|
||||
: this.isText()
|
||||
? html`
|
||||
<dees-input-code
|
||||
.value=${this.originalTextContent}
|
||||
<dees-preview
|
||||
.textContent=${this.originalTextContent}
|
||||
.filename=${getFileName(this.objectKey)}
|
||||
.language=${this.getLanguage()}
|
||||
height="100%"
|
||||
@content-change=${(e: CustomEvent) => this.handleContentChange(e)}
|
||||
></dees-input-code>
|
||||
.showToolbar=${true}
|
||||
.showFilename=${false}
|
||||
></dees-preview>
|
||||
`
|
||||
: html`
|
||||
<div class="binary-preview">
|
||||
<p>Binary file preview not available</p>
|
||||
<p>Download to view</p>
|
||||
</div>
|
||||
`}
|
||||
<dees-preview
|
||||
.base64=${this.content}
|
||||
.mimeType=${this.contentType}
|
||||
.filename=${getFileName(this.objectKey)}
|
||||
.showToolbar=${true}
|
||||
.showFilename=${false}
|
||||
></dees-preview>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="preview-actions">
|
||||
${this.hasChanges ? html`
|
||||
<button class="action-btn secondary" @click=${this.handleDiscard}>Discard</button>
|
||||
<button
|
||||
class="action-btn primary"
|
||||
@click=${this.handleSave}
|
||||
?disabled=${this.saving}
|
||||
>
|
||||
${this.saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
` : html`
|
||||
<button class="action-btn" @click=${this.handleDownload}>Download</button>
|
||||
<button class="action-btn danger" @click=${this.handleDelete}>Delete</button>
|
||||
`}
|
||||
${this.editing
|
||||
? html`
|
||||
<button class="action-btn secondary" @click=${this.handleDiscard}>
|
||||
${this.hasChanges ? 'Discard' : 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
class="action-btn primary"
|
||||
@click=${this.handleSave}
|
||||
?disabled=${this.saving || !this.hasChanges}
|
||||
>
|
||||
${this.saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
`
|
||||
: html`
|
||||
${this.isText()
|
||||
? html`<button class="action-btn" @click=${this.handleEdit}>Edit</button>`
|
||||
: ''}
|
||||
<button class="action-btn" @click=${this.handleDownload}>Download</button>
|
||||
<button class="action-btn danger" @click=${this.handleDelete}>Delete</button>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user