Files
dees-catalog/ts_web/elements/00group-dataview/dees-s3-browser/dees-s3-browser.ts

440 lines
12 KiB
TypeScript

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<string, unknown>) {
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`
<div class="browser-container">
<div class="toolbar">
<div class="breadcrumb">
<span
class="breadcrumb-item"
@click=${() => this.navigateToPrefix('')}
>
${this.bucketName}
</span>
${breadcrumbParts.map((part, index) => {
const prefix = breadcrumbParts.slice(0, index + 1).join('/') + '/';
return html`
<span class="breadcrumb-separator">/</span>
<span
class="breadcrumb-item"
@click=${() => this.navigateToPrefix(prefix)}
>
${part}
</span>
`;
})}
</div>
${this.onChangeEvent ? html`
<div class="stream-status">
<span class="stream-dot ${this.isStreamConnected ? 'connected' : ''}"></span>
${this.isStreamConnected ? 'Live' : 'Offline'}
</div>
` : ''}
${this.recentChangeCount > 0
? html`
<div class="change-indicator pulse">
${this.recentChangeCount} change${this.recentChangeCount > 1 ? 's' : ''}
</div>
`
: ''}
<div class="view-toggle">
<button
class="view-btn ${this.viewType === 'columns' ? 'active' : ''}"
@click=${() => this.setViewType('columns')}
>
Columns
</button>
<button
class="view-btn ${this.viewType === 'keys' ? 'active' : ''}"
@click=${() => this.setViewType('keys')}
>
List
</button>
</div>
</div>
<div class="content ${this.selectedKey ? 'has-preview' : ''}" style="--preview-width: ${this.previewWidth}px">
<div class="main-view">
${this.viewType === 'columns'
? html`
<dees-s3-columns
.dataProvider=${this.dataProvider}
.bucketName=${this.bucketName}
.currentPrefix=${this.currentPrefix}
.refreshKey=${this.refreshKey}
@key-selected=${this.handleKeySelected}
@navigate=${this.handleNavigate}
></dees-s3-columns>
`
: html`
<dees-s3-keys
.dataProvider=${this.dataProvider}
.bucketName=${this.bucketName}
.currentPrefix=${this.currentPrefix}
.refreshKey=${this.refreshKey}
@key-selected=${this.handleKeySelected}
@navigate=${this.handleNavigate}
></dees-s3-keys>
`}
</div>
${this.selectedKey
? html`
<div
class="resize-divider ${this.isResizingPreview ? 'active' : ''}"
@mousedown=${this.startPreviewResize}
></div>
<div class="preview-panel">
<dees-s3-preview
.dataProvider=${this.dataProvider}
.bucketName=${this.bucketName}
.objectKey=${this.selectedKey}
@object-deleted=${this.handleObjectDeleted}
></dees-s3-preview>
</div>
`
: ''}
</div>
</div>
`;
}
}