This commit is contained in:
retoor 2025-11-11 01:05:13 +01:00
parent 2325661df4
commit ba73b8bdf7
31 changed files with 2026 additions and 306 deletions

View File

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

View File

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

View File

@ -108,15 +108,22 @@ class UsageTracker:
@staticmethod @staticmethod
async def get_current_storage(user: User) -> int: async def get_current_storage(user: User) -> int:
from ..models import File 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) return sum(f.size for f in files)
@staticmethod @staticmethod
async def get_monthly_usage(user: User, year: int, month: int) -> dict: 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( aggregates = await UsageAggregate.filter(
user=user, user=user,
date__year=year, date__gte=start_date,
date__month=month date__lte=end_date
).all() ).all()
if not aggregates: if not aggregates:

View File

@ -2,10 +2,9 @@ from datetime import timedelta
from typing import Optional, List from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel 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 ..models import User
from ..schemas import Token, UserCreate, TokenData, UserLoginWith2FA from ..schemas import Token, UserCreate, TokenData, UserLoginWith2FA
from ..two_factor import ( from ..two_factor import (
@ -19,6 +18,10 @@ router = APIRouter(
tags=["auth"], tags=["auth"],
) )
class LoginRequest(BaseModel):
username: str
password: str
class TwoFactorLogin(BaseModel): class TwoFactorLogin(BaseModel):
username: str username: str
password: str password: str
@ -65,8 +68,8 @@ async def register_user(user_in: UserCreate):
return {"access_token": access_token, "token_type": "bearer"} return {"access_token": access_token, "token_type": "bearer"}
@router.post("/token", response_model=Token) @router.post("/token", response_model=Token)
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()): async def login_for_access_token(login_data: LoginRequest):
auth_result = await authenticate_user(form_data.username, form_data.password, None) auth_result = await authenticate_user(login_data.username, login_data.password, None)
if not auth_result: if not auth_result:
raise HTTPException( raise HTTPException(
@ -83,7 +86,7 @@ async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(
headers={"X-2FA-Required": "true"}, headers={"X-2FA-Required": "true"},
) )
access_token_expires = timedelta(minutes=30) # Use settings access_token_expires = timedelta(minutes=30)
access_token = create_access_token( access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires, two_factor_verified=True 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") @router.get("/usage/current")
async def get_current_usage(current_user: User = Depends(get_current_user)): async def get_current_usage(current_user: User = Depends(get_current_user)):
try:
storage_bytes = await UsageTracker.get_current_storage(current_user) storage_bytes = await UsageTracker.get_current_storage(current_user)
today = date.today() today = date.today()
@ -71,6 +72,8 @@ async def get_current_usage(current_user: User = Depends(get_current_user)):
"bandwidth_up_gb_today": 0, "bandwidth_up_gb_today": 0,
"as_of": today.isoformat() "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") @router.get("/usage/monthly")
async def get_monthly_usage( async def get_monthly_usage(
@ -78,17 +81,27 @@ async def get_monthly_usage(
month: Optional[int] = None, month: Optional[int] = None,
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
) -> UsageResponse: ) -> UsageResponse:
try:
if year is None or month is None: if year is None or month is None:
now = datetime.now() now = datetime.now()
year = now.year year = now.year
month = now.month 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) usage = await UsageTracker.get_monthly_usage(current_user, year, month)
return UsageResponse( return UsageResponse(
**usage, **usage,
period=f"{year}-{month:02d}" 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") @router.get("/invoices")
async def list_invoices( async def list_invoices(
@ -96,6 +109,12 @@ async def list_invoices(
offset: int = 0, offset: int = 0,
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
) -> List[InvoiceResponse]: ) -> 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() invoices = await Invoice.filter(user=current_user).order_by("-created_at").offset(offset).limit(limit).all()
result = [] result = []
@ -125,6 +144,10 @@ async def list_invoices(
)) ))
return result 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}") @router.get("/invoices/{invoice_id}")
async def get_invoice( 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") @router.post("/payment-methods/setup-intent")
async def create_setup_intent(current_user: User = Depends(get_current_user)): 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) subscription = await UserSubscription.get_or_none(user=current_user)
if not subscription or not subscription.stripe_customer_id: 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() await subscription.save()
import stripe import stripe
StripeClient._ensure_api_key()
setup_intent = stripe.SetupIntent.create( setup_intent = stripe.SetupIntent.create(
customer=subscription.stripe_customer_id, customer=subscription.stripe_customer_id,
payment_method_types=["card"] 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, "client_secret": setup_intent.client_secret,
"customer_id": subscription.stripe_customer_id "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") @router.get("/payment-methods")
async def list_payment_methods(current_user: User = Depends(get_current_user)): 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): async def stripe_webhook(request: Request):
import stripe import stripe
from ..settings import settings from ..settings import settings
from ..billing.models import BillingEvent
try:
payload = await request.body() payload = await request.body()
sig_header = request.headers.get("stripe-signature") sig_header = request.headers.get("stripe-signature")
if not settings.STRIPE_WEBHOOK_SECRET:
raise HTTPException(status_code=503, detail="Webhook secret not configured")
try: try:
event = stripe.Webhook.construct_event( event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
) )
except ValueError: except ValueError as e:
raise HTTPException(status_code=400, detail="Invalid payload") raise HTTPException(status_code=400, detail=f"Invalid payload: {str(e)}")
except stripe.error.SignatureVerificationError: except stripe.error.SignatureVerificationError as e:
raise HTTPException(status_code=400, detail="Invalid signature") 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": if event["type"] == "invoice.payment_succeeded":
invoice_data = event["data"]["object"] invoice_data = event["data"]["object"]
@ -280,7 +330,12 @@ async def stripe_webhook(request: Request):
is_default=True is_default=True
) )
await BillingEvent.filter(stripe_event_id=event_id).update(processed=True)
return JSONResponse(content={"status": "success"}) 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") @router.get("/pricing")
async def get_pricing(): async def get_pricing():
@ -310,3 +365,10 @@ async def list_plans():
} }
for plan in 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; border-radius: 4px;
font-size: 1rem; 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 { .code-editor-overlay {
position: absolute; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 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; display: flex;
flex-direction: column; flex-direction: column;
background: white; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
z-index: 100;
} }
.code-editor-header { .code-editor-header {
@ -49,6 +61,16 @@
font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px; font-size: 14px;
background: white; 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 { .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/code-editor-view.css">
<link rel="stylesheet" href="/static/css/file-upload-view.css"> <link rel="stylesheet" href="/static/css/file-upload-view.css">
<link rel="manifest" href="/static/manifest.json"> <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/codemirror.min.js"></script>
<script src="/static/lib/codemirror/javascript.min.js"></script> <script src="/static/lib/codemirror/javascript.min.js"></script>
<script src="/static/lib/codemirror/python.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 { class APIClient {
constructor(baseURL = '/') { constructor(baseURL = '/', logger = null, perfMonitor = null, appState = null) {
this.baseURL = baseURL; this.baseURL = baseURL;
this.token = localStorage.getItem('token'); this.token = localStorage.getItem('token');
this.logger = logger;
this.perfMonitor = perfMonitor;
this.appState = appState;
this.activeRequests = 0;
} }
setToken(token) { setToken(token) {
@ -18,7 +22,16 @@ class APIClient {
} }
async request(endpoint, options = {}) { async request(endpoint, options = {}) {
this.activeRequests++;
this.appState?.setState({ isLoading: true });
const startTime = performance.now();
const url = `${this.baseURL}${endpoint}`; const url = `${this.baseURL}${endpoint}`;
const method = options.method || 'GET';
try {
this.logger?.debug(`API ${method}: ${endpoint}`);
const headers = { const headers = {
...options.headers, ...options.headers,
}; };
@ -27,22 +40,44 @@ class APIClient {
headers['Authorization'] = `Bearer ${this.token}`; headers['Authorization'] = `Bearer ${this.token}`;
} }
if (!(options.body instanceof FormData) && options.body) {
headers['Content-Type'] = 'application/json';
}
const config = { const config = {
...options, ...options,
headers, 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); config.body = JSON.stringify(config.body);
this.logger?.debug('Request body serialized to JSON');
}
} }
const response = await fetch(url, config); 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) { if (response.status === 401) {
this.logger?.warn('Unauthorized request, clearing token');
this.setToken(null); this.setToken(null);
window.location.href = '/'; window.location.href = '/';
} }
@ -55,39 +90,79 @@ class APIClient {
errorData = { message: 'Unknown error' }; errorData = { message: 'Unknown error' };
} }
const errorMessage = errorData.detail || errorData.message || 'Request failed'; 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', { document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: errorMessage, type: 'error' } detail: { message: errorMessage, type: 'error' }
})); }));
throw new Error(errorMessage); throw new Error(errorMessage);
} }
this.logger?.debug(`API ${method} ${endpoint} success`, {
status: response.status,
duration: `${duration.toFixed(2)}ms`
});
if (response.status === 204) { if (response.status === 204) {
return null; 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(); 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) { async register(username, email, password) {
this.logger?.info('Attempting registration', { username, email });
const data = await this.request('auth/register', { const data = await this.request('auth/register', {
method: 'POST', method: 'POST',
body: { username, email, password }, body: { username, email, password },
skipAuth: true 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); this.setToken(data.access_token);
return data; return data;
} }
async login(username, password) { async login(username, password) {
const formData = new FormData(); this.logger?.info('Attempting login', { username });
formData.append('username', username);
formData.append('password', password);
const data = await this.request('auth/token', { const data = await this.request('auth/token', {
method: 'POST', method: 'POST',
body: formData, body: { username, password },
skipAuth: true 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); this.setToken(data.access_token);
return data; 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.pricingConfig = [];
this.stats = null; this.stats = null;
this.boundHandleClick = this.handleClick.bind(this); this.boundHandleClick = this.handleClick.bind(this);
this.loading = true;
this.error = null;
} }
async connectedCallback() { async connectedCallback() {
@ -18,6 +20,8 @@ class AdminBilling extends HTMLElement {
} }
async loadData() { async loadData() {
this.loading = true;
this.error = null;
try { try {
const [pricing, stats] = await Promise.all([ const [pricing, stats] = await Promise.all([
this.fetchPricing(), this.fetchPricing(),
@ -26,8 +30,11 @@ class AdminBilling extends HTMLElement {
this.pricingConfig = pricing; this.pricingConfig = pricing;
this.stats = stats; this.stats = stats;
this.loading = false;
} catch (error) { } catch (error) {
console.error('Failed to load admin billing data:', 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() { 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 = ` this.innerHTML = `
<div class="admin-billing"> <div class="admin-billing">
<h2>Billing Administration</h2> <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.pricing = null;
this.invoices = []; this.invoices = [];
this.boundHandleClick = this.handleClick.bind(this); this.boundHandleClick = this.handleClick.bind(this);
this.loading = true;
this.error = null;
this.stripe = null;
} }
async connectedCallback() { async connectedCallback() {
this.addEventListener('click', this.boundHandleClick); this.addEventListener('click', this.boundHandleClick);
this.render();
await this.loadData(); await this.loadData();
this.render(); this.render();
this.attachEventListeners(); this.attachEventListeners();
await this.initStripe();
} }
disconnectedCallback() { disconnectedCallback() {
@ -20,6 +25,8 @@ class BillingDashboard extends HTMLElement {
} }
async loadData() { async loadData() {
this.loading = true;
this.error = null;
try { try {
const [usage, subscription, pricing, invoices] = await Promise.all([ const [usage, subscription, pricing, invoices] = await Promise.all([
this.fetchCurrentUsage(), this.fetchCurrentUsage(),
@ -32,8 +39,21 @@ class BillingDashboard extends HTMLElement {
this.subscription = subscription; this.subscription = subscription;
this.pricing = pricing; this.pricing = pricing;
this.invoices = invoices; this.invoices = invoices;
this.loading = false;
} catch (error) { } catch (error) {
console.error('Failed to load billing data:', 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() { 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 estimatedCost = this.calculateEstimatedCost();
const storageUsed = this.currentUsage?.storage_gb || 0; const storageUsed = this.currentUsage?.storage_gb || 0;
const freeStorage = parseFloat(this.pricing?.free_tier_storage_gb?.value || 15); const freeStorage = parseFloat(this.pricing?.free_tier_storage_gb?.value || 15);
@ -253,7 +283,74 @@ class BillingDashboard extends HTMLElement {
} }
async showPaymentMethodModal() { 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) { 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 { class CodeEditorView extends HTMLElement {
constructor() { constructor() {
@ -6,146 +9,205 @@ class CodeEditorView extends HTMLElement {
this.editor = null; this.editor = null;
this.file = null; this.file = null;
this.previousView = null; this.previousView = null;
this.boundHandleClick = this.handleClick.bind(this); this.isRendered = false;
this.boundHandleEscape = this.handleEscape.bind(this);
} }
connectedCallback() { connectedCallback() {
this.addEventListener('click', this.boundHandleClick); logger.debug('CodeEditorView connected');
document.addEventListener('keydown', this.boundHandleEscape);
} }
disconnectedCallback() { disconnectedCallback() {
this.removeEventListener('click', this.boundHandleClick); logger.debug('CodeEditorView disconnected');
document.removeEventListener('keydown', this.boundHandleEscape); this.destroyEditor();
if (this.editor) {
this.editor.toTextArea();
this.editor = null;
}
}
handleEscape(e) {
if (e.key === 'Escape') {
this.goBack();
}
} }
async setFile(file, previousView = 'files') { async setFile(file, previousView = 'files') {
this.file = file; if (this.isRendered) {
this.previousView = previousView; logger.warn('Editor already rendered, skipping');
await this.loadAndRender(); return;
} }
async loadAndRender() { this.file = file;
this.previousView = previousView;
this.isRendered = true;
try { 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(); const content = await blob.text();
this.render(content);
this.initializeEditor(content); this.createUI(content);
this.createEditor(content);
} catch (error) { } catch (error) {
console.error('Failed to load file:', error); logger.error('Failed to load file', error);
document.dispatchEvent(new CustomEvent('show-toast', { document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Failed to load file: ' + error.message, type: 'error' } detail: { message: 'Failed to load file: ' + error.message, type: 'error' }
})); }));
this.render('');
window.history.back(); window.history.back();
} }
} }
getMimeType(filename) { createUI(content) {
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) {
this.innerHTML = ` this.innerHTML = `
<div class="code-editor-view"> <div class="code-editor-overlay">
<div class="code-editor-container">
<div class="code-editor-header"> <div class="code-editor-header">
<div class="header-left"> <div class="header-left">
<button class="button" id="back-btn">Back</button> <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>
<div class="header-right"> <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> </div>
<div class="code-editor-body"> <div class="code-editor-body">
<textarea id="code-editor-textarea">${content}</textarea> <textarea id="editor-textarea"></textarea>
</div>
</div> </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) { createEditor(content) {
const textarea = this.querySelector('#code-editor-textarea'); const textarea = this.querySelector('#editor-textarea');
if (!textarea) return; 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, { this.editor = CodeMirror.fromTextArea(textarea, {
value: content, mode: mode,
mode: this.getMimeType(this.file.name),
lineNumbers: true, lineNumbers: true,
theme: 'default',
lineWrapping: true, lineWrapping: true,
indentUnit: 4, indentUnit: 4,
indentWithTabs: false, indentWithTabs: false,
theme: 'default',
readOnly: false,
autofocus: true,
extraKeys: { extraKeys: {
'Ctrl-S': () => this.save(), 'Ctrl-S': () => { this.save(); return false; },
'Cmd-S': () => this.save() 'Cmd-S': () => { this.save(); return false; },
'Esc': () => { this.close(); return false; }
} }
}); });
this.editor.setSize('100%', '100%'); this.editor.setSize('100%', '100%');
setTimeout(() => {
if (this.editor) {
this.editor.refresh();
this.editor.focus();
logger.debug('Editor ready and focused');
}
}, 100);
} }
handleClick(e) { getMode(filename) {
if (e.target.id === 'back-btn') { const ext = filename.split('.').pop().toLowerCase();
this.goBack(); const modes = {
} else if (e.target.id === 'save-btn') { 'js': 'javascript',
this.save(); '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() { 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 { try {
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
const content = this.editor.getValue(); const content = this.editor.getValue();
logger.debug('Saving file', { fileName: this.file.name, size: content.length });
await api.updateFile(this.file.id, content); await api.updateFile(this.file.id, content);
logger.info('File saved successfully', { fileName: this.file.name });
document.dispatchEvent(new CustomEvent('show-toast', { document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'File saved successfully!', type: 'success' } detail: { message: 'File saved successfully!', type: 'success' }
})); }));
setTimeout(() => {
this.close();
}, 500);
} catch (error) { } catch (error) {
logger.error('Failed to save file', error);
document.dispatchEvent(new CustomEvent('show-toast', { 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(); window.history.back();
} }
hide() { hide() {
document.removeEventListener('keydown', this.boundHandleEscape); logger.debug('Hiding editor');
this.destroyEditor();
this.remove();
}
destroyEditor() {
if (this.editor) { if (this.editor) {
logger.debug('Destroying CodeMirror instance');
try {
this.editor.toTextArea(); this.editor.toTextArea();
} catch (e) {
logger.warn('Error destroying editor', e);
}
this.editor = null; 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'; this.style.display = 'block';
document.addEventListener('keydown', this.handleEscape); document.addEventListener('keydown', this.handleEscape);
this.renderPreview(); this.renderPreview();
if (pushState) {
window.history.pushState(
{ view: 'file-preview', file: file },
'',
`#preview/${file.id}`
);
}
} }
close() { close() {

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 { export class LoginView extends HTMLElement {
constructor() { constructor() {
@ -74,9 +77,12 @@ export class LoginView extends HTMLElement {
const errorDiv = this.querySelector('#login-error'); const errorDiv = this.querySelector('#login-error');
try { try {
logger.info('Login attempt started', { username });
await api.login(username, password); await api.login(username, password);
logger.info('Login successful, dispatching auth-success event');
this.dispatchEvent(new CustomEvent('auth-success')); this.dispatchEvent(new CustomEvent('auth-success'));
} catch (error) { } catch (error) {
logger.error('Login failed', { username, error: error.message });
errorDiv.textContent = error.message; errorDiv.textContent = error.message;
} }
} }
@ -91,9 +97,12 @@ export class LoginView extends HTMLElement {
const errorDiv = this.querySelector('#register-error'); const errorDiv = this.querySelector('#register-error');
try { try {
logger.info('Registration attempt started', { username, email });
await api.register(username, email, password); await api.register(username, email, password);
logger.info('Registration successful, dispatching auth-success event');
this.dispatchEvent(new CustomEvent('auth-success')); this.dispatchEvent(new CustomEvent('auth-success'));
} catch (error) { } catch (error) {
logger.error('Registration failed', { username, email, error: error.message });
errorDiv.textContent = 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 './login-view.js';
import './file-list.js'; import './file-list.js';
import './file-upload-view.js'; import './file-upload-view.js';
@ -16,22 +16,51 @@ import './admin-billing.js';
import './code-editor-view.js'; import './code-editor-view.js';
import { shortcuts } from '../shortcuts.js'; import { shortcuts } from '../shortcuts.js';
const api = app.getAPI();
const logger = app.getLogger();
const appState = app.getState();
export class RBoxApp extends HTMLElement { export class RBoxApp extends HTMLElement {
constructor() { constructor() {
super(); super();
this.currentView = 'files'; this.currentView = 'files';
this.user = null; this.user = null;
this.navigationStack = []; this.navigationStack = [];
this.boundHandlePopState = this.handlePopState.bind(this);
this.popstateAttached = false;
} }
async connectedCallback() { async connectedCallback() {
try {
await this.init(); await this.init();
this.addEventListener('show-toast', this.handleShowToast); 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() { disconnectedCallback() {
this.removeEventListener('show-toast', this.handleShowToast); this.removeEventListener('show-toast', this.handleShowToast);
if (this.popstateAttached) {
window.removeEventListener('popstate', this.boundHandlePopState);
this.popstateAttached = false;
logger.debug('Popstate listener removed');
}
} }
handleShowToast = (event) => { handleShowToast = (event) => {
@ -46,15 +75,21 @@ export class RBoxApp extends HTMLElement {
} }
async init() { async init() {
try {
if (!api.getToken()) { if (!api.getToken()) {
logger.info('No token found, showing login');
this.showLogin(); this.showLogin();
} else { } else {
try { logger.info('Initializing application with stored token');
this.user = await api.getCurrentUser(); this.user = await api.getCurrentUser();
appState.setState({ user: this.user });
logger.info('User loaded successfully', { username: this.user.username });
this.render(); 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) { handlePopState(e) {
logger.debug('Popstate event', { state: e.state, url: window.location.href });
this.closeAllOverlays(); this.closeAllOverlays();
if (e.state && e.state.view) { 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); 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); 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; const folderId = e.state.folderId !== undefined ? e.state.folderId : null;
this.showUpload(folderId, false); this.showUpload(folderId, false);
} else { } else {
this.switchView(e.state.view, false); logger.debug('Switching to view', { view });
this.switchView(view, false);
} }
} else { } else {
logger.debug('No state, defaulting to files view');
this.switchView('files', false); this.switchView('files', false);
} }
} }
closeAllOverlays() { closeAllOverlays() {
logger.debug('Closing all overlays');
const existingEditor = this.querySelector('code-editor-view'); const existingEditor = this.querySelector('code-editor-view');
if (existingEditor) { if (existingEditor) {
logger.debug('Hiding code editor');
existingEditor.hide(); existingEditor.hide();
} }
const existingPreview = this.querySelector('file-preview'); const existingPreview = this.querySelector('file-preview');
if (existingPreview) { if (existingPreview) {
logger.debug('Hiding file preview');
existingPreview.hide(); existingPreview.hide();
} }
const existingUpload = this.querySelector('file-upload-view'); const existingUpload = this.querySelector('file-upload-view');
if (existingUpload) { if (existingUpload) {
logger.debug('Hiding file upload');
existingUpload.hide(); existingUpload.hide();
} }
const shareModal = this.querySelector('share-modal'); const shareModal = this.querySelector('share-modal');
if (shareModal && shareModal.style.display !== 'none') { if (shareModal && shareModal.style.display !== 'none') {
logger.debug('Hiding share modal');
shareModal.style.display = 'none'; shareModal.style.display = 'none';
} }
} }
showCodeEditor(file, pushState = true) { showCodeEditor(file, pushState = true) {
logger.debug('Showing code editor', { file: file.name, pushState });
this.closeAllOverlays(); this.closeAllOverlays();
const mainElement = this.querySelector('.app-main'); const mainElement = this.querySelector('.app-main');
@ -432,15 +483,29 @@ export class RBoxApp extends HTMLElement {
editorView.setFile(file, this.currentView); editorView.setFile(file, this.currentView);
if (pushState) { if (pushState) {
const currentState = window.history.state || {};
const currentView = currentState.view || this.currentView;
if (currentView !== 'code-editor') {
window.history.pushState( 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}` `#editor/${file.id}`
); );
} }
} }
}
showFilePreview(file, pushState = true) { showFilePreview(file, pushState = true) {
logger.debug('Showing file preview', { file: file.name, pushState });
this.closeAllOverlays(); this.closeAllOverlays();
const mainElement = this.querySelector('.app-main'); const mainElement = this.querySelector('.app-main');
@ -449,15 +514,29 @@ export class RBoxApp extends HTMLElement {
preview.show(file, false); preview.show(file, false);
if (pushState) { if (pushState) {
const currentState = window.history.state || {};
const currentView = currentState.view || this.currentView;
if (currentView !== 'file-preview') {
window.history.pushState( 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}` `#preview/${file.id}`
); );
} }
} }
}
showUpload(folderId = null, pushState = true) { showUpload(folderId = null, pushState = true) {
logger.debug('Showing upload view', { folderId, pushState });
this.closeAllOverlays(); this.closeAllOverlays();
const mainElement = this.querySelector('.app-main'); const mainElement = this.querySelector('.app-main');
@ -466,11 +545,24 @@ export class RBoxApp extends HTMLElement {
uploadView.setFolder(folderId); uploadView.setFolder(folderId);
if (pushState) { if (pushState) {
const currentState = window.history.state || {};
const currentView = currentState.view || this.currentView;
if (currentView !== 'upload') {
window.history.pushState( window.history.pushState(
{ view: 'upload', folderId: folderId }, { view: 'upload', folderId: folderId, previousView: currentView },
'', '',
'#upload' '#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 const startupResults = verifyStartup();
customElements.define('rbox-app', RBoxApp);
// Instantiate the main application class if (startupResults.failed.length > 0) {
const app = new RBoxApp(); 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) import('./app.js').then(({ default: app }) => {
// document.body.appendChild(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;
}
customElements.define('rbox-app', rboxApp.RBoxApp);
// Register service worker for PWA
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', () => { 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
import pytest_asyncio import pytest_asyncio
import asyncio import asyncio
from playwright.async_api import async_playwright from playwright.async_api import async_playwright, Page, expect
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def event_loop(): def event_loop():
@ -35,4 +35,33 @@ async def page(context):
@pytest.fixture @pytest.fixture
def base_url(): 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)