417 lines
11 KiB
TypeScript
417 lines
11 KiB
TypeScript
import * as plugins from '../plugins.js';
|
|
import { apiService, changeStreamService, type IS3ChangeEvent } from '../services/index.js';
|
|
import { themeStyles } from '../styles/index.js';
|
|
|
|
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
|
|
|
type TViewType = 'columns' | 'keys';
|
|
|
|
@customElement('tsview-s3-browser')
|
|
export class TsviewS3Browser extends DeesElement {
|
|
@property({ type: String })
|
|
public accessor bucketName: string = '';
|
|
|
|
@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 = 350;
|
|
|
|
@state()
|
|
private accessor isResizingPreview: boolean = false;
|
|
|
|
@state()
|
|
private accessor recentChangeCount: number = 0;
|
|
|
|
@state()
|
|
private accessor isStreamConnected: boolean = false;
|
|
|
|
private changeSubscription: plugins.smartrx.rxjs.Subscription | null = null;
|
|
|
|
public static styles = [
|
|
cssManager.defaultStyles,
|
|
themeStyles,
|
|
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: 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: #999;
|
|
}
|
|
|
|
.breadcrumb-item {
|
|
cursor: pointer;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.breadcrumb-item:hover {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
color: #fff;
|
|
}
|
|
|
|
.breadcrumb-separator {
|
|
color: #555;
|
|
}
|
|
|
|
.view-toggle {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
|
|
.view-btn {
|
|
padding: 6px 12px;
|
|
background: transparent;
|
|
border: 1px solid #444;
|
|
color: #888;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.view-btn:hover {
|
|
border-color: #666;
|
|
color: #aaa;
|
|
}
|
|
|
|
.view-btn.active {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-color: #404040;
|
|
color: #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, 350px);
|
|
}
|
|
|
|
.resize-divider {
|
|
width: 4px;
|
|
background: transparent;
|
|
cursor: col-resize;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.resize-divider:hover,
|
|
.resize-divider.active {
|
|
background: rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
.main-view {
|
|
overflow: auto;
|
|
background: rgba(0, 0, 0, 0.2);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.preview-panel {
|
|
background: 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: #888;
|
|
margin-left: auto;
|
|
margin-right: 12px;
|
|
}
|
|
|
|
.stream-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: #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();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
this.unsubscribeFromChanges();
|
|
}
|
|
|
|
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 = '';
|
|
// Increment refresh key to trigger re-render of child components
|
|
this.refreshKey++;
|
|
}
|
|
|
|
updated(changedProperties: Map<string, unknown>) {
|
|
if (changedProperties.has('bucketName')) {
|
|
// Clear selection when bucket changes
|
|
this.selectedKey = '';
|
|
this.currentPrefix = '';
|
|
this.recentChangeCount = 0;
|
|
// Re-subscribe to the new bucket
|
|
this.unsubscribeFromChanges();
|
|
this.subscribeToChanges();
|
|
}
|
|
}
|
|
|
|
private async subscribeToChanges() {
|
|
if (!this.bucketName) return;
|
|
|
|
try {
|
|
// Subscribe to bucket changes (with optional prefix)
|
|
const success = await changeStreamService.subscribeToBucket(this.bucketName, this.currentPrefix || undefined);
|
|
this.isStreamConnected = success;
|
|
|
|
if (success) {
|
|
// Listen for changes
|
|
this.changeSubscription = changeStreamService
|
|
.getBucketChanges(this.bucketName, this.currentPrefix || undefined)
|
|
.subscribe((event) => {
|
|
this.handleChange(event);
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.warn('[S3Browser] Failed to subscribe to changes:', error);
|
|
this.isStreamConnected = false;
|
|
}
|
|
}
|
|
|
|
private unsubscribeFromChanges() {
|
|
if (this.changeSubscription) {
|
|
this.changeSubscription.unsubscribe();
|
|
this.changeSubscription = null;
|
|
}
|
|
if (this.bucketName) {
|
|
changeStreamService.unsubscribeFromBucket(this.bucketName, this.currentPrefix || undefined);
|
|
}
|
|
this.isStreamConnected = false;
|
|
}
|
|
|
|
private handleChange(event: IS3ChangeEvent) {
|
|
console.log('[S3Browser] Received change:', event);
|
|
this.recentChangeCount++;
|
|
|
|
// Trigger refresh of child components
|
|
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), 600);
|
|
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>
|
|
|
|
<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`
|
|
<tsview-s3-columns
|
|
.bucketName=${this.bucketName}
|
|
.currentPrefix=${this.currentPrefix}
|
|
.refreshKey=${this.refreshKey}
|
|
@key-selected=${this.handleKeySelected}
|
|
@navigate=${this.handleNavigate}
|
|
></tsview-s3-columns>
|
|
`
|
|
: html`
|
|
<tsview-s3-keys
|
|
.bucketName=${this.bucketName}
|
|
.currentPrefix=${this.currentPrefix}
|
|
.refreshKey=${this.refreshKey}
|
|
@key-selected=${this.handleKeySelected}
|
|
@navigate=${this.handleNavigate}
|
|
></tsview-s3-keys>
|
|
`}
|
|
</div>
|
|
|
|
${this.selectedKey
|
|
? html`
|
|
<div
|
|
class="resize-divider ${this.isResizingPreview ? 'active' : ''}"
|
|
@mousedown=${this.startPreviewResize}
|
|
></div>
|
|
<div class="preview-panel">
|
|
<tsview-s3-preview
|
|
.bucketName=${this.bucketName}
|
|
.objectKey=${this.selectedKey}
|
|
@object-deleted=${this.handleObjectDeleted}
|
|
></tsview-s3-preview>
|
|
</div>
|
|
`
|
|
: ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|