Compare commits

...

3 Commits

Author SHA1 Message Date
1df5621c90 Update. 2025-11-11 01:06:10 +01:00
ba73b8bdf7 Update. 2025-11-11 01:05:13 +01:00
2325661df4 Fixed bread crumbs. 2025-11-10 17:59:40 +01:00
34 changed files with 2213 additions and 323 deletions

2
.gitignore vendored
View File

@ -1,4 +1,6 @@
__pycache__/
*.png
*.sqlite*
*.py[cod]
*$py.class
*.md

View File

@ -38,16 +38,17 @@ class UserSubscription(models.Model):
class UsageRecord(models.Model):
id = fields.BigIntField(pk=True)
user = fields.ForeignKeyField("models.User", related_name="usage_records")
record_type = fields.CharField(max_length=50)
record_type = fields.CharField(max_length=50, index=True)
amount_bytes = fields.BigIntField()
resource_type = fields.CharField(max_length=50, null=True)
resource_id = fields.IntField(null=True)
timestamp = fields.DatetimeField(auto_now_add=True)
timestamp = fields.DatetimeField(auto_now_add=True, index=True)
idempotency_key = fields.CharField(max_length=255, unique=True, null=True)
metadata = fields.JSONField(null=True)
class Meta:
table = "usage_records"
indexes = [("user_id", "record_type", "timestamp")]
class UsageAggregate(models.Model):
id = fields.IntField(pk=True)
@ -68,21 +69,22 @@ class Invoice(models.Model):
user = fields.ForeignKeyField("models.User", related_name="invoices")
invoice_number = fields.CharField(max_length=50, unique=True)
stripe_invoice_id = fields.CharField(max_length=255, unique=True, null=True)
period_start = fields.DateField()
period_start = fields.DateField(index=True)
period_end = fields.DateField()
subtotal = fields.DecimalField(max_digits=10, decimal_places=4)
tax = fields.DecimalField(max_digits=10, decimal_places=4, default=0)
total = fields.DecimalField(max_digits=10, decimal_places=4)
currency = fields.CharField(max_length=3, default="USD")
status = fields.CharField(max_length=50, default="draft")
status = fields.CharField(max_length=50, default="draft", index=True)
due_date = fields.DateField(null=True)
paid_at = fields.DatetimeField(null=True)
created_at = fields.DatetimeField(auto_now_add=True)
created_at = fields.DatetimeField(auto_now_add=True, index=True)
updated_at = fields.DatetimeField(auto_now=True)
metadata = fields.JSONField(null=True)
class Meta:
table = "invoices"
indexes = [("user_id", "status", "created_at")]
class InvoiceLineItem(models.Model):
id = fields.IntField(pk=True)

View File

