4 Commits

21 changed files with 1576 additions and 161 deletions

View File

@@ -1,5 +1,27 @@
# Changelog # Changelog
## 2026-01-25 - 1.4.0 - 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
- Add backend handler getDatabaseStats + request/response typings (IReq_GetDatabaseStats, IDatabaseStats) and ApiService.getDatabaseStats()
- New UI component tsview-mongo-db-overview to show database statistics (collections, objects, avg size, data/storage size, indexes)
- Collections list: added an "Overview" entry that opens the DB overview when selected
- Sidebar: context menu option to show/hide system databases (admin/config/local) and database collapse-on-click behavior
- Resizable layout improvements: draggable dividers added for sidebar, Mongo editor panel and S3 preview panel (persisted via local state variables)
- Document editor: switch to dees-input-code, track original content and unsaved changes, add discard/save flow and improved save handling
- S3 handlers: fastPut calls now pass overwrite: true to allow replacing existing keys
- Minor dependency bumps: @git.zone/tstest and @design.estate/dees-catalog
## 2026-01-25 - 1.3.0 - feat(s3)
add S3 create file/folder dialogs and in-place text editor; export mongodb plugin
- Add mongodb dependency and export mongodb in ts/plugins.ts so ObjectId can be reused from plugins.
- Update handlers.mongodb to use plugins.mongodb.ObjectId instead of requiring mongodb directly.
- UI: Add create-file and create-folder dialogs and context-menu entries in tsview-app, tsview-s3-columns, and tsview-s3-keys to create objects (folders use a .keep object).
- Implement client-side helpers to determine content type/default content and call apiService.putObject with base64 content when creating files/folders.
- S3 preview: embed dees-input-code editor for text files with language detection, unsaved-changes indicator, Save/Discard flows, and saving via apiService.putObject.
- Various styling and UX improvements for dialogs, buttons, and editor states.
## 2026-01-25 - 1.2.0 - feat(s3,web-ui) ## 2026-01-25 - 1.2.0 - feat(s3,web-ui)
add S3 deletePrefix and getObjectUrl endpoints and add context menus in UI for S3 and Mongo views add S3 deletePrefix and getObjectUrl endpoints and add context menus in UI for S3 and Mongo views

View File

