diff --git a/changelog.md b/changelog.md index cb605fe..12adfc2 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-03-12 - 3.48.0 - feat(dataview) +add an S3 browser component with column and list views, file preview, editing, and object management + +- introduces a new dees-s3-browser module with shared interfaces, utilities, demo, and exports +- supports browsing S3-style prefixes in both column and list layouts with breadcrumb navigation +- adds file preview with text editing, download, and delete actions +- includes create, rename, move, delete, upload, and drag-and-drop handling for files and folders +- adds optional live change stream integration with refresh indicators + ## 2026-03-11 - 3.47.2 - fix(deps) bump @design.estate/dees-domtools and @design.estate/dees-element dependencies diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 3271858..413251f 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@design.estate/dees-catalog', - version: '3.47.2', + version: '3.48.0', description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.' } diff --git a/ts_web/elements/00group-dataview/dees-s3-browser/dees-s3-browser.demo.ts b/ts_web/elements/00group-dataview/dees-s3-browser/dees-s3-browser.demo.ts new file mode 100644 index 0000000..e6e9151 --- /dev/null +++ b/ts_web/elements/00group-dataview/dees-s3-browser/dees-s3-browser.demo.ts @@ -0,0 +1,156 @@ +import { html } from '@design.estate/dees-element'; +import type { IS3DataProvider, IS3Object } from './interfaces.js'; +import './dees-s3-browser.js'; + +// Mock in-memory S3 data provider for demo purposes +class MockS3DataProvider implements IS3DataProvider { + private objects: Map = new Map(); + + constructor() { + const now = new Date().toISOString(); + // Seed with sample data + this.objects.set('documents/readme.md', { + content: btoa('# Welcome\n\nThis is a demo S3 browser.\n'), + contentType: 'text/markdown', + size: 42, + lastModified: now, + }); + this.objects.set('documents/config.json', { + content: btoa('{\n "name": "demo",\n "version": "1.0.0"\n}'), + contentType: 'application/json', + size: 48, + lastModified: now, + }); + this.objects.set('documents/notes/todo.txt', { + content: btoa('Buy milk\nFix bug #42\nDeploy to production'), + contentType: 'text/plain', + size: 45, + lastModified: now, + }); + this.objects.set('images/logo.png', { + content: btoa('fake-png-data'), + contentType: 'image/png', + size: 24500, + lastModified: now, + }); + this.objects.set('images/banner.jpg', { + content: btoa('fake-jpg-data'), + contentType: 'image/jpeg', + size: 156000, + lastModified: now, + }); + this.objects.set('scripts/deploy.sh', { + content: btoa('#!/bin/bash\necho "Deploying..."\n'), + contentType: 'text/plain', + size: 34, + lastModified: now, + }); + this.objects.set('index.html', { + content: btoa('\n\n\n

Hello World

