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__/ __pycache__/
*.png
*.sqlite*
*.py[cod] *.py[cod]
*$py.class *$py.class
*.md *.md

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,25 +52,28 @@ 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)):
storage_bytes = await UsageTracker.get_current_storage(current_user) try:
today = date.today() storage_bytes = await UsageTracker.get_current_storage(current_user)
today = date.today()
usage_today = await UsageAggregate.get_or_none(user=current_user, date=today) usage_today = await UsageAggregate.get_or_none(user=current_user, date=today)
if usage_today:
return {
"storage_gb": round(storage_bytes / (1024**3), 4),
"bandwidth_down_gb_today": round(usage_today.bandwidth_down_bytes / (1024**3), 4),
"bandwidth_up_gb_today": round(usage_today.bandwidth_up_bytes / (1024**3), 4),
"as_of": today.isoformat()
}
if usage_today:
return { return {
"storage_gb": round(storage_bytes / (1024**3), 4), "storage_gb": round(storage_bytes / (1024**3), 4),
"bandwidth_down_gb_today": round(usage_today.bandwidth_down_bytes / (1024**3), 4), "bandwidth_down_gb_today": 0,
"bandwidth_up_gb_today": round(usage_today.bandwidth_up_bytes / (1024**3), 4), "bandwidth_up_gb_today": 0,
"as_of": today.isoformat() "as_of": today.isoformat()
} }
except Exception as e:
return { raise HTTPException(status_code=500, detail=f"Failed to fetch usage data: {str(e)}")
"storage_gb": round(storage_bytes / (1024**3), 4),
"bandwidth_down_gb_today": 0,
"bandwidth_up_gb_today": 0,
"as_of": today.isoformat()
}
@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:
if year is None or month is None: try:
now = datetime.now() if year is None or month is None:
year = now.year now = datetime.now()
month = now.month year = now.year
month = now.month
usage = await UsageTracker.get_monthly_usage(current_user, year, 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")
return UsageResponse( usage = await UsageTracker.get_monthly_usage(current_user, year, month)
**usage,
period=f"{year}-{month:02d}" 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") @router.get("/invoices")
async def list_invoices( async def list_invoices(
@ -96,35 +109,45 @@ 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]:
invoices = await Invoice.filter(user=current_user).order_by("-created_at").offset(offset).limit(limit).all() 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")
result = [] invoices = await Invoice.filter(user=current_user).order_by("-created_at").offset(offset).limit(limit).all()
for invoice in invoices:
line_items = await invoice.line_items.all()
result.append(InvoiceResponse(
id=invoice.id,
invoice_number=invoice.invoice_number,
period_start=invoice.period_start,
period_end=invoice.period_end,
subtotal=float(invoice.subtotal),
tax=float(invoice.tax),
total=float(invoice.total),
status=invoice.status,
due_date=invoice.due_date,
paid_at=invoice.paid_at,
line_items=[
{
"description": item.description,
"quantity": float(item.quantity),
"unit_price": float(item.unit_price),
"amount": float(item.amount),
"type": item.item_type
}
for item in line_items
]
))
return result result = []
for invoice in invoices:
line_items = await invoice.line_items.all()
result.append(InvoiceResponse(
id=invoice.id,
invoice_number=invoice.invoice_number,
period_start=invoice.period_start,
period_end=invoice.period_end,
subtotal=float(invoice.subtotal),
tax=float(invoice.tax),
total=float(invoice.total),
status=invoice.status,
due_date=invoice.due_date,
paid_at=invoice.paid_at,
line_items=[
{
"description": item.description,
"quantity": float(item.quantity),
"unit_price": float(item.unit_price),
"amount": float(item.amount),
"type": item.item_type
}
for item in line_items
]
))
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,36 +210,46 @@ 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)):
subscription = await UserSubscription.get_or_none(user=current_user) try:
from ..settings import settings
if not settings.STRIPE_SECRET_KEY:
raise HTTPException(status_code=503, detail="Payment processing not configured")
if not subscription or not subscription.stripe_customer_id: subscription = await UserSubscription.get_or_none(user=current_user)
customer_id = await StripeClient.create_customer(
email=current_user.email, if not subscription or not subscription.stripe_customer_id:
name=current_user.username, customer_id = await StripeClient.create_customer(
metadata={"user_id": str(current_user.id)} email=current_user.email,
name=current_user.username,
metadata={"user_id": str(current_user.id)}
)
if not subscription:
subscription = await UserSubscription.create(
user=current_user,
billing_type="pay_as_you_go",
stripe_customer_id=customer_id,
status="active"
)
else:
subscription.stripe_customer_id = customer_id
await subscription.save()
import stripe
StripeClient._ensure_api_key()
setup_intent = stripe.SetupIntent.create(
customer=subscription.stripe_customer_id,
payment_method_types=["card"]
) )
if not subscription: return {
subscription = await UserSubscription.create( "client_secret": setup_intent.client_secret,
user=current_user, "customer_id": subscription.stripe_customer_id
billing_type="pay_as_you_go", }
stripe_customer_id=customer_id, except HTTPException:
status="active" raise
) except Exception as e:
else: raise HTTPException(status_code=500, detail=f"Failed to create setup intent: {str(e)}")
subscription.stripe_customer_id = customer_id
await subscription.save()
import stripe
setup_intent = stripe.SetupIntent.create(
customer=subscription.stripe_customer_id,
payment_method_types=["card"]
)
return {
"client_secret": setup_intent.client_secret,
"customer_id": subscription.stripe_customer_id
}
@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,49 +271,71 @@ 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
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
try: try:
event = stripe.Webhook.construct_event( payload = await request.body()
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET sig_header = request.headers.get("stripe-signature")
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid payload")
except stripe.error.SignatureVerificationError:
raise HTTPException(status_code=400, detail="Invalid signature")
if event["type"] == "invoice.payment_succeeded": if not settings.STRIPE_WEBHOOK_SECRET:
invoice_data = event["data"]["object"] raise HTTPException(status_code=503, detail="Webhook secret not configured")
rbox_invoice_id = invoice_data.get("metadata", {}).get("rbox_invoice_id")
if rbox_invoice_id: try:
invoice = await Invoice.get_or_none(id=int(rbox_invoice_id)) event = stripe.Webhook.construct_event(
if invoice: payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
await InvoiceGenerator.mark_invoice_paid(invoice)
elif event["type"] == "invoice.payment_failed":
pass
elif event["type"] == "payment_method.attached":
payment_method = event["data"]["object"]
customer_id = payment_method["customer"]
subscription = await UserSubscription.get_or_none(stripe_customer_id=customer_id)
if subscription:
await PaymentMethod.create(
user=subscription.user,
stripe_payment_method_id=payment_method["id"],
type=payment_method["type"],
last4=payment_method.get("card", {}).get("last4"),
brand=payment_method.get("card", {}).get("brand"),
exp_month=payment_method.get("card", {}).get("exp_month"),
exp_year=payment_method.get("card", {}).get("exp_year"),
is_default=True
) )
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)}")
return JSONResponse(content={"status": "success"}) 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"]
rbox_invoice_id = invoice_data.get("metadata", {}).get("rbox_invoice_id")
if rbox_invoice_id:
invoice = await Invoice.get_or_none(id=int(rbox_invoice_id))
if invoice:
await InvoiceGenerator.mark_invoice_paid(invoice)
elif event["type"] == "invoice.payment_failed":
pass
elif event["type"] == "payment_method.attached":
payment_method = event["data"]["object"]
customer_id = payment_method["customer"]
subscription = await UserSubscription.get_or_none(stripe_customer_id=customer_id)
if subscription:
await PaymentMethod.create(
user=subscription.user,
stripe_payment_method_id=payment_method["id"],
type=payment_method["type"],
last4=payment_method.get("card", {}).get("last4"),
brand=payment_method.get("card", {}).get("brand"),
exp_month=payment_method.get("card", {}).get("exp_month"),
exp_year=payment_method.get("card", {}).get("exp_year"),
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") @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,76 +22,147 @@ 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 headers = { const method = options.method || 'GET';
...options.headers,
};
if (this.token && !options.skipAuth) { try {
headers['Authorization'] = `Bearer ${this.token}`; this.logger?.debug(`API ${method}: ${endpoint}`);
}
if (!(options.body instanceof FormData) && options.body) { const headers = {
headers['Content-Type'] = 'application/json'; ...options.headers,
} };
const config = { if (this.token && !options.skipAuth) {
...options, headers['Authorization'] = `Bearer ${this.token}`;
headers,
};
if (config.body && !(config.body instanceof FormData)) {
config.body = JSON.stringify(config.body);
}
const response = await fetch(url, config);
if (response.status === 401) {
this.setToken(null);
window.location.href = '/';
}
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch (e) {
errorData = { message: 'Unknown error' };
} }
const errorMessage = errorData.detail || errorData.message || 'Request failed';
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: errorMessage, type: 'error' }
}));
throw new Error(errorMessage);
}
if (response.status === 204) { const config = {
return null; ...options,
} headers,
};
return response.json(); 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 = '/';
}
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch (e) {
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) { 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') {
if (this.isRendered) {
logger.warn('Editor already rendered, skipping');
return;
}
this.file = file; this.file = file;
this.previousView = previousView; this.previousView = previousView;
await this.loadAndRender(); this.isRendered = true;
}
async loadAndRender() {
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-header"> <div class="code-editor-container">
<div class="header-left"> <div class="code-editor-header">
<button class="button" id="back-btn">Back</button> <div class="header-left">
<h2 class="editor-filename">${this.file.name}</h2> <button class="button" id="back-btn">Back</button>
<h2 class="editor-filename">${this.escapeHtml(this.file.name)}</h2>
</div>
<div class="header-right">
<button class="button button-primary" id="save-btn">Save & Close</button>
</div>
</div> </div>
<div class="header-right"> <div class="code-editor-body">
<button class="button button-primary" id="save-btn">Save</button> <textarea id="editor-textarea"></textarea>
</div> </div>
</div> </div>
<div class="code-editor-body">
<textarea id="code-editor-textarea">${content}</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) { 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) {
this.editor.toTextArea(); logger.debug('Destroying CodeMirror instance');
try {
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

@ -5,6 +5,7 @@ export class FileUploadView extends HTMLElement {
super(); super();
this.folderId = null; this.folderId = null;
this.handleEscape = this.handleEscape.bind(this); this.handleEscape = this.handleEscape.bind(this);
this.uploadItems = new Map();
} }
connectedCallback() { connectedCallback() {
@ -94,6 +95,8 @@ export class FileUploadView extends HTMLElement {
const uploadList = this.querySelector('#upload-list'); const uploadList = this.querySelector('#upload-list');
if (!uploadList) return; if (!uploadList) return;
const uploadPromises = [];
for (const file of files) { for (const file of files) {
const itemId = `upload-${Date.now()}-${Math.random()}`; const itemId = `upload-${Date.now()}-${Math.random()}`;
const item = document.createElement('div'); const item = document.createElement('div');
@ -113,22 +116,31 @@ export class FileUploadView extends HTMLElement {
`; `;
uploadList.appendChild(item); uploadList.appendChild(item);
try { this.uploadItems.set(itemId, {
await this.uploadFile(file, itemId); element: item,
const statusEl = item.querySelector('.upload-item-status'); progress: 0
if (statusEl) { });
statusEl.textContent = 'Complete';
statusEl.classList.add('success'); const promise = this.uploadFile(file, itemId)
} .then(() => {
} catch (error) { setTimeout(() => {
const statusEl = item.querySelector('.upload-item-status'); this.uploadItems.delete(itemId);
if (statusEl) { item.remove();
statusEl.textContent = 'Failed: ' + error.message; }, 500);
statusEl.classList.add('error'); })
} .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 })); this.dispatchEvent(new CustomEvent('upload-complete', { bubbles: true }));
setTimeout(() => { setTimeout(() => {
@ -136,6 +148,19 @@ export class FileUploadView extends HTMLElement {
}, 1500); }, 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) { async uploadFile(file, itemId) {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
@ -149,18 +174,34 @@ export class FileUploadView extends HTMLElement {
xhr.upload.addEventListener('progress', (e) => { xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) { if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100; const percentComplete = (e.loaded / e.total) * 100;
const item = this.querySelector(`#${itemId}`); const itemData = this.uploadItems.get(itemId);
if (item) { if (itemData) {
const progressFill = item.querySelector('.progress-fill'); itemData.progress = percentComplete;
const progressFill = itemData.element.querySelector('.progress-fill');
if (progressFill) { if (progressFill) {
progressFill.style.width = percentComplete + '%'; progressFill.style.width = percentComplete + '%';
} }
this.sortUploadList();
} }
} }
}); });
xhr.addEventListener('load', () => { xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) { 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)); resolve(JSON.parse(xhr.responseText));
} else { } else {
reject(new Error(xhr.statusText)); 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 { 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() {
await this.init(); try {
this.addEventListener('show-toast', this.handleShowToast); await this.init();
window.addEventListener('popstate', this.handlePopState.bind(this)); this.addEventListener('show-toast', this.handleShowToast);
if (!this.popstateAttached) {
window.addEventListener('popstate', this.boundHandlePopState);
this.popstateAttached = true;
logger.debug('Popstate listener attached');
}
} catch (error) {
logger.error('Failed to initialize RBoxApp', error);
this.innerHTML = `
<div style="padding: 2rem; text-align: center; font-family: sans-serif;">
<h1 style="color: #d32f2f;">Failed to Load Application</h1>
<p>${error.message}</p>
<button onclick="location.reload()" style="padding: 0.75rem 1.5rem; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem;">
Reload Page
</button>
</div>
`;
}
} }
disconnectedCallback() { 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() {
if (!api.getToken()) { try {
this.showLogin(); if (!api.getToken()) {
} else { logger.info('No token found, showing login');
try {
this.user = await api.getCurrentUser();
this.render();
} catch (error) {
this.showLogin(); this.showLogin();
} else {
logger.info('Initializing application with stored token');
this.user = await api.getCurrentUser();
appState.setState({ user: this.user });
logger.info('User loaded successfully', { username: this.user.username });
this.render();
} }
} catch (error) {
logger.error('Failed to initialize application', error);
api.setToken(null);
this.showLogin();
} }
} }
@ -383,47 +418,63 @@ export class RBoxApp extends HTMLElement {
} }
handlePopState(e) { 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) {
window.history.pushState( const currentState = window.history.state || {};
{ view: 'code-editor', file: file }, const currentView = currentState.view || this.currentView;
'',
`#editor/${file.id}` if (currentView !== 'code-editor') {
); window.history.pushState(
{ view: 'code-editor', file: file, previousView: currentView },
'',
`#editor/${file.id}`
);
logger.debug('Pushed code editor state', { previousView: currentView });
} else {
logger.debug('Already in code editor view, replacing state');
window.history.replaceState(
{ view: 'code-editor', file: file, previousView: currentView },
'',
`#editor/${file.id}`
);
}
} }
} }
showFilePreview(file, pushState = true) { 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) {
window.history.pushState( const currentState = window.history.state || {};
{ view: 'file-preview', file: file }, const currentView = currentState.view || this.currentView;
'',
`#preview/${file.id}` if (currentView !== 'file-preview') {
); window.history.pushState(
{ view: 'file-preview', file: file, previousView: currentView },
'',
`#preview/${file.id}`
);
logger.debug('Pushed file preview state', { previousView: currentView });
} else {
logger.debug('Already in file preview view, replacing state');
window.history.replaceState(
{ view: 'file-preview', file: file, previousView: currentView },
'',
`#preview/${file.id}`
);
}
} }
} }
showUpload(folderId = null, pushState = true) { 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) {
window.history.pushState( const currentState = window.history.state || {};
{ view: 'upload', folderId: folderId }, const currentView = currentState.view || this.currentView;
'',
'#upload' if (currentView !== 'upload') {
); window.history.pushState(
{ view: 'upload', folderId: folderId, previousView: currentView },
'',
'#upload'
);
logger.debug('Pushed upload state', { previousView: currentView });
} else {
logger.debug('Already in upload view, replacing state');
window.history.replaceState(
{ view: 'upload', folderId: folderId, previousView: currentView },
'',
'#upload'
);
}
} }
} }

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;
}
// Register service worker for PWA customElements.define('rbox-app', rboxApp.RBoxApp);
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/static/service-worker.js'); window.addEventListener('load', () => {
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)

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