@ -3,11 +3,17 @@ from decimal import Decimal
from typing import Optional, Dict, Any
from ..settings import settings
stripe.api_key = settings.STRIPE_SECRET_KEY if hasattr(settings, 'STRIPE_SECRET_KEY') else ""
class StripeClient:
@staticmethod
def _ensure_api_key():
if not stripe.api_key:
if settings.STRIPE_SECRET_KEY:
stripe.api_key = settings.STRIPE_SECRET_KEY
else:
raise ValueError("Stripe API key not configured")
@staticmethod
async def create_customer(email: str, name: str, metadata: Dict = None) -> str:
StripeClient._ensure_api_key()
customer = stripe.Customer.create(
email=email,
name=name,
@ -22,6 +28,7 @@ class StripeClient:
customer_id: str = None,
metadata: Dict = None
) -> stripe.PaymentIntent:
StripeClient._ensure_api_key()
return stripe.PaymentIntent.create(
amount=amount,
currency=currency,
@ -37,6 +44,7 @@ class StripeClient:
line_items: list,
metadata: Dict = None
) -> stripe.Invoice:
StripeClient._ensure_api_key()
for item in line_items:
stripe.InvoiceItem.create(
customer=customer_id,
@ -58,10 +66,12 @@ class StripeClient:
@staticmethod
async def finalize_invoice(invoice_id: str) -> stripe.Invoice:
StripeClient._ensure_api_key()
return stripe.Invoice.finalize_invoice(invoice_id)
@staticmethod
async def pay_invoice(invoice_id: str) -> stripe.Invoice:
StripeClient._ensure_api_key()
return stripe.Invoice.pay(invoice_id)
@staticmethod
@ -69,6 +79,7 @@ class StripeClient:
payment_method_id: str,
customer_id: str
) -> stripe.PaymentMethod:
StripeClient._ensure_api_key()
payment_method = stripe.PaymentMethod.attach(
payment_method_id,
customer=customer_id
@ -83,6 +94,7 @@ class StripeClient:
@staticmethod
async def list_payment_methods(customer_id: str, type: str = "card"):
StripeClient._ensure_api_key()
return stripe.PaymentMethod.list(
customer=customer_id,
type=type
@ -94,6 +106,7 @@ class StripeClient:
price_id: str,
metadata: Dict = None
) -> stripe.Subscription:
StripeClient._ensure_api_key()
return stripe.Subscription.create(
customer=customer_id,
items=[{'price': price_id}],
@ -102,4 +115,5 @@ class StripeClient:
@staticmethod
async def cancel_subscription(subscription_id: str) -> stripe.Subscription:
StripeClient._ensure_api_key()
return stripe.Subscription.delete(subscription_id)

View File

@ -108,15 +108,22 @@ class UsageTracker:
@staticmethod
async def get_current_storage(user: User) -> int:
from ..models import File
files = await File.filter(user=user, is_deleted=False).all()
files = await File.filter(owner=user, is_deleted=False).all()
return sum(f.size for f in files)
@staticmethod
async def get_monthly_usage(user: User, year: int, month: int) -> dict:
from datetime import date
from calendar import monthrange
start_date = date(year, month, 1)
_, last_day = monthrange(year, month)
end_date = date(year, month, last_day)
aggregates = await UsageAggregate.filter(
user=user,
date__year=year,
date__month=month
date__gte=start_date,
date__lte=end_date
).all()
if not aggregates:

View File

@ -2,10 +2,9 @@ from datetime import timedelta
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from ..auth import authenticate_user, create_access_token, get_password_hash, get_current_user, get_current_verified_user
from ..auth import authenticate_user, create_access_token, get_password_hash, get_current_user, get_current_verified_user, verify_password
from ..models import User
from ..schemas import Token, UserCreate, TokenData, UserLoginWith2FA
from ..two_factor import (
@ -19,6 +18,10 @@ router = APIRouter(
tags=["auth"],
)
class LoginRequest(BaseModel):
username: str
password: str
class TwoFactorLogin(BaseModel):
username: str
password: str
@ -65,8 +68,8 @@ async def register_user(user_in: UserCreate):
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/token", response_model=Token)
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
auth_result = await authenticate_user(form_data.username, form_data.password, None)
async def login_for_access_token(login_data: LoginRequest):
auth_result = await authenticate_user(login_data.username, login_data.password, None)
if not auth_result:
raise HTTPException(
@ -83,7 +86,7 @@ async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(
headers={"X-2FA-Required": "true"},
)
access_token_expires = timedelta(minutes=30) # Use settings
access_token_expires = timedelta(minutes=30)
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires, two_factor_verified=True
)

View File

@ -52,6 +52,7 @@ class SubscriptionResponse(BaseModel):
@router.get("/usage/current")
async def get_current_usage(current_user: User = Depends(get_current_user)):
try:
storage_bytes = await UsageTracker.get_current_storage(current_user)
today = date.today()
@ -71,6 +72,8 @@ async def get_current_usage(current_user: User = Depends(get_current_user)):
"bandwidth_up_gb_today": 0,
"as_of": today.isoformat()
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to fetch usage data: {str(e)}")
@router.get("/usage/monthly")
async def get_monthly_usage(
@ -78,17 +81,27 @@ async def get_monthly_usage(
month: Optional[int] = None,
current_user: User = Depends(get_current_user)
) -> UsageResponse:
try:
if year is None or month is None:
now = datetime.now()
year = now.year
month = now.month
if not (1 <= month <= 12):
raise HTTPException(status_code=400, detail="Month must be between 1 and 12")
if not (2020 <= year <= 2100):
raise HTTPException(status_code=400, detail="Year must be between 2020 and 2100")
usage = await UsageTracker.get_monthly_usage(current_user, year, month)
return UsageResponse(
**usage,
period=f"{year}-{month:02d}"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to fetch monthly usage: {str(e)}")
@router.get("/invoices")
async def list_invoices(
@ -96,6 +109,12 @@ async def list_invoices(
offset: int = 0,
current_user: User = Depends(get_current_user)
) -> List[InvoiceResponse]:
try:
if limit < 1 or limit > 100:
raise HTTPException(status_code=400, detail="Limit must be between 1 and 100")
if offset < 0:
raise HTTPException(status_code=400, detail="Offset must be non-negative")
invoices = await Invoice.filter(user=current_user).order_by("-created_at").offset(offset).limit(limit).all()
result = []
@ -125,6 +144,10 @@ async def list_invoices(
))
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to fetch invoices: {str(e)}")
@router.get("/invoices/{invoice_id}")
async def get_invoice(
@ -187,6 +210,11 @@ async def get_subscription(current_user: User = Depends(get_current_user)) -> Su
@router.post("/payment-methods/setup-intent")
async def create_setup_intent(current_user: User = Depends(get_current_user)):
try:
from ..settings import settings
if not settings.STRIPE_SECRET_KEY:
raise HTTPException(status_code=503, detail="Payment processing not configured")
subscription = await UserSubscription.get_or_none(user=current_user)
if not subscription or not subscription.stripe_customer_id:
@ -208,6 +236,7 @@ async def create_setup_intent(current_user: User = Depends(get_current_user)):
await subscription.save()
import stripe
StripeClient._ensure_api_key()
setup_intent = stripe.SetupIntent.create(
customer=subscription.stripe_customer_id,
payment_method_types=["card"]
@ -217,6 +246,10 @@ async def create_setup_intent(current_user: User = Depends(get_current_user)):
"client_secret": setup_intent.client_secret,
"customer_id": subscription.stripe_customer_id
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to create setup intent: {str(e)}")
@router.get("/payment-methods")
async def list_payment_methods(current_user: User = Depends(get_current_user)):
@ -238,18 +271,35 @@ async def list_payment_methods(current_user: User = Depends(get_current_user)):
async def stripe_webhook(request: Request):
import stripe
from ..settings import settings
from ..billing.models import BillingEvent
try:
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
if not settings.STRIPE_WEBHOOK_SECRET:
raise HTTPException(status_code=503, detail="Webhook secret not configured")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid payload")
except stripe.error.SignatureVerificationError:
raise HTTPException(status_code=400, detail="Invalid signature")
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Invalid payload: {str(e)}")
except stripe.error.SignatureVerificationError as e:
raise HTTPException(status_code=400, detail=f"Invalid signature: {str(e)}")
event_id = event.get("id")
existing_event = await BillingEvent.get_or_none(stripe_event_id=event_id)
if existing_event:
return JSONResponse(content={"status": "already_processed"})
await BillingEvent.create(
event_type=event["type"],
stripe_event_id=event_id,
data=event["data"],
processed=False
)
if event["type"] == "invoice.payment_succeeded":
invoice_data = event["data"]["object"]
@ -280,7 +330,12 @@ async def stripe_webhook(request: Request):
is_default=True
)
await BillingEvent.filter(stripe_event_id=event_id).update(processed=True)
return JSONResponse(content={"status": "success"})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Webhook processing failed: {str(e)}")
@router.get("/pricing")
async def get_pricing():
@ -310,3 +365,10 @@ async def list_plans():
}
for plan in plans
]
@router.get("/stripe-key")
async def get_stripe_key():
from ..settings import settings
if not settings.STRIPE_PUBLISHABLE_KEY:
raise HTTPException(status_code=503, detail="Payment processing not configured")
return {"publishable_key": settings.STRIPE_PUBLISHABLE_KEY}

View File

@ -369,3 +369,33 @@
border-radius: 4px;
font-size: 1rem;
}
.loading {
text-align: center;
padding: 3rem;
font-size: 1.2rem;
color: #6b7280;
}
.error-message {
background: #fee2e2;
border: 1px solid #ef4444;
color: #991b1b;
padding: 1rem;
border-radius: 8px;
margin: 1rem 0;
}
.modal-actions {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
justify-content: flex-end;
}
#payment-element {
margin: 1.5rem 0;
padding: 1rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
}

View File

@ -1,13 +1,25 @@
.code-editor-view {
position: absolute;
.code-editor-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.code-editor-container {
width: 90%;
height: 90%;
max-width: 1400px;
background: white;
border-radius: 8px;
display: flex;
flex-direction: column;
background: white;
z-index: 100;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.code-editor-header {
@ -49,6 +61,16 @@
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
background: white;
user-select: text !important;
cursor: text;
}
.code-editor-body .CodeMirror * {
user-select: text !important;
}
.code-editor-body .CodeMirror-scroll {
cursor: text;
}
.code-editor-body .CodeMirror-gutters {

View File

@ -10,6 +10,7 @@
<link rel="stylesheet" href="/static/css/code-editor-view.css">
<link rel="stylesheet" href="/static/css/file-upload-view.css">
<link rel="manifest" href="/static/manifest.json">
<script src="https://js.stripe.com/v3/"></script>
<script src="/static/lib/codemirror/codemirror.min.js"></script>
<script src="/static/lib/codemirror/javascript.min.js"></script>
<script src="/static/lib/codemirror/python.min.js"></script>

42
static/js/api-contract.js Normal file
View File

@ -0,0 +1,42 @@
export default class APIContract {
static validateResponse(response, schema, logger = null) {
const errors = [];
for (const [field, expectedType] of Object.entries(schema)) {
if (!(field in response)) {
errors.push(`Missing required field: ${field}`);
} else {
const actualType = typeof response[field];
if (actualType !== expectedType) {
errors.push(
`Field '${field}' type mismatch: expected ${expectedType}, got ${actualType}`
);
}
}
}
if (errors.length > 0) {
const message = `API Contract Violation:\n${errors.join('\n')}`;
logger?.error(message);
throw new Error(message);
}
return response;
}
static validateArray(array, itemSchema, logger = null) {
if (!Array.isArray(array)) {
logger?.error('Expected array but got non-array');
throw new Error('Expected array response');
}
return array.map((item, index) => {
try {
return this.validateResponse(item, itemSchema, logger);
} catch (error) {
logger?.error(`Array item ${index} validation failed`, error);
throw error;
}
});
}
}

View File

@ -1,7 +1,11 @@
class APIClient {
constructor(baseURL = '/') {
constructor(baseURL = '/', logger = null, perfMonitor = null, appState = null) {
this.baseURL = baseURL;
this.token = localStorage.getItem('token');
this.logger = logger;
this.perfMonitor = perfMonitor;
this.appState = appState;
this.activeRequests = 0;
}
setToken(token) {
@ -18,7 +22,16 @@ class APIClient {
}
async request(endpoint, options = {}) {
this.activeRequests++;
this.appState?.setState({ isLoading: true });
const startTime = performance.now();
const url = `${this.baseURL}${endpoint}`;
const method = options.method || 'GET';
try {
this.logger?.debug(`API ${method}: ${endpoint}`);
const headers = {
...options.headers,
};
@ -27,22 +40,44 @@ class APIClient {
headers['Authorization'] = `Bearer ${this.token}`;
}
if (!(options.body instanceof FormData) && options.body) {
headers['Content-Type'] = 'application/json';
}
const config = {
...options,
headers,
};
if (config.body && !(config.body instanceof FormData)) {
if (config.body) {
if (config.body instanceof FormData) {
let hasFile = false;
for (let pair of config.body.entries()) {
if (pair[1] instanceof File) {
hasFile = true;
break;
}
}
if (!hasFile) {
this.logger?.error('FormData without File objects not allowed - JSON only communication enforced');
throw new Error('FormData is only allowed for file uploads');
}
this.logger?.debug('File upload detected, allowing FormData');
} else if (typeof config.body === 'object') {
headers['Content-Type'] = 'application/json';
config.body = JSON.stringify(config.body);
this.logger?.debug('Request body serialized to JSON');
}
}
const response = await fetch(url, config);
const duration = performance.now() - startTime;
if (this.perfMonitor) {
this.perfMonitor._recordMetric('api-request', duration);
this.perfMonitor._recordMetric(`api-${method}`, duration);
}
if (response.status === 401) {
this.logger?.warn('Unauthorized request, clearing token');
this.setToken(null);
window.location.href = '/';
}
@ -55,39 +90,79 @@ class APIClient {
errorData = { message: 'Unknown error' };
}
const errorMessage = errorData.detail || errorData.message || 'Request failed';
this.logger?.error(`API ${method} ${endpoint} failed: ${errorMessage}`, {
status: response.status,
duration: `${duration.toFixed(2)}ms`
});
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: errorMessage, type: 'error' }
}));
throw new Error(errorMessage);
}
this.logger?.debug(`API ${method} ${endpoint} success`, {
status: response.status,
duration: `${duration.toFixed(2)}ms`
});
if (response.status === 204) {
return null;
}
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
this.logger?.error(`Invalid response content-type: ${contentType}. Expected application/json`);
throw new Error('Server returned non-JSON response');
}
return response.json();
} catch (error) {
this.logger?.error(`API ${method} ${endpoint} exception`, error);
throw error;
} finally {
this.activeRequests--;
if (this.activeRequests === 0) {
this.appState?.setState({ isLoading: false });
}
}
}
async register(username, email, password) {
this.logger?.info('Attempting registration', { username, email });
const data = await this.request('auth/register', {
method: 'POST',
body: { username, email, password },
skipAuth: true
});
if (!data || !data.access_token) {
this.logger?.error('Invalid registration response: missing access_token', data);
throw new Error('Invalid registration response');
}
this.logger?.info('Registration successful', { username });
this.setToken(data.access_token);
return data;
}
async login(username, password) {
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
this.logger?.info('Attempting login', { username });
const data = await this.request('auth/token', {
method: 'POST',
body: formData,
body: { username, password },
skipAuth: true
});
if (!data || !data.access_token) {
this.logger?.error('Invalid login response: missing access_token', data);
throw new Error('Invalid authentication response');
}
this.logger?.info('Login successful', { username });
this.setToken(data.access_token);
return data;
}
@ -328,4 +403,28 @@ class APIClient {
}
}
export const api = new APIClient();
export { APIClient };
let _sharedInstance = null;
export function setSharedAPIInstance(instance) {
_sharedInstance = instance;
}
export const api = new Proxy({}, {
get(target, prop) {
if (!_sharedInstance) {
console.warn('API instance accessed before initialization. This may cause issues with logging and state management.');
_sharedInstance = new APIClient();
}
const instance = _sharedInstance;
const value = instance[prop];
if (typeof value === 'function') {
return value.bind(instance);
}
return value;
}
});

111
static/js/app.js Normal file
View File

@ -0,0 +1,111 @@
import Logger from './logger.js';
import AppState from './state.js';
import PerformanceMonitor from './perf-monitor.js';
import LazyLoader from './lazy-loader.js';
import { APIClient, setSharedAPIInstance } from './api.js';
class Application {
constructor() {
try {
this.logger = new Logger({
level: 'debug',
maxLogs: 200,
enableRemote: false
});
this.appState = new AppState({
currentView: 'files',
currentPage: 'files',
user: null,
isLoading: false,
notifications: []
});
this.perfMonitor = new PerformanceMonitor(this.logger);
this.api = new APIClient('/', this.logger, this.perfMonitor, this.appState);
setSharedAPIInstance(this.api);
this.lazyLoader = new LazyLoader({
threshold: 0.1,
rootMargin: '50px',
logger: this.logger
});
this._setupStateSubscriptions();
this._makeGloballyAccessible();
this.initialized = true;
this.logger.info('Application initialized successfully', {
version: '1.0.0',
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('CRITICAL: Application initialization failed', error);
this.initialized = false;
throw error;
}
}
_setupStateSubscriptions() {
this.appState.subscribe((newState, prevState, action) => {
this.logger.debug('State changed', {
from: prevState,
to: newState,
action
});
});
}
_makeGloballyAccessible() {
if (typeof window !== 'undefined') {
window.app = this;
}
}
isReady() {
return this.initialized === true;
}
getLogger() {
if (!this.initialized) {
console.warn('Application not fully initialized');
}
return this.logger;
}
getState() {
if (!this.initialized) {
console.warn('Application not fully initialized');
}
return this.appState;
}
getAPI() {
if (!this.initialized) {
console.warn('Application not fully initialized');
}
return this.api;
}
getPerfMonitor() {
if (!this.initialized) {
console.warn('Application not fully initialized');
}
return this.perfMonitor;
}
getLazyLoader() {
if (!this.initialized) {
console.warn('Application not fully initialized');
}
return this.lazyLoader;
}
}
const app = new Application();
export { Application, app };
export default app;

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>

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) || [];
}
}

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) {

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') {
this.file = file;
this.previousView = previousView;
await this.loadAndRender();
if (this.isRendered) {
logger.warn('Editor already rendered, skipping');
return;
}
async loadAndRender() {
this.file = file;
this.previousView = previousView;
this.isRendered = true;
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-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.file.name}</h2>
<h2 class="editor-filename">${this.escapeHtml(this.file.name)}</h2>
</div>
<div class="header-right">
<button class="button button-primary" id="save-btn">Save</button>
<button class="button button-primary" id="save-btn">Save & Close</button>
</div>
</div>
<div class="code-editor-body">
<textarea id="code-editor-textarea">${content}</textarea>
<textarea id="editor-textarea"></textarea>
</div>
</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) {
logger.debug('Destroying CodeMirror instance');
try {
this.editor.toTextArea();
} catch (e) {
logger.warn('Error destroying editor', e);
}
this.editor = null;
}
this.remove();
}
}

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);

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() {

View File

@ -5,6 +5,7 @@ export class FileUploadView extends HTMLElement {
super();
this.folderId = null;
this.handleEscape = this.handleEscape.bind(this);
this.uploadItems = new Map();
}
connectedCallback() {
@ -94,6 +95,8 @@ export class FileUploadView extends HTMLElement {
const uploadList = this.querySelector('#upload-list');
if (!uploadList) return;
const uploadPromises = [];
for (const file of files) {
const itemId = `upload-${Date.now()}-${Math.random()}`;
const item = document.createElement('div');
@ -113,21 +116,30 @@ export class FileUploadView extends HTMLElement {
`;
uploadList.appendChild(item);
try {
await this.uploadFile(file, itemId);
const statusEl = item.querySelector('.upload-item-status');
if (statusEl) {
statusEl.textContent = 'Complete';
statusEl.classList.add('success');
}
} catch (error) {
this.uploadItems.set(itemId, {
element: item,
progress: 0
});
const promise = this.uploadFile(file, itemId)
.then(() => {
setTimeout(() => {
this.uploadItems.delete(itemId);
item.remove();
}, 500);
})
.catch(error => {
const statusEl = item.querySelector('.upload-item-status');
if (statusEl) {
statusEl.textContent = 'Failed: ' + error.message;
statusEl.classList.add('error');
}
});
uploadPromises.push(promise);
}
}
await Promise.all(uploadPromises);
this.dispatchEvent(new CustomEvent('upload-complete', { bubbles: true }));
@ -136,6 +148,19 @@ export class FileUploadView extends HTMLElement {
}, 1500);
}
sortUploadList() {
const uploadList = this.querySelector('#upload-list');
if (!uploadList) return;
const items = Array.from(this.uploadItems.entries())
.sort((a, b) => b[1].progress - a[1].progress);
uploadList.innerHTML = '';
items.forEach(([itemId, data]) => {
uploadList.appendChild(data.element);
});
}
async uploadFile(file, itemId) {
const formData = new FormData();
formData.append('file', file);
@ -149,18 +174,34 @@ export class FileUploadView extends HTMLElement {
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
const item = this.querySelector(`#${itemId}`);
if (item) {
const progressFill = item.querySelector('.progress-fill');
const itemData = this.uploadItems.get(itemId);
if (itemData) {
itemData.progress = percentComplete;
const progressFill = itemData.element.querySelector('.progress-fill');
if (progressFill) {
progressFill.style.width = percentComplete + '%';
}
this.sortUploadList();
}
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
const itemData = this.uploadItems.get(itemId);
if (itemData) {
itemData.progress = 100;
const progressFill = itemData.element.querySelector('.progress-fill');
const statusEl = itemData.element.querySelector('.upload-item-status');
if (progressFill) {
progressFill.style.width = '100%';
}
if (statusEl) {
statusEl.textContent = 'Complete';
statusEl.classList.add('success');
}
this.sortUploadList(); // Sort after completion to move completed items to top
}
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(xhr.statusText));

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

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() {
try {
await this.init();
this.addEventListener('show-toast', this.handleShowToast);
window.addEventListener('popstate', this.handlePopState.bind(this));
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() {
try {
if (!api.getToken()) {
logger.info('No token found, showing login');
this.showLogin();
} else {
try {
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) {
this.showLogin();
}
} 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) {
const currentState = window.history.state || {};
const currentView = currentState.view || this.currentView;
if (currentView !== 'code-editor') {
window.history.pushState(
{ view: 'code-editor', file: file },
{ 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) {
const currentState = window.history.state || {};
const currentView = currentState.view || this.currentView;
if (currentView !== 'file-preview') {
window.history.pushState(
{ view: 'file-preview', file: file },
{ 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) {
const currentState = window.history.state || {};
const currentView = currentState.view || this.currentView;
if (currentView !== 'upload') {
window.history.pushState(
{ view: 'upload', folderId: folderId },
{ 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'
);
}
}
}

View File

@ -0,0 +1,47 @@
export default class FetchInterceptor {
constructor(logger = null, appState = null, perfMonitor = null) {
this.logger = logger;
this.appState = appState;
this.perfMonitor = perfMonitor;
this.activeRequests = 0;
}
async request(url, options = {}) {
this.activeRequests++;
this.appState?.setState({ isLoading: true });
const startTime = performance.now();
try {
this.logger?.debug(`Fetch START: ${options.method || 'GET'} ${url}`);
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
const duration = performance.now() - startTime;
this.perfMonitor?.['_recordMetric']?.('fetch', duration);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
this.logger?.debug(`Fetch SUCCESS: ${url}`, { status: response.status, duration: `${duration.toFixed(2)}ms` });
return data;
} catch (error) {
this.logger?.error(`Fetch FAILED: ${url}`, { error: error.message });
throw error;
} finally {
this.activeRequests--;
if (this.activeRequests === 0) {
this.appState?.setState({ isLoading: false });
}
}
}
}

View File

@ -0,0 +1,83 @@
export default class FocusManager {
static trapFocus(element, logger = null) {
const focusableSelectors = [
'button',
'[href]',
'input',
'select',
'textarea',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
const focusableElements = element.querySelectorAll(focusableSelectors);
if (focusableElements.length === 0) {
logger?.warn('No focusable elements found for trap');
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const handleKeydown = (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
lastElement.focus();
e.preventDefault();
}
} else {
if (document.activeElement === lastElement) {
firstElement.focus();
e.preventDefault();
}
}
};
element.addEventListener('keydown', handleKeydown);
return () => element.removeEventListener('keydown', handleKeydown);
}
static moveFocusToElement(element, options = {}) {
const { smooth = true, center = true } = options;
element.focus({ preventScroll: !smooth });
if (smooth) {
element.scrollIntoView({
behavior: 'smooth',
block: center ? 'center' : 'nearest'
});
}
}
static getFirstFocusableElement(container) {
const focusableSelectors = [
'button',
'[href]',
'input',
'select',
'textarea',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
return container.querySelector(focusableSelectors);
}
static restoreFocus(element) {
const prevElement = element.dataset.previousFocus;
if (prevElement) {
const el = document.querySelector(prevElement);
if (el) el.focus();
}
}
static saveFocus(element) {
const current = document.activeElement;
if (current) {
element.dataset.previousFocus = current.getAttribute('data-focus-id') || current.id;
}
}
}

View File

@ -0,0 +1,61 @@
export default class FormValidator {
static rules = {
required: (value) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'This field is required';
}
return null;
},
email: (value) => {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(value) ? null : 'Invalid email address';
},
minLength: (min) => (value) => {
return (value?.length || 0) >= min ? null : `Minimum ${min} characters required`;
},
maxLength: (max) => (value) => {
return (value?.length || 0) <= max ? null : `Maximum ${max} characters allowed`;
},
pattern: (regex, message = 'Invalid format') => (value) => {
return regex.test(value) ? null : message;
},
custom: (fn) => (value) => {
try {
const error = fn(value);
return error || null;
} catch (err) {
return 'Validation error';
}
}
};
static validate(formData, schema) {
const errors = {};
for (const [field, rules] of Object.entries(schema)) {
const value = formData[field];
for (const rule of rules) {
let error = null;
if (typeof rule === 'function') {
error = rule(value);
}
if (error) {
errors[field] = error;
break;
}
}
}
return {
isValid: Object.keys(errors).length === 0,
errors
};
}
static createSchema() {
return {};
}
}

68
static/js/lazy-loader.js Normal file
View File

@ -0,0 +1,68 @@
export default class LazyLoader {
constructor(options = {}) {
this.threshold = options.threshold || 0.1;
this.rootMargin = options.rootMargin || '50px';
this.observer = null;
this.logger = options.logger || null;
this._setup();
}
_setup() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this._loadElement(entry.target);
this.observer.unobserve(entry.target);
}
});
},
{
threshold: this.threshold,
rootMargin: this.rootMargin
}
);
}
observe(element) {
if (!element) return;
this.observer.observe(element);
}
unobserve(element) {
if (!element) return;
this.observer.unobserve(element);
}
observeAll(selector) {
document.querySelectorAll(selector).forEach((el) => {
this.observe(el);
});
}
_loadElement(element) {
try {
if (element.dataset.src) {
element.src = element.dataset.src;
element.removeAttribute('data-src');
this.logger?.debug(`Lazy loaded image: ${element.src}`);
}
if (element.dataset.backgroundImage) {
element.style.backgroundImage = `url(${element.dataset.backgroundImage})`;
element.removeAttribute('data-background-image');
this.logger?.debug(`Lazy loaded background image`);
}
element.classList.add('loaded');
} catch (error) {
this.logger?.error('Failed to lazy load element', error);
}
}
destroy() {
if (this.observer) {
this.observer.disconnect();
}
}
}

99
static/js/logger.js Normal file
View File

@ -0,0 +1,99 @@
export default class Logger {
constructor(config = {}) {
this.levels = { debug: 0, info: 1, warn: 2, error: 3 };
this.currentLevel = config.level || 'info';
this.maxLogs = config.maxLogs || 100;
this.enableRemote = config.enableRemote || false;
this.remoteEndpoint = config.remoteEndpoint || '/api/logs';
this._setupGlobalHandlers();
}
log(level, message, data = null) {
const levelIndex = this.levels[level];
const currentIndex = this.levels[this.currentLevel];
if (levelIndex < currentIndex) return;
const timestamp = new Date().toISOString();
const entry = {
timestamp,
level,
message,
data,
url: window.location.href,
userAgent: navigator.userAgent
};
this._console(level, message, data);
this._persistLog(entry);
if (this.enableRemote && level === 'error') {
this._sendRemote(entry);
}
}
debug(message, data) { this.log('debug', message, data); }
info(message, data) { this.log('info', message, data); }
warn(message, data) { this.log('warn', message, data); }
error(message, data) { this.log('error', message, data); }
_console(level, message, data) {
const logFn = console[level] || console.log;
const timestamp = new Date().toISOString();
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
logFn(`${prefix} ${message}`, data || '');
}
_persistLog(entry) {
if (typeof localStorage === 'undefined') return;
try {
const logs = JSON.parse(localStorage.getItem('app_logs') || '[]');
logs.push(entry);
if (logs.length > this.maxLogs) {
logs.splice(0, logs.length - this.maxLogs);
}
localStorage.setItem('app_logs', JSON.stringify(logs));
} catch (err) {
console.error('Failed to persist log:', err);
}
}
_sendRemote(entry) {
fetch(this.remoteEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entry)
}).catch(err => console.error('Remote logging failed:', err));
}
_setupGlobalHandlers() {
window.addEventListener('error', (event) => {
this.error(`JavaScript Error: ${event.message}`, {
file: event.filename,
line: event.lineno,
column: event.colno,
stack: event.error?.stack
});
});
window.addEventListener('unhandledrejection', (event) => {
this.error(`Unhandled Promise Rejection: ${event.reason}`, {
stack: event.reason?.stack
});
});
}
exportLogs() {
if (typeof localStorage === 'undefined') return [];
return JSON.parse(localStorage.getItem('app_logs') || '[]');
}
clearLogs() {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('app_logs');
}
}
}

View File

@ -1,17 +1,61 @@
import { RBoxApp } from './components/rbox-app.js';
import { verifyStartup, showCompatibilityError } from './startup-check.js';
// Define the custom element
customElements.define('rbox-app', RBoxApp);
const startupResults = verifyStartup();
// Instantiate the main application class
const app = new RBoxApp();
if (startupResults.failed.length > 0) {
console.error('Startup checks failed:', startupResults.failed);
showCompatibilityError(startupResults);
} else {
console.log('All startup checks passed');
// Append the app to the body (if not already in index.html)
// document.body.appendChild(app);
import('./app.js').then(({ default: app }) => {
return Promise.all([
import('./components/error-boundary.js'),
import('./components/rbox-app.js')
]).then(([errorBoundary, rboxApp]) => {
if (!app.isReady()) {
console.error('CRITICAL: Application failed to initialize properly');
document.body.innerHTML = `
<div style="padding: 2rem; text-align: center; font-family: sans-serif;">
<h1 style="color: #d32f2f;">Application Failed to Initialize</h1>
<p>Please refresh the page. If the problem persists, check the console for errors.</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>
`;
return;
}
// Register service worker for PWA
if ('serviceWorker' in navigator) {
customElements.define('rbox-app', rboxApp.RBoxApp);
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/service-worker.js');
navigator.serviceWorker.register('/static/service-worker.js').then(() => {
app.getLogger().info('Service worker registered successfully');
}).catch((error) => {
app.getLogger().error('Service worker registration failed', error);
});
});
}
app.getLogger().info('Main application loaded successfully');
window.addEventListener('load', () => {
app.getLazyLoader().observeAll('[data-src]');
app.getLogger().info('Application fully ready');
});
});
}).catch((error) => {
console.error('Failed to load application modules:', error);
document.body.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>
`;
});
}

71
static/js/perf-monitor.js Normal file
View File

@ -0,0 +1,71 @@
export default class PerformanceMonitor {
constructor(logger = null) {
this.logger = logger;
this.metrics = new Map();
}
measureOperation(name, fn) {
const start = performance.now();
try {
const result = fn();
const duration = performance.now() - start;
this._recordMetric(name, duration);
return result;
} catch (error) {
this.logger?.error(`Operation ${name} failed`, error);
throw error;
}
}
async measureAsync(name, fn) {
const start = performance.now();
try {
const result = await fn();
const duration = performance.now() - start;
this._recordMetric(name, duration);
return result;
} catch (error) {
this.logger?.error(`Async operation ${name} failed`, error);
throw error;
}
}
_recordMetric(name, duration) {
if (!this.metrics.has(name)) {
this.metrics.set(name, []);
}
this.metrics.get(name).push(duration);
this.logger?.debug(`[PERF] ${name}: ${duration.toFixed(2)}ms`);
}
getMetrics(name) {
const values = this.metrics.get(name) || [];
return {
count: values.length,
min: Math.min(...values),
max: Math.max(...values),
avg: values.reduce((a, b) => a + b, 0) / values.length,
values
};
}
reportWebVitals() {
if ('web-vital' in window) {
const vitals = window['web-vital'];
this.logger?.info('Core Web Vitals', vitals);
}
}
getAllMetrics() {
const result = {};
for (const [name, values] of this.metrics.entries()) {
result[name] = this.getMetrics(name);
}
return result;
}
reset() {
this.metrics.clear();
}
}

125
static/js/router.js Normal file
View File

@ -0,0 +1,125 @@
export default class Router {
constructor(options = {}) {
this.routes = new Map();
this.currentRoute = null;
this.basePath = options.basePath || '/';
this.transitionEnabled = true;
this.logger = options.logger || null;
this.appState = options.appState || null;
this._setupListeners();
}
register(path, handler, metadata = {}) {
this.routes.set(path, { handler, metadata });
return this;
}
async navigate(path, state = {}, skipTransition = false) {
try {
const route = this._matchRoute(path);
if (!route) {
this.logger?.warn(`No route found for: ${path}`);
return;
}
if (this.currentRoute?.path === path) return;
this.appState?.setState({ isLoading: true });
const performTransition = () => {
if (this.transitionEnabled && !skipTransition && document.startViewTransition) {
document.startViewTransition(() => {
this._executeRoute(path, route, state);
}).finished.then(() => {
this.appState?.setState({ isLoading: false });
}).catch(err => {
this.logger?.error('View transition failed', err);
this.appState?.setState({ isLoading: false });
});
} else {
this._executeRoute(path, route, state);
this.appState?.setState({ isLoading: false });
}
};
window.history.pushState(state, '', `${this.basePath}${path}`);
performTransition();
} catch (error) {
this.logger?.error(`Navigation error: ${error.message}`);
this.appState?.setState({ isLoading: false });
}
}
_executeRoute(path, route, state) {
const root = document.getElementById('app-root');
if (!root) return;
root.innerHTML = '';
this.currentRoute = { path, ...route };
try {
const result = route.handler(state);
if (result instanceof HTMLElement) {
root.appendChild(result);
} else if (typeof result === 'string') {
root.innerHTML = result;
}
this.appState?.setState({ currentPage: path });
window.dispatchEvent(new CustomEvent('route-changed', { detail: { path, state } }));
} catch (error) {
this.logger?.error(`Route handler failed for ${path}`, error);
root.innerHTML = '<h1>Error loading page</h1>';
}
}
_matchRoute(path) {
if (this.routes.has(path)) {
return this.routes.get(path);
}
for (const [routePath, route] of this.routes.entries()) {
const regex = this._pathToRegex(routePath);
if (regex.test(path)) {
return route;
}
}
return null;
}
_pathToRegex(path) {
const pattern = path
.replace(/\//g, '\\/')
.replace(/:(\w+)/g, '(?<$1>[^/]+)')
.replace(/\*/g, '.*');
return new RegExp(`^${pattern}$`);
}
_setupListeners() {
window.addEventListener('popstate', (event) => {
const path = window.location.pathname.replace(this.basePath, '') || '/';
this.navigate(path, event.state, true);
});
document.addEventListener('click', (event) => {
const link = event.target.closest('a[data-nav]');
if (link) {
event.preventDefault();
const path = link.getAttribute('href') || link.getAttribute('data-nav');
this.navigate(path);
}
});
}
back() {
window.history.back();
}
forward() {
window.history.forward();
}
}

View File

@ -0,0 +1,89 @@
export function verifyStartup() {
const checks = [];
checks.push({
name: 'LocalStorage Available',
test: () => {
try {
const test = '__storage_test__';
localStorage.setItem(test, test);
localStorage.removeItem(test);
return true;
} catch (e) {
return false;
}
}
});
checks.push({
name: 'Fetch API Available',
test: () => typeof fetch === 'function'
});
checks.push({
name: 'Promises Available',
test: () => typeof Promise === 'function'
});
checks.push({
name: 'Custom Elements Supported',
test: () => 'customElements' in window
});
checks.push({
name: 'ES6 Modules Supported',
test: () => {
try {
new Function('import("")');
return true;
} catch (e) {
return false;
}
}
});
const results = {
passed: [],
failed: []
};
for (const check of checks) {
try {
if (check.test()) {
results.passed.push(check.name);
} else {
results.failed.push(check.name);
}
} catch (error) {
results.failed.push(`${check.name} (Error: ${error.message})`);
}
}
return results;
}
export function showCompatibilityError(results) {
document.body.innerHTML = `
<div style="padding: 2rem; max-width: 600px; margin: 2rem auto; font-family: sans-serif; background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<h1 style="color: #d32f2f; margin-bottom: 1rem;">Browser Not Supported</h1>
<p style="margin-bottom: 1rem;">Your browser does not support the required features for this application.</p>
<h3 style="color: #666; margin-top: 1.5rem;">Failed Checks:</h3>
<ul style="color: #d32f2f; margin: 0.5rem 0 1rem 1.5rem;">
${results.failed.map(f => `<li>${f}</li>`).join('')}
</ul>
${results.passed.length > 0 ? `
<h3 style="color: #666; margin-top: 1.5rem;">Passed Checks:</h3>
<ul style="color: #4caf50; margin: 0.5rem 0 1rem 1.5rem;">
${results.passed.map(p => `<li>${p}</li>`).join('')}
</ul>
` : ''}
<p style="margin-top: 1.5rem; padding: 1rem; background: #f5f5f5; border-radius: 4px;">
<strong>Recommended browsers:</strong><br>
Chrome 90+, Firefox 88+, Safari 14+, Edge 90+
</p>
</div>
`;
}

92
static/js/state.js Normal file
View File

@ -0,0 +1,92 @@
export default class AppState {
constructor(initialState = {}, reducers = {}) {
this._state = { ...initialState };
this._reducers = reducers;
this._subscribers = new Set();
this._middlewares = [];
this._history = [{ ...initialState }];
this._historyIndex = 0;
}
use(middleware) {
this._middlewares.push(middleware);
return this;
}
subscribe(callback) {
this._subscribers.add(callback);
return () => this._subscribers.delete(callback);
}
getState() {
return { ...this._state };
}
setState(updates) {
const prevState = this.getState();
this._state = { ...this._state, ...updates };
this._history = this._history.slice(0, this._historyIndex + 1);
this._history.push({ ...this._state });
this._historyIndex++;
this._notifySubscribers(prevState, { type: 'STATE_UPDATE', payload: updates });
}
async dispatch(action) {
let processedAction = action;
for (const middleware of this._middlewares) {
processedAction = await middleware(processedAction, this.getState(), this);
if (!processedAction) return;
}
const { type, payload } = processedAction;
const reducer = this._reducers[type];
if (!reducer) {
console.warn(`No reducer for action: ${type}`);
return;
}
try {
const prevState = this.getState();
const newState = reducer(prevState, payload);
this._state = { ...newState };
this._history = this._history.slice(0, this._historyIndex + 1);
this._history.push({ ...this._state });
this._historyIndex++;
this._notifySubscribers(prevState, processedAction);
} catch (error) {
console.error(`Reducer error for ${type}:`, error);
}
}
undo() {
if (this._historyIndex > 0) {
this._historyIndex--;
this._state = { ...this._history[this._historyIndex] };
this._notifySubscribers(null, { type: 'UNDO' });
}
}
redo() {
if (this._historyIndex < this._history.length - 1) {
this._historyIndex++;
this._state = { ...this._history[this._historyIndex] };
this._notifySubscribers(null, { type: 'REDO' });
}
}
_notifySubscribers(prevState, action) {
this._subscribers.forEach(callback => {
try {
callback(this.getState(), prevState, action);
} catch (error) {
console.error('Subscriber error:', error);
}
});
}
}

71
static/js/utils.js Normal file
View File

@ -0,0 +1,71 @@
export function debounce(fn, delay = 300) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delay);
};
}
export function throttle(fn, delay = 300) {
let lastCall = 0;
let timeoutId;
return function (...args) {
const now = Date.now();
if (now - lastCall >= delay) {
lastCall = now;
fn.apply(this, args);
} else {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
lastCall = Date.now();
fn.apply(this, args);
}, delay - (now - lastCall));
}
};
}
export function once(fn) {
let called = false;
return function (...args) {
if (!called) {
called = true;
return fn.apply(this, args);
}
};
}
export function compose(...fns) {
return (value) => fns.reduceRight((acc, fn) => fn(acc), value);
}
export function pipe(...fns) {
return (value) => fns.reduce((acc, fn) => fn(acc), value);
}
export async function retry(fn, maxAttempts = 3, delay = 1000) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxAttempts) throw error;
await new Promise(resolve => setTimeout(resolve, delay * attempt));
}
}
}
export function deepClone(obj) {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return new Date(obj.getTime());
if (obj instanceof Array) return obj.map(item => deepClone(item));
if (obj instanceof Object) {
const cloned = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
cloned[key] = deepClone(obj[key]);
}
}
return cloned;
}
}

View File

@ -1,7 +1,7 @@
import pytest
import pytest_asyncio
import asyncio
from playwright.async_api import async_playwright
from playwright.async_api import async_playwright, Page, expect
@pytest.fixture(scope="session")
def event_loop():
@ -35,4 +35,33 @@ async def page(context):
@pytest.fixture
def base_url():
return "http://localhost:8000"
return "http://localhost:9004"
@pytest_asyncio.fixture(scope="function", autouse=True)
async def login(page: Page, base_url):
print(f"Navigating to base_url: {base_url}")
await page.goto(f"{base_url}/")
await page.screenshot(path="01_initial_page.png")
# If already logged in, log out first to ensure a clean state
if await page.locator('a:has-text("Logout")').is_visible():
await page.click('a:has-text("Logout")')
await page.screenshot(path="02_after_logout.png")
# Now, proceed with login or registration
login_form = page.locator('#login-form:visible')
if await login_form.count() > 0:
await login_form.locator('input[name="username"]').fill('billingtest')
await login_form.locator('input[name="password"]').fill('password123')
await page.screenshot(path="03_before_login_click.png")
await expect(page.locator('h2:has-text("Files")')).to_be_visible(timeout=10000)
else:
# If no login form, try to register
await page.click('text=Sign Up')
register_form = page.locator('#register-form:visible')
await register_form.locator('input[name="username"]').fill('billingtest')
await register_form.locator('input[name="email"]').fill('billingtest@example.com')
await register_form.locator('input[name="password"]').fill('password123')
await page.screenshot(path="05_before_register_click.png")
await register_form.locator('button[type="submit"]').click()
await expect(page.locator('h1:has-text("My Files")')).to_be_visible(timeout=10000)

View File

@ -0,0 +1,127 @@
import pytest
import asyncio
from playwright.async_api import expect, Page
@pytest.mark.asyncio
class TestBillingUserFlowRefactored:
async def test_navigate_to_billing_dashboard(self, page: Page):
await page.click('a.nav-link[data-view="billing"]')
await expect(page.locator('h1:has-text("Billing & Usage")')).to_be_visible(timeout=5000)
await expect(page.locator('billing-dashboard')).to_be_visible()
async def test_view_current_usage(self, page: Page):
await page.click('a.nav-link[data-view="billing"]')
await expect(page.locator('h1:has-text("Billing & Usage")')).to_be_visible(timeout=5000)
await expect(page.locator('.usage-card')).to_be_visible()
await expect(page.locator('text=Current Usage')).to_be_visible()
await expect(page.locator('.usage-label:has-text("Storage")')).to_be_visible()
await expect(page.locator('.usage-label:has-text("Bandwidth")')).to_be_visible()
async def test_view_estimated_cost(self, page: Page):
await page.click('a.nav-link[data-view="billing"]')
await expect(page.locator('h1:has-text("Billing & Usage")')).to_be_visible(timeout=5000)
await expect(page.locator('.cost-card')).to_be_visible()
await expect(page.locator('text=Estimated Monthly Cost')).to_be_visible()
await expect(page.locator('.estimated-cost')).to_be_visible()
cost_text = await page.locator('.estimated-cost').text_content()
assert '$' in cost_text
async def test_view_pricing_information(self, page: Page):
await page.click('a.nav-link[data-view="billing"]')
await expect(page.locator('h1:has-text("Billing & Usage")')).to_be_visible(timeout=5000)
await expect(page.locator('.pricing-card')).to_be_visible()
await expect(page.locator('text=Current Pricing')).to_be_visible()
await expect(page.locator('.pricing-item:has-text("Storage")')).to_be_visible()
await expect(page.locator('.pricing-item:has-text("Bandwidth")')).to_be_visible()
await expect(page.locator('.pricing-item:has-text("Free Tier")')).to_be_visible()
async def test_view_invoice_history_empty(self, page: Page):
await page.click('a.nav-link[data-view="billing"]')
await expect(page.locator('h1:has-text("Billing & Usage")')).to_be_visible(timeout=5000)
await expect(page.locator('.invoices-section')).to_be_visible()
await expect(page.locator('text=Recent Invoices')).to_be_visible()
no_invoices = page.locator('.no-invoices')
if await no_invoices.is_visible():
await expect(no_invoices).to_contain_text('No invoices yet')
async def test_upload_file_to_track_usage(self, page: Page):
# Navigate back to files view
await page.click('a.nav-link[data-view="files"]')
await expect(page.locator('h2:has-text("Files")')).to_be_visible(timeout=5000)
await page.set_input_files('input[type="file"]', {
'name': 'test-file.txt',
'mimeType': 'text/plain',
'buffer': b'This is a test file for billing usage tracking.'
})
await page.click('button:has-text("Upload")')
await expect(page.locator('text=test-file.txt')).to_be_visible(timeout=10000)
async def test_verify_usage_updated_after_upload(self, page: Page):
await page.click('a.nav-link[data-view="billing"]')
await expect(page.locator('h1:has-text("Billing & Usage")')).to_be_visible(timeout=5000)
storage_value_locator = page.locator('.usage-item:has(.usage-label:has-text("Storage")) .usage-value')
await expect(storage_value_locator).not_to_contain_text("0 B", timeout=10000)
async def test_add_payment_method_button(self, page: Page):
await page.click('a.nav-link[data-view="billing"]')
await expect(page.locator('h1:has-text("Billing & Usage")')).to_be_visible(timeout=5000)
await expect(page.locator('.payment-methods-section')).to_be_visible()
await expect(page.locator('text=Payment Methods')).to_be_visible()
await expect(page.locator('#addPaymentMethod')).to_be_visible()
async def test_click_add_payment_method(self, page: Page):
await page.click('a.nav-link[data-view="billing"]')
await expect(page.locator('h1:has-text("Billing & Usage")')).to_be_visible(timeout=5000)
page.on('dialog', lambda dialog: dialog.accept())
await page.click('#addPaymentMethod')
# We can't assert much here as it likely navigates to Stripe
await page.wait_for_timeout(1000) # Allow time for navigation
async def test_view_subscription_status(self, page: Page):
await page.click('a.nav-link[data-view="billing"]')
await expect(page.locator('h1:has-text("Billing & Usage")')).to_be_visible(timeout=5000)
await expect(page.locator('.subscription-badge')).to_be_visible()
badge_text = await page.locator('.subscription-badge').text_content()
assert badge_text in ['Pay As You Go', 'Free', 'Active']
async def test_verify_free_tier_display(self, page: Page):
await page.click('a.nav-link[data-view="billing"]')
await expect(page.locator('h1:has-text("Billing & Usage")')).to_be_visible(timeout=5000)
await expect(page.locator('.usage-info:has-text("GB included free")')).to_be_visible()
free_tier_info = await page.locator('.usage-info').text_content()
assert '15' in free_tier_info or 'GB' in free_tier_info
async def test_verify_progress_bar(self, page: Page):
await page.click('a.nav-link[data-view="billing"]')
await expect(page.locator('h1:has-text("Billing & Usage")')).to_be_visible(timeout=5000)
await expect(page.locator('.usage-progress')).to_be_visible()
await expect(page.locator('.usage-progress-bar')).to_be_visible()
async def test_verify_cost_breakdown(self, page: Page):
await page.click('a.nav-link[data-view="billing"]')
await expect(page.locator('h1:has-text("Billing & Usage")')).to_be_visible(timeout=5000)
await expect(page.locator('.cost-breakdown')).to_be_visible()
await expect(page.locator('.cost-item:has-text("Storage")')).to_be_visible()
await expect(page.locator('.cost-item:has-text("Bandwidth")')).to_be_visible()