@@ -1,6 +1,6 @@
{ {
"name": "@git.zone/tsview", "name": "@git.zone/tsview",
"version": "1.2.0", "version": "1.4.0",
"private": false, "private": false,
"description": "A CLI tool for viewing S3 and MongoDB data with a web UI", "description": "A CLI tool for viewing S3 and MongoDB data with a web UI",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
@@ -22,7 +22,7 @@
"@git.zone/tsbuild": "^4.1.2", "@git.zone/tsbuild": "^4.1.2",
"@git.zone/tsbundle": "^2.8.3", "@git.zone/tsbundle": "^2.8.3",
"@git.zone/tsrun": "^2.0.1", "@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.1.6", "@git.zone/tstest": "^3.1.7",
"@git.zone/tswatch": "3.0.1", "@git.zone/tswatch": "3.0.1",
"@types/node": "^25.0.10" "@types/node": "^25.0.10"
}, },
@@ -31,7 +31,7 @@
"@api.global/typedrequest-interfaces": "^3.0.19", "@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.3.0", "@api.global/typedserver": "^8.3.0",
"@aws-sdk/client-s3": "^3.975.0", "@aws-sdk/client-s3": "^3.975.0",
"@design.estate/dees-catalog": "^3.37.0", "@design.estate/dees-catalog": "^3.37.1",
"@design.estate/dees-element": "^2.1.5", "@design.estate/dees-element": "^2.1.5",
"@push.rocks/early": "^4.0.4", "@push.rocks/early": "^4.0.4",
"@push.rocks/npmextra": "^5.3.3", "@push.rocks/npmextra": "^5.3.3",
@@ -44,7 +44,8 @@
"@push.rocks/smartnetwork": "^4.4.0", "@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/smartopen": "^2.0.0", "@push.rocks/smartopen": "^2.0.0",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3" "@push.rocks/smartpromise": "^4.2.3",
"mongodb": "^7.0.0"
}, },
"files": [ "files": [
"ts/**/*", "ts/**/*",

40
pnpm-lock.yaml generated
View File

@@ -21,8 +21,8 @@ importers:
specifier: ^3.975.0 specifier: ^3.975.0
version: 3.975.0 version: 3.975.0
'@design.estate/dees-catalog': '@design.estate/dees-catalog':
specifier: ^3.37.0 specifier: ^3.37.1
version: 3.37.0(@tiptap/pm@2.27.2) version: 3.37.1(@tiptap/pm@2.27.2)
'@design.estate/dees-element': '@design.estate/dees-element':
specifier: ^2.1.5 specifier: ^2.1.5
version: 2.1.5 version: 2.1.5
@@ -62,6 +62,9 @@ importers:
'@push.rocks/smartpromise': '@push.rocks/smartpromise':
specifier: ^4.2.3 specifier: ^4.2.3
version: 4.2.3 version: 4.2.3
mongodb:
specifier: ^7.0.0
version: 7.0.0(socks@2.8.7)
devDependencies: devDependencies:
'@git.zone/tsbuild': '@git.zone/tsbuild':
specifier: ^4.1.2 specifier: ^4.1.2
@@ -73,8 +76,8 @@ importers:
specifier: ^2.0.1 specifier: ^2.0.1
version: 2.0.1 version: 2.0.1
'@git.zone/tstest': '@git.zone/tstest':
specifier: ^3.1.6 specifier: ^3.1.7
version: 3.1.6(@push.rocks/smartserve@2.0.1)(socks@2.8.7)(typescript@5.9.3) version: 3.1.7(@push.rocks/smartserve@2.0.1)(socks@2.8.7)(typescript@5.9.3)
'@git.zone/tswatch': '@git.zone/tswatch':
specifier: 3.0.1 specifier: 3.0.1
version: 3.0.1(@tiptap/pm@2.27.2) version: 3.0.1(@tiptap/pm@2.27.2)
@@ -316,11 +319,14 @@ packages:
'@cloudflare/workers-types@4.20260123.0': '@cloudflare/workers-types@4.20260123.0':
resolution: {integrity: sha512-pQccZ8IDLFKkvdKBXZRPkbMtWtS7vVz1giJGkAAZ5cZH2RHK5Bs6p1OoVZA8Z2Sry8Q0tZbZ5Yjud4R7SrG3KQ==} resolution: {integrity: sha512-pQccZ8IDLFKkvdKBXZRPkbMtWtS7vVz1giJGkAAZ5cZH2RHK5Bs6p1OoVZA8Z2Sry8Q0tZbZ5Yjud4R7SrG3KQ==}
'@cloudflare/workers-types@4.20260124.0':
resolution: {integrity: sha512-h6TJlew6AtGuEXFc+k5ifalk+tg3fkg0lla6XbMAb2AKKfJGwlFNTwW2xyT/Ha92KY631CIJ+Ace08DPdFohdA==}
'@configvault.io/interfaces@1.0.17': '@configvault.io/interfaces@1.0.17':
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==} resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
'@design.estate/dees-catalog@3.37.0': '@design.estate/dees-catalog@3.37.1':
resolution: {integrity: sha512-c6q+yK2FwMsMK72GykUhZnvKUgTzjFO9vdbn6OBxas2/eY/6Wi6BC5i9YONN0UYcW8yqjHIDjN9nP7yE1Ai4PA==} resolution: {integrity: sha512-NCgzzCG3NJVF7C7aa1nExCMhB+7nA6glFgZpsff32CpvdtbAuBQiuOngU0suVw65uK7Y0a2r/y2CEPGNNmj3TA==}
'@design.estate/dees-comms@1.0.30': '@design.estate/dees-comms@1.0.30':
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==} resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
@@ -535,8 +541,8 @@ packages:
resolution: {integrity: sha512-NEcnsjvlC1o3Z6SS3VhKCf6Ev+Sh4EAinmggslrIR/ppMrvjDbXNFXoyr3PB+GLeSAR0JRZ1fGvVYjpEzjBdIg==} resolution: {integrity: sha512-NEcnsjvlC1o3Z6SS3VhKCf6Ev+Sh4EAinmggslrIR/ppMrvjDbXNFXoyr3PB+GLeSAR0JRZ1fGvVYjpEzjBdIg==}
hasBin: true hasBin: true
'@git.zone/tstest@3.1.6': '@git.zone/tstest@3.1.7':
resolution: {integrity: sha512-xRGc6wO4rJ6mohPCMIBDRH+oNjiIvX6Jeo8v/Y5o5VyKSHFmqol7FCKSBrojMcqgBpESnLHFPJAAOmT9W3JV8Q==} resolution: {integrity: sha512-YCDA+65LJhoY3WJxrNduKlpGf37aq4bFe+fdRqE0dZ2W1f7j3sUunBaBzckShSHKRjkMdPZKr0W0sXFXUK/PcA==}
hasBin: true hasBin: true
'@git.zone/tswatch@3.0.1': '@git.zone/tswatch@3.0.1':
@@ -2802,8 +2808,8 @@ packages:
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
engines: {node: '>=12'} engines: {node: '>=12'}
lucide@0.562.0: lucide@0.563.0:
resolution: {integrity: sha512-k1Fb8ZMnRQovWRlea7Jr0b9UKA29IM7/cu79+mJrhVohvA2YC/Ti3Sk+G+h/SIu3IlrKT4RAbWMHUBBQd1O6XA==} resolution: {integrity: sha512-2zBzDJ5n2Plj3d0ksj6h9TWPOSiKu9gtxJxnBAye11X/8gfWied6IYJn6ADYBp1NPoJmgpyOYP3wMrVx69+2AA==}
make-dir@3.1.0: make-dir@3.1.0:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
@@ -4005,7 +4011,7 @@ snapshots:
'@api.global/typedrequest': 3.2.5 '@api.global/typedrequest': 3.2.5
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 3.1.1(@push.rocks/smartserve@2.0.1) '@api.global/typedsocket': 3.1.1(@push.rocks/smartserve@2.0.1)
'@cloudflare/workers-types': 4.20260123.0 '@cloudflare/workers-types': 4.20260124.0
'@design.estate/dees-comms': 1.0.30 '@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.2.2 '@push.rocks/lik': 6.2.2
'@push.rocks/smartchok': 1.2.0 '@push.rocks/smartchok': 1.2.0
@@ -4054,7 +4060,7 @@ snapshots:
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.0(@push.rocks/smartserve@2.0.1) '@api.global/typedsocket': 4.1.0(@push.rocks/smartserve@2.0.1)
'@cloudflare/workers-types': 4.20260123.0 '@cloudflare/workers-types': 4.20260123.0
'@design.estate/dees-catalog': 3.37.0(@tiptap/pm@2.27.2) '@design.estate/dees-catalog': 3.37.1(@tiptap/pm@2.27.2)
'@design.estate/dees-comms': 1.0.30 '@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.2.2 '@push.rocks/lik': 6.2.2
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.0.5
@@ -4676,11 +4682,13 @@ snapshots:
'@cloudflare/workers-types@4.20260123.0': {} '@cloudflare/workers-types@4.20260123.0': {}
'@cloudflare/workers-types@4.20260124.0': {}
'@configvault.io/interfaces@1.0.17': '@configvault.io/interfaces@1.0.17':
dependencies: dependencies:
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@design.estate/dees-catalog@3.37.0(@tiptap/pm@2.27.2)': '@design.estate/dees-catalog@3.37.1(@tiptap/pm@2.27.2)':
dependencies: dependencies:
'@design.estate/dees-domtools': 2.3.7 '@design.estate/dees-domtools': 2.3.7
'@design.estate/dees-element': 2.1.5 '@design.estate/dees-element': 2.1.5
@@ -4703,7 +4711,7 @@ snapshots:
apexcharts: 5.3.6 apexcharts: 5.3.6
highlight.js: 11.11.1 highlight.js: 11.11.1
ibantools: 4.5.1 ibantools: 4.5.1
lucide: 0.562.0 lucide: 0.563.0
monaco-editor: 0.55.1 monaco-editor: 0.55.1
pdfjs-dist: 4.10.38 pdfjs-dist: 4.10.38
xterm: 5.3.0 xterm: 5.3.0
@@ -4959,7 +4967,7 @@ snapshots:
'@push.rocks/smartshell': 3.3.0 '@push.rocks/smartshell': 3.3.0
tsx: 4.21.0 tsx: 4.21.0
'@git.zone/tstest@3.1.6(@push.rocks/smartserve@2.0.1)(socks@2.8.7)(typescript@5.9.3)': '@git.zone/tstest@3.1.7(@push.rocks/smartserve@2.0.1)(socks@2.8.7)(typescript@5.9.3)':
dependencies: dependencies:
'@api.global/typedserver': 3.0.80(@push.rocks/smartserve@2.0.1) '@api.global/typedserver': 3.0.80(@push.rocks/smartserve@2.0.1)
'@git.zone/tsbundle': 2.8.3 '@git.zone/tsbundle': 2.8.3
@@ -8103,7 +8111,7 @@ snapshots:
lru-cache@7.18.3: {} lru-cache@7.18.3: {}
lucide@0.562.0: {} lucide@0.563.0: {}
make-dir@3.1.0: make-dir@3.1.0:
dependencies: dependencies:

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@git.zone/tsview', name: '@git.zone/tsview',
version: '1.2.0', version: '1.4.0',
description: 'A CLI tool for viewing S3 and MongoDB data with a web UI' description: 'A CLI tool for viewing S3 and MongoDB data with a web UI'
} }

View File

