440 lines
12 KiB
TypeScript
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>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
}
|