This commit is contained in:
2026-01-23 22:15:51 +00:00
commit 74d24cf8b9
44 changed files with 15483 additions and 0 deletions

15
ts_web/elements/index.ts Normal file
View 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';

View 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>
`;
}
}

View 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>
`;
}
}

View 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>
`;
}
}

View 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>
`;
}
}

View 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>
`;
}
}

View 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>
`
: ''}
`;
}
}

View 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>
`;
}
}

View 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>
`;
}
}

View 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>
`;
}
}

View 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>
`;
}
}