feat(s3): add S3 create file/folder dialogs and in-place text editor; export mongodb plugin

This commit is contained in:
2026-01-25 12:56:56 +00:00
parent 07010376cb
commit 349b43612e
12 changed files with 860 additions and 20 deletions

View File

@@ -1,5 +1,15 @@
# Changelog # Changelog
## 2026-01-25 - 1.3.0 - feat(s3)
add S3 create file/folder dialogs and in-place text editor; export mongodb plugin
- Add mongodb dependency and export mongodb in ts/plugins.ts so ObjectId can be reused from plugins.
- Update handlers.mongodb to use plugins.mongodb.ObjectId instead of requiring mongodb directly.
- UI: Add create-file and create-folder dialogs and context-menu entries in tsview-app, tsview-s3-columns, and tsview-s3-keys to create objects (folders use a .keep object).
- Implement client-side helpers to determine content type/default content and call apiService.putObject with base64 content when creating files/folders.
- S3 preview: embed dees-input-code editor for text files with language detection, unsaved-changes indicator, Save/Discard flows, and saving via apiService.putObject.
- Various styling and UX improvements for dialogs, buttons, and editor states.
## 2026-01-25 - 1.2.0 - feat(s3,web-ui) ## 2026-01-25 - 1.2.0 - feat(s3,web-ui)
add S3 deletePrefix and getObjectUrl endpoints and add context menus in UI for S3 and Mongo views add S3 deletePrefix and getObjectUrl endpoints and add context menus in UI for S3 and Mongo views

View File

@@ -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/**/*",

3
pnpm-lock.yaml generated
View File

@@ -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

View File

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

View File

@@ -21,16 +21,10 @@ export async function registerMongoHandlers(
// Helper to create ObjectId filter // Helper to create ObjectId filter
const createIdFilter = (documentId: string) => { const createIdFilter = (documentId: string) => {
// Try to treat as ObjectId string - MongoDB driver will handle conversion const { ObjectId } = plugins.mongodb;
try {
// Import ObjectId from the mongodb package that smartdata uses
const { ObjectId } = require('mongodb');
if (ObjectId.isValid(documentId)) { if (ObjectId.isValid(documentId)) {
return { _id: new ObjectId(documentId) }; return { _id: new ObjectId(documentId) };
} }
} catch {
// Fall through to string filter
}
return { _id: documentId }; return { _id: documentId };
}; };

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -45,6 +45,18 @@ export class TsviewApp extends DeesElement {
@state() @state()
private accessor newDatabaseName: string = ''; private accessor newDatabaseName: string = '';
@state()
private accessor showS3CreateDialog: boolean = false;
@state()
private accessor s3CreateDialogType: 'folder' | 'file' = 'folder';
@state()
private accessor s3CreateDialogBucket: string = '';
@state()
private accessor s3CreateDialogName: string = '';
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
themeStyles, themeStyles,
@@ -297,6 +309,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;
@@ -483,6 +509,17 @@ export class TsviewApp extends DeesElement {
}, },
}, },
{ divider: true }, { divider: true },
{
name: 'New Folder',
iconName: 'lucide:folderPlus',
action: async () => this.openS3CreateDialog(bucket, 'folder'),
},
{
name: 'New File',
iconName: 'lucide:filePlus',
action: async () => this.openS3CreateDialog(bucket, 'file'),
},
{ divider: true },
{ {
name: 'Delete Bucket', name: 'Delete Bucket',
iconName: 'lucide:trash2', iconName: 'lucide:trash2',
@@ -501,6 +538,72 @@ export class TsviewApp extends DeesElement {
]); ]);
} }
private openS3CreateDialog(bucket: string, type: 'folder' | 'file') {
this.s3CreateDialogBucket = bucket;
this.s3CreateDialogType = type;
this.s3CreateDialogName = '';
this.showS3CreateDialog = true;
}
private getContentType(ext: string): string {
const contentTypes: Record<string, string> = {
json: 'application/json',
txt: 'text/plain',
html: 'text/html',
css: 'text/css',
js: 'application/javascript',
ts: 'text/typescript',
md: 'text/markdown',
xml: 'application/xml',
yaml: 'text/yaml',
yml: 'text/yaml',
csv: 'text/csv',
};
return contentTypes[ext] || 'application/octet-stream';
}
private getDefaultContent(ext: string): string {
const defaults: Record<string, string> = {
json: '{\n \n}',
html: '<!DOCTYPE html>\n<html>\n<head>\n <title></title>\n</head>\n<body>\n \n</body>\n</html>',
md: '# Title\n\n',
txt: '',
};
return defaults[ext] || '';
}
private async handleS3Create() {
if (!this.s3CreateDialogName.trim()) return;
const name = this.s3CreateDialogName.trim();
let path: string;
if (this.s3CreateDialogType === 'folder') {
path = name + '/.keep';
} else {
path = name;
}
const ext = name.split('.').pop()?.toLowerCase() || '';
const contentType = this.s3CreateDialogType === 'file' ? this.getContentType(ext) : 'application/octet-stream';
const content = this.s3CreateDialogType === 'file' ? this.getDefaultContent(ext) : '';
const success = await apiService.putObject(
this.s3CreateDialogBucket,
path,
btoa(content),
contentType
);
if (success) {
this.showS3CreateDialog = false;
// Select the bucket to show the new content
this.selectedBucket = this.s3CreateDialogBucket;
// Trigger a refresh by dispatching an event
this.requestUpdate();
}
}
private handleDatabaseContextMenu(event: MouseEvent, dbName: string) { private handleDatabaseContextMenu(event: MouseEvent, dbName: string) {
event.preventDefault(); event.preventDefault();
DeesContextmenu.openContextMenuWithOptions(event, [ DeesContextmenu.openContextMenuWithOptions(event, [
@@ -574,6 +677,7 @@ export class TsviewApp extends DeesElement {
${this.renderCreateBucketDialog()} ${this.renderCreateBucketDialog()}
${this.renderCreateCollectionDialog()} ${this.renderCreateCollectionDialog()}
${this.renderCreateDatabaseDialog()} ${this.renderCreateDatabaseDialog()}
${this.renderS3CreateDialog()}
`; `;
} }
@@ -670,6 +774,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`

View File

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

View File

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

View File

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