initial
This commit is contained in:
15
ts_web/elements/index.ts
Normal file
15
ts_web/elements/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Main app shell
|
||||
export * from './tsview-app.js';
|
||||
|
||||
// S3 components
|
||||
export * from './tsview-s3-browser.js';
|
||||
export * from './tsview-s3-columns.js';
|
||||
export * from './tsview-s3-keys.js';
|
||||
export * from './tsview-s3-preview.js';
|
||||
|
||||
// MongoDB components
|
||||
export * from './tsview-mongo-browser.js';
|
||||
export * from './tsview-mongo-collections.js';
|
||||
export * from './tsview-mongo-documents.js';
|
||||
export * from './tsview-mongo-document.js';
|
||||
export * from './tsview-mongo-indexes.js';
|
||||
648
ts_web/elements/tsview-app.ts
Normal file
648
ts_web/elements/tsview-app.ts
Normal file
@@ -0,0 +1,648 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { apiService } from '../services/index.js';
|
||||
|
||||
const { html, css, cssManager, customElement, state, DeesElement } = plugins;
|
||||
|
||||
type TViewMode = 's3' | 'mongo' | 'settings';
|
||||
|
||||
@customElement('tsview-app')
|
||||
export class TsviewApp extends DeesElement {
|
||||
@state()
|
||||
private accessor viewMode: TViewMode = 's3';
|
||||
|
||||
@state()
|
||||
private accessor selectedBucket: string = '';
|
||||
|
||||
@state()
|
||||
private accessor selectedDatabase: string = '';
|
||||
|
||||
@state()
|
||||
private accessor selectedCollection: string = '';
|
||||
|
||||
@state()
|
||||
private accessor buckets: string[] = [];
|
||||
|
||||
@state()
|
||||
private accessor databases: Array<{ name: string; sizeOnDisk?: number }> = [];
|
||||
|
||||
@state()
|
||||
private accessor showCreateBucketDialog: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor newBucketName: string = '';
|
||||
|
||||
@state()
|
||||
private accessor showCreateCollectionDialog: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor newCollectionName: string = '';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: grid;
|
||||
grid-template-rows: 48px 1fr;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: #16162a;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-title svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: #1e1e38;
|
||||
border-right: 1px solid #333;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 12px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: #666;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.sidebar-list {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.sidebar-item.selected {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.sidebar-item .count {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.db-group {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.db-group-header {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.db-group-header:hover {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.db-group-collections {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.collection-item {
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.collection-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.collection-item.selected {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
margin: 8px;
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
border: 1px dashed rgba(99, 102, 241, 0.4);
|
||||
border-radius: 6px;
|
||||
color: #818cf8;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.create-btn:hover {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
border-color: rgba(99, 102, 241, 0.6);
|
||||
}
|
||||
|
||||
.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: #1e1e38;
|
||||
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: #fff;
|
||||
}
|
||||
|
||||
.dialog-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: #16162a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dialog-input:focus {
|
||||
outline: none;
|
||||
border-color: #818cf8;
|
||||
}
|
||||
|
||||
.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 #444;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.dialog-btn-cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dialog-btn-create {
|
||||
background: #6366f1;
|
||||
border: none;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dialog-btn-create:hover {
|
||||
background: #5558e8;
|
||||
}
|
||||
|
||||
.dialog-btn-create:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
private async loadData() {
|
||||
try {
|
||||
// Load buckets for S3
|
||||
this.buckets = await apiService.listBuckets();
|
||||
|
||||
// Load databases for MongoDB
|
||||
this.databases = await apiService.listDatabases();
|
||||
|
||||
// Select first item if available
|
||||
if (this.viewMode === 's3' && this.buckets.length > 0 && !this.selectedBucket) {
|
||||
this.selectedBucket = this.buckets[0];
|
||||
}
|
||||
if (this.viewMode === 'mongo' && this.databases.length > 0 && !this.selectedDatabase) {
|
||||
this.selectedDatabase = this.databases[0].name;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private setViewMode(mode: TViewMode) {
|
||||
this.viewMode = mode;
|
||||
}
|
||||
|
||||
private selectBucket(bucket: string) {
|
||||
this.selectedBucket = bucket;
|
||||
}
|
||||
|
||||
private selectDatabase(db: string) {
|
||||
this.selectedDatabase = db;
|
||||
this.selectedCollection = '';
|
||||
}
|
||||
|
||||
private selectCollection(collection: string) {
|
||||
this.selectedCollection = collection;
|
||||
}
|
||||
|
||||
private async createBucket() {
|
||||
if (!this.newBucketName.trim()) return;
|
||||
const success = await apiService.createBucket(this.newBucketName.trim());
|
||||
if (success) {
|
||||
this.buckets = [...this.buckets, this.newBucketName.trim()];
|
||||
this.newBucketName = '';
|
||||
this.showCreateBucketDialog = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async createCollection() {
|
||||
if (!this.newCollectionName.trim() || !this.selectedDatabase) return;
|
||||
const success = await apiService.createCollection(this.selectedDatabase, this.newCollectionName.trim());
|
||||
if (success) {
|
||||
this.newCollectionName = '';
|
||||
this.showCreateCollectionDialog = false;
|
||||
// Refresh will happen through the collections component
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="app-container">
|
||||
<header class="app-header">
|
||||
<div class="app-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path>
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
|
||||
</svg>
|
||||
TsView
|
||||
</div>
|
||||
|
||||
<nav class="nav-tabs">
|
||||
<button
|
||||
class="nav-tab ${this.viewMode === 's3' ? 'active' : ''}"
|
||||
@click=${() => this.setViewMode('s3')}
|
||||
>
|
||||
S3 Storage
|
||||
</button>
|
||||
<button
|
||||
class="nav-tab ${this.viewMode === 'mongo' ? 'active' : ''}"
|
||||
@click=${() => this.setViewMode('mongo')}
|
||||
>
|
||||
MongoDB
|
||||
</button>
|
||||
<button
|
||||
class="nav-tab ${this.viewMode === 'settings' ? 'active' : ''}"
|
||||
@click=${() => this.setViewMode('settings')}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
${this.renderSidebar()}
|
||||
${this.renderContent()}
|
||||
</main>
|
||||
</div>
|
||||
${this.renderCreateBucketDialog()}
|
||||
${this.renderCreateCollectionDialog()}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCreateBucketDialog() {
|
||||
if (!this.showCreateBucketDialog) return '';
|
||||
return html`
|
||||
<div class="dialog-overlay" @click=${() => this.showCreateBucketDialog = false}>
|
||||
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="dialog-title">Create New Bucket</div>
|
||||
<input
|
||||
type="text"
|
||||
class="dialog-input"
|
||||
placeholder="Bucket name"
|
||||
.value=${this.newBucketName}
|
||||
@input=${(e: InputEvent) => this.newBucketName = (e.target as HTMLInputElement).value}
|
||||
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.createBucket()}
|
||||
/>
|
||||
<div class="dialog-actions">
|
||||
<button class="dialog-btn dialog-btn-cancel" @click=${() => this.showCreateBucketDialog = false}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="dialog-btn dialog-btn-create"
|
||||
?disabled=${!this.newBucketName.trim()}
|
||||
@click=${() => this.createBucket()}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCreateCollectionDialog() {
|
||||
if (!this.showCreateCollectionDialog) return '';
|
||||
return html`
|
||||
<div class="dialog-overlay" @click=${() => this.showCreateCollectionDialog = false}>
|
||||
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="dialog-title">Create Collection in ${this.selectedDatabase}</div>
|
||||
<input
|
||||
type="text"
|
||||
class="dialog-input"
|
||||
placeholder="Collection name"
|
||||
.value=${this.newCollectionName}
|
||||
@input=${(e: InputEvent) => this.newCollectionName = (e.target as HTMLInputElement).value}
|
||||
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.createCollection()}
|
||||
/>
|
||||
<div class="dialog-actions">
|
||||
<button class="dialog-btn dialog-btn-cancel" @click=${() => this.showCreateCollectionDialog = false}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="dialog-btn dialog-btn-create"
|
||||
?disabled=${!this.newCollectionName.trim()}
|
||||
@click=${() => this.createCollection()}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSidebar() {
|
||||
if (this.viewMode === 's3') {
|
||||
return html`
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">Buckets</div>
|
||||
<button class="create-btn" @click=${() => this.showCreateBucketDialog = true}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
New Bucket
|
||||
</button>
|
||||
<div class="sidebar-list">
|
||||
${this.buckets.length === 0
|
||||
? html`<div class="sidebar-item" style="color: #666; cursor: default;">No buckets found</div>`
|
||||
: this.buckets.map(
|
||||
(bucket) => html`
|
||||
<div
|
||||
class="sidebar-item ${bucket === this.selectedBucket ? 'selected' : ''}"
|
||||
@click=${() => this.selectBucket(bucket)}
|
||||
>
|
||||
${bucket}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.viewMode === 'mongo') {
|
||||
return html`
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">Databases & Collections</div>
|
||||
${this.selectedDatabase ? html`
|
||||
<button class="create-btn" @click=${() => this.showCreateCollectionDialog = true}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
New Collection
|
||||
</button>
|
||||
` : ''}
|
||||
<div class="sidebar-list">
|
||||
${this.databases.length === 0
|
||||
? html`<div class="sidebar-item" style="color: #666; cursor: default;">No databases found</div>`
|
||||
: this.databases.map((db) => this.renderDatabaseGroup(db))}
|
||||
</div>
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">Settings</div>
|
||||
<div class="sidebar-list">
|
||||
<div class="sidebar-item">Connection</div>
|
||||
<div class="sidebar-item">Display</div>
|
||||
</div>
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDatabaseGroup(db: { name: string }) {
|
||||
return html`
|
||||
<div class="db-group">
|
||||
<div
|
||||
class="db-group-header ${this.selectedDatabase === db.name ? 'selected' : ''}"
|
||||
@click=${() => this.selectDatabase(db.name)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
|
||||
</svg>
|
||||
${db.name}
|
||||
</div>
|
||||
${this.selectedDatabase === db.name ? this.renderCollectionsList(db.name) : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCollectionsList(dbName: string) {
|
||||
return html`
|
||||
<tsview-mongo-collections
|
||||
.databaseName=${dbName}
|
||||
.selectedCollection=${this.selectedCollection}
|
||||
@collection-selected=${(e: CustomEvent) => this.selectCollection(e.detail)}
|
||||
></tsview-mongo-collections>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderContent() {
|
||||
if (this.viewMode === 's3') {
|
||||
if (!this.selectedBucket) {
|
||||
return html`
|
||||
<div class="content-area">
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
<p>Select a bucket to browse</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="content-area">
|
||||
<tsview-s3-browser .bucketName=${this.selectedBucket}></tsview-s3-browser>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.viewMode === 'mongo') {
|
||||
if (!this.selectedCollection) {
|
||||
return html`
|
||||
<div class="content-area">
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
|
||||
</svg>
|
||||
<p>Select a collection to view documents</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="content-area">
|
||||
<tsview-mongo-browser
|
||||
.databaseName=${this.selectedDatabase}
|
||||
.collectionName=${this.selectedCollection}
|
||||
></tsview-mongo-browser>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="content-area">
|
||||
<h2>Settings</h2>
|
||||
<p>Configuration options coming soon.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
253
ts_web/elements/tsview-mongo-browser.ts
Normal file
253
ts_web/elements/tsview-mongo-browser.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { apiService, type ICollectionStats } from '../services/index.js';
|
||||
|
||||
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||
|
||||
type TViewTab = 'documents' | 'indexes' | 'aggregation';
|
||||
|
||||
@customElement('tsview-mongo-browser')
|
||||
export class TsviewMongoBrowser extends DeesElement {
|
||||
@property({ type: String })
|
||||
public accessor databaseName: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public accessor collectionName: string = '';
|
||||
|
||||
@state()
|
||||
private accessor activeTab: TViewTab = 'documents';
|
||||
|
||||
@state()
|
||||
private accessor selectedDocumentId: string = '';
|
||||
|
||||
@state()
|
||||
private accessor stats: ICollectionStats | null = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.browser-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.collection-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.collection-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.collection-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-panel {
|
||||
overflow: auto;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.loadStats();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, unknown>) {
|
||||
if (changedProperties.has('databaseName') || changedProperties.has('collectionName')) {
|
||||
this.loadStats();
|
||||
this.selectedDocumentId = '';
|
||||
}
|
||||
}
|
||||
|
||||
private async loadStats() {
|
||||
if (!this.databaseName || !this.collectionName) return;
|
||||
|
||||
try {
|
||||
this.stats = await apiService.getCollectionStats(this.databaseName, this.collectionName);
|
||||
} catch (err) {
|
||||
console.error('Error loading stats:', err);
|
||||
this.stats = null;
|
||||
}
|
||||
}
|
||||
|
||||
private setActiveTab(tab: TViewTab) {
|
||||
this.activeTab = tab;
|
||||
}
|
||||
|
||||
private handleDocumentSelected(e: CustomEvent) {
|
||||
this.selectedDocumentId = e.detail.documentId;
|
||||
}
|
||||
|
||||
private formatCount(num: number): string {
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
private formatSize(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
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]}`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="browser-container">
|
||||
<div class="header">
|
||||
<div class="collection-info">
|
||||
<span class="collection-title">${this.collectionName}</span>
|
||||
${this.stats
|
||||
? html`
|
||||
<div class="collection-stats">
|
||||
<span class="stat-item">${this.formatCount(this.stats.count)} docs</span>
|
||||
<span class="stat-item">${this.formatSize(this.stats.size)}</span>
|
||||
<span class="stat-item">${this.stats.indexCount} indexes</span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab ${this.activeTab === 'documents' ? 'active' : ''}"
|
||||
@click=${() => this.setActiveTab('documents')}
|
||||
>
|
||||
Documents
|
||||
</button>
|
||||
<button
|
||||
class="tab ${this.activeTab === 'indexes' ? 'active' : ''}"
|
||||
@click=${() => this.setActiveTab('indexes')}
|
||||
>
|
||||
Indexes
|
||||
</button>
|
||||
<button
|
||||
class="tab ${this.activeTab === 'aggregation' ? 'active' : ''}"
|
||||
@click=${() => this.setActiveTab('aggregation')}
|
||||
>
|
||||
Aggregation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="main-panel">
|
||||
${this.activeTab === 'documents'
|
||||
? html`
|
||||
<tsview-mongo-documents
|
||||
.databaseName=${this.databaseName}
|
||||
.collectionName=${this.collectionName}
|
||||
@document-selected=${this.handleDocumentSelected}
|
||||
></tsview-mongo-documents>
|
||||
`
|
||||
: this.activeTab === 'indexes'
|
||||
? html`
|
||||
<tsview-mongo-indexes
|
||||
.databaseName=${this.databaseName}
|
||||
.collectionName=${this.collectionName}
|
||||
></tsview-mongo-indexes>
|
||||
`
|
||||
: html`
|
||||
<div style="padding: 24px; text-align: center; color: #666;">
|
||||
Aggregation pipeline builder coming soon
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="detail-panel">
|
||||
<tsview-mongo-document
|
||||
.databaseName=${this.databaseName}
|
||||
.collectionName=${this.collectionName}
|
||||
.documentId=${this.selectedDocumentId}
|
||||
></tsview-mongo-document>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
159
ts_web/elements/tsview-mongo-collections.ts
Normal file
159
ts_web/elements/tsview-mongo-collections.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { apiService, type IMongoCollection } from '../services/index.js';
|
||||
|
||||
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||
|
||||
@customElement('tsview-mongo-collections')
|
||||
export class TsviewMongoCollections extends DeesElement {
|
||||
@property({ type: String })
|
||||
public accessor databaseName: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public accessor selectedCollection: string = '';
|
||||
|
||||
@state()
|
||||
private accessor collections: IMongoCollection[] = [];
|
||||
|
||||
@state()
|
||||
private accessor loading: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.collections-list {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.collection-item {
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.collection-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.collection-item.selected {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.collection-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.collection-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.collection-count {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 8px 12px;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 8px 12px;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.loadCollections();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, unknown>) {
|
||||
if (changedProperties.has('databaseName')) {
|
||||
this.loadCollections();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadCollections() {
|
||||
if (!this.databaseName) return;
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
this.collections = await apiService.listCollections(this.databaseName);
|
||||
} catch (err) {
|
||||
console.error('Error loading collections:', err);
|
||||
this.collections = [];
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
private selectCollection(name: string) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('collection-selected', {
|
||||
detail: name,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private formatCount(count?: number): string {
|
||||
if (count === undefined) return '';
|
||||
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.loading) {
|
||||
return html`<div class="loading-state">Loading collections...</div>`;
|
||||
}
|
||||
|
||||
if (this.collections.length === 0) {
|
||||
return html`<div class="empty-state">No collections</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="collections-list">
|
||||
${this.collections.map(
|
||||
(coll) => html`
|
||||
<div
|
||||
class="collection-item ${this.selectedCollection === coll.name ? 'selected' : ''}"
|
||||
@click=${() => this.selectCollection(coll.name)}
|
||||
>
|
||||
<span class="collection-name">
|
||||
<svg class="collection-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
${coll.name}
|
||||
</span>
|
||||
${coll.count !== undefined
|
||||
? html`<span class="collection-count">${this.formatCount(coll.count)}</span>`
|
||||
: ''}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
365
ts_web/elements/tsview-mongo-document.ts
Normal file
365
ts_web/elements/tsview-mongo-document.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { apiService } from '../services/index.js';
|
||||
|
||||
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||
|
||||
@customElement('tsview-mongo-document')
|
||||
export class TsviewMongoDocument extends DeesElement {
|
||||
@property({ type: String })
|
||||
public accessor databaseName: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public accessor collectionName: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public accessor documentId: string = '';
|
||||
|
||||
@state()
|
||||
private accessor document: Record<string, unknown> | null = null;
|
||||
|
||||
@state()
|
||||
private accessor loading: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor editing: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor editContent: string = '';
|
||||
|
||||
@state()
|
||||
private accessor error: string = '';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.document-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid #444;
|
||||
color: #888;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
border-color: #666;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
border-color: #6366f1;
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.json-view {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.json-key {
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.json-string {
|
||||
color: #a5d6a7;
|
||||
}
|
||||
|
||||
.json-number {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.json-boolean {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.json-null {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.edit-area {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
padding: 12px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.edit-area:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #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: #888;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
padding: 16px;
|
||||
color: #f87171;
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
updated(changedProperties: Map<string, unknown>) {
|
||||
if (changedProperties.has('documentId')) {
|
||||
this.editing = false;
|
||||
if (this.documentId) {
|
||||
this.loadDocument();
|
||||
} else {
|
||||
this.document = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async loadDocument() {
|
||||
if (!this.documentId || !this.databaseName || !this.collectionName) return;
|
||||
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
this.document = await apiService.getDocument(
|
||||
this.databaseName,
|
||||
this.collectionName,
|
||||
this.documentId
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error loading document:', err);
|
||||
this.error = 'Failed to load document';
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
private startEditing() {
|
||||
this.editContent = JSON.stringify(this.document, null, 2);
|
||||
this.editing = true;
|
||||
}
|
||||
|
||||
private cancelEditing() {
|
||||
this.editing = false;
|
||||
this.editContent = '';
|
||||
}
|
||||
|
||||
private async saveDocument() {
|
||||
try {
|
||||
const updatedDoc = JSON.parse(this.editContent);
|
||||
|
||||
// Remove _id from update (can't update _id)
|
||||
const { _id, ...updateFields } = updatedDoc;
|
||||
|
||||
await apiService.updateDocument(
|
||||
this.databaseName,
|
||||
this.collectionName,
|
||||
this.documentId,
|
||||
updateFields
|
||||
);
|
||||
|
||||
this.editing = false;
|
||||
await this.loadDocument();
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('document-updated', {
|
||||
detail: { documentId: this.documentId },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error saving document:', err);
|
||||
this.error = 'Invalid JSON or save failed';
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteDocument() {
|
||||
if (!confirm('Delete this document?')) return;
|
||||
|
||||
try {
|
||||
await apiService.deleteDocument(
|
||||
this.databaseName,
|
||||
this.collectionName,
|
||||
this.documentId
|
||||
);
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('document-deleted', {
|
||||
detail: { documentId: this.documentId },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
|
||||
this.document = null;
|
||||
} catch (err) {
|
||||
console.error('Error deleting document:', err);
|
||||
this.error = 'Delete failed';
|
||||
}
|
||||
}
|
||||
|
||||
private formatJson(obj: unknown): string {
|
||||
return JSON.stringify(obj, null, 2);
|
||||
}
|
||||
|
||||
private syntaxHighlight(json: string): string {
|
||||
// Basic syntax highlighting
|
||||
return json
|
||||
.replace(/"([^"]+)":/g, '<span class="json-key">"$1"</span>:')
|
||||
.replace(/: "([^"]*)"/g, ': <span class="json-string">"$1"</span>')
|
||||
.replace(/: (\d+\.?\d*)/g, ': <span class="json-number">$1</span>')
|
||||
.replace(/: (true|false)/g, ': <span class="json-boolean">$1</span>')
|
||||
.replace(/: (null)/g, ': <span class="json-null">$1</span>');
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.documentId) {
|
||||
return html`
|
||||
<div class="document-container">
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<p>Select a document to view</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.loading) {
|
||||
return html`
|
||||
<div class="document-container">
|
||||
<div class="loading-state">Loading...</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.error && !this.document) {
|
||||
return html`
|
||||
<div class="document-container">
|
||||
<div class="error-state">${this.error}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="document-container">
|
||||
<div class="header">
|
||||
<span class="header-title">Document</span>
|
||||
<div class="header-actions">
|
||||
${this.editing
|
||||
? html`
|
||||
<button class="action-btn" @click=${this.cancelEditing}>Cancel</button>
|
||||
<button class="action-btn primary" @click=${this.saveDocument}>Save</button>
|
||||
`
|
||||
: html`
|
||||
<button class="action-btn" @click=${this.startEditing}>Edit</button>
|
||||
<button class="action-btn danger" @click=${this.deleteDocument}>Delete</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
${this.editing
|
||||
? html`
|
||||
<textarea
|
||||
class="edit-area"
|
||||
.value=${this.editContent}
|
||||
@input=${(e: Event) => (this.editContent = (e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
`
|
||||
: html`
|
||||
<div
|
||||
class="json-view"
|
||||
.innerHTML=${this.syntaxHighlight(this.formatJson(this.document))}
|
||||
></div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
399
ts_web/elements/tsview-mongo-documents.ts
Normal file
399
ts_web/elements/tsview-mongo-documents.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { apiService } from '../services/index.js';
|
||||
|
||||
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||
|
||||
@customElement('tsview-mongo-documents')
|
||||
export class TsviewMongoDocuments extends DeesElement {
|
||||
@property({ type: String })
|
||||
public accessor databaseName: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public accessor collectionName: string = '';
|
||||
|
||||
@state()
|
||||
private accessor documents: Record<string, unknown>[] = [];
|
||||
|
||||
@state()
|
||||
private accessor total: number = 0;
|
||||
|
||||
@state()
|
||||
private accessor page: number = 1;
|
||||
|
||||
@state()
|
||||
private accessor pageSize: number = 50;
|
||||
|
||||
@state()
|
||||
private accessor loading: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor filterText: string = '';
|
||||
|
||||
@state()
|
||||
private accessor selectedId: string = '';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.documents-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.filter-input::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 8px 16px;
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
border: 1px solid #6366f1;
|
||||
color: #818cf8;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.documents-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.document-row {
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.document-row:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.document-row.selected {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.document-id {
|
||||
font-size: 12px;
|
||||
color: #818cf8;
|
||||
font-family: monospace;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.document-preview {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
border-top: 1px solid #333;
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid #444;
|
||||
color: #888;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
border-color: #666;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.actions-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 12px;
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
border: 1px solid #22c55e;
|
||||
color: #4ade80;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.loadDocuments();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, unknown>) {
|
||||
if (changedProperties.has('databaseName') || changedProperties.has('collectionName')) {
|
||||
this.page = 1;
|
||||
this.loadDocuments();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadDocuments() {
|
||||
if (!this.databaseName || !this.collectionName) return;
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
let filter = {};
|
||||
if (this.filterText.trim()) {
|
||||
try {
|
||||
filter = JSON.parse(this.filterText);
|
||||
} catch {
|
||||
// Invalid JSON, ignore filter
|
||||
}
|
||||
}
|
||||
|
||||
const result = await apiService.findDocuments(
|
||||
this.databaseName,
|
||||
this.collectionName,
|
||||
{
|
||||
filter,
|
||||
skip: (this.page - 1) * this.pageSize,
|
||||
limit: this.pageSize,
|
||||
}
|
||||
);
|
||||
|
||||
this.documents = result.documents;
|
||||
this.total = result.total;
|
||||
} catch (err) {
|
||||
console.error('Error loading documents:', err);
|
||||
this.documents = [];
|
||||
this.total = 0;
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
private handleFilterInput(e: Event) {
|
||||
this.filterText = (e.target as HTMLInputElement).value;
|
||||
}
|
||||
|
||||
private handleFilterSubmit() {
|
||||
this.page = 1;
|
||||
this.loadDocuments();
|
||||
}
|
||||
|
||||
private handleKeyPress(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
this.handleFilterSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
private selectDocument(doc: Record<string, unknown>) {
|
||||
const id = (doc._id as string) || '';
|
||||
this.selectedId = id;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('document-selected', {
|
||||
detail: { documentId: id, document: doc },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private goToPage(pageNum: number) {
|
||||
this.page = pageNum;
|
||||
this.loadDocuments();
|
||||
}
|
||||
|
||||
private getDocumentPreview(doc: Record<string, unknown>): string {
|
||||
const preview: Record<string, unknown> = {};
|
||||
const keys = Object.keys(doc).filter((k) => k !== '_id');
|
||||
for (const key of keys.slice(0, 3)) {
|
||||
preview[key] = doc[key];
|
||||
}
|
||||
return JSON.stringify(preview);
|
||||
}
|
||||
|
||||
private get totalPages(): number {
|
||||
return Math.ceil(this.total / this.pageSize);
|
||||
}
|
||||
|
||||
private async handleInsertNew() {
|
||||
const newDoc = {
|
||||
// Default empty document
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
const insertedId = await apiService.insertDocument(
|
||||
this.databaseName,
|
||||
this.collectionName,
|
||||
newDoc
|
||||
);
|
||||
await this.loadDocuments();
|
||||
this.selectedId = insertedId;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('document-selected', {
|
||||
detail: { documentId: insertedId },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error inserting document:', err);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const startRecord = (this.page - 1) * this.pageSize + 1;
|
||||
const endRecord = Math.min(this.page * this.pageSize, this.total);
|
||||
|
||||
return html`
|
||||
<div class="documents-container">
|
||||
<div class="filter-bar">
|
||||
<input
|
||||
type="text"
|
||||
class="filter-input"
|
||||
placeholder='Filter: {"field": "value"}'
|
||||
.value=${this.filterText}
|
||||
@input=${this.handleFilterInput}
|
||||
@keypress=${this.handleKeyPress}
|
||||
/>
|
||||
<button class="filter-btn" @click=${this.handleFilterSubmit}>Apply</button>
|
||||
</div>
|
||||
|
||||
<div class="actions-bar">
|
||||
<button class="action-btn" @click=${this.handleInsertNew}>+ Insert Document</button>
|
||||
</div>
|
||||
|
||||
<div class="documents-list">
|
||||
${this.loading
|
||||
? html`<div class="loading-state">Loading...</div>`
|
||||
: this.documents.length === 0
|
||||
? html`<div class="empty-state">No documents found</div>`
|
||||
: this.documents.map(
|
||||
(doc) => html`
|
||||
<div
|
||||
class="document-row ${this.selectedId === doc._id ? 'selected' : ''}"
|
||||
@click=${() => this.selectDocument(doc)}
|
||||
>
|
||||
<div class="document-id">_id: ${doc._id}</div>
|
||||
<div class="document-preview">${this.getDocumentPreview(doc)}</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
|
||||
${this.total > 0
|
||||
? html`
|
||||
<div class="pagination">
|
||||
<div class="pagination-info">
|
||||
Showing ${startRecord}-${endRecord} of ${this.total}
|
||||
</div>
|
||||
<div class="pagination-controls">
|
||||
<button
|
||||
class="page-btn"
|
||||
?disabled=${this.page <= 1}
|
||||
@click=${() => this.goToPage(this.page - 1)}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
class="page-btn"
|
||||
?disabled=${this.page >= this.totalPages}
|
||||
@click=${() => this.goToPage(this.page + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
428
ts_web/elements/tsview-mongo-indexes.ts
Normal file
428
ts_web/elements/tsview-mongo-indexes.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { apiService, type IMongoIndex } from '../services/index.js';
|
||||
|
||||
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||
|
||||
@customElement('tsview-mongo-indexes')
|
||||
export class TsviewMongoIndexes extends DeesElement {
|
||||
@property({ type: String })
|
||||
public accessor databaseName: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public accessor collectionName: string = '';
|
||||
|
||||
@state()
|
||||
private accessor indexes: IMongoIndex[] = [];
|
||||
|
||||
@state()
|
||||
private accessor loading: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor showCreateDialog: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor newIndexKeys: string = '';
|
||||
|
||||
@state()
|
||||
private accessor newIndexUnique: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor newIndexSparse: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.indexes-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
padding: 8px 16px;
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
border: 1px solid #22c55e;
|
||||
color: #4ade80;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.create-btn:hover {
|
||||
background: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.indexes-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.index-card {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.index-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.index-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.index-badges {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.badge.unique {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.badge.sparse {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.index-keys {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.index-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.drop-btn {
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid #ef4444;
|
||||
color: #f87171;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.drop-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.drop-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Dialog styles */
|
||||
.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: #1e1e38;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 400px;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dialog-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dialog-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.dialog-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.dialog-input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.dialog-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.dialog-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dialog-btn.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid #444;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.dialog-btn.secondary:hover {
|
||||
border-color: #666;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.dialog-btn.primary {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
border: 1px solid #6366f1;
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.dialog-btn.primary:hover {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.loadIndexes();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, unknown>) {
|
||||
if (changedProperties.has('databaseName') || changedProperties.has('collectionName')) {
|
||||
this.loadIndexes();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadIndexes() {
|
||||
if (!this.databaseName || !this.collectionName) return;
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
this.indexes = await apiService.listIndexes(this.databaseName, this.collectionName);
|
||||
} catch (err) {
|
||||
console.error('Error loading indexes:', err);
|
||||
this.indexes = [];
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
private openCreateDialog() {
|
||||
this.newIndexKeys = '';
|
||||
this.newIndexUnique = false;
|
||||
this.newIndexSparse = false;
|
||||
this.showCreateDialog = true;
|
||||
}
|
||||
|
||||
private closeCreateDialog() {
|
||||
this.showCreateDialog = false;
|
||||
}
|
||||
|
||||
private async createIndex() {
|
||||
try {
|
||||
const keys = JSON.parse(this.newIndexKeys);
|
||||
|
||||
await apiService.createIndex(this.databaseName, this.collectionName, keys, {
|
||||
unique: this.newIndexUnique,
|
||||
sparse: this.newIndexSparse,
|
||||
});
|
||||
|
||||
this.closeCreateDialog();
|
||||
await this.loadIndexes();
|
||||
} catch (err) {
|
||||
console.error('Error creating index:', err);
|
||||
alert('Invalid JSON or index creation failed');
|
||||
}
|
||||
}
|
||||
|
||||
private async dropIndex(indexName: string) {
|
||||
if (indexName === '_id_') {
|
||||
alert('Cannot drop the _id index');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Drop index "${indexName}"?`)) return;
|
||||
|
||||
try {
|
||||
await apiService.dropIndex(this.databaseName, this.collectionName, indexName);
|
||||
await this.loadIndexes();
|
||||
} catch (err) {
|
||||
console.error('Error dropping index:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private formatKeys(keys: Record<string, number>): string {
|
||||
return JSON.stringify(keys);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="indexes-container">
|
||||
<div class="toolbar">
|
||||
<button class="create-btn" @click=${this.openCreateDialog}>+ Create Index</button>
|
||||
</div>
|
||||
|
||||
<div class="indexes-list">
|
||||
${this.loading
|
||||
? html`<div class="loading-state">Loading...</div>`
|
||||
: this.indexes.length === 0
|
||||
? html`<div class="empty-state">No indexes found</div>`
|
||||
: this.indexes.map(
|
||||
(idx) => html`
|
||||
<div class="index-card">
|
||||
<div class="index-header">
|
||||
<span class="index-name">${idx.name}</span>
|
||||
<div class="index-badges">
|
||||
${idx.unique ? html`<span class="badge unique">unique</span>` : ''}
|
||||
${idx.sparse ? html`<span class="badge sparse">sparse</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="index-keys">${this.formatKeys(idx.keys)}</div>
|
||||
<div class="index-actions">
|
||||
<button
|
||||
class="drop-btn"
|
||||
?disabled=${idx.name === '_id_'}
|
||||
@click=${() => this.dropIndex(idx.name)}
|
||||
>
|
||||
Drop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.showCreateDialog
|
||||
? html`
|
||||
<div class="dialog-overlay" @click=${this.closeCreateDialog}>
|
||||
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="dialog-title">Create Index</div>
|
||||
|
||||
<div class="dialog-field">
|
||||
<label class="dialog-label">Index Keys (JSON)</label>
|
||||
<input
|
||||
type="text"
|
||||
class="dialog-input"
|
||||
placeholder='{"field": 1}'
|
||||
.value=${this.newIndexKeys}
|
||||
@input=${(e: Event) => (this.newIndexKeys = (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="dialog-field">
|
||||
<label class="dialog-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${this.newIndexUnique}
|
||||
@change=${(e: Event) => (this.newIndexUnique = (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
Unique
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="dialog-field">
|
||||
<label class="dialog-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${this.newIndexSparse}
|
||||
@change=${(e: Event) => (this.newIndexSparse = (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
Sparse
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button class="dialog-btn secondary" @click=${this.closeCreateDialog}>Cancel</button>
|
||||
<button class="dialog-btn primary" @click=${this.createIndex}>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
223
ts_web/elements/tsview-s3-browser.ts
Normal file
223
ts_web/elements/tsview-s3-browser.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { apiService } from '../services/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 = '';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
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(99, 102, 241, 0.2);
|
||||
border-color: #6366f1;
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 350px;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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="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">
|
||||
<div class="main-view">
|
||||
${this.viewType === 'columns'
|
||||
? html`
|
||||
<tsview-s3-columns
|
||||
.bucketName=${this.bucketName}
|
||||
.currentPrefix=${this.currentPrefix}
|
||||
@key-selected=${this.handleKeySelected}
|
||||
@navigate=${this.handleNavigate}
|
||||
></tsview-s3-columns>
|
||||
`
|
||||
: html`
|
||||
<tsview-s3-keys
|
||||
.bucketName=${this.bucketName}
|
||||
.currentPrefix=${this.currentPrefix}
|
||||
@key-selected=${this.handleKeySelected}
|
||||
@navigate=${this.handleNavigate}
|
||||
></tsview-s3-keys>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="preview-panel">
|
||||
<tsview-s3-preview
|
||||
.bucketName=${this.bucketName}
|
||||
.objectKey=${this.selectedKey}
|
||||
></tsview-s3-preview>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
299
ts_web/elements/tsview-s3-columns.ts
Normal file
299
ts_web/elements/tsview-s3-columns.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { apiService, type IS3Object } from '../services/index.js';
|
||||
|
||||
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||
|
||||
interface IColumn {
|
||||
prefix: string;
|
||||
objects: IS3Object[];
|
||||
prefixes: string[];
|
||||
selectedItem: string | null;
|
||||
}
|
||||
|
||||
@customElement('tsview-s3-columns')
|
||||
export class TsviewS3Columns extends DeesElement {
|
||||
@property({ type: String })
|
||||
public accessor bucketName: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public accessor currentPrefix: string = '';
|
||||
|
||||
@state()
|
||||
private accessor columns: IColumn[] = [];
|
||||
|
||||
@state()
|
||||
private accessor loading: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.columns-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.column {
|
||||
min-width: 220px;
|
||||
max-width: 280px;
|
||||
border-right: 1px solid #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.column:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-bottom: 1px solid #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: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.column-item.selected {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.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: #555;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.loadInitialColumn();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, unknown>) {
|
||||
if (changedProperties.has('bucketName') || changedProperties.has('currentPrefix')) {
|
||||
this.loadInitialColumn();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadInitialColumn() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const result = await apiService.listObjects(this.bucketName, this.currentPrefix, '/');
|
||||
this.columns = [
|
||||
{
|
||||
prefix: this.currentPrefix,
|
||||
objects: result.objects,
|
||||
prefixes: result.prefixes,
|
||||
selectedItem: null,
|
||||
},
|
||||
];
|
||||
} catch (err) {
|
||||
console.error('Error loading objects:', err);
|
||||
this.columns = [];
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
private async selectFolder(columnIndex: number, prefix: string) {
|
||||
// Update selection in current column
|
||||
this.columns = this.columns.map((col, i) => {
|
||||
if (i === columnIndex) {
|
||||
return { ...col, selectedItem: prefix };
|
||||
}
|
||||
return col;
|
||||
});
|
||||
|
||||
// Remove columns after current
|
||||
this.columns = this.columns.slice(0, columnIndex + 1);
|
||||
|
||||
// Load new column
|
||||
try {
|
||||
const result = await apiService.listObjects(this.bucketName, prefix, '/');
|
||||
this.columns = [
|
||||
...this.columns,
|
||||
{
|
||||
prefix,
|
||||
objects: result.objects,
|
||||
prefixes: result.prefixes,
|
||||
selectedItem: null,
|
||||
},
|
||||
];
|
||||
} catch (err) {
|
||||
console.error('Error loading folder:', err);
|
||||
}
|
||||
|
||||
// Dispatch navigate event
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('navigate', {
|
||||
detail: { prefix },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private selectFile(columnIndex: number, key: string) {
|
||||
// Update selection
|
||||
this.columns = this.columns.map((col, i) => {
|
||||
if (i === columnIndex) {
|
||||
return { ...col, selectedItem: key };
|
||||
}
|
||||
return col;
|
||||
});
|
||||
|
||||
// Remove columns after current
|
||||
this.columns = this.columns.slice(0, columnIndex + 1);
|
||||
|
||||
// Dispatch key-selected event
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('key-selected', {
|
||||
detail: { key },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private getFileName(path: string): string {
|
||||
const parts = path.replace(/\/$/, '').split('/');
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
|
||||
private getFileIcon(key: string): string {
|
||||
const ext = key.split('.').pop()?.toLowerCase() || '';
|
||||
const iconMap: Record<string, string> = {
|
||||
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';
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.loading && this.columns.length === 0) {
|
||||
return html`<div class="loading">Loading...</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="columns-container">
|
||||
${this.columns.map((column, index) => this.renderColumn(column, index))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderColumn(column: IColumn, index: number) {
|
||||
const headerName = column.prefix
|
||||
? this.getFileName(column.prefix)
|
||||
: this.bucketName;
|
||||
|
||||
return html`
|
||||
<div class="column">
|
||||
<div class="column-header" title=${column.prefix || this.bucketName}>
|
||||
${headerName}
|
||||
</div>
|
||||
<div class="column-items">
|
||||
${column.prefixes.length === 0 && column.objects.length === 0
|
||||
? html`<div class="empty-state">Empty folder</div>`
|
||||
: ''}
|
||||
${column.prefixes.map(
|
||||
(prefix) => html`
|
||||
<div
|
||||
class="column-item folder ${column.selectedItem === prefix ? 'selected' : ''}"
|
||||
@click=${() => this.selectFolder(index, prefix)}
|
||||
>
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
<span class="name">${this.getFileName(prefix)}</span>
|
||||
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
${column.objects.map(
|
||||
(obj) => html`
|
||||
<div
|
||||
class="column-item ${column.selectedItem === obj.key ? 'selected' : ''}"
|
||||
@click=${() => this.selectFile(index, obj.key)}
|
||||
>
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="${this.getFileIcon(obj.key)}" />
|
||||
</svg>
|
||||
<span class="name">${this.getFileName(obj.key)}</span>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
286
ts_web/elements/tsview-s3-keys.ts
Normal file
286
ts_web/elements/tsview-s3-keys.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { apiService, type IS3Object } from '../services/index.js';
|
||||
|
||||
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||
|
||||
@customElement('tsview-s3-keys')
|
||||
export class TsviewS3Keys extends DeesElement {
|
||||
@property({ type: String })
|
||||
public accessor bucketName: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public accessor currentPrefix: string = '';
|
||||
|
||||
@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 = '';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
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 #333;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.filter-input::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.keys-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #1a1a2e;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid #2a2a3e;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
tr.selected td {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.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: #888;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.loadObjects();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, unknown>) {
|
||||
if (changedProperties.has('bucketName') || changedProperties.has('currentPrefix')) {
|
||||
this.loadObjects();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadObjects() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const result = await apiService.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 getFileName(path: string): string {
|
||||
const parts = path.replace(/\/$/, '').split('/');
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
|
||||
private formatSize(bytes?: number): string {
|
||||
if (!bytes) return '-';
|
||||
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]}`;
|
||||
}
|
||||
|
||||
private get filteredItems() {
|
||||
const filter = this.filterText.toLowerCase();
|
||||
const folders = this.prefixes
|
||||
.filter((p) => !filter || this.getFileName(p).toLowerCase().includes(filter))
|
||||
.map((p) => ({ key: p, isFolder: true, size: undefined }));
|
||||
const files = this.allKeys
|
||||
.filter((o) => !filter || this.getFileName(o.key).toLowerCase().includes(filter))
|
||||
.map((o) => ({ key: o.key, isFolder: false, size: o.size }));
|
||||
return [...folders, ...files];
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="keys-container">
|
||||
<div class="filter-bar">
|
||||
<input
|
||||
type="text"
|
||||
class="filter-input"
|
||||
placeholder="Filter files..."
|
||||
.value=${this.filterText}
|
||||
@input=${this.handleFilterInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="keys-list">
|
||||
${this.loading
|
||||
? html`<div class="empty-state">Loading...</div>`
|
||||
: this.filteredItems.length === 0
|
||||
? html`<div class="empty-state">No objects found</div>`
|
||||
: html`
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th style="width: 100px;">Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this.filteredItems.map(
|
||||
(item) => html`
|
||||
<tr
|
||||
class="${this.selectedKey === item.key ? 'selected' : ''}"
|
||||
@click=${() => this.selectKey(item.key, item.isFolder)}
|
||||
>
|
||||
<td>
|
||||
<div class="key-cell">
|
||||
${item.isFolder
|
||||
? html`
|
||||
<svg class="key-icon folder-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
`
|
||||
: html`
|
||||
<svg class="key-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
</svg>
|
||||
`}
|
||||
<span class="key-name">${this.getFileName(item.key)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="size-cell">
|
||||
${item.isFolder ? '-' : this.formatSize(item.size)}
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
341
ts_web/elements/tsview-s3-preview.ts
Normal file
341
ts_web/elements/tsview-s3-preview.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { apiService } from '../services/index.js';
|
||||
|
||||
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||
|
||||
@customElement('tsview-s3-preview')
|
||||
export class TsviewS3Preview extends DeesElement {
|
||||
@property({ type: String })
|
||||
public accessor bucketName: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public accessor objectKey: string = '';
|
||||
|
||||
@state()
|
||||
private accessor loading: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor content: string = '';
|
||||
|
||||
@state()
|
||||
private accessor contentType: string = '';
|
||||
|
||||
@state()
|
||||
private accessor size: number = 0;
|
||||
|
||||
@state()
|
||||
private accessor lastModified: string = '';
|
||||
|
||||
@state()
|
||||
private accessor error: string = '';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #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: #888;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: #ccc;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
padding: 12px;
|
||||
border-top: 1px solid #333;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
padding: 8px 16px;
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
border: 1px solid #6366f1;
|
||||
color: #818cf8;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #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: #888;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
padding: 16px;
|
||||
color: #f87171;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.binary-preview {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
padding: 24px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
updated(changedProperties: Map<string, unknown>) {
|
||||
if (changedProperties.has('objectKey') || changedProperties.has('bucketName')) {
|
||||
if (this.objectKey) {
|
||||
this.loadObject();
|
||||
} else {
|
||||
this.content = '';
|
||||
this.contentType = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async loadObject() {
|
||||
if (!this.objectKey || !this.bucketName) return;
|
||||
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const result = await apiService.getObject(this.bucketName, this.objectKey);
|
||||
this.content = result.content;
|
||||
this.contentType = result.contentType;
|
||||
this.size = result.size;
|
||||
this.lastModified = result.lastModified;
|
||||
} catch (err) {
|
||||
console.error('Error loading object:', err);
|
||||
this.error = 'Failed to load object';
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
private getFileName(path: string): string {
|
||||
const parts = path.split('/');
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
|
||||
private formatSize(bytes: number): string {
|
||||
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]}`;
|
||||
}
|
||||
|
||||
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 {
|
||||
return atob(this.content);
|
||||
} 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 = this.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 (!confirm(`Delete "${this.getFileName(this.objectKey)}"?`)) return;
|
||||
|
||||
try {
|
||||
await apiService.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);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.objectKey) {
|
||||
return html`
|
||||
<div class="preview-container">
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<p>Select a file to preview</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.loading) {
|
||||
return html`
|
||||
<div class="preview-container">
|
||||
<div class="loading-state">Loading...</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.error) {
|
||||
return html`
|
||||
<div class="preview-container">
|
||||
<div class="error-state">${this.error}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="preview-container">
|
||||
<div class="preview-header">
|
||||
<div class="preview-title">${this.getFileName(this.objectKey)}</div>
|
||||
<div class="preview-meta">
|
||||
<span class="meta-item">${this.contentType}</span>
|
||||
<span class="meta-item">${this.formatSize(this.size)}</span>
|
||||
<span class="meta-item">${this.formatDate(this.lastModified)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-content">
|
||||
${this.isImage()
|
||||
? html`<img class="preview-image" src="data:${this.contentType};base64,${this.content}" />`
|
||||
: this.isText()
|
||||
? html`<pre class="preview-text">${this.getTextContent()}</pre>`
|
||||
: html`
|
||||
<div class="binary-preview">
|
||||
<p>Binary file preview not available</p>
|
||||
<p>Download to view</p>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="preview-actions">
|
||||
<button class="action-btn" @click=${this.handleDownload}>Download</button>
|
||||
<button class="action-btn danger" @click=${this.handleDelete}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user