feat(web): add database overview panel, collection overview and resizable panels; show/hide system databases; use code editor with change-tracking in document view; add getDatabaseStats API and typings; enable overwrite for S3 uploads
This commit is contained in:
@@ -13,3 +13,4 @@ export * from './tsview-mongo-collections.js';
|
||||
export * from './tsview-mongo-documents.js';
|
||||
export * from './tsview-mongo-document.js';
|
||||
export * from './tsview-mongo-indexes.js';
|
||||
export * from './tsview-mongo-db-overview.js';
|
||||
|
||||
@@ -45,6 +45,9 @@ export class TsviewApp extends DeesElement {
|
||||
@state()
|
||||
private accessor newDatabaseName: string = '';
|
||||
|
||||
@state()
|
||||
private accessor showSystemDatabases: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor showS3CreateDialog: boolean = false;
|
||||
|
||||
@@ -57,6 +60,12 @@ export class TsviewApp extends DeesElement {
|
||||
@state()
|
||||
private accessor s3CreateDialogName: string = '';
|
||||
|
||||
@state()
|
||||
private accessor sidebarWidth: number = 240;
|
||||
|
||||
@state()
|
||||
private accessor isResizingSidebar: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
themeStyles,
|
||||
@@ -130,10 +139,22 @@ export class TsviewApp extends DeesElement {
|
||||
|
||||
.app-main {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
grid-template-columns: var(--sidebar-width, 240px) 4px 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: #1e1e1e;
|
||||
border-right: 1px solid #333;
|
||||
@@ -389,6 +410,15 @@ export class TsviewApp extends DeesElement {
|
||||
`,
|
||||
];
|
||||
|
||||
private readonly SYSTEM_DATABASES = ['admin', 'config', 'local'];
|
||||
|
||||
private get visibleDatabases() {
|
||||
if (this.showSystemDatabases) {
|
||||
return this.databases;
|
||||
}
|
||||
return this.databases.filter(db => !this.SYSTEM_DATABASES.includes(db.name));
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.loadData();
|
||||
@@ -423,8 +453,15 @@ export class TsviewApp extends DeesElement {
|
||||
}
|
||||
|
||||
private selectDatabase(db: string) {
|
||||
this.selectedDatabase = db;
|
||||
this.selectedCollection = '';
|
||||
if (this.selectedDatabase === db) {
|
||||
// Collapse - clicking the same database again
|
||||
// Keep the collection selection intact
|
||||
this.selectedDatabase = '';
|
||||
} else {
|
||||
// Switch to different database - clear collection
|
||||
this.selectedDatabase = db;
|
||||
this.selectedCollection = '';
|
||||
}
|
||||
}
|
||||
|
||||
private selectCollection(collection: string) {
|
||||
@@ -635,6 +672,38 @@ export class TsviewApp extends DeesElement {
|
||||
]);
|
||||
}
|
||||
|
||||
private handleSidebarContextMenu(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||
{
|
||||
name: this.showSystemDatabases ? 'Hide System Databases' : 'Show System Databases',
|
||||
iconName: this.showSystemDatabases ? 'lucide:eyeOff' : 'lucide:eye',
|
||||
action: async () => {
|
||||
this.showSystemDatabases = !this.showSystemDatabases;
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
private startSidebarResize = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
this.isResizingSidebar = true;
|
||||
document.addEventListener('mousemove', this.handleSidebarResize);
|
||||
document.addEventListener('mouseup', this.endSidebarResize);
|
||||
};
|
||||
|
||||
private handleSidebarResize = (e: MouseEvent) => {
|
||||
if (!this.isResizingSidebar) return;
|
||||
const newWidth = Math.min(Math.max(e.clientX, 150), 500);
|
||||
this.sidebarWidth = newWidth;
|
||||
};
|
||||
|
||||
private endSidebarResize = () => {
|
||||
this.isResizingSidebar = false;
|
||||
document.removeEventListener('mousemove', this.handleSidebarResize);
|
||||
document.removeEventListener('mouseup', this.endSidebarResize);
|
||||
};
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="app-container">
|
||||
@@ -669,8 +738,12 @@ export class TsviewApp extends DeesElement {
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<main class="app-main" style="--sidebar-width: ${this.sidebarWidth}px">
|
||||
${this.renderSidebar()}
|
||||
<div
|
||||
class="resize-divider ${this.isResizingSidebar ? 'active' : ''}"
|
||||
@mousedown=${this.startSidebarResize}
|
||||
></div>
|
||||
${this.renderContent()}
|
||||
</main>
|
||||
</div>
|
||||
@@ -849,7 +922,7 @@ export class TsviewApp extends DeesElement {
|
||||
|
||||
if (this.viewMode === 'mongo') {
|
||||
return html`
|
||||
<aside class="sidebar">
|
||||
<aside class="sidebar" @contextmenu=${(e: MouseEvent) => this.handleSidebarContextMenu(e)}>
|
||||
<div class="sidebar-header">Databases & Collections</div>
|
||||
<button class="create-btn" @click=${() => this.showCreateDatabaseDialog = true}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -859,9 +932,9 @@ export class TsviewApp extends DeesElement {
|
||||
New Database
|
||||
</button>
|
||||
<div class="sidebar-list">
|
||||
${this.databases.length === 0
|
||||
${this.visibleDatabases.length === 0
|
||||
? html`<div class="sidebar-item" style="color: #666; cursor: default;">No databases found</div>`
|
||||
: this.databases.map((db) => this.renderDatabaseGroup(db))}
|
||||
: this.visibleDatabases.map((db) => this.renderDatabaseGroup(db))}
|
||||
</div>
|
||||
</aside>
|
||||
`;
|
||||
@@ -954,6 +1027,17 @@ export class TsviewApp extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
// Show database overview when __overview__ is selected
|
||||
if (this.selectedCollection === '__overview__') {
|
||||
return html`
|
||||
<div class="content-area">
|
||||
<tsview-mongo-db-overview
|
||||
.databaseName=${this.selectedDatabase}
|
||||
></tsview-mongo-db-overview>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="content-area">
|
||||
<tsview-mongo-browser
|
||||
|
||||
@@ -24,6 +24,12 @@ export class TsviewMongoBrowser extends DeesElement {
|
||||
@state()
|
||||
private accessor stats: ICollectionStats | null = null;
|
||||
|
||||
@state()
|
||||
private accessor editorWidth: number = 400;
|
||||
|
||||
@state()
|
||||
private accessor isResizingEditor: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
themeStyles,
|
||||
@@ -102,11 +108,23 @@ export class TsviewMongoBrowser extends DeesElement {
|
||||
.content {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
gap: 16px;
|
||||
grid-template-columns: 1fr 4px var(--editor-width, 400px);
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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-panel {
|
||||
overflow: auto;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
@@ -117,6 +135,7 @@ export class TsviewMongoBrowser extends DeesElement {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
@@ -124,7 +143,8 @@ export class TsviewMongoBrowser extends DeesElement {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
.detail-panel,
|
||||
.resize-divider {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -162,6 +182,28 @@ export class TsviewMongoBrowser extends DeesElement {
|
||||
this.selectedDocumentId = e.detail.documentId;
|
||||
}
|
||||
|
||||
private startEditorResize = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
this.isResizingEditor = true;
|
||||
document.addEventListener('mousemove', this.handleEditorResize);
|
||||
document.addEventListener('mouseup', this.endEditorResize);
|
||||
};
|
||||
|
||||
private handleEditorResize = (e: MouseEvent) => {
|
||||
if (!this.isResizingEditor) return;
|
||||
const contentEl = this.shadowRoot?.querySelector('.content');
|
||||
if (!contentEl) return;
|
||||
const containerRect = contentEl.getBoundingClientRect();
|
||||
const newWidth = Math.min(Math.max(containerRect.right - e.clientX, 300), 700);
|
||||
this.editorWidth = newWidth;
|
||||
};
|
||||
|
||||
private endEditorResize = () => {
|
||||
this.isResizingEditor = false;
|
||||
document.removeEventListener('mousemove', this.handleEditorResize);
|
||||
document.removeEventListener('mouseup', this.endEditorResize);
|
||||
};
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="browser-container">
|
||||
@@ -201,7 +243,7 @@ export class TsviewMongoBrowser extends DeesElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="content" style="--editor-width: ${this.editorWidth}px">
|
||||
<div class="main-panel">
|
||||
${this.activeTab === 'documents'
|
||||
? html`
|
||||
@@ -225,6 +267,11 @@ export class TsviewMongoBrowser extends DeesElement {
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="resize-divider ${this.isResizingEditor ? 'active' : ''}"
|
||||
@mousedown=${this.startEditorResize}
|
||||
></div>
|
||||
|
||||
<div class="detail-panel">
|
||||
<tsview-mongo-document
|
||||
.databaseName=${this.databaseName}
|
||||
|
||||
@@ -90,6 +90,33 @@ export class TsviewMongoCollections extends DeesElement {
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.overview-item {
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: background 0.1s;
|
||||
color: #a5d6a7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.overview-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.overview-item.selected {
|
||||
background: rgba(165, 214, 167, 0.15);
|
||||
color: #a5d6a7;
|
||||
}
|
||||
|
||||
.overview-item svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -168,36 +195,56 @@ export class TsviewMongoCollections extends DeesElement {
|
||||
await this.loadCollections();
|
||||
}
|
||||
|
||||
private selectOverview() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('collection-selected', {
|
||||
detail: '__overview__',
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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)}
|
||||
@contextmenu=${(e: MouseEvent) => this.handleCollectionContextMenu(e, coll)}
|
||||
>
|
||||
<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">${formatCount(coll.count)}</span>`
|
||||
: ''}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
<div
|
||||
class="overview-item ${this.selectedCollection === '__overview__' ? 'selected' : ''}"
|
||||
@click=${() => this.selectOverview()}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="14" width="7" height="7"></rect>
|
||||
<rect x="3" y="14" width="7" height="7"></rect>
|
||||
</svg>
|
||||
Overview
|
||||
</div>
|
||||
${this.collections.length === 0
|
||||
? html`<div class="empty-state">No collections</div>`
|
||||
: this.collections.map(
|
||||
(coll) => html`
|
||||
<div
|
||||
class="collection-item ${this.selectedCollection === coll.name ? 'selected' : ''}"
|
||||
@click=${() => this.selectCollection(coll.name)}
|
||||
@contextmenu=${(e: MouseEvent) => this.handleCollectionContextMenu(e, coll)}
|
||||
>
|
||||
<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">${formatCount(coll.count)}</span>`
|
||||
: ''}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
291
ts_web/elements/tsview-mongo-db-overview.ts
Normal file
291
ts_web/elements/tsview-mongo-db-overview.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { apiService, type IDatabaseStats } from '../services/index.js';
|
||||
import { formatSize, formatCount } from '../utilities/index.js';
|
||||
import { themeStyles } from '../styles/index.js';
|
||||
|
||||
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||
|
||||
@customElement('tsview-mongo-db-overview')
|
||||
export class TsviewMongoDbOverview extends DeesElement {
|
||||
@property({ type: String })
|
||||
public accessor databaseName: string = '';
|
||||
|
||||
@state()
|
||||
private accessor stats: IDatabaseStats | null = null;
|
||||
|
||||
@state()
|
||||
private accessor loading: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor error: string = '';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
themeStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.overview-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin: 0 0 8px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-title svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.stat-value.small {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.stat-description {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #666;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #f87171;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
updated(changedProperties: Map<string, unknown>) {
|
||||
if (changedProperties.has('databaseName') && this.databaseName) {
|
||||
this.loadStats();
|
||||
}
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.databaseName) {
|
||||
await this.loadStats();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadStats() {
|
||||
if (!this.databaseName) return;
|
||||
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
this.stats = await apiService.getDatabaseStats(this.databaseName);
|
||||
} catch (err) {
|
||||
console.error('Error loading database stats:', err);
|
||||
this.error = 'Failed to load database statistics';
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.databaseName) {
|
||||
return html`
|
||||
<div class="overview-container">
|
||||
<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 database to view overview</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.loading) {
|
||||
return html`
|
||||
<div class="overview-container">
|
||||
<div class="loading-state">Loading database statistics...</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.error) {
|
||||
return html`
|
||||
<div class="overview-container">
|
||||
<div class="error-state">${this.error}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (!this.stats) {
|
||||
return html`
|
||||
<div class="overview-container">
|
||||
<div class="empty-state">
|
||||
<p>No statistics available</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="overview-container">
|
||||
<div class="header">
|
||||
<h1 class="header-title">
|
||||
<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>
|
||||
${this.databaseName}
|
||||
</h1>
|
||||
<p class="header-subtitle">Database Overview</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Storage</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Collections</span>
|
||||
<span class="stat-value">${this.stats.collections}</span>
|
||||
<span class="stat-description">Total collections in database</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Documents</span>
|
||||
<span class="stat-value">${formatCount(this.stats.objects) || this.stats.objects}</span>
|
||||
<span class="stat-description">Total documents stored</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Avg Document Size</span>
|
||||
<span class="stat-value small">${formatSize(this.stats.avgObjSize)}</span>
|
||||
<span class="stat-description">Average size per document</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Data Size</span>
|
||||
<span class="stat-value small">${formatSize(this.stats.dataSize)}</span>
|
||||
<span class="stat-description">Uncompressed data size</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Storage Size</span>
|
||||
<span class="stat-value small">${formatSize(this.stats.storageSize)}</span>
|
||||
<span class="stat-description">Allocated storage</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Indexes</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Index Count</span>
|
||||
<span class="stat-value">${this.stats.indexes}</span>
|
||||
<span class="stat-description">Total indexes in database</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Index Size</span>
|
||||
<span class="stat-value small">${formatSize(this.stats.indexSize)}</span>
|
||||
<span class="stat-description">Total index storage</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,10 @@ export class TsviewMongoDocument extends DeesElement {
|
||||
private accessor editing: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor editContent: string = '';
|
||||
private accessor originalContent: string = '';
|
||||
|
||||
@state()
|
||||
private accessor hasChanges: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor error: string = '';
|
||||
@@ -101,56 +104,13 @@ export class TsviewMongoDocument extends DeesElement {
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.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: #e0e0e0;
|
||||
}
|
||||
|
||||
.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: #404040;
|
||||
.content dees-input-code {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@@ -190,10 +150,12 @@ export class TsviewMongoDocument extends DeesElement {
|
||||
updated(changedProperties: Map<string, unknown>) {
|
||||
if (changedProperties.has('documentId')) {
|
||||
this.editing = false;
|
||||
this.hasChanges = false;
|
||||
if (this.documentId) {
|
||||
this.loadDocument();
|
||||
} else {
|
||||
this.document = null;
|
||||
this.originalContent = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,6 +172,8 @@ export class TsviewMongoDocument extends DeesElement {
|
||||
this.collectionName,
|
||||
this.documentId
|
||||
);
|
||||
this.originalContent = JSON.stringify(this.document, null, 2);
|
||||
this.hasChanges = false;
|
||||
} catch (err) {
|
||||
console.error('Error loading document:', err);
|
||||
this.error = 'Failed to load document';
|
||||
@@ -219,18 +183,37 @@ export class TsviewMongoDocument extends DeesElement {
|
||||
}
|
||||
|
||||
private startEditing() {
|
||||
this.editContent = JSON.stringify(this.document, null, 2);
|
||||
this.editing = true;
|
||||
}
|
||||
|
||||
private cancelEditing() {
|
||||
this.editing = false;
|
||||
this.editContent = '';
|
||||
// Reset content to original
|
||||
const codeEditor = this.shadowRoot?.querySelector('dees-input-code') as any;
|
||||
if (codeEditor) {
|
||||
codeEditor.value = this.originalContent;
|
||||
}
|
||||
this.hasChanges = false;
|
||||
}
|
||||
|
||||
private handleContentChange(e: CustomEvent) {
|
||||
const newValue = e.detail as string;
|
||||
this.hasChanges = newValue !== this.originalContent;
|
||||
}
|
||||
|
||||
private handleDiscard() {
|
||||
const codeEditor = this.shadowRoot?.querySelector('dees-input-code') as any;
|
||||
if (codeEditor) {
|
||||
codeEditor.value = this.originalContent;
|
||||
}
|
||||
this.hasChanges = false;
|
||||
}
|
||||
|
||||
private async saveDocument() {
|
||||
try {
|
||||
const updatedDoc = JSON.parse(this.editContent);
|
||||
const codeEditor = this.shadowRoot?.querySelector('dees-input-code') as any;
|
||||
const content = codeEditor?.value || this.originalContent;
|
||||
const updatedDoc = JSON.parse(content);
|
||||
|
||||
// Remove _id from update (can't update _id)
|
||||
const { _id, ...updateFields } = updatedDoc;
|
||||
@@ -243,6 +226,7 @@ export class TsviewMongoDocument extends DeesElement {
|
||||
);
|
||||
|
||||
this.editing = false;
|
||||
this.hasChanges = false;
|
||||
await this.loadDocument();
|
||||
|
||||
this.dispatchEvent(
|
||||
@@ -283,20 +267,6 @@ export class TsviewMongoDocument extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
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`
|
||||
@@ -334,10 +304,14 @@ export class TsviewMongoDocument extends DeesElement {
|
||||
<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>
|
||||
`
|
||||
? this.hasChanges
|
||||
? html`
|
||||
<button class="action-btn" @click=${this.handleDiscard}>Discard</button>
|
||||
<button class="action-btn primary" @click=${this.saveDocument}>Save</button>
|
||||
`
|
||||
: html`
|
||||
<button class="action-btn" @click=${this.cancelEditing}>Cancel</button>
|
||||
`
|
||||
: html`
|
||||
<button class="action-btn" @click=${this.startEditing}>Edit</button>
|
||||
<button class="action-btn danger" @click=${this.deleteDocument}>Delete</button>
|
||||
@@ -346,20 +320,12 @@ export class TsviewMongoDocument extends DeesElement {
|
||||
</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>
|
||||
`}
|
||||
<dees-input-code
|
||||
.value=${this.originalContent}
|
||||
.language=${'json'}
|
||||
.disabled=${!this.editing}
|
||||
@content-change=${(e: CustomEvent) => this.handleContentChange(e)}
|
||||
></dees-input-code>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -23,6 +23,12 @@ export class TsviewS3Browser extends DeesElement {
|
||||
@state()
|
||||
private accessor refreshKey: number = 0;
|
||||
|
||||
@state()
|
||||
private accessor previewWidth: number = 350;
|
||||
|
||||
@state()
|
||||
private accessor isResizingPreview: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
themeStyles,
|
||||
@@ -104,12 +110,24 @@ export class TsviewS3Browser extends DeesElement {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content.has-preview {
|
||||
grid-template-columns: 1fr 350px;
|
||||
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 {
|
||||
@@ -122,6 +140,7 @@ export class TsviewS3Browser extends DeesElement {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@@ -130,7 +149,8 @@ export class TsviewS3Browser extends DeesElement {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
.preview-panel,
|
||||
.resize-divider {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -168,6 +188,28 @@ export class TsviewS3Browser extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -213,7 +255,7 @@ export class TsviewS3Browser extends DeesElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content ${this.selectedKey ? 'has-preview' : ''}">
|
||||
<div class="content ${this.selectedKey ? 'has-preview' : ''}" style="--preview-width: ${this.previewWidth}px">
|
||||
<div class="main-view">
|
||||
${this.viewType === 'columns'
|
||||
? html`
|
||||
@@ -238,6 +280,10 @@ export class TsviewS3Browser extends DeesElement {
|
||||
|
||||
${this.selectedKey
|
||||
? html`
|
||||
<div
|
||||
class="resize-divider ${this.isResizingPreview ? 'active' : ''}"
|
||||
@mousedown=${this.startPreviewResize}
|
||||
></div>
|
||||
<div class="preview-panel">
|
||||
<tsview-s3-preview
|
||||
.bucketName=${this.bucketName}
|
||||
|
||||
Reference in New Issue
Block a user