@@ -21,16 +21,10 @@ export async function registerMongoHandlers(
// Helper to create ObjectId filter // Helper to create ObjectId filter
const createIdFilter = (documentId: string) => { const createIdFilter = (documentId: string) => {
// Try to treat as ObjectId string - MongoDB driver will handle conversion const { ObjectId } = plugins.mongodb;
try {
// Import ObjectId from the mongodb package that smartdata uses
const { ObjectId } = require('mongodb');
if (ObjectId.isValid(documentId)) { if (ObjectId.isValid(documentId)) {
return { _id: new ObjectId(documentId) }; return { _id: new ObjectId(documentId) };
} }
} catch {
// Fall through to string filter
}
return { _id: documentId }; return { _id: documentId };
}; };
@@ -59,6 +53,37 @@ export async function registerMongoHandlers(
) )
); );
// Get database stats
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_GetDatabaseStats>(
'getDatabaseStats',
async (reqData) => {
try {
const client = await getMongoClient();
const db = client.db(reqData.databaseName);
const stats = await db.stats();
const collections = await db.listCollections().toArray();
return {
stats: {
collections: collections.length,
views: stats.views || 0,
objects: stats.objects || 0,
avgObjSize: stats.avgObjSize || 0,
dataSize: stats.dataSize || 0,
storageSize: stats.storageSize || 0,
indexes: stats.indexes || 0,
indexSize: stats.indexSize || 0,
},
};
} catch (err) {
console.error('Error getting database stats:', err);
return { stats: null };
}
}
)
);
// List collections // List collections
typedrouter.addTypedHandler( typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_ListCollections>( new plugins.typedrequest.TypedHandler<interfaces.IReq_ListCollections>(

View File

@@ -292,6 +292,7 @@ export async function registerS3Handlers(
await bucket.fastPut({ await bucket.fastPut({
path: reqData.key, path: reqData.key,
contents: content, contents: content,
overwrite: true,
}); });
return { success: true }; return { success: true };
@@ -354,6 +355,7 @@ export async function registerS3Handlers(
await destBucket.fastPut({ await destBucket.fastPut({
path: reqData.destKey, path: reqData.destKey,
contents: content, contents: content,
overwrite: true,
}); });
return { success: true }; return { success: true };

File diff suppressed because one or more lines are too long

View File

@@ -511,3 +511,27 @@ export interface IReq_GetServerStatus extends plugins.typedrequestInterfaces.imp
}; };
}; };
} }
export interface IDatabaseStats {
collections: number;
views: number;
objects: number;
avgObjSize: number;
dataSize: number;
storageSize: number;
indexes: number;
indexSize: number;
}
export interface IReq_GetDatabaseStats extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetDatabaseStats
> {
method: 'getDatabaseStats';
request: {
databaseName: string;
};
response: {
stats: IDatabaseStats | null;
};
}

View File

@@ -37,6 +37,10 @@ export {
import * as s3 from '@aws-sdk/client-s3'; import * as s3 from '@aws-sdk/client-s3';
export { s3 }; export { s3 };
// MongoDB driver for ObjectId handling
import * as mongodb from 'mongodb';
export { mongodb };
// @api.global scope // @api.global scope
import * as typedrequest from '@api.global/typedrequest'; import * as typedrequest from '@api.global/typedrequest';
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces'; import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@git.zone/tsview', name: '@git.zone/tsview',
version: '1.2.0', version: '1.4.0',
description: 'A CLI tool for viewing S3 and MongoDB data with a web UI' description: 'A CLI tool for viewing S3 and MongoDB data with a web UI'
} }

View File

@@ -13,3 +13,4 @@ export * from './tsview-mongo-collections.js';
export * from './tsview-mongo-documents.js'; export * from './tsview-mongo-documents.js';
export * from './tsview-mongo-document.js'; export * from './tsview-mongo-document.js';
export * from './tsview-mongo-indexes.js'; export * from './tsview-mongo-indexes.js';
export * from './tsview-mongo-db-overview.js';

View File