\n\n'), + contentType: 'text/html', + size: 72, + lastModified: now, + }); + this.objects.set('styles.css', { + content: btoa('body { margin: 0; font-family: sans-serif; }'), + contentType: 'text/css', + size: 44, + lastModified: now, + }); + } + + async listObjects(bucket: string, prefix?: string, delimiter?: string): Promise<{ objects: IS3Object[]; prefixes: string[] }> { + const pfx = prefix || ''; + const objects: IS3Object[] = []; + const prefixes = new Set(); + + for (const [key, data] of this.objects) { + if (!key.startsWith(pfx)) continue; + const rest = key.slice(pfx.length); + + if (delimiter) { + const slashIndex = rest.indexOf(delimiter); + if (slashIndex >= 0) { + prefixes.add(pfx + rest.slice(0, slashIndex + 1)); + } else { + objects.push({ key, size: data.size, lastModified: data.lastModified }); + } + } else { + objects.push({ key, size: data.size, lastModified: data.lastModified }); + } + } + + return { objects, prefixes: Array.from(prefixes).sort() }; + } + + async getObject(bucket: string, key: string): Promise<{ content: string; contentType: string; size: number; lastModified: string }> { + const obj = this.objects.get(key); + if (!obj) throw new Error('Not found'); + return { ...obj }; + } + + async putObject(bucket: string, key: string, base64Content: string, contentType: string): Promise { + this.objects.set(key, { + content: base64Content, + contentType, + size: atob(base64Content).length, + lastModified: new Date().toISOString(), + }); + return true; + } + + async deleteObject(bucket: string, key: string): Promise { + return this.objects.delete(key); + } + + async deletePrefix(bucket: string, prefix: string): Promise { + for (const key of this.objects.keys()) { + if (key.startsWith(prefix)) { + this.objects.delete(key); + } + } + return true; + } + + async getObjectUrl(bucket: string, key: string): Promise { + const obj = this.objects.get(key); + if (!obj) return ''; + const blob = new Blob([Uint8Array.from(atob(obj.content), c => c.charCodeAt(0))], { type: obj.contentType }); + return URL.createObjectURL(blob); + } + + async moveObject(bucket: string, sourceKey: string, destKey: string): Promise<{ success: boolean; error?: string }> { + const obj = this.objects.get(sourceKey); + if (!obj) return { success: false, error: 'Source not found' }; + this.objects.set(destKey, { ...obj, lastModified: new Date().toISOString() }); + this.objects.delete(sourceKey); + return { success: true }; + } + + async movePrefix(bucket: string, sourcePrefix: string, destPrefix: string): Promise<{ success: boolean; movedCount?: number; error?: string }> { + let count = 0; + const toMove = Array.from(this.objects.entries()).filter(([k]) => k.startsWith(sourcePrefix)); + for (const [key, data] of toMove) { + const newKey = destPrefix + key.slice(sourcePrefix.length); + this.objects.set(newKey, { ...data, lastModified: new Date().toISOString() }); + this.objects.delete(key); + count++; + } + return { success: true, movedCount: count }; + } +} + +export const demoFunc = () => html` + +
+ +
+`; diff --git a/ts_web/elements/00group-dataview/dees-s3-browser/dees-s3-browser.ts b/ts_web/elements/00group-dataview/dees-s3-browser/dees-s3-browser.ts new file mode 100644 index 0000000..0fd6b14 --- /dev/null +++ b/ts_web/elements/00group-dataview/dees-s3-browser/dees-s3-browser.ts @@ -0,0 +1,439 @@ +import { customElement, html, css, cssManager, property, state, DeesElement } from '@design.estate/dees-element'; +import { themeDefaultStyles } from '../../00theme.js'; +import { demoFunc } from './dees-s3-browser.demo.js'; +import type { IS3DataProvider, IS3ChangeEvent } from './interfaces.js'; +import './dees-s3-columns.js'; +import './dees-s3-keys.js'; +import './dees-s3-preview.js'; + +declare global { + interface HTMLElementTagNameMap { + 'dees-s3-browser': DeesS3Browser; + } +} + +type TViewType = 'columns' | 'keys'; + +@customElement('dees-s3-browser') +export class DeesS3Browser extends DeesElement { + public static demo = demoFunc; + public static demoGroups = ['Data View']; + + @property({ type: Object }) + public accessor dataProvider: IS3DataProvider | null = null; + + @property({ type: String }) + public accessor bucketName: string = ''; + + /** + * Optional change stream subscription. + * Pass a function that takes a callback and returns an unsubscribe function. + */ + @property({ type: Object }) + public accessor onChangeEvent: ((callback: (event: IS3ChangeEvent) => void) => (() => void)) | null = null; + + @state() + private accessor viewType: TViewType = 'columns'; + + @state() + private accessor currentPrefix: string = ''; + + @state() + private accessor selectedKey: string = ''; + + @state() + private accessor refreshKey: number = 0; + + @state() + private accessor previewWidth: number = 700; + + @state() + private accessor isResizingPreview: boolean = false; + + @state() + private accessor recentChangeCount: number = 0; + + @state() + private accessor isStreamConnected: boolean = false; + + private changeUnsubscribe: (() => void) | null = null; + + public static styles = [ + cssManager.defaultStyles, + themeDefaultStyles, + css` + :host { + display: block; + height: 100%; + } + + .browser-container { + display: flex; + flex-direction: column; + height: 100%; + } + + .toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.03)', 'rgba(0, 0, 0, 0.2)')}; + border-radius: 8px; + margin-bottom: 16px; + } + + .breadcrumb { + display: flex; + align-items: center; + gap: 4px; + flex: 1; + font-size: 14px; + color: ${cssManager.bdTheme('#71717a', '#999')}; + } + + .breadcrumb-item { + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + transition: background 0.15s; + } + + .breadcrumb-item:hover { + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.1)')}; + color: ${cssManager.bdTheme('#18181b', '#fff')}; + } + + .breadcrumb-separator { + color: ${cssManager.bdTheme('#d4d4d8', '#555')}; + } + + .view-toggle { + display: flex; + gap: 4px; + } + + .view-btn { + padding: 6px 12px; + background: transparent; + border: 1px solid ${cssManager.bdTheme('#d4d4d8', '#444')}; + color: ${cssManager.bdTheme('#71717a', '#888')}; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + transition: all 0.15s; + } + + .view-btn:hover { + border-color: ${cssManager.bdTheme('#a1a1aa', '#666')}; + color: ${cssManager.bdTheme('#3f3f46', '#aaa')}; + } + + .view-btn.active { + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.1)')}; + border-color: ${cssManager.bdTheme('#a1a1aa', '#404040')}; + color: ${cssManager.bdTheme('#18181b', '#e0e0e0')}; + } + + .content { + flex: 1; + display: grid; + grid-template-columns: 1fr; + gap: 0; + overflow: hidden; + } + + .content.has-preview { + grid-template-columns: 1fr 4px var(--preview-width, 700px); + } + + .resize-divider { + width: 4px; + background: transparent; + cursor: col-resize; + transition: background 0.2s; + } + + .resize-divider:hover, + .resize-divider.active { + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.2)')}; + } + + .main-view { + overflow: auto; + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.02)', 'rgba(0, 0, 0, 0.2)')}; + border-radius: 8px; + } + + .preview-panel { + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.02)', 'rgba(0, 0, 0, 0.2)')}; + border-radius: 8px; + overflow: hidden; + margin-left: 12px; + } + + @media (max-width: 1024px) { + .content, + .content.has-preview { + grid-template-columns: 1fr; + } + + .preview-panel, + .resize-divider { + display: none; + } + } + + .stream-status { + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: ${cssManager.bdTheme('#71717a', '#888')}; + margin-left: auto; + margin-right: 12px; + } + + .stream-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: ${cssManager.bdTheme('#a1a1aa', '#888')}; + } + + .stream-dot.connected { + background: #22c55e; + } + + .change-indicator { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: rgba(245, 158, 11, 0.2); + border-radius: 4px; + font-size: 11px; + color: #f59e0b; + margin-right: 12px; + } + + .change-indicator.pulse { + animation: pulse-orange 1s ease-in-out; + } + + @keyframes pulse-orange { + 0% { background: rgba(245, 158, 11, 0.4); } + 100% { background: rgba(245, 158, 11, 0.2); } + } + `, + ]; + + async connectedCallback() { + super.connectedCallback(); + this.subscribeToChanges(); + } + + async disconnectedCallback() { + await super.disconnectedCallback(); + this.unsubscribeFromChanges(); + } + + /** + * Public method to trigger a refresh of child components + */ + public refresh() { + this.refreshKey++; + } + + private setViewType(type: TViewType) { + this.viewType = type; + } + + private navigateToPrefix(prefix: string) { + this.currentPrefix = prefix; + this.selectedKey = ''; + } + + private handleKeySelected(e: CustomEvent) { + this.selectedKey = e.detail.key; + } + + private handleNavigate(e: CustomEvent) { + this.navigateToPrefix(e.detail.prefix); + } + + private handleObjectDeleted(e: CustomEvent) { + this.selectedKey = ''; + this.refreshKey++; + } + + updated(changedProperties: Map) { + if (changedProperties.has('bucketName')) { + this.selectedKey = ''; + this.currentPrefix = ''; + this.recentChangeCount = 0; + this.unsubscribeFromChanges(); + this.subscribeToChanges(); + } + if (changedProperties.has('onChangeEvent')) { + this.unsubscribeFromChanges(); + this.subscribeToChanges(); + } + } + + private subscribeToChanges() { + if (!this.onChangeEvent) { + this.isStreamConnected = false; + return; + } + + try { + this.changeUnsubscribe = this.onChangeEvent((event: IS3ChangeEvent) => { + this.handleChange(event); + }); + this.isStreamConnected = true; + } catch (error) { + console.warn('[S3Browser] Failed to subscribe to changes:', error); + this.isStreamConnected = false; + } + } + + private unsubscribeFromChanges() { + if (this.changeUnsubscribe) { + this.changeUnsubscribe(); + this.changeUnsubscribe = null; + } + this.isStreamConnected = false; + } + + private handleChange(event: IS3ChangeEvent) { + this.recentChangeCount++; + this.refreshKey++; + } + + private startPreviewResize = (e: MouseEvent) => { + e.preventDefault(); + this.isResizingPreview = true; + document.addEventListener('mousemove', this.handlePreviewResize); + document.addEventListener('mouseup', this.endPreviewResize); + }; + + private handlePreviewResize = (e: MouseEvent) => { + if (!this.isResizingPreview) return; + const contentEl = this.shadowRoot?.querySelector('.content'); + if (!contentEl) return; + const containerRect = contentEl.getBoundingClientRect(); + const newWidth = Math.min(Math.max(containerRect.right - e.clientX, 250), 1000); + this.previewWidth = newWidth; + }; + + private endPreviewResize = () => { + this.isResizingPreview = false; + document.removeEventListener('mousemove', this.handlePreviewResize); + document.removeEventListener('mouseup', this.endPreviewResize); + }; + + render() { + const breadcrumbParts = this.currentPrefix + ? this.currentPrefix.split('/').filter(Boolean) + : []; + + return html` +
+
+ + + ${this.onChangeEvent ? html` +
+ + ${this.isStreamConnected ? 'Live' : 'Offline'} +
+ ` : ''} + ${this.recentChangeCount > 0 + ? html` +
+ ${this.recentChangeCount} change${this.recentChangeCount > 1 ? 's' : ''} +
+ ` + : ''} +
+ + +
+
+ +
+
+ ${this.viewType === 'columns' + ? html` + + ` + : html` + + `} +
+ + ${this.selectedKey + ? html` +
+
+ +
+ ` + : ''} +
+
+ `; + } +} diff --git a/ts_web/elements/00group-dataview/dees-s3-browser/dees-s3-columns.ts b/ts_web/elements/00group-dataview/dees-s3-browser/dees-s3-columns.ts new file mode 100644 index 0000000..6e2f951 --- /dev/null +++ b/ts_web/elements/00group-dataview/dees-s3-browser/dees-s3-columns.ts @@ -0,0 +1,1652 @@ +import { customElement, html, css, cssManager, property, state, DeesElement } from '@design.estate/dees-element'; +import { DeesContextmenu } from '../../00group-overlay/dees-contextmenu/dees-contextmenu.js'; +import { themeDefaultStyles } from '../../00theme.js'; +import type { IS3DataProvider, IS3Object, IColumn } from './interfaces.js'; +import { getFileName, validateMove, getParentPrefix, getContentType, getDefaultContent, getPathSegments } from './utilities.js'; + +// 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; +} + +declare global { + interface HTMLElementTagNameMap { + 'dees-s3-columns': DeesS3Columns; + } +} + +@customElement('dees-s3-columns') +export class DeesS3Columns extends DeesElement { + @property({ type: Object }) + public accessor dataProvider: IS3DataProvider | null = null; + + @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; + + // Move dialog state + @state() + private accessor showMoveDialog: boolean = false; + + @state() + private accessor moveSource: { key: string; isFolder: boolean } | null = null; + + @state() + private accessor moveDestination: string = ''; + + @state() + private accessor moveInProgress: boolean = false; + + @state() + private accessor moveError: string | null = null; + + // Move picker dialog state + @state() + private accessor showMovePickerDialog: boolean = false; + + @state() + private accessor movePickerSource: { key: string; isFolder: boolean } | null = null; + + @state() + private accessor movePickerCurrentPrefix: string = ''; + + @state() + private accessor movePickerPrefixes: string[] = []; + + @state() + private accessor movePickerLoading: boolean = false; + + // Rename dialog state + @state() + private accessor showRenameDialog: boolean = false; + + @state() + private accessor renameSource: { key: string; isFolder: boolean } | null = null; + + @state() + private accessor renameName: string = ''; + + @state() + private accessor renameInProgress: boolean = false; + + @state() + private accessor renameError: string | null = null; + + // Internal drag state + @state() + private accessor draggedItem: { key: string; isFolder: boolean } | 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 = new Map(); + private folderHoverTimer: ReturnType | null = null; + private fileInputElement: HTMLInputElement | null = null; + private folderInputElement: HTMLInputElement | null = null; + + public static styles = [ + cssManager.defaultStyles, + themeDefaultStyles, + 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: ${cssManager.bdTheme('#e5e7eb', '#333')}; + } + + .resize-handle:hover::after, + .resize-handle.active::after { + background: ${cssManager.bdTheme('#a1a1aa', '#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: ${cssManager.bdTheme('#71717a', '#666')}; + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.02)', 'rgba(0, 0, 0, 0.2)')}; + border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#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: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.05)')}; + } + + .column-item.selected { + background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(255, 255, 255, 0.1)')}; + color: ${cssManager.bdTheme('#1d4ed8', '#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: ${cssManager.bdTheme('#d4d4d8', '#555')}; + } + + .empty-state { + padding: 16px; + text-align: center; + color: ${cssManager.bdTheme('#a1a1aa', '#666')}; + font-size: 13px; + } + + .loading { + padding: 16px; + text-align: center; + color: ${cssManager.bdTheme('#a1a1aa', '#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: ${cssManager.bdTheme('#ffffff', '#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: ${cssManager.bdTheme('#18181b', '#fff')}; + } + + .dialog-location { + font-size: 12px; + color: ${cssManager.bdTheme('#71717a', '#888')}; + margin-bottom: 12px; + font-family: monospace; + } + + .dialog-input { + width: 100%; + padding: 10px 12px; + background: ${cssManager.bdTheme('#f4f4f5', '#141414')}; + border: 1px solid ${cssManager.bdTheme('#d4d4d8', '#333')}; + border-radius: 6px; + color: ${cssManager.bdTheme('#18181b', '#fff')}; + font-size: 14px; + margin-bottom: 8px; + box-sizing: border-box; + } + + .dialog-input:focus { + outline: none; + border-color: ${cssManager.bdTheme('#a1a1aa', '#e0e0e0')}; + } + + .dialog-hint { + font-size: 11px; + color: ${cssManager.bdTheme('#a1a1aa', '#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 ${cssManager.bdTheme('#d4d4d8', '#444')}; + color: ${cssManager.bdTheme('#71717a', '#aaa')}; + } + + .dialog-btn-cancel:hover { + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.03)', 'rgba(255, 255, 255, 0.05)')}; + color: ${cssManager.bdTheme('#18181b', '#fff')}; + } + + .dialog-btn-create { + background: ${cssManager.bdTheme('#e5e7eb', '#404040')}; + border: none; + color: ${cssManager.bdTheme('#18181b', '#fff')}; + } + + .dialog-btn-create:hover { + background: ${cssManager.bdTheme('#d4d4d8', '#505050')}; + } + + .dialog-btn-create:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + /* Move dialog styles */ + .move-summary { + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.03)', 'rgba(0, 0, 0, 0.2)')}; + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; + } + + .move-item { + display: flex; + gap: 8px; + margin-bottom: 8px; + } + + .move-item:last-child { + margin-bottom: 0; + } + + .move-label { + color: ${cssManager.bdTheme('#71717a', '#888')}; + min-width: 40px; + } + + .move-path { + color: ${cssManager.bdTheme('#3f3f46', '#e0e0e0')}; + font-family: monospace; + font-size: 12px; + word-break: break-all; + } + + .move-error { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #f87171; + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 16px; + font-size: 13px; + } + + /* Move picker dialog styles */ + .move-picker-dialog { + min-width: 450px; + max-height: 500px; + } + + .picker-breadcrumb { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 12px; + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.03)', 'rgba(0, 0, 0, 0.2)')}; + border-radius: 6px; + margin-bottom: 12px; + font-size: 12px; + overflow-x: auto; + } + + .picker-crumb { + color: ${cssManager.bdTheme('#71717a', '#888')}; + cursor: pointer; + white-space: nowrap; + } + + .picker-crumb:hover { + color: ${cssManager.bdTheme('#18181b', '#fff')}; + } + + .picker-separator { + color: ${cssManager.bdTheme('#d4d4d8', '#555')}; + } + + .picker-list { + max-height: 280px; + overflow-y: auto; + border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#333')}; + border-radius: 6px; + margin-bottom: 16px; + min-height: 100px; + } + + .picker-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + color: #fbbf24; + } + + .picker-item:hover { + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.03)', 'rgba(255, 255, 255, 0.05)')}; + } + + .picker-item .icon { + width: 16px; + height: 16px; + flex-shrink: 0; + } + + .picker-item .name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .picker-empty { + padding: 24px; + text-align: center; + color: ${cssManager.bdTheme('#a1a1aa', '#666')}; + font-size: 13px; + } + + .column-item.dragging { + opacity: 0.5; + } + + .column-item.drop-target { + background: rgba(59, 130, 246, 0.15) !important; + outline: 1px dashed rgba(59, 130, 246, 0.5); + } + + .column-item.drop-invalid { + background: rgba(239, 68, 68, 0.1) !important; + cursor: not-allowed; + } + `, + ]; + + async connectedCallback() { + super.connectedCallback(); + await this.loadInitialColumn(); + } + + async disconnectedCallback() { + await 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) { + if (changedProperties.has('bucketName') || changedProperties.has('currentPrefix')) { + this.loadInitialColumn(); + } else if (changedProperties.has('refreshKey')) { + this.refreshAllColumns(); + } + } + + private async loadInitialColumn() { + if (!this.dataProvider) return; + this.loading = true; + try { + const pathSegments = getPathSegments(this.currentPrefix); + const prefixesToLoad = ['', ...pathSegments]; + + const columnResults = await Promise.all( + prefixesToLoad.map(prefix => + this.dataProvider!.listObjects(this.bucketName, prefix, '/') + ) + ); + + this.columns = columnResults.map((result, index) => { + const prefix = prefixesToLoad[index]; + const selectedItem = index < pathSegments.length ? pathSegments[index] : null; + + return { + prefix, + objects: result.objects, + prefixes: result.prefixes, + selectedItem, + width: this.DEFAULT_COLUMN_WIDTH, + }; + }); + + this.updateComplete.then(() => this.scrollToEnd()); + } catch (err) { + console.error('Error loading objects:', err); + this.columns = []; + } + this.loading = false; + } + + private async refreshAllColumns() { + if (!this.dataProvider) return; + const updatedColumns = await Promise.all( + this.columns.map(async (col) => { + try { + const result = await this.dataProvider!.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) { + if (!this.dataProvider) return; + + this.columns = this.columns.map((col, i) => { + if (i === columnIndex) { + return { ...col, selectedItem: prefix }; + } + return col; + }); + + this.columns = this.columns.slice(0, columnIndex + 1); + + try { + const result = await this.dataProvider.listObjects(this.bucketName, prefix, '/'); + this.columns = [ + ...this.columns, + { + prefix, + objects: result.objects, + prefixes: result.prefixes, + selectedItem: null, + width: this.DEFAULT_COLUMN_WIDTH, + }, + ]; + this.updateComplete.then(() => this.scrollToEnd()); + } catch (err) { + console.error('Error loading folder:', err); + } + } + + 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) { + this.columns = this.columns.map((col, i) => { + if (i === columnIndex) { + return { ...col, selectedItem: key }; + } + return col; + }); + + this.columns = this.columns.slice(0, columnIndex + 1); + + 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 = { + 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) { + if (!this.dataProvider) return; + 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); + }, + }, + { + name: 'Rename', + iconName: 'lucide:pencil', + action: async () => this.openRenameDialog(prefix, true), + }, + { divider: true }, + { + name: 'Move to...', + iconName: 'lucide:folderInput', + action: async () => this.openMovePickerDialog(prefix, true), + }, + { 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 this.dataProvider!.deletePrefix(this.bucketName, prefix); + if (success) { + 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) { + if (!this.dataProvider) return; + 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 this.dataProvider!.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); + }, + }, + { + name: 'Rename', + iconName: 'lucide:pencil', + action: async () => this.openRenameDialog(key, false), + }, + { divider: true }, + { + name: 'Move to...', + iconName: 'lucide:folderInput', + action: async () => this.openMovePickerDialog(key, false), + }, + { divider: true }, + { + name: 'Delete', + iconName: 'lucide:trash2', + action: async () => { + if (confirm(`Delete file "${getFileName(key)}"?`)) { + const success = await this.dataProvider!.deleteObject(this.bucketName, key); + if (success) { + await this.refreshColumnByPrefix(this.columns[columnIndex].prefix); + } + } + }, + }, + ]); + } + + private handleEmptySpaceContextMenu(event: MouseEvent, columnIndex: number) { + 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; + } + + // --- 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) { + if (!this.dataProvider) return; + 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 relativePath = (file as any).webkitRelativePath || file.name; + const key = targetPrefix + relativePath; + const contentType = file.type || getContentType(file.name.split('.').pop()?.toLowerCase() || ''); + await this.dataProvider.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 + ) { + if (!this.dataProvider) return; + 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 || getContentType(file.name.split('.').pop()?.toLowerCase() || ''); + await this.dataProvider.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 { + 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) { + if (!this.dataProvider) return; + const columnIndex = this.columns.findIndex(col => col.prefix === prefix); + if (columnIndex === -1) { + await this.refreshAllColumns(); + return; + } + try { + const result = await this.dataProvider.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 = this.draggedItem ? 'move' : '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(); + + const targetPrefix = this.dragOverFolderPrefix ?? this.columns[columnIndex].prefix; + + if (this.draggedItem) { + this.initiateMove(this.draggedItem.key, this.draggedItem.isFolder, targetPrefix); + this.draggedItem = null; + this.clearFolderHover(); + this.dragCounters.clear(); + this.dragOverColumnIndex = -1; + return; + } + + this.dragCounters.clear(); + this.dragOverColumnIndex = -1; + + const items = e.dataTransfer?.items; + if (!items || items.length === 0) return; + + this.clearFolderHover(); + + const allFiles: { file: File; relativePath: string }[] = []; + + const processEntry = async (entry: FileSystemEntry, path: string): Promise => { + if (entry.isFile) { + const fileEntry = entry as FileSystemFileEntry; + const file = await new Promise((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((resolve, reject) => { + reader.readEntries(resolve, reject); + }); + for (const childEntry of entries) { + await processEntry(childEntry, path + entry.name + '/'); + } + } + }; + + 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(); + const target = e.currentTarget as HTMLElement; + const relatedTarget = e.relatedTarget as Node | null; + if (relatedTarget && target.contains(relatedTarget)) { + return; + } + if (this.dragOverFolderPrefix === folderPrefix) this.dragOverFolderPrefix = null; + if (this.folderHoverTimer) { + clearTimeout(this.folderHoverTimer); + this.folderHoverTimer = null; + } + } + + private handleFolderDragOver(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) { + e.dataTransfer.dropEffect = this.draggedItem ? 'move' : 'copy'; + } + } + + private clearFolderHover() { + if (this.folderHoverTimer) { clearTimeout(this.folderHoverTimer); this.folderHoverTimer = null; } + this.dragOverFolderPrefix = null; + } + + // --- Internal drag handlers for move --- + + private handleItemDragStart(e: DragEvent, key: string, isFolder: boolean) { + this.draggedItem = { key, isFolder }; + e.dataTransfer?.setData('text/plain', key); + e.dataTransfer!.effectAllowed = 'copyMove'; + const target = e.target as HTMLElement; + target.classList.add('dragging'); + } + + private handleItemDragEnd(e: DragEvent) { + this.draggedItem = null; + const target = e.target as HTMLElement; + target.classList.remove('dragging'); + } + + // --- Move picker dialog --- + + private async openMovePickerDialog(key: string, isFolder: boolean) { + this.movePickerSource = { key, isFolder }; + this.movePickerCurrentPrefix = ''; + this.showMovePickerDialog = true; + await this.loadMovePickerPrefixes(''); + } + + private async navigateMovePicker(prefix: string) { + this.movePickerCurrentPrefix = prefix; + await this.loadMovePickerPrefixes(prefix); + } + + private async loadMovePickerPrefixes(prefix: string) { + if (!this.dataProvider) return; + this.movePickerLoading = true; + try { + const result = await this.dataProvider.listObjects(this.bucketName, prefix, '/'); + this.movePickerPrefixes = result.prefixes; + } catch { + this.movePickerPrefixes = []; + } + this.movePickerLoading = false; + } + + private selectMoveDestination(destPrefix: string) { + if (!this.movePickerSource) return; + this.closeMovePickerDialog(); + this.initiateMove(this.movePickerSource.key, this.movePickerSource.isFolder, destPrefix); + } + + private closeMovePickerDialog() { + this.showMovePickerDialog = false; + this.movePickerSource = null; + this.movePickerCurrentPrefix = ''; + this.movePickerPrefixes = []; + } + + // --- Move confirmation dialog --- + + private initiateMove(sourceKey: string, isFolder: boolean, destPrefix: string) { + const validation = validateMove(sourceKey, destPrefix); + + if (!validation.valid) { + this.moveSource = { key: sourceKey, isFolder }; + this.moveDestination = destPrefix; + this.moveError = validation.error!; + this.showMoveDialog = true; + return; + } + + this.moveSource = { key: sourceKey, isFolder }; + this.moveDestination = destPrefix; + this.moveError = null; + this.showMoveDialog = true; + } + + private async executeMove() { + if (!this.moveSource || !this.dataProvider) return; + + this.moveInProgress = true; + + try { + const sourceName = getFileName(this.moveSource.key); + const destKey = this.moveDestination + sourceName + (this.moveSource.isFolder ? '/' : ''); + + let result: { success: boolean; error?: string }; + if (this.moveSource.isFolder) { + result = await this.dataProvider.movePrefix( + this.bucketName, + this.moveSource.key, + destKey + ); + } else { + result = await this.dataProvider.moveObject( + this.bucketName, + this.moveSource.key, + destKey + ); + } + + if (result.success) { + this.closeMoveDialog(); + await this.refreshAllColumns(); + } else { + this.moveError = result.error || 'Move operation failed'; + } + } catch (err) { + this.moveError = `Error: ${err}`; + } + + this.moveInProgress = false; + } + + private closeMoveDialog() { + this.showMoveDialog = false; + this.moveSource = null; + this.moveDestination = ''; + this.moveError = null; + this.moveInProgress = false; + } + + // --- Rename dialog methods --- + + private openRenameDialog(key: string, isFolder: boolean) { + this.renameSource = { key, isFolder }; + this.renameName = getFileName(key); + this.renameError = null; + this.showRenameDialog = true; + + this.updateComplete.then(() => { + const input = this.shadowRoot?.querySelector('.rename-dialog-input') as HTMLInputElement; + if (input) { + input.focus(); + if (!isFolder) { + const lastDot = this.renameName.lastIndexOf('.'); + if (lastDot > 0) { + input.setSelectionRange(0, lastDot); + } else { + input.select(); + } + } else { + input.select(); + } + } + }); + } + + private async executeRename() { + if (!this.renameSource || !this.renameName.trim() || !this.dataProvider) return; + + const newName = this.renameName.trim(); + const currentName = getFileName(this.renameSource.key); + + if (newName === currentName) { + this.renameError = 'Name is the same as current'; + return; + } + if (!newName) { + this.renameError = 'Name cannot be empty'; + return; + } + if (newName.includes('/')) { + this.renameError = 'Name cannot contain "/"'; + return; + } + + this.renameInProgress = true; + this.renameError = null; + + try { + const parentPrefix = getParentPrefix(this.renameSource.key); + const newKey = parentPrefix + newName + (this.renameSource.isFolder ? '/' : ''); + + let result: { success: boolean; error?: string }; + if (this.renameSource.isFolder) { + result = await this.dataProvider.movePrefix(this.bucketName, this.renameSource.key, newKey); + } else { + result = await this.dataProvider.moveObject(this.bucketName, this.renameSource.key, newKey); + } + + if (result.success) { + this.closeRenameDialog(); + await this.refreshAllColumns(); + } else { + this.renameError = result.error || 'Rename failed'; + } + } catch (err) { + this.renameError = `Error: ${err}`; + } + this.renameInProgress = false; + } + + private closeRenameDialog() { + this.showRenameDialog = false; + this.renameSource = null; + this.renameName = ''; + this.renameError = null; + this.renameInProgress = false; + } + + private renderRenameDialog() { + if (!this.showRenameDialog || !this.renameSource) return ''; + + const isFolder = this.renameSource.isFolder; + const title = isFolder ? 'Rename Folder' : 'Rename File'; + + return html` +
this.closeRenameDialog()}> +
e.stopPropagation()}> +
${title}
+
+ Location: ${this.bucketName}/${getParentPrefix(this.renameSource.key)} +
+ ${this.renameError ? html`
${this.renameError}
` : ''} + { + this.renameName = (e.target as HTMLInputElement).value; + this.renameError = null; + }} + @keydown=${(e: KeyboardEvent) => { + if (e.key === 'Enter') this.executeRename(); + if (e.key === 'Escape') this.closeRenameDialog(); + }} + /> +
+ + +
+
+
+ `; + } + + private renderMoveDialog() { + if (!this.showMoveDialog || !this.moveSource) return ''; + + const sourceName = getFileName(this.moveSource.key); + + return html` +
this.closeMoveDialog()}> +
e.stopPropagation()}> +
Move ${this.moveSource.isFolder ? 'Folder' : 'File'}
+ + ${this.moveError ? html` +
${this.moveError}
+ ` : html` +
+
+ From: + ${this.moveSource.key} +
+
+ To: + ${this.moveDestination}${sourceName} +
+
+ `} + +
+ + ${!this.moveError ? html` + + ` : ''} +
+
+
+ `; + } + + private renderMovePickerDialog() { + if (!this.showMovePickerDialog || !this.movePickerSource) return ''; + + const sourceName = getFileName(this.movePickerSource.key); + const sourceParent = getParentPrefix(this.movePickerSource.key); + + return html` +
this.closeMovePickerDialog()}> +
e.stopPropagation()}> +
Move "${sourceName}" to...
+ +
+ this.navigateMovePicker('')}> + ${this.bucketName} + + ${getPathSegments(this.movePickerCurrentPrefix).map(seg => html` + / + this.navigateMovePicker(seg)}> + ${getFileName(seg)} + + `)} +
+ +
+ ${this.movePickerLoading ? html`
Loading...
` : ''} + ${!this.movePickerLoading && this.movePickerPrefixes.filter(p => p !== this.movePickerSource!.key).length === 0 ? html` +
No subfolders
+ ` : ''} + ${this.movePickerPrefixes + .filter(p => p !== this.movePickerSource!.key) + .map(prefix => html` +
this.navigateMovePicker(prefix)} + @dblclick=${() => this.selectMoveDestination(prefix)}> + + + + ${getFileName(prefix)} +
+ `)} +
+ +
+ + +
+
+
+ `; + } + + private async handleCreate() { + if (!this.createDialogName.trim() || !this.dataProvider) return; + + const name = this.createDialogName.trim(); + let path: string; + + if (this.createDialogType === 'folder') { + path = this.createDialogPrefix + name + '/.keep'; + } else { + path = this.createDialogPrefix + name; + } + + const ext = name.split('.').pop()?.toLowerCase() || ''; + const contentType = this.createDialogType === 'file' ? getContentType(ext) : 'application/octet-stream'; + const content = this.createDialogType === 'file' ? getDefaultContent(ext) : ''; + + const success = await this.dataProvider.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` +
this.showCreateDialog = false}> +
e.stopPropagation()}> +
${title}
+
+ Location: ${this.bucketName}/${this.createDialogPrefix} +
+ this.createDialogName = (e.target as HTMLInputElement).value} + @keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleCreate()} + /> +
+ Use "/" to create nested ${isFolder ? 'folders' : 'path'} (e.g., ${isFolder ? 'parent/child' : 'folder/file.txt'}) +
+
+ + +
+
+
+ `; + } + + render() { + if (this.loading && this.columns.length === 0) { + return html`
Loading...
`; + } + + return html` +
+ ${this.columns.map((column, index) => this.renderColumnWrapper(column, index))} +
+ ${this.renderCreateDialog()} + ${this.renderMoveDialog()} + ${this.renderMovePickerDialog()} + ${this.renderRenameDialog()} + `; + } + + private renderColumnWrapper(column: IColumn, index: number) { + return html` +
+ ${this.renderColumn(column, index)} +
this.startResize(e, index)} + >
+
+ `; + } + + private renderColumn(column: IColumn, index: number) { + const headerName = column.prefix + ? getFileName(column.prefix) + : this.bucketName; + + return html` +
this.handleColumnDragEnter(e, index)} + @dragover=${(e: DragEvent) => this.handleColumnDragOver(e)} + @dragleave=${(e: DragEvent) => this.handleColumnDragLeave(e, index)} + @drop=${(e: DragEvent) => this.handleColumnDrop(e, index)} + > +
+ ${headerName} +
+
this.handleEmptySpaceContextMenu(e, index)}> + ${column.prefixes.length === 0 && column.objects.length === 0 + ? html`
Empty folder
` + : ''} + ${column.prefixes.map( + (prefix) => html` +
this.selectFolder(index, prefix)} + @contextmenu=${(e: MouseEvent) => this.handleFolderContextMenu(e, index, prefix)} + @dragstart=${(e: DragEvent) => this.handleItemDragStart(e, prefix, true)} + @dragend=${(e: DragEvent) => this.handleItemDragEnd(e)} + @dragenter=${(e: DragEvent) => this.handleFolderDragEnter(e, prefix)} + @dragover=${(e: DragEvent) => this.handleFolderDragOver(e)} + @dragleave=${(e: DragEvent) => this.handleFolderDragLeave(e, prefix)} + > + + + + ${getFileName(prefix)} + + + +
+ ` + )} + ${column.objects.map( + (obj) => html` +
this.selectFile(index, obj.key)} + @contextmenu=${(e: MouseEvent) => this.handleFileContextMenu(e, index, obj.key)} + @dragstart=${(e: DragEvent) => this.handleItemDragStart(e, obj.key, false)} + @dragend=${(e: DragEvent) => this.handleItemDragEnd(e)} + > + + + + ${getFileName(obj.key)} +
+ ` + )} +
+ ${this.dragOverColumnIndex === index ? html` +
+ ${this.draggedItem + ? (this.dragOverFolderPrefix + ? `Move to ${getFileName(this.dragOverFolderPrefix)}` + : 'Move here') + : (this.dragOverFolderPrefix + ? `Drop to upload into ${getFileName(this.dragOverFolderPrefix)}` + : 'Drop to upload here')} +
+ ` : ''} + ${this.uploading ? html` +
+
Uploading...
+ ${this.uploadProgress ? html` +
${this.uploadProgress.current} / ${this.uploadProgress.total} files
+ ` : ''} +
+ ` : ''} +
+ `; + } +} diff --git a/ts_web/elements/00group-dataview/dees-s3-browser/dees-s3-keys.ts b/ts_web/elements/00group-dataview/dees-s3-browser/dees-s3-keys.ts new file mode 100644 index 0000000..fe1b547 --- /dev/null +++ b/ts_web/elements/00group-dataview/dees-s3-browser/dees-s3-keys.ts @@ -0,0 +1,1094 @@ +import { customElement, html, css, cssManager, property, state, DeesElement } from '@design.estate/dees-element'; +import { DeesContextmenu } from '../../00group-overlay/dees-contextmenu/dees-contextmenu.js'; +import { themeDefaultStyles } from '../../00theme.js'; +import type { IS3DataProvider, IS3Object } from './interfaces.js'; +import { formatSize, getFileName, validateMove, getParentPrefix, getContentType, getDefaultContent, getPathSegments } from './utilities.js'; + +declare global { + interface HTMLElementTagNameMap { + 'dees-s3-keys': DeesS3Keys; + } +} + +@customElement('dees-s3-keys') +export class DeesS3Keys extends DeesElement { + @property({ type: Object }) + public accessor dataProvider: IS3DataProvider | null = null; + + @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 allKeys: IS3Object[] = []; + + @state() + private accessor prefixes: string[] = []; + + @state() + private accessor loading: boolean = false; + + @state() + private accessor selectedKey: string = ''; + + @state() + private accessor filterText: string = ''; + + @state() + private accessor showCreateDialog: boolean = false; + + @state() + private accessor createDialogType: 'folder' | 'file' = 'folder'; + + @state() + private accessor createDialogPrefix: string = ''; + + @state() + private accessor createDialogName: string = ''; + + // Move dialog state + @state() + private accessor showMoveDialog: boolean = false; + + @state() + private accessor moveSource: { key: string; isFolder: boolean } | null = null; + + @state() + private accessor moveDestination: string = ''; + + @state() + private accessor moveInProgress: boolean = false; + + @state() + private accessor moveError: string | null = null; + + // Move picker dialog state + @state() + private accessor showMovePickerDialog: boolean = false; + + @state() + private accessor movePickerSource: { key: string; isFolder: boolean } | null = null; + + @state() + private accessor movePickerCurrentPrefix: string = ''; + + @state() + private accessor movePickerPrefixes: string[] = []; + + @state() + private accessor movePickerLoading: boolean = false; + + // Rename dialog state + @state() + private accessor showRenameDialog: boolean = false; + + @state() + private accessor renameSource: { key: string; isFolder: boolean } | null = null; + + @state() + private accessor renameName: string = ''; + + @state() + private accessor renameInProgress: boolean = false; + + @state() + private accessor renameError: string | null = null; + + public static styles = [ + cssManager.defaultStyles, + themeDefaultStyles, + css` + :host { + display: block; + height: 100%; + overflow: hidden; + } + + .keys-container { + display: flex; + flex-direction: column; + height: 100%; + } + + .filter-bar { + padding: 12px; + border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#333')}; + } + + .filter-input { + width: 100%; + padding: 8px 12px; + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.03)', 'rgba(0, 0, 0, 0.3)')}; + border: 1px solid ${cssManager.bdTheme('#d4d4d8', '#444')}; + border-radius: 6px; + color: ${cssManager.bdTheme('#18181b', '#fff')}; + font-size: 14px; + } + + .filter-input:focus { + outline: none; + border-color: ${cssManager.bdTheme('#a1a1aa', '#404040')}; + } + + .filter-input::placeholder { + color: ${cssManager.bdTheme('#a1a1aa', '#666')}; + } + + .keys-list { + flex: 1; + overflow-y: auto; + } + + table { + width: 100%; + border-collapse: collapse; + } + + thead { + position: sticky; + top: 0; + background: ${cssManager.bdTheme('#fafafa', '#1a1a1a')}; + z-index: 1; + } + + th { + text-align: left; + padding: 10px 12px; + font-size: 12px; + font-weight: 500; + color: ${cssManager.bdTheme('#71717a', '#666')}; + text-transform: uppercase; + border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#333')}; + } + + td { + padding: 8px 12px; + font-size: 13px; + border-bottom: 1px solid ${cssManager.bdTheme('#f4f4f5', '#2a2a3e')}; + } + + tr:hover td { + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.02)', 'rgba(255, 255, 255, 0.03)')}; + } + + tr.selected td { + background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.08)', 'rgba(255, 255, 255, 0.08)')}; + } + + .key-cell { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + } + + .key-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + } + + .folder-icon { + color: #fbbf24; + } + + .key-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .size-cell { + color: ${cssManager.bdTheme('#71717a', '#888')}; + font-variant-numeric: tabular-nums; + } + + .empty-state { + padding: 32px; + text-align: center; + color: ${cssManager.bdTheme('#a1a1aa', '#666')}; + } + + .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: ${cssManager.bdTheme('#ffffff', '#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: ${cssManager.bdTheme('#18181b', '#fff')}; + } + + .dialog-location { + font-size: 12px; + color: ${cssManager.bdTheme('#71717a', '#888')}; + margin-bottom: 12px; + font-family: monospace; + } + + .dialog-input { + width: 100%; + padding: 10px 12px; + background: ${cssManager.bdTheme('#f4f4f5', '#141414')}; + border: 1px solid ${cssManager.bdTheme('#d4d4d8', '#333')}; + border-radius: 6px; + color: ${cssManager.bdTheme('#18181b', '#fff')}; + font-size: 14px; + margin-bottom: 8px; + box-sizing: border-box; + } + + .dialog-input:focus { + outline: none; + border-color: ${cssManager.bdTheme('#a1a1aa', '#e0e0e0')}; + } + + .dialog-hint { + font-size: 11px; + color: ${cssManager.bdTheme('#a1a1aa', '#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 ${cssManager.bdTheme('#d4d4d8', '#444')}; + color: ${cssManager.bdTheme('#71717a', '#aaa')}; + } + + .dialog-btn-cancel:hover { + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.03)', 'rgba(255, 255, 255, 0.05)')}; + color: ${cssManager.bdTheme('#18181b', '#fff')}; + } + + .dialog-btn-create { + background: ${cssManager.bdTheme('#e5e7eb', '#404040')}; + border: none; + color: ${cssManager.bdTheme('#18181b', '#fff')}; + } + + .dialog-btn-create:hover { + background: ${cssManager.bdTheme('#d4d4d8', '#505050')}; + } + + .dialog-btn-create:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + /* Move dialog styles */ + .move-summary { + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.03)', 'rgba(0, 0, 0, 0.2)')}; + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; + } + + .move-item { + display: flex; + gap: 8px; + margin-bottom: 8px; + } + + .move-item:last-child { + margin-bottom: 0; + } + + .move-label { + color: ${cssManager.bdTheme('#71717a', '#888')}; + min-width: 40px; + } + + .move-path { + color: ${cssManager.bdTheme('#3f3f46', '#e0e0e0')}; + font-family: monospace; + font-size: 12px; + word-break: break-all; + } + + .move-error { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #f87171; + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 16px; + font-size: 13px; + } + + /* Move picker dialog styles */ + .move-picker-dialog { + min-width: 450px; + max-height: 500px; + } + + .picker-breadcrumb { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 12px; + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.03)', 'rgba(0, 0, 0, 0.2)')}; + border-radius: 6px; + margin-bottom: 12px; + font-size: 12px; + overflow-x: auto; + } + + .picker-crumb { + color: ${cssManager.bdTheme('#71717a', '#888')}; + cursor: pointer; + white-space: nowrap; + } + + .picker-crumb:hover { + color: ${cssManager.bdTheme('#18181b', '#fff')}; + } + + .picker-separator { + color: ${cssManager.bdTheme('#d4d4d8', '#555')}; + } + + .picker-list { + max-height: 280px; + overflow-y: auto; + border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#333')}; + border-radius: 6px; + margin-bottom: 16px; + min-height: 100px; + } + + .picker-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + color: #fbbf24; + } + + .picker-item:hover { + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.03)', 'rgba(255, 255, 255, 0.05)')}; + } + + .picker-item .icon { + width: 16px; + height: 16px; + flex-shrink: 0; + } + + .picker-item .name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .picker-empty { + padding: 24px; + text-align: center; + color: ${cssManager.bdTheme('#a1a1aa', '#666')}; + font-size: 13px; + } + `, + ]; + + async connectedCallback() { + super.connectedCallback(); + await this.loadObjects(); + } + + updated(changedProperties: Map) { + if (changedProperties.has('bucketName') || changedProperties.has('currentPrefix') || changedProperties.has('refreshKey')) { + this.loadObjects(); + } + } + + private async loadObjects() { + if (!this.dataProvider) return; + this.loading = true; + try { + const result = await this.dataProvider.listObjects(this.bucketName, this.currentPrefix, '/'); + this.allKeys = result.objects; + this.prefixes = result.prefixes; + } catch (err) { + console.error('Error loading objects:', err); + this.allKeys = []; + this.prefixes = []; + } + this.loading = false; + } + + private handleFilterInput(e: Event) { + this.filterText = (e.target as HTMLInputElement).value; + } + + private selectKey(key: string, isFolder: boolean) { + this.selectedKey = key; + + if (isFolder) { + this.dispatchEvent( + new CustomEvent('navigate', { + detail: { prefix: key }, + bubbles: true, + composed: true, + }) + ); + } else { + this.dispatchEvent( + new CustomEvent('key-selected', { + detail: { key }, + bubbles: true, + composed: true, + }) + ); + } + } + + private get filteredItems() { + const filter = this.filterText.toLowerCase(); + const folders = this.prefixes + .filter((p) => !filter || getFileName(p).toLowerCase().includes(filter)) + .map((p) => ({ key: p, isFolder: true, size: undefined as number | undefined })); + const files = this.allKeys + .filter((o) => !filter || getFileName(o.key).toLowerCase().includes(filter)) + .map((o) => ({ key: o.key, isFolder: false, size: o.size })); + return [...folders, ...files]; + } + + private handleItemContextMenu(event: MouseEvent, key: string, isFolder: boolean) { + if (!this.dataProvider) return; + event.preventDefault(); + + if (isFolder) { + DeesContextmenu.openContextMenuWithOptions(event, [ + { + name: 'Open', + iconName: 'lucide:folderOpen', + action: async () => { + this.selectKey(key, true); + }, + }, + { + name: 'Copy Path', + iconName: 'lucide:copy', + action: async () => { + await navigator.clipboard.writeText(key); + }, + }, + { + name: 'Rename', + iconName: 'lucide:pencil', + action: async () => this.openRenameDialog(key, true), + }, + { divider: true }, + { + name: 'Move to...', + iconName: 'lucide:folderInput', + action: async () => this.openMovePickerDialog(key, true), + }, + { divider: true }, + { + name: 'New Folder Inside', + iconName: 'lucide:folderPlus', + action: async () => this.openCreateDialog('folder', key), + }, + { + name: 'New File Inside', + iconName: 'lucide:filePlus', + action: async () => this.openCreateDialog('file', key), + }, + { divider: true }, + { + name: 'Delete Folder', + iconName: 'lucide:trash2', + action: async () => { + if (confirm(`Delete folder "${getFileName(key)}" and all its contents?`)) { + const success = await this.dataProvider!.deletePrefix(this.bucketName, key); + if (success) { + await this.loadObjects(); + } + } + }, + }, + ]); + } else { + DeesContextmenu.openContextMenuWithOptions(event, [ + { + name: 'Preview', + iconName: 'lucide:eye', + action: async () => { + this.selectKey(key, false); + }, + }, + { + name: 'Download', + iconName: 'lucide:download', + action: async () => { + const url = await this.dataProvider!.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); + }, + }, + { + name: 'Rename', + iconName: 'lucide:pencil', + action: async () => this.openRenameDialog(key, false), + }, + { divider: true }, + { + name: 'Move to...', + iconName: 'lucide:folderInput', + action: async () => this.openMovePickerDialog(key, false), + }, + { divider: true }, + { + name: 'Delete', + iconName: 'lucide:trash2', + action: async () => { + if (confirm(`Delete file "${getFileName(key)}"?`)) { + const success = await this.dataProvider!.deleteObject(this.bucketName, key); + if (success) { + await this.loadObjects(); + } + } + }, + }, + ]); + } + } + + private handleEmptySpaceContextMenu(event: MouseEvent) { + if ((event.target as HTMLElement).closest('tr')) return; + event.preventDefault(); + + DeesContextmenu.openContextMenuWithOptions(event, [ + { + name: 'New Folder', + iconName: 'lucide:folderPlus', + action: async () => this.openCreateDialog('folder', this.currentPrefix), + }, + { + name: 'New File', + iconName: 'lucide:filePlus', + action: async () => this.openCreateDialog('file', this.currentPrefix), + }, + ]); + } + + private openCreateDialog(type: 'folder' | 'file', prefix: string) { + this.createDialogType = type; + this.createDialogPrefix = prefix; + this.createDialogName = ''; + this.showCreateDialog = true; + } + + private async handleCreate() { + if (!this.createDialogName.trim() || !this.dataProvider) return; + + const name = this.createDialogName.trim(); + let path: string; + + if (this.createDialogType === 'folder') { + path = this.createDialogPrefix + name + '/.keep'; + } else { + path = this.createDialogPrefix + name; + } + + const ext = name.split('.').pop()?.toLowerCase() || ''; + const contentType = this.createDialogType === 'file' ? getContentType(ext) : 'application/octet-stream'; + const content = this.createDialogType === 'file' ? getDefaultContent(ext) : ''; + + const success = await this.dataProvider.putObject( + this.bucketName, + path, + btoa(content), + contentType + ); + + if (success) { + this.showCreateDialog = false; + await this.loadObjects(); + } + } + + 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` +
this.showCreateDialog = false}> +
e.stopPropagation()}> +
${title}
+
+ Location: ${this.bucketName}/${this.createDialogPrefix} +
+ this.createDialogName = (e.target as HTMLInputElement).value} + @keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleCreate()} + /> +
+ Use "/" to create nested ${isFolder ? 'folders' : 'path'} (e.g., ${isFolder ? 'parent/child' : 'folder/file.txt'}) +
+
+ + +
+
+
+ `; + } + + // --- Move picker dialog --- + + private async openMovePickerDialog(key: string, isFolder: boolean) { + this.movePickerSource = { key, isFolder }; + this.movePickerCurrentPrefix = ''; + this.showMovePickerDialog = true; + await this.loadMovePickerPrefixes(''); + } + + private async navigateMovePicker(prefix: string) { + this.movePickerCurrentPrefix = prefix; + await this.loadMovePickerPrefixes(prefix); + } + + private async loadMovePickerPrefixes(prefix: string) { + if (!this.dataProvider) return; + this.movePickerLoading = true; + try { + const result = await this.dataProvider.listObjects(this.bucketName, prefix, '/'); + this.movePickerPrefixes = result.prefixes; + } catch { + this.movePickerPrefixes = []; + } + this.movePickerLoading = false; + } + + private selectMoveDestination(destPrefix: string) { + if (!this.movePickerSource) return; + this.closeMovePickerDialog(); + this.initiateMove(this.movePickerSource.key, this.movePickerSource.isFolder, destPrefix); + } + + private closeMovePickerDialog() { + this.showMovePickerDialog = false; + this.movePickerSource = null; + this.movePickerCurrentPrefix = ''; + this.movePickerPrefixes = []; + } + + // --- Move confirmation dialog --- + + private initiateMove(sourceKey: string, isFolder: boolean, destPrefix: string) { + const validation = validateMove(sourceKey, destPrefix); + + if (!validation.valid) { + this.moveSource = { key: sourceKey, isFolder }; + this.moveDestination = destPrefix; + this.moveError = validation.error!; + this.showMoveDialog = true; + return; + } + + this.moveSource = { key: sourceKey, isFolder }; + this.moveDestination = destPrefix; + this.moveError = null; + this.showMoveDialog = true; + } + + private async executeMove() { + if (!this.moveSource || !this.dataProvider) return; + + this.moveInProgress = true; + + try { + const sourceName = getFileName(this.moveSource.key); + const destKey = this.moveDestination + sourceName; + + let result: { success: boolean; error?: string }; + if (this.moveSource.isFolder) { + result = await this.dataProvider.movePrefix( + this.bucketName, + this.moveSource.key, + destKey + ); + } else { + result = await this.dataProvider.moveObject( + this.bucketName, + this.moveSource.key, + destKey + ); + } + + if (result.success) { + this.closeMoveDialog(); + await this.loadObjects(); + } else { + this.moveError = result.error || 'Move operation failed'; + } + } catch (err) { + this.moveError = `Error: ${err}`; + } + + this.moveInProgress = false; + } + + private closeMoveDialog() { + this.showMoveDialog = false; + this.moveSource = null; + this.moveDestination = ''; + this.moveError = null; + this.moveInProgress = false; + } + + // --- Rename dialog methods --- + + private openRenameDialog(key: string, isFolder: boolean) { + this.renameSource = { key, isFolder }; + this.renameName = getFileName(key); + this.renameError = null; + this.showRenameDialog = true; + + this.updateComplete.then(() => { + const input = this.shadowRoot?.querySelector('.rename-dialog-input') as HTMLInputElement; + if (input) { + input.focus(); + if (!isFolder) { + const lastDot = this.renameName.lastIndexOf('.'); + if (lastDot > 0) { + input.setSelectionRange(0, lastDot); + } else { + input.select(); + } + } else { + input.select(); + } + } + }); + } + + private async executeRename() { + if (!this.renameSource || !this.renameName.trim() || !this.dataProvider) return; + + const newName = this.renameName.trim(); + const currentName = getFileName(this.renameSource.key); + + if (newName === currentName) { + this.renameError = 'Name is the same as current'; + return; + } + if (!newName) { + this.renameError = 'Name cannot be empty'; + return; + } + if (newName.includes('/')) { + this.renameError = 'Name cannot contain "/"'; + return; + } + + this.renameInProgress = true; + this.renameError = null; + + try { + const parentPrefix = getParentPrefix(this.renameSource.key); + const newKey = parentPrefix + newName + (this.renameSource.isFolder ? '/' : ''); + + let result: { success: boolean; error?: string }; + if (this.renameSource.isFolder) { + result = await this.dataProvider.movePrefix(this.bucketName, this.renameSource.key, newKey); + } else { + result = await this.dataProvider.moveObject(this.bucketName, this.renameSource.key, newKey); + } + + if (result.success) { + this.closeRenameDialog(); + await this.loadObjects(); + } else { + this.renameError = result.error || 'Rename failed'; + } + } catch (err) { + this.renameError = `Error: ${err}`; + } + this.renameInProgress = false; + } + + private closeRenameDialog() { + this.showRenameDialog = false; + this.renameSource = null; + this.renameName = ''; + this.renameError = null; + this.renameInProgress = false; + } + + private renderRenameDialog() { + if (!this.showRenameDialog || !this.renameSource) return ''; + + const isFolder = this.renameSource.isFolder; + const title = isFolder ? 'Rename Folder' : 'Rename File'; + + return html` +
this.closeRenameDialog()}> +
e.stopPropagation()}> +
${title}
+
+ Location: ${this.bucketName}/${getParentPrefix(this.renameSource.key)} +
+ ${this.renameError ? html`
${this.renameError}
` : ''} + { + this.renameName = (e.target as HTMLInputElement).value; + this.renameError = null; + }} + @keydown=${(e: KeyboardEvent) => { + if (e.key === 'Enter') this.executeRename(); + if (e.key === 'Escape') this.closeRenameDialog(); + }} + /> +
+ + +
+
+
+ `; + } + + private renderMoveDialog() { + if (!this.showMoveDialog || !this.moveSource) return ''; + + const sourceName = getFileName(this.moveSource.key); + + return html` +
this.closeMoveDialog()}> +
e.stopPropagation()}> +
Move ${this.moveSource.isFolder ? 'Folder' : 'File'}
+ + ${this.moveError ? html` +
${this.moveError}
+ ` : html` +
+
+ From: + ${this.moveSource.key} +
+
+ To: + ${this.moveDestination}${sourceName} +
+
+ `} + +
+ + ${!this.moveError ? html` + + ` : ''} +
+
+
+ `; + } + + private renderMovePickerDialog() { + if (!this.showMovePickerDialog || !this.movePickerSource) return ''; + + const sourceName = getFileName(this.movePickerSource.key); + const sourceParent = getParentPrefix(this.movePickerSource.key); + + return html` +
this.closeMovePickerDialog()}> +
e.stopPropagation()}> +
Move "${sourceName}" to...
+ +
+ this.navigateMovePicker('')}> + ${this.bucketName} + + ${getPathSegments(this.movePickerCurrentPrefix).map(seg => html` + / + this.navigateMovePicker(seg)}> + ${getFileName(seg)} + + `)} +
+ +
+ ${this.movePickerLoading ? html`
Loading...
` : ''} + ${!this.movePickerLoading && this.movePickerPrefixes.filter(p => p !== this.movePickerSource!.key).length === 0 ? html` +
No subfolders
+ ` : ''} + ${this.movePickerPrefixes + .filter(p => p !== this.movePickerSource!.key) + .map(prefix => html` +
this.navigateMovePicker(prefix)} + @dblclick=${() => this.selectMoveDestination(prefix)}> + + + + ${getFileName(prefix)} +
+ `)} +
+ +
+ + +
+
+
+ `; + } + + render() { + return html` +
+
+ +
+ +
this.handleEmptySpaceContextMenu(e)}> + ${this.loading + ? html`
Loading...
` + : this.filteredItems.length === 0 + ? html`
No objects found
` + : html` + + + + + + + + + ${this.filteredItems.map( + (item) => html` + this.selectKey(item.key, item.isFolder)} + @contextmenu=${(e: MouseEvent) => this.handleItemContextMenu(e, item.key, item.isFolder)} + > + + + + ` + )} + +
NameSize
+
+ ${item.isFolder + ? html` + + + + ` + : html` + + + + `} + ${getFileName(item.key)} +
+
+ ${item.isFolder ? '-' : formatSize(item.size)} +
+ `} +
+
+ ${this.renderCreateDialog()} + ${this.renderMoveDialog()} + ${this.renderMovePickerDialog()} + ${this.renderRenameDialog()} + `; + } +} diff --git a/ts_web/elements/00group-dataview/dees-s3-browser/dees-s3-preview.ts b/ts_web/elements/00group-dataview/dees-s3-browser/dees-s3-preview.ts new file mode 100644 index 0000000..6507ffb --- /dev/null +++ b/ts_web/elements/00group-dataview/dees-s3-browser/dees-s3-preview.ts @@ -0,0 +1,540 @@ +import { customElement, html, css, cssManager, property, state, DeesElement } from '@design.estate/dees-element'; +import { themeDefaultStyles } from '../../00theme.js'; +import type { IS3DataProvider } from './interfaces.js'; +import { formatSize, getFileName } from './utilities.js'; + +declare global { + interface HTMLElementTagNameMap { + 'dees-s3-preview': DeesS3Preview; + } +} + +@customElement('dees-s3-preview') +export class DeesS3Preview extends DeesElement { + @property({ type: Object }) + public accessor dataProvider: IS3DataProvider | null = null; + + @property({ type: String }) + public accessor bucketName: string = ''; + + @property({ type: String }) + public accessor objectKey: string = ''; + + @state() + private accessor loading: boolean = false; + + @state() + private accessor saving: boolean = false; + + @state() + private accessor content: string = ''; + + @state() + private accessor originalTextContent: string = ''; + + @state() + private accessor hasChanges: boolean = false; + + @state() + private accessor editing: boolean = false; + + @state() + private accessor contentType: string = ''; + + @state() + private accessor fileSize: number = 0; + + @state() + private accessor lastModified: string = ''; + + @state() + private accessor error: string = ''; + + public static styles = [ + cssManager.defaultStyles, + themeDefaultStyles, + css` + :host { + display: block; + height: 100%; + } + + .preview-container { + display: flex; + flex-direction: column; + height: 100%; + } + + .preview-header { + padding: 12px; + border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#333')}; + } + + .preview-title { + font-size: 14px; + font-weight: 500; + margin-bottom: 8px; + word-break: break-all; + } + + .preview-meta { + display: flex; + flex-wrap: wrap; + gap: 16px; + font-size: 12px; + color: ${cssManager.bdTheme('#71717a', '#888')}; + } + + .meta-item { + display: flex; + align-items: center; + gap: 4px; + } + + .preview-content { + flex: 1; + overflow: hidden; + } + + .preview-content dees-preview { + width: 100%; + height: 100%; + } + + .preview-content.code-editor { + padding: 0; + overflow: hidden; + } + + .preview-content.code-editor dees-input-code { + height: 100%; + } + + .preview-actions { + padding: 12px; + border-top: 1px solid ${cssManager.bdTheme('#e5e7eb', '#333')}; + display: flex; + gap: 8px; + } + + .action-btn { + flex: 1; + padding: 8px 16px; + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.1)')}; + border: 1px solid ${cssManager.bdTheme('#d4d4d8', '#404040')}; + color: ${cssManager.bdTheme('#3f3f46', '#e0e0e0')}; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: all 0.15s; + } + + .action-btn:hover { + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.15)')}; + } + + .action-btn.danger { + background: rgba(239, 68, 68, 0.2); + border-color: #ef4444; + color: #f87171; + } + + .action-btn.danger:hover { + background: rgba(239, 68, 68, 0.3); + } + + .action-btn.primary { + background: rgba(59, 130, 246, 0.3); + border-color: #3b82f6; + color: #60a5fa; + } + + .action-btn.primary:hover { + background: rgba(59, 130, 246, 0.4); + } + + .action-btn.primary:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .action-btn.secondary { + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.03)', 'rgba(255, 255, 255, 0.05)')}; + border-color: ${cssManager.bdTheme('#d4d4d8', '#555')}; + color: ${cssManager.bdTheme('#71717a', '#aaa')}; + } + + .action-btn.secondary:hover { + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.1)')}; + color: ${cssManager.bdTheme('#18181b', '#fff')}; + } + + .unsaved-indicator { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: rgba(251, 191, 36, 0.1); + border: 1px solid rgba(251, 191, 36, 0.3); + border-radius: 4px; + font-size: 12px; + color: #fbbf24; + } + + .unsaved-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #fbbf24; + } + + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: ${cssManager.bdTheme('#a1a1aa', '#666')}; + text-align: center; + padding: 24px; + } + + .empty-state svg { + width: 48px; + height: 48px; + margin-bottom: 12px; + opacity: 0.5; + } + + .loading-state { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: ${cssManager.bdTheme('#71717a', '#888')}; + } + + .error-state { + padding: 16px; + color: #f87171; + text-align: center; + } + `, + ]; + + updated(changedProperties: Map) { + if (changedProperties.has('objectKey') || changedProperties.has('bucketName')) { + if (this.objectKey) { + this.loadObject(); + } else { + this.content = ''; + this.contentType = ''; + this.error = ''; + this.originalTextContent = ''; + this.hasChanges = false; + this.editing = false; + } + } + } + + private async loadObject() { + if (!this.objectKey || !this.bucketName || !this.dataProvider) return; + + this.loading = true; + this.error = ''; + this.hasChanges = false; + this.editing = false; + + try { + const result = await this.dataProvider.getObject(this.bucketName, this.objectKey); + if (!result) { + this.error = 'Object not found'; + this.loading = false; + return; + } + this.content = result.content || ''; + this.contentType = result.contentType || ''; + this.fileSize = result.size || 0; + this.lastModified = result.lastModified || ''; + + if (this.isText()) { + this.originalTextContent = this.getTextContent(); + } + } catch (err) { + console.error('Error loading object:', err); + this.error = 'Failed to load object'; + } + + this.loading = false; + } + + private formatDate(dateStr: string): string { + if (!dateStr) return '-'; + const date = new Date(dateStr); + return date.toLocaleString(); + } + + private isImage(): boolean { + return this.contentType.startsWith('image/'); + } + + private isText(): boolean { + return ( + this.contentType.startsWith('text/') || + this.contentType === 'application/json' || + this.contentType === 'application/xml' || + this.contentType === 'application/javascript' + ); + } + + private getTextContent(): string { + try { + const binaryString = atob(this.content); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return new TextDecoder('utf-8').decode(bytes); + } catch { + return 'Unable to decode content'; + } + } + + private async handleDownload() { + try { + const blob = new Blob([Uint8Array.from(atob(this.content), (c) => c.charCodeAt(0))], { + type: this.contentType, + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = getFileName(this.objectKey); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (err) { + console.error('Error downloading:', err); + } + } + + private async handleDelete() { + if (!this.dataProvider) return; + if (!confirm(`Delete "${getFileName(this.objectKey)}"?`)) return; + + try { + await this.dataProvider.deleteObject(this.bucketName, this.objectKey); + this.dispatchEvent( + new CustomEvent('object-deleted', { + detail: { key: this.objectKey }, + bubbles: true, + composed: true, + }) + ); + } catch (err) { + console.error('Error deleting object:', err); + } + } + + private getLanguage(): string { + const ext = this.objectKey.split('.').pop()?.toLowerCase() || ''; + const languageMap: Record = { + ts: 'typescript', + tsx: 'typescript', + js: 'javascript', + jsx: 'javascript', + mjs: 'javascript', + cjs: 'javascript', + json: 'json', + html: 'html', + htm: 'html', + css: 'css', + scss: 'scss', + sass: 'scss', + less: 'less', + md: 'markdown', + markdown: 'markdown', + xml: 'xml', + yaml: 'yaml', + yml: 'yaml', + py: 'python', + rb: 'ruby', + go: 'go', + rs: 'rust', + java: 'java', + c: 'c', + cpp: 'cpp', + h: 'c', + hpp: 'cpp', + cs: 'csharp', + php: 'php', + sh: 'shell', + bash: 'shell', + zsh: 'shell', + sql: 'sql', + graphql: 'graphql', + gql: 'graphql', + dockerfile: 'dockerfile', + txt: 'plaintext', + }; + return languageMap[ext] || 'plaintext'; + } + + private handleContentChange(event: CustomEvent) { + const newValue = event.detail as string; + 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() { + if (!this.hasChanges || this.saving || !this.dataProvider) return; + + this.saving = true; + + try { + const codeEditor = this.shadowRoot?.querySelector('dees-input-code') as any; + const currentContent = codeEditor?.value ?? ''; + + const encoder = new TextEncoder(); + const bytes = encoder.encode(currentContent); + const base64Content = btoa(String.fromCharCode(...bytes)); + + const success = await this.dataProvider.putObject( + this.bucketName, + this.objectKey, + base64Content, + this.contentType + ); + + if (success) { + this.originalTextContent = currentContent; + this.hasChanges = false; + this.editing = false; + this.content = base64Content; + } + } catch (err) { + console.error('Error saving object:', err); + } + + this.saving = false; + } + + render() { + if (!this.objectKey) { + return html` +
+
+ + + + +

Select a file to preview

+
+
+ `; + } + + if (this.loading) { + return html` +
+
Loading...
+
+ `; + } + + if (this.error) { + return html` +
+
${this.error}
+
+ `; + } + + return html` +
+
+
${getFileName(this.objectKey)}
+
+ ${this.contentType} + ${formatSize(this.fileSize)} + ${this.formatDate(this.lastModified)} + ${this.hasChanges ? html` + + + Unsaved changes + + ` : ''} +
+
+ +
+ ${this.editing + ? html` + this.handleContentChange(e)} + > + ` + : this.isText() + ? html` + + ` + : html` + + ` + } +
+ +
+ ${this.editing + ? html` + + + ` + : html` + ${this.isText() + ? html`` + : ''} + + + ` + } +
+
+ `; + } +} diff --git a/ts_web/elements/00group-dataview/dees-s3-browser/index.ts b/ts_web/elements/00group-dataview/dees-s3-browser/index.ts new file mode 100644 index 0000000..f3b21e4 --- /dev/null +++ b/ts_web/elements/00group-dataview/dees-s3-browser/index.ts @@ -0,0 +1,6 @@ +export * from './dees-s3-browser.js'; +export * from './dees-s3-columns.js'; +export * from './dees-s3-keys.js'; +export * from './dees-s3-preview.js'; +export * from './interfaces.js'; +export { formatSize, formatCount, getFileName, validateMove, getParentPrefix, getContentType, getDefaultContent, getPathSegments } from './utilities.js'; diff --git a/ts_web/elements/00group-dataview/dees-s3-browser/interfaces.ts b/ts_web/elements/00group-dataview/dees-s3-browser/interfaces.ts new file mode 100644 index 0000000..1e6ec5b --- /dev/null +++ b/ts_web/elements/00group-dataview/dees-s3-browser/interfaces.ts @@ -0,0 +1,37 @@ +/** + * S3 Data Provider interface - implement this to connect the S3 browser to your backend + */ + +export interface IS3Object { + key: string; + size?: number; + lastModified?: string; + isPrefix?: boolean; +} + +export interface IS3ChangeEvent { + type: 'add' | 'modify' | 'delete'; + key: string; + bucket: string; + size?: number; + lastModified?: Date; +} + +export interface IS3DataProvider { + listObjects(bucket: string, prefix?: string, delimiter?: string): Promise<{ objects: IS3Object[]; prefixes: string[] }>; + getObject(bucket: string, key: string): Promise<{ content: string; contentType: string; size: number; lastModified: string }>; + putObject(bucket: string, key: string, base64Content: string, contentType: string): Promise; + deleteObject(bucket: string, key: string): Promise; + deletePrefix(bucket: string, prefix: string): Promise; + getObjectUrl(bucket: string, key: string): Promise; + moveObject(bucket: string, sourceKey: string, destKey: string): Promise<{ success: boolean; error?: string }>; + movePrefix(bucket: string, sourcePrefix: string, destPrefix: string): Promise<{ success: boolean; movedCount?: number; error?: string }>; +} + +export interface IColumn { + prefix: string; + objects: IS3Object[]; + prefixes: string[]; + selectedItem: string | null; + width: number; +} diff --git a/ts_web/elements/00group-dataview/dees-s3-browser/utilities.ts b/ts_web/elements/00group-dataview/dees-s3-browser/utilities.ts new file mode 100644 index 0000000..94b66d7 --- /dev/null +++ b/ts_web/elements/00group-dataview/dees-s3-browser/utilities.ts @@ -0,0 +1,120 @@ +/** + * Shared utilities for S3 browser components + */ + +export interface IMoveValidation { + valid: boolean; + error?: string; +} + +/** + * Format a byte size into a human-readable string + */ +export function formatSize(bytes?: number): string { + if (bytes === undefined || bytes === null) return '-'; + if (bytes === 0) return '0 B'; + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(unitIndex > 0 ? 1 : 0)} ${units[unitIndex]}`; +} + +/** + * Format a count into a compact human-readable string + */ +export function formatCount(count?: number): string { + if (count === undefined || count === null) return ''; + if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`; + if (count >= 1000) return `${(count / 1000).toFixed(1)}K`; + return count.toString(); +} + +/** + * Extract the file name from a path + */ +export function getFileName(path: string): string { + const parts = path.replace(/\/$/, '').split('/'); + return parts[parts.length - 1] || path; +} + +/** + * Validates if a move operation is allowed + */ +export function validateMove(sourceKey: string, destPrefix: string): IMoveValidation { + if (sourceKey.endsWith('/')) { + if (destPrefix.startsWith(sourceKey)) { + return { valid: false, error: 'Cannot move a folder into itself' }; + } + } + + const sourceParent = getParentPrefix(sourceKey); + if (sourceParent === destPrefix) { + return { valid: false, error: 'Item is already in this location' }; + } + + return { valid: true }; +} + +/** + * Gets the parent prefix (directory) of a given key + */ +export function getParentPrefix(key: string): string { + const trimmed = key.endsWith('/') ? key.slice(0, -1) : key; + const lastSlash = trimmed.lastIndexOf('/'); + return lastSlash >= 0 ? trimmed.substring(0, lastSlash + 1) : ''; +} + +/** + * Get content type from file extension + */ +export function getContentType(ext: string): string { + const contentTypes: Record = { + 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'; +} + +/** + * Get default content for a new file based on extension + */ +export function getDefaultContent(ext: string): string { + const defaults: Record = { + json: '{\n \n}', + html: '\n\n\n \n\n\n \n\n', + md: '# Title\n\n', + txt: '', + }; + return defaults[ext] || ''; +} + +/** + * Parse a prefix into cumulative path segments + */ +export function getPathSegments(prefix: string): string[] { + if (!prefix) return []; + const parts = prefix.split('/').filter(p => p); + const segments: string[] = []; + let cumulative = ''; + for (const part of parts) { + cumulative += part + '/'; + segments.push(cumulative); + } + return segments; +} diff --git a/ts_web/elements/00group-dataview/index.ts b/ts_web/elements/00group-dataview/index.ts index 5791f42..805f323 100644 --- a/ts_web/elements/00group-dataview/index.ts +++ b/ts_web/elements/00group-dataview/index.ts @@ -3,3 +3,4 @@ export * from './dees-dataview-codebox/index.js'; export * from './dees-dataview-statusobject/index.js'; export * from './dees-table/index.js'; export * from './dees-statsgrid/index.js'; +export * from './dees-s3-browser/index.js';