feat(tsview): add database and S3 handlers, tswatch/watch scripts, web utilities, assets and release config

This commit is contained in:
2026-01-25 11:02:53 +00:00
parent cf07f8cad9
commit afc32f3578
52 changed files with 1078 additions and 237 deletions

View File

@@ -1,5 +1,6 @@
import * as plugins from '../plugins.js';
import { apiService } from '../services/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, state, DeesElement } = plugins;
@@ -37,8 +38,15 @@ export class TsviewApp extends DeesElement {
@state()
private accessor newCollectionName: string = '';
@state()
private accessor showCreateDatabaseDialog: boolean = false;
@state()
private accessor newDatabaseName: string = '';
public static styles = [
cssManager.defaultStyles,
themeStyles,
css`
:host {
display: block;
@@ -47,7 +55,7 @@ export class TsviewApp extends DeesElement {
left: 0;
right: 0;
bottom: 0;
background: #1a1a2e;
background: var(--tsview-bg-primary, #1a1a1a);
color: #eee;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
@@ -59,7 +67,7 @@ export class TsviewApp extends DeesElement {
}
.app-header {
background: #16162a;
background: #141414;
border-bottom: 1px solid #333;
display: flex;
align-items: center;
@@ -103,8 +111,8 @@ export class TsviewApp extends DeesElement {
}
.nav-tab.active {
background: rgba(99, 102, 241, 0.2);
color: #818cf8;
background: rgba(255, 255, 255, 0.1);
color: #e0e0e0;
}
.app-main {
@@ -114,7 +122,7 @@ export class TsviewApp extends DeesElement {
}
.sidebar {
background: #1e1e38;
background: #1e1e1e;
border-right: 1px solid #333;
overflow-y: auto;
}
@@ -148,8 +156,8 @@ export class TsviewApp extends DeesElement {
}
.sidebar-item.selected {
background: rgba(99, 102, 241, 0.2);
color: #818cf8;
background: rgba(255, 255, 255, 0.1);
color: #e0e0e0;
}
.sidebar-item .count {
@@ -219,8 +227,8 @@ export class TsviewApp extends DeesElement {
}
.collection-item.selected {
background: rgba(99, 102, 241, 0.15);
color: #818cf8;
background: rgba(255, 255, 255, 0.08);
color: #e0e0e0;
}
.create-btn {
@@ -229,18 +237,18 @@ export class TsviewApp extends DeesElement {
gap: 6px;
padding: 8px 12px;
margin: 8px;
background: rgba(99, 102, 241, 0.2);
border: 1px dashed rgba(99, 102, 241, 0.4);
background: rgba(255, 255, 255, 0.1);
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: #818cf8;
color: #e0e0e0;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.create-btn:hover {
background: rgba(99, 102, 241, 0.3);
border-color: rgba(99, 102, 241, 0.6);
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
.dialog-overlay {
@@ -257,7 +265,7 @@ export class TsviewApp extends DeesElement {
}
.dialog {
background: #1e1e38;
background: #1e1e1e;
border-radius: 12px;
padding: 24px;
min-width: 400px;
@@ -274,7 +282,7 @@ export class TsviewApp extends DeesElement {
.dialog-input {
width: 100%;
padding: 10px 12px;
background: #16162a;
background: #141414;
border: 1px solid #333;
border-radius: 6px;
color: #fff;
@@ -285,7 +293,7 @@ export class TsviewApp extends DeesElement {
.dialog-input:focus {
outline: none;
border-color: #818cf8;
border-color: #e0e0e0;
}
.dialog-actions {
@@ -314,19 +322,67 @@ export class TsviewApp extends DeesElement {
}
.dialog-btn-create {
background: #6366f1;
background: #404040;
border: none;
color: #fff;
}
.dialog-btn-create:hover {
background: #5558e8;
background: #505050;
}
.dialog-btn-create:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dialog-btn-delete {
background: rgba(239, 68, 68, 0.2);
border: 1px solid #ef4444;
color: #f87171;
}
.dialog-btn-delete:hover {
background: rgba(239, 68, 68, 0.3);
}
.sidebar-item-content {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.sidebar-item-name {
overflow: hidden;
text-overflow: ellipsis;
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;
}
`,
];
@@ -392,6 +448,53 @@ export class TsviewApp extends DeesElement {
}
}
private async createDatabase() {
if (!this.newDatabaseName.trim()) return;
const success = await apiService.createDatabase(this.newDatabaseName.trim());
if (success) {
this.databases = [...this.databases, { name: this.newDatabaseName.trim() }];
this.newDatabaseName = '';
this.showCreateDatabaseDialog = false;
}
}
private async deleteBucket(bucket: string, e: Event) {
e.stopPropagation();
if (!confirm(`Delete bucket "${bucket}"? This will delete all objects in the bucket.`)) return;
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 async deleteDatabase(dbName: string, e: Event) {
e.stopPropagation();
if (!confirm(`Delete database "${dbName}"? This will delete all collections and documents.`)) return;
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 async deleteCollection(dbName: string, collectionName: string) {
if (!confirm(`Delete collection "${collectionName}"? This will delete all documents.`)) return;
const success = await apiService.dropCollection(dbName, collectionName);
if (success) {
if (this.selectedCollection === collectionName) {
this.selectedCollection = '';
}
// Force refresh of the collections list
this.requestUpdate();
}
}
render() {
return html`
<div class="app-container">
@@ -433,6 +536,7 @@ export class TsviewApp extends DeesElement {
</div>
${this.renderCreateBucketDialog()}
${this.renderCreateCollectionDialog()}
${this.renderCreateDatabaseDialog()}
`;
}
@@ -498,6 +602,37 @@ export class TsviewApp extends DeesElement {
`;
}
private renderCreateDatabaseDialog() {
if (!this.showCreateDatabaseDialog) return '';
return html`
<div class="dialog-overlay" @click=${() => this.showCreateDatabaseDialog = false}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog-title">Create New Database</div>
<input
type="text"
class="dialog-input"
placeholder="Database name"
.value=${this.newDatabaseName}
@input=${(e: InputEvent) => this.newDatabaseName = (e.target as HTMLInputElement).value}
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.createDatabase()}
/>
<div class="dialog-actions">
<button class="dialog-btn dialog-btn-cancel" @click=${() => this.showCreateDatabaseDialog = false}>
Cancel
</button>
<button
class="dialog-btn dialog-btn-create"
?disabled=${!this.newDatabaseName.trim()}
@click=${() => this.createDatabase()}
>
Create
</button>
</div>
</div>
</div>
`;
}
private renderSidebar() {
if (this.viewMode === 's3') {
return html`
@@ -519,7 +654,13 @@ export class TsviewApp extends DeesElement {
class="sidebar-item ${bucket === this.selectedBucket ? 'selected' : ''}"
@click=${() => this.selectBucket(bucket)}
>
${bucket}
<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>
`
)}
@@ -532,6 +673,13 @@ export class TsviewApp extends DeesElement {
return html`
<aside class="sidebar">
<div class="sidebar-header">Databases & Collections</div>
<button class="create-btn" @click=${() => this.showCreateDatabaseDialog = true}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
New Database
</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">
@@ -573,7 +721,13 @@ export class TsviewApp extends DeesElement {
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
</svg>
${db.name}
<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>
${this.selectedDatabase === db.name ? this.renderCollectionsList(db.name) : ''}
</div>
@@ -586,10 +740,18 @@ export class TsviewApp extends DeesElement {
.databaseName=${dbName}
.selectedCollection=${this.selectedCollection}
@collection-selected=${(e: CustomEvent) => this.selectCollection(e.detail)}
@collection-deleted=${(e: CustomEvent) => this.handleCollectionDeleted(e)}
></tsview-mongo-collections>
`;
}
private handleCollectionDeleted(e: CustomEvent) {
const { collectionName } = e.detail;
if (this.selectedCollection === collectionName) {
this.selectedCollection = '';
}
}
private renderContent() {
if (this.viewMode === 's3') {
if (!this.selectedBucket) {

View File

@@ -1,5 +1,7 @@
import * as plugins from '../plugins.js';
import { apiService, type ICollectionStats } 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;
@@ -24,6 +26,7 @@ export class TsviewMongoBrowser extends DeesElement {
public static styles = [
cssManager.defaultStyles,
themeStyles,
css`
:host {
display: block;
@@ -92,8 +95,8 @@ export class TsviewMongoBrowser extends DeesElement {
}
.tab.active {
background: rgba(99, 102, 241, 0.2);
color: #818cf8;
background: rgba(255, 255, 255, 0.1);
color: #e0e0e0;
}
.content {
@@ -159,23 +162,6 @@ export class TsviewMongoBrowser extends DeesElement {
this.selectedDocumentId = e.detail.documentId;
}
private formatCount(num: number): string {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num.toString();
}
private formatSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(unitIndex > 0 ? 1 : 0)} ${units[unitIndex]}`;
}
render() {
return html`
<div class="browser-container">
@@ -185,8 +171,8 @@ export class TsviewMongoBrowser extends DeesElement {
${this.stats
? html`
<div class="collection-stats">
<span class="stat-item">${this.formatCount(this.stats.count)} docs</span>
<span class="stat-item">${this.formatSize(this.stats.size)}</span>
<span class="stat-item">${formatCount(this.stats.count)} docs</span>
<span class="stat-item">${formatSize(this.stats.size)}</span>
<span class="stat-item">${this.stats.indexCount} indexes</span>
</div>
`

View File

@@ -1,8 +1,16 @@
import * as plugins from '../plugins.js';
import { apiService, type IMongoCollection } from '../services/index.js';
import { formatCount } from '../utilities/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
declare global {
interface HTMLElementEventMap {
'collection-deleted': CustomEvent<{ databaseName: string; collectionName: string }>;
}
}
@customElement('tsview-mongo-collections')
export class TsviewMongoCollections extends DeesElement {
@property({ type: String })
@@ -19,6 +27,7 @@ export class TsviewMongoCollections extends DeesElement {
public static styles = [
cssManager.defaultStyles,
themeStyles,
css`
:host {
display: block;
@@ -44,8 +53,8 @@ export class TsviewMongoCollections extends DeesElement {
}
.collection-item.selected {
background: rgba(99, 102, 241, 0.15);
color: #818cf8;
background: rgba(255, 255, 255, 0.08);
color: #e0e0e0;
}
.collection-name {
@@ -80,6 +89,29 @@ export class TsviewMongoCollections extends DeesElement {
font-size: 12px;
font-style: italic;
}
.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;
}
.collection-item:hover .delete-btn {
opacity: 1;
}
.delete-btn:hover {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
`,
];
@@ -117,11 +149,25 @@ export class TsviewMongoCollections extends DeesElement {
);
}
private formatCount(count?: number): string {
if (count === undefined) return '';
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return count.toString();
private async deleteCollection(name: string, e: Event) {
e.stopPropagation();
if (!confirm(`Delete collection "${name}"? This will delete all documents.`)) return;
const success = await apiService.dropCollection(this.databaseName, name);
if (success) {
this.collections = this.collections.filter(c => c.name !== name);
this.dispatchEvent(
new CustomEvent('collection-deleted', {
detail: { databaseName: this.databaseName, collectionName: name },
bubbles: true,
composed: true,
})
);
}
}
public async refresh() {
await this.loadCollections();
}
render() {
@@ -147,9 +193,17 @@ export class TsviewMongoCollections extends DeesElement {
</svg>
${coll.name}
</span>
${coll.count !== undefined
? html`<span class="collection-count">${this.formatCount(coll.count)}</span>`
: ''}
<span style="display: flex; align-items: center; gap: 4px;">
${coll.count !== undefined
? html`<span class="collection-count">${formatCount(coll.count)}</span>`
: ''}
<button class="delete-btn" @click=${(e: Event) => this.deleteCollection(coll.name, e)} title="Delete collection">
<svg width="12" height="12" 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>
</span>
</div>
`
)}

View File

@@ -1,5 +1,6 @@
import * as plugins from '../plugins.js';
import { apiService } from '../services/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
@@ -31,6 +32,7 @@ export class TsviewMongoDocument extends DeesElement {
public static styles = [
cssManager.defaultStyles,
themeStyles,
css`
:host {
display: block;
@@ -78,13 +80,13 @@ export class TsviewMongoDocument extends DeesElement {
}
.action-btn.primary {
background: rgba(99, 102, 241, 0.2);
border-color: #6366f1;
color: #818cf8;
background: rgba(255, 255, 255, 0.1);
border-color: #404040;
color: #e0e0e0;
}
.action-btn.primary:hover {
background: rgba(99, 102, 241, 0.3);
background: rgba(255, 255, 255, 0.15);
}
.action-btn.danger {
@@ -113,7 +115,7 @@ export class TsviewMongoDocument extends DeesElement {
}
.json-key {
color: #818cf8;
color: #e0e0e0;
}
.json-string {
@@ -148,7 +150,7 @@ export class TsviewMongoDocument extends DeesElement {
.edit-area:focus {
outline: none;
border-color: #6366f1;
border-color: #404040;
}
.empty-state {

View File

@@ -1,5 +1,6 @@
import * as plugins from '../plugins.js';
import { apiService } from '../services/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
@@ -34,6 +35,7 @@ export class TsviewMongoDocuments extends DeesElement {
public static styles = [
cssManager.defaultStyles,
themeStyles,
css`
:host {
display: block;
@@ -67,7 +69,7 @@ export class TsviewMongoDocuments extends DeesElement {
.filter-input:focus {
outline: none;
border-color: #6366f1;
border-color: #404040;
}
.filter-input::placeholder {
@@ -76,16 +78,16 @@ export class TsviewMongoDocuments extends DeesElement {
.filter-btn {
padding: 8px 16px;
background: rgba(99, 102, 241, 0.2);
border: 1px solid #6366f1;
color: #818cf8;
background: rgba(255, 255, 255, 0.1);
border: 1px solid #404040;
color: #e0e0e0;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.filter-btn:hover {
background: rgba(99, 102, 241, 0.3);
background: rgba(255, 255, 255, 0.15);
}
.documents-list {
@@ -108,13 +110,13 @@ export class TsviewMongoDocuments extends DeesElement {
}
.document-row.selected {
background: rgba(99, 102, 241, 0.15);
border: 1px solid rgba(99, 102, 241, 0.3);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.document-id {
font-size: 12px;
color: #818cf8;
color: #e0e0e0;
font-family: monospace;
margin-bottom: 4px;
}

View File

@@ -1,5 +1,6 @@
import * as plugins from '../plugins.js';
import { apiService, type IMongoIndex } from '../services/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
@@ -31,6 +32,7 @@ export class TsviewMongoIndexes extends DeesElement {
public static styles = [
cssManager.defaultStyles,
themeStyles,
css`
:host {
display: block;
@@ -106,8 +108,8 @@ export class TsviewMongoIndexes extends DeesElement {
}
.badge.unique {
background: rgba(99, 102, 241, 0.2);
color: #818cf8;
background: rgba(255, 255, 255, 0.1);
color: #e0e0e0;
}
.badge.sparse {
@@ -181,7 +183,7 @@ export class TsviewMongoIndexes extends DeesElement {
}
.dialog {
background: #1e1e38;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 12px;
padding: 24px;
@@ -219,7 +221,7 @@ export class TsviewMongoIndexes extends DeesElement {
.dialog-input:focus {
outline: none;
border-color: #6366f1;
border-color: #404040;
}
.dialog-checkbox {
@@ -255,13 +257,13 @@ export class TsviewMongoIndexes extends DeesElement {
}
.dialog-btn.primary {
background: rgba(99, 102, 241, 0.2);
border: 1px solid #6366f1;
color: #818cf8;
background: rgba(255, 255, 255, 0.1);
border: 1px solid #404040;
color: #e0e0e0;
}
.dialog-btn.primary:hover {
background: rgba(99, 102, 241, 0.3);
background: rgba(255, 255, 255, 0.15);
}
`,
];

View File

@@ -1,5 +1,6 @@
import * as plugins from '../plugins.js';
import { apiService } from '../services/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
@@ -19,8 +20,12 @@ export class TsviewS3Browser extends DeesElement {
@state()
private accessor selectedKey: string = '';
@state()
private accessor refreshKey: number = 0;
public static styles = [
cssManager.defaultStyles,
themeStyles,
css`
:host {
display: block;
@@ -90,19 +95,23 @@ export class TsviewS3Browser extends DeesElement {
}
.view-btn.active {
background: rgba(99, 102, 241, 0.2);
border-color: #6366f1;
color: #818cf8;
background: rgba(255, 255, 255, 0.1);
border-color: #404040;
color: #e0e0e0;
}
.content {
flex: 1;
display: grid;
grid-template-columns: 1fr 350px;
grid-template-columns: 1fr;
gap: 16px;
overflow: hidden;
}
.content.has-preview {
grid-template-columns: 1fr 350px;
}
.main-view {
overflow: auto;
background: rgba(0, 0, 0, 0.2);
@@ -116,7 +125,8 @@ export class TsviewS3Browser extends DeesElement {
}
@media (max-width: 1024px) {
.content {
.content,
.content.has-preview {
grid-template-columns: 1fr;
}
@@ -144,6 +154,20 @@ export class TsviewS3Browser extends DeesElement {
this.navigateToPrefix(e.detail.prefix);
}
private handleObjectDeleted(e: CustomEvent) {
this.selectedKey = '';
// Increment refresh key to trigger re-render of child components
this.refreshKey++;
}
updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('bucketName')) {
// Clear selection when bucket changes
this.selectedKey = '';
this.currentPrefix = '';
}
}
render() {
const breadcrumbParts = this.currentPrefix
? this.currentPrefix.split('/').filter(Boolean)
@@ -189,13 +213,14 @@ export class TsviewS3Browser extends DeesElement {
</div>
</div>
<div class="content">
<div class="content ${this.selectedKey ? 'has-preview' : ''}">
<div class="main-view">
${this.viewType === 'columns'
? html`
<tsview-s3-columns
.bucketName=${this.bucketName}
.currentPrefix=${this.currentPrefix}
.refreshKey=${this.refreshKey}
@key-selected=${this.handleKeySelected}
@navigate=${this.handleNavigate}
></tsview-s3-columns>
@@ -204,18 +229,24 @@ export class TsviewS3Browser extends DeesElement {
<tsview-s3-keys
.bucketName=${this.bucketName}
.currentPrefix=${this.currentPrefix}
.refreshKey=${this.refreshKey}
@key-selected=${this.handleKeySelected}
@navigate=${this.handleNavigate}
></tsview-s3-keys>
`}
</div>
<div class="preview-panel">
<tsview-s3-preview
.bucketName=${this.bucketName}
.objectKey=${this.selectedKey}
></tsview-s3-preview>
</div>
${this.selectedKey
? html`
<div class="preview-panel">
<tsview-s3-preview
.bucketName=${this.bucketName}
.objectKey=${this.selectedKey}
@object-deleted=${this.handleObjectDeleted}
></tsview-s3-preview>
</div>
`
: ''}
</div>
</div>
`;

View File

@@ -1,5 +1,7 @@
import * as plugins from '../plugins.js';
import { apiService, type IS3Object } from '../services/index.js';
import { getFileName } from '../utilities/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
@@ -19,6 +21,9 @@ export class TsviewS3Columns extends DeesElement {
@property({ type: String })
public accessor currentPrefix: string = '';
@property({ type: Number })
public accessor refreshKey: number = 0;
@state()
private accessor columns: IColumn[] = [];
@@ -32,6 +37,7 @@ export class TsviewS3Columns extends DeesElement {
public static styles = [
cssManager.defaultStyles,
themeStyles,
css`
:host {
display: block;
@@ -81,7 +87,7 @@ export class TsviewS3Columns extends DeesElement {
.resize-handle:hover::after,
.resize-handle.active::after {
background: #6366f1;
background: #404040;
width: 2px;
left: 1px;
}
@@ -124,8 +130,8 @@ export class TsviewS3Columns extends DeesElement {
}
.column-item.selected {
background: rgba(99, 102, 241, 0.2);
color: #818cf8;
background: rgba(255, 255, 255, 0.1);
color: #e0e0e0;
}
.column-item.folder {
@@ -172,9 +178,9 @@ export class TsviewS3Columns extends DeesElement {
}
updated(changedProperties: Map<string, unknown>) {
// Only reset columns when bucket changes
// Only reset columns when bucket changes or refresh is triggered
// Internal folder navigation is handled by selectFolder() which appends columns
if (changedProperties.has('bucketName')) {
if (changedProperties.has('bucketName') || changedProperties.has('refreshKey')) {
this.loadInitialColumn();
}
}
@@ -298,11 +304,6 @@ export class TsviewS3Columns extends DeesElement {
);
}
private getFileName(path: string): string {
const parts = path.replace(/\/$/, '').split('/');
return parts[parts.length - 1] || path;
}
private getFileIcon(key: string): string {
const ext = key.split('.').pop()?.toLowerCase() || '';
const iconMap: Record<string, string> = {
@@ -343,7 +344,7 @@ export class TsviewS3Columns extends DeesElement {
private renderColumn(column: IColumn, index: number) {
const headerName = column.prefix
? this.getFileName(column.prefix)
? getFileName(column.prefix)
: this.bucketName;
return html`
@@ -364,7 +365,7 @@ export class TsviewS3Columns extends DeesElement {
<svg class="icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
<span class="name">${this.getFileName(prefix)}</span>
<span class="name">${getFileName(prefix)}</span>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
@@ -380,7 +381,7 @@ export class TsviewS3Columns extends DeesElement {
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="${this.getFileIcon(obj.key)}" />
</svg>
<span class="name">${this.getFileName(obj.key)}</span>
<span class="name">${getFileName(obj.key)}</span>
</div>
`
)}

View File

@@ -1,5 +1,7 @@
import * as plugins from '../plugins.js';
import { apiService, type IS3Object } from '../services/index.js';
import { formatSize, getFileName } from '../utilities/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
@@ -11,6 +13,9 @@ export class TsviewS3Keys extends DeesElement {
@property({ type: String })
public accessor currentPrefix: string = '';
@property({ type: Number })
public accessor refreshKey: number = 0;
@state()
private accessor allKeys: IS3Object[] = [];
@@ -28,6 +33,7 @@ export class TsviewS3Keys extends DeesElement {
public static styles = [
cssManager.defaultStyles,
themeStyles,
css`
:host {
display: block;
@@ -58,7 +64,7 @@ export class TsviewS3Keys extends DeesElement {
.filter-input:focus {
outline: none;
border-color: #6366f1;
border-color: #404040;
}
.filter-input::placeholder {
@@ -78,7 +84,7 @@ export class TsviewS3Keys extends DeesElement {
thead {
position: sticky;
top: 0;
background: #1a1a2e;
background: #1a1a1a;
z-index: 1;
}
@@ -103,7 +109,7 @@ export class TsviewS3Keys extends DeesElement {
}
tr.selected td {
background: rgba(99, 102, 241, 0.15);
background: rgba(255, 255, 255, 0.08);
}
.key-cell {
@@ -148,7 +154,7 @@ export class TsviewS3Keys extends DeesElement {
}
updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('bucketName') || changedProperties.has('currentPrefix')) {
if (changedProperties.has('bucketName') || changedProperties.has('currentPrefix') || changedProperties.has('refreshKey')) {
this.loadObjects();
}
}
@@ -193,30 +199,13 @@ export class TsviewS3Keys extends DeesElement {
}
}
private getFileName(path: string): string {
const parts = path.replace(/\/$/, '').split('/');
return parts[parts.length - 1] || path;
}
private formatSize(bytes?: number): string {
if (!bytes) return '-';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(unitIndex > 0 ? 1 : 0)} ${units[unitIndex]}`;
}
private get filteredItems() {
const filter = this.filterText.toLowerCase();
const folders = this.prefixes
.filter((p) => !filter || this.getFileName(p).toLowerCase().includes(filter))
.filter((p) => !filter || getFileName(p).toLowerCase().includes(filter))
.map((p) => ({ key: p, isFolder: true, size: undefined }));
const files = this.allKeys
.filter((o) => !filter || this.getFileName(o.key).toLowerCase().includes(filter))
.filter((o) => !filter || getFileName(o.key).toLowerCase().includes(filter))
.map((o) => ({ key: o.key, isFolder: false, size: o.size }));
return [...folders, ...files];
}
@@ -267,11 +256,11 @@ export class TsviewS3Keys extends DeesElement {
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
</svg>
`}
<span class="key-name">${this.getFileName(item.key)}</span>
<span class="key-name">${getFileName(item.key)}</span>
</div>
</td>
<td class="size-cell">
${item.isFolder ? '-' : this.formatSize(item.size)}
${item.isFolder ? '-' : formatSize(item.size)}
</td>
</tr>
`

View File

@@ -1,5 +1,7 @@
import * as plugins from '../plugins.js';
import { apiService } from '../services/index.js';
import { formatSize, getFileName } from '../utilities/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
@@ -31,6 +33,7 @@ export class TsviewS3Preview extends DeesElement {
public static styles = [
cssManager.defaultStyles,
themeStyles,
css`
:host {
display: block;
@@ -104,9 +107,9 @@ export class TsviewS3Preview extends DeesElement {
.action-btn {
flex: 1;
padding: 8px 16px;
background: rgba(99, 102, 241, 0.2);
border: 1px solid #6366f1;
color: #818cf8;
background: rgba(255, 255, 255, 0.1);
border: 1px solid #404040;
color: #e0e0e0;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
@@ -114,7 +117,7 @@ export class TsviewS3Preview extends DeesElement {
}
.action-btn:hover {
background: rgba(99, 102, 241, 0.3);
background: rgba(255, 255, 255, 0.15);
}
.action-btn.danger {
@@ -174,6 +177,7 @@ export class TsviewS3Preview extends DeesElement {
} else {
this.content = '';
this.contentType = '';
this.error = ''; // Clear error when no file selected
}
}
}
@@ -198,22 +202,6 @@ export class TsviewS3Preview extends DeesElement {
this.loading = false;
}
private getFileName(path: string): string {
const parts = path.split('/');
return parts[parts.length - 1] || path;
}
private formatSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(unitIndex > 0 ? 1 : 0)} ${units[unitIndex]}`;
}
private formatDate(dateStr: string): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
@@ -235,7 +223,13 @@ export class TsviewS3Preview extends DeesElement {
private getTextContent(): string {
try {
return atob(this.content);
// Properly decode base64 to UTF-8 text
const binaryString = atob(this.content);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new TextDecoder('utf-8').decode(bytes);
} catch {
return 'Unable to decode content';
}
@@ -249,7 +243,7 @@ export class TsviewS3Preview extends DeesElement {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = this.getFileName(this.objectKey);
a.download = getFileName(this.objectKey);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
@@ -260,7 +254,7 @@ export class TsviewS3Preview extends DeesElement {
}
private async handleDelete() {
if (!confirm(`Delete "${this.getFileName(this.objectKey)}"?`)) return;
if (!confirm(`Delete "${getFileName(this.objectKey)}"?`)) return;
try {
await apiService.deleteObject(this.bucketName, this.objectKey);
@@ -310,10 +304,10 @@ export class TsviewS3Preview extends DeesElement {
return html`
<div class="preview-container">
<div class="preview-header">
<div class="preview-title">${this.getFileName(this.objectKey)}</div>
<div class="preview-title">${getFileName(this.objectKey)}</div>
<div class="preview-meta">
<span class="meta-item">${this.contentType}</span>
<span class="meta-item">${this.formatSize(this.size)}</span>
<span class="meta-item">${formatSize(this.size)}</span>
<span class="meta-item">${this.formatDate(this.lastModified)}</span>
</div>
</div>