@@ -45,6 +45,27 @@ export class TsviewApp extends DeesElement {
@state() @state()
private accessor newDatabaseName: string = ''; private accessor newDatabaseName: string = '';
@state()
private accessor showSystemDatabases: boolean = false;
@state()
private accessor showS3CreateDialog: boolean = false;
@state()
private accessor s3CreateDialogType: 'folder' | 'file' = 'folder';
@state()
private accessor s3CreateDialogBucket: string = '';
@state()
private accessor s3CreateDialogName: string = '';
@state()
private accessor sidebarWidth: number = 240;
@state()
private accessor isResizingSidebar: boolean = false;
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
themeStyles, themeStyles,
@@ -118,10 +139,22 @@ export class TsviewApp extends DeesElement {
.app-main { .app-main {
display: grid; display: grid;
grid-template-columns: 240px 1fr; grid-template-columns: var(--sidebar-width, 240px) 4px 1fr;
overflow: hidden; 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 { .sidebar {
background: #1e1e1e; background: #1e1e1e;
border-right: 1px solid #333; border-right: 1px solid #333;
@@ -297,6 +330,20 @@ export class TsviewApp extends DeesElement {
border-color: #e0e0e0; border-color: #e0e0e0;
} }
.dialog-location {
font-size: 12px;
color: #888;
margin-bottom: 12px;
font-family: monospace;
}
.dialog-hint {
font-size: 11px;
color: #666;
margin-bottom: 16px;
margin-top: -8px;
}
.dialog-actions { .dialog-actions {
display: flex; display: flex;
gap: 12px; gap: 12px;
@@ -363,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() { async connectedCallback() {
super.connectedCallback(); super.connectedCallback();
await this.loadData(); await this.loadData();
@@ -397,9 +453,16 @@ export class TsviewApp extends DeesElement {
} }
private selectDatabase(db: string) { private selectDatabase(db: string) {
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.selectedDatabase = db;
this.selectedCollection = ''; this.selectedCollection = '';
} }
}
private selectCollection(collection: string) { private selectCollection(collection: string) {
this.selectedCollection = collection; this.selectedCollection = collection;
@@ -483,6 +546,17 @@ export class TsviewApp extends DeesElement {
}, },
}, },
{ divider: true }, { divider: true },
{
name: 'New Folder',
iconName: 'lucide:folderPlus',
action: async () => this.openS3CreateDialog(bucket, 'folder'),
},
{
name: 'New File',
iconName: 'lucide:filePlus',
action: async () => this.openS3CreateDialog(bucket, 'file'),
},
{ divider: true },
{ {
name: 'Delete Bucket', name: 'Delete Bucket',
iconName: 'lucide:trash2', iconName: 'lucide:trash2',
@@ -501,6 +575,72 @@ export class TsviewApp extends DeesElement {
]); ]);
} }
private openS3CreateDialog(bucket: string, type: 'folder' | 'file') {
this.s3CreateDialogBucket = bucket;
this.s3CreateDialogType = type;
this.s3CreateDialogName = '';
this.showS3CreateDialog = true;
}
private getContentType(ext: string): string {
const contentTypes: Record<string, string> = {
json: 'application/json',
txt: 'text/plain',
html: 'text/html',
css: 'text/css',
js: 'application/javascript',
ts: 'text/typescript',
md: 'text/markdown',
xml: 'application/xml',
yaml: 'text/yaml',
yml: 'text/yaml',
csv: 'text/csv',
};
return contentTypes[ext] || 'application/octet-stream';
}
private getDefaultContent(ext: string): string {
const defaults: Record<string, string> = {
json: '{\n \n}',
html: '<!DOCTYPE html>\n<html>\n<head>\n <title></title>\n</head>\n<body>\n \n</body>\n</html>',
md: '# Title\n\n',
txt: '',
};
return defaults[ext] || '';
}
private async handleS3Create() {
if (!this.s3CreateDialogName.trim()) return;
const name = this.s3CreateDialogName.trim();
let path: string;
if (this.s3CreateDialogType === 'folder') {
path = name + '/.keep';
} else {
path = name;
}
const ext = name.split('.').pop()?.toLowerCase() || '';
const contentType = this.s3CreateDialogType === 'file' ? this.getContentType(ext) : 'application/octet-stream';
const content = this.s3CreateDialogType === 'file' ? this.getDefaultContent(ext) : '';
const success = await apiService.putObject(
this.s3CreateDialogBucket,
path,
btoa(content),
contentType
);
if (success) {
this.showS3CreateDialog = false;
// Select the bucket to show the new content
this.selectedBucket = this.s3CreateDialogBucket;
// Trigger a refresh by dispatching an event
this.requestUpdate();
}
}
private handleDatabaseContextMenu(event: MouseEvent, dbName: string) { private handleDatabaseContextMenu(event: MouseEvent, dbName: string) {
event.preventDefault(); event.preventDefault();
DeesContextmenu.openContextMenuWithOptions(event, [ DeesContextmenu.openContextMenuWithOptions(event, [
@@ -532,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() { render() {
return html` return html`
<div class="app-container"> <div class="app-container">
@@ -566,14 +738,19 @@ export class TsviewApp extends DeesElement {
</nav> </nav>
</header> </header>
<main class="app-main"> <main class="app-main" style="--sidebar-width: ${this.sidebarWidth}px">
${this.renderSidebar()} ${this.renderSidebar()}
<div
class="resize-divider ${this.isResizingSidebar ? 'active' : ''}"
@mousedown=${this.startSidebarResize}
></div>
${this.renderContent()} ${this.renderContent()}
</main> </main>
</div> </div>
${this.renderCreateBucketDialog()} ${this.renderCreateBucketDialog()}
${this.renderCreateCollectionDialog()} ${this.renderCreateCollectionDialog()}
${this.renderCreateDatabaseDialog()} ${this.renderCreateDatabaseDialog()}
${this.renderS3CreateDialog()}
`; `;
} }
@@ -670,6 +847,48 @@ export class TsviewApp extends DeesElement {
`; `;
} }
private renderS3CreateDialog() {
if (!this.showS3CreateDialog) return '';
const isFolder = this.s3CreateDialogType === 'folder';
const title = isFolder ? 'Create New Folder' : 'Create New File';
const placeholder = isFolder ? 'folder-name or path/to/folder' : 'filename.txt or path/to/file.txt';
return html`
<div class="dialog-overlay" @click=${() => this.showS3CreateDialog = false}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog-title">${title}</div>
<div class="dialog-location">
Location: ${this.s3CreateDialogBucket}/
</div>
<input
type="text"
class="dialog-input"
placeholder=${placeholder}
.value=${this.s3CreateDialogName}
@input=${(e: InputEvent) => this.s3CreateDialogName = (e.target as HTMLInputElement).value}
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleS3Create()}
/>
<div class="dialog-hint">
Use "/" to create nested ${isFolder ? 'folders' : 'path'} (e.g., ${isFolder ? 'parent/child' : 'folder/file.txt'})
</div>
<div class="dialog-actions">
<button class="dialog-btn dialog-btn-cancel" @click=${() => this.showS3CreateDialog = false}>
Cancel
</button>
<button
class="dialog-btn dialog-btn-create"
?disabled=${!this.s3CreateDialogName.trim()}
@click=${() => this.handleS3Create()}
>
Create
</button>
</div>
</div>
</div>
`;
}
private renderSidebar() { private renderSidebar() {
if (this.viewMode === 's3') { if (this.viewMode === 's3') {
return html` return html`
@@ -703,7 +922,7 @@ export class TsviewApp extends DeesElement {
if (this.viewMode === 'mongo') { if (this.viewMode === 'mongo') {
return html` return html`
<aside class="sidebar"> <aside class="sidebar" @contextmenu=${(e: MouseEvent) => this.handleSidebarContextMenu(e)}>
<div class="sidebar-header">Databases & Collections</div> <div class="sidebar-header">Databases & Collections</div>
<button class="create-btn" @click=${() => this.showCreateDatabaseDialog = true}> <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"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -713,9 +932,9 @@ export class TsviewApp extends DeesElement {
New Database New Database
</button> </button>
<div class="sidebar-list"> <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>` ? 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> </div>
</aside> </aside>
`; `;
@@ -808,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` return html`
<div class="content-area"> <div class="content-area">
<tsview-mongo-browser <tsview-mongo-browser

View File

@@ -24,6 +24,12 @@ export class TsviewMongoBrowser extends DeesElement {
@state() @state()
private accessor stats: ICollectionStats | null = null; private accessor stats: ICollectionStats | null = null;
@state()
private accessor editorWidth: number = 400;
@state()
private accessor isResizingEditor: boolean = false;
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
themeStyles, themeStyles,
@@ -102,11 +108,23 @@ export class TsviewMongoBrowser extends DeesElement {
.content { .content {
flex: 1; flex: 1;
display: grid; display: grid;
grid-template-columns: 1fr 400px; grid-template-columns: 1fr 4px var(--editor-width, 400px);
gap: 16px; gap: 0;
overflow: hidden; 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 { .main-panel {
overflow: auto; overflow: auto;
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
@@ -117,6 +135,7 @@ export class TsviewMongoBrowser extends DeesElement {
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
margin-left: 12px;
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
@@ -124,7 +143,8 @@ export class TsviewMongoBrowser extends DeesElement {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.detail-panel { .detail-panel,
.resize-divider {
display: none; display: none;
} }
} }
@@ -162,6 +182,28 @@ export class TsviewMongoBrowser extends DeesElement {
this.selectedDocumentId = e.detail.documentId; 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() { render() {
return html` return html`
<div class="browser-container"> <div class="browser-container">
@@ -201,7 +243,7 @@ export class TsviewMongoBrowser extends DeesElement {
</div> </div>
</div> </div>
<div class="content"> <div class="content" style="--editor-width: ${this.editorWidth}px">
<div class="main-panel"> <div class="main-panel">
${this.activeTab === 'documents' ${this.activeTab === 'documents'
? html` ? html`
@@ -225,6 +267,11 @@ export class TsviewMongoBrowser extends DeesElement {
`} `}
</div> </div>
<div
class="resize-divider ${this.isResizingEditor ? 'active' : ''}"
@mousedown=${this.startEditorResize}
></div>
<div class="detail-panel"> <div class="detail-panel">
<tsview-mongo-document <tsview-mongo-document
.databaseName=${this.databaseName} .databaseName=${this.databaseName}

View File

@@ -90,6 +90,33 @@ export class TsviewMongoCollections extends DeesElement {
font-size: 12px; font-size: 12px;
font-style: italic; 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,18 +195,38 @@ export class TsviewMongoCollections extends DeesElement {
await this.loadCollections(); await this.loadCollections();
} }
private selectOverview() {
this.dispatchEvent(
new CustomEvent('collection-selected', {
detail: '__overview__',
bubbles: true,
composed: true,
})
);
}
render() { render() {
if (this.loading) { if (this.loading) {
return html`<div class="loading-state">Loading collections...</div>`; 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` return html`
<div class="collections-list"> <div class="collections-list">
${this.collections.map( <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` (coll) => html`
<div <div
class="collection-item ${this.selectedCollection === coll.name ? 'selected' : ''}" class="collection-item ${this.selectedCollection === coll.name ? 'selected' : ''}"

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

View File

@@ -25,7 +25,10 @@ export class TsviewMongoDocument extends DeesElement {
private accessor editing: boolean = false; private accessor editing: boolean = false;
@state() @state()
private accessor editContent: string = ''; private accessor originalContent: string = '';
@state()
private accessor hasChanges: boolean = false;
@state() @state()
private accessor error: string = ''; private accessor error: string = '';
@@ -101,56 +104,13 @@ export class TsviewMongoDocument extends DeesElement {
.content { .content {
flex: 1; flex: 1;
overflow: auto; overflow: hidden;
padding: 12px; display: flex;
flex-direction: column;
} }
.json-view { .content dees-input-code {
font-family: 'Monaco', 'Menlo', monospace; flex: 1;
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;
} }
.empty-state { .empty-state {
@@ -190,10 +150,12 @@ export class TsviewMongoDocument extends DeesElement {
updated(changedProperties: Map<string, unknown>) { updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('documentId')) { if (changedProperties.has('documentId')) {
this.editing = false; this.editing = false;
this.hasChanges = false;
if (this.documentId) { if (this.documentId) {
this.loadDocument(); this.loadDocument();
} else { } else {
this.document = null; this.document = null;
this.originalContent = '';
} }
} }
} }
@@ -210,6 +172,8 @@ export class TsviewMongoDocument extends DeesElement {
this.collectionName, this.collectionName,
this.documentId this.documentId
); );
this.originalContent = JSON.stringify(this.document, null, 2);
this.hasChanges = false;
} catch (err) { } catch (err) {
console.error('Error loading document:', err); console.error('Error loading document:', err);
this.error = 'Failed to load document'; this.error = 'Failed to load document';
@@ -219,18 +183,37 @@ export class TsviewMongoDocument extends DeesElement {
} }
private startEditing() { private startEditing() {
this.editContent = JSON.stringify(this.document, null, 2);
this.editing = true; this.editing = true;
} }
private cancelEditing() { private cancelEditing() {
this.editing = false; 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() { private async saveDocument() {
try { 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) // Remove _id from update (can't update _id)
const { _id, ...updateFields } = updatedDoc; const { _id, ...updateFields } = updatedDoc;
@@ -243,6 +226,7 @@ export class TsviewMongoDocument extends DeesElement {
); );
this.editing = false; this.editing = false;
this.hasChanges = false;
await this.loadDocument(); await this.loadDocument();
this.dispatchEvent( 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() { render() {
if (!this.documentId) { if (!this.documentId) {
return html` return html`
@@ -334,10 +304,14 @@ export class TsviewMongoDocument extends DeesElement {
<span class="header-title">Document</span> <span class="header-title">Document</span>
<div class="header-actions"> <div class="header-actions">
${this.editing ${this.editing
? this.hasChanges
? html` ? html`
<button class="action-btn" @click=${this.cancelEditing}>Cancel</button> <button class="action-btn" @click=${this.handleDiscard}>Discard</button>
<button class="action-btn primary" @click=${this.saveDocument}>Save</button> <button class="action-btn primary" @click=${this.saveDocument}>Save</button>
` `
: html`
<button class="action-btn" @click=${this.cancelEditing}>Cancel</button>
`
: html` : html`
<button class="action-btn" @click=${this.startEditing}>Edit</button> <button class="action-btn" @click=${this.startEditing}>Edit</button>
<button class="action-btn danger" @click=${this.deleteDocument}>Delete</button> <button class="action-btn danger" @click=${this.deleteDocument}>Delete</button>
@@ -346,20 +320,12 @@ export class TsviewMongoDocument extends DeesElement {
</div> </div>
<div class="content"> <div class="content">
${this.editing <dees-input-code
? html` .value=${this.originalContent}
<textarea .language=${'json'}
class="edit-area" .disabled=${!this.editing}
.value=${this.editContent} @content-change=${(e: CustomEvent) => this.handleContentChange(e)}
@input=${(e: Event) => (this.editContent = (e.target as HTMLTextAreaElement).value)} ></dees-input-code>
></textarea>
`
: html`
<div
class="json-view"
.innerHTML=${this.syntaxHighlight(this.formatJson(this.document))}
></div>
`}
</div> </div>
</div> </div>
`; `;

View File

@@ -23,6 +23,12 @@ export class TsviewS3Browser extends DeesElement {
@state() @state()
private accessor refreshKey: number = 0; private accessor refreshKey: number = 0;
@state()
private accessor previewWidth: number = 350;
@state()
private accessor isResizingPreview: boolean = false;
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
themeStyles, themeStyles,
@@ -104,12 +110,24 @@ export class TsviewS3Browser extends DeesElement {
flex: 1; flex: 1;
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 16px; gap: 0;
overflow: hidden; overflow: hidden;
} }
.content.has-preview { .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 { .main-view {
@@ -122,6 +140,7 @@ export class TsviewS3Browser extends DeesElement {
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
margin-left: 12px;
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
@@ -130,7 +149,8 @@ export class TsviewS3Browser extends DeesElement {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.preview-panel { .preview-panel,
.resize-divider {
display: none; 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() { render() {
const breadcrumbParts = this.currentPrefix const breadcrumbParts = this.currentPrefix
? this.currentPrefix.split('/').filter(Boolean) ? this.currentPrefix.split('/').filter(Boolean)
@@ -213,7 +255,7 @@ export class TsviewS3Browser extends DeesElement {
</div> </div>
</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"> <div class="main-view">
${this.viewType === 'columns' ${this.viewType === 'columns'
? html` ? html`
@@ -238,6 +280,10 @@ export class TsviewS3Browser extends DeesElement {
${this.selectedKey ${this.selectedKey
? html` ? html`
<div
class="resize-divider ${this.isResizingPreview ? 'active' : ''}"
@mousedown=${this.startPreviewResize}
></div>
<div class="preview-panel"> <div class="preview-panel">
<tsview-s3-preview <tsview-s3-preview
.bucketName=${this.bucketName} .bucketName=${this.bucketName}

View File

@@ -31,6 +31,18 @@ export class TsviewS3Columns extends DeesElement {
@state() @state()
private accessor loading: boolean = false; private accessor loading: boolean = false;
@state()
private accessor showCreateDialog: boolean = false;
@state()
private accessor createDialogType: 'folder' | 'file' = 'folder';
@state()
private accessor createDialogPrefix: string = '';
@state()
private accessor createDialogName: string = '';
private resizing: { columnIndex: number; startX: number; startWidth: number } | null = null; private resizing: { columnIndex: number; startX: number; startWidth: number } | null = null;
private readonly DEFAULT_COLUMN_WIDTH = 250; private readonly DEFAULT_COLUMN_WIDTH = 250;
private readonly MIN_COLUMN_WIDTH = 150; private readonly MIN_COLUMN_WIDTH = 150;
@@ -170,6 +182,104 @@ export class TsviewS3Columns extends DeesElement {
text-align: center; text-align: center;
color: #666; color: #666;
} }
.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: #1e1e1e;
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-location {
font-size: 12px;
color: #888;
margin-bottom: 12px;
font-family: monospace;
}
.dialog-input {
width: 100%;
padding: 10px 12px;
background: #141414;
border: 1px solid #333;
border-radius: 6px;
color: #fff;
font-size: 14px;
margin-bottom: 8px;
box-sizing: border-box;
}
.dialog-input:focus {
outline: none;
border-color: #e0e0e0;
}
.dialog-hint {
font-size: 11px;
color: #666;
margin-bottom: 16px;
}
.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: #404040;
border: none;
color: #fff;
}
.dialog-btn-create:hover {
background: #505050;
}
.dialog-btn-create:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`, `,
]; ];
@@ -337,6 +447,17 @@ export class TsviewS3Columns extends DeesElement {
}, },
}, },
{ divider: true }, { divider: true },
{
name: 'New Folder Inside',
iconName: 'lucide:folderPlus',
action: async () => this.openCreateDialog('folder', prefix),
},
{
name: 'New File Inside',
iconName: 'lucide:filePlus',
action: async () => this.openCreateDialog('file', prefix),
},
{ divider: true },
{ {
name: 'Delete Folder', name: 'Delete Folder',
iconName: 'lucide:trash2', iconName: 'lucide:trash2',
@@ -396,6 +517,133 @@ export class TsviewS3Columns extends DeesElement {
]); ]);
} }
private handleEmptySpaceContextMenu(event: MouseEvent, columnIndex: number) {
// Only trigger if clicking on the container itself, not on items
if (event.target !== event.currentTarget) return;
event.preventDefault();
const prefix = this.columns[columnIndex].prefix;
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'New Folder',
iconName: 'lucide:folderPlus',
action: async () => this.openCreateDialog('folder', prefix),
},
{
name: 'New File',
iconName: 'lucide:filePlus',
action: async () => this.openCreateDialog('file', prefix),
},
]);
}
private openCreateDialog(type: 'folder' | 'file', prefix: string) {
this.createDialogType = type;
this.createDialogPrefix = prefix;
this.createDialogName = '';
this.showCreateDialog = true;
}
private getContentType(ext: string): string {
const contentTypes: Record<string, string> = {
json: 'application/json',
txt: 'text/plain',
html: 'text/html',
css: 'text/css',
js: 'application/javascript',
ts: 'text/typescript',
md: 'text/markdown',
xml: 'application/xml',
yaml: 'text/yaml',
yml: 'text/yaml',
csv: 'text/csv',
};
return contentTypes[ext] || 'application/octet-stream';
}
private getDefaultContent(ext: string): string {
const defaults: Record<string, string> = {
json: '{\n \n}',
html: '<!DOCTYPE html>\n<html>\n<head>\n <title></title>\n</head>\n<body>\n \n</body>\n</html>',
md: '# Title\n\n',
txt: '',
};
return defaults[ext] || '';
}
private async handleCreate() {
if (!this.createDialogName.trim()) return;
const name = this.createDialogName.trim();
let path: string;
if (this.createDialogType === 'folder') {
// Support deep paths: "a/b/c" creates nested folders
path = this.createDialogPrefix + name + '/.keep';
} else {
path = this.createDialogPrefix + name;
}
const ext = name.split('.').pop()?.toLowerCase() || '';
const contentType = this.createDialogType === 'file' ? this.getContentType(ext) : 'application/octet-stream';
const content = this.createDialogType === 'file' ? this.getDefaultContent(ext) : '';
const success = await apiService.putObject(
this.bucketName,
path,
btoa(content),
contentType
);
if (success) {
this.showCreateDialog = false;
await this.loadInitialColumn();
}
}
private renderCreateDialog() {
if (!this.showCreateDialog) return '';
const isFolder = this.createDialogType === 'folder';
const title = isFolder ? 'Create New Folder' : 'Create New File';
const placeholder = isFolder ? 'folder-name or path/to/folder' : 'filename.txt or path/to/file.txt';
return html`
<div class="dialog-overlay" @click=${() => this.showCreateDialog = false}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog-title">${title}</div>
<div class="dialog-location">
Location: ${this.bucketName}/${this.createDialogPrefix}
</div>
<input
type="text"
class="dialog-input"
placeholder=${placeholder}
.value=${this.createDialogName}
@input=${(e: InputEvent) => this.createDialogName = (e.target as HTMLInputElement).value}
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleCreate()}
/>
<div class="dialog-hint">
Use "/" to create nested ${isFolder ? 'folders' : 'path'} (e.g., ${isFolder ? 'parent/child' : 'folder/file.txt'})
</div>
<div class="dialog-actions">
<button class="dialog-btn dialog-btn-cancel" @click=${() => this.showCreateDialog = false}>
Cancel
</button>
<button
class="dialog-btn dialog-btn-create"
?disabled=${!this.createDialogName.trim()}
@click=${() => this.handleCreate()}
>
Create
</button>
</div>
</div>
</div>
`;
}
render() { render() {
if (this.loading && this.columns.length === 0) { if (this.loading && this.columns.length === 0) {
return html`<div class="loading">Loading...</div>`; return html`<div class="loading">Loading...</div>`;
@@ -405,6 +653,7 @@ export class TsviewS3Columns extends DeesElement {
<div class="columns-container"> <div class="columns-container">
${this.columns.map((column, index) => this.renderColumnWrapper(column, index))} ${this.columns.map((column, index) => this.renderColumnWrapper(column, index))}
</div> </div>
${this.renderCreateDialog()}
`; `;
} }
@@ -430,7 +679,7 @@ export class TsviewS3Columns extends DeesElement {
<div class="column-header" title=${column.prefix || this.bucketName}> <div class="column-header" title=${column.prefix || this.bucketName}>
${headerName} ${headerName}
</div> </div>
<div class="column-items"> <div class="column-items" @contextmenu=${(e: MouseEvent) => this.handleEmptySpaceContextMenu(e, index)}>
${column.prefixes.length === 0 && column.objects.length === 0 ${column.prefixes.length === 0 && column.objects.length === 0
? html`<div class="empty-state">Empty folder</div>` ? html`<div class="empty-state">Empty folder</div>`
: ''} : ''}

View File

@@ -32,6 +32,18 @@ export class TsviewS3Keys extends DeesElement {
@state() @state()
private accessor filterText: string = ''; private accessor filterText: string = '';
@state()
private accessor showCreateDialog: boolean = false;
@state()
private accessor createDialogType: 'folder' | 'file' = 'folder';
@state()
private accessor createDialogPrefix: string = '';
@state()
private accessor createDialogName: string = '';
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
themeStyles, themeStyles,
@@ -146,6 +158,104 @@ export class TsviewS3Keys extends DeesElement {
text-align: center; text-align: center;
color: #666; color: #666;
} }
.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: #1e1e1e;
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-location {
font-size: 12px;
color: #888;
margin-bottom: 12px;
font-family: monospace;
}
.dialog-input {
width: 100%;
padding: 10px 12px;
background: #141414;
border: 1px solid #333;
border-radius: 6px;
color: #fff;
font-size: 14px;
margin-bottom: 8px;
box-sizing: border-box;
}
.dialog-input:focus {
outline: none;
border-color: #e0e0e0;
}
.dialog-hint {
font-size: 11px;
color: #666;
margin-bottom: 16px;
}
.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: #404040;
border: none;
color: #fff;
}
.dialog-btn-create:hover {
background: #505050;
}
.dialog-btn-create:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`, `,
]; ];
@@ -231,6 +341,17 @@ export class TsviewS3Keys extends DeesElement {
}, },
}, },
{ divider: true }, { divider: true },
{
name: 'New Folder Inside',
iconName: 'lucide:folderPlus',
action: async () => this.openCreateDialog('folder', key),
},
{
name: 'New File Inside',
iconName: 'lucide:filePlus',
action: async () => this.openCreateDialog('file', key),
},
{ divider: true },
{ {
name: 'Delete Folder', name: 'Delete Folder',
iconName: 'lucide:trash2', iconName: 'lucide:trash2',
@@ -288,6 +409,130 @@ export class TsviewS3Keys extends DeesElement {
} }
} }
private handleEmptySpaceContextMenu(event: MouseEvent) {
// Only trigger if clicking on the container itself, not on items
if ((event.target as HTMLElement).closest('tr')) return;
event.preventDefault();
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'New Folder',
iconName: 'lucide:folderPlus',
action: async () => this.openCreateDialog('folder', this.currentPrefix),
},
{
name: 'New File',
iconName: 'lucide:filePlus',
action: async () => this.openCreateDialog('file', this.currentPrefix),
},
]);
}
private openCreateDialog(type: 'folder' | 'file', prefix: string) {
this.createDialogType = type;
this.createDialogPrefix = prefix;
this.createDialogName = '';
this.showCreateDialog = true;
}
private getContentType(ext: string): string {
const contentTypes: Record<string, string> = {
json: 'application/json',
txt: 'text/plain',
html: 'text/html',
css: 'text/css',
js: 'application/javascript',
ts: 'text/typescript',
md: 'text/markdown',
xml: 'application/xml',
yaml: 'text/yaml',
yml: 'text/yaml',
csv: 'text/csv',
};
return contentTypes[ext] || 'application/octet-stream';
}
private getDefaultContent(ext: string): string {
const defaults: Record<string, string> = {
json: '{\n \n}',
html: '<!DOCTYPE html>\n<html>\n<head>\n <title></title>\n</head>\n<body>\n \n</body>\n</html>',
md: '# Title\n\n',
txt: '',
};
return defaults[ext] || '';
}
private async handleCreate() {
if (!this.createDialogName.trim()) return;
const name = this.createDialogName.trim();
let path: string;
if (this.createDialogType === 'folder') {
path = this.createDialogPrefix + name + '/.keep';
} else {
path = this.createDialogPrefix + name;
}
const ext = name.split('.').pop()?.toLowerCase() || '';
const contentType = this.createDialogType === 'file' ? this.getContentType(ext) : 'application/octet-stream';
const content = this.createDialogType === 'file' ? this.getDefaultContent(ext) : '';
const success = await apiService.putObject(
this.bucketName,
path,
btoa(content),
contentType
);
if (success) {
this.showCreateDialog = false;
await this.loadObjects();
}
}
private renderCreateDialog() {
if (!this.showCreateDialog) return '';
const isFolder = this.createDialogType === 'folder';
const title = isFolder ? 'Create New Folder' : 'Create New File';
const placeholder = isFolder ? 'folder-name or path/to/folder' : 'filename.txt or path/to/file.txt';
return html`
<div class="dialog-overlay" @click=${() => this.showCreateDialog = false}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog-title">${title}</div>
<div class="dialog-location">
Location: ${this.bucketName}/${this.createDialogPrefix}
</div>
<input
type="text"
class="dialog-input"
placeholder=${placeholder}
.value=${this.createDialogName}
@input=${(e: InputEvent) => this.createDialogName = (e.target as HTMLInputElement).value}
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleCreate()}
/>
<div class="dialog-hint">
Use "/" to create nested ${isFolder ? 'folders' : 'path'} (e.g., ${isFolder ? 'parent/child' : 'folder/file.txt'})
</div>
<div class="dialog-actions">
<button class="dialog-btn dialog-btn-cancel" @click=${() => this.showCreateDialog = false}>
Cancel
</button>
<button
class="dialog-btn dialog-btn-create"
?disabled=${!this.createDialogName.trim()}
@click=${() => this.handleCreate()}
>
Create
</button>
</div>
</div>
</div>
`;
}
render() { render() {
return html` return html`
<div class="keys-container"> <div class="keys-container">
@@ -301,7 +546,7 @@ export class TsviewS3Keys extends DeesElement {
/> />
</div> </div>
<div class="keys-list"> <div class="keys-list" @contextmenu=${(e: MouseEvent) => this.handleEmptySpaceContextMenu(e)}>
${this.loading ${this.loading
? html`<div class="empty-state">Loading...</div>` ? html`<div class="empty-state">Loading...</div>`
: this.filteredItems.length === 0 : this.filteredItems.length === 0
@@ -349,6 +594,7 @@ export class TsviewS3Keys extends DeesElement {
`} `}
</div> </div>
</div> </div>
${this.renderCreateDialog()}
`; `;
} }
} }

View File

@@ -16,9 +16,18 @@ export class TsviewS3Preview extends DeesElement {
@state() @state()
private accessor loading: boolean = false; private accessor loading: boolean = false;
@state()
private accessor saving: boolean = false;
@state() @state()
private accessor content: string = ''; private accessor content: string = '';
@state()
private accessor originalTextContent: string = '';
@state()
private accessor hasChanges: boolean = false;
@state() @state()
private accessor contentType: string = ''; private accessor contentType: string = '';
@@ -78,6 +87,15 @@ export class TsviewS3Preview extends DeesElement {
padding: 12px; padding: 12px;
} }
.preview-content.code-editor {
padding: 0;
overflow: hidden;
}
.preview-content.code-editor dees-input-code {
height: 100%;
}
.preview-image { .preview-image {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
@@ -130,6 +148,51 @@ export class TsviewS3Preview extends DeesElement {
background: rgba(239, 68, 68, 0.3); background: rgba(239, 68, 68, 0.3);
} }
.action-btn.primary {
background: rgba(59, 130, 246, 0.3);
border-color: #3b82f6;
color: #60a5fa;
}
.action-btn.primary:hover {
background: rgba(59, 130, 246, 0.4);
}
.action-btn.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-btn.secondary {
background: rgba(255, 255, 255, 0.05);
border-color: #555;
color: #aaa;
}
.action-btn.secondary:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.unsaved-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: 4px;
font-size: 12px;
color: #fbbf24;
}
.unsaved-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #fbbf24;
}
.empty-state { .empty-state {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -177,7 +240,9 @@ export class TsviewS3Preview extends DeesElement {
} else { } else {
this.content = ''; this.content = '';
this.contentType = ''; this.contentType = '';
this.error = ''; // Clear error when no file selected this.error = '';
this.originalTextContent = '';
this.hasChanges = false;
} }
} }
} }
@@ -187,6 +252,7 @@ export class TsviewS3Preview extends DeesElement {
this.loading = true; this.loading = true;
this.error = ''; this.error = '';
this.hasChanges = false;
try { try {
const result = await apiService.getObject(this.bucketName, this.objectKey); const result = await apiService.getObject(this.bucketName, this.objectKey);
@@ -194,6 +260,11 @@ export class TsviewS3Preview extends DeesElement {
this.contentType = result.contentType; this.contentType = result.contentType;
this.size = result.size; this.size = result.size;
this.lastModified = result.lastModified; this.lastModified = result.lastModified;
// For text files, decode and store original content
if (this.isText()) {
this.originalTextContent = this.getTextContent();
}
} catch (err) { } catch (err) {
console.error('Error loading object:', err); console.error('Error loading object:', err);
this.error = 'Failed to load object'; this.error = 'Failed to load object';
@@ -270,6 +341,98 @@ export class TsviewS3Preview extends DeesElement {
} }
} }
private getLanguage(): string {
const ext = this.objectKey.split('.').pop()?.toLowerCase() || '';
const languageMap: Record<string, string> = {
ts: 'typescript',
tsx: 'typescript',
js: 'javascript',
jsx: 'javascript',
mjs: 'javascript',
cjs: 'javascript',
json: 'json',
html: 'html',
htm: 'html',
css: 'css',
scss: 'scss',
sass: 'scss',
less: 'less',
md: 'markdown',
markdown: 'markdown',
xml: 'xml',
yaml: 'yaml',
yml: 'yaml',
py: 'python',
rb: 'ruby',
go: 'go',
rs: 'rust',
java: 'java',
c: 'c',
cpp: 'cpp',
h: 'c',
hpp: 'cpp',
cs: 'csharp',
php: 'php',
sh: 'shell',
bash: 'shell',
zsh: 'shell',
sql: 'sql',
graphql: 'graphql',
gql: 'graphql',
dockerfile: 'dockerfile',
txt: 'plaintext',
};
return languageMap[ext] || 'plaintext';
}
private handleContentChange(event: CustomEvent) {
const newValue = event.detail as string;
this.hasChanges = newValue !== this.originalTextContent;
}
private handleDiscard() {
const codeEditor = this.shadowRoot?.querySelector('dees-input-code') as any;
if (codeEditor) {
codeEditor.value = this.originalTextContent;
}
this.hasChanges = false;
}
private async handleSave() {
if (!this.hasChanges || this.saving) return;
this.saving = true;
try {
// Get current content from the editor
const codeEditor = this.shadowRoot?.querySelector('dees-input-code') as any;
const currentContent = codeEditor?.value ?? '';
// Encode the text content to base64
const encoder = new TextEncoder();
const bytes = encoder.encode(currentContent);
const base64Content = btoa(String.fromCharCode(...bytes));
const success = await apiService.putObject(
this.bucketName,
this.objectKey,
base64Content,
this.contentType
);
if (success) {
this.originalTextContent = currentContent;
this.hasChanges = false;
// Update the stored content as well
this.content = base64Content;
}
} catch (err) {
console.error('Error saving object:', err);
}
this.saving = false;
}
render() { render() {
if (!this.objectKey) { if (!this.objectKey) {
return html` return html`
@@ -309,14 +472,27 @@ export class TsviewS3Preview extends DeesElement {
<span class="meta-item">${this.contentType}</span> <span class="meta-item">${this.contentType}</span>
<span class="meta-item">${formatSize(this.size)}</span> <span class="meta-item">${formatSize(this.size)}</span>
<span class="meta-item">${this.formatDate(this.lastModified)}</span> <span class="meta-item">${this.formatDate(this.lastModified)}</span>
${this.hasChanges ? html`
<span class="unsaved-indicator">
<span class="unsaved-dot"></span>
Unsaved changes
</span>
` : ''}
</div> </div>
</div> </div>
<div class="preview-content"> <div class="preview-content ${this.isText() ? 'code-editor' : ''}">
${this.isImage() ${this.isImage()
? html`<img class="preview-image" src="data:${this.contentType};base64,${this.content}" />` ? html`<img class="preview-image" src="data:${this.contentType};base64,${this.content}" />`
: this.isText() : this.isText()
? html`<pre class="preview-text">${this.getTextContent()}</pre>` ? html`
<dees-input-code
.value=${this.originalTextContent}
.language=${this.getLanguage()}
height="100%"
@content-change=${(e: CustomEvent) => this.handleContentChange(e)}
></dees-input-code>
`
: html` : html`
<div class="binary-preview"> <div class="binary-preview">
<p>Binary file preview not available</p> <p>Binary file preview not available</p>
@@ -326,8 +502,19 @@ export class TsviewS3Preview extends DeesElement {
</div> </div>
<div class="preview-actions"> <div class="preview-actions">
${this.hasChanges ? html`
<button class="action-btn secondary" @click=${this.handleDiscard}>Discard</button>
<button
class="action-btn primary"
@click=${this.handleSave}
?disabled=${this.saving}
>
${this.saving ? 'Saving...' : 'Save'}
</button>
` : html`
<button class="action-btn" @click=${this.handleDownload}>Download</button> <button class="action-btn" @click=${this.handleDownload}>Download</button>
<button class="action-btn danger" @click=${this.handleDelete}>Delete</button> <button class="action-btn danger" @click=${this.handleDelete}>Delete</button>
`}
</div> </div>
</div> </div>
`; `;

View File

@@ -35,6 +35,17 @@ export interface ICollectionStats {
indexCount: number; indexCount: number;
} }
export interface IDatabaseStats {
collections: number;
views: number;
objects: number;
avgObjSize: number;
dataSize: number;
storageSize: number;
indexes: number;
indexSize: number;
}
/** /**
* API service for communicating with the tsview backend * API service for communicating with the tsview backend
*/ */
@@ -344,4 +355,12 @@ export class ApiService {
}> { }> {
return this.request('getServerStatus', {}); return this.request('getServerStatus', {});
} }
async getDatabaseStats(databaseName: string): Promise<IDatabaseStats | null> {
const result = await this.request<
{ databaseName: string },
{ stats: IDatabaseStats | null }
>('getDatabaseStats', { databaseName });
return result.stats;
}
} }