This commit is contained in:
2025-11-11 01:05:13 +01:00
parent 2325661df4
commit ba73b8bdf7
31 changed files with 2026 additions and 306 deletions
+17
View File
@@ -4,6 +4,8 @@ class AdminBilling extends HTMLElement {
this.pricingConfig = [];
this.stats = null;
this.boundHandleClick = this.handleClick.bind(this);
this.loading = true;
this.error = null;
}
async connectedCallback() {
@@ -18,6 +20,8 @@ class AdminBilling extends HTMLElement {
}
async loadData() {
this.loading = true;
this.error = null;
try {
const [pricing, stats] = await Promise.all([
this.fetchPricing(),
@@ -26,8 +30,11 @@ class AdminBilling extends HTMLElement {
this.pricingConfig = pricing;
this.stats = stats;
this.loading = false;
} catch (error) {
console.error('Failed to load admin billing data:', error);
this.error = error.message || 'Failed to load admin billing data';
this.loading = false;
}
}
@@ -53,6 +60,16 @@ class AdminBilling extends HTMLElement {
}
render() {
if (this.loading) {
this.innerHTML = '<div class="admin-billing"><div class="loading">Loading admin billing data...</div></div>';
return;
}
if (this.error) {
this.innerHTML = `<div class="admin-billing"><div class="error-message">Error: ${this.error}</div></div>`;
return;
}
this.innerHTML = `
<div class="admin-billing">
<h2>Billing Administration</h2>
+78
View File
@@ -0,0 +1,78 @@
export default class BaseComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._state = {};
this._unsubscribers = [];
}
connectedCallback() {
this.render();
this._setupListeners();
this._subscribe();
}
disconnectedCallback() {
this._unsubscribers.forEach(unsubscribe => unsubscribe?.());
this._cleanup();
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.render();
}
}
render() {
if (!this.shadowRoot) return;
const style = this._getStyles();
const template = this._getTemplate();
this.shadowRoot.innerHTML = `${style}${template}`;
}
_getStyles() {
return '<style>:host { display: block; }</style>';
}
_getTemplate() {
return '';
}
_setupListeners() {
}
_subscribe() {
}
_cleanup() {
}
emit(eventName, detail = {}) {
this.dispatchEvent(
new CustomEvent(eventName, {
detail,
bubbles: true,
composed: true
})
);
}
setState(updates) {
this._state = { ...this._state, ...updates };
this.render();
}
getState() {
return { ...this._state };
}
querySelector(selector) {
return this.shadowRoot?.querySelector(selector);
}
querySelectorAll(selector) {
return this.shadowRoot?.querySelectorAll(selector) || [];
}
}
+98 -1
View File
@@ -6,13 +6,18 @@ class BillingDashboard extends HTMLElement {
this.pricing = null;
this.invoices = [];
this.boundHandleClick = this.handleClick.bind(this);
this.loading = true;
this.error = null;
this.stripe = null;
}
async connectedCallback() {
this.addEventListener('click', this.boundHandleClick);
this.render();
await this.loadData();
this.render();
this.attachEventListeners();
await this.initStripe();
}
disconnectedCallback() {
@@ -20,6 +25,8 @@ class BillingDashboard extends HTMLElement {
}
async loadData() {
this.loading = true;
this.error = null;
try {
const [usage, subscription, pricing, invoices] = await Promise.all([
this.fetchCurrentUsage(),
@@ -32,8 +39,21 @@ class BillingDashboard extends HTMLElement {
this.subscription = subscription;
this.pricing = pricing;
this.invoices = invoices;
this.loading = false;
} catch (error) {
console.error('Failed to load billing data:', error);
this.error = error.message || 'Failed to load billing data';
this.loading = false;
}
}
async initStripe() {
if (window.Stripe) {
const response = await fetch('/api/billing/stripe-key');
if (response.ok) {
const data = await response.json();
this.stripe = window.Stripe(data.publishable_key);
}
}
}
@@ -109,6 +129,16 @@ class BillingDashboard extends HTMLElement {
}
render() {
if (this.loading) {
this.innerHTML = '<div class="billing-dashboard"><div class="loading">Loading billing data...</div></div>';
return;
}
if (this.error) {
this.innerHTML = `<div class="billing-dashboard"><div class="error-message">Error: ${this.error}</div></div>`;
return;
}
const estimatedCost = this.calculateEstimatedCost();
const storageUsed = this.currentUsage?.storage_gb || 0;
const freeStorage = parseFloat(this.pricing?.free_tier_storage_gb?.value || 15);
@@ -253,7 +283,74 @@ class BillingDashboard extends HTMLElement {
}
async showPaymentMethodModal() {
alert('Payment method modal will be implemented with Stripe Elements');
if (!this.stripe) {
alert('Payment processing not available');
return;
}
try {
const response = await fetch('/api/billing/payment-methods/setup-intent', {
method: 'POST',
headers: {'Authorization': `Bearer ${localStorage.getItem('token')}`}
});
if (!response.ok) {
const error = await response.json();
alert(`Failed to initialize payment: ${error.detail}`);
return;
}
const { client_secret } = await response.json();
const modal = document.createElement('div');
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content">
<h2>Add Payment Method</h2>
<div id="payment-element"></div>
<div class="modal-actions">
<button class="btn-primary" id="submitPayment">Add Card</button>
<button class="btn-secondary" id="cancelPayment">Cancel</button>
</div>
</div>
`;
document.body.appendChild(modal);
const elements = this.stripe.elements({ clientSecret: client_secret });
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
modal.querySelector('#submitPayment').addEventListener('click', async () => {
const submitButton = modal.querySelector('#submitPayment');
submitButton.disabled = true;
submitButton.textContent = 'Processing...';
const { error } = await this.stripe.confirmSetup({
elements,
confirmParams: {
return_url: window.location.href,
},
redirect: 'if_required'
});
if (error) {
alert(`Payment failed: ${error.message}`);
submitButton.disabled = false;
submitButton.textContent = 'Add Card';
} else {
alert('Payment method added successfully');
modal.remove();
await this.loadData();
this.render();
}
});
modal.querySelector('#cancelPayment').addEventListener('click', () => {
modal.remove();
});
} catch (error) {
alert(`Error: ${error.message}`);
}
}
async showInvoiceDetail(invoiceId) {
+136 -74
View File
@@ -1,4 +1,7 @@
import { api } from '../api.js';
import app from '../app.js';
const api = app.getAPI();
const logger = app.getLogger();
class CodeEditorView extends HTMLElement {
constructor() {
@@ -6,146 +9,205 @@ class CodeEditorView extends HTMLElement {
this.editor = null;
this.file = null;
this.previousView = null;
this.boundHandleClick = this.handleClick.bind(this);
this.boundHandleEscape = this.handleEscape.bind(this);
this.isRendered = false;
}
connectedCallback() {
this.addEventListener('click', this.boundHandleClick);
document.addEventListener('keydown', this.boundHandleEscape);
logger.debug('CodeEditorView connected');
}
disconnectedCallback() {
this.removeEventListener('click', this.boundHandleClick);
document.removeEventListener('keydown', this.boundHandleEscape);
if (this.editor) {
this.editor.toTextArea();
this.editor = null;
}
}
handleEscape(e) {
if (e.key === 'Escape') {
this.goBack();
}
logger.debug('CodeEditorView disconnected');
this.destroyEditor();
}
async setFile(file, previousView = 'files') {
if (this.isRendered) {
logger.warn('Editor already rendered, skipping');
return;
}
this.file = file;
this.previousView = previousView;
await this.loadAndRender();
}
this.isRendered = true;
async loadAndRender() {
try {
const blob = await api.downloadFile(this.file.id);
logger.debug('Loading file', { fileName: file.name });
const blob = await api.downloadFile(file.id);
const content = await blob.text();
this.render(content);
this.initializeEditor(content);
this.createUI(content);
this.createEditor(content);
} catch (error) {
console.error('Failed to load file:', error);
logger.error('Failed to load file', error);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Failed to load file: ' + error.message, type: 'error' }
}));
this.render('');
window.history.back();
}
}
getMimeType(filename) {
const extension = filename.split('.').pop().toLowerCase();
const mimeMap = {
'js': 'text/javascript',
'json': 'application/json',
'py': 'text/x-python',
'md': 'text/x-markdown',
'html': 'text/html',
'xml': 'application/xml',
'css': 'text/css',
'txt': 'text/plain',
'log': 'text/plain',
'sh': 'text/x-sh',
'yaml': 'text/x-yaml',
'yml': 'text/x-yaml'
};
return mimeMap[extension] || 'text/plain';
}
render(content) {
createUI(content) {
this.innerHTML = `
<div class="code-editor-view">
<div class="code-editor-header">
<div class="header-left">
<button class="button" id="back-btn">Back</button>
<h2 class="editor-filename">${this.file.name}</h2>
<div class="code-editor-overlay">
<div class="code-editor-container">
<div class="code-editor-header">
<div class="header-left">
<button class="button" id="back-btn">Back</button>
<h2 class="editor-filename">${this.escapeHtml(this.file.name)}</h2>
</div>
<div class="header-right">
<button class="button button-primary" id="save-btn">Save & Close</button>
</div>
</div>
<div class="header-right">
<button class="button button-primary" id="save-btn">Save</button>
<div class="code-editor-body">
<textarea id="editor-textarea"></textarea>
</div>
</div>
<div class="code-editor-body">
<textarea id="code-editor-textarea">${content}</textarea>
</div>
</div>
`;
const backBtn = this.querySelector('#back-btn');
const saveBtn = this.querySelector('#save-btn');
backBtn.addEventListener('click', () => this.close());
saveBtn.addEventListener('click', () => this.save());
document.addEventListener('keydown', this.handleKeydown.bind(this));
}
initializeEditor(content) {
const textarea = this.querySelector('#code-editor-textarea');
if (!textarea) return;
createEditor(content) {
const textarea = this.querySelector('#editor-textarea');
if (!textarea) {
logger.error('Textarea not found');
return;
}
textarea.value = content;
const mode = this.getMode(this.file.name);
logger.debug('Creating CodeMirror editor', { mode, fileSize: content.length });
this.editor = CodeMirror.fromTextArea(textarea, {
value: content,
mode: this.getMimeType(this.file.name),
mode: mode,
lineNumbers: true,
theme: 'default',
lineWrapping: true,
indentUnit: 4,
indentWithTabs: false,
theme: 'default',
readOnly: false,
autofocus: true,
extraKeys: {
'Ctrl-S': () => this.save(),
'Cmd-S': () => this.save()
'Ctrl-S': () => { this.save(); return false; },
'Cmd-S': () => { this.save(); return false; },
'Esc': () => { this.close(); return false; }
}
});
this.editor.setSize('100%', '100%');
setTimeout(() => {
if (this.editor) {
this.editor.refresh();
this.editor.focus();
logger.debug('Editor ready and focused');
}
}, 100);
}
handleClick(e) {
if (e.target.id === 'back-btn') {
this.goBack();
} else if (e.target.id === 'save-btn') {
this.save();
getMode(filename) {
const ext = filename.split('.').pop().toLowerCase();
const modes = {
'js': 'javascript',
'json': { name: 'javascript', json: true },
'py': 'python',
'md': 'markdown',
'html': 'htmlmixed',
'xml': 'xml',
'css': 'css',
'txt': 'text/plain',
'log': 'text/plain',
'sh': 'shell',
'yaml': 'yaml',
'yml': 'yaml'
};
return modes[ext] || 'text/plain';
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
handleKeydown(e) {
if (e.key === 'Escape' && !this.editor.getOption('readOnly')) {
this.close();
}
}
async save() {
if (!this.editor) return;
if (!this.editor) {
logger.warn('No editor instance');
return;
}
const saveBtn = this.querySelector('#save-btn');
if (!saveBtn) return;
try {
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
const content = this.editor.getValue();
logger.debug('Saving file', { fileName: this.file.name, size: content.length });
await api.updateFile(this.file.id, content);
logger.info('File saved successfully', { fileName: this.file.name });
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'File saved successfully!', type: 'success' }
}));
setTimeout(() => {
this.close();
}, 500);
} catch (error) {
logger.error('Failed to save file', error);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Failed to save file: ' + error.message, type: 'error' }
detail: { message: 'Failed to save: ' + error.message, type: 'error' }
}));
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.textContent = 'Save & Close';
}
}
}
goBack() {
close() {
logger.debug('Closing editor');
window.history.back();
}
hide() {
document.removeEventListener('keydown', this.boundHandleEscape);
logger.debug('Hiding editor');
this.destroyEditor();
this.remove();
}
destroyEditor() {
if (this.editor) {
this.editor.toTextArea();
logger.debug('Destroying CodeMirror instance');
try {
this.editor.toTextArea();
} catch (e) {
logger.warn('Error destroying editor', e);
}
this.editor = null;
}
this.remove();
}
}
+101
View File
@@ -0,0 +1,101 @@
import BaseComponent from './base-component.js';
export default class ErrorBoundary extends BaseComponent {
constructor() {
super();
this.error = null;
this.errorInfo = null;
}
_getStyles() {
return `<style>
:host {
display: block;
}
.error-container {
padding: 2rem;
background: #ffebee;
border: 1px solid #f44336;
border-radius: 8px;
color: #c62828;
}
h2 {
margin: 0 0 1rem 0;
font-size: 1.5rem;
}
p {
margin: 0 0 0.5rem 0;
line-height: 1.6;
}
.error-details {
background: rgba(0, 0, 0, 0.1);
padding: 1rem;
border-radius: 4px;
font-family: monospace;
font-size: 0.85rem;
overflow-x: auto;
max-height: 200px;
overflow-y: auto;
margin-top: 1rem;
}
.error-details pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
button {
margin-top: 1rem;
padding: 0.75rem 1.5rem;
background: #f44336;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
}
button:hover {
background: #d32f2f;
}
</style>`;
}
_getTemplate() {
if (!this.error) {
return '<slot></slot>';
}
return `
<div class="error-container" role="alert">
<h2>Something went wrong</h2>
<p>${this.error.message || 'An unexpected error occurred'}</p>
<p>Please refresh the page or contact support if the problem persists.</p>
<div class="error-details">
<pre>${this.error.stack || 'No stack trace available'}</pre>
</div>
<button type="button" onclick="location.reload()">Reload Page</button>
</div>
`;
}
catch(error, errorInfo) {
this.error = error;
this.errorInfo = errorInfo;
this.render();
console.error('Error caught by boundary:', error, errorInfo);
}
reset() {
this.error = null;
this.errorInfo = null;
this.render();
}
}
customElements.define('error-boundary', ErrorBoundary);
-8
View File
@@ -41,14 +41,6 @@ class FilePreview extends HTMLElement {
this.style.display = 'block';
document.addEventListener('keydown', this.handleEscape);
this.renderPreview();
if (pushState) {
window.history.pushState(
{ view: 'file-preview', file: file },
'',
`#preview/${file.id}`
);
}
}
close() {
+10 -1
View File
@@ -1,4 +1,7 @@
import { api } from '../api.js';
import app from '../app.js';
const api = app.getAPI();
const logger = app.getLogger();
export class LoginView extends HTMLElement {
constructor() {
@@ -74,9 +77,12 @@ export class LoginView extends HTMLElement {
const errorDiv = this.querySelector('#login-error');
try {
logger.info('Login attempt started', { username });
await api.login(username, password);
logger.info('Login successful, dispatching auth-success event');
this.dispatchEvent(new CustomEvent('auth-success'));
} catch (error) {
logger.error('Login failed', { username, error: error.message });
errorDiv.textContent = error.message;
}
}
@@ -91,9 +97,12 @@ export class LoginView extends HTMLElement {
const errorDiv = this.querySelector('#register-error');
try {
logger.info('Registration attempt started', { username, email });
await api.register(username, email, password);
logger.info('Registration successful, dispatching auth-success event');
this.dispatchEvent(new CustomEvent('auth-success'));
} catch (error) {
logger.error('Registration failed', { username, email, error: error.message });
errorDiv.textContent = error.message;
}
}
+122 -30
View File
@@ -1,4 +1,4 @@
import { api } from '../api.js';
import app from '../app.js';
import './login-view.js';
import './file-list.js';
import './file-upload-view.js';
@@ -16,22 +16,51 @@ import './admin-billing.js';
import './code-editor-view.js';
import { shortcuts } from '../shortcuts.js';
const api = app.getAPI();
const logger = app.getLogger();
const appState = app.getState();
export class RBoxApp extends HTMLElement {
constructor() {
super();
this.currentView = 'files';
this.user = null;
this.navigationStack = [];
this.boundHandlePopState = this.handlePopState.bind(this);
this.popstateAttached = false;
}
async connectedCallback() {
await this.init();
this.addEventListener('show-toast', this.handleShowToast);
window.addEventListener('popstate', this.handlePopState.bind(this));
try {
await this.init();
this.addEventListener('show-toast', this.handleShowToast);
if (!this.popstateAttached) {
window.addEventListener('popstate', this.boundHandlePopState);
this.popstateAttached = true;
logger.debug('Popstate listener attached');
}
} catch (error) {
logger.error('Failed to initialize RBoxApp', error);
this.innerHTML = `
<div style="padding: 2rem; text-align: center; font-family: sans-serif;">
<h1 style="color: #d32f2f;">Failed to Load Application</h1>
<p>${error.message}</p>
<button onclick="location.reload()" style="padding: 0.75rem 1.5rem; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem;">
Reload Page
</button>
</div>
`;
}
}
disconnectedCallback() {
this.removeEventListener('show-toast', this.handleShowToast);
if (this.popstateAttached) {
window.removeEventListener('popstate', this.boundHandlePopState);
this.popstateAttached = false;
logger.debug('Popstate listener removed');
}
}
handleShowToast = (event) => {
@@ -46,15 +75,21 @@ export class RBoxApp extends HTMLElement {
}
async init() {
if (!api.getToken()) {
this.showLogin();
} else {
try {
this.user = await api.getCurrentUser();
this.render();
} catch (error) {
try {
if (!api.getToken()) {
logger.info('No token found, showing login');
this.showLogin();
} else {
logger.info('Initializing application with stored token');
this.user = await api.getCurrentUser();
appState.setState({ user: this.user });
logger.info('User loaded successfully', { username: this.user.username });
this.render();
}
} catch (error) {
logger.error('Failed to initialize application', error);
api.setToken(null);
this.showLogin();
}
}
@@ -383,47 +418,63 @@ export class RBoxApp extends HTMLElement {
}
handlePopState(e) {
logger.debug('Popstate event', { state: e.state, url: window.location.href });
this.closeAllOverlays();
if (e.state && e.state.view) {
if (e.state.view === 'code-editor' && e.state.file) {
const view = e.state.view;
if (view === 'code-editor' && e.state.file) {
logger.debug('Restoring code editor view');
this.showCodeEditor(e.state.file, false);
} else if (e.state.view === 'file-preview' && e.state.file) {
} else if (view === 'file-preview' && e.state.file) {
logger.debug('Restoring file preview view');
this.showFilePreview(e.state.file, false);
} else if (e.state.view === 'upload') {
} else if (view === 'upload') {
logger.debug('Restoring upload view');
const folderId = e.state.folderId !== undefined ? e.state.folderId : null;
this.showUpload(folderId, false);
} else {
this.switchView(e.state.view, false);
logger.debug('Switching to view', { view });
this.switchView(view, false);
}
} else {
logger.debug('No state, defaulting to files view');
this.switchView('files', false);
}
}
closeAllOverlays() {
logger.debug('Closing all overlays');
const existingEditor = this.querySelector('code-editor-view');
if (existingEditor) {
logger.debug('Hiding code editor');
existingEditor.hide();
}
const existingPreview = this.querySelector('file-preview');
if (existingPreview) {
logger.debug('Hiding file preview');
existingPreview.hide();
}
const existingUpload = this.querySelector('file-upload-view');
if (existingUpload) {
logger.debug('Hiding file upload');
existingUpload.hide();
}
const shareModal = this.querySelector('share-modal');
if (shareModal && shareModal.style.display !== 'none') {
logger.debug('Hiding share modal');
shareModal.style.display = 'none';
}
}
showCodeEditor(file, pushState = true) {
logger.debug('Showing code editor', { file: file.name, pushState });
this.closeAllOverlays();
const mainElement = this.querySelector('.app-main');
@@ -432,15 +483,29 @@ export class RBoxApp extends HTMLElement {
editorView.setFile(file, this.currentView);
if (pushState) {
window.history.pushState(
{ view: 'code-editor', file: file },
'',
`#editor/${file.id}`
);
const currentState = window.history.state || {};
const currentView = currentState.view || this.currentView;
if (currentView !== 'code-editor') {
window.history.pushState(
{ view: 'code-editor', file: file, previousView: currentView },
'',
`#editor/${file.id}`
);
logger.debug('Pushed code editor state', { previousView: currentView });
} else {
logger.debug('Already in code editor view, replacing state');
window.history.replaceState(
{ view: 'code-editor', file: file, previousView: currentView },
'',
`#editor/${file.id}`
);
}
}
}
showFilePreview(file, pushState = true) {
logger.debug('Showing file preview', { file: file.name, pushState });
this.closeAllOverlays();
const mainElement = this.querySelector('.app-main');
@@ -449,15 +514,29 @@ export class RBoxApp extends HTMLElement {
preview.show(file, false);
if (pushState) {
window.history.pushState(
{ view: 'file-preview', file: file },
'',
`#preview/${file.id}`
);
const currentState = window.history.state || {};
const currentView = currentState.view || this.currentView;
if (currentView !== 'file-preview') {
window.history.pushState(
{ view: 'file-preview', file: file, previousView: currentView },
'',
`#preview/${file.id}`
);
logger.debug('Pushed file preview state', { previousView: currentView });
} else {
logger.debug('Already in file preview view, replacing state');
window.history.replaceState(
{ view: 'file-preview', file: file, previousView: currentView },
'',
`#preview/${file.id}`
);
}
}
}
showUpload(folderId = null, pushState = true) {
logger.debug('Showing upload view', { folderId, pushState });
this.closeAllOverlays();
const mainElement = this.querySelector('.app-main');
@@ -466,11 +545,24 @@ export class RBoxApp extends HTMLElement {
uploadView.setFolder(folderId);
if (pushState) {
window.history.pushState(
{ view: 'upload', folderId: folderId },
'',
'#upload'
);
const currentState = window.history.state || {};
const currentView = currentState.view || this.currentView;
if (currentView !== 'upload') {
window.history.pushState(
{ view: 'upload', folderId: folderId, previousView: currentView },
'',
'#upload'
);
logger.debug('Pushed upload state', { previousView: currentView });
} else {
logger.debug('Already in upload view, replacing state');
window.history.replaceState(
{ view: 'upload', folderId: folderId, previousView: currentView },
'',
'#upload'
);
}
}
}