feat: add database indexes, stripe api key lazy init, and usage query date range fix
Add composite index on usage_records for user_id, record_type, and timestamp columns; add individual indexes on record_type, timestamp, period_start, and status fields in billing models. Implement lazy Stripe API key initialization via _ensure_api_key method called before each Stripe operation. Fix monthly usage query to use date range filtering instead of year/month decomposition. Refactor auth login endpoint to replace OAuth2PasswordRequestForm dependency with custom LoginRequest model. Add error handling wrapper around current usage endpoint and introduce loading/error CSS classes for billing UI. Convert code editor view from absolute positioning to fixed overlay with centered modal container. Include Stripe.js script tag in index.html and add new APIContract class for response validation against schemas.
This commit is contained in:
parent
e0517e4300
commit
76ccf48628
@ -38,16 +38,17 @@ class UserSubscription(models.Model):
|
||||
class UsageRecord(models.Model):
|
||||
id = fields.BigIntField(pk=True)
|
||||
user = fields.ForeignKeyField("models.User", related_name="usage_records")
|
||||
record_type = fields.CharField(max_length=50)
|
||||
record_type = fields.CharField(max_length=50, index=True)
|
||||
amount_bytes = fields.BigIntField()
|
||||
resource_type = fields.CharField(max_length=50, null=True)
|
||||
resource_id = fields.IntField(null=True)
|
||||
timestamp = fields.DatetimeField(auto_now_add=True)
|
||||
timestamp = fields.DatetimeField(auto_now_add=True, index=True)
|
||||
idempotency_key = fields.CharField(max_length=255, unique=True, null=True)
|
||||
metadata = fields.JSONField(null=True)
|
||||
|
||||
class Meta:
|
||||
table = "usage_records"
|
||||
indexes = [("user_id", "record_type", "timestamp")]
|
||||
|
||||
class UsageAggregate(models.Model):
|
||||
id = fields.IntField(pk=True)
|
||||
@ -68,21 +69,22 @@ class Invoice(models.Model):
|
||||
user = fields.ForeignKeyField("models.User", related_name="invoices")
|
||||
invoice_number = fields.CharField(max_length=50, unique=True)
|
||||
stripe_invoice_id = fields.CharField(max_length=255, unique=True, null=True)
|
||||
period_start = fields.DateField()
|
||||
period_start = fields.DateField(index=True)
|
||||
period_end = fields.DateField()
|
||||
subtotal = fields.DecimalField(max_digits=10, decimal_places=4)
|
||||
tax = fields.DecimalField(max_digits=10, decimal_places=4, default=0)
|
||||
total = fields.DecimalField(max_digits=10, decimal_places=4)
|
||||
currency = fields.CharField(max_length=3, default="USD")
|
||||
status = fields.CharField(max_length=50, default="draft")
|
||||
status = fields.CharField(max_length=50, default="draft", index=True)
|
||||
due_date = fields.DateField(null=True)
|
||||
paid_at = fields.DatetimeField(null=True)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
created_at = fields.DatetimeField(auto_now_add=True, index=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
metadata = fields.JSONField(null=True)
|
||||
|
||||
class Meta:
|
||||
table = "invoices"
|
||||
indexes = [("user_id", "status", "created_at")]
|
||||
|
||||
class InvoiceLineItem(models.Model):
|
||||
id = fields.IntField(pk=True)
|
||||
|
||||
@ -3,11 +3,17 @@ from decimal import Decimal
|
||||
from typing import Optional, Dict, Any
|
||||
from ..settings import settings
|
||||
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY if hasattr(settings, 'STRIPE_SECRET_KEY') else ""
|
||||
|
||||
class StripeClient:
|
||||
@staticmethod
|
||||
def _ensure_api_key():
|
||||
if not stripe.api_key:
|
||||
if settings.STRIPE_SECRET_KEY:
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||
else:
|
||||
raise ValueError("Stripe API key not configured")
|
||||
@staticmethod
|
||||
async def create_customer(email: str, name: str, metadata: Dict = None) -> str:
|
||||
StripeClient._ensure_api_key()
|
||||
customer = stripe.Customer.create(
|
||||
email=email,
|
||||
name=name,
|
||||
@ -22,6 +28,7 @@ class StripeClient:
|
||||
customer_id: str = None,
|
||||
metadata: Dict = None
|
||||
) -> stripe.PaymentIntent:
|
||||
StripeClient._ensure_api_key()
|
||||
return stripe.PaymentIntent.create(
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
@ -37,6 +44,7 @@ class StripeClient:
|
||||
line_items: list,
|
||||
metadata: Dict = None
|
||||
) -> stripe.Invoice:
|
||||
StripeClient._ensure_api_key()
|
||||
for item in line_items:
|
||||
stripe.InvoiceItem.create(
|
||||
customer=customer_id,
|
||||
@ -58,10 +66,12 @@ class StripeClient:
|
||||
|
||||
@staticmethod
|
||||
async def finalize_invoice(invoice_id: str) -> stripe.Invoice:
|
||||
StripeClient._ensure_api_key()
|
||||
return stripe.Invoice.finalize_invoice(invoice_id)
|
||||
|
||||
@staticmethod
|
||||
async def pay_invoice(invoice_id: str) -> stripe.Invoice:
|
||||
StripeClient._ensure_api_key()
|
||||
return stripe.Invoice.pay(invoice_id)
|
||||
|
||||
@staticmethod
|
||||
@ -69,6 +79,7 @@ class StripeClient:
|
||||
payment_method_id: str,
|
||||
customer_id: str
|
||||
) -> stripe.PaymentMethod:
|
||||
StripeClient._ensure_api_key()
|
||||
payment_method = stripe.PaymentMethod.attach(
|
||||
payment_method_id,
|
||||
customer=customer_id
|
||||
@ -83,6 +94,7 @@ class StripeClient:
|
||||
|
||||
@staticmethod
|
||||
async def list_payment_methods(customer_id: str, type: str = "card"):
|
||||
StripeClient._ensure_api_key()
|
||||
return stripe.PaymentMethod.list(
|
||||
customer=customer_id,
|
||||
type=type
|
||||
@ -94,6 +106,7 @@ class StripeClient:
|
||||
price_id: str,
|
||||
metadata: Dict = None
|
||||
) -> stripe.Subscription:
|
||||
StripeClient._ensure_api_key()
|
||||
return stripe.Subscription.create(
|
||||
customer=customer_id,
|
||||
items=[{'price': price_id}],
|
||||
@ -102,4 +115,5 @@ class StripeClient:
|
||||
|
||||
@staticmethod
|
||||
async def cancel_subscription(subscription_id: str) -> stripe.Subscription:
|
||||
StripeClient._ensure_api_key()
|
||||
return stripe.Subscription.delete(subscription_id)
|
||||
|
||||
@ -108,15 +108,22 @@ class UsageTracker:
|
||||
@staticmethod
|
||||
async def get_current_storage(user: User) -> int:
|
||||
from ..models import File
|
||||
files = await File.filter(user=user, is_deleted=False).all()
|
||||
files = await File.filter(owner=user, is_deleted=False).all()
|
||||
return sum(f.size for f in files)
|
||||
|
||||
@staticmethod
|
||||
async def get_monthly_usage(user: User, year: int, month: int) -> dict:
|
||||
from datetime import date
|
||||
from calendar import monthrange
|
||||
|
||||
start_date = date(year, month, 1)
|
||||
_, last_day = monthrange(year, month)
|
||||
end_date = date(year, month, last_day)
|
||||
|
||||
aggregates = await UsageAggregate.filter(
|
||||
user=user,
|
||||
date__year=year,
|
||||
date__month=month
|
||||
date__gte=start_date,
|
||||
date__lte=end_date
|
||||
).all()
|
||||
|
||||
if not aggregates:
|
||||
|
||||
@ -2,10 +2,9 @@ from datetime import timedelta
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..auth import authenticate_user, create_access_token, get_password_hash, get_current_user, get_current_verified_user
|
||||
from ..auth import authenticate_user, create_access_token, get_password_hash, get_current_user, get_current_verified_user, verify_password
|
||||
from ..models import User
|
||||
from ..schemas import Token, UserCreate, TokenData, UserLoginWith2FA
|
||||
from ..two_factor import (
|
||||
@ -19,6 +18,10 @@ router = APIRouter(
|
||||
tags=["auth"],
|
||||
)
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class TwoFactorLogin(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
@ -65,8 +68,8 @@ async def register_user(user_in: UserCreate):
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
@router.post("/token", response_model=Token)
|
||||
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
auth_result = await authenticate_user(form_data.username, form_data.password, None)
|
||||
async def login_for_access_token(login_data: LoginRequest):
|
||||
auth_result = await authenticate_user(login_data.username, login_data.password, None)
|
||||
|
||||
if not auth_result:
|
||||
raise HTTPException(
|
||||
@ -83,7 +86,7 @@ async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(
|
||||
headers={"X-2FA-Required": "true"},
|
||||
)
|
||||
|
||||
access_token_expires = timedelta(minutes=30) # Use settings
|
||||
access_token_expires = timedelta(minutes=30)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username}, expires_delta=access_token_expires, two_factor_verified=True
|
||||
)
|
||||
|
||||
@ -52,25 +52,28 @@ class SubscriptionResponse(BaseModel):
|
||||
|
||||
@router.get("/usage/current")
|
||||
async def get_current_usage(current_user: User = Depends(get_current_user)):
|
||||
storage_bytes = await UsageTracker.get_current_storage(current_user)
|
||||
today = date.today()
|
||||
try:
|
||||
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 {
|
||||
"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),
|
||||
"bandwidth_down_gb_today": 0,
|
||||
"bandwidth_up_gb_today": 0,
|
||||
"as_of": today.isoformat()
|
||||
}
|
||||
|
||||
return {
|
||||
"storage_gb": round(storage_bytes / (1024**3), 4),
|
||||
"bandwidth_down_gb_today": 0,
|
||||
"bandwidth_up_gb_today": 0,
|
||||
"as_of": today.isoformat()
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch usage data: {str(e)}")
|
||||
|
||||
@router.get("/usage/monthly")
|
||||
async def get_monthly_usage(
|
||||
@ -78,17 +81,27 @@ async def get_monthly_usage(
|
||||
month: Optional[int] = None,
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> UsageResponse:
|
||||
if year is None or month is None:
|
||||
now = datetime.now()
|
||||
year = now.year
|
||||
month = now.month
|
||||
try:
|
||||
if year is None or month is None:
|
||||
now = datetime.now()
|
||||
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,
|
||||
period=f"{year}-{month:02d}"
|
||||
)
|
||||
usage = await UsageTracker.get_monthly_usage(current_user, year, month)
|
||||
|
||||
return UsageResponse(
|
||||
**usage,
|
||||
period=f"{year}-{month:02d}"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch monthly usage: {str(e)}")
|
||||
|
||||
@router.get("/invoices")
|
||||
async def list_invoices(
|
||||
@ -96,35 +109,45 @@ async def list_invoices(
|
||||
offset: int = 0,
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> 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 = []
|
||||
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
|
||||
]
|
||||
))
|
||||
invoices = await Invoice.filter(user=current_user).order_by("-created_at").offset(offset).limit(limit).all()
|
||||
|
||||
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}")
|
||||
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")
|
||||
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:
|
||||
customer_id = await StripeClient.create_customer(
|
||||
email=current_user.email,
|
||||
name=current_user.username,
|
||||
metadata={"user_id": str(current_user.id)}
|
||||
subscription = await UserSubscription.get_or_none(user=current_user)
|
||||
|
||||
if not subscription or not subscription.stripe_customer_id:
|
||||
customer_id = await StripeClient.create_customer(
|
||||
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:
|
||||
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
|
||||
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
|
||||
}
|
||||
return {
|
||||
"client_secret": setup_intent.client_secret,
|
||||
"customer_id": subscription.stripe_customer_id
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create setup intent: {str(e)}")
|
||||
|
||||
@router.get("/payment-methods")
|
||||
async def list_payment_methods(current_user: User = Depends(get_current_user)):
|
||||
@ -238,49 +271,71 @@ async def list_payment_methods(current_user: User = Depends(get_current_user)):
|
||||
async def stripe_webhook(request: Request):
|
||||
import stripe
|
||||
from ..settings import settings
|
||||
|
||||
payload = await request.body()
|
||||
sig_header = request.headers.get("stripe-signature")
|
||||
from ..billing.models import BillingEvent
|
||||
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||
except stripe.error.SignatureVerificationError:
|
||||
raise HTTPException(status_code=400, detail="Invalid signature")
|
||||
payload = await request.body()
|
||||
sig_header = request.headers.get("stripe-signature")
|
||||
|
||||
if event["type"] == "invoice.payment_succeeded":
|
||||
invoice_data = event["data"]["object"]
|
||||
rbox_invoice_id = invoice_data.get("metadata", {}).get("rbox_invoice_id")
|
||||
if not settings.STRIPE_WEBHOOK_SECRET:
|
||||
raise HTTPException(status_code=503, detail="Webhook secret not configured")
|
||||
|
||||
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
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
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")
|
||||
async def get_pricing():
|
||||
@ -310,3 +365,10 @@ async def list_plans():
|
||||
}
|
||||
for plan in plans
|
||||
]
|
||||
|
||||
@router.get("/stripe-key")
|
||||
async def get_stripe_key():
|
||||
from ..settings import settings
|
||||
if not settings.STRIPE_PUBLISHABLE_KEY:
|
||||
raise HTTPException(status_code=503, detail="Payment processing not configured")
|
||||
return {"publishable_key": settings.STRIPE_PUBLISHABLE_KEY}
|
||||
|
||||
@ -369,3 +369,33 @@
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
font-size: 1.2rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #ef4444;
|
||||
color: #991b1b;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
#payment-element {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@ -1,13 +1,25 @@
|
||||
.code-editor-view {
|
||||
position: absolute;
|
||||
.code-editor-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.code-editor-container {
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
max-width: 1400px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.code-editor-header {
|
||||
@ -49,6 +61,16 @@
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
user-select: text !important;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.code-editor-body .CodeMirror * {
|
||||
user-select: text !important;
|
||||
}
|
||||
|
||||
.code-editor-body .CodeMirror-scroll {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.code-editor-body .CodeMirror-gutters {
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
<link rel="stylesheet" href="/static/css/code-editor-view.css">
|
||||
<link rel="stylesheet" href="/static/css/file-upload-view.css">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<script src="https://js.stripe.com/v3/"></script>
|
||||
<script src="/static/lib/codemirror/codemirror.min.js"></script>
|
||||
<script src="/static/lib/codemirror/javascript.min.js"></script>
|
||||
<script src="/static/lib/codemirror/python.min.js"></script>
|
||||
|
||||
42
static/js/api-contract.js
Normal file
42
static/js/api-contract.js
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
193
static/js/api.js
193
static/js/api.js
@ -1,7 +1,11 @@
|
||||
class APIClient {
|
||||
constructor(baseURL = '/') {
|
||||
constructor(baseURL = '/', logger = null, perfMonitor = null, appState = null) {
|
||||
this.baseURL = baseURL;
|
||||
this.token = localStorage.getItem('token');
|
||||
this.logger = logger;
|
||||
this.perfMonitor = perfMonitor;
|
||||
this.appState = appState;
|
||||
this.activeRequests = 0;
|
||||
}
|
||||
|
||||
setToken(token) {
|
||||
@ -18,76 +22,147 @@ class APIClient {
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
this.activeRequests++;
|
||||
this.appState?.setState({ isLoading: true });
|
||||
|
||||
const startTime = performance.now();
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
const headers = {
|
||||
...options.headers,
|
||||
};
|
||||
const method = options.method || 'GET';
|
||||
|
||||
if (this.token && !options.skipAuth) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
try {
|
||||
this.logger?.debug(`API ${method}: ${endpoint}`);
|
||||
|
||||
if (!(options.body instanceof FormData) && options.body) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
const headers = {
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const config = {
|
||||
...options,
|
||||
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' };
|
||||
if (this.token && !options.skipAuth) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
const config = {
|
||||
...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) {
|
||||
this.logger?.info('Attempting registration', { username, email });
|
||||
|
||||
const data = await this.request('auth/register', {
|
||||
method: 'POST',
|
||||
body: { username, email, password },
|
||||
skipAuth: true
|
||||
});
|
||||
|
||||
if (!data || !data.access_token) {
|
||||
this.logger?.error('Invalid registration response: missing access_token', data);
|
||||
throw new Error('Invalid registration response');
|
||||
}
|
||||
|
||||
this.logger?.info('Registration successful', { username });
|
||||
this.setToken(data.access_token);
|
||||
return data;
|
||||
}
|
||||
|
||||
async login(username, password) {
|
||||
const formData = new FormData();
|
||||
formData.append('username', username);
|
||||
formData.append('password', password);
|
||||
this.logger?.info('Attempting login', { username });
|
||||
|
||||
const data = await this.request('auth/token', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
body: { username, password },
|
||||
skipAuth: true
|
||||
});
|
||||
|
||||
if (!data || !data.access_token) {
|
||||
this.logger?.error('Invalid login response: missing access_token', data);
|
||||
throw new Error('Invalid authentication response');
|
||||
}
|
||||
|
||||
this.logger?.info('Login successful', { username });
|
||||
this.setToken(data.access_token);
|
||||
return data;
|
||||
}
|
||||
@ -328,4 +403,28 @@ class APIClient {
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new APIClient();
|
||||
export { APIClient };
|
||||
|
||||
let _sharedInstance = null;
|
||||
|
||||
export function setSharedAPIInstance(instance) {
|
||||
_sharedInstance = instance;
|
||||
}
|
||||
|
||||
export const api = new Proxy({}, {
|
||||
get(target, prop) {
|
||||
if (!_sharedInstance) {
|
||||
console.warn('API instance accessed before initialization. This may cause issues with logging and state management.');
|
||||
_sharedInstance = new APIClient();
|
||||
}
|
||||
|
||||
const instance = _sharedInstance;
|
||||
const value = instance[prop];
|
||||
|
||||
if (typeof value === 'function') {
|
||||
return value.bind(instance);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
});
|
||||
|
||||
111
static/js/app.js
Normal file
111
static/js/app.js
Normal 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;
|
||||
@ -4,6 +4,8 @@ class AdminBilling extends HTMLElement {
|
||||
this.pricingConfig = [];
|
||||
this.stats = null;
|
||||
this.boundHandleClick = this.handleClick.bind(this);
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
@ -18,6 +20,8 @@ class AdminBilling extends HTMLElement {
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const [pricing, stats] = await Promise.all([
|
||||
this.fetchPricing(),
|
||||
@ -26,8 +30,11 @@ class AdminBilling extends HTMLElement {
|
||||
|
||||
this.pricingConfig = pricing;
|
||||
this.stats = stats;
|
||||
this.loading = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to load admin billing data:', error);
|
||||
this.error = error.message || 'Failed to load admin billing data';
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,6 +60,16 @@ class AdminBilling extends HTMLElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.loading) {
|
||||
this.innerHTML = '<div class="admin-billing"><div class="loading">Loading admin billing data...</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.error) {
|
||||
this.innerHTML = `<div class="admin-billing"><div class="error-message">Error: ${this.error}</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.innerHTML = `
|
||||
<div class="admin-billing">
|
||||
<h2>Billing Administration</h2>
|
||||
|
||||
78
static/js/components/base-component.js
Normal file
78
static/js/components/base-component.js
Normal 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) || [];
|
||||
}
|
||||
}
|
||||
@ -6,13 +6,18 @@ class BillingDashboard extends HTMLElement {
|
||||
this.pricing = null;
|
||||
this.invoices = [];
|
||||
this.boundHandleClick = this.handleClick.bind(this);
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.stripe = null;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this.addEventListener('click', this.boundHandleClick);
|
||||
this.render();
|
||||
await this.loadData();
|
||||
this.render();
|
||||
this.attachEventListeners();
|
||||
await this.initStripe();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@ -20,6 +25,8 @@ class BillingDashboard extends HTMLElement {
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const [usage, subscription, pricing, invoices] = await Promise.all([
|
||||
this.fetchCurrentUsage(),
|
||||
@ -32,8 +39,21 @@ class BillingDashboard extends HTMLElement {
|
||||
this.subscription = subscription;
|
||||
this.pricing = pricing;
|
||||
this.invoices = invoices;
|
||||
this.loading = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to load billing data:', error);
|
||||
this.error = error.message || 'Failed to load billing data';
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async initStripe() {
|
||||
if (window.Stripe) {
|
||||
const response = await fetch('/api/billing/stripe-key');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.stripe = window.Stripe(data.publishable_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -109,6 +129,16 @@ class BillingDashboard extends HTMLElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.loading) {
|
||||
this.innerHTML = '<div class="billing-dashboard"><div class="loading">Loading billing data...</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.error) {
|
||||
this.innerHTML = `<div class="billing-dashboard"><div class="error-message">Error: ${this.error}</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const estimatedCost = this.calculateEstimatedCost();
|
||||
const storageUsed = this.currentUsage?.storage_gb || 0;
|
||||
const freeStorage = parseFloat(this.pricing?.free_tier_storage_gb?.value || 15);
|
||||
@ -253,7 +283,74 @@ class BillingDashboard extends HTMLElement {
|
||||
}
|
||||
|
||||
async showPaymentMethodModal() {
|
||||
alert('Payment method modal will be implemented with Stripe Elements');
|
||||
if (!this.stripe) {
|
||||
alert('Payment processing not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/billing/payment-methods/setup-intent', {
|
||||
method: 'POST',
|
||||
headers: {'Authorization': `Bearer ${localStorage.getItem('token')}`}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert(`Failed to initialize payment: ${error.detail}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { client_secret } = await response.json();
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<h2>Add Payment Method</h2>
|
||||
<div id="payment-element"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-primary" id="submitPayment">Add Card</button>
|
||||
<button class="btn-secondary" id="cancelPayment">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const elements = this.stripe.elements({ clientSecret: client_secret });
|
||||
const paymentElement = elements.create('payment');
|
||||
paymentElement.mount('#payment-element');
|
||||
|
||||
modal.querySelector('#submitPayment').addEventListener('click', async () => {
|
||||
const submitButton = modal.querySelector('#submitPayment');
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = 'Processing...';
|
||||
|
||||
const { error } = await this.stripe.confirmSetup({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: window.location.href,
|
||||
},
|
||||
redirect: 'if_required'
|
||||
});
|
||||
|
||||
if (error) {
|
||||
alert(`Payment failed: ${error.message}`);
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Add Card';
|
||||
} else {
|
||||
alert('Payment method added successfully');
|
||||
modal.remove();
|
||||
await this.loadData();
|
||||
this.render();
|
||||
}
|
||||
});
|
||||
|
||||
modal.querySelector('#cancelPayment').addEventListener('click', () => {
|
||||
modal.remove();
|
||||
});
|
||||
} catch (error) {
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async showInvoiceDetail(invoiceId) {
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import { api } from '../api.js';
|
||||
import app from '../app.js';
|
||||
|
||||
const api = app.getAPI();
|
||||
const logger = app.getLogger();
|
||||
|
||||
class CodeEditorView extends HTMLElement {
|
||||
constructor() {
|
||||
@ -6,146 +9,205 @@ class CodeEditorView extends HTMLElement {
|
||||
this.editor = null;
|
||||
this.file = null;
|
||||
this.previousView = null;
|
||||
this.boundHandleClick = this.handleClick.bind(this);
|
||||
this.boundHandleEscape = this.handleEscape.bind(this);
|
||||
this.isRendered = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.addEventListener('click', this.boundHandleClick);
|
||||
document.addEventListener('keydown', this.boundHandleEscape);
|
||||
logger.debug('CodeEditorView connected');
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeEventListener('click', this.boundHandleClick);
|
||||
document.removeEventListener('keydown', this.boundHandleEscape);
|
||||
if (this.editor) {
|
||||
this.editor.toTextArea();
|
||||
this.editor = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleEscape(e) {
|
||||
if (e.key === 'Escape') {
|
||||
this.goBack();
|
||||
}
|
||||
logger.debug('CodeEditorView disconnected');
|
||||
this.destroyEditor();
|
||||
}
|
||||
|
||||
async setFile(file, previousView = 'files') {
|
||||
if (this.isRendered) {
|
||||
logger.warn('Editor already rendered, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
this.file = file;
|
||||
this.previousView = previousView;
|
||||
await this.loadAndRender();
|
||||
}
|
||||
this.isRendered = true;
|
||||
|
||||
async loadAndRender() {
|
||||
try {
|
||||
const blob = await api.downloadFile(this.file.id);
|
||||
logger.debug('Loading file', { fileName: file.name });
|
||||
const blob = await api.downloadFile(file.id);
|
||||
const content = await blob.text();
|
||||
this.render(content);
|
||||
this.initializeEditor(content);
|
||||
|
||||
this.createUI(content);
|
||||
this.createEditor(content);
|
||||
} catch (error) {
|
||||
console.error('Failed to load file:', error);
|
||||
logger.error('Failed to load file', error);
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Failed to load file: ' + error.message, type: 'error' }
|
||||
}));
|
||||
this.render('');
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
|
||||
getMimeType(filename) {
|
||||
const extension = filename.split('.').pop().toLowerCase();
|
||||
const mimeMap = {
|
||||
'js': 'text/javascript',
|
||||
'json': 'application/json',
|
||||
'py': 'text/x-python',
|
||||
'md': 'text/x-markdown',
|
||||
'html': 'text/html',
|
||||
'xml': 'application/xml',
|
||||
'css': 'text/css',
|
||||
'txt': 'text/plain',
|
||||
'log': 'text/plain',
|
||||
'sh': 'text/x-sh',
|
||||
'yaml': 'text/x-yaml',
|
||||
'yml': 'text/x-yaml'
|
||||
};
|
||||
return mimeMap[extension] || 'text/plain';
|
||||
}
|
||||
|
||||
render(content) {
|
||||
createUI(content) {
|
||||
this.innerHTML = `
|
||||
<div class="code-editor-view">
|
||||
<div class="code-editor-header">
|
||||
<div class="header-left">
|
||||
<button class="button" id="back-btn">Back</button>
|
||||
<h2 class="editor-filename">${this.file.name}</h2>
|
||||
<div class="code-editor-overlay">
|
||||
<div class="code-editor-container">
|
||||
<div class="code-editor-header">
|
||||
<div class="header-left">
|
||||
<button class="button" id="back-btn">Back</button>
|
||||
<h2 class="editor-filename">${this.escapeHtml(this.file.name)}</h2>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="button button-primary" id="save-btn">Save & Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="button button-primary" id="save-btn">Save</button>
|
||||
<div class="code-editor-body">
|
||||
<textarea id="editor-textarea"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="code-editor-body">
|
||||
<textarea id="code-editor-textarea">${content}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const backBtn = this.querySelector('#back-btn');
|
||||
const saveBtn = this.querySelector('#save-btn');
|
||||
|
||||
backBtn.addEventListener('click', () => this.close());
|
||||
saveBtn.addEventListener('click', () => this.save());
|
||||
|
||||
document.addEventListener('keydown', this.handleKeydown.bind(this));
|
||||
}
|
||||
|
||||
initializeEditor(content) {
|
||||
const textarea = this.querySelector('#code-editor-textarea');
|
||||
if (!textarea) return;
|
||||
createEditor(content) {
|
||||
const textarea = this.querySelector('#editor-textarea');
|
||||
if (!textarea) {
|
||||
logger.error('Textarea not found');
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.value = content;
|
||||
|
||||
const mode = this.getMode(this.file.name);
|
||||
logger.debug('Creating CodeMirror editor', { mode, fileSize: content.length });
|
||||
|
||||
this.editor = CodeMirror.fromTextArea(textarea, {
|
||||
value: content,
|
||||
mode: this.getMimeType(this.file.name),
|
||||
mode: mode,
|
||||
lineNumbers: true,
|
||||
theme: 'default',
|
||||
lineWrapping: true,
|
||||
indentUnit: 4,
|
||||
indentWithTabs: false,
|
||||
theme: 'default',
|
||||
readOnly: false,
|
||||
autofocus: true,
|
||||
extraKeys: {
|
||||
'Ctrl-S': () => this.save(),
|
||||
'Cmd-S': () => this.save()
|
||||
'Ctrl-S': () => { this.save(); return false; },
|
||||
'Cmd-S': () => { this.save(); return false; },
|
||||
'Esc': () => { this.close(); return false; }
|
||||
}
|
||||
});
|
||||
|
||||
this.editor.setSize('100%', '100%');
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.editor) {
|
||||
this.editor.refresh();
|
||||
this.editor.focus();
|
||||
logger.debug('Editor ready and focused');
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
handleClick(e) {
|
||||
if (e.target.id === 'back-btn') {
|
||||
this.goBack();
|
||||
} else if (e.target.id === 'save-btn') {
|
||||
this.save();
|
||||
getMode(filename) {
|
||||
const ext = filename.split('.').pop().toLowerCase();
|
||||
const modes = {
|
||||
'js': 'javascript',
|
||||
'json': { name: 'javascript', json: true },
|
||||
'py': 'python',
|
||||
'md': 'markdown',
|
||||
'html': 'htmlmixed',
|
||||
'xml': 'xml',
|
||||
'css': 'css',
|
||||
'txt': 'text/plain',
|
||||
'log': 'text/plain',
|
||||
'sh': 'shell',
|
||||
'yaml': 'yaml',
|
||||
'yml': 'yaml'
|
||||
};
|
||||
return modes[ext] || 'text/plain';
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
handleKeydown(e) {
|
||||
if (e.key === 'Escape' && !this.editor.getOption('readOnly')) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
if (!this.editor) return;
|
||||
if (!this.editor) {
|
||||
logger.warn('No editor instance');
|
||||
return;
|
||||
}
|
||||
|
||||
const saveBtn = this.querySelector('#save-btn');
|
||||
if (!saveBtn) return;
|
||||
|
||||
try {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = 'Saving...';
|
||||
|
||||
const content = this.editor.getValue();
|
||||
logger.debug('Saving file', { fileName: this.file.name, size: content.length });
|
||||
|
||||
await api.updateFile(this.file.id, content);
|
||||
|
||||
logger.info('File saved successfully', { fileName: this.file.name });
|
||||
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'File saved successfully!', type: 'success' }
|
||||
}));
|
||||
|
||||
setTimeout(() => {
|
||||
this.close();
|
||||
}, 500);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to save file', error);
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Failed to save file: ' + error.message, type: 'error' }
|
||||
detail: { message: 'Failed to save: ' + error.message, type: 'error' }
|
||||
}));
|
||||
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = 'Save & Close';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
goBack() {
|
||||
close() {
|
||||
logger.debug('Closing editor');
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
hide() {
|
||||
document.removeEventListener('keydown', this.boundHandleEscape);
|
||||
logger.debug('Hiding editor');
|
||||
this.destroyEditor();
|
||||
this.remove();
|
||||
}
|
||||
|
||||
destroyEditor() {
|
||||
if (this.editor) {
|
||||
this.editor.toTextArea();
|
||||
logger.debug('Destroying CodeMirror instance');
|
||||
try {
|
||||
this.editor.toTextArea();
|
||||
} catch (e) {
|
||||
logger.warn('Error destroying editor', e);
|
||||
}
|
||||
this.editor = null;
|
||||
}
|
||||
this.remove();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
101
static/js/components/error-boundary.js
Normal file
101
static/js/components/error-boundary.js
Normal 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);
|
||||
@ -41,14 +41,6 @@ class FilePreview extends HTMLElement {
|
||||
this.style.display = 'block';
|
||||
document.addEventListener('keydown', this.handleEscape);
|
||||
this.renderPreview();
|
||||
|
||||
if (pushState) {
|
||||
window.history.pushState(
|
||||
{ view: 'file-preview', file: file },
|
||||
'',
|
||||
`#preview/${file.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import { api } from '../api.js';
|
||||
import app from '../app.js';
|
||||
|
||||
const api = app.getAPI();
|
||||
const logger = app.getLogger();
|
||||
|
||||
export class LoginView extends HTMLElement {
|
||||
constructor() {
|
||||
@ -74,9 +77,12 @@ export class LoginView extends HTMLElement {
|
||||
const errorDiv = this.querySelector('#login-error');
|
||||
|
||||
try {
|
||||
logger.info('Login attempt started', { username });
|
||||
await api.login(username, password);
|
||||
logger.info('Login successful, dispatching auth-success event');
|
||||
this.dispatchEvent(new CustomEvent('auth-success'));
|
||||
} catch (error) {
|
||||
logger.error('Login failed', { username, error: error.message });
|
||||
errorDiv.textContent = error.message;
|
||||
}
|
||||
}
|
||||
@ -91,9 +97,12 @@ export class LoginView extends HTMLElement {
|
||||
const errorDiv = this.querySelector('#register-error');
|
||||
|
||||
try {
|
||||
logger.info('Registration attempt started', { username, email });
|
||||
await api.register(username, email, password);
|
||||
logger.info('Registration successful, dispatching auth-success event');
|
||||
this.dispatchEvent(new CustomEvent('auth-success'));
|
||||
} catch (error) {
|
||||
logger.error('Registration failed', { username, email, error: error.message });
|
||||
errorDiv.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { api } from '../api.js';
|
||||
import app from '../app.js';
|
||||
import './login-view.js';
|
||||
import './file-list.js';
|
||||
import './file-upload-view.js';
|
||||
@ -16,22 +16,51 @@ import './admin-billing.js';
|
||||
import './code-editor-view.js';
|
||||
import { shortcuts } from '../shortcuts.js';
|
||||
|
||||
const api = app.getAPI();
|
||||
const logger = app.getLogger();
|
||||
const appState = app.getState();
|
||||
|
||||
export class RBoxApp extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.currentView = 'files';
|
||||
this.user = null;
|
||||
this.navigationStack = [];
|
||||
this.boundHandlePopState = this.handlePopState.bind(this);
|
||||
this.popstateAttached = false;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await this.init();
|
||||
this.addEventListener('show-toast', this.handleShowToast);
|
||||
window.addEventListener('popstate', this.handlePopState.bind(this));
|
||||
try {
|
||||
await this.init();
|
||||
this.addEventListener('show-toast', this.handleShowToast);
|
||||
|
||||
if (!this.popstateAttached) {
|
||||
window.addEventListener('popstate', this.boundHandlePopState);
|
||||
this.popstateAttached = true;
|
||||
logger.debug('Popstate listener attached');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize RBoxApp', error);
|
||||
this.innerHTML = `
|
||||
<div style="padding: 2rem; text-align: center; font-family: sans-serif;">
|
||||
<h1 style="color: #d32f2f;">Failed to Load Application</h1>
|
||||
<p>${error.message}</p>
|
||||
<button onclick="location.reload()" style="padding: 0.75rem 1.5rem; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem;">
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeEventListener('show-toast', this.handleShowToast);
|
||||
if (this.popstateAttached) {
|
||||
window.removeEventListener('popstate', this.boundHandlePopState);
|
||||
this.popstateAttached = false;
|
||||
logger.debug('Popstate listener removed');
|
||||
}
|
||||
}
|
||||
|
||||
handleShowToast = (event) => {
|
||||
@ -46,15 +75,21 @@ export class RBoxApp extends HTMLElement {
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!api.getToken()) {
|
||||
this.showLogin();
|
||||
} else {
|
||||
try {
|
||||
this.user = await api.getCurrentUser();
|
||||
this.render();
|
||||
} catch (error) {
|
||||
try {
|
||||
if (!api.getToken()) {
|
||||
logger.info('No token found, showing login');
|
||||
this.showLogin();
|
||||
} else {
|
||||
logger.info('Initializing application with stored token');
|
||||
this.user = await api.getCurrentUser();
|
||||
appState.setState({ user: this.user });
|
||||
logger.info('User loaded successfully', { username: this.user.username });
|
||||
this.render();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize application', error);
|
||||
api.setToken(null);
|
||||
this.showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
@ -383,47 +418,63 @@ export class RBoxApp extends HTMLElement {
|
||||
}
|
||||
|
||||
handlePopState(e) {
|
||||
logger.debug('Popstate event', { state: e.state, url: window.location.href });
|
||||
|
||||
this.closeAllOverlays();
|
||||
|
||||
if (e.state && e.state.view) {
|
||||
if (e.state.view === 'code-editor' && e.state.file) {
|
||||
const view = e.state.view;
|
||||
|
||||
if (view === 'code-editor' && e.state.file) {
|
||||
logger.debug('Restoring code editor view');
|
||||
this.showCodeEditor(e.state.file, false);
|
||||
} else if (e.state.view === 'file-preview' && e.state.file) {
|
||||
} else if (view === 'file-preview' && e.state.file) {
|
||||
logger.debug('Restoring file preview view');
|
||||
this.showFilePreview(e.state.file, false);
|
||||
} else if (e.state.view === 'upload') {
|
||||
} else if (view === 'upload') {
|
||||
logger.debug('Restoring upload view');
|
||||
const folderId = e.state.folderId !== undefined ? e.state.folderId : null;
|
||||
this.showUpload(folderId, false);
|
||||
} else {
|
||||
this.switchView(e.state.view, false);
|
||||
logger.debug('Switching to view', { view });
|
||||
this.switchView(view, false);
|
||||
}
|
||||
} else {
|
||||
logger.debug('No state, defaulting to files view');
|
||||
this.switchView('files', false);
|
||||
}
|
||||
}
|
||||
|
||||
closeAllOverlays() {
|
||||
logger.debug('Closing all overlays');
|
||||
|
||||
const existingEditor = this.querySelector('code-editor-view');
|
||||
if (existingEditor) {
|
||||
logger.debug('Hiding code editor');
|
||||
existingEditor.hide();
|
||||
}
|
||||
|
||||
const existingPreview = this.querySelector('file-preview');
|
||||
if (existingPreview) {
|
||||
logger.debug('Hiding file preview');
|
||||
existingPreview.hide();
|
||||
}
|
||||
|
||||
const existingUpload = this.querySelector('file-upload-view');
|
||||
if (existingUpload) {
|
||||
logger.debug('Hiding file upload');
|
||||
existingUpload.hide();
|
||||
}
|
||||
|
||||
const shareModal = this.querySelector('share-modal');
|
||||
if (shareModal && shareModal.style.display !== 'none') {
|
||||
logger.debug('Hiding share modal');
|
||||
shareModal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
showCodeEditor(file, pushState = true) {
|
||||
logger.debug('Showing code editor', { file: file.name, pushState });
|
||||
this.closeAllOverlays();
|
||||
|
||||
const mainElement = this.querySelector('.app-main');
|
||||
@ -432,15 +483,29 @@ export class RBoxApp extends HTMLElement {
|
||||
editorView.setFile(file, this.currentView);
|
||||
|
||||
if (pushState) {
|
||||
window.history.pushState(
|
||||
{ view: 'code-editor', file: file },
|
||||
'',
|
||||
`#editor/${file.id}`
|
||||
);
|
||||
const currentState = window.history.state || {};
|
||||
const currentView = currentState.view || this.currentView;
|
||||
|
||||
if (currentView !== 'code-editor') {
|
||||
window.history.pushState(
|
||||
{ view: 'code-editor', file: file, previousView: currentView },
|
||||
'',
|
||||
`#editor/${file.id}`
|
||||
);
|
||||
logger.debug('Pushed code editor state', { previousView: currentView });
|
||||
} else {
|
||||
logger.debug('Already in code editor view, replacing state');
|
||||
window.history.replaceState(
|
||||
{ view: 'code-editor', file: file, previousView: currentView },
|
||||
'',
|
||||
`#editor/${file.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showFilePreview(file, pushState = true) {
|
||||
logger.debug('Showing file preview', { file: file.name, pushState });
|
||||
this.closeAllOverlays();
|
||||
|
||||
const mainElement = this.querySelector('.app-main');
|
||||
@ -449,15 +514,29 @@ export class RBoxApp extends HTMLElement {
|
||||
preview.show(file, false);
|
||||
|
||||
if (pushState) {
|
||||
window.history.pushState(
|
||||
{ view: 'file-preview', file: file },
|
||||
'',
|
||||
`#preview/${file.id}`
|
||||
);
|
||||
const currentState = window.history.state || {};
|
||||
const currentView = currentState.view || this.currentView;
|
||||
|
||||
if (currentView !== 'file-preview') {
|
||||
window.history.pushState(
|
||||
{ view: 'file-preview', file: file, previousView: currentView },
|
||||
'',
|
||||
`#preview/${file.id}`
|
||||
);
|
||||
logger.debug('Pushed file preview state', { previousView: currentView });
|
||||
} else {
|
||||
logger.debug('Already in file preview view, replacing state');
|
||||
window.history.replaceState(
|
||||
{ view: 'file-preview', file: file, previousView: currentView },
|
||||
'',
|
||||
`#preview/${file.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showUpload(folderId = null, pushState = true) {
|
||||
logger.debug('Showing upload view', { folderId, pushState });
|
||||
this.closeAllOverlays();
|
||||
|
||||
const mainElement = this.querySelector('.app-main');
|
||||
@ -466,11 +545,24 @@ export class RBoxApp extends HTMLElement {
|
||||
uploadView.setFolder(folderId);
|
||||
|
||||
if (pushState) {
|
||||
window.history.pushState(
|
||||
{ view: 'upload', folderId: folderId },
|
||||
'',
|
||||
'#upload'
|
||||
);
|
||||
const currentState = window.history.state || {};
|
||||
const currentView = currentState.view || this.currentView;
|
||||
|
||||
if (currentView !== 'upload') {
|
||||
window.history.pushState(
|
||||
{ view: 'upload', folderId: folderId, previousView: currentView },
|
||||
'',
|
||||
'#upload'
|
||||
);
|
||||
logger.debug('Pushed upload state', { previousView: currentView });
|
||||
} else {
|
||||
logger.debug('Already in upload view, replacing state');
|
||||
window.history.replaceState(
|
||||
{ view: 'upload', folderId: folderId, previousView: currentView },
|
||||
'',
|
||||
'#upload'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
static/js/fetch-interceptor.js
Normal file
47
static/js/fetch-interceptor.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
83
static/js/focus-manager.js
Normal file
83
static/js/focus-manager.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
61
static/js/form-validator.js
Normal file
61
static/js/form-validator.js
Normal 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
68
static/js/lazy-loader.js
Normal 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
99
static/js/logger.js
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,17 +1,61 @@
|
||||
import { RBoxApp } from './components/rbox-app.js';
|
||||
import { verifyStartup, showCompatibilityError } from './startup-check.js';
|
||||
|
||||
// Define the custom element
|
||||
customElements.define('rbox-app', RBoxApp);
|
||||
const startupResults = verifyStartup();
|
||||
|
||||
// Instantiate the main application class
|
||||
const app = new RBoxApp();
|
||||
if (startupResults.failed.length > 0) {
|
||||
console.error('Startup checks failed:', startupResults.failed);
|
||||
showCompatibilityError(startupResults);
|
||||
} else {
|
||||
console.log('All startup checks passed');
|
||||
|
||||
// Append the app to the body (if not already in index.html)
|
||||
// document.body.appendChild(app);
|
||||
import('./app.js').then(({ default: app }) => {
|
||||
return Promise.all([
|
||||
import('./components/error-boundary.js'),
|
||||
import('./components/rbox-app.js')
|
||||
]).then(([errorBoundary, rboxApp]) => {
|
||||
if (!app.isReady()) {
|
||||
console.error('CRITICAL: Application failed to initialize properly');
|
||||
document.body.innerHTML = `
|
||||
<div style="padding: 2rem; text-align: center; font-family: sans-serif;">
|
||||
<h1 style="color: #d32f2f;">Application Failed to Initialize</h1>
|
||||
<p>Please refresh the page. If the problem persists, check the console for errors.</p>
|
||||
<button onclick="location.reload()" style="padding: 0.75rem 1.5rem; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem;">
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Register service worker for PWA
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/static/service-worker.js');
|
||||
customElements.define('rbox-app', rboxApp.RBoxApp);
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
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
71
static/js/perf-monitor.js
Normal 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
125
static/js/router.js
Normal 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();
|
||||
}
|
||||
}
|
||||
89
static/js/startup-check.js
Normal file
89
static/js/startup-check.js
Normal 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
92
static/js/state.js
Normal 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
71
static/js/utils.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import asyncio
|
||||
from playwright.async_api import async_playwright
|
||||
from playwright.async_api import async_playwright, Page, expect
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
@ -35,4 +35,33 @@ async def page(context):
|
||||
|
||||
@pytest.fixture
|
||||
def base_url():
|
||||
return "http://localhost:8000"
|
||||
return "http://localhost:9004"
|
||||
|
||||
@pytest_asyncio.fixture(scope="function", autouse=True)
|
||||
async def login(page: Page, base_url):
|
||||
print(f"Navigating to base_url: {base_url}")
|
||||
await page.goto(f"{base_url}/")
|
||||
await page.screenshot(path="01_initial_page.png")
|
||||
|
||||
# If already logged in, log out first to ensure a clean state
|
||||
if await page.locator('a:has-text("Logout")').is_visible():
|
||||
await page.click('a:has-text("Logout")')
|
||||
await page.screenshot(path="02_after_logout.png")
|
||||
|
||||
# Now, proceed with login or registration
|
||||
login_form = page.locator('#login-form:visible')
|
||||
if await login_form.count() > 0:
|
||||
await login_form.locator('input[name="username"]').fill('billingtest')
|
||||
await login_form.locator('input[name="password"]').fill('password123')
|
||||
await page.screenshot(path="03_before_login_click.png")
|
||||
await expect(page.locator('h2:has-text("Files")')).to_be_visible(timeout=10000)
|
||||
else:
|
||||
# If no login form, try to register
|
||||
await page.click('text=Sign Up')
|
||||
register_form = page.locator('#register-form:visible')
|
||||
await register_form.locator('input[name="username"]').fill('billingtest')
|
||||
await register_form.locator('input[name="email"]').fill('billingtest@example.com')
|
||||
await register_form.locator('input[name="password"]').fill('password123')
|
||||
await page.screenshot(path="05_before_register_click.png")
|
||||
await register_form.locator('button[type="submit"]').click()
|
||||
await expect(page.locator('h1:has-text("My Files")')).to_be_visible(timeout=10000)
|
||||
Loading…
Reference in New Issue
Block a user