Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c60cbf5215 | |||
| a26e7a5a20 | |||
| 2ca5f52da3 | |||
| 349b43612e | |||
| 07010376cb | |||
| 5d533caccb |
31
changelog.md
31
changelog.md
@@ -1,5 +1,36 @@
|
|||||||
# 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)
|
||||||
|
add S3 deletePrefix and getObjectUrl endpoints and add context menus in UI for S3 and Mongo views
|
||||||
|
|
||||||
|
- Add server-side TypedHandlers: deletePrefix and getObjectUrl (ts/api/handlers.s3.ts)
|
||||||
|
- Add request/response interfaces IReq_DeletePrefix and IReq_GetObjectUrl (ts/interfaces/index.ts)
|
||||||
|
- Add client API methods deletePrefix and getObjectUrl (ts_web/services/api.service.ts)
|
||||||
|
- Introduce context menu actions (DeesContextmenu) across UI: bucket/database/collection/document/folder/file actions including open, copy path, delete, download and duplicate (ts_web/elements/tsview-app.ts, tsview-mongo-collections.ts, tsview-mongo-documents.ts, tsview-s3-columns.ts, tsview-s3-keys.ts)
|
||||||
|
- Switch from inline delete buttons to contextual menus for safer UX; implement downloads via data URLs returned by getObjectUrl and deletion of S3 prefixes (folders)
|
||||||
|
|
||||||
## 2026-01-25 - 1.1.3 - fix(package)
|
## 2026-01-25 - 1.1.3 - fix(package)
|
||||||
update package metadata
|
update package metadata
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@git.zone/tsview",
|
"name": "@git.zone/tsview",
|
||||||
"version": "1.1.3",
|
"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
40
pnpm-lock.yaml
generated
@@ -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:
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@git.zone/tsview',
|
name: '@git.zone/tsview',
|
||||||
version: '1.1.3',
|
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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,15 +21,9 @@ 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 {
|
if (ObjectId.isValid(documentId)) {
|
||||||
// Import ObjectId from the mongodb package that smartdata uses
|
return { _id: new ObjectId(documentId) };
|
||||||
const { ObjectId } = require('mongodb');
|
|
||||||
if (ObjectId.isValid(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>(
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -364,4 +366,103 @@ export async function registerS3Handlers(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Delete prefix (folder and all contents)
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_DeletePrefix>(
|
||||||
|
'deletePrefix',
|
||||||
|
async (reqData) => {
|
||||||
|
const smartbucket = await tsview.getSmartBucket();
|
||||||
|
if (!smartbucket) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bucket = await smartbucket.getBucketByName(reqData.bucketName);
|
||||||
|
if (!bucket) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseDir = await bucket.getBaseDirectory();
|
||||||
|
let targetDir = baseDir;
|
||||||
|
|
||||||
|
// Navigate to the prefix directory
|
||||||
|
const prefix = reqData.prefix.replace(/\/$/, '');
|
||||||
|
const prefixParts = prefix.split('/').filter(Boolean);
|
||||||
|
for (const part of prefixParts) {
|
||||||
|
const subDir = await targetDir.getSubDirectoryByName(part, { getEmptyDirectory: true });
|
||||||
|
if (subDir) {
|
||||||
|
targetDir = subDir;
|
||||||
|
} else {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the directory and all its contents
|
||||||
|
await targetDir.delete({ mode: 'permanent' });
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting prefix:', err);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get object URL (for downloads)
|
||||||
|
typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.IReq_GetObjectUrl>(
|
||||||
|
'getObjectUrl',
|
||||||
|
async (reqData) => {
|
||||||
|
const smartbucket = await tsview.getSmartBucket();
|
||||||
|
if (!smartbucket) {
|
||||||
|
throw new Error('S3 not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bucket = await smartbucket.getBucketByName(reqData.bucketName);
|
||||||
|
if (!bucket) {
|
||||||
|
throw new Error(`Bucket ${reqData.bucketName} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the content and create a data URL
|
||||||
|
const content = await bucket.fastGet({ path: reqData.key });
|
||||||
|
const ext = reqData.key.split('.').pop()?.toLowerCase() || '';
|
||||||
|
const contentTypeMap: Record<string, string> = {
|
||||||
|
'json': 'application/json',
|
||||||
|
'txt': 'text/plain',
|
||||||
|
'html': 'text/html',
|
||||||
|
'css': 'text/css',
|
||||||
|
'js': 'application/javascript',
|
||||||
|
'ts': 'text/plain',
|
||||||
|
'tsx': 'text/plain',
|
||||||
|
'jsx': 'text/plain',
|
||||||
|
'md': 'text/markdown',
|
||||||
|
'csv': 'text/csv',
|
||||||
|
'yaml': 'text/yaml',
|
||||||
|
'yml': 'text/yaml',
|
||||||
|
'log': 'text/plain',
|
||||||
|
'sh': 'text/plain',
|
||||||
|
'env': 'text/plain',
|
||||||
|
'png': 'image/png',
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'gif': 'image/gif',
|
||||||
|
'webp': 'image/webp',
|
||||||
|
'svg': 'image/svg+xml',
|
||||||
|
'pdf': 'application/pdf',
|
||||||
|
'xml': 'application/xml',
|
||||||
|
};
|
||||||
|
const contentType = contentTypeMap[ext] || 'application/octet-stream';
|
||||||
|
const base64 = content.toString('base64');
|
||||||
|
const url = `data:${contentType};base64,${base64}`;
|
||||||
|
|
||||||
|
return { url };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error getting object URL:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -199,6 +199,34 @@ export interface IReq_CopyObject extends plugins.typedrequestInterfaces.implemen
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IReq_DeletePrefix extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeletePrefix
|
||||||
|
> {
|
||||||
|
method: 'deletePrefix';
|
||||||
|
request: {
|
||||||
|
bucketName: string;
|
||||||
|
prefix: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetObjectUrl extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetObjectUrl
|
||||||
|
> {
|
||||||
|
method: 'getObjectUrl';
|
||||||
|
request: {
|
||||||
|
bucketName: string;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ===========================================
|
// ===========================================
|
||||||
// TypedRequest interfaces for MongoDB API
|
// TypedRequest interfaces for MongoDB API
|
||||||
// ===========================================
|
// ===========================================
|
||||||
@@ -483,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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@git.zone/tsview',
|
name: '@git.zone/tsview',
|
||||||
version: '1.1.3',
|
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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { apiService } from '../services/index.js';
|
|||||||
import { themeStyles } from '../styles/index.js';
|
import { themeStyles } from '../styles/index.js';
|
||||||
|
|
||||||
const { html, css, cssManager, customElement, state, DeesElement } = plugins;
|
const { html, css, cssManager, customElement, state, DeesElement } = plugins;
|
||||||
|
const { DeesContextmenu } = plugins.deesCatalog;
|
||||||
|
|
||||||
type TViewMode = 's3' | 'mongo' | 'settings';
|
type TViewMode = 's3' | 'mongo' | 'settings';
|
||||||
|
|
||||||
@@ -44,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,
|
||||||
@@ -117,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;
|
||||||
@@ -296,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;
|
||||||
@@ -359,33 +407,18 @@ export class TsviewApp extends DeesElement {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-btn {
|
|
||||||
opacity: 0;
|
|
||||||
padding: 4px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #888;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item:hover .delete-btn,
|
|
||||||
.db-group-header:hover .delete-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-btn:hover {
|
|
||||||
background: rgba(239, 68, 68, 0.2);
|
|
||||||
color: #f87171;
|
|
||||||
}
|
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -420,8 +453,15 @@ export class TsviewApp extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private selectDatabase(db: string) {
|
private selectDatabase(db: string) {
|
||||||
this.selectedDatabase = db;
|
if (this.selectedDatabase === db) {
|
||||||
this.selectedCollection = '';
|
// Collapse - clicking the same database again
|
||||||
|
// Keep the collection selection intact
|
||||||
|
this.selectedDatabase = '';
|
||||||
|
} else {
|
||||||
|
// Switch to different database - clear collection
|
||||||
|
this.selectedDatabase = db;
|
||||||
|
this.selectedCollection = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private selectCollection(collection: string) {
|
private selectCollection(collection: string) {
|
||||||
@@ -495,6 +535,175 @@ export class TsviewApp extends DeesElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleBucketContextMenu(event: MouseEvent, bucket: string) {
|
||||||
|
event.preventDefault();
|
||||||
|
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||||
|
{
|
||||||
|
name: 'View Contents',
|
||||||
|
iconName: 'lucide:folderOpen',
|
||||||
|
action: async () => {
|
||||||
|
this.selectBucket(bucket);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ 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',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
action: async () => {
|
||||||
|
if (confirm(`Delete bucket "${bucket}"? This will delete all objects in the bucket.`)) {
|
||||||
|
const success = await apiService.deleteBucket(bucket);
|
||||||
|
if (success) {
|
||||||
|
this.buckets = this.buckets.filter(b => b !== bucket);
|
||||||
|
if (this.selectedBucket === bucket) {
|
||||||
|
this.selectedBucket = this.buckets[0] || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
event.preventDefault();
|
||||||
|
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||||
|
{
|
||||||
|
name: 'New Collection',
|
||||||
|
iconName: 'lucide:folderPlus',
|
||||||
|
action: async () => {
|
||||||
|
this.selectedDatabase = dbName;
|
||||||
|
this.showCreateCollectionDialog = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ divider: true },
|
||||||
|
{
|
||||||
|
name: 'Delete Database',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
action: async () => {
|
||||||
|
if (confirm(`Delete database "${dbName}"? This will delete all collections and documents.`)) {
|
||||||
|
const success = await apiService.dropDatabase(dbName);
|
||||||
|
if (success) {
|
||||||
|
this.databases = this.databases.filter(d => d.name !== dbName);
|
||||||
|
if (this.selectedDatabase === dbName) {
|
||||||
|
this.selectedDatabase = this.databases[0]?.name || '';
|
||||||
|
this.selectedCollection = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
@@ -529,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()}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,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`
|
||||||
@@ -653,14 +909,9 @@ export class TsviewApp extends DeesElement {
|
|||||||
<div
|
<div
|
||||||
class="sidebar-item ${bucket === this.selectedBucket ? 'selected' : ''}"
|
class="sidebar-item ${bucket === this.selectedBucket ? 'selected' : ''}"
|
||||||
@click=${() => this.selectBucket(bucket)}
|
@click=${() => this.selectBucket(bucket)}
|
||||||
|
@contextmenu=${(e: MouseEvent) => this.handleBucketContextMenu(e, bucket)}
|
||||||
>
|
>
|
||||||
<span class="sidebar-item-name">${bucket}</span>
|
<span class="sidebar-item-name">${bucket}</span>
|
||||||
<button class="delete-btn" @click=${(e: Event) => this.deleteBucket(bucket, e)} title="Delete bucket">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<polyline points="3 6 5 6 21 6"></polyline>
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
)}
|
)}
|
||||||
@@ -671,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">
|
||||||
@@ -680,19 +931,10 @@ export class TsviewApp extends DeesElement {
|
|||||||
</svg>
|
</svg>
|
||||||
New Database
|
New Database
|
||||||
</button>
|
</button>
|
||||||
${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">
|
<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>
|
||||||
`;
|
`;
|
||||||
@@ -715,6 +957,7 @@ export class TsviewApp extends DeesElement {
|
|||||||
<div
|
<div
|
||||||
class="db-group-header ${this.selectedDatabase === db.name ? 'selected' : ''}"
|
class="db-group-header ${this.selectedDatabase === db.name ? 'selected' : ''}"
|
||||||
@click=${() => this.selectDatabase(db.name)}
|
@click=${() => this.selectDatabase(db.name)}
|
||||||
|
@contextmenu=${(e: MouseEvent) => this.handleDatabaseContextMenu(e, db.name)}
|
||||||
>
|
>
|
||||||
<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">
|
||||||
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
|
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
|
||||||
@@ -722,12 +965,6 @@ export class TsviewApp extends DeesElement {
|
|||||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
|
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span style="flex: 1;">${db.name}</span>
|
<span style="flex: 1;">${db.name}</span>
|
||||||
<button class="delete-btn" @click=${(e: Event) => this.deleteDatabase(db.name, e)} title="Delete database">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<polyline points="3 6 5 6 21 6"></polyline>
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
${this.selectedDatabase === db.name ? this.renderCollectionsList(db.name) : ''}
|
${this.selectedDatabase === db.name ? this.renderCollectionsList(db.name) : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -790,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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { formatCount } from '../utilities/index.js';
|
|||||||
import { themeStyles } from '../styles/index.js';
|
import { themeStyles } from '../styles/index.js';
|
||||||
|
|
||||||
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||||
|
const { DeesContextmenu } = plugins.deesCatalog;
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementEventMap {
|
interface HTMLElementEventMap {
|
||||||
@@ -90,27 +91,31 @@ export class TsviewMongoCollections extends DeesElement {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-btn {
|
.overview-item {
|
||||||
opacity: 0;
|
padding: 6px 12px;
|
||||||
padding: 4px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #888;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 6px;
|
||||||
transition: all 0.15s;
|
transition: background 0.1s;
|
||||||
|
color: #a5d6a7;
|
||||||
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collection-item:hover .delete-btn {
|
.overview-item:hover {
|
||||||
opacity: 1;
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-btn:hover {
|
.overview-item.selected {
|
||||||
background: rgba(239, 68, 68, 0.2);
|
background: rgba(165, 214, 167, 0.15);
|
||||||
color: #f87171;
|
color: #a5d6a7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-item svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
@@ -149,8 +154,7 @@ export class TsviewMongoCollections extends DeesElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deleteCollection(name: string, e: Event) {
|
private async deleteCollection(name: string) {
|
||||||
e.stopPropagation();
|
|
||||||
if (!confirm(`Delete collection "${name}"? This will delete all documents.`)) return;
|
if (!confirm(`Delete collection "${name}"? This will delete all documents.`)) return;
|
||||||
|
|
||||||
const success = await apiService.dropCollection(this.databaseName, name);
|
const success = await apiService.dropCollection(this.databaseName, name);
|
||||||
@@ -166,47 +170,81 @@ export class TsviewMongoCollections extends DeesElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleCollectionContextMenu(event: MouseEvent, collection: IMongoCollection) {
|
||||||
|
event.preventDefault();
|
||||||
|
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||||
|
{
|
||||||
|
name: 'View Documents',
|
||||||
|
iconName: 'lucide:fileText',
|
||||||
|
action: async () => {
|
||||||
|
this.selectCollection(collection.name);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ divider: true },
|
||||||
|
{
|
||||||
|
name: 'Delete Collection',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
action: async () => {
|
||||||
|
await this.deleteCollection(collection.name);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public async refresh() {
|
public async refresh() {
|
||||||
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
|
||||||
(coll) => html`
|
class="overview-item ${this.selectedCollection === '__overview__' ? 'selected' : ''}"
|
||||||
<div
|
@click=${() => this.selectOverview()}
|
||||||
class="collection-item ${this.selectedCollection === coll.name ? 'selected' : ''}"
|
>
|
||||||
@click=${() => this.selectCollection(coll.name)}
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
>
|
<rect x="3" y="3" width="7" height="7"></rect>
|
||||||
<span class="collection-name">
|
<rect x="14" y="3" width="7" height="7"></rect>
|
||||||
<svg class="collection-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<rect x="14" y="14" width="7" height="7"></rect>
|
||||||
<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>
|
<rect x="3" y="14" width="7" height="7"></rect>
|
||||||
</svg>
|
</svg>
|
||||||
${coll.name}
|
Overview
|
||||||
</span>
|
</div>
|
||||||
<span style="display: flex; align-items: center; gap: 4px;">
|
${this.collections.length === 0
|
||||||
${coll.count !== undefined
|
? html`<div class="empty-state">No collections</div>`
|
||||||
? html`<span class="collection-count">${formatCount(coll.count)}</span>`
|
: this.collections.map(
|
||||||
: ''}
|
(coll) => html`
|
||||||
<button class="delete-btn" @click=${(e: Event) => this.deleteCollection(coll.name, e)} title="Delete collection">
|
<div
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
class="collection-item ${this.selectedCollection === coll.name ? 'selected' : ''}"
|
||||||
<polyline points="3 6 5 6 21 6"></polyline>
|
@click=${() => this.selectCollection(coll.name)}
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
@contextmenu=${(e: MouseEvent) => this.handleCollectionContextMenu(e, coll)}
|
||||||
</svg>
|
>
|
||||||
</button>
|
<span class="collection-name">
|
||||||
</span>
|
<svg class="collection-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
</div>
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||||
`
|
</svg>
|
||||||
)}
|
${coll.name}
|
||||||
|
</span>
|
||||||
|
${coll.count !== undefined
|
||||||
|
? html`<span class="collection-count">${formatCount(coll.count)}</span>`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
291
ts_web/elements/tsview-mongo-db-overview.ts
Normal file
291
ts_web/elements/tsview-mongo-db-overview.ts
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { apiService, type IDatabaseStats } from '../services/index.js';
|
||||||
|
import { formatSize, formatCount } from '../utilities/index.js';
|
||||||
|
import { themeStyles } from '../styles/index.js';
|
||||||
|
|
||||||
|
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||||
|
|
||||||
|
@customElement('tsview-mongo-db-overview')
|
||||||
|
export class TsviewMongoDbOverview extends DeesElement {
|
||||||
|
@property({ type: String })
|
||||||
|
public accessor databaseName: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor stats: IDatabaseStats | null = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor loading: boolean = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor error: string = '';
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
themeStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title svg {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-subtitle {
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.small {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-description {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #f87171;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
updated(changedProperties: Map<string, unknown>) {
|
||||||
|
if (changedProperties.has('databaseName') && this.databaseName) {
|
||||||
|
this.loadStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
if (this.databaseName) {
|
||||||
|
await this.loadStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadStats() {
|
||||||
|
if (!this.databaseName) return;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.stats = await apiService.getDatabaseStats(this.databaseName);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading database stats:', err);
|
||||||
|
this.error = 'Failed to load database statistics';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.databaseName) {
|
||||||
|
return html`
|
||||||
|
<div class="overview-container">
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
|
||||||
|
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
|
||||||
|
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
|
||||||
|
</svg>
|
||||||
|
<p>Select a database to view overview</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.loading) {
|
||||||
|
return html`
|
||||||
|
<div class="overview-container">
|
||||||
|
<div class="loading-state">Loading database statistics...</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.error) {
|
||||||
|
return html`
|
||||||
|
<div class="overview-container">
|
||||||
|
<div class="error-state">${this.error}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.stats) {
|
||||||
|
return html`
|
||||||
|
<div class="overview-container">
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No statistics available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="overview-container">
|
||||||
|
<div class="header">
|
||||||
|
<h1 class="header-title">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
|
||||||
|
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
|
||||||
|
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
|
||||||
|
</svg>
|
||||||
|
${this.databaseName}
|
||||||
|
</h1>
|
||||||
|
<p class="header-subtitle">Database Overview</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Storage</div>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Collections</span>
|
||||||
|
<span class="stat-value">${this.stats.collections}</span>
|
||||||
|
<span class="stat-description">Total collections in database</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Documents</span>
|
||||||
|
<span class="stat-value">${formatCount(this.stats.objects) || this.stats.objects}</span>
|
||||||
|
<span class="stat-description">Total documents stored</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Avg Document Size</span>
|
||||||
|
<span class="stat-value small">${formatSize(this.stats.avgObjSize)}</span>
|
||||||
|
<span class="stat-description">Average size per document</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Data Size</span>
|
||||||
|
<span class="stat-value small">${formatSize(this.stats.dataSize)}</span>
|
||||||
|
<span class="stat-description">Uncompressed data size</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Storage Size</span>
|
||||||
|
<span class="stat-value small">${formatSize(this.stats.storageSize)}</span>
|
||||||
|
<span class="stat-description">Allocated storage</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Indexes</div>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Index Count</span>
|
||||||
|
<span class="stat-value">${this.stats.indexes}</span>
|
||||||
|
<span class="stat-description">Total indexes in database</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Index Size</span>
|
||||||
|
<span class="stat-value small">${formatSize(this.stats.indexSize)}</span>
|
||||||
|
<span class="stat-description">Total index storage</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,10 @@ export class TsviewMongoDocument extends DeesElement {
|
|||||||
private accessor editing: boolean = false;
|
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
|
||||||
? html`
|
? this.hasChanges
|
||||||
<button class="action-btn" @click=${this.cancelEditing}>Cancel</button>
|
? html`
|
||||||
<button class="action-btn primary" @click=${this.saveDocument}>Save</button>
|
<button class="action-btn" @click=${this.handleDiscard}>Discard</button>
|
||||||
`
|
<button class="action-btn primary" @click=${this.saveDocument}>Save</button>
|
||||||
|
`
|
||||||
|
: html`
|
||||||
|
<button class="action-btn" @click=${this.cancelEditing}>Cancel</button>
|
||||||
|
`
|
||||||
: html`
|
: 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>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { apiService } from '../services/index.js';
|
|||||||
import { themeStyles } from '../styles/index.js';
|
import { themeStyles } from '../styles/index.js';
|
||||||
|
|
||||||
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||||
|
const { DeesContextmenu } = plugins.deesCatalog;
|
||||||
|
|
||||||
@customElement('tsview-mongo-documents')
|
@customElement('tsview-mongo-documents')
|
||||||
export class TsviewMongoDocuments extends DeesElement {
|
export class TsviewMongoDocuments extends DeesElement {
|
||||||
@@ -330,6 +331,74 @@ export class TsviewMongoDocuments extends DeesElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleDocumentContextMenu(event: MouseEvent, doc: Record<string, unknown>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const docId = doc._id as string;
|
||||||
|
|
||||||
|
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||||
|
{
|
||||||
|
name: 'View/Edit',
|
||||||
|
iconName: 'lucide:edit',
|
||||||
|
action: async () => {
|
||||||
|
this.selectDocument(doc);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Copy as JSON',
|
||||||
|
iconName: 'lucide:copy',
|
||||||
|
action: async () => {
|
||||||
|
await navigator.clipboard.writeText(JSON.stringify(doc, null, 2));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Duplicate',
|
||||||
|
iconName: 'lucide:copyPlus',
|
||||||
|
action: async () => {
|
||||||
|
const { _id, ...docWithoutId } = doc;
|
||||||
|
const newDoc = { ...docWithoutId, 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 duplicating document:', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ divider: true },
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
action: async () => {
|
||||||
|
if (confirm(`Delete document "${docId}"?`)) {
|
||||||
|
const result = await apiService.deleteDocument(
|
||||||
|
this.databaseName,
|
||||||
|
this.collectionName,
|
||||||
|
docId
|
||||||
|
);
|
||||||
|
if (result.success) {
|
||||||
|
await this.loadDocuments();
|
||||||
|
if (this.selectedId === docId) {
|
||||||
|
this.selectedId = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const startRecord = (this.page - 1) * this.pageSize + 1;
|
const startRecord = (this.page - 1) * this.pageSize + 1;
|
||||||
const endRecord = Math.min(this.page * this.pageSize, this.total);
|
const endRecord = Math.min(this.page * this.pageSize, this.total);
|
||||||
@@ -362,6 +431,7 @@ export class TsviewMongoDocuments extends DeesElement {
|
|||||||
<div
|
<div
|
||||||
class="document-row ${this.selectedId === doc._id ? 'selected' : ''}"
|
class="document-row ${this.selectedId === doc._id ? 'selected' : ''}"
|
||||||
@click=${() => this.selectDocument(doc)}
|
@click=${() => this.selectDocument(doc)}
|
||||||
|
@contextmenu=${(e: MouseEvent) => this.handleDocumentContextMenu(e, doc)}
|
||||||
>
|
>
|
||||||
<div class="document-id">_id: ${doc._id}</div>
|
<div class="document-id">_id: ${doc._id}</div>
|
||||||
<div class="document-preview">${this.getDocumentPreview(doc)}</div>
|
<div class="document-preview">${this.getDocumentPreview(doc)}</div>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getFileName } from '../utilities/index.js';
|
|||||||
import { themeStyles } from '../styles/index.js';
|
import { themeStyles } from '../styles/index.js';
|
||||||
|
|
||||||
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||||
|
const { DeesContextmenu } = plugins.deesCatalog;
|
||||||
|
|
||||||
interface IColumn {
|
interface IColumn {
|
||||||
prefix: string;
|
prefix: string;
|
||||||
@@ -30,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;
|
||||||
@@ -169,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;
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -318,6 +429,221 @@ export class TsviewS3Columns extends DeesElement {
|
|||||||
return iconMap[ext] || '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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleFolderContextMenu(event: MouseEvent, columnIndex: number, prefix: string) {
|
||||||
|
event.preventDefault();
|
||||||
|
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||||
|
{
|
||||||
|
name: 'Open',
|
||||||
|
iconName: 'lucide:folderOpen',
|
||||||
|
action: async () => {
|
||||||
|
this.selectFolder(columnIndex, prefix);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Copy Path',
|
||||||
|
iconName: 'lucide:copy',
|
||||||
|
action: async () => {
|
||||||
|
await navigator.clipboard.writeText(prefix);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ 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',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
action: async () => {
|
||||||
|
if (confirm(`Delete folder "${getFileName(prefix)}" and all its contents?`)) {
|
||||||
|
const success = await apiService.deletePrefix(this.bucketName, prefix);
|
||||||
|
if (success) {
|
||||||
|
await this.loadInitialColumn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleFileContextMenu(event: MouseEvent, columnIndex: number, key: string) {
|
||||||
|
event.preventDefault();
|
||||||
|
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||||
|
{
|
||||||
|
name: 'Preview',
|
||||||
|
iconName: 'lucide:eye',
|
||||||
|
action: async () => {
|
||||||
|
this.selectFile(columnIndex, key);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Download',
|
||||||
|
iconName: 'lucide:download',
|
||||||
|
action: async () => {
|
||||||
|
const url = await apiService.getObjectUrl(this.bucketName, key);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = getFileName(key);
|
||||||
|
link.click();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Copy Path',
|
||||||
|
iconName: 'lucide:copy',
|
||||||
|
action: async () => {
|
||||||
|
await navigator.clipboard.writeText(key);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ divider: true },
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
action: async () => {
|
||||||
|
if (confirm(`Delete file "${getFileName(key)}"?`)) {
|
||||||
|
const success = await apiService.deleteObject(this.bucketName, key);
|
||||||
|
if (success) {
|
||||||
|
await this.loadInitialColumn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>`;
|
||||||
@@ -327,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()}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,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>`
|
||||||
: ''}
|
: ''}
|
||||||
@@ -361,6 +688,7 @@ export class TsviewS3Columns extends DeesElement {
|
|||||||
<div
|
<div
|
||||||
class="column-item folder ${column.selectedItem === prefix ? 'selected' : ''}"
|
class="column-item folder ${column.selectedItem === prefix ? 'selected' : ''}"
|
||||||
@click=${() => this.selectFolder(index, prefix)}
|
@click=${() => this.selectFolder(index, prefix)}
|
||||||
|
@contextmenu=${(e: MouseEvent) => this.handleFolderContextMenu(e, index, prefix)}
|
||||||
>
|
>
|
||||||
<svg class="icon" viewBox="0 0 24 24" fill="currentColor">
|
<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" />
|
<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" />
|
||||||
@@ -377,6 +705,7 @@ export class TsviewS3Columns extends DeesElement {
|
|||||||
<div
|
<div
|
||||||
class="column-item ${column.selectedItem === obj.key ? 'selected' : ''}"
|
class="column-item ${column.selectedItem === obj.key ? 'selected' : ''}"
|
||||||
@click=${() => this.selectFile(index, obj.key)}
|
@click=${() => this.selectFile(index, obj.key)}
|
||||||
|
@contextmenu=${(e: MouseEvent) => this.handleFileContextMenu(e, index, obj.key)}
|
||||||
>
|
>
|
||||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="${this.getFileIcon(obj.key)}" />
|
<path d="${this.getFileIcon(obj.key)}" />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { formatSize, getFileName } from '../utilities/index.js';
|
|||||||
import { themeStyles } from '../styles/index.js';
|
import { themeStyles } from '../styles/index.js';
|
||||||
|
|
||||||
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
|
||||||
|
const { DeesContextmenu } = plugins.deesCatalog;
|
||||||
|
|
||||||
@customElement('tsview-s3-keys')
|
@customElement('tsview-s3-keys')
|
||||||
export class TsviewS3Keys extends DeesElement {
|
export class TsviewS3Keys extends DeesElement {
|
||||||
@@ -31,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,
|
||||||
@@ -145,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;
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -210,6 +321,218 @@ export class TsviewS3Keys extends DeesElement {
|
|||||||
return [...folders, ...files];
|
return [...folders, ...files];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleItemContextMenu(event: MouseEvent, key: string, isFolder: boolean) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (isFolder) {
|
||||||
|
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||||
|
{
|
||||||
|
name: 'Open',
|
||||||
|
iconName: 'lucide:folderOpen',
|
||||||
|
action: async () => {
|
||||||
|
this.selectKey(key, true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Copy Path',
|
||||||
|
iconName: 'lucide:copy',
|
||||||
|
action: async () => {
|
||||||
|
await navigator.clipboard.writeText(key);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ 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',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
action: async () => {
|
||||||
|
if (confirm(`Delete folder "${getFileName(key)}" and all its contents?`)) {
|
||||||
|
const success = await apiService.deletePrefix(this.bucketName, key);
|
||||||
|
if (success) {
|
||||||
|
await this.loadObjects();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||||
|
{
|
||||||
|
name: 'Preview',
|
||||||
|
iconName: 'lucide:eye',
|
||||||
|
action: async () => {
|
||||||
|
this.selectKey(key, false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Download',
|
||||||
|
iconName: 'lucide:download',
|
||||||
|
action: async () => {
|
||||||
|
const url = await apiService.getObjectUrl(this.bucketName, key);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = getFileName(key);
|
||||||
|
link.click();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Copy Path',
|
||||||
|
iconName: 'lucide:copy',
|
||||||
|
action: async () => {
|
||||||
|
await navigator.clipboard.writeText(key);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ divider: true },
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
action: async () => {
|
||||||
|
if (confirm(`Delete file "${getFileName(key)}"?`)) {
|
||||||
|
const success = await apiService.deleteObject(this.bucketName, key);
|
||||||
|
if (success) {
|
||||||
|
await this.loadObjects();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
@@ -223,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
|
||||||
@@ -242,6 +565,7 @@ export class TsviewS3Keys extends DeesElement {
|
|||||||
<tr
|
<tr
|
||||||
class="${this.selectedKey === item.key ? 'selected' : ''}"
|
class="${this.selectedKey === item.key ? 'selected' : ''}"
|
||||||
@click=${() => this.selectKey(item.key, item.isFolder)}
|
@click=${() => this.selectKey(item.key, item.isFolder)}
|
||||||
|
@contextmenu=${(e: MouseEvent) => this.handleItemContextMenu(e, item.key, item.isFolder)}
|
||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
<div class="key-cell">
|
<div class="key-cell">
|
||||||
@@ -270,6 +594,7 @@ export class TsviewS3Keys extends DeesElement {
|
|||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
${this.renderCreateDialog()}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
<button class="action-btn" @click=${this.handleDownload}>Download</button>
|
${this.hasChanges ? html`
|
||||||
<button class="action-btn danger" @click=${this.handleDelete}>Delete</button>
|
<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 danger" @click=${this.handleDelete}>Delete</button>
|
||||||
|
`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
@@ -128,6 +139,22 @@ export class ApiService {
|
|||||||
return result.success;
|
return result.success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deletePrefix(bucketName: string, prefix: string): Promise<boolean> {
|
||||||
|
const result = await this.request<
|
||||||
|
{ bucketName: string; prefix: string },
|
||||||
|
{ success: boolean }
|
||||||
|
>('deletePrefix', { bucketName, prefix });
|
||||||
|
return result.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getObjectUrl(bucketName: string, key: string): Promise<string> {
|
||||||
|
const result = await this.request<
|
||||||
|
{ bucketName: string; key: string },
|
||||||
|
{ url: string }
|
||||||
|
>('getObjectUrl', { bucketName, key });
|
||||||
|
return result.url;
|
||||||
|
}
|
||||||
|
|
||||||
async copyObject(
|
async copyObject(
|
||||||
sourceBucket: string,
|
sourceBucket: string,
|
||||||
sourceKey: string,
|
sourceKey: string,
|
||||||
@@ -